activerecord-spanner-adapter 0.5.0 → 0.6.0

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