em-pg-client-helper 1.0.0 → 1.1.0

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.
@@ -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