activerecord-spanner-adapter 0.6.0 → 1.0.1

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