activerecord-spanner-adapter 1.5.0 → 1.6.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 (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?