activerecord-spanner-adapter 0.7.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/blunderbuss.yml +2 -0
  3. data/.github/sync-repo-settings.yaml +1 -1
  4. data/.github/workflows/acceptance-tests-on-emulator.yaml +8 -6
  5. data/.github/workflows/acceptance-tests-on-production.yaml +3 -3
  6. data/.github/workflows/ci.yaml +8 -6
  7. data/.github/workflows/nightly-acceptance-tests-on-emulator.yaml +9 -5
  8. data/.github/workflows/nightly-acceptance-tests-on-production.yaml +2 -2
  9. data/.github/workflows/nightly-unit-tests.yaml +9 -5
  10. data/.github/workflows/release-please-label.yml +4 -4
  11. data/.github/workflows/release-please.yml +12 -11
  12. data/.github/workflows/rubocop.yaml +4 -4
  13. data/.release-please-manifest.json +3 -0
  14. data/CHANGELOG.md +48 -26
  15. data/CONTRIBUTING.md +1 -1
  16. data/Gemfile +2 -1
  17. data/README.md +3 -3
  18. data/acceptance/cases/models/insert_all_test.rb +150 -0
  19. data/acceptance/cases/type/all_types_test.rb +24 -25
  20. data/acceptance/cases/type/json_test.rb +0 -2
  21. data/acceptance/schema/schema.rb +2 -4
  22. data/acceptance/test_helper.rb +1 -1
  23. data/activerecord-spanner-adapter.gemspec +1 -1
  24. data/examples/rails/README.md +8 -8
  25. data/lib/active_record/connection_adapters/spanner/schema_creation.rb +10 -4
  26. data/lib/active_record/connection_adapters/spanner_adapter.rb +64 -31
  27. data/lib/active_record/type/spanner/array.rb +19 -5
  28. data/lib/activerecord_spanner_adapter/base.rb +72 -5
  29. data/lib/activerecord_spanner_adapter/connection.rb +46 -20
  30. data/lib/activerecord_spanner_adapter/information_schema.rb +2 -1
  31. data/lib/activerecord_spanner_adapter/transaction.rb +52 -21
  32. data/lib/activerecord_spanner_adapter/version.rb +1 -1
  33. data/lib/arel/visitors/spanner.rb +10 -0
  34. data/lib/spanner_client_ext.rb +4 -0
  35. data/release-please-config.json +19 -0
  36. metadata +17 -17
  37. data/examples/snippets/interleaved-tables/README.md +0 -152
  38. data/examples/snippets/interleaved-tables/Rakefile +0 -13
  39. data/examples/snippets/interleaved-tables/application.rb +0 -109
  40. data/examples/snippets/interleaved-tables/config/database.yml +0 -8
  41. data/examples/snippets/interleaved-tables/db/migrate/01_create_tables.rb +0 -44
  42. data/examples/snippets/interleaved-tables/db/schema.rb +0 -32
  43. data/examples/snippets/interleaved-tables/db/seeds.rb +0 -40
  44. data/examples/snippets/interleaved-tables/models/album.rb +0 -15
  45. data/examples/snippets/interleaved-tables/models/singer.rb +0 -20
  46. data/examples/snippets/interleaved-tables/models/track.rb +0 -25
@@ -30,19 +30,19 @@ module ActiveRecord
30
30
  AllTypes.create col_string: "string", col_int64: 100, col_float64: 3.14, col_numeric: 6.626, col_bool: true,
31
31
  col_bytes: StringIO.new("bytes"), col_date: ::Date.new(2021, 6, 23),
32
32
  col_timestamp: ::Time.new(2021, 6, 23, 17, 8, 21, "+02:00"),
33
- col_json: ENV["SPANNER_EMULATOR_HOST"] ? "" : { kind: "user_renamed", change: %w[jack john]},
33
+ col_json: { kind: "user_renamed", change: %w[jack john]},
34
34
  col_array_string: ["string1", nil, "string2"],
35
- col_array_int64: [100, nil, 200],
36
- col_array_float64: [3.14, nil, 2.0/3.0],
37
- col_array_numeric: [6.626, nil, 3.20],
38
- col_array_bool: [true, nil, false],
35
+ col_array_int64: [100, nil, 200, "300"],
36
+ col_array_float64: [3.14, nil, 2.0/3.0, "3.14"],
37
+ col_array_numeric: [6.626, nil, 3.20, "400"],
38
+ col_array_bool: [true, nil, false, "false"],
39
39
  col_array_bytes: [StringIO.new("bytes1"), nil, StringIO.new("bytes2")],
40
- col_array_date: [::Date.new(2021, 6, 23), nil, ::Date.new(2021, 6, 24)],
40
+ col_array_date: [::Date.new(2021, 6, 23), nil, ::Date.new(2021, 6, 24), "2021-06-25"],
41
41
  col_array_timestamp: [::Time.new(2021, 6, 23, 17, 8, 21, "+02:00"), nil, \
42
- ::Time.new(2021, 6, 24, 17, 8, 21, "+02:00")],
43
- col_array_json: ENV["SPANNER_EMULATOR_HOST"] ? [""] : \
44
- [{ kind: "user_renamed", change: %w[jack john]}, nil, \
45
- { kind: "user_renamed", change: %w[alice meredith]}]
42
+ ::Time.new(2021, 6, 24, 17, 8, 21, "+02:00"), "2021-06-25 17:08:21 +02:00"],
43
+ col_array_json: [{ kind: "user_renamed", change: %w[jack john]}, nil, \
44
+ { kind: "user_renamed", change: %w[alice meredith]},
45
+ "{\"kind\":\"user_renamed\",\"change\":[\"bob\",\"carol\"]}"]
46
46
  end
47
47
 
48
48
  def test_create_record
@@ -66,24 +66,25 @@ module ActiveRecord
66
66
  assert_equal ::Date.new(2021, 6, 23), record.col_date
67
67
  assert_equal ::Time.new(2021, 6, 23, 17, 8, 21, "+02:00").utc, record.col_timestamp.utc
68
68
  assert_equal ({"kind" => "user_renamed", "change" => %w[jack john]}),
69
- record.col_json unless ENV["SPANNER_EMULATOR_HOST"]
69
+ record.col_json
70
70
 
71
71
  assert_equal ["string1", nil, "string2"], record.col_array_string
72
- assert_equal [100, nil, 200], record.col_array_int64
73
- assert_equal [3.14, nil, 2.0/3.0], record.col_array_float64
74
- assert_equal [6.626, nil, 3.20], record.col_array_numeric
75
- assert_equal [true, nil, false], record.col_array_bool
72
+ assert_equal [100, nil, 200, 300], record.col_array_int64
73
+ assert_equal [3.14, nil, 2.0/3.0, 3.14], record.col_array_float64
74
+ assert_equal [6.626, nil, 3.20, 400], record.col_array_numeric
75
+ assert_equal [true, nil, false, false], record.col_array_bool
76
76
  assert_equal [StringIO.new("bytes1"), nil, StringIO.new("bytes2")].map { |bytes| bytes&.read },
77
77
  record.col_array_bytes.map { |bytes| bytes&.read }
78
- assert_equal [::Date.new(2021, 6, 23), nil, ::Date.new(2021, 6, 24)], record.col_array_date
78
+ assert_equal [::Date.new(2021, 6, 23), nil, ::Date.new(2021, 6, 24), ::Date.new(2021, 06, 25)], record.col_array_date
79
79
  assert_equal [::Time.new(2021, 6, 23, 17, 8, 21, "+02:00"), \
80
80
  nil, \
81
- ::Time.new(2021, 6, 24, 17, 8, 21, "+02:00")].map { |timestamp| timestamp&.utc },
81
+ ::Time.new(2021, 6, 24, 17, 8, 21, "+02:00"), ::Time.new(2021, 6, 25, 17, 8, 21, "+02:00")].map { |timestamp| timestamp&.utc },
82
82
  record.col_array_timestamp.map { |timestamp| timestamp&.utc}
83
83
  assert_equal [{"kind" => "user_renamed", "change" => %w[jack john]}, \
84
84
  nil, \
85
- {"kind" => "user_renamed", "change" => %w[alice meredith]}],
86
- record.col_array_json unless ENV["SPANNER_EMULATOR_HOST"]
85
+ {"kind" => "user_renamed", "change" => %w[alice meredith]}, \
86
+ "{\"kind\":\"user_renamed\",\"change\":[\"bob\",\"carol\"]}"],
87
+ record.col_array_json
87
88
  end
88
89
  end
89
90
 
@@ -98,7 +99,7 @@ module ActiveRecord
98
99
  col_bool: false, col_bytes: StringIO.new("new bytes"),
99
100
  col_date: ::Date.new(2021, 6, 28),
100
101
  col_timestamp: ::Time.new(2021, 6, 28, 11, 22, 21, "+02:00"),
101
- col_json: ENV["SPANNER_EMULATOR_HOST"] ? "" : { kind: "user_created", change: %w[jack alice]},
102
+ col_json: { kind: "user_created", change: %w[jack alice]},
102
103
  col_array_string: ["new string 1", "new string 2"],
103
104
  col_array_int64: [300, 200, 100],
104
105
  col_array_float64: [1.1, 2.2, 3.3],
@@ -107,9 +108,7 @@ module ActiveRecord
107
108
  col_array_bytes: [StringIO.new("new bytes 1"), StringIO.new("new bytes 2")],
108
109
  col_array_date: [::Date.new(2021, 6, 28)],
109
110
  col_array_timestamp: [::Time.utc(2020, 12, 31, 0, 0, 0)],
110
- col_array_json: ENV["SPANNER_EMULATOR_HOST"] ?
111
- [""] : \
112
- [{ kind: "user_created", change: %w[jack alice]}]
111
+ col_array_json: [{ kind: "user_created", change: %w[jack alice]}]
113
112
  end
114
113
 
115
114
  # Verify that the record was updated.
@@ -123,7 +122,7 @@ module ActiveRecord
123
122
  assert_equal ::Date.new(2021, 6, 28), record.col_date
124
123
  assert_equal ::Time.new(2021, 6, 28, 11, 22, 21, "+02:00").utc, record.col_timestamp.utc
125
124
  assert_equal ({"kind" => "user_created", "change" => %w[jack alice]}),
126
- record.col_json unless ENV["SPANNER_EMULATOR_HOST"]
125
+ record.col_json
127
126
 
128
127
  assert_equal ["new string 1", "new string 2"], record.col_array_string
129
128
  assert_equal [300, 200, 100], record.col_array_int64
@@ -135,7 +134,7 @@ module ActiveRecord
135
134
  assert_equal [::Date.new(2021, 6, 28)], record.col_array_date
136
135
  assert_equal [::Time.utc(2020, 12, 31, 0, 0, 0)], record.col_array_timestamp.map(&:utc)
137
136
  assert_equal [{"kind" => "user_created", "change" => %w[jack alice]}],
138
- record.col_array_json unless ENV["SPANNER_EMULATOR_HOST"]
137
+ record.col_array_json
139
138
  end
140
139
  end
141
140
 
@@ -18,8 +18,6 @@ module ActiveRecord
18
18
  end
19
19
 
20
20
  def test_set_json
21
- return if ENV["SPANNER_EMULATOR_HOST"]
22
-
23
21
  expected_hash = {"key"=>"value", "array_key"=>%w[value1 value2]}
24
22
  record = TestTypeModel.new details: {key: "value", array_key: %w[value1 value2]}
25
23
 
@@ -17,8 +17,7 @@ ActiveRecord::Schema.define do
17
17
  t.column :col_bytes, :binary
18
18
  t.column :col_date, :date
19
19
  t.column :col_timestamp, :datetime
20
- t.column :col_json, :json unless ENV["SPANNER_EMULATOR_HOST"]
21
- t.column :col_json, :string if ENV["SPANNER_EMULATOR_HOST"]
20
+ t.column :col_json, :json
22
21
 
23
22
  t.column :col_array_string, :string, array: true
24
23
  t.column :col_array_int64, :bigint, array: true
@@ -28,8 +27,7 @@ ActiveRecord::Schema.define do
28
27
  t.column :col_array_bytes, :binary, array: true
29
28
  t.column :col_array_date, :date, array: true
30
29
  t.column :col_array_timestamp, :datetime, array: true
31
- t.column :col_array_json, :json, array: true unless ENV["SPANNER_EMULATOR_HOST"]
32
- t.column :col_array_json, :string, array: true if ENV["SPANNER_EMULATOR_HOST"]
30
+ t.column :col_array_json, :json, array: true
33
31
  end
34
32
 
35
33
  create_table :firms do |t|
@@ -208,7 +208,7 @@ module SpannerAdapter
208
208
  t.date :start_date
209
209
  t.datetime :start_datetime
210
210
  t.time :start_time
211
- t.json :details unless ENV["SPANNER_EMULATOR_HOST"]
211
+ t.json :details
212
212
  end
213
213
  end
214
214
 
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.required_ruby_version = ">= 2.5"
26
26
 
27
27
  spec.add_dependency "google-cloud-spanner", "~> 2.10"
28
- spec.add_runtime_dependency "activerecord", "~> 6.1.4"
28
+ spec.add_runtime_dependency "activerecord", [">= 6.0.0", "< 7.1"]
29
29
 
30
30
  spec.add_development_dependency "autotest-suffix", "~> 1.1"
31
31
  spec.add_development_dependency "bundler", "~> 2.0"
@@ -142,17 +142,17 @@ Replace `[PROJECT_ID]` with the project id you are currently using.
142
142
  ### Create database
143
143
 
144
144
  You now can run the following command to create the database:
145
- ```shell
146
- ./bin/rails db:create
147
- ```
148
- You should see output like the following:
149
- ```
150
- Created database 'blog_dev'
151
- ```
145
+
146
+ ```shell
147
+ ./bin/rails db:create
148
+ ```
149
+
150
+ You should see output like the following: `Created database 'blog_dev'`
151
+
152
152
  ### Generate a Model and apply the migration
153
153
  1. Use the model generato to define a model:
154
154
  ```shell
155
- bin/rails generate model Article title:string body:text
155
+ ./bin/rails generate model Article title:string body:text
156
156
  ```
157
157
  1. Apply the migration:
158
158
  ```shell
@@ -10,14 +10,20 @@ module ActiveRecord
10
10
  class SchemaCreation < SchemaCreation
11
11
  private
12
12
 
13
- # rubocop:disable Naming/MethodName, Metrics/AbcSize
13
+ # rubocop:disable Naming/MethodName, Metrics/AbcSize, Metrics/PerceivedComplexity
14
14
 
15
15
  def visit_TableDefinition o
16
16
  create_sql = +"CREATE TABLE #{quote_table_name o.name} "
17
17
  statements = o.columns.map { |c| accept c }
18
18
 
19
- o.foreign_keys.each do |to_table, options|
20
- statements << foreign_key_in_create(o.name, to_table, options)
19
+ if ActiveRecord::VERSION::MAJOR >= 7
20
+ o.foreign_keys.each do |fk|
21
+ statements << accept(fk)
22
+ end
23
+ else
24
+ o.foreign_keys.each do |to_table, options|
25
+ statements << foreign_key_in_create(o.name, to_table, options)
26
+ end
21
27
  end
22
28
 
23
29
  create_sql << "(#{statements.join ', '}) " if statements.any?
@@ -106,7 +112,7 @@ module ActiveRecord
106
112
  sql
107
113
  end
108
114
 
109
- # rubocop:enable Naming/MethodName, Metrics/AbcSize
115
+ # rubocop:enable Naming/MethodName, Metrics/AbcSize, Metrics/PerceivedComplexity
110
116
 
111
117
  def add_column_options! sql, options
112
118
  if options[:null] == false || options[:primary_key] == true
@@ -8,6 +8,7 @@ require "securerandom"
8
8
  require "google/cloud/spanner"
9
9
  require "spanner_client_ext"
10
10
  require "active_record/connection_adapters/abstract_adapter"
11
+ require "active_record/connection_adapters/abstract/connection_pool"
11
12
  require "active_record/connection_adapters/spanner/database_statements"
12
13
  require "active_record/connection_adapters/spanner/schema_statements"
13
14
  require "active_record/connection_adapters/spanner/schema_cache"
@@ -43,9 +44,9 @@ module ActiveRecord
43
44
  module ConnectionAdapters
44
45
  module AbstractPool
45
46
  def get_schema_cache connection
46
- @schema_cache ||= SpannerSchemaCache.new connection
47
- @schema_cache.connection = connection
48
- @schema_cache
47
+ self.schema_cache ||= SpannerSchemaCache.new connection
48
+ schema_cache.connection = connection
49
+ schema_cache
49
50
  end
50
51
  end
51
52
 
@@ -178,41 +179,73 @@ module ActiveRecord
178
179
  Arel::Visitors::Spanner.new self
179
180
  end
180
181
 
181
- private
182
+ def build_insert_sql insert
183
+ if current_spanner_transaction&.isolation == :buffered_mutations
184
+ raise "ActiveRecordSpannerAdapter does not support insert_sql with buffered_mutations transaction."
185
+ end
182
186
 
183
- def initialize_type_map m = type_map
184
- m.register_type "BOOL", Type::Boolean.new
185
- register_class_with_limit(
186
- m, %r{^BYTES}i, ActiveRecord::Type::Spanner::Bytes
187
- )
188
- m.register_type "DATE", Type::Date.new
189
- m.register_type "FLOAT64", Type::Float.new
190
- m.register_type "NUMERIC", Type::Decimal.new
191
- m.register_type "INT64", Type::Integer.new(limit: 8)
192
- register_class_with_limit m, %r{^STRING}i, Type::String
193
- m.register_type "TIMESTAMP", ActiveRecord::Type::Spanner::Time.new
194
- m.register_type "JSON", ActiveRecord::Type::Json.new
187
+ if insert.skip_duplicates? || insert.update_duplicates?
188
+ raise NotImplementedError, "CloudSpanner does not support skip_duplicates and update_duplicates."
189
+ end
195
190
 
196
- register_array_types m
191
+ values_list, = insert.values_list
192
+ "INSERT #{insert.into} #{values_list}"
197
193
  end
198
194
 
199
- def register_array_types m
200
- m.register_type %r{^ARRAY<BOOL>}i, Type::Spanner::Array.new(Type::Boolean.new)
201
- m.register_type %r{^ARRAY<BYTES\((MAX|d+)\)>}i, Type::Spanner::Array.new(Type::Binary.new)
202
- m.register_type %r{^ARRAY<DATE>}i, Type::Spanner::Array.new(Type::Date.new)
203
- m.register_type %r{^ARRAY<FLOAT64>}i, Type::Spanner::Array.new(Type::Float.new)
204
- m.register_type %r{^ARRAY<NUMERIC>}i, Type::Spanner::Array.new(Type::Decimal.new)
205
- m.register_type %r{^ARRAY<INT64>}i, Type::Spanner::Array.new(Type::Integer.new(limit: 8))
206
- m.register_type %r{^ARRAY<STRING\((MAX|d+)\)>}i, Type::Spanner::Array.new(Type::String.new)
207
- m.register_type %r{^ARRAY<TIMESTAMP>}i, Type::Spanner::Array.new(ActiveRecord::Type::Spanner::Time.new)
208
- m.register_type %r{^ARRAY<JSON>}i, Type::Spanner::Array.new(ActiveRecord::Type::Json.new)
195
+ module TypeMapBuilder
196
+ private
197
+
198
+ def initialize_type_map m = type_map
199
+ m.register_type "BOOL", Type::Boolean.new
200
+ register_class_with_limit(
201
+ m, %r{^BYTES}i, ActiveRecord::Type::Spanner::Bytes
202
+ )
203
+ m.register_type "DATE", Type::Date.new
204
+ m.register_type "FLOAT64", Type::Float.new
205
+ m.register_type "NUMERIC", Type::Decimal.new
206
+ m.register_type "INT64", Type::Integer.new(limit: 8)
207
+ register_class_with_limit m, %r{^STRING}i, Type::String
208
+ m.register_type "TIMESTAMP", ActiveRecord::Type::Spanner::Time.new
209
+ m.register_type "JSON", ActiveRecord::Type::Json.new
210
+
211
+ register_array_types m
212
+ end
213
+
214
+ def register_array_types m
215
+ m.register_type %r{^ARRAY<BOOL>}i, Type::Spanner::Array.new(Type::Boolean.new)
216
+ m.register_type %r{^ARRAY<BYTES\((MAX|d+)\)>}i,
217
+ Type::Spanner::Array.new(ActiveRecord::Type::Spanner::Bytes.new)
218
+ m.register_type %r{^ARRAY<DATE>}i, Type::Spanner::Array.new(Type::Date.new)
219
+ m.register_type %r{^ARRAY<FLOAT64>}i, Type::Spanner::Array.new(Type::Float.new)
220
+ m.register_type %r{^ARRAY<NUMERIC>}i, Type::Spanner::Array.new(Type::Decimal.new)
221
+ m.register_type %r{^ARRAY<INT64>}i, Type::Spanner::Array.new(Type::Integer.new(limit: 8))
222
+ m.register_type %r{^ARRAY<STRING\((MAX|d+)\)>}i, Type::Spanner::Array.new(Type::String.new)
223
+ m.register_type %r{^ARRAY<TIMESTAMP>}i, Type::Spanner::Array.new(ActiveRecord::Type::Spanner::Time.new)
224
+ m.register_type %r{^ARRAY<JSON>}i, Type::Spanner::Array.new(ActiveRecord::Type::Json.new)
225
+ end
226
+
227
+ def extract_limit sql_type
228
+ value = /\((.*)\)/.match sql_type
229
+ return unless value
230
+
231
+ value[1] == "MAX" ? "MAX" : value[1].to_i
232
+ end
209
233
  end
210
234
 
211
- def extract_limit sql_type
212
- value = /\((.*)\)/.match sql_type
213
- return unless value
235
+ if ActiveRecord::VERSION::MAJOR >= 7
236
+ class << self
237
+ include TypeMapBuilder
238
+ end
239
+
240
+ TYPE_MAP = Type::TypeMap.new.tap { |m| initialize_type_map m }
241
+
242
+ private
214
243
 
215
- value[1] == "MAX" ? "MAX" : value[1].to_i
244
+ def type_map
245
+ TYPE_MAP
246
+ end
247
+ else
248
+ include TypeMapBuilder
216
249
  end
217
250
 
218
251
  def translate_exception exception, message:, sql:, binds:
@@ -15,15 +15,29 @@ module ActiveRecord
15
15
  @element_type = element_type
16
16
  end
17
17
 
18
- def serialize value
18
+ def cast value
19
19
  return super if value.nil?
20
- return super unless @element_type.is_a? Type::Decimal
21
20
  return super unless value.respond_to? :map
22
21
 
23
- # Convert a decimal (NUMERIC) array to a String array to prevent it from being encoded as a FLOAT64 array.
24
22
  value.map do |v|
25
- next if v.nil?
26
- v.to_s
23
+ @element_type.cast v
24
+ end
25
+ end
26
+
27
+ def serialize value
28
+ return super if value.nil?
29
+ return super unless value.respond_to? :map
30
+
31
+ if @element_type.is_a? ActiveRecord::Type::Decimal
32
+ # Convert a decimal (NUMERIC) array to a String array to prevent it from being encoded as a FLOAT64 array.
33
+ value.map do |v|
34
+ next if v.nil?
35
+ v.to_s
36
+ end
37
+ else
38
+ value.map do |v|
39
+ @element_type.serialize v
40
+ end
27
41
  end
28
42
  end
29
43
  end
@@ -41,14 +41,69 @@ module ActiveRecord
41
41
  spanner_adapter? && connection&.current_spanner_transaction&.isolation == :buffered_mutations
42
42
  end
43
43
 
44
+ def self.insert_all _attributes, _returning: nil, _unique_by: nil
45
+ raise NotImplementedError, "Cloud Spanner does not support skip_duplicates."
46
+ end
47
+
48
+ def self.insert_all! attributes, returning: nil
49
+ return super unless spanner_adapter?
50
+ return super if active_transaction? && !buffered_mutations?
51
+
52
+ # This might seem inefficient, but is actually not, as it is only buffering a mutation locally.
53
+ # The mutations will be sent as one batch when the transaction is committed.
54
+ if active_transaction?
55
+ attributes.each do |record|
56
+ _insert_record record
57
+ end
58
+ else
59
+ transaction isolation: :buffered_mutations do
60
+ attributes.each do |record|
61
+ _insert_record record
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ def self.upsert_all attributes, returning: nil, unique_by: nil
68
+ return super unless spanner_adapter?
69
+ if active_transaction? && !buffered_mutations?
70
+ raise NotImplementedError, "Cloud Spanner does not support upsert using DML. " \
71
+ "Use upsert outside a transaction block or in a transaction " \
72
+ "block with isolation: :buffered_mutations"
73
+ end
74
+
75
+ # This might seem inefficient, but is actually not, as it is only buffering a mutation locally.
76
+ # The mutations will be sent as one batch when the transaction is committed.
77
+ if active_transaction?
78
+ attributes.each do |record|
79
+ _upsert_record record
80
+ end
81
+ else
82
+ transaction isolation: :buffered_mutations do
83
+ attributes.each do |record|
84
+ _upsert_record record
85
+ end
86
+ end
87
+ end
88
+ end
89
+
44
90
  def self._insert_record values
45
91
  return super unless buffered_mutations?
46
92
 
93
+ _buffer_record values, :insert
94
+ end
95
+
96
+ def self._upsert_record values
97
+ _buffer_record values, :insert_or_update
98
+ end
99
+
100
+ def self._buffer_record values, method
47
101
  primary_key = self.primary_key
48
102
  primary_key_value = nil
49
103
 
50
104
  if primary_key && values.is_a?(Hash)
51
105
  primary_key_value = values[primary_key]
106
+ primary_key_value ||= values[:"#{primary_key}"]
52
107
 
53
108
  if !primary_key_value && prefetch_primary_key?
54
109
  primary_key_value = next_sequence_value
@@ -59,13 +114,15 @@ module ActiveRecord
59
114
  metadata = TableMetadata.new self, arel_table
60
115
  columns, grpc_values = _create_grpc_values_for_insert metadata, values
61
116
 
117
+ write = Google::Cloud::Spanner::V1::Mutation::Write.new(
118
+ table: arel_table.name,
119
+ columns: columns,
120
+ values: [grpc_values.list_value]
121
+ )
62
122
  mutation = Google::Cloud::Spanner::V1::Mutation.new(
63
- insert: Google::Cloud::Spanner::V1::Mutation::Write.new(
64
- table: arel_table.name,
65
- columns: columns,
66
- values: [grpc_values.list_value]
67
- )
123
+ "#{method}": write
68
124
  )
125
+
69
126
  connection.current_spanner_transaction.buffer mutation
70
127
 
71
128
  primary_key_value
@@ -87,6 +144,14 @@ module ActiveRecord
87
144
  !(current_transaction.nil? || current_transaction.is_a?(ConnectionAdapters::NullTransaction))
88
145
  end
89
146
 
147
+ def self.unwrap_attribute attr_or_value
148
+ if attr_or_value.is_a? ActiveModel::Attribute
149
+ attr_or_value.value
150
+ else
151
+ attr_or_value
152
+ end
153
+ end
154
+
90
155
  # Updates the given attributes of the object in the database. This method will use mutations instead
91
156
  # of DML if there is no active transaction, or if the active transaction has been created with the option
92
157
  # isolation: :buffered_mutations.
@@ -117,6 +182,7 @@ module ActiveRecord
117
182
  serialized_values = []
118
183
  columns = []
119
184
  values.each_pair do |k, v|
185
+ v = unwrap_attribute v
120
186
  type = metadata.type k
121
187
  serialized_values << (type.method(:serialize).arity < 0 ? type.serialize(v, :mutation) : type.serialize(v))
122
188
  columns << metadata.arel_table[k].name
@@ -168,6 +234,7 @@ module ActiveRecord
168
234
  all_values.each do |h|
169
235
  h.each_pair do |k, v|
170
236
  type = metadata.type k
237
+ v = self.class.unwrap_attribute v
171
238
  has_serialize_options = type.method(:serialize).arity < 0
172
239
  all_serialized_values << (has_serialize_options ? type.serialize(v, :mutation) : type.serialize(v))
173
240
  all_columns << metadata.arel_table[k].name
@@ -208,27 +208,53 @@ module ActiveRecordSpannerAdapter
208
208
  self.current_transaction = nil
209
209
  end
210
210
 
211
- begin
212
- session.execute_query \
213
- sql,
214
- params: converted_params,
215
- types: types,
216
- transaction: transaction_selector || single_use_selector,
217
- seqno: (current_transaction&.next_sequence_number)
218
- rescue Google::Cloud::AbortedError
219
- # Mark the current transaction as aborted to prevent any unnecessary further requests on the transaction.
220
- current_transaction&.mark_aborted
221
- raise
222
- rescue Google::Cloud::NotFoundError => e
223
- if session_not_found?(e) || transaction_not_found?(e)
224
- reset!
225
- # Force a retry of the entire transaction if this statement was executed as part of a transaction.
226
- # Otherwise, just retry the statement itself.
227
- raise_aborted_err if current_transaction&.active?
228
- retry
229
- end
230
- raise
211
+ selector = transaction_selector || single_use_selector
212
+ execute_sql_request sql, converted_params, types, selector
213
+ end
214
+
215
+ def execute_sql_request sql, converted_params, types, selector
216
+ res = session.execute_query \
217
+ sql,
218
+ params: converted_params,
219
+ types: types,
220
+ transaction: selector,
221
+ seqno: (current_transaction&.next_sequence_number)
222
+ current_transaction.grpc_transaction = res.metadata.transaction \
223
+ if current_transaction && res&.metadata&.transaction
224
+ res
225
+ rescue Google::Cloud::AbortedError
226
+ # Mark the current transaction as aborted to prevent any unnecessary further requests on the transaction.
227
+ current_transaction&.mark_aborted
228
+ raise
229
+ rescue Google::Cloud::NotFoundError => e
230
+ if session_not_found?(e) || transaction_not_found?(e)
231
+ reset!
232
+ # Force a retry of the entire transaction if this statement was executed as part of a transaction.
233
+ # Otherwise, just retry the statement itself.
234
+ raise_aborted_err if current_transaction&.active?
235
+ retry
236
+ end
237
+ raise
238
+ rescue Google::Cloud::Error => e
239
+ # Check if it was the first statement in a transaction that included a BeginTransaction
240
+ # option in the request. If so, execute an explicit BeginTransaction and then retry the
241
+ # request without the BeginTransaction option.
242
+ if current_transaction && selector&.begin&.read_write
243
+ selector = create_transaction_after_failed_first_statement e
244
+ retry
231
245
  end
246
+ # It was not the first statement, so propagate the error.
247
+ raise
248
+ end
249
+
250
+ # Creates a transaction using a BeginTransaction RPC. This is used if the first statement of a
251
+ # transaction fails, as that also means that no transaction id was returned.
252
+ def create_transaction_after_failed_first_statement original_error
253
+ transaction = current_transaction.force_begin_read_write
254
+ Google::Spanner::V1::TransactionSelector.new id: transaction.transaction_id
255
+ rescue Google::Cloud::Error
256
+ # Raise the original error if the BeginTransaction RPC also fails.
257
+ raise original_error
232
258
  end
233
259
 
234
260
  # Transactions
@@ -62,7 +62,8 @@ module ActiveRecordSpannerAdapter
62
62
  end
63
63
 
64
64
  def table_columns table_name, column_name: nil
65
- sql = +"SELECT COLUMN_NAME, SPANNER_TYPE, IS_NULLABLE, COLUMN_DEFAULT, ORDINAL_POSITION"
65
+ sql = +"SELECT COLUMN_NAME, SPANNER_TYPE, IS_NULLABLE,"
66
+ sql << " CAST(COLUMN_DEFAULT AS STRING) AS COLUMN_DEFAULT, ORDINAL_POSITION"
66
67
  sql << " FROM INFORMATION_SCHEMA.COLUMNS"
67
68
  sql << " WHERE TABLE_NAME=%<table_name>s"
68
69
  sql << " AND COLUMN_NAME=%<column_name>s" if column_name