activerecord-spanner-adapter 0.5.0 → 0.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/acceptance-tests-on-production.yaml +13 -0
  3. data/CHANGELOG.md +9 -0
  4. data/acceptance/cases/transactions/read_only_transactions_test.rb +63 -0
  5. data/acceptance/cases/type/all_types_test.rb +23 -3
  6. data/acceptance/cases/type/json_test.rb +34 -0
  7. data/acceptance/schema/schema.rb +4 -0
  8. data/acceptance/test_helper.rb +1 -0
  9. data/activerecord-spanner-adapter.gemspec +1 -1
  10. data/examples/snippets/read-only-transactions/application.rb +29 -1
  11. data/examples/snippets/read-only-transactions/db/schema.rb +4 -4
  12. data/examples/snippets/stale-reads/README.md +27 -0
  13. data/examples/snippets/stale-reads/Rakefile +13 -0
  14. data/examples/snippets/stale-reads/application.rb +63 -0
  15. data/examples/snippets/stale-reads/config/database.yml +8 -0
  16. data/examples/snippets/stale-reads/db/migrate/01_create_tables.rb +21 -0
  17. data/examples/snippets/stale-reads/db/schema.rb +26 -0
  18. data/examples/snippets/stale-reads/db/seeds.rb +24 -0
  19. data/examples/snippets/stale-reads/models/album.rb +9 -0
  20. data/examples/snippets/stale-reads/models/singer.rb +9 -0
  21. data/lib/active_record/connection_adapters/spanner/database_statements.rb +26 -3
  22. data/lib/active_record/connection_adapters/spanner_adapter.rb +4 -1
  23. data/lib/active_record/type/spanner/spanner_active_record_converter.rb +1 -0
  24. data/lib/activerecord_spanner_adapter/connection.rb +2 -2
  25. data/lib/activerecord_spanner_adapter/transaction.rb +14 -4
  26. data/lib/activerecord_spanner_adapter/version.rb +1 -1
  27. data/lib/arel/visitors/spanner.rb +39 -2
  28. data/lib/spanner_client_ext.rb +21 -0
  29. metadata +14 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3761fdcb057da7fcec3150ec38e865bdb61d50eff368634146eefff6fb2bea27
4
- data.tar.gz: cebbacfbbce4943f0d4cc799dfa2abc6deb352c2069059e6aac869293063eb6c
3
+ metadata.gz: 55f265a14ddd7a176378a49090cbb3277aae66ce2c74913978ec9614289759fc
4
+ data.tar.gz: ae48998a381c0142063ac4499c8a0c13ece58bded34a72cbcc0371541ee02231
5
5
  SHA512:
6
- metadata.gz: 6aeefe0da3b1c627a2e5319b84f7d1519630421d773420197810b3e619e3f766e02ccd67cc89e9daf7ddee14985eb7aec371d1a685a5ff27004a57864f5e7ef4
7
- data.tar.gz: 0c807717d9d42ee7624a4e0710626718a89f0a51a2e3c968001f3d01587dd13448ac57c5569af3d82d417305bfa63f46cb932cae8d007735f68fde080d4e119e
6
+ metadata.gz: fa104b38f89edc91eb78d05b5f89e232fb50847953061eae4e3c9eb12bf307f9a0cf69545eb06a0682c92b2ac7bce065b8d88f8fa148570b4720b47ccccad0d5
7
+ data.tar.gz: 3de12a8b087940d6234ecd902ea7b109a8319960700dd323c363da266aa299f211b003e50fb01e24ff5e4632e4372b5451e0d129168cacfa3541771263dc2703
@@ -5,7 +5,20 @@ on:
5
5
  pull_request:
6
6
  name: acceptance tests on production
7
7
  jobs:
8
+ check-env:
9
+ outputs:
10
+ has-key: ${{ steps.project-id.outputs.defined }}
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - id: project-id
14
+ env:
15
+ GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
16
+ if: "${{ env.GCP_PROJECT_ID != '' }}"
17
+ run: echo "::set-output name=defined::true"
18
+
8
19
  test:
20
+ needs: [check-env]
21
+ if: needs.check-env.outputs.has-key == 'true'
9
22
  runs-on: ubuntu-latest
10
23
 
11
24
  strategy:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0](https://www.github.com/googleapis/ruby-spanner-activerecord/compare/activerecord-spanner-adapter/v0.5.0...activerecord-spanner-adapter/v0.6.0) (2021-09-09)
4
+
5
+
6
+ ### Features
7
+
8
+ * support JSON data type ([#123](https://www.github.com/googleapis/ruby-spanner-activerecord/issues/123)) ([d177ddf](https://www.github.com/googleapis/ruby-spanner-activerecord/commit/d177ddfc7326f02189bd4054571564b94d162b02))
9
+ * support single stale reads ([#127](https://www.github.com/googleapis/ruby-spanner-activerecord/issues/127)) ([a600628](https://www.github.com/googleapis/ruby-spanner-activerecord/commit/a600628267355b808f478ed543bc505e73f95d4a))
10
+ * support stale reads in read-only transactions ([#126](https://www.github.com/googleapis/ruby-spanner-activerecord/issues/126)) ([8bf7730](https://www.github.com/googleapis/ruby-spanner-activerecord/commit/8bf77300283c01e951725dd5e457270db20e98d2))
11
+
3
12
  ## 0.5.0 (2021-08-31)
4
13
 
5
14
 
@@ -35,6 +35,69 @@ module ActiveRecord
35
35
  end
36
36
  end
37
37
 
38
+ def test_read_in_snapshot_at_timestamp
39
+ # Get a valid timestamp from the server to use for the transaction.
40
+ timestamp = ActiveRecord::Base.connection.select_all("SELECT CURRENT_TIMESTAMP AS ts")[0]["ts"]
41
+ Base.transaction isolation: { timestamp: timestamp } do
42
+ org = Organization.find @organization.id
43
+ assert_equal "Organization 1", org.name
44
+ end
45
+ end
46
+
47
+ def test_read_in_snapshot_with_staleness
48
+ Base.transaction isolation: { staleness: 1 } do
49
+ begin
50
+ # It could be that the record or even the table cannot be found, as the read timestamp could be
51
+ # before either of them were created, but the record could also be found, all depending on the execution
52
+ # speed of the test. All those scenarios are valid.
53
+ org = Organization.find @organization.id
54
+ assert_equal "Organization 1", org.name
55
+ rescue => e
56
+ assert e.message.include?("Table not found") || e.message.include?("Couldn't find Organization"), e.message
57
+ end
58
+ end
59
+ end
60
+
61
+ def test_single_read_at_timestamp
62
+ # Get a valid timestamp from the server to use for the transaction.
63
+ timestamp = ActiveRecord::Base.connection.select_all("SELECT CURRENT_TIMESTAMP AS ts")[0]["ts"]
64
+
65
+ org = Organization.optimizer_hints("read_timestamp:#{timestamp.xmlschema(9)}").find @organization.id
66
+ assert_equal "Organization 1", org.name
67
+ end
68
+
69
+ def test_single_read_at_min_read_timestamp
70
+ # Get a valid timestamp from the server to use for the transaction.
71
+ timestamp = ActiveRecord::Base.connection.select_all("SELECT CURRENT_TIMESTAMP AS ts")[0]["ts"]
72
+
73
+ org = Organization.optimizer_hints("min_read_timestamp:#{timestamp.xmlschema(9)}").find @organization.id
74
+ assert_equal "Organization 1", org.name
75
+ end
76
+
77
+ def test_single_read_with_max_staleness
78
+ begin
79
+ # It could be that the record or even the table cannot be found, as the read timestamp could be
80
+ # before either of them were created, but the record could also be found, all depending on the execution
81
+ # speed of the test. All those scenarios are valid.
82
+ org = Organization.optimizer_hints("max_staleness: 1").find @organization.id
83
+ assert_equal "Organization 1", org.name
84
+ rescue => e
85
+ assert e.message.include?("Table not found") || e.message.include?("Couldn't find Organization"), e.message
86
+ end
87
+ end
88
+
89
+ def test_single_read_with_exact_staleness
90
+ begin
91
+ # It could be that the record or even the table cannot be found, as the read timestamp could be
92
+ # before either of them were created, but the record could also be found, all depending on the execution
93
+ # speed of the test. All those scenarios are valid.
94
+ org = Organization.optimizer_hints("exact_staleness: 1").find @organization.id
95
+ assert_equal "Organization 1", org.name
96
+ rescue => e
97
+ assert e.message.include?("Table not found") || e.message.include?("Couldn't find Organization"), e.message
98
+ end
99
+ end
100
+
38
101
  def test_snapshot_does_not_see_new_changes
39
102
  Base.transaction isolation: :read_only do
40
103
  org = Organization.find @organization.id
@@ -30,6 +30,7 @@ module ActiveRecord
30
30
  AllTypes.create col_string: "string", col_int64: 100, col_float64: 3.14, col_numeric: 6.626, col_bool: true,
31
31
  col_bytes: StringIO.new("bytes"), col_date: ::Date.new(2021, 6, 23),
32
32
  col_timestamp: ::Time.new(2021, 6, 23, 17, 8, 21, "+02:00"),
33
+ col_json: ENV["SPANNER_EMULATOR_HOST"] ? "" : { kind: "user_renamed", change: %w[jack john]},
33
34
  col_array_string: ["string1", nil, "string2"],
34
35
  col_array_int64: [100, nil, 200],
35
36
  col_array_float64: [3.14, nil, 2.0/3.0],
@@ -38,7 +39,10 @@ module ActiveRecord
38
39
  col_array_bytes: [StringIO.new("bytes1"), nil, StringIO.new("bytes2")],
39
40
  col_array_date: [::Date.new(2021, 6, 23), nil, ::Date.new(2021, 6, 24)],
40
41
  col_array_timestamp: [::Time.new(2021, 6, 23, 17, 8, 21, "+02:00"), nil, \
41
- ::Time.new(2021, 6, 24, 17, 8, 21, "+02:00")]
42
+ ::Time.new(2021, 6, 24, 17, 8, 21, "+02:00")],
43
+ col_array_json: ENV["SPANNER_EMULATOR_HOST"] ? [""] : \
44
+ [{ kind: "user_renamed", change: %w[jack john]}, nil, \
45
+ { kind: "user_renamed", change: %w[alice meredith]}]
42
46
  end
43
47
 
44
48
  def test_create_record
@@ -61,6 +65,8 @@ module ActiveRecord
61
65
  assert_equal StringIO.new("bytes").read, record.col_bytes.read
62
66
  assert_equal ::Date.new(2021, 6, 23), record.col_date
63
67
  assert_equal ::Time.new(2021, 6, 23, 17, 8, 21, "+02:00").utc, record.col_timestamp.utc
68
+ assert_equal ({"kind" => "user_renamed", "change" => %w[jack john]}),
69
+ record.col_json unless ENV["SPANNER_EMULATOR_HOST"]
64
70
 
65
71
  assert_equal ["string1", nil, "string2"], record.col_array_string
66
72
  assert_equal [100, nil, 200], record.col_array_int64
@@ -74,6 +80,10 @@ module ActiveRecord
74
80
  nil, \
75
81
  ::Time.new(2021, 6, 24, 17, 8, 21, "+02:00")].map { |timestamp| timestamp&.utc },
76
82
  record.col_array_timestamp.map { |timestamp| timestamp&.utc}
83
+ assert_equal [{"kind" => "user_renamed", "change" => %w[jack john]}, \
84
+ nil, \
85
+ {"kind" => "user_renamed", "change" => %w[alice meredith]}],
86
+ record.col_array_json unless ENV["SPANNER_EMULATOR_HOST"]
77
87
  end
78
88
  end
79
89
 
@@ -88,6 +98,7 @@ module ActiveRecord
88
98
  col_bool: false, col_bytes: StringIO.new("new bytes"),
89
99
  col_date: ::Date.new(2021, 6, 28),
90
100
  col_timestamp: ::Time.new(2021, 6, 28, 11, 22, 21, "+02:00"),
101
+ col_json: ENV["SPANNER_EMULATOR_HOST"] ? "" : { kind: "user_created", change: %w[jack alice]},
91
102
  col_array_string: ["new string 1", "new string 2"],
92
103
  col_array_int64: [300, 200, 100],
93
104
  col_array_float64: [1.1, 2.2, 3.3],
@@ -95,7 +106,10 @@ module ActiveRecord
95
106
  col_array_bool: [false, true, false],
96
107
  col_array_bytes: [StringIO.new("new bytes 1"), StringIO.new("new bytes 2")],
97
108
  col_array_date: [::Date.new(2021, 6, 28)],
98
- col_array_timestamp: [::Time.utc(2020, 12, 31, 0, 0, 0)]
109
+ col_array_timestamp: [::Time.utc(2020, 12, 31, 0, 0, 0)],
110
+ col_array_json: ENV["SPANNER_EMULATOR_HOST"] ?
111
+ [""] : \
112
+ [{ kind: "user_created", change: %w[jack alice]}]
99
113
  end
100
114
 
101
115
  # Verify that the record was updated.
@@ -108,6 +122,8 @@ module ActiveRecord
108
122
  assert_equal StringIO.new("new bytes").read, record.col_bytes.read
109
123
  assert_equal ::Date.new(2021, 6, 28), record.col_date
110
124
  assert_equal ::Time.new(2021, 6, 28, 11, 22, 21, "+02:00").utc, record.col_timestamp.utc
125
+ assert_equal ({"kind" => "user_created", "change" => %w[jack alice]}),
126
+ record.col_json unless ENV["SPANNER_EMULATOR_HOST"]
111
127
 
112
128
  assert_equal ["new string 1", "new string 2"], record.col_array_string
113
129
  assert_equal [300, 200, 100], record.col_array_int64
@@ -118,6 +134,8 @@ module ActiveRecord
118
134
  record.col_array_bytes.map(&:read)
119
135
  assert_equal [::Date.new(2021, 6, 28)], record.col_array_date
120
136
  assert_equal [::Time.utc(2020, 12, 31, 0, 0, 0)], record.col_array_timestamp.map(&:utc)
137
+ assert_equal [{"kind" => "user_created", "change" => %w[jack alice]}],
138
+ record.col_array_json unless ENV["SPANNER_EMULATOR_HOST"]
121
139
  end
122
140
  end
123
141
 
@@ -133,7 +151,8 @@ module ActiveRecord
133
151
  col_array_bool: [],
134
152
  col_array_bytes: [],
135
153
  col_array_date: [],
136
- col_array_timestamp: []
154
+ col_array_timestamp: [],
155
+ col_array_json: []
137
156
  end
138
157
 
139
158
  record = AllTypes.find record.id
@@ -145,6 +164,7 @@ module ActiveRecord
145
164
  assert_equal [], record.col_array_bytes
146
165
  assert_equal [], record.col_array_date
147
166
  assert_equal [], record.col_array_timestamp
167
+ assert_equal [], record.col_array_json
148
168
  end
149
169
  end
150
170
  end
@@ -0,0 +1,34 @@
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
+ # frozen_string_literal: true
8
+
9
+ require "test_helper"
10
+
11
+ module ActiveRecord
12
+ module Type
13
+ class DateTest < SpannerAdapter::TestCase
14
+ include SpannerAdapter::Types::TestHelper
15
+
16
+ def test_convert_to_sql_type
17
+ assert_equal "JSON", connection.type_to_sql(:json)
18
+ end
19
+
20
+ def test_set_json
21
+ return if ENV["SPANNER_EMULATOR_HOST"]
22
+
23
+ expected_hash = {"key"=>"value", "array_key"=>%w[value1 value2]}
24
+ record = TestTypeModel.new details: {key: "value", array_key: %w[value1 value2]}
25
+
26
+ assert_equal expected_hash, record.details
27
+
28
+ record.save!
29
+ record.reload
30
+ assert_equal expected_hash, record.details
31
+ end
32
+ end
33
+ end
34
+ end
@@ -17,6 +17,8 @@ ActiveRecord::Schema.define do
17
17
  t.column :col_bytes, :binary
18
18
  t.column :col_date, :date
19
19
  t.column :col_timestamp, :datetime
20
+ t.column :col_json, :json unless ENV["SPANNER_EMULATOR_HOST"]
21
+ t.column :col_json, :string if ENV["SPANNER_EMULATOR_HOST"]
20
22
 
21
23
  t.column :col_array_string, :string, array: true
22
24
  t.column :col_array_int64, :bigint, array: true
@@ -26,6 +28,8 @@ ActiveRecord::Schema.define do
26
28
  t.column :col_array_bytes, :binary, array: true
27
29
  t.column :col_array_date, :date, array: true
28
30
  t.column :col_array_timestamp, :datetime, array: true
31
+ t.column :col_array_json, :json, array: true unless ENV["SPANNER_EMULATOR_HOST"]
32
+ t.column :col_array_json, :string, array: true if ENV["SPANNER_EMULATOR_HOST"]
29
33
  end
30
34
 
31
35
  create_table :firms do |t|
@@ -208,6 +208,7 @@ module SpannerAdapter
208
208
  t.date :start_date
209
209
  t.datetime :start_datetime
210
210
  t.time :start_time
211
+ t.json :details unless ENV["SPANNER_EMULATOR_HOST"]
211
212
  end
212
213
  end
213
214
 
@@ -24,7 +24,7 @@ Gem::Specification.new do |spec|
24
24
 
25
25
  spec.required_ruby_version = ">= 2.5"
26
26
 
27
- spec.add_dependency "google-cloud-spanner", "~> 2.4"
27
+ spec.add_dependency "google-cloud-spanner", "~> 2.10"
28
28
  spec.add_runtime_dependency "activerecord", "~> 6.1.4"
29
29
 
30
30
  spec.add_development_dependency "autotest-suffix", "~> 1.1"
@@ -10,7 +10,7 @@ require_relative "models/singer"
10
10
  require_relative "models/album"
11
11
 
12
12
  class Application
13
- def self.run # rubocop:disable Metrics/AbcSize
13
+ def self.run # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
14
14
  # Use a read-only transaction to execute multiple reads at the same commit timestamp.
15
15
  # The Spanner ActiveRecord adapter supports the custom isolation level :read_only that
16
16
  # will start a read-only Spanner transaction with a strong timestamp bound.
@@ -40,6 +40,34 @@ class Application
40
40
  puts "Album title 1: #{album1.reload.title}"
41
41
  puts "Album title 2: #{album2.reload.title}"
42
42
 
43
+ # You can also execute a stale read with ActiveRecord. Specify a hash as the isolation level with one of
44
+ # the following options:
45
+ # * timestamp: Read data at a specific timestamp.
46
+ # * staleness: Read data at a specific staleness measured in seconds.
47
+
48
+ # Get a valid timestamp from the server to use for the transaction.
49
+ timestamp = ActiveRecord::Base.connection.select_all("SELECT CURRENT_TIMESTAMP AS ts")[0]["ts"]
50
+ puts ""
51
+ puts "Read data at timestamp #{timestamp}"
52
+ ActiveRecord::Base.transaction isolation: { timestamp: timestamp } do
53
+ puts "Album title 1: #{album1.reload.title}"
54
+ puts "Album title 2: #{album2.reload.title}"
55
+ end
56
+
57
+ puts ""
58
+ puts "Read data with staleness 30 seconds"
59
+ ActiveRecord::Base.transaction isolation: { staleness: 30 } do
60
+ begin
61
+ puts "Album title 1: #{album1.reload.title}"
62
+ rescue StandardError => e
63
+ # The table will (in almost all cases) not be found, because the timestamp that is being used to read
64
+ # the data will be before the table was created. This will therefore cause a 'Table not found' error.
65
+ puts "Reading data with 30 seconds staleness failed with error: #{e.message}"
66
+ puts ""
67
+ puts "This error is expected."
68
+ end
69
+ end
70
+
43
71
  puts ""
44
72
  puts "Press any key to end the application"
45
73
  STDIN.getch
@@ -2,8 +2,8 @@
2
2
  # of editing this file, please use the migrations feature of Active Record to
3
3
  # incrementally modify your database, and then regenerate this schema definition.
4
4
  #
5
- # This file is the source Rails uses to define your schema when running `rails
6
- # db:schema:load`. When creating a new database, `rails db:schema:load` tends to
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
7
  # be faster and is potentially less error prone than running all of your
8
8
  # migrations from scratch. Old migrations may fail to apply correctly if those
9
9
  # migrations use external dependencies or application code.
@@ -12,12 +12,12 @@
12
12
 
13
13
  ActiveRecord::Schema.define(version: 1) do
14
14
 
15
- create_table "albums", force: :cascade do |t|
15
+ create_table "albums", id: { limit: 8 }, force: :cascade do |t|
16
16
  t.string "title"
17
17
  t.integer "singer_id", limit: 8
18
18
  end
19
19
 
20
- create_table "singers", force: :cascade do |t|
20
+ create_table "singers", id: { limit: 8 }, force: :cascade do |t|
21
21
  t.string "first_name"
22
22
  t.string "last_name"
23
23
  end
@@ -0,0 +1,27 @@
1
+ # Sample - Stale reads
2
+
3
+ Read and query operations outside of a transaction block will by default be executed using a
4
+ single-use read-only transaction with strong timestamp bound. This means that the read is
5
+ guaranteed to return all data that has been committed at the time of the read. It is also possible
6
+ to specify that the Spanner ActiveRecord provider should execute a stale read. This is done by
7
+ specifying an optimizer hint for the read or query operation. The hints that are available are:
8
+
9
+ * `max_staleness: <seconds>`
10
+ * `exact_staleness: <seconds>`
11
+ * `min_read_timestamp: <timestamp>`
12
+ * `read_timestamp: <timestamp>`
13
+
14
+ See https://cloud.google.com/spanner/docs/timestamp-bounds for more information on what the
15
+ different timestamp bounds in Cloud Spanner mean.
16
+
17
+ NOTE: These optimizer hints ONLY work OUTSIDE transactions. See the read-only-transactions
18
+ samples for more information on how to specify a timestamp bound for a read-only transaction.
19
+
20
+ The sample will automatically start a Spanner Emulator in a docker container and execute the sample
21
+ against that emulator. The emulator will automatically be stopped when the application finishes.
22
+
23
+ Run the application with the command
24
+
25
+ ```bash
26
+ bundle exec rake run
27
+ ```
@@ -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 execute stale reads on Spanner with ActiveRecord."
11
+ task :run do
12
+ Dir.chdir("..") { sh "bundle exec rake run[stale-reads]" }
13
+ end
@@ -0,0 +1,63 @@
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 # rubocop:disable Metrics/AbcSize
14
+ # Get a random album.
15
+ album = Album.all.sample
16
+
17
+ # Get a valid timestamp from the Cloud Spanner server that we can use to specify a timestamp bound.
18
+ timestamp = ActiveRecord::Base.connection.select_all("SELECT CURRENT_TIMESTAMP AS ts")[0]["ts"]
19
+
20
+ # Update the name of the album and then read the version of the album before the update.
21
+ album.update title: "New title"
22
+
23
+ # The timestamp should be specified in the format '2021-09-07T15:22:10.123456789Z'
24
+ timestamp_string = timestamp.xmlschema 9
25
+
26
+ # Read the album at a specific timestamp.
27
+ album_previous_version = Album.optimizer_hints("read_timestamp: #{timestamp_string}").find_by id: album.id
28
+ album = album.reload
29
+
30
+ puts ""
31
+ puts "Updated album title: #{album.title}"
32
+ puts "Previous album version title: #{album_previous_version.title}"
33
+
34
+ # Read the same album using a minimum read timestamp. It could be that we get the first version
35
+ # of the row, but it could also be that we get the updated row.
36
+ album_min_read_timestamp = Album.optimizer_hints("min_read_timestamp: #{timestamp_string}").find_by id: album.id
37
+ puts ""
38
+ puts "Updated album title: #{album.title}"
39
+ puts "Min-read timestamp title: #{album_min_read_timestamp.title}"
40
+
41
+ # Staleness can also be specified as a number of seconds. The number of seconds may contain a fraction.
42
+ # The following reads the album version at exactly 1.5 seconds ago. That will normally be nil, as the
43
+ # row did not yet exist at that moment.
44
+ album_exact_staleness = Album.optimizer_hints("exact_staleness: 1.5").find_by id: album.id
45
+
46
+ puts ""
47
+ puts "Updated album title: #{album.title}"
48
+ puts "Title 1.5 seconds ago: #{album_exact_staleness&.title}"
49
+
50
+ # You can also specify a max staleness. The server will determine the best timestamp to use for the read.
51
+ album_max_staleness = Album.optimizer_hints("max_staleness: 10").find_by id: album.id
52
+
53
+ puts ""
54
+ puts "Updated album title: #{album.title}"
55
+ puts "Title somewhere during the last 10 seconds: #{album_max_staleness&.title}"
56
+
57
+ puts ""
58
+ puts "Press any key to end the application"
59
+ STDIN.getch
60
+ end
61
+ end
62
+
63
+ 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
12
+ t.string :last_name
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"
22
+ t.string "last_name"
23
+ end
24
+
25
+ add_foreign_key "albums", "singers"
26
+ end
@@ -0,0 +1,24 @@
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 = ["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 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
@@ -27,6 +27,14 @@ module ActiveRecord
27
27
  transaction_required = statement_type == :dml
28
28
  materialize_transactions
29
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
+
30
38
  log sql, name do
31
39
  types, params = to_types_and_params binds
32
40
  ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
@@ -35,7 +43,7 @@ module ActiveRecord
35
43
  @connection.execute_query sql, params: params, types: types
36
44
  end
37
45
  else
38
- @connection.execute_query sql, params: params, types: types
46
+ @connection.execute_query sql, params: params, types: types, single_use_selector: selector
39
47
  end
40
48
  end
41
49
  end
@@ -149,7 +157,7 @@ module ActiveRecord
149
157
  end
150
158
 
151
159
  # Begins a transaction on the database with the specified isolation level. Cloud Spanner only supports
152
- # isolation level :serializable, but also defines two additional 'isolation levels' that can be used
160
+ # isolation level :serializable, but also defines three additional 'isolation levels' that can be used
153
161
  # to start specific types of Spanner transactions:
154
162
  # * :read_only: Starts a read-only snapshot transaction using a strong timestamp bound.
155
163
  # * :buffered_mutations: Starts a read/write transaction that will use mutations instead of DML for single-row
@@ -158,9 +166,24 @@ module ActiveRecord
158
166
  # * :pdml: Starts a Partitioned DML transaction. Executing multiple DML statements in one PDML transaction
159
167
  # block is NOT supported A PDML transaction is not guaranteed to be atomic.
160
168
  # See https://cloud.google.com/spanner/docs/dml-partitioned for more information.
169
+ #
170
+ # In addition to the above, a Hash containing read-only snapshot options may be used to start a specific
171
+ # read-only snapshot:
172
+ # * { timestamp: Time } Starts a read-only snapshot at the given timestamp.
173
+ # * { staleness: Integer } Starts a read-only snapshot with the given staleness in seconds.
174
+ # * { strong: <any value>} Starts a read-only snapshot with strong timestamp bound
175
+ # (this is the same as :read_only)
176
+ #
161
177
  def begin_isolated_db_transaction isolation
162
- raise "Unsupported isolation level: #{isolation}" unless \
178
+ if isolation.is_a? Hash
179
+ raise "Unsupported isolation level: #{isolation}" unless \
180
+ isolation[:timestamp] || isolation[:staleness] || isolation[:strong]
181
+ raise "Only one option is supported. It must be one of `timestamp`, `staleness` or `strong`." \
182
+ if isolation.count != 1
183
+ else
184
+ raise "Unsupported isolation level: #{isolation}" unless \
163
185
  [:serializable, :read_only, :buffered_mutations, :pdml].include? isolation
186
+ end
164
187
 
165
188
  log "BEGIN #{isolation}" do
166
189
  @connection.begin_transaction isolation
@@ -65,7 +65,8 @@ module ActiveRecord
65
65
  time: { name: "TIMESTAMP" },
66
66
  date: { name: "DATE" },
67
67
  binary: { name: "BYTES", limit: "MAX" },
68
- boolean: { name: "BOOL" }
68
+ boolean: { name: "BOOL" },
69
+ json: { name: "JSON" }
69
70
  }.freeze
70
71
 
71
72
  include Spanner::Quoting
@@ -190,6 +191,7 @@ module ActiveRecord
190
191
  m.register_type "INT64", Type::Integer.new(limit: 8)
191
192
  register_class_with_limit m, %r{^STRING}i, Type::String
192
193
  m.register_type "TIMESTAMP", ActiveRecord::Type::Spanner::Time.new
194
+ m.register_type "JSON", ActiveRecord::Type::Json.new
193
195
 
194
196
  register_array_types m
195
197
  end
@@ -203,6 +205,7 @@ module ActiveRecord
203
205
  m.register_type %r{^ARRAY<INT64>}i, Type::Spanner::Array.new(Type::Integer.new(limit: 8))
204
206
  m.register_type %r{^ARRAY<STRING\((MAX|d+)\)>}i, Type::Spanner::Array.new(Type::String.new)
205
207
  m.register_type %r{^ARRAY<TIMESTAMP>}i, Type::Spanner::Array.new(ActiveRecord::Type::Spanner::Time.new)
208
+ m.register_type %r{^ARRAY<JSON>}i, Type::Spanner::Array.new(ActiveRecord::Type::Json.new)
206
209
  end
207
210
 
208
211
  def extract_limit sql_type
@@ -23,6 +23,7 @@ module ActiveRecord
23
23
  when ActiveModel::Type::Decimal then :NUMERIC
24
24
  when ActiveModel::Type::DateTime, ActiveModel::Type::Time, ActiveRecord::Type::Spanner::Time then :TIMESTAMP
25
25
  when ActiveModel::Type::Date then :DATE
26
+ when ActiveRecord::Type::Json then :JSON
26
27
  when ActiveRecord::Type::Spanner::Array then [convert_active_model_type_to_spanner(type.element_type)]
27
28
  end
28
29
  end
@@ -195,7 +195,7 @@ module ActiveRecordSpannerAdapter
195
195
 
196
196
  # DQL, DML Statements
197
197
 
198
- def execute_query sql, params: nil, types: nil
198
+ def execute_query sql, params: nil, types: nil, single_use_selector: nil
199
199
  if params
200
200
  converted_params, types = \
201
201
  Google::Cloud::Spanner::Convert.to_input_params_and_types(
@@ -213,7 +213,7 @@ module ActiveRecordSpannerAdapter
213
213
  sql,
214
214
  params: converted_params,
215
215
  types: types,
216
- transaction: transaction_selector,
216
+ transaction: transaction_selector || single_use_selector,
217
217
  seqno: (current_transaction&.next_sequence_number)
218
218
  rescue Google::Cloud::AbortedError
219
219
  # Mark the current transaction as aborted to prevent any unnecessary further requests on the transaction.
@@ -11,6 +11,7 @@ module ActiveRecordSpannerAdapter
11
11
  def initialize connection, isolation
12
12
  @connection = connection
13
13
  @isolation = isolation
14
+ @committable = ![:read_only, :pdml].include?(isolation) && !isolation.is_a?(Hash)
14
15
  @state = :INITIALIZED
15
16
  @sequence_number = 0
16
17
  @mutations = []
@@ -34,6 +35,16 @@ module ActiveRecordSpannerAdapter
34
35
  begin
35
36
  @grpc_transaction =
36
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
37
48
  when :read_only
38
49
  @connection.session.create_snapshot strong: true
39
50
  when :pdml
@@ -56,15 +67,14 @@ module ActiveRecordSpannerAdapter
56
67
  end
57
68
 
58
69
  def next_sequence_number
59
- @sequence_number += 1 unless @isolation == :read_only
70
+ @sequence_number += 1 if @committable
60
71
  end
61
72
 
62
73
  def commit
63
74
  raise "This transaction is not active" unless active?
64
75
 
65
76
  begin
66
- @connection.session.commit_transaction @grpc_transaction, @mutations \
67
- unless [:read_only, :pdml].include? @isolation
77
+ @connection.session.commit_transaction @grpc_transaction, @mutations if @committable
68
78
  @state = :COMMITTED
69
79
  rescue Google::Cloud::NotFoundError => e
70
80
  if @connection.session_not_found? e
@@ -94,7 +104,7 @@ module ActiveRecordSpannerAdapter
94
104
  end
95
105
 
96
106
  def shoot_and_forget_rollback
97
- @connection.session.rollback @grpc_transaction.transaction_id unless @isolation == :read_only
107
+ @connection.session.rollback @grpc_transaction.transaction_id if @committable
98
108
  rescue StandardError # rubocop:disable Lint/HandleExceptions
99
109
  # Ignored
100
110
  end
@@ -5,5 +5,5 @@
5
5
  # https://opensource.org/licenses/MIT.
6
6
 
7
7
  module ActiveRecordSpannerAdapter
8
- VERSION = "0.5.0".freeze
8
+ VERSION = "0.6.0".freeze
9
9
  end
@@ -6,10 +6,21 @@
6
6
 
7
7
  module Arel # :nodoc: all
8
8
  module Visitors
9
+ class StalenessHint
10
+ attr_reader :value
11
+
12
+ def initialize value
13
+ @value = value
14
+ end
15
+ end
16
+
9
17
  class Spanner < Arel::Visitors::ToSql
10
18
  def compile node, collector = Arel::Collectors::SQLString.new
11
- @index = 0
12
- accept(node, collector).value
19
+ collector.class.module_eval { attr_accessor :hints }
20
+ collector.hints = {}
21
+ sql, binds = accept(node, collector).value
22
+ binds << collector.hints[:staleness] if collector.hints[:staleness]
23
+ [sql, binds]
13
24
  end
14
25
 
15
26
  private
@@ -22,6 +33,32 @@ module Arel # :nodoc: all
22
33
  end
23
34
 
24
35
  # rubocop:disable Naming/MethodName
36
+ def visit_Arel_Nodes_OptimizerHints o, collector
37
+ o.expr.each do |v|
38
+ if v.start_with? "max_staleness:"
39
+ collector.hints[:staleness] = \
40
+ StalenessHint.new max_staleness: v.delete_prefix("max_staleness:").to_f
41
+ next
42
+ end
43
+ if v.start_with? "exact_staleness:"
44
+ collector.hints[:staleness] = \
45
+ StalenessHint.new exact_staleness: v.delete_prefix("exact_staleness:").to_f
46
+ next
47
+ end
48
+ if v.start_with? "min_read_timestamp:"
49
+ time = Time.xmlschema v.delete_prefix("min_read_timestamp:")
50
+ collector.hints[:staleness] = \
51
+ StalenessHint.new min_read_timestamp: time
52
+ next
53
+ end
54
+ next unless v.start_with? "read_timestamp:"
55
+ time = Time.xmlschema v.delete_prefix("read_timestamp:")
56
+ collector.hints[:staleness] = \
57
+ StalenessHint.new read_timestamp: time
58
+ end
59
+ collector
60
+ end
61
+
25
62
  def visit_Arel_Nodes_BindParam o, collector
26
63
  # Do not generate a query parameter if the value should be set to the PENDING_COMMIT_TIMESTAMP(), as that is
27
64
  # not supported as a parameter value by Cloud Spanner.
@@ -35,6 +35,27 @@ module Google
35
35
  Convert.timestamp_to_time resp.commit_timestamp
36
36
  end
37
37
 
38
+ # Create a single-use transaction selector.
39
+ def self.single_use_transaction opts
40
+ return nil if opts.nil? || opts.empty?
41
+
42
+ exact_timestamp = Convert.time_to_timestamp opts[:read_timestamp]
43
+ exact_staleness = Convert.number_to_duration opts[:exact_staleness]
44
+ min_read_timestamp = Convert.time_to_timestamp opts[:min_read_timestamp]
45
+ max_staleness = Convert.number_to_duration opts[:max_staleness]
46
+
47
+ V1::TransactionSelector.new(single_use:
48
+ V1::TransactionOptions.new(read_only:
49
+ V1::TransactionOptions::ReadOnly.new({
50
+ strong: opts[:strong],
51
+ read_timestamp: exact_timestamp,
52
+ exact_staleness: exact_staleness,
53
+ min_read_timestamp: min_read_timestamp,
54
+ max_staleness: max_staleness,
55
+ return_read_timestamp: true
56
+ }.delete_if { |_, v| v.nil? })))
57
+ end
58
+
38
59
  def create_snapshot strong: nil,
39
60
  timestamp: nil, read_timestamp: nil,
40
61
  staleness: nil, exact_staleness: nil
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-spanner-adapter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Google LLC
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-08-31 00:00:00.000000000 Z
11
+ date: 2021-09-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: google-cloud-spanner
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '2.4'
19
+ version: '2.10'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '2.4'
26
+ version: '2.10'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: activerecord
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -275,6 +275,7 @@ files:
275
275
  - acceptance/cases/type/date_time_test.rb
276
276
  - acceptance/cases/type/float_test.rb
277
277
  - acceptance/cases/type/integer_test.rb
278
+ - acceptance/cases/type/json_test.rb
278
279
  - acceptance/cases/type/numeric_test.rb
279
280
  - acceptance/cases/type/string_test.rb
280
281
  - acceptance/cases/type/text_test.rb
@@ -433,6 +434,15 @@ files:
433
434
  - examples/snippets/read-write-transactions/db/seeds.rb
434
435
  - examples/snippets/read-write-transactions/models/album.rb
435
436
  - examples/snippets/read-write-transactions/models/singer.rb
437
+ - examples/snippets/stale-reads/README.md
438
+ - examples/snippets/stale-reads/Rakefile
439
+ - examples/snippets/stale-reads/application.rb
440
+ - examples/snippets/stale-reads/config/database.yml
441
+ - examples/snippets/stale-reads/db/migrate/01_create_tables.rb
442
+ - examples/snippets/stale-reads/db/schema.rb
443
+ - examples/snippets/stale-reads/db/seeds.rb
444
+ - examples/snippets/stale-reads/models/album.rb
445
+ - examples/snippets/stale-reads/models/singer.rb
436
446
  - examples/snippets/timestamp-data-type/README.md
437
447
  - examples/snippets/timestamp-data-type/Rakefile
438
448
  - examples/snippets/timestamp-data-type/application.rb