janko 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|