janko 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/Gemfile +2 -0
- data/Guardfile +7 -0
- data/LICENSE.txt +202 -0
- data/README.md +186 -0
- data/Rakefile +14 -0
- data/assets/insert-performance-graph.png +0 -0
- data/assets/merge-performance-graph.png +0 -0
- data/config/environment.rb +7 -0
- data/janko.gemspec +32 -0
- data/lib/janko.rb +5 -0
- data/lib/janko/bulk_merge.rb +57 -0
- data/lib/janko/column_list.rb +193 -0
- data/lib/janko/connection.rb +107 -0
- data/lib/janko/constants.rb +13 -0
- data/lib/janko/copy_importer.rb +36 -0
- data/lib/janko/flag.rb +26 -0
- data/lib/janko/import.rb +80 -0
- data/lib/janko/insert_importer.rb +32 -0
- data/lib/janko/merge.rb +167 -0
- data/lib/janko/merge_result.rb +37 -0
- data/lib/janko/single_merge.rb +33 -0
- data/lib/janko/tagged_column.rb +133 -0
- data/lib/janko/upsert.rb +193 -0
- data/lib/janko/version.rb +3 -0
- data/spec/column_list_spec.rb +164 -0
- data/spec/connection_spec.rb +27 -0
- data/spec/flag_spec.rb +16 -0
- data/spec/import_spec.rb +112 -0
- data/spec/merge_spec.rb +400 -0
- data/spec/spec_helper.rb +32 -0
- data/spec/tagged_column_spec.rb +56 -0
- metadata +261 -0
data/lib/janko/upsert.rb
ADDED
@@ -0,0 +1,193 @@
|
|
1
|
+
require "securerandom"
|
2
|
+
require "agrippa/mutable"
|
3
|
+
|
4
|
+
module Janko
|
5
|
+
# http://dba.stackexchange.com/questions/13468/most-idiomatic-way-to-implement-upsert-in-postgresql-nowadays
|
6
|
+
# http://stackoverflow.com/questions/1109061/insert-on-duplicate-update-in-postgresql/8702291#8702291
|
7
|
+
# http://stackoverflow.com/questions/17575489/postgresql-cte-upsert-returning-modified-rows
|
8
|
+
class Upsert
|
9
|
+
include Agrippa::Mutable
|
10
|
+
|
11
|
+
state_reader :connection, :table, :columns, :collector, :returning
|
12
|
+
|
13
|
+
def default_state
|
14
|
+
{ collector: MergeResult.new }
|
15
|
+
end
|
16
|
+
|
17
|
+
def result
|
18
|
+
collector
|
19
|
+
end
|
20
|
+
|
21
|
+
def prepare
|
22
|
+
return(self) if prepared?
|
23
|
+
@prepared = "upsert_#{SecureRandom.hex(8)}"
|
24
|
+
connection.prepare(@prepared, query)
|
25
|
+
self
|
26
|
+
end
|
27
|
+
|
28
|
+
def push(*values)
|
29
|
+
raise(RuntimeError, "Can't #push when reading from a table.") \
|
30
|
+
if read_from_table?
|
31
|
+
collect_result(exec_query(columns.pack(*values)))
|
32
|
+
self
|
33
|
+
end
|
34
|
+
|
35
|
+
def process
|
36
|
+
raise(RuntimeError, "Can't #process without from_table") \
|
37
|
+
unless read_from_table?
|
38
|
+
result.clear
|
39
|
+
collect_result(exec_query)
|
40
|
+
self
|
41
|
+
end
|
42
|
+
|
43
|
+
def cleanup
|
44
|
+
return unless prepared?
|
45
|
+
connection.exec("DEALLOCATE \"#{@prepared}\"")
|
46
|
+
@prepared = nil
|
47
|
+
self
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def result_type_column
|
53
|
+
"__type"
|
54
|
+
end
|
55
|
+
|
56
|
+
def collect_result(tuples)
|
57
|
+
return(self) if (tuples.count == 0)
|
58
|
+
tuples.each { |t| result.push(t.delete(result_type_column), t) }
|
59
|
+
end
|
60
|
+
|
61
|
+
def read_from_table?
|
62
|
+
not @from_table.nil?
|
63
|
+
end
|
64
|
+
|
65
|
+
def prepared?
|
66
|
+
not @prepared.nil?
|
67
|
+
end
|
68
|
+
|
69
|
+
def exec_query(*binds)
|
70
|
+
return(connection.exec(query, *binds)) unless prepared?
|
71
|
+
connection.exec_prepared(@prepared, *binds)
|
72
|
+
end
|
73
|
+
|
74
|
+
def key_columns
|
75
|
+
@key_columns ||= columns.tagged(:key)
|
76
|
+
end
|
77
|
+
|
78
|
+
def update_columns
|
79
|
+
@update_columns ||= columns.tagged(:update).not_tagged(:key)
|
80
|
+
end
|
81
|
+
|
82
|
+
def insert_columns
|
83
|
+
@insert_columns ||= columns.tagged(:insert)
|
84
|
+
end
|
85
|
+
|
86
|
+
def select_columns
|
87
|
+
@select_columns ||= columns.tagged(:select)
|
88
|
+
end
|
89
|
+
|
90
|
+
def insert_columns_with_defaults
|
91
|
+
insert_columns.to_list_with_defaults
|
92
|
+
end
|
93
|
+
|
94
|
+
def columns_to_binds_with_types
|
95
|
+
columns.to_typecast_binds
|
96
|
+
end
|
97
|
+
|
98
|
+
def update_clause
|
99
|
+
update_columns.to_setters("upsert_updates", source_table)
|
100
|
+
end
|
101
|
+
|
102
|
+
def update_key_clause
|
103
|
+
key_columns.to_conditions("upsert_updates", source_table)
|
104
|
+
end
|
105
|
+
|
106
|
+
def insert_key_clause
|
107
|
+
key_columns.to_conditions("upsert", source_table)
|
108
|
+
end
|
109
|
+
|
110
|
+
def query
|
111
|
+
return(query_returning_nothing) if select_columns.empty?
|
112
|
+
return(query_returning_all) if (returning == "all")
|
113
|
+
return(query_returning_inserts) if (returning == "inserted")
|
114
|
+
return(query_returning_updates) if (returning == "updated")
|
115
|
+
query_returning_nothing
|
116
|
+
end
|
117
|
+
|
118
|
+
def source_table
|
119
|
+
@from_table || "upsert_data"
|
120
|
+
end
|
121
|
+
|
122
|
+
def source_fragment
|
123
|
+
return if read_from_table?
|
124
|
+
return(<<-END)
|
125
|
+
#{source_table} (#{columns.to_list}) AS (VALUES
|
126
|
+
(#{columns_to_binds_with_types})),
|
127
|
+
END
|
128
|
+
end
|
129
|
+
|
130
|
+
def upsert_fragment
|
131
|
+
return(<<-END)
|
132
|
+
UPDATE #{table} upsert_updates
|
133
|
+
SET #{update_clause} FROM #{source_table}
|
134
|
+
WHERE (#{update_key_clause})
|
135
|
+
RETURNING upsert_updates.*
|
136
|
+
END
|
137
|
+
end
|
138
|
+
|
139
|
+
def insert_fragment
|
140
|
+
return(<<-END)
|
141
|
+
INSERT INTO #{table} (#{insert_columns.to_list})
|
142
|
+
SELECT #{insert_columns_with_defaults} FROM #{source_table}
|
143
|
+
WHERE NOT EXISTS (SELECT 1 FROM upsert
|
144
|
+
WHERE #{insert_key_clause})
|
145
|
+
END
|
146
|
+
end
|
147
|
+
|
148
|
+
def query_returning_nothing
|
149
|
+
return(<<-END)
|
150
|
+
WITH #{source_fragment} upsert AS (#{upsert_fragment})
|
151
|
+
#{insert_fragment}
|
152
|
+
END
|
153
|
+
end
|
154
|
+
|
155
|
+
def query_with_returnable_tuples
|
156
|
+
return(<<-END)
|
157
|
+
WITH #{source_fragment} upsert AS (#{upsert_fragment}),
|
158
|
+
inserted AS (#{insert_fragment} RETURNING #{table}.*)
|
159
|
+
END
|
160
|
+
end
|
161
|
+
|
162
|
+
def query_returning_inserts
|
163
|
+
return(<<-END)
|
164
|
+
#{query_with_returnable_tuples}
|
165
|
+
SELECT 'inserted'
|
166
|
+
AS #{result_type_column}, #{select_columns.to_list}
|
167
|
+
FROM inserted
|
168
|
+
END
|
169
|
+
end
|
170
|
+
|
171
|
+
def query_returning_updates
|
172
|
+
return(<<-END)
|
173
|
+
#{query_with_returnable_tuples}
|
174
|
+
SELECT 'updated'
|
175
|
+
AS #{result_type_column}, #{select_columns.to_list}
|
176
|
+
FROM upsert
|
177
|
+
END
|
178
|
+
end
|
179
|
+
|
180
|
+
def query_returning_all
|
181
|
+
return(<<-END)
|
182
|
+
#{query_with_returnable_tuples}
|
183
|
+
SELECT 'inserted'
|
184
|
+
AS #{result_type_column}, #{select_columns.to_list}
|
185
|
+
FROM inserted
|
186
|
+
UNION ALL
|
187
|
+
SELECT 'updated'
|
188
|
+
AS #{result_type_column}, #{select_columns.to_list}
|
189
|
+
FROM upsert
|
190
|
+
END
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
@@ -0,0 +1,164 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require "janko/column_list"
|
3
|
+
|
4
|
+
RSpec.describe Janko::ColumnList do
|
5
|
+
let(:subject) { Janko::ColumnList.new.builder }
|
6
|
+
|
7
|
+
it "#empty?" do
|
8
|
+
expect(subject.empty?).to eq(true)
|
9
|
+
expect(subject.add(:a).empty?).to eq(false)
|
10
|
+
end
|
11
|
+
|
12
|
+
it "#columns" do
|
13
|
+
expect(subject.add(:a, "b", :c).columns).to eq(%w(a b c))
|
14
|
+
end
|
15
|
+
|
16
|
+
describe "#tagged" do
|
17
|
+
it "filters" do
|
18
|
+
subject.tag(:red, :a).tag(:blue, :b)
|
19
|
+
expect(subject.tagged(:red).columns).to eq(%w(a))
|
20
|
+
end
|
21
|
+
|
22
|
+
it "symbols or strings" do
|
23
|
+
subject.tag(:red, :a)
|
24
|
+
expect(subject.tagged("red").columns).to eq(%w(a))
|
25
|
+
subject.tag("red", :b)
|
26
|
+
expect(subject.tagged("red").columns).to eq(%w(b))
|
27
|
+
end
|
28
|
+
|
29
|
+
it "preserves column order" do
|
30
|
+
subject.add(:a, :b, :c)
|
31
|
+
subject.tag(:red, :c, :b)
|
32
|
+
expect(subject.tagged(:red).columns).to eq(%w(b c))
|
33
|
+
end
|
34
|
+
|
35
|
+
it "all tagged columns" do
|
36
|
+
subject.add(:a, :b, :c)
|
37
|
+
subject.tag(:red, :a).tag(:green, :c)
|
38
|
+
expect(subject.tagged.columns).to eq(%w(a c))
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
describe "#pack" do
|
43
|
+
it "maintains order" do
|
44
|
+
subject.add(:a, :b, :c)
|
45
|
+
expect(subject.pack(b: "2", a: "1", c: "3")).to eq(%w(1 2 3))
|
46
|
+
end
|
47
|
+
|
48
|
+
it "null-fills fewer columns" do
|
49
|
+
subject.add(:a, :b, :c)
|
50
|
+
expect(subject.pack(a: 1, c: 3)).to eq([1, nil, 3])
|
51
|
+
end
|
52
|
+
|
53
|
+
it "unknown columns" do
|
54
|
+
subject.add(:a, :b)
|
55
|
+
expect(lambda { subject.pack(b: 2, a: 1, c: 3) }).to \
|
56
|
+
raise_error(ArgumentError)
|
57
|
+
end
|
58
|
+
|
59
|
+
it "indifferent access" do
|
60
|
+
subject.add(:a, "b")
|
61
|
+
expect(subject.pack("a" => "1", b: "2")).to eq(%w(1 2))
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
describe "#add" do
|
66
|
+
it "multiple times" do
|
67
|
+
subject.add(:a).add(:b, :c)
|
68
|
+
expect(subject.pack(b: "2", a: "1", c: "3")).to eq(%w(1 2 3))
|
69
|
+
end
|
70
|
+
|
71
|
+
it "duplicate columns" do
|
72
|
+
subject.add(:a).add(:a, :b, :c)
|
73
|
+
expect(subject.pack(b: "2", a: "1", c: "3")).to eq(%w(1 2 3))
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
describe "#alter" do
|
78
|
+
before(:each) { subject.add(:a) }
|
79
|
+
|
80
|
+
it "modifies a column" do
|
81
|
+
subject.alter(:a) { |f| f.tag(:altered) }
|
82
|
+
expect(subject.tagged(:altered).columns).to eq(%w(a))
|
83
|
+
end
|
84
|
+
|
85
|
+
it "modifies multiple columns" do
|
86
|
+
subject.add(:b)
|
87
|
+
subject.alter(:a, :b) { |f| f.tag(:altered) }
|
88
|
+
expect(subject.tagged(:altered).columns).to eq(%w(a b))
|
89
|
+
end
|
90
|
+
|
91
|
+
it "preserves name and parent" do
|
92
|
+
parent = double
|
93
|
+
connection = double
|
94
|
+
expect(parent).to receive(:connection).and_return(connection)
|
95
|
+
subject.set(parent: parent)
|
96
|
+
subject.alter(:a) { |f| Janko::TaggedColumn.new }
|
97
|
+
expect(subject.columns).to eq(%w(a))
|
98
|
+
expect(subject).to be_all { |_, c| c.connection == connection }
|
99
|
+
end
|
100
|
+
|
101
|
+
it "only works on existing columns" do
|
102
|
+
expect(lambda { subject.alter(:b) { |f| f.tag(:b) } }) \
|
103
|
+
.to raise_error(RuntimeError)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
it "Janko::ALL" do
|
108
|
+
parent = double
|
109
|
+
connection = double
|
110
|
+
expect(parent).to receive(:table)
|
111
|
+
expect(parent).to receive(:connection).and_return(connection)
|
112
|
+
expect(connection).to receive(:column_list) \
|
113
|
+
.and_return(%w(id a b c))
|
114
|
+
subject.set(parent: parent)
|
115
|
+
subject.add(Janko::ALL)
|
116
|
+
expect(subject.columns).to eq(%w(id a b c))
|
117
|
+
end
|
118
|
+
|
119
|
+
it "Janko::DEFAULT" do
|
120
|
+
parent = double
|
121
|
+
connection = double
|
122
|
+
expect(parent).to receive(:table)
|
123
|
+
expect(parent).to receive(:connection).and_return(connection)
|
124
|
+
expect(connection).to receive(:column_list) \
|
125
|
+
.and_return(%w(id a b c))
|
126
|
+
subject.set(parent: parent)
|
127
|
+
subject.add(Janko::DEFAULT)
|
128
|
+
expect(subject.columns).to eq(%w(a b c))
|
129
|
+
end
|
130
|
+
|
131
|
+
it "all columns except" do
|
132
|
+
parent = double
|
133
|
+
connection = double
|
134
|
+
expect(parent).to receive(:table)
|
135
|
+
expect(parent).to receive(:connection).and_return(connection)
|
136
|
+
expect(connection).to receive(:column_list) \
|
137
|
+
.and_return(%w(id a b c))
|
138
|
+
subject.set(parent: parent)
|
139
|
+
subject.add(except: "b")
|
140
|
+
expect(subject.columns).to eq(%w(id a c))
|
141
|
+
end
|
142
|
+
|
143
|
+
it "#remove" do
|
144
|
+
subject.add(:a, :b, :c).remove("b")
|
145
|
+
expect(subject.columns).to eq(%w(a c))
|
146
|
+
end
|
147
|
+
|
148
|
+
it "#to_list" do
|
149
|
+
subject.add(:a, :b, :c)
|
150
|
+
expect(subject.to_list).to eq("\"a\",\"b\",\"c\"")
|
151
|
+
end
|
152
|
+
|
153
|
+
it "#to_binds" do
|
154
|
+
subject.add(:a, :b, :c)
|
155
|
+
expect(subject.to_binds).to eq("$1,$2,$3")
|
156
|
+
end
|
157
|
+
|
158
|
+
it "#inspect includes children" do
|
159
|
+
subject.add(:foo, :bar).tag("blue", :bar)
|
160
|
+
expect(subject.inspect).to match(/foo/)
|
161
|
+
expect(subject.inspect).to match(/bar/)
|
162
|
+
expect(subject.inspect).to match(/blue/)
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require "janko/connection"
|
3
|
+
|
4
|
+
RSpec.describe Janko::Connection do
|
5
|
+
describe ".build" do
|
6
|
+
it "PG::Connection" do
|
7
|
+
backend = ActiveRecord::Base.connection.raw_connection
|
8
|
+
connection = Janko::Connection.build(backend)
|
9
|
+
expect(connection.backend).to be(backend)
|
10
|
+
end
|
11
|
+
|
12
|
+
it "ActiveRecord::Base.connection" do
|
13
|
+
backend = ActiveRecord::Base.connection
|
14
|
+
connection = Janko::Connection.build(backend)
|
15
|
+
raw_connection = backend.raw_connection
|
16
|
+
expect(connection.backend).to be(raw_connection)
|
17
|
+
end
|
18
|
+
|
19
|
+
it "ActiveRecord::Base" do
|
20
|
+
backend = ActiveRecord::Base
|
21
|
+
connection = Janko::Connection.build(backend)
|
22
|
+
raw_connection = backend.connection.raw_connection
|
23
|
+
expect(connection.backend).to be(raw_connection)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
data/spec/flag_spec.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require "janko/flag"
|
3
|
+
|
4
|
+
RSpec.describe Janko::Flag do
|
5
|
+
def flag(value)
|
6
|
+
Janko::Flag.new(value)
|
7
|
+
end
|
8
|
+
|
9
|
+
it { expect(flag(2 ** 0)).to_not eq(2 ** 0) }
|
10
|
+
|
11
|
+
it { expect(flag(2 ** 0)).to eq(flag(2 ** 0)) }
|
12
|
+
|
13
|
+
it { expect(flag(2 ** 0)).to eq(flag(2 ** 0) | flag(2 ** 1)) }
|
14
|
+
|
15
|
+
it { expect(flag(2 ** 0)).to_not eq(flag(2 ** 2) | flag(2 ** 1)) }
|
16
|
+
end
|
data/spec/import_spec.rb
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
require "janko/import"
|
3
|
+
require "pg"
|
4
|
+
|
5
|
+
RSpec.shared_examples_for "an importer" do
|
6
|
+
describe "insert" do
|
7
|
+
let(:subject) { importer.connect(connection) }
|
8
|
+
|
9
|
+
around :each do |example|
|
10
|
+
begin
|
11
|
+
subject.table("import_test").columns(:id, :value)
|
12
|
+
connection.exec(<<-END)
|
13
|
+
BEGIN; CREATE TEMP TABLE import_test(
|
14
|
+
id integer not null, value text)
|
15
|
+
WITHOUT OIDS ON COMMIT DROP;
|
16
|
+
CREATE UNIQUE INDEX index_import_test_id
|
17
|
+
ON import_test(id);
|
18
|
+
END
|
19
|
+
example.run
|
20
|
+
rescue
|
21
|
+
raise
|
22
|
+
ensure
|
23
|
+
connection.exec("ROLLBACK")
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def result
|
28
|
+
output = connection.exec(<<-END)
|
29
|
+
SELECT id, value FROM import_test ORDER BY id asc
|
30
|
+
END
|
31
|
+
output.values.map { |v| v.join(",") }.join(";")
|
32
|
+
end
|
33
|
+
|
34
|
+
it "single row" do
|
35
|
+
subject.start.push(id: 1, value: "fish").stop
|
36
|
+
expect(result).to eq("1,fish")
|
37
|
+
end
|
38
|
+
|
39
|
+
it "multiple rows" do
|
40
|
+
subject.start
|
41
|
+
subject.push(id: 1, value: "fish")
|
42
|
+
subject.push(id: 2, value: "fish")
|
43
|
+
subject.stop
|
44
|
+
expect(result).to eq("1,fish;2,fish")
|
45
|
+
end
|
46
|
+
|
47
|
+
it "nullable column" do
|
48
|
+
subject.start.push(id: "1").stop
|
49
|
+
expect(result).to eq("1,")
|
50
|
+
end
|
51
|
+
|
52
|
+
it "violates not-null constraint" do
|
53
|
+
subject.start
|
54
|
+
row = { value: "fish" }
|
55
|
+
expect(lambda { subject.push(row).stop })
|
56
|
+
.to raise_error(PG::NotNullViolation)
|
57
|
+
connection.exec("ROLLBACK")
|
58
|
+
end
|
59
|
+
|
60
|
+
it "integer overflow" do
|
61
|
+
subject.start
|
62
|
+
row = { id: 2**32, value: "fish" }
|
63
|
+
expect(lambda { subject.push(row).stop })
|
64
|
+
.to raise_error(PG::NumericValueOutOfRange)
|
65
|
+
connection.exec("ROLLBACK")
|
66
|
+
end
|
67
|
+
|
68
|
+
it "violate uniqueness constraint" do
|
69
|
+
subject.start
|
70
|
+
row = { id: 1, value: "fish" }
|
71
|
+
subject.push(row)
|
72
|
+
expect(lambda { subject.push(row).stop })
|
73
|
+
.to raise_error(PG::UniqueViolation)
|
74
|
+
connection.exec("ROLLBACK")
|
75
|
+
end
|
76
|
+
|
77
|
+
it "violate uniqueness constraint (async errors)" do
|
78
|
+
subject.start
|
79
|
+
a = { id: 1, value: "fish" }
|
80
|
+
b = { id: 2, value: "fish" }
|
81
|
+
c = { id: 3, value: "fish" }
|
82
|
+
subject.push(b)
|
83
|
+
copy = lambda { subject.push(a).push(b).push(c).stop }
|
84
|
+
expect(copy).to raise_error(PG::UniqueViolation)
|
85
|
+
connection.exec("ROLLBACK")
|
86
|
+
end
|
87
|
+
|
88
|
+
it "too many columns" do
|
89
|
+
subject.start
|
90
|
+
row = [ 1, "fish", "woah" ]
|
91
|
+
expect(lambda { subject.push(*row).stop })
|
92
|
+
.to raise_error(ArgumentError)
|
93
|
+
connection.exec("ROLLBACK")
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
RSpec.describe Janko::Import do
|
99
|
+
let(:connection) { ActiveRecord::Base.connection.raw_connection }
|
100
|
+
|
101
|
+
describe "#strategy insert" do
|
102
|
+
let(:importer) { Janko::Import.new.use(Janko::InsertImporter) }
|
103
|
+
|
104
|
+
it_behaves_like "an importer"
|
105
|
+
end
|
106
|
+
|
107
|
+
describe "#strategy copy" do
|
108
|
+
let(:importer) { Janko::Import.new.use(Janko::CopyImporter) }
|
109
|
+
|
110
|
+
it_behaves_like "an importer"
|
111
|
+
end
|
112
|
+
end
|