postgres_upsert 3.0.0 → 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,14 +1,17 @@
1
1
  require 'rubygems'
2
2
  require 'active_record'
3
- require 'postgres_upsert/active_record'
4
3
  require 'postgres_upsert/writer'
4
+ require 'postgres_upsert/table_writer'
5
+ require 'postgres_upsert/result'
5
6
  require 'rails'
6
7
 
7
- class PostgresCopy < Rails::Railtie
8
+ module PostgresUpsert
8
9
 
9
- initializer 'postgres_upsert' do
10
- ActiveSupport.on_load :active_record do
11
- require "postgres_upsert/active_record"
10
+ class << self
11
+ def write class_or_table, path_or_io, options = {}
12
+ writer = class_or_table.is_a?(String) ?
13
+ TableWriter : Writer
14
+ writer.new(class_or_table, path_or_io, options).write
12
15
  end
13
16
  end
14
17
  end
@@ -0,0 +1,11 @@
1
+ module PostgresUpsert
2
+ class Result
3
+ attr_reader :inserted, :updated
4
+
5
+ def initialize(insert_result, update_result)
6
+ @inserted = insert_result ? insert_result.cmd_tuples : 0
7
+ @updated = update_result ? update_result.cmd_tuples : 0
8
+ end
9
+ end
10
+ end
11
+
@@ -0,0 +1,46 @@
1
+ module PostgresUpsert
2
+ # alternate version of PostgresUpsert::Writer which does not rely on AR table information. We
3
+ # we can use this model to upsert data into views, or tables not associated to rails models
4
+ class TableWriter < Writer
5
+
6
+ def initialize(table_name, source, options = {})
7
+ @table_name = table_name
8
+ super(nil, source, options)
9
+ end
10
+
11
+ private
12
+
13
+ def primary_key
14
+ @primary_key ||= begin
15
+ query = <<-sql
16
+ SELECT
17
+ pg_attribute.attname,
18
+ format_type(pg_attribute.atttypid, pg_attribute.atttypmod)
19
+ FROM pg_index, pg_class, pg_attribute
20
+ WHERE
21
+ pg_class.oid = '#{@table_name}'::regclass AND
22
+ indrelid = pg_class.oid AND
23
+ pg_attribute.attrelid = pg_class.oid AND
24
+ pg_attribute.attnum = any(pg_index.indkey)
25
+ AND indisprimary
26
+ sql
27
+
28
+ pg_result = ActiveRecord::Base.connection.execute query
29
+ pg_result.each{ |row| return row['attname'] }
30
+ end
31
+ end
32
+
33
+ def column_names
34
+ @column_names ||= begin
35
+ query = "SELECT * FROM information_schema.columns WHERE TABLE_NAME = '#{@table_name}'"
36
+ pg_result = ActiveRecord::Base.connection.execute query
37
+ pg_result.map{ |row| row['column_name'] }
38
+ end
39
+ end
40
+
41
+ def quoted_table_name
42
+ @quoted_table_name ||= ActiveRecord::Base.connection.quote_table_name(@table_name)
43
+ end
44
+
45
+ end
46
+ end
@@ -1,12 +1,10 @@
1
1
  module PostgresUpsert
2
-
3
2
  class Writer
4
3
 
5
- def initialize(table_name, source, options = {})
6
- @table_name = table_name
4
+ def initialize(klass, source, options = {})
5
+ @klass = klass
7
6
  @options = options.reverse_merge({
8
7
  :delimiter => ",",
9
- :format => :csv,
10
8
  :header => true,
11
9
  :key_column => primary_key,
12
10
  :update_only => false})
@@ -20,16 +18,15 @@ module PostgresUpsert
20
18
  raise "Either the :columns option or :header => true are required"
21
19
  end
22
20
 
23
- csv_options = @options[:format] == :binary ? "BINARY" : "DELIMITER '#{@options[:delimiter]}' CSV"
21
+ csv_options = "DELIMITER '#{@options[:delimiter]}' CSV"
24
22
 
25
23
  copy_table = @temp_table_name
26
-
27
24
  columns_string = columns_string_for_copy
28
25
  create_temp_table
29
26
 
30
27
  ActiveRecord::Base.connection.raw_connection.copy_data %{COPY #{copy_table} #{columns_string} FROM STDIN #{csv_options}} do
31
28
 
32
- while line = read_input_line do
29
+ while line = @source.gets do
33
30
  next if line.strip.size == 0
34
31
  ActiveRecord::Base.connection.raw_connection.put_copy_data line
35
32
  end
@@ -37,46 +34,31 @@ module PostgresUpsert
37
34
 
38
35
  upsert_from_temp_table
39
36
  drop_temp_table
37
+ PostgresUpsert::Result.new(@insert_result, @update_result)
40
38
  end
41
39
 
42
40
  private
43
41
 
44
42
  def primary_key
45
- @primary_key ||= begin
46
- query = <<-sql
47
- SELECT
48
- pg_attribute.attname,
49
- format_type(pg_attribute.atttypid, pg_attribute.atttypmod)
50
- FROM pg_index, pg_class, pg_attribute
51
- WHERE
52
- pg_class.oid = '#{@table_name}'::regclass AND
53
- indrelid = pg_class.oid AND
54
- pg_attribute.attrelid = pg_class.oid AND
55
- pg_attribute.attnum = any(pg_index.indkey)
56
- AND indisprimary
57
- sql
58
-
59
- pg_result = ActiveRecord::Base.connection.execute query
60
- pg_result.each{ |row| return row['attname'] }
61
- end
43
+ @klass.primary_key
62
44
  end
63
45
 
64
46
  def column_names
65
- @column_names ||= begin
66
- query = "SELECT * FROM information_schema.columns WHERE TABLE_NAME = '#{@table_name}'"
67
- pg_result = ActiveRecord::Base.connection.execute query
68
- pg_result.map{ |row| row['column_name'] }
69
- end
47
+ @klass.column_names
48
+ end
49
+
50
+ def quoted_table_name
51
+ @klass.quoted_table_name
70
52
  end
71
53
 
72
54
  def get_columns
73
- columns_list = @options[:columns] || []
74
- if @options[:format] != :binary && @options[:header]
55
+ columns_list = @options[:columns] ? @options[:columns].map(&:to_s) : []
56
+ if @options[:header]
75
57
  #if header is present, we need to strip it from io, whether we use it for the columns list or not.
76
58
  line = @source.gets
77
- if columns_list.empty?
78
- columns_list = line.strip.split(@options[:delimiter])
79
- end
59
+ if columns_list.empty?
60
+ columns_list = line.strip.split(@options[:delimiter])
61
+ end
80
62
  end
81
63
  columns_list = columns_list.map{|c| @options[:map][c.to_s] } if @options[:map]
82
64
  return columns_list
@@ -120,33 +102,17 @@ module PostgresUpsert
120
102
  columns.size > 0 ? "\"#{columns.join('","')}\"" : ""
121
103
  end
122
104
 
123
- def quoted_table_name
124
- @quoted_table_name ||= ActiveRecord::Base.connection.quote_table_name(@table_name)
125
- end
126
-
127
105
  def generate_temp_table_name
128
106
  @temp_table_name = "#{@table_name}_temp_#{rand(1000)}"
129
107
  end
130
108
 
131
- def read_input_line
132
- if @options[:format] == :binary
133
- begin
134
- return @source.readpartial(10240)
135
- rescue EOFError
136
- end
137
- else
138
- line = @source.gets
139
- return line
140
- end
141
- end
142
-
143
109
  def upsert_from_temp_table
144
110
  update_from_temp_table
145
111
  insert_from_temp_table unless @options[:update_only]
146
112
  end
147
113
 
148
114
  def update_from_temp_table
149
- ActiveRecord::Base.connection.execute <<-SQL
115
+ @update_result = ActiveRecord::Base.connection.execute <<-SQL
150
116
  UPDATE #{quoted_table_name} AS d
151
117
  #{update_set_clause}
152
118
  FROM #{@temp_table_name} as t
@@ -166,7 +132,7 @@ module PostgresUpsert
166
132
  def insert_from_temp_table
167
133
  columns_string = columns_string_for_insert
168
134
  select_string = select_string_for_insert
169
- ActiveRecord::Base.connection.execute <<-SQL
135
+ @insert_result = ActiveRecord::Base.connection.execute <<-SQL
170
136
  INSERT INTO #{quoted_table_name} (#{columns_string})
171
137
  SELECT #{select_string}
172
138
  FROM #{@temp_table_name} as t
@@ -180,6 +146,7 @@ module PostgresUpsert
180
146
 
181
147
  def create_temp_table
182
148
  columns_string = select_string_for_create
149
+ verify_temp_has_key
183
150
  ActiveRecord::Base.connection.execute <<-SQL
184
151
  SET client_min_messages=WARNING;
185
152
  DROP TABLE IF EXISTS #{@temp_table_name};
@@ -189,12 +156,16 @@ module PostgresUpsert
189
156
  SQL
190
157
  end
191
158
 
159
+ def verify_temp_has_key
160
+ unless @columns_list.include?(@options[:key_column].to_s)
161
+ raise "Expected a unique column '#{@options[:key_column]}' but the source data does not include this column. Update the :columns list or explicitly set the :key_column option.}"
162
+ end
163
+ end
164
+
192
165
  def drop_temp_table
193
166
  ActiveRecord::Base.connection.execute <<-SQL
194
167
  DROP TABLE #{@temp_table_name}
195
168
  SQL
196
169
  end
197
170
  end
198
-
199
-
200
171
  end
@@ -5,7 +5,7 @@ $:.unshift lib unless $:.include?(lib)
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = "postgres_upsert"
8
- s.version = "3.0.0"
8
+ s.version = "4.0.0"
9
9
 
10
10
  s.platform = Gem::Platform::RUBY
11
11
  s.required_ruby_version = ">= 1.8.7"
@@ -26,8 +26,8 @@ Gem::Specification.new do |s|
26
26
  s.add_dependency "activerecord", '>= 3.0.0'
27
27
  s.add_dependency "rails", '>= 3.0.0'
28
28
  s.add_development_dependency "bundler"
29
- s.add_development_dependency "rdoc"
30
29
  s.add_development_dependency "pry-rails"
31
30
  s.add_development_dependency "rspec", "~> 2.12"
31
+ s.add_development_dependency "rspec-rails", "~> 2.0"
32
32
  end
33
33
 
@@ -18,14 +18,14 @@ describe "pg_upsert from file with CSV format" do
18
18
  end
19
19
 
20
20
  it "should import from file if path is passed without field_map" do
21
- TestModel.pg_upsert File.expand_path('spec/fixtures/comma_with_header.csv')
21
+ PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/comma_with_header.csv')
22
22
  expect(
23
23
  TestModel.first.attributes
24
24
  ).to include('data' => 'test data 1', 'created_at' => timestamp, 'updated_at' => timestamp)
25
25
  end
26
26
 
27
27
  it "correctly handles delimiters in content" do
28
- TestModel.pg_upsert File.expand_path('spec/fixtures/comma_with_header_and_comma_values.csv')
28
+ PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/comma_with_header_and_comma_values.csv')
29
29
  expect(
30
30
  TestModel.first.attributes
31
31
  ).to include('data' => 'test, the data 1', 'created_at' => timestamp, 'updated_at' => timestamp)
@@ -33,39 +33,33 @@ describe "pg_upsert from file with CSV format" do
33
33
 
34
34
  it "throws error if csv is malformed" do
35
35
  expect{
36
- TestModel.pg_upsert File.expand_path('spec/fixtures/comma_with_header_and_unquoted_comma.csv')
37
- }.to raise_error
38
- end
39
-
40
- it "throws error if the csv has mixed delimiters" do
41
- expect{
42
- TestModel.pg_upsert File.expand_path('spec/fixtures/tab_with_error.csv'), :delimiter => "\t"
36
+ PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/comma_with_header_and_unquoted_comma.csv')
43
37
  }.to raise_error
44
38
  end
45
39
 
46
40
  it "should import from IO without field_map" do
47
- TestModel.pg_upsert File.open(File.expand_path('spec/fixtures/comma_with_header.csv'), 'r')
41
+ PostgresUpsert.write TestModel, File.open(File.expand_path('spec/fixtures/comma_with_header.csv'), 'r')
48
42
  expect(
49
43
  TestModel.first.attributes
50
44
  ).to include('data' => 'test data 1', 'created_at' => timestamp, 'updated_at' => timestamp)
51
45
  end
52
46
 
53
47
  it "should import with custom delimiter from path" do
54
- TestModel.pg_upsert File.expand_path('spec/fixtures/semicolon_with_header.csv'), :delimiter => ';'
48
+ PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/semicolon_with_header.csv'), :delimiter => ';'
55
49
  expect(
56
50
  TestModel.first.attributes
57
51
  ).to include('data' => 'test data 1', 'created_at' => timestamp, 'updated_at' => timestamp)
58
52
  end
59
53
 
60
54
  it "should import with custom delimiter from IO" do
61
- TestModel.pg_upsert File.open(File.expand_path('spec/fixtures/semicolon_with_header.csv'), 'r'), :delimiter => ';'
55
+ PostgresUpsert.write TestModel, File.open(File.expand_path('spec/fixtures/semicolon_with_header.csv'), 'r'), :delimiter => ';'
62
56
  expect(
63
57
  TestModel.first.attributes
64
58
  ).to include('data' => 'test data 1', 'created_at' => timestamp, 'updated_at' => timestamp)
65
59
  end
66
60
 
67
61
  it "should not expect a header when :header is false" do
68
- TestModel.pg_upsert(File.open(File.expand_path('spec/fixtures/comma_without_header.csv'), 'r'), :header => false, :columns => [:id,:data])
62
+ PostgresUpsert.write(TestModel, File.open(File.expand_path('spec/fixtures/comma_without_header.csv'), 'r'), :header => false, :columns => [:id,:data])
69
63
 
70
64
  expect(
71
65
  TestModel.first.attributes
@@ -73,7 +67,7 @@ describe "pg_upsert from file with CSV format" do
73
67
  end
74
68
 
75
69
  it "should be able to map the header in the file to diferent column names" do
76
- TestModel.pg_upsert(File.open(File.expand_path('spec/fixtures/tab_with_different_header.csv'), 'r'), :delimiter => "\t", :map => {'cod' => 'id', 'info' => 'data'})
70
+ PostgresUpsert.write(TestModel, File.open(File.expand_path('spec/fixtures/tab_with_different_header.csv'), 'r'), :delimiter => "\t", :map => {'cod' => 'id', 'info' => 'data'})
77
71
 
78
72
  expect(
79
73
  TestModel.first.attributes
@@ -81,7 +75,7 @@ describe "pg_upsert from file with CSV format" do
81
75
  end
82
76
 
83
77
  it "should be able to map the header in the file to diferent column names with custom delimiter" do
84
- TestModel.pg_upsert(File.open(File.expand_path('spec/fixtures/semicolon_with_different_header.csv'), 'r'), :delimiter => ';', :map => {'cod' => 'id', 'info' => 'data'})
78
+ PostgresUpsert.write(TestModel, File.open(File.expand_path('spec/fixtures/semicolon_with_different_header.csv'), 'r'), :delimiter => ';', :map => {'cod' => 'id', 'info' => 'data'})
85
79
 
86
80
  expect(
87
81
  TestModel.first.attributes
@@ -89,7 +83,7 @@ describe "pg_upsert from file with CSV format" do
89
83
  end
90
84
 
91
85
  it "should ignore empty lines" do
92
- TestModel.pg_upsert(File.open(File.expand_path('spec/fixtures/tab_with_extra_line.csv'), 'r'), :delimiter => "\t")
86
+ PostgresUpsert.write(TestModel, File.open(File.expand_path('spec/fixtures/tab_with_extra_line.csv'), 'r'), :delimiter => "\t")
93
87
 
94
88
  expect(
95
89
  TestModel.first.attributes
@@ -97,7 +91,7 @@ describe "pg_upsert from file with CSV format" do
97
91
  end
98
92
 
99
93
  it "should not create timestamps when the model does not include them" do
100
- ReservedWordModel.pg_upsert File.expand_path('spec/fixtures/reserved_words.csv'), :delimiter => "\t"
94
+ PostgresUpsert.write ReservedWordModel, File.expand_path('spec/fixtures/reserved_words.csv'), :delimiter => "\t"
101
95
 
102
96
  expect(
103
97
  ReservedWordModel.first.attributes
@@ -113,12 +107,12 @@ describe "pg_upsert from file with CSV format" do
113
107
 
114
108
  it "should not violate primary key constraint" do
115
109
  expect{
116
- TestModel.pg_upsert File.expand_path('spec/fixtures/comma_with_header.csv')
110
+ PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/comma_with_header.csv')
117
111
  }.to_not raise_error
118
112
  end
119
113
 
120
114
  it "should upsert (update existing records and insert new records)" do
121
- TestModel.pg_upsert File.expand_path('spec/fixtures/tab_with_two_lines.csv'), :delimiter => "\t"
115
+ PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/tab_with_two_lines.csv'), :delimiter => "\t"
122
116
 
123
117
  expect(
124
118
  TestModel.find(1).attributes
@@ -128,21 +122,33 @@ describe "pg_upsert from file with CSV format" do
128
122
  ).to eq("id"=>2, "data"=>"test data 2", "created_at" => timestamp, "updated_at" => timestamp)
129
123
  end
130
124
 
125
+ it "should return updated and inserted results" do
126
+ result = PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/tab_with_two_lines.csv'), :delimiter => "\t"
127
+
128
+ expect(
129
+ result.updated
130
+ ).to eq(1)
131
+
132
+ expect(
133
+ result.inserted
134
+ ).to eq(1)
135
+ end
136
+
131
137
  it "should require columns option if no header" do
132
138
  expect{
133
- TestModel.pg_upsert File.expand_path('spec/fixtures/2_col_binary_data.dat'), :format => :binary
139
+ PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/comma_without_header.csv'), :header => false
134
140
  }.to raise_error("Either the :columns option or :header => true are required")
135
141
  end
136
142
 
137
143
  it "should clean up the temp table after completion" do
138
- TestModel.pg_upsert File.expand_path('spec/fixtures/tab_with_two_lines.csv'), :delimiter => "\t"
144
+ PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/tab_with_two_lines.csv'), :delimiter => "\t"
139
145
 
140
146
  ActiveRecord::Base.connection.tables.should_not include("test_models_temp")
141
147
  end
142
148
 
143
149
  it "should gracefully drop the temp table if it already exists" do
144
150
  ActiveRecord::Base.connection.execute "CREATE TEMP TABLE test_models_temp (LIKE test_models);"
145
- TestModel.pg_upsert File.expand_path('spec/fixtures/tab_with_two_lines.csv'), :delimiter => "\t"
151
+ PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/tab_with_two_lines.csv'), :delimiter => "\t"
146
152
 
147
153
  expect(
148
154
  TestModel.find(1).attributes
@@ -154,7 +160,7 @@ describe "pg_upsert from file with CSV format" do
154
160
 
155
161
  it "should be able to copy using custom set of columns" do
156
162
  ThreeColumn.create(id: 1, data: "old stuff", extra: "neva change!", created_at: original_created_at)
157
- ThreeColumn.pg_upsert(File.open(File.expand_path('spec/fixtures/tab_only_data.csv'), 'r'), :delimiter => "\t", :columns => ["id", "data"])
163
+ PostgresUpsert.write(ThreeColumn, File.open(File.expand_path('spec/fixtures/tab_only_data.csv'), 'r'), :delimiter => "\t", :columns => ["id", "data"])
158
164
 
159
165
  expect(
160
166
  ThreeColumn.first.attributes
@@ -167,8 +173,7 @@ describe "pg_upsert from file with CSV format" do
167
173
  three_col = ThreeColumn.create(id: 1, data: "old stuff", extra: "neva change!")
168
174
  file = File.open(File.expand_path('spec/fixtures/no_id.csv'), 'r')
169
175
 
170
-
171
- ThreeColumn.pg_upsert(file, :key_column => "data")
176
+ PostgresUpsert.write(ThreeColumn, file, :key_column => "data")
172
177
  expect(
173
178
  three_col.reload.extra
174
179
  ).to eq("ABC: Always Be Changing.")
@@ -177,11 +182,29 @@ describe "pg_upsert from file with CSV format" do
177
182
  it 'inserts records if the passed match column doesnt exist' do
178
183
  file = File.open(File.expand_path('spec/fixtures/no_id.csv'), 'r')
179
184
 
180
- ThreeColumn.pg_upsert(file, :key_column => "data")
185
+ PostgresUpsert.write(ThreeColumn, file, :key_column => "data")
181
186
  expect(
182
187
  ThreeColumn.last.attributes
183
- ).to include("id" => 1, "data" => "old stuff", "extra" => "ABC: Always Be Changing.")
188
+ ).to include("data" => "old stuff", "extra" => "ABC: Always Be Changing.")
189
+ end
190
+
191
+ it 'allows key column to be a string or symbol' do
192
+ file = File.open(File.expand_path('spec/fixtures/no_id.csv'), 'r')
193
+
194
+ PostgresUpsert.write(ThreeColumn, file, :header => true, :key_column => :data)
195
+ expect(
196
+ ThreeColumn.last.attributes
197
+ ).to include("data" => "old stuff", "extra" => "ABC: Always Be Changing.")
198
+ end
199
+
200
+ it 'raises an error if the expected key column is not in data' do
201
+ file = File.open(File.expand_path('spec/fixtures/no_id.csv'), 'r')
202
+
203
+ expect{
204
+ PostgresUpsert.write(ThreeColumn, file, :header => true)
205
+ }.to raise_error (/Expected a unique column 'id'/)
184
206
  end
207
+
185
208
  end
186
209
 
187
210
  context 'update only' do
@@ -189,9 +212,9 @@ describe "pg_upsert from file with CSV format" do
189
212
  before(:each) do
190
213
  TestModel.create(id: 1, data: "From the before time, in the long long ago", :created_at => original_created_at)
191
214
  end
192
- it 'will only update and not insert if insert_only flag is passed.' do
193
- TestModel.pg_upsert File.expand_path('spec/fixtures/tab_with_two_lines.csv'), :delimiter => "\t", :update_only => true
194
215
 
216
+ it 'will only update and not insert if insert_only flag is passed.' do
217
+ PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/tab_with_two_lines.csv'), :delimiter => "\t", :update_only => true
195
218
  expect(
196
219
  TestModel.find(1).attributes
197
220
  ).to eq("id"=>1, "data"=>"test data 1", "created_at" => original_created_at , "updated_at" => timestamp)
@@ -201,6 +224,41 @@ describe "pg_upsert from file with CSV format" do
201
224
 
202
225
  end
203
226
 
227
+ it 'will return the number of updated rows' do
228
+ a = PostgresUpsert.write TestModel, File.expand_path('spec/fixtures/tab_with_two_lines.csv'), :delimiter => "\t", :update_only => true
229
+ expect(
230
+ a.updated
231
+ ).to eq(1)
232
+
233
+ expect(
234
+ a.inserted
235
+ ).to eq(0)
236
+ end
237
+
238
+ end
239
+
240
+ context 'using table_name' do
241
+ it "should import from file if path is passed without field_map" do
242
+ PostgresUpsert.write TestModel.table_name, File.expand_path('spec/fixtures/comma_with_header.csv')
243
+ expect(
244
+ TestModel.first.attributes
245
+ ).to include('data' => 'test data 1', 'created_at' => timestamp, 'updated_at' => timestamp)
246
+ end
247
+
248
+ it "should still report results" do
249
+ TestModel.create(data: "test data 1")
250
+ result = result = PostgresUpsert.write TestModel.table_name, File.expand_path('spec/fixtures/tab_with_two_lines.csv'), :delimiter => "\t"
251
+
252
+ expect(
253
+ result.updated
254
+ ).to eq(1)
255
+
256
+ expect(
257
+ result.inserted
258
+ ).to eq(1)
259
+ end
260
+
261
+
204
262
  end
205
263
  end
206
264