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.
- checksums.yaml +4 -4
- data/.github/workflows/acceptance-tests-on-production.yaml +13 -0
- data/CHANGELOG.md +9 -0
- data/acceptance/cases/transactions/read_only_transactions_test.rb +63 -0
- data/acceptance/cases/type/all_types_test.rb +23 -3
- data/acceptance/cases/type/json_test.rb +34 -0
- data/acceptance/schema/schema.rb +4 -0
- data/acceptance/test_helper.rb +1 -0
- data/activerecord-spanner-adapter.gemspec +1 -1
- data/examples/snippets/read-only-transactions/application.rb +29 -1
- data/examples/snippets/read-only-transactions/db/schema.rb +4 -4
- data/examples/snippets/stale-reads/README.md +27 -0
- data/examples/snippets/stale-reads/Rakefile +13 -0
- data/examples/snippets/stale-reads/application.rb +63 -0
- data/examples/snippets/stale-reads/config/database.yml +8 -0
- data/examples/snippets/stale-reads/db/migrate/01_create_tables.rb +21 -0
- data/examples/snippets/stale-reads/db/schema.rb +26 -0
- data/examples/snippets/stale-reads/db/seeds.rb +24 -0
- data/examples/snippets/stale-reads/models/album.rb +9 -0
- data/examples/snippets/stale-reads/models/singer.rb +9 -0
- data/lib/active_record/connection_adapters/spanner/database_statements.rb +26 -3
- data/lib/active_record/connection_adapters/spanner_adapter.rb +4 -1
- data/lib/active_record/type/spanner/spanner_active_record_converter.rb +1 -0
- data/lib/activerecord_spanner_adapter/connection.rb +2 -2
- data/lib/activerecord_spanner_adapter/transaction.rb +14 -4
- data/lib/activerecord_spanner_adapter/version.rb +1 -1
- data/lib/arel/visitors/spanner.rb +39 -2
- data/lib/spanner_client_ext.rb +21 -0
- metadata +14 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 55f265a14ddd7a176378a49090cbb3277aae66ce2c74913978ec9614289759fc
|
4
|
+
data.tar.gz: ae48998a381c0142063ac4499c8a0c13ece58bded34a72cbcc0371541ee02231
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/acceptance/schema/schema.rb
CHANGED
@@ -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|
|
data/acceptance/test_helper.rb
CHANGED
@@ -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.
|
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,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
|
@@ -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
|
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
|
-
|
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
|
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
|
107
|
+
@connection.session.rollback @grpc_transaction.transaction_id if @committable
|
98
108
|
rescue StandardError # rubocop:disable Lint/HandleExceptions
|
99
109
|
# Ignored
|
100
110
|
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
|
-
|
12
|
-
|
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.
|
data/lib/spanner_client_ext.rb
CHANGED
@@ -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.
|
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-
|
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.
|
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.
|
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
|