activerecord-spanner-adapter 1.5.0 → 1.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/acceptance-tests-on-emulator.yaml +1 -1
  3. data/.github/workflows/acceptance-tests-on-production.yaml +5 -3
  4. data/.github/workflows/ci.yaml +1 -1
  5. data/.github/workflows/nightly-acceptance-tests-on-emulator.yaml +1 -1
  6. data/.github/workflows/nightly-acceptance-tests-on-production.yaml +5 -3
  7. data/.github/workflows/nightly-unit-tests.yaml +1 -1
  8. data/.github/workflows/release-please-label.yml +1 -1
  9. data/.release-please-manifest.json +1 -1
  10. data/CHANGELOG.md +14 -0
  11. data/Gemfile +5 -2
  12. data/README.md +10 -10
  13. data/acceptance/cases/interleaved_associations/has_many_associations_using_interleaved_test.rb +6 -0
  14. data/acceptance/cases/migration/change_schema_test.rb +19 -3
  15. data/acceptance/cases/migration/schema_dumper_test.rb +10 -1
  16. data/acceptance/cases/models/insert_all_test.rb +22 -0
  17. data/acceptance/cases/models/interleave_test.rb +6 -0
  18. data/acceptance/cases/tasks/database_tasks_test.rb +340 -2
  19. data/acceptance/cases/transactions/optimistic_locking_test.rb +6 -0
  20. data/acceptance/cases/transactions/read_write_transactions_test.rb +24 -0
  21. data/acceptance/models/table_with_sequence.rb +10 -0
  22. data/acceptance/schema/schema.rb +65 -19
  23. data/acceptance/test_helper.rb +1 -1
  24. data/activerecord-spanner-adapter.gemspec +1 -1
  25. data/examples/snippets/bit-reversed-sequence/README.md +103 -0
  26. data/examples/snippets/bit-reversed-sequence/Rakefile +13 -0
  27. data/examples/snippets/bit-reversed-sequence/application.rb +68 -0
  28. data/examples/snippets/bit-reversed-sequence/config/database.yml +8 -0
  29. data/examples/snippets/bit-reversed-sequence/db/migrate/01_create_tables.rb +33 -0
  30. data/examples/snippets/bit-reversed-sequence/db/schema.rb +31 -0
  31. data/examples/snippets/bit-reversed-sequence/db/seeds.rb +31 -0
  32. data/examples/snippets/bit-reversed-sequence/models/album.rb +11 -0
  33. data/examples/snippets/bit-reversed-sequence/models/singer.rb +15 -0
  34. data/examples/snippets/interleaved-tables/README.md +44 -53
  35. data/examples/snippets/interleaved-tables/Rakefile +2 -2
  36. data/examples/snippets/interleaved-tables/application.rb +2 -2
  37. data/examples/snippets/interleaved-tables/db/migrate/01_create_tables.rb +12 -18
  38. data/examples/snippets/interleaved-tables/db/schema.rb +9 -7
  39. data/examples/snippets/interleaved-tables/db/seeds.rb +1 -1
  40. data/examples/snippets/interleaved-tables/models/album.rb +3 -7
  41. data/examples/snippets/interleaved-tables/models/singer.rb +1 -1
  42. data/examples/snippets/interleaved-tables/models/track.rb +6 -7
  43. data/examples/snippets/interleaved-tables-before-7.1/README.md +167 -0
  44. data/examples/snippets/interleaved-tables-before-7.1/Rakefile +13 -0
  45. data/examples/snippets/interleaved-tables-before-7.1/application.rb +126 -0
  46. data/examples/snippets/interleaved-tables-before-7.1/config/database.yml +8 -0
  47. data/examples/snippets/interleaved-tables-before-7.1/db/migrate/01_create_tables.rb +44 -0
  48. data/examples/snippets/interleaved-tables-before-7.1/db/schema.rb +37 -0
  49. data/examples/snippets/interleaved-tables-before-7.1/db/seeds.rb +40 -0
  50. data/examples/snippets/interleaved-tables-before-7.1/models/album.rb +20 -0
  51. data/examples/snippets/interleaved-tables-before-7.1/models/singer.rb +18 -0
  52. data/examples/snippets/interleaved-tables-before-7.1/models/track.rb +28 -0
  53. data/examples/snippets/query-logs/README.md +43 -0
  54. data/examples/snippets/query-logs/Rakefile +13 -0
  55. data/examples/snippets/query-logs/application.rb +63 -0
  56. data/examples/snippets/query-logs/config/database.yml +8 -0
  57. data/examples/snippets/query-logs/db/migrate/01_create_tables.rb +21 -0
  58. data/examples/snippets/query-logs/db/schema.rb +31 -0
  59. data/examples/snippets/query-logs/db/seeds.rb +24 -0
  60. data/examples/snippets/query-logs/models/album.rb +9 -0
  61. data/examples/snippets/query-logs/models/singer.rb +9 -0
  62. data/lib/active_record/connection_adapters/spanner/column.rb +13 -0
  63. data/lib/active_record/connection_adapters/spanner/database_statements.rb +144 -35
  64. data/lib/active_record/connection_adapters/spanner/schema_cache.rb +3 -21
  65. data/lib/active_record/connection_adapters/spanner/schema_creation.rb +11 -0
  66. data/lib/active_record/connection_adapters/spanner/schema_definitions.rb +4 -0
  67. data/lib/active_record/connection_adapters/spanner/schema_statements.rb +3 -2
  68. data/lib/active_record/connection_adapters/spanner_adapter.rb +28 -9
  69. data/lib/activerecord_spanner_adapter/base.rb +58 -21
  70. data/lib/activerecord_spanner_adapter/information_schema.rb +33 -24
  71. data/lib/activerecord_spanner_adapter/primary_key.rb +1 -1
  72. data/lib/activerecord_spanner_adapter/table/column.rb +4 -9
  73. data/lib/activerecord_spanner_adapter/version.rb +1 -1
  74. data/lib/arel/visitors/spanner.rb +3 -1
  75. metadata +33 -4
@@ -0,0 +1,31 @@
1
+ # This file is auto-generated from the current state of the database. Instead
2
+ # of editing this file, please use the migrations feature of Active Record to
3
+ # incrementally modify your database, and then regenerate this schema definition.
4
+ #
5
+ # This file is the source Rails uses to define your schema when running `bin/rails
6
+ # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7
+ # be faster and is potentially less error prone than running all of your
8
+ # migrations from scratch. Old migrations may fail to apply correctly if those
9
+ # migrations use external dependencies or application code.
10
+ #
11
+ # It's strongly recommended that you check this file into your version control system.
12
+
13
+ ActiveRecord::Schema[7.1].define(version: 1) do
14
+ connection.start_batch_ddl
15
+
16
+ create_table "albums", force: :cascade do |t|
17
+ t.string "title"
18
+ t.integer "singer_id", limit: 8
19
+ end
20
+
21
+ create_table "singers", force: :cascade do |t|
22
+ t.string "first_name"
23
+ t.string "last_name"
24
+ end
25
+
26
+ add_foreign_key "albums", "singers"
27
+ connection.run_batch
28
+ rescue
29
+ abort_batch
30
+ raise
31
+ end
@@ -0,0 +1,24 @@
1
+ # Copyright 2023 Google LLC
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ require_relative "../../config/environment.rb"
8
+ require_relative "../models/singer"
9
+ require_relative "../models/album"
10
+
11
+ first_names = ["Pete", "Alice", "John", "Ethel", "Trudy", "Naomi", "Wendy", "Ruben", "Thomas", "Elly"]
12
+ last_names = ["Wendelson", "Allison", "Peterson", "Johnson", "Henderson", "Ericsson", "Aronson", "Tennet", "Courtou"]
13
+
14
+ adjectives = ["daily", "happy", "blue", "generous", "cooked", "bad", "open"]
15
+ nouns = ["windows", "potatoes", "bank", "street", "tree", "glass", "bottle"]
16
+
17
+ 5.times do
18
+ Singer.create first_name: first_names.sample, last_name: last_names.sample
19
+ end
20
+
21
+ 20.times do
22
+ singer_id = Singer.all.sample.id
23
+ Album.create title: "#{adjectives.sample} #{nouns.sample}", singer_id: singer_id
24
+ end
@@ -0,0 +1,9 @@
1
+ # Copyright 2023 Google LLC
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ class Album < ActiveRecord::Base
8
+ belongs_to :singer
9
+ end
@@ -0,0 +1,9 @@
1
+ # Copyright 2023 Google LLC
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ class Singer < ActiveRecord::Base
8
+ has_many :albums
9
+ end
@@ -10,6 +10,15 @@ module ActiveRecord
10
10
  module ConnectionAdapters
11
11
  module Spanner
12
12
  class Column < ConnectionAdapters::Column
13
+ # rubocop:disable Style/MethodDefParentheses
14
+ def initialize(name, default, sql_type_metadata = nil, null = true,
15
+ default_function = nil, collation: nil, comment: nil,
16
+ primary_key: false, **)
17
+ # rubocop:enable Style/MethodDefParentheses
18
+ super
19
+ @primary_key = primary_key
20
+ end
21
+
13
22
  def has_default? # rubocop:disable Naming/PredicateName
14
23
  super && !virtual?
15
24
  end
@@ -17,6 +26,10 @@ module ActiveRecord
17
26
  def virtual?
18
27
  sql_type_metadata.generated
19
28
  end
29
+
30
+ def primary_key?
31
+ @primary_key
32
+ end
20
33
  end
21
34
  end
22
35
  end
@@ -6,14 +6,34 @@
6
6
 
7
7
  # frozen_string_literal: true
8
8
 
9
+ require "active_record/gem_version"
10
+
9
11
  module ActiveRecord
10
12
  module ConnectionAdapters
11
13
  module Spanner
12
14
  module DatabaseStatements
15
+ VERSION_7_1_0 = Gem::Version.create "7.1.0"
16
+ RequestOptions = Google::Cloud::Spanner::V1::RequestOptions
17
+
13
18
  # DDL, DML and DQL Statements
14
19
 
15
20
  def execute sql, name = nil, binds = []
21
+ internal_execute sql, name, binds
22
+ end
23
+
24
+ def internal_exec_query sql, name = "SQL", binds = [], prepare: false, async: false
25
+ result = internal_execute sql, name, binds, prepare: prepare, async: async
26
+ ActiveRecord::Result.new(
27
+ result.fields.keys.map(&:to_s), result.rows.map(&:values)
28
+ )
29
+ end
30
+
31
+ def internal_execute sql, name = "SQL", binds = [],
32
+ prepare: false, async: false # rubocop:disable Lint/UnusedMethodArgument
16
33
  statement_type = sql_statement_type sql
34
+ # Call `transform` to invoke any query transformers that might have been registered.
35
+ sql = transform sql
36
+ append_request_tag_from_query_logs sql, binds
17
37
 
18
38
  if preventing_writes? && [:dml, :ddl].include?(statement_type)
19
39
  raise ActiveRecord::ReadOnlyError(
@@ -24,49 +44,127 @@ module ActiveRecord
24
44
  if statement_type == :ddl
25
45
  execute_ddl sql
26
46
  else
27
- transaction_required = statement_type == :dml
28
- materialize_transactions
29
-
30
- # First process and remove any hints in the binds that indicate that
31
- # a different read staleness should be used than the default.
32
- staleness_hint = binds.find { |b| b.is_a? Arel::Visitors::StalenessHint }
33
- if staleness_hint
34
- selector = Google::Cloud::Spanner::Session.single_use_transaction staleness_hint.value
35
- binds.delete staleness_hint
36
- end
37
- request_options = binds.find { |b| b.is_a? Google::Cloud::Spanner::V1::RequestOptions }
38
- if request_options
39
- binds.delete request_options
40
- end
47
+ execute_query_or_dml statement_type, sql, name, binds
48
+ end
49
+ end
50
+
51
+ def execute_query_or_dml statement_type, sql, name, binds
52
+ transaction_required = statement_type == :dml
53
+ materialize_transactions
54
+
55
+ # First process and remove any hints in the binds that indicate that
56
+ # a different read staleness should be used than the default.
57
+ staleness_hint = binds.find { |b| b.is_a? Arel::Visitors::StalenessHint }
58
+ if staleness_hint
59
+ selector = Google::Cloud::Spanner::Session.single_use_transaction staleness_hint.value
60
+ binds.delete staleness_hint
61
+ end
62
+ request_options = binds.find { |b| b.is_a? RequestOptions }
63
+ if request_options
64
+ binds.delete request_options
65
+ end
66
+
67
+ log_args = [sql, name]
68
+ log_args.concat [binds, type_casted_binds(binds)] if log_statement_binds
41
69
 
42
- log_args = [sql, name]
43
- log_args.concat [binds, type_casted_binds(binds)] if log_statement_binds
44
-
45
- log(*log_args) do
46
- types, params = to_types_and_params binds
47
- ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
48
- if transaction_required
49
- transaction do
50
- @connection.execute_query sql, params: params, types: types, request_options: request_options
51
- end
52
- else
53
- @connection.execute_query sql, params: params, types: types, single_use_selector: selector,
54
- request_options: request_options
70
+ log(*log_args) do
71
+ types, params = to_types_and_params binds
72
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
73
+ if transaction_required
74
+ transaction do
75
+ @connection.execute_query sql, params: params, types: types, request_options: request_options
55
76
  end
77
+ else
78
+ @connection.execute_query sql, params: params, types: types, single_use_selector: selector,
79
+ request_options: request_options
56
80
  end
57
81
  end
58
82
  end
59
83
  end
60
84
 
61
- def query sql, name = nil
62
- exec_query sql, name
85
+ def append_request_tag_from_query_logs sql, binds
86
+ legacy_formatter_prefix = "/*request_tag:true,"
87
+ sql_commenter_prefix = "/*request_tag='true',"
88
+ if sql.start_with? legacy_formatter_prefix
89
+ append_request_tag_from_query_logs_with_format sql, binds, legacy_formatter_prefix
90
+ elsif sql.start_with? sql_commenter_prefix
91
+ append_request_tag_from_query_logs_with_format sql, binds, sql_commenter_prefix
92
+ end
63
93
  end
64
94
 
65
- def exec_query sql, name = "SQL", binds = [], prepare: false # rubocop:disable Lint/UnusedMethodArgument
66
- result = execute sql, name, binds
67
- ActiveRecord::Result.new(
68
- result.fields.keys.map(&:to_s), result.rows.map(&:values)
69
- )
95
+ def append_request_tag_from_query_logs_with_format sql, binds, prefix
96
+ end_of_comment = sql.index "*/", prefix.length
97
+ return unless end_of_comment
98
+
99
+ request_tag = sql[prefix.length, end_of_comment - prefix.length]
100
+ options = binds.find { |bind| bind.is_a? RequestOptions } || RequestOptions.new
101
+ if options.request_tag == ""
102
+ options.request_tag = request_tag
103
+ else
104
+ options.request_tag += "," + request_tag
105
+ end
106
+
107
+ binds.append options
108
+ end
109
+
110
+ # The method signatures for executing queries and DML statements changed between Rails 7.0 and 7.1.
111
+
112
+ if ActiveRecord.gem_version >= VERSION_7_1_0
113
+ def sql_for_insert sql, pk, binds, returning
114
+ if pk && !_has_pk_binding(pk, binds)
115
+ # Add the primary key to the columns that should be returned if there is no value specified for it.
116
+ returning ||= []
117
+ returning |= if pk.respond_to? :each
118
+ pk
119
+ else
120
+ [pk]
121
+ end
122
+ end
123
+ if returning&.any?
124
+ returning_columns_statement = returning.map { |c| quote_column_name c }.join(", ")
125
+ sql = "#{sql} THEN RETURN #{returning_columns_statement}"
126
+ end
127
+
128
+ [sql, binds]
129
+ end
130
+
131
+ def query sql, name = nil
132
+ exec_query sql, name
133
+ end
134
+ else # ActiveRecord.gem_version < VERSION_7_1_0
135
+ def query sql, name = nil
136
+ exec_query sql, name
137
+ end
138
+
139
+ def exec_query sql, name = "SQL", binds = [], prepare: false # rubocop:disable Lint/UnusedMethodArgument
140
+ result = execute sql, name, binds
141
+ ActiveRecord::Result.new(
142
+ result.fields.keys.map(&:to_s), result.rows.map(&:values)
143
+ )
144
+ end
145
+
146
+ def sql_for_insert sql, pk, binds
147
+ if pk && !_has_pk_binding(pk, binds)
148
+ # Add the primary key to the columns that should be returned if there is no value specified for it.
149
+ returning_columns_statement = if pk.respond_to? :each
150
+ pk.map { |c| quote_column_name c }.join(", ")
151
+ else
152
+ quote_column_name pk
153
+ end
154
+ sql = "#{sql} THEN RETURN #{returning_columns_statement}" if returning_columns_statement
155
+ end
156
+ super
157
+ end
158
+ end
159
+
160
+ def _has_pk_binding pk, binds
161
+ if pk.respond_to? :each
162
+ has_value = true
163
+ pk.each { |col| has_value &&= binds.any? { |bind| bind.name == col } }
164
+ has_value
165
+ else
166
+ binds.any? { |bind| bind.name == pk }
167
+ end
70
168
  end
71
169
 
72
170
  def exec_mutation mutation
@@ -219,6 +317,9 @@ module ActiveRecord
219
317
  if bind.respond_to? :type
220
318
  type = ActiveRecord::Type::Spanner::SpannerActiveRecordConverter
221
319
  .convert_active_model_type_to_spanner(bind.type)
320
+ elsif bind.class == Symbol
321
+ # This ensures that for example :environment is sent as the string 'environment' to Cloud Spanner.
322
+ type = :STRING
222
323
  end
223
324
  [
224
325
  # Generates binds for named parameters in the format `@p1, @p2, ...`
@@ -226,7 +327,15 @@ module ActiveRecord
226
327
  ]
227
328
  end.to_h
228
329
  params = binds.enum_for(:each_with_index).map do |bind, i|
229
- type = bind.respond_to?(:type) ? bind.type : ActiveModel::Type::Integer
330
+ type = if bind.respond_to? :type
331
+ bind.type
332
+ elsif bind.class == Symbol
333
+ # This ensures that for example :environment is sent as the string 'environment' to Cloud Spanner.
334
+ :STRING
335
+ else
336
+ # The Cloud Spanner default type is INT64 if no other type is known.
337
+ ActiveModel::Type::Integer
338
+ end
230
339
  bind_value = bind.respond_to?(:value) ? bind.value : bind
231
340
  value = ActiveRecord::Type::Spanner::SpannerActiveRecordConverter
232
341
  .serialize_with_transaction_isolation_level(type, bind_value, :dml)
@@ -6,37 +6,19 @@
6
6
 
7
7
  module ActiveRecord
8
8
  module ConnectionAdapters
9
- class SpannerSchemaCache < SchemaCache
9
+ class SpannerSchemaCache
10
10
  def initialize conn
11
+ @connection = conn
11
12
  @primary_and_parent_keys = {}
12
- super
13
- end
14
-
15
- def initialize_dup other
16
- @primary_and_parent_keys = @primary_and_parent_keys.dup
17
- super
18
- end
19
-
20
- def encode_with coder
21
- coder["primary_and_parent_keys"] = @primary_and_parent_keys
22
- super
23
- end
24
-
25
- def init_with coder
26
- @primary_and_parent_keys = coder["primary_and_parent_keys"]
27
- super
28
13
  end
29
14
 
30
15
  def primary_and_parent_keys table_name
31
16
  @primary_and_parent_keys[table_name] ||=
32
- if data_source_exists? table_name
33
- connection.primary_and_parent_keys table_name
34
- end
17
+ @connection.primary_and_parent_keys table_name
35
18
  end
36
19
 
37
20
  def clear!
38
21
  @primary_and_parent_keys.clear
39
- super
40
22
  end
41
23
  end
42
24
  end
@@ -39,6 +39,17 @@ module ActiveRecord
39
39
 
40
40
  primary_keys = if o.primary_keys
41
41
  o.primary_keys
42
+ elsif o.options[:primary_key]
43
+ columns = if o.options[:primary_key].is_a? Array
44
+ o.options[:primary_key]
45
+ else
46
+ [o.options[:primary_key]]
47
+ end
48
+ pk_names = []
49
+ columns.each do |c|
50
+ pk_names.append c.to_s
51
+ end
52
+ PrimaryKeyDefinition.new pk_names
42
53
  else
43
54
  pk_names = o.columns.each_with_object [] do |c, r|
44
55
  if c.type == :primary_key || c.primary_key?
@@ -42,6 +42,10 @@ module ActiveRecord
42
42
 
43
43
  super
44
44
  end
45
+
46
+ def valid_column_definition_options
47
+ super + [:type, :array, :allow_commit_timestamp, :as, :stored, :parent_key, :passed_type, :index]
48
+ end
45
49
  end
46
50
 
47
51
  class Table < ActiveRecord::ConnectionAdapters::Table
@@ -109,7 +109,7 @@ module ActiveRecord
109
109
  information_schema { |i| i.table_columns table_name }
110
110
  end
111
111
 
112
- def new_column_from_field _table_name, field
112
+ def new_column_from_field _table_name, field, _definitions = nil
113
113
  Spanner::Column.new \
114
114
  field.name,
115
115
  field.default,
@@ -118,7 +118,8 @@ module ActiveRecord
118
118
  field.allow_commit_timestamp,
119
119
  field.generated),
120
120
  field.nullable,
121
- field.default_function
121
+ field.default_function,
122
+ primary_key: field.primary_key
122
123
  end
123
124
 
124
125
  def fetch_type_metadata sql_type, ordinal_position = nil, allow_commit_timestamp = nil, generated = nil
@@ -42,14 +42,6 @@ module ActiveRecord
42
42
  end
43
43
 
44
44
  module ConnectionAdapters
45
- module AbstractPool
46
- def get_schema_cache connection
47
- self.schema_cache ||= SpannerSchemaCache.new connection
48
- schema_cache.connection = connection
49
- schema_cache
50
- end
51
- end
52
-
53
45
  class SpannerAdapter < AbstractAdapter
54
46
  ADAPTER_NAME = "spanner".freeze
55
47
  NATIVE_DATABASE_TYPES = {
@@ -78,8 +70,10 @@ module ActiveRecord
78
70
  class_attribute :log_statement_binds, instance_writer: false, default: false
79
71
 
80
72
  def initialize connection, logger, connection_options, config
81
- super connection, logger, config
73
+ @connection = connection
82
74
  @connection_options = connection_options
75
+ super connection, logger, config
76
+ @raw_connection ||= connection
83
77
  end
84
78
 
85
79
  def max_identifier_length
@@ -117,6 +111,10 @@ module ActiveRecord
117
111
  end
118
112
  alias reconnect! reset!
119
113
 
114
+ def spanner_schema_cache
115
+ @spanner_schema_cache ||= SpannerSchemaCache.new self
116
+ end
117
+
120
118
  # Spanner Connection API
121
119
  delegate :ddl_batch, :ddl_batch?, :start_batch_ddl, :abort_batch, :run_batch, to: :@connection
122
120
 
@@ -186,6 +184,10 @@ module ActiveRecord
186
184
  SecureRandom.uuid.gsub("-", "").hex & 0x7FFFFFFFFFFFFFFF
187
185
  end
188
186
 
187
+ def return_value_after_insert? column
188
+ column.auto_incremented_by_db? || column.primary_key?
189
+ end
190
+
189
191
  def arel_visitor
190
192
  Arel::Visitors::Spanner.new self
191
193
  end
@@ -259,6 +261,23 @@ module ActiveRecord
259
261
  include TypeMapBuilder
260
262
  end
261
263
 
264
+ def transform sql
265
+ if ActiveRecord::VERSION::MAJOR >= 7
266
+ transform_query sql
267
+ else
268
+ sql
269
+ end
270
+ end
271
+
272
+ # Overwrite the standard log method to be able to translate exceptions.
273
+ def log sql, name = "SQL", binds = [], type_casted_binds = [], statement_name = nil, *args
274
+ super
275
+ rescue ActiveRecord::StatementInvalid
276
+ raise
277
+ rescue StandardError => e
278
+ raise translate_exception_class(e, sql, binds)
279
+ end
280
+
262
281
  def translate_exception exception, message:, sql:, binds:
263
282
  if exception.is_a? Google::Cloud::FailedPreconditionError
264
283
  case exception.message
@@ -4,6 +4,7 @@
4
4
  # license that can be found in the LICENSE file or at
5
5
  # https://opensource.org/licenses/MIT.
6
6
 
7
+ require "active_record/gem_version"
7
8
  require "activerecord_spanner_adapter/relation"
8
9
 
9
10
  module ActiveRecord
@@ -14,6 +15,8 @@ module ActiveRecord
14
15
  end
15
16
 
16
17
  class Base
18
+ VERSION_7_1 = Gem::Version.create "7.1.0"
19
+
17
20
  # Creates an object (or multiple objects) and saves it to the database. This method will use mutations instead
18
21
  # of DML if there is no active transaction, or if the active transaction has been created with the option
19
22
  # isolation: :buffered_mutations.
@@ -30,7 +33,9 @@ module ActiveRecord
30
33
  return super unless spanner_adapter?
31
34
  return super if active_transaction?
32
35
 
33
- transaction isolation: :buffered_mutations do
36
+ # Only use mutations to create new records if the primary key is generated client-side.
37
+ isolation = sequence_name ? nil : :buffered_mutations
38
+ transaction isolation: isolation do
34
39
  return super
35
40
  end
36
41
  end
@@ -43,35 +48,67 @@ module ActiveRecord
43
48
  spanner_adapter? && connection&.current_spanner_transaction&.isolation == :buffered_mutations
44
49
  end
45
50
 
46
- def self._insert_record values
51
+ def self._insert_record values, returning = []
47
52
  return super unless buffered_mutations? || (primary_key && values.is_a?(Hash))
48
53
 
49
- return _buffer_record values, :insert if buffered_mutations?
54
+ # Mutations cannot be used in combination with a sequence, as mutations do not support a THEN RETURN clause.
55
+ if buffered_mutations? && sequence_name
56
+ raise StatementInvalid, "Mutations cannot be used to create records that use a sequence " \
57
+ "to generate the primary key. #{self} uses #{sequence_name}."
58
+ end
50
59
 
51
- primary_key_value =
52
- if primary_key.is_a? Array
53
- _set_composite_primary_key_values primary_key, values
54
- else
55
- _set_single_primary_key_value primary_key, values
56
- end
60
+ return _buffer_record values, :insert, returning if buffered_mutations?
61
+
62
+ primary_key_value = _set_primary_key_value values
57
63
  if ActiveRecord::VERSION::MAJOR >= 7
58
64
  im = Arel::InsertManager.new arel_table
59
65
  im.insert(values.transform_keys { |name| arel_table[name] })
60
66
  else
61
67
  im = arel_table.compile_insert _substitute_values(values)
62
68
  end
63
- connection.insert(im, "#{self} Create", primary_key || false, primary_key_value)
69
+ result = connection.insert(im, "#{self} Create", primary_key || false, primary_key_value)
70
+
71
+ _convert_primary_key result, returning
64
72
  end
65
73
 
66
- def self._upsert_record values
67
- _buffer_record values, :insert_or_update
74
+ def self._set_primary_key_value values
75
+ if primary_key.is_a? Array
76
+ _set_composite_primary_key_values primary_key, values
77
+ else
78
+ _set_single_primary_key_value primary_key, values
79
+ end
80
+ end
81
+
82
+ def self._convert_primary_key primary_key_value, returning
83
+ # Rails 7.1 and higher supports composite primary keys, and therefore require the provider to return an array
84
+ # instead of a single value in all cases. The order of the values should be equal to the order of the returning
85
+ # columns (or the primary key if no returning columns were specified).
86
+ return primary_key_value if ActiveRecord.gem_version < VERSION_7_1
87
+ return primary_key_value if primary_key_value.is_a?(Array) && primary_key_value.length == 1
88
+ return [primary_key_value] unless primary_key_value.is_a? Array
89
+
90
+ # Re-order the returned values according to the returning columns if it is not equal to the primary key of the
91
+ # table.
92
+ keys = returning || primary_key
93
+ return primary_key_value if keys == primary_key
94
+
95
+ primary_key_values_hash = Hash[primary_key.zip(primary_key_value)]
96
+ values = []
97
+ keys.each do |column|
98
+ values.append primary_key_values_hash[column]
99
+ end
100
+ values
101
+ end
102
+
103
+ def self._upsert_record values, returning
104
+ _buffer_record values, :insert_or_update, returning
68
105
  end
69
106
 
70
- def self.insert_all _attributes, _returning: nil, _unique_by: nil
71
- raise NotImplementedError, "Cloud Spanner does not support skip_duplicates."
107
+ def self.insert_all _attributes, **_kwargs
108
+ raise NotImplementedError, "Cloud Spanner does not support skip_duplicates. Use insert! or upsert instead."
72
109
  end
73
110
 
74
- def self.insert_all! attributes, returning: nil
111
+ def self.insert_all! attributes, returning: nil, **_kwargs
75
112
  return super unless spanner_adapter?
76
113
  return super if active_transaction? && !buffered_mutations?
77
114
 
@@ -90,7 +127,7 @@ module ActiveRecord
90
127
  end
91
128
  end
92
129
 
93
- def self.upsert_all attributes, returning: nil, unique_by: nil
130
+ def self.upsert_all attributes, returning: nil, unique_by: nil, **_kwargs
94
131
  return super unless spanner_adapter?
95
132
  if active_transaction? && !buffered_mutations?
96
133
  raise NotImplementedError, "Cloud Spanner does not support upsert using DML. " \
@@ -102,18 +139,18 @@ module ActiveRecord
102
139
  # The mutations will be sent as one batch when the transaction is committed.
103
140
  if active_transaction?
104
141
  attributes.each do |record|
105
- _upsert_record record
142
+ _upsert_record record, returning
106
143
  end
107
144
  else
108
145
  transaction isolation: :buffered_mutations do
109
146
  attributes.each do |record|
110
- _upsert_record record
147
+ _upsert_record record, returning
111
148
  end
112
149
  end
113
150
  end
114
151
  end
115
152
 
116
- def self._buffer_record values, method
153
+ def self._buffer_record values, method, returning
117
154
  primary_key_value =
118
155
  if primary_key.is_a? Array
119
156
  _set_composite_primary_key_values primary_key, values
@@ -132,10 +169,9 @@ module ActiveRecord
132
169
  mutation = Google::Cloud::Spanner::V1::Mutation.new(
133
170
  "#{method}": write
134
171
  )
135
-
136
172
  connection.current_spanner_transaction.buffer mutation
137
173
 
138
- primary_key_value
174
+ _convert_primary_key primary_key_value, returning
139
175
  end
140
176
 
141
177
  def self._set_composite_primary_key_values primary_key, values
@@ -174,6 +210,7 @@ module ActiveRecord
174
210
  def self._set_single_primary_key_value primary_key, values
175
211
  primary_key_value = values[primary_key] || values[primary_key.to_sym]
176
212
 
213
+ return primary_key_value if sequence_name
177
214
  return primary_key_value unless prefetch_primary_key?
178
215
 
179
216
  if primary_key_value.nil?