em-pg-client-helper 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -47,6 +47,106 @@ module PG::EM::Client::Helper
47
47
  db.exec_defer(*insert_sql(tbl, params))
48
48
  end
49
49
 
50
+ # @macro upsert_params
51
+ #
52
+ # @param tbl [#to_s] The name of the table on which to operate.
53
+ #
54
+ # @param key [#to_s, Array<#to_s>] A field (or list of fields) which
55
+ # are the set of values that uniquely identify the record to be
56
+ # updated, if it exists. You only need to specify the field names
57
+ # here, as the values which will be used in the query will be taken
58
+ # from the `data`.
59
+ #
60
+ # @param data [Hash<#to_s, Object>] The fields and values to insert into
61
+ # the database, or to set in the existing record.
62
+ #
63
+ # @raise [ArgumentError] if a field is specified in `key` but which
64
+ # does not exist in `data`.
65
+ #
66
+ #
67
+ # An "upsert" is a kind of crazy hybrid "update if the record exists,
68
+ # insert it if it doesn't" query. It isn't part of the SQL standard,
69
+ # but it is such a common idiom that we're keen to support it.
70
+ #
71
+ # The trick is that it's actually two queries in one. We try to do an
72
+ # `UPDATE` first, and if that doesn't actually update anything, then we
73
+ # try an `INSERT`. Since it is two separate queries, though, there is still
74
+ # a small chance that the query will fail with a `PG::UniqueViolation`, so
75
+ # your code must handle that.
76
+ #
77
+ # @!macro upsert_params
78
+ #
79
+ # @return [Array<String, Array<Object>>] A two-element array, the first
80
+ # of which is a string containing the literal SQL to be executed, while
81
+ # the second element is an array containing the values, in order
82
+ # corresponding to the placeholders in the SQL.
83
+ #
84
+ def upsert_sql(tbl, key, data)
85
+ tbl = quote_identifier(tbl)
86
+ insert_keys = data.keys.map { |k| quote_identifier(k.to_s) }
87
+ unique_keys = (key.is_a?(Array) ? key : [key])
88
+ unique_keys.map! { |k| quote_identifier(k.to_s) }
89
+ update_keys = insert_keys - unique_keys
90
+
91
+ unless (bad_keys = unique_keys - insert_keys).empty?
92
+ raise ArgumentError,
93
+ "These field(s) were mentioned in the key list, but were not in the data set: #{bad_keys.inspect}"
94
+ end
95
+
96
+ values = data.values
97
+ # field-to-placeholder mapping
98
+ i = 0
99
+ fp_map = Hash[insert_keys.map { |k| i += 1; [k, "$#{i}"] }]
100
+
101
+ update_values = update_keys.map { |k| "#{k}=#{fp_map[k]}" }.join(',')
102
+ select_values = unique_keys.map { |k| "#{k}=#{fp_map[k]}" }.join(' AND ')
103
+ update_query = "UPDATE #{tbl} SET #{update_values} WHERE #{select_values} RETURNING *"
104
+
105
+ insert_query = "INSERT INTO #{tbl} (#{fp_map.keys.join(',')}) SELECT #{fp_map.values.join(',')}"
106
+
107
+ ["WITH upsert AS (#{update_query}) #{insert_query} WHERE NOT EXISTS (SELECT * FROM upsert)",
108
+ data.values
109
+ ]
110
+ end
111
+
112
+ # Run an upsert query.
113
+ #
114
+ # @see #upsert_sql
115
+ #
116
+ # Apply an upsert (update-or-insert) against a given database connection or
117
+ # connection pool, handling the (rarely needed) unique violation that can
118
+ # result.
119
+ #
120
+ # @param db [PG::EM::Client, PG::EM::ConnectionPool] the connection
121
+ # against which all database operations will be run.
122
+ #
123
+ # @!macro upsert_params
124
+ #
125
+ # @return [EM::Deferrable] the deferrable in which the query is being
126
+ # called; this means you should attach the code to run after the query
127
+ # completes with `#callback`, and you can attach an error handler with
128
+ # `#errback` if you like.
129
+ #
130
+ def db_upsert(db, tbl, key, data)
131
+ q = upsert_sql(tbl, key, data)
132
+
133
+ ::EM::DefaultDeferrable.new.tap do |df|
134
+ db.exec_defer(*q).callback do
135
+ df.succeed
136
+ end.errback do |ex|
137
+ if ex.is_a?(PG::UniqueViolation)
138
+ db.exec_defer(*q).callback do
139
+ df.succeed
140
+ end.errback do |ex|
141
+ df.fail(ex)
142
+ end
143
+ else
144
+ df.fail(ex)
145
+ end
146
+ end
147
+ end
148
+ end
149
+
50
150
  # Execute code in a transaction.
51
151
  #
52
152
  # Calling this method opens up a transaction (by executing `BEGIN`), and
@@ -117,6 +117,19 @@ class PG::EM::Client::Helper::Transaction
117
117
  exec(*insert_sql(tbl, params), &blk)
118
118
  end
119
119
 
120
+ # Run an upsert inside a transaction.
121
+ #
122
+ # @see {PG::EM::Client::Helper#upsert_sql} for all the parameters.
123
+ #
124
+ # @return [EM::Deferrable]
125
+ def upsert(*args, &blk)
126
+ db_upsert(@conn, *args).tap do |df|
127
+ df.callback(&blk) if block_given?
128
+ end.errback do |ex|
129
+ rollback(ex)
130
+ end
131
+ end
132
+
120
133
  # Execute an arbitrary block of SQL in `sql` within the transaction.
121
134
  # If you need to pass dynamic values to the query, those should be
122
135
  # given in `values`, and referenced in `sql` as `$1`, `$2`, etc. The
@@ -3,36 +3,6 @@ require_relative './spec_helper'
3
3
  describe "PG::EM::Client::Helper#db_transaction" do
4
4
  let(:mock_conn) { double(PG::EM::Client) }
5
5
 
6
- def expect_query_failure(q, args=[], err=nil, exec_time = 0.001)
7
- err ||= RuntimeError.new("Dummy failure")
8
- expect_query(q, args, exec_time, :fail, err)
9
- end
10
-
11
- def expect_query(q, args=[], exec_time = 0.001, disposition = :succeed, *disp_opts)
12
- df = EM::DefaultDeferrable.new
13
-
14
- expect(mock_conn)
15
- .to receive(:exec_defer)
16
- .with(*[q, args].compact)
17
- .and_return(df)
18
- .ordered
19
-
20
- EM.add_timer(exec_time) do
21
- df.__send__(disposition, *disp_opts)
22
- end
23
- end
24
-
25
- def in_transaction(*args, &blk)
26
- db_transaction(mock_conn, *args, &blk).callback { EM.stop }.errback { EM.stop }
27
- end
28
-
29
- def in_em
30
- EM.run do
31
- EM.add_timer(0.5) { EM.stop; raise "test timeout" }
32
- yield
33
- end
34
- end
35
-
36
6
  it "runs a BEGIN/COMMIT cycle by default" do
37
7
  in_em do
38
8
  expect_query("BEGIN")
@@ -0,0 +1,87 @@
1
+ require_relative './spec_helper'
2
+
3
+ describe "PG::EM::Client::Helper#db_transaction#upsert" do
4
+ let(:mock_conn) { double(PG::EM::Client) }
5
+ let(:mock_query) do
6
+ [
7
+ 'WITH upsert AS (UPDATE "foo" SET "bar"=$1 WHERE "wombat"=$2 RETURNING *) ' +
8
+ 'INSERT INTO "foo" ("bar","wombat") SELECT $1,$2 ' +
9
+ 'WHERE NOT EXISTS (SELECT * FROM upsert)',
10
+ ["baz", 42]
11
+ ]
12
+ end
13
+ let(:mock_query_opts) do
14
+ ["foo", :wombat, { :bar => "baz", :wombat => 42 }]
15
+ end
16
+
17
+ it "runs a simple UPSERT correctly" do
18
+ in_em do
19
+ expect_query("BEGIN")
20
+ expect_query(*mock_query)
21
+ expect_query("COMMIT")
22
+ in_transaction do |txn|
23
+ txn.upsert(*mock_query_opts) do
24
+ txn.commit
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ it "rolls back after a failed attempt" do
31
+ in_em do
32
+ expect_query("BEGIN")
33
+ expect_query_failure(*mock_query)
34
+ expect_query("ROLLBACK")
35
+ in_transaction do |txn|
36
+ txn.upsert(*mock_query_opts) do
37
+ txn.commit
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ it "tries again if the first failure was a PG::UniqueViolation" do
44
+ in_em do
45
+ expect_query("BEGIN")
46
+ expect_query_failure(*mock_query, PG::UniqueViolation.new("OMFG"))
47
+ expect_query(*mock_query)
48
+ expect_query("COMMIT")
49
+
50
+ in_transaction do |txn|
51
+ txn.upsert(*mock_query_opts) do
52
+ txn.commit
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ it "explodes if the second time fails" do
59
+ in_em do
60
+ expect_query("BEGIN")
61
+ expect_query_failure(*mock_query, PG::UniqueViolation.new("OMFG"))
62
+ expect_query_failure(*mock_query)
63
+ expect_query("ROLLBACK")
64
+
65
+ in_transaction do |txn|
66
+ txn.upsert(*mock_query_opts) do
67
+ txn.commit
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ it "explodes if the second failure was a PG::UniqueViolation" do
74
+ in_em do
75
+ expect_query("BEGIN")
76
+ expect_query_failure(*mock_query, PG::UniqueViolation.new("OMFG"))
77
+ expect_query_failure(*mock_query, PG::UniqueViolation.new("OMFG"))
78
+ expect_query("ROLLBACK")
79
+
80
+ in_transaction do |txn|
81
+ txn.upsert(*mock_query_opts) do
82
+ txn.commit
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,58 @@
1
+ require_relative './spec_helper'
2
+
3
+ describe "PG::EM::Client::Helper#db_upsert" do
4
+ let(:mock_query) do
5
+ [
6
+ 'WITH upsert AS (UPDATE "foo" SET "bar"=$1 WHERE "wombat"=$2 RETURNING *) ' +
7
+ 'INSERT INTO "foo" ("bar","wombat") SELECT $1,$2 ' +
8
+ 'WHERE NOT EXISTS (SELECT * FROM upsert)',
9
+ ["baz", 42]
10
+ ]
11
+ end
12
+
13
+ it "works first time" do
14
+ expect(db = double).
15
+ to receive(:exec_defer).
16
+ with(*mock_query).
17
+ and_return(::EM::DefaultDeferrable.new.tap { |df| df.succeed })
18
+
19
+ df = db_upsert(db, "foo", :wombat, :bar => "baz", :wombat => 42)
20
+ expect(df.instance_variable_get(:@deferred_status)).to eq(:succeeded)
21
+ end
22
+
23
+ it "works on retry" do
24
+ expect(db = double).
25
+ to receive(:exec_defer).
26
+ with(*mock_query).
27
+ ordered.
28
+ and_return(
29
+ ::EM::DefaultDeferrable.new.tap do |df|
30
+ df.fail(PG::UniqueViolation.new("OMFG"))
31
+ end
32
+ )
33
+
34
+ expect(db).
35
+ to receive(:exec_defer).
36
+ with(*mock_query).
37
+ ordered.
38
+ and_return(::EM::DefaultDeferrable.new.tap { |df| df.succeed })
39
+
40
+ df = db_upsert(db, "foo", :wombat, :bar => "baz", :wombat => 42)
41
+ expect(df.instance_variable_get(:@deferred_status)).to eq(:succeeded)
42
+ end
43
+
44
+ it "fails on a different error" do
45
+ expect(db = double).
46
+ to receive(:exec_defer).
47
+ with(*mock_query).
48
+ ordered.
49
+ and_return(
50
+ ::EM::DefaultDeferrable.new.tap do |df|
51
+ df.fail(PG::UndefinedTable.new("OMFG"))
52
+ end
53
+ )
54
+
55
+ df = db_upsert(db, "foo", :wombat, :bar => "baz", :wombat => 42)
56
+ expect(df.instance_variable_get(:@deferred_status)).to eq(:failed)
57
+ end
58
+ end
data/spec/spec_helper.rb CHANGED
@@ -5,6 +5,7 @@ Spork.prefork do
5
5
  Bundler.setup(:default, :development, :test)
6
6
  require 'rspec/core'
7
7
  require 'rspec/mocks'
8
+ require 'txn_helper'
8
9
 
9
10
  require 'pry'
10
11
 
@@ -15,6 +16,8 @@ Spork.prefork do
15
16
  config.expect_with :rspec do |c|
16
17
  c.syntax = :expect
17
18
  end
19
+
20
+ config.include TxnHelper
18
21
  end
19
22
  end
20
23
 
@@ -0,0 +1,31 @@
1
+ module TxnHelper
2
+ def expect_query_failure(q, args=[], err=nil, exec_time = 0.001)
3
+ err ||= RuntimeError.new("A mock exec_defer failure")
4
+ expect_query(q, args, exec_time, :fail, err)
5
+ end
6
+
7
+ def expect_query(q, args=[], exec_time = 0.001, disposition = :succeed, *disp_opts)
8
+ df = EM::DefaultDeferrable.new
9
+
10
+ expect(mock_conn)
11
+ .to receive(:exec_defer)
12
+ .with(*[q, args].compact)
13
+ .and_return(df)
14
+ .ordered
15
+
16
+ EM.add_timer(exec_time) do
17
+ df.__send__(disposition, *disp_opts)
18
+ end
19
+ end
20
+
21
+ def in_transaction(*args, &blk)
22
+ db_transaction(mock_conn, *args, &blk).callback { EM.stop }.errback { EM.stop }
23
+ end
24
+
25
+ def in_em
26
+ EM.run do
27
+ EM.add_timer(0.5) { EM.stop; raise "test timeout" }
28
+ yield
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,32 @@
1
+ require_relative './spec_helper'
2
+
3
+ describe "PG::EM::Client::Helper#upsert_sql" do
4
+ it "assembles a simple query correctly" do
5
+ expect(upsert_sql("foo", :wombat, :bar => "baz", :wombat => 42)).
6
+ to eq([
7
+ 'WITH upsert AS (UPDATE "foo" SET "bar"=$1 WHERE "wombat"=$2 RETURNING *) ' +
8
+ 'INSERT INTO "foo" ("bar","wombat") SELECT $1,$2 ' +
9
+ 'WHERE NOT EXISTS (SELECT * FROM upsert)',
10
+ ["baz", 42]
11
+ ])
12
+ end
13
+
14
+ it "assembles a more complex query correctly" do
15
+ expect(
16
+ upsert_sql(
17
+ "user_store_data",
18
+ [:id, :platform, :profile_type],
19
+ :id => 42,
20
+ :platform => 'wooden',
21
+ :profile_type => 'xyzzy',
22
+ :data => "ohai!"
23
+ )
24
+ ).to eq([
25
+ 'WITH upsert AS (UPDATE "user_store_data" SET "data"=$4 ' +
26
+ 'WHERE "id"=$1 AND "platform"=$2 AND "profile_type"=$3 RETURNING *) ' +
27
+ 'INSERT INTO "user_store_data" ("id","platform","profile_type","data") ' +
28
+ 'SELECT $1,$2,$3,$4 WHERE NOT EXISTS (SELECT * FROM upsert)',
29
+ [42, "wooden", "xyzzy", "ohai!"]
30
+ ])
31
+ end
32
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: em-pg-client-helper
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2015-01-22 00:00:00.000000000 Z
12
+ date: 2015-01-23 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: git-version-bump
@@ -245,8 +245,12 @@ files:
245
245
  - lib/em-pg-client-helper/transaction.rb
246
246
  - spec/db_insert_spec.rb
247
247
  - spec/db_transaction_spec.rb
248
+ - spec/db_transaction_upsert_spec.rb
249
+ - spec/db_upsert_spec.rb
248
250
  - spec/insert_sql_spec.rb
249
251
  - spec/spec_helper.rb
252
+ - spec/txn_helper.rb
253
+ - spec/upsert_sql_spec.rb
250
254
  homepage: http://github.com/mpalmer/em-pg-client-helper
251
255
  licenses: []
252
256
  post_install_message:
@@ -261,7 +265,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
261
265
  version: '0'
262
266
  segments:
263
267
  - 0
264
- hash: 4034289824364546696
268
+ hash: 3057886313740167473
265
269
  required_rubygems_version: !ruby/object:Gem::Requirement
266
270
  none: false
267
271
  requirements:
@@ -270,7 +274,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
270
274
  version: '0'
271
275
  segments:
272
276
  - 0
273
- hash: 4034289824364546696
277
+ hash: 3057886313740167473
274
278
  requirements: []
275
279
  rubyforge_project:
276
280
  rubygems_version: 1.8.23