activerecord-spanner-adapter 0.6.0 → 1.0.1

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 (55) 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 +1 -1
  5. data/.github/workflows/acceptance-tests-on-production.yaml +2 -2
  6. data/.github/workflows/ci.yaml +1 -1
  7. data/.github/workflows/release-please-label.yml +4 -4
  8. data/.github/workflows/release-please.yml +10 -9
  9. data/.github/workflows/rubocop.yaml +2 -2
  10. data/.release-please-manifest.json +3 -0
  11. data/.toys/release.rb +2 -2
  12. data/CHANGELOG.md +46 -23
  13. data/CONTRIBUTING.md +1 -1
  14. data/Gemfile +1 -1
  15. data/README.md +3 -3
  16. data/acceptance/cases/models/query_test.rb +24 -0
  17. data/acceptance/cases/type/all_types_test.rb +16 -14
  18. data/examples/rails/README.md +9 -9
  19. data/examples/snippets/generated-column/db/schema.rb +3 -3
  20. data/examples/snippets/hints/README.md +19 -0
  21. data/examples/snippets/{interleaved-tables → hints}/Rakefile +2 -2
  22. data/examples/snippets/hints/application.rb +47 -0
  23. data/examples/snippets/{interleaved-tables → hints}/config/database.yml +0 -0
  24. data/examples/snippets/hints/db/migrate/01_create_tables.rb +23 -0
  25. data/examples/snippets/{interleaved-tables → hints}/db/schema.rb +10 -14
  26. data/examples/snippets/{interleaved-tables → hints}/db/seeds.rb +0 -11
  27. data/examples/snippets/hints/models/album.rb +9 -0
  28. data/examples/snippets/hints/models/singer.rb +9 -0
  29. data/examples/snippets/partitioned-dml/README.md +16 -0
  30. data/examples/snippets/partitioned-dml/Rakefile +13 -0
  31. data/examples/snippets/partitioned-dml/application.rb +48 -0
  32. data/examples/snippets/partitioned-dml/config/database.yml +8 -0
  33. data/examples/snippets/partitioned-dml/db/migrate/01_create_tables.rb +21 -0
  34. data/examples/snippets/partitioned-dml/db/schema.rb +26 -0
  35. data/examples/snippets/partitioned-dml/db/seeds.rb +29 -0
  36. data/examples/snippets/partitioned-dml/models/album.rb +9 -0
  37. data/examples/snippets/partitioned-dml/models/singer.rb +9 -0
  38. data/lib/active_record/connection_adapters/spanner_adapter.rb +1 -1
  39. data/lib/active_record/tasks/spanner_database_tasks.rb +1 -1
  40. data/lib/active_record/type/spanner/array.rb +19 -5
  41. data/lib/activerecord_spanner_adapter/base.rb +31 -10
  42. data/lib/activerecord_spanner_adapter/connection.rb +46 -20
  43. data/lib/activerecord_spanner_adapter/information_schema.rb +2 -1
  44. data/lib/activerecord_spanner_adapter/transaction.rb +52 -21
  45. data/lib/activerecord_spanner_adapter/version.rb +1 -1
  46. data/lib/arel/visitors/spanner.rb +39 -0
  47. data/lib/spanner_client_ext.rb +4 -0
  48. data/release-please-config.json +19 -0
  49. metadata +24 -13
  50. data/examples/snippets/interleaved-tables/README.md +0 -152
  51. data/examples/snippets/interleaved-tables/application.rb +0 -109
  52. data/examples/snippets/interleaved-tables/db/migrate/01_create_tables.rb +0 -44
  53. data/examples/snippets/interleaved-tables/models/album.rb +0 -15
  54. data/examples/snippets/interleaved-tables/models/singer.rb +0 -20
  55. data/examples/snippets/interleaved-tables/models/track.rb +0 -25
@@ -0,0 +1,16 @@
1
+ # Sample - Partitioned DML
2
+
3
+ This example shows how to use Partitioned DML with the Spanner ActiveRecord adapter.
4
+
5
+ See https://cloud.google.com/spanner/docs/dml-partitioned for more information on Partitioned DML.
6
+
7
+ ## Running the Sample
8
+
9
+ The sample will automatically start a Spanner Emulator in a docker container and execute the sample
10
+ against that emulator. The emulator will automatically be stopped when the application finishes.
11
+
12
+ Run the application with the command
13
+
14
+ ```bash
15
+ bundle exec rake run
16
+ ```
@@ -0,0 +1,13 @@
1
+ # Copyright 2021 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"
8
+ require "sinatra/activerecord/rake"
9
+
10
+ desc "Sample showing how to work with Partitioned DML in ActiveRecord."
11
+ task :run do
12
+ Dir.chdir("..") { sh "bundle exec rake run[partitioned-dml]" }
13
+ end
@@ -0,0 +1,48 @@
1
+ # Copyright 2021 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 "io/console"
8
+ require_relative "../config/environment"
9
+ require_relative "models/singer"
10
+ require_relative "models/album"
11
+
12
+ class Application
13
+ def self.run
14
+ singer_count = Singer.all.count
15
+ album_count = Album.all.count
16
+ puts ""
17
+ puts "Singers in the database: #{singer_count}"
18
+ puts "Albums in the database: #{album_count}"
19
+
20
+ puts ""
21
+ puts "Deleting all albums in the database using Partitioned DML"
22
+ # Note that a Partitioned DML transaction can contain ONLY ONE DML statement.
23
+ # If we want to delete all data in two different tables, we need to do so in two different PDML transactions.
24
+ Album.transaction isolation: :pdml do
25
+ count = Album.delete_all
26
+ puts "Deleted #{count} albums"
27
+ end
28
+
29
+ puts ""
30
+ puts "Deleting all singers in the database using Partitioned DML"
31
+ Singer.transaction isolation: :pdml do
32
+ count = Singer.delete_all
33
+ puts "Deleted #{count} singers"
34
+ end
35
+
36
+ singer_count = Singer.all.count
37
+ album_count = Album.all.count
38
+ puts ""
39
+ puts "Singers in the database: #{singer_count}"
40
+ puts "Albums in the database: #{album_count}"
41
+
42
+ puts ""
43
+ puts "Press any key to end the application"
44
+ STDIN.getch
45
+ end
46
+ end
47
+
48
+ Application.run
@@ -0,0 +1,8 @@
1
+ development:
2
+ adapter: spanner
3
+ emulator_host: localhost:9010
4
+ project: test-project
5
+ instance: test-instance
6
+ database: testdb
7
+ pool: 5
8
+ timeout: 5000
@@ -0,0 +1,21 @@
1
+ # Copyright 2021 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 CreateTables < ActiveRecord::Migration[6.0]
8
+ def change
9
+ connection.ddl_batch do
10
+ create_table :singers do |t|
11
+ t.string :first_name, limit: 100
12
+ t.string :last_name, limit: 200, null: false
13
+ end
14
+
15
+ create_table :albums do |t|
16
+ t.string :title
17
+ t.references :singer, index: false, foreign_key: true
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
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.define(version: 1) do
14
+
15
+ create_table "albums", id: { limit: 8 }, force: :cascade do |t|
16
+ t.string "title"
17
+ t.integer "singer_id", limit: 8
18
+ end
19
+
20
+ create_table "singers", id: { limit: 8 }, force: :cascade do |t|
21
+ t.string "first_name", limit: 100
22
+ t.string "last_name", limit: 200, null: false
23
+ end
24
+
25
+ add_foreign_key "albums", "singers"
26
+ end
@@ -0,0 +1,29 @@
1
+ # Copyright 2021 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 = %w[Pete Alice John Ethel Trudy Naomi Wendy Ruben Thomas Elly]
12
+ last_names = %w[Wendelson Allison Peterson Johnson Henderson Ericsson Aronson Tennet Courtou]
13
+
14
+ adjectives = %w[daily happy blue generous cooked bad open]
15
+ nouns = %w[windows potatoes bank street tree glass bottle]
16
+
17
+ # This ensures all the records are inserted using one read/write transaction that will use mutations instead of DML.
18
+ ActiveRecord::Base.transaction isolation: :buffered_mutations do
19
+ singers = []
20
+ 5.times do
21
+ singers << Singer.create(first_name: first_names.sample, last_name: last_names.sample)
22
+ end
23
+
24
+ albums = []
25
+ 20.times do
26
+ singer = singers.sample
27
+ albums << Album.create(title: "#{adjectives.sample} #{nouns.sample}", singer: singer)
28
+ end
29
+ end
@@ -0,0 +1,9 @@
1
+ # Copyright 2021 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 2021 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
@@ -198,7 +198,7 @@ module ActiveRecord
198
198
 
199
199
  def register_array_types m
200
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)
201
+ m.register_type %r{^ARRAY<BYTES\((MAX|d+)\)>}i, Type::Spanner::Array.new(ActiveRecord::Type::Spanner::Bytes.new)
202
202
  m.register_type %r{^ARRAY<DATE>}i, Type::Spanner::Array.new(Type::Date.new)
203
203
  m.register_type %r{^ARRAY<FLOAT64>}i, Type::Spanner::Array.new(Type::Float.new)
204
204
  m.register_type %r{^ARRAY<NUMERIC>}i, Type::Spanner::Array.new(Type::Decimal.new)
@@ -18,7 +18,7 @@ module ActiveRecord
18
18
  @connection.create_database
19
19
  rescue Google::Cloud::Error => error
20
20
  if error.instance_of? Google::Cloud::AlreadyExistsError
21
- raise ActiveRecord::Tasks::DatabaseAlreadyExists
21
+ raise ActiveRecord::DatabaseAlreadyExists
22
22
  end
23
23
 
24
24
  raise error
@@ -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
@@ -15,7 +15,17 @@ module ActiveRecord
15
15
  # Creates an object (or multiple objects) and saves it to the database. This method will use mutations instead
16
16
  # of DML if there is no active transaction, or if the active transaction has been created with the option
17
17
  # isolation: :buffered_mutations.
18
+ def self.create! attributes = nil, &block
19
+ return super unless spanner_adapter?
20
+ return super if active_transaction?
21
+
22
+ transaction isolation: :buffered_mutations do
23
+ return super
24
+ end
25
+ end
26
+
18
27
  def self.create attributes = nil, &block
28
+ return super unless spanner_adapter?
19
29
  return super if active_transaction?
20
30
 
21
31
  transaction isolation: :buffered_mutations do
@@ -23,8 +33,16 @@ module ActiveRecord
23
33
  end
24
34
  end
25
35
 
36
+ def self.spanner_adapter?
37
+ connection.adapter_name == "spanner"
38
+ end
39
+
40
+ def self.buffered_mutations?
41
+ spanner_adapter? && connection&.current_spanner_transaction&.isolation == :buffered_mutations
42
+ end
43
+
26
44
  def self._insert_record values
27
- return super unless Base.connection&.current_spanner_transaction&.isolation == :buffered_mutations
45
+ return super unless buffered_mutations?
28
46
 
29
47
  primary_key = self.primary_key
30
48
  primary_key_value = nil
@@ -48,7 +66,7 @@ module ActiveRecord
48
66
  values: [grpc_values.list_value]
49
67
  )
50
68
  )
51
- Base.connection.current_spanner_transaction.buffer mutation
69
+ connection.current_spanner_transaction.buffer mutation
52
70
 
53
71
  primary_key_value
54
72
  end
@@ -56,6 +74,7 @@ module ActiveRecord
56
74
  # Deletes all records of this class. This method will use mutations instead of DML if there is no active
57
75
  # transaction, or if the active transaction has been created with the option isolation: :buffered_mutations.
58
76
  def self.delete_all
77
+ return super unless spanner_adapter?
59
78
  return super if active_transaction?
60
79
 
61
80
  transaction isolation: :buffered_mutations do
@@ -72,7 +91,8 @@ module ActiveRecord
72
91
  # of DML if there is no active transaction, or if the active transaction has been created with the option
73
92
  # isolation: :buffered_mutations.
74
93
  def update attributes
75
- return super if Base.active_transaction?
94
+ return super unless self.class.spanner_adapter?
95
+ return super if self.class.active_transaction?
76
96
 
77
97
  transaction isolation: :buffered_mutations do
78
98
  return super
@@ -83,7 +103,8 @@ module ActiveRecord
83
103
  # of DML if there is no active transaction, or if the active transaction has been created with the option
84
104
  # isolation: :buffered_mutations.
85
105
  def destroy
86
- return super if Base.active_transaction?
106
+ return super unless self.class.spanner_adapter?
107
+ return super if self.class.active_transaction?
87
108
 
88
109
  transaction isolation: :buffered_mutations do
89
110
  return super
@@ -110,7 +131,7 @@ module ActiveRecord
110
131
  private_class_method :_create_grpc_values_for_insert
111
132
 
112
133
  def _update_row attribute_names, attempted_action = "update"
113
- return super unless Base.connection&.current_spanner_transaction&.isolation == :buffered_mutations
134
+ return super unless self.class.buffered_mutations?
114
135
 
115
136
  if locking_enabled?
116
137
  _execute_version_check attempted_action
@@ -129,7 +150,7 @@ module ActiveRecord
129
150
  values: [grpc_values.list_value]
130
151
  )
131
152
  )
132
- Base.connection.current_spanner_transaction.buffer mutation
153
+ self.class.connection.current_spanner_transaction.buffer mutation
133
154
  1 # Affected rows
134
155
  end
135
156
 
@@ -161,13 +182,13 @@ module ActiveRecord
161
182
  end
162
183
 
163
184
  def destroy_row
164
- return super unless Base.connection&.current_spanner_transaction&.isolation == :buffered_mutations
185
+ return super unless self.class.buffered_mutations?
165
186
 
166
187
  _delete_row
167
188
  end
168
189
 
169
190
  def _delete_row
170
- return super unless Base.connection&.current_spanner_transaction&.isolation == :buffered_mutations
191
+ return super unless self.class.buffered_mutations?
171
192
  if locking_enabled?
172
193
  _execute_version_check "destroy"
173
194
  end
@@ -186,7 +207,7 @@ module ActiveRecord
186
207
  key_set: { keys: [list_value] }
187
208
  )
188
209
  )
189
- Base.connection.current_spanner_transaction.buffer mutation
210
+ self.class.connection.current_spanner_transaction.buffer mutation
190
211
  1 # Affected rows
191
212
  end
192
213
 
@@ -210,7 +231,7 @@ module ActiveRecord
210
231
  "WHERE `#{self.class.primary_key}` = @id AND `#{locking_column}` = @lock_version"
211
232
  params = { "id" => id_in_database, "lock_version" => previous_lock_value }
212
233
  param_types = { "id" => :INT64, "lock_version" => :INT64 }
213
- locked_row = Base.connection.raw_connection.execute_query sql, params: params, types: param_types
234
+ locked_row = self.class.connection.raw_connection.execute_query sql, params: params, types: param_types
214
235
  raise ActiveRecord::StaleObjectError.new(self, attempted_action) unless locked_row.rows.any?
215
236
  end
216
237
  end
@@ -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
@@ -30,28 +30,37 @@ module ActiveRecordSpannerAdapter
30
30
  @mutations << mutation
31
31
  end
32
32
 
33
+ # Begins the transaction.
34
+ #
35
+ # Read-only and PDML transactions are started by executing a BeginTransaction RPC.
36
+ # Read/write transactions are not really started by this method, and instead a
37
+ # transaction selector is prepared that will be included with the first statement
38
+ # on the transaction.
33
39
  def begin
34
40
  raise "Nested transactions are not allowed" if @state != :INITIALIZED
35
41
  begin
36
- @grpc_transaction =
37
- case @isolation
38
- when Hash
39
- if @isolation[:timestamp]
40
- @connection.session.create_snapshot timestamp: @isolation[:timestamp]
41
- elsif @isolation[:staleness]
42
- @connection.session.create_snapshot staleness: @isolation[:staleness]
43
- elsif @isolation[:strong]
44
- @connection.session.create_snapshot strong: true
45
- else
46
- raise "Invalid snapshot argument: #{@isolation}"
47
- end
48
- when :read_only
49
- @connection.session.create_snapshot strong: true
50
- when :pdml
51
- @connection.session.create_pdml
42
+ case @isolation
43
+ when Hash
44
+ if @isolation[:timestamp]
45
+ @grpc_transaction = @connection.session.create_snapshot timestamp: @isolation[:timestamp]
46
+ elsif @isolation[:staleness]
47
+ @grpc_transaction = @connection.session.create_snapshot staleness: @isolation[:staleness]
48
+ elsif @isolation[:strong]
49
+ @grpc_transaction = @connection.session.create_snapshot strong: true
52
50
  else
53
- @connection.session.create_transaction
51
+ raise "Invalid snapshot argument: #{@isolation}"
54
52
  end
53
+ when :read_only
54
+ @grpc_transaction = @connection.session.create_snapshot strong: true
55
+ when :pdml
56
+ @grpc_transaction = @connection.session.create_pdml
57
+ else
58
+ @begin_transaction_selector = Google::Spanner::V1::TransactionSelector.new \
59
+ begin: Google::Spanner::V1::TransactionOptions.new(
60
+ read_write: Google::Spanner::V1::TransactionOptions::ReadWrite.new
61
+ )
62
+
63
+ end
55
64
  @state = :STARTED
56
65
  rescue Google::Cloud::NotFoundError => e
57
66
  if @connection.session_not_found? e
@@ -66,6 +75,12 @@ module ActiveRecordSpannerAdapter
66
75
  end
67
76
  end
68
77
 
78
+ # Forces a BeginTransaction RPC for a read/write transaction. This is used by a
79
+ # connection if the first statement of a transaction failed.
80
+ def force_begin_read_write
81
+ @grpc_transaction = @connection.session.create_transaction
82
+ end
83
+
69
84
  def next_sequence_number
70
85
  @sequence_number += 1 if @committable
71
86
  end
@@ -74,7 +89,10 @@ module ActiveRecordSpannerAdapter
74
89
  raise "This transaction is not active" unless active?
75
90
 
76
91
  begin
77
- @connection.session.commit_transaction @grpc_transaction, @mutations if @committable
92
+ # Start a transaction with an explicit BeginTransaction RPC if the transaction only contains mutations.
93
+ force_begin_read_write if @committable && !@mutations.empty? && !@grpc_transaction
94
+
95
+ @connection.session.commit_transaction @grpc_transaction, @mutations if @committable && @grpc_transaction
78
96
  @state = :COMMITTED
79
97
  rescue Google::Cloud::NotFoundError => e
80
98
  if @connection.session_not_found? e
@@ -93,7 +111,7 @@ module ActiveRecordSpannerAdapter
93
111
  def rollback
94
112
  # Allow rollback after abort and/or a failed commit.
95
113
  raise "This transaction is not active" unless active? || @state == :FAILED || @state == :ABORTED
96
- if active?
114
+ if active? && @grpc_transaction
97
115
  # We do a shoot-and-forget rollback here, as the error that caused the transaction to be rolled back could
98
116
  # also have invalidated the transaction (e.g. `Session not found`). If the rollback fails for any other
99
117
  # reason, we also do not need to retry it or propagate the error to the application, as the transaction will
@@ -113,11 +131,24 @@ module ActiveRecordSpannerAdapter
113
131
  @state = :ABORTED
114
132
  end
115
133
 
134
+ # Sets the underlying gRPC transaction to use for this Transaction.
135
+ # This is used for queries/DML statements that inlined the BeginTransaction option and returned
136
+ # a transaction in the metadata.
137
+ def grpc_transaction= grpc
138
+ @grpc_transaction = Google::Cloud::Spanner::Transaction.from_grpc grpc, @connection.session
139
+ end
140
+
116
141
  def transaction_selector
117
142
  return unless active?
118
143
 
119
- Google::Spanner::V1::TransactionSelector.new \
120
- id: @grpc_transaction.transaction_id
144
+ # Use the transaction that has been started by a BeginTransaction RPC or returned by a
145
+ # statement, if present.
146
+ return Google::Spanner::V1::TransactionSelector.new id: @grpc_transaction.transaction_id \
147
+ if @grpc_transaction
148
+
149
+ # Return a transaction selector that will instruct the statement to also start a transaction
150
+ # and return its id as a side effect.
151
+ @begin_transaction_selector
121
152
  end
122
153
  end
123
154
  end
@@ -5,5 +5,5 @@
5
5
  # https://opensource.org/licenses/MIT.
6
6
 
7
7
  module ActiveRecordSpannerAdapter
8
- VERSION = "0.6.0".freeze
8
+ VERSION = "1.0.1".freeze
9
9
  end
@@ -14,11 +14,24 @@ module Arel # :nodoc: all
14
14
  end
15
15
  end
16
16
 
17
+ class StatementHint
18
+ attr_reader :value
19
+
20
+ def initialize value
21
+ @value = value
22
+ end
23
+ end
24
+
17
25
  class Spanner < Arel::Visitors::ToSql
18
26
  def compile node, collector = Arel::Collectors::SQLString.new
19
27
  collector.class.module_eval { attr_accessor :hints }
28
+ collector.class.module_eval { attr_accessor :table_hints }
29
+ collector.class.module_eval { attr_accessor :join_hints }
20
30
  collector.hints = {}
31
+ collector.table_hints = {}
32
+ collector.join_hints = {}
21
33
  sql, binds = accept(node, collector).value
34
+ sql = collector.hints[:statement_hint].value + sql if collector.hints[:statement_hint]
22
35
  binds << collector.hints[:staleness] if collector.hints[:staleness]
23
36
  [sql, binds]
24
37
  end
@@ -32,9 +45,25 @@ module Arel # :nodoc: all
32
45
  BIND_BLOCK
33
46
  end
34
47
 
48
+ def visit_table_hint v, collector
49
+ value = v.delete_prefix("table_hint:").strip
50
+ # TODO: This does not support FORCE_INDEX hints that reference an index that contains '@{' in the name.
51
+ start_of_hint_index = value.rindex "@{"
52
+ table_name = value[0, start_of_hint_index]
53
+ table_hint = value[start_of_hint_index, value.length]
54
+ collector.table_hints[table_name] = table_hint if table_name && table_hint
55
+ end
56
+
57
+ def visit_statement_hint v, collector
58
+ collector.hints[:statement_hint] = \
59
+ StatementHint.new v.delete_prefix("statement_hint:")
60
+ end
61
+
35
62
  # rubocop:disable Naming/MethodName
36
63
  def visit_Arel_Nodes_OptimizerHints o, collector
37
64
  o.expr.each do |v|
65
+ visit_table_hint v, collector if v.start_with? "table_hint:"
66
+ visit_statement_hint v, collector if v.start_with? "statement_hint:"
38
67
  if v.start_with? "max_staleness:"
39
68
  collector.hints[:staleness] = \
40
69
  StalenessHint.new max_staleness: v.delete_prefix("max_staleness:").to_f
@@ -59,6 +88,16 @@ module Arel # :nodoc: all
59
88
  collector
60
89
  end
61
90
 
91
+ def visit_Arel_Table o, collector
92
+ return super unless collector.table_hints[o.name]
93
+ if o.table_alias
94
+ collector << quote_table_name(o.name) << collector.table_hints[o.name] \
95
+ << " " << quote_table_name(o.table_alias)
96
+ else
97
+ collector << quote_table_name(o.name) << collector.table_hints[o.name]
98
+ end
99
+ end
100
+
62
101
  def visit_Arel_Nodes_BindParam o, collector
63
102
  # Do not generate a query parameter if the value should be set to the PENDING_COMMIT_TIMESTAMP(), as that is
64
103
  # not supported as a parameter value by Cloud Spanner.
@@ -95,6 +95,10 @@ module Google
95
95
  end
96
96
  end
97
97
 
98
+ class Results
99
+ attr_reader :metadata
100
+ end
101
+
98
102
  class Transaction
99
103
  attr_accessor :seqno, :commit
100
104
  end