activerecord-spanner-adapter 0.7.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
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