activerecord-spanner-adapter 1.0.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.github/CODEOWNERS +1 -1
  3. data/.github/blunderbuss.yml +2 -0
  4. data/.github/sync-repo-settings.yaml +1 -1
  5. data/.github/workflows/acceptance-tests-on-emulator.yaml +8 -6
  6. data/.github/workflows/acceptance-tests-on-production.yaml +3 -3
  7. data/.github/workflows/ci.yaml +8 -6
  8. data/.github/workflows/nightly-acceptance-tests-on-emulator.yaml +9 -5
  9. data/.github/workflows/nightly-acceptance-tests-on-production.yaml +2 -2
  10. data/.github/workflows/nightly-unit-tests.yaml +9 -5
  11. data/.github/workflows/release-please-label.yml +4 -4
  12. data/.github/workflows/release-please.yml +12 -11
  13. data/.github/workflows/rubocop.yaml +4 -4
  14. data/.release-please-manifest.json +3 -0
  15. data/CHANGELOG.md +46 -31
  16. data/CONTRIBUTING.md +1 -1
  17. data/Gemfile +6 -2
  18. data/README.md +2 -1
  19. data/acceptance/cases/interleaved_associations/has_many_associations_using_interleaved_test.rb +12 -8
  20. data/acceptance/cases/models/insert_all_test.rb +150 -0
  21. data/acceptance/cases/transactions/optimistic_locking_test.rb +5 -0
  22. data/acceptance/cases/type/all_types_test.rb +24 -25
  23. data/acceptance/cases/type/json_test.rb +0 -2
  24. data/acceptance/models/album.rb +7 -2
  25. data/acceptance/models/singer.rb +2 -2
  26. data/acceptance/models/track.rb +5 -2
  27. data/acceptance/schema/schema.rb +2 -4
  28. data/acceptance/test_helper.rb +1 -1
  29. data/activerecord-spanner-adapter.gemspec +1 -1
  30. data/examples/rails/README.md +8 -8
  31. data/examples/snippets/interleaved-tables/README.md +164 -0
  32. data/examples/snippets/interleaved-tables/Rakefile +13 -0
  33. data/examples/snippets/interleaved-tables/application.rb +126 -0
  34. data/examples/snippets/interleaved-tables/config/database.yml +8 -0
  35. data/examples/snippets/interleaved-tables/db/migrate/01_create_tables.rb +44 -0
  36. data/examples/snippets/interleaved-tables/db/schema.rb +32 -0
  37. data/examples/snippets/interleaved-tables/db/seeds.rb +40 -0
  38. data/examples/snippets/interleaved-tables/models/album.rb +20 -0
  39. data/examples/snippets/interleaved-tables/models/singer.rb +18 -0
  40. data/examples/snippets/interleaved-tables/models/track.rb +28 -0
  41. data/lib/active_record/connection_adapters/spanner/schema_creation.rb +10 -4
  42. data/lib/active_record/connection_adapters/spanner_adapter.rb +64 -31
  43. data/lib/active_record/type/spanner/array.rb +19 -5
  44. data/lib/activerecord_spanner_adapter/base.rb +150 -17
  45. data/lib/activerecord_spanner_adapter/relation.rb +21 -0
  46. data/lib/activerecord_spanner_adapter/version.rb +1 -1
  47. data/lib/arel/visitors/spanner.rb +10 -0
  48. data/release-please-config.json +19 -0
  49. metadata +28 -7
@@ -0,0 +1,150 @@
1
+ # Copyright 2022 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
+ require "models/author"
11
+
12
+ module ActiveRecord
13
+ module Model
14
+ class InsertAllTest < SpannerAdapter::TestCase
15
+ include SpannerAdapter::Associations::TestHelper
16
+
17
+ def setup
18
+ super
19
+ end
20
+
21
+ def teardown
22
+ super
23
+ Author.destroy_all
24
+ end
25
+
26
+ def test_insert_all
27
+ values = [
28
+ { id: Author.next_sequence_value, name: "Alice" },
29
+ { id: Author.next_sequence_value, name: "Bob" },
30
+ { id: Author.next_sequence_value, name: "Carol" },
31
+ ]
32
+
33
+ assert_raise(NotImplementedError) { Author.insert_all(values) }
34
+ end
35
+
36
+ def test_insert_all!
37
+ values = [
38
+ { id: Author.next_sequence_value, name: "Alice" },
39
+ { id: Author.next_sequence_value, name: "Bob" },
40
+ { id: Author.next_sequence_value, name: "Carol" },
41
+ ]
42
+
43
+ Author.insert_all!(values)
44
+
45
+ authors = Author.all.order(:name)
46
+
47
+ assert_equal "Alice", authors[0].name
48
+ assert_equal "Bob", authors[1].name
49
+ assert_equal "Carol", authors[2].name
50
+ end
51
+
52
+ def test_insert_all_with_transaction
53
+ values = [
54
+ { id: Author.next_sequence_value, name: "Alice" },
55
+ { id: Author.next_sequence_value, name: "Bob" },
56
+ { id: Author.next_sequence_value, name: "Carol" },
57
+ ]
58
+
59
+ ActiveRecord::Base.transaction do
60
+ Author.insert_all!(values)
61
+ end
62
+
63
+ authors = Author.all.order(:name)
64
+
65
+ assert_equal "Alice", authors[0].name
66
+ assert_equal "Bob", authors[1].name
67
+ assert_equal "Carol", authors[2].name
68
+ end
69
+
70
+ def test_insert_all_with_buffered_mutation_transaction
71
+ values = [
72
+ { id: Author.next_sequence_value, name: "Alice" },
73
+ { id: Author.next_sequence_value, name: "Bob" },
74
+ { id: Author.next_sequence_value, name: "Carol" },
75
+ ]
76
+
77
+ ActiveRecord::Base.transaction isolation: :buffered_mutations do
78
+ Author.insert_all!(values)
79
+ end
80
+
81
+ authors = Author.all.order(:name)
82
+
83
+ assert_equal "Alice", authors[0].name
84
+ assert_equal "Bob", authors[1].name
85
+ assert_equal "Carol", authors[2].name
86
+ end
87
+
88
+ def test_upsert_all
89
+ Author.create id: 1, name: "David"
90
+ authors = Author.all.order(:name)
91
+ assert_equal 1, authors.length
92
+ assert_equal "David", authors[0].name
93
+
94
+ values = [
95
+ { id: 1, name: "Alice" },
96
+ { id: 2, name: "Bob" },
97
+ { id: 3, name: "Carol" },
98
+ ]
99
+
100
+ Author.upsert_all(values)
101
+
102
+ authors = Author.all.order(:name)
103
+
104
+ assert_equal 3, authors.length
105
+ assert_equal "Alice", authors[0].name
106
+ assert_equal "Bob", authors[1].name
107
+ assert_equal "Carol", authors[2].name
108
+ end
109
+
110
+ def test_upsert_all_with_transaction
111
+ values = [
112
+ { id: Author.next_sequence_value, name: "Alice" },
113
+ { id: Author.next_sequence_value, name: "Bob" },
114
+ { id: Author.next_sequence_value, name: "Carol" },
115
+ ]
116
+
117
+ err = assert_raise(NotImplementedError) do
118
+ ActiveRecord::Base.transaction do
119
+ Author.upsert_all(values)
120
+ end
121
+ end
122
+ assert_match "Use upsert outside a transaction block", err.message
123
+ end
124
+
125
+ def test_upsert_all_with_buffered_mutation_transaction
126
+ Author.create id: 1, name: "David"
127
+ authors = Author.all.order(:name)
128
+ assert_equal 1, authors.length
129
+ assert_equal "David", authors[0].name
130
+
131
+ values = [
132
+ { id: 1, name: "Alice" },
133
+ { id: 2, name: "Bob" },
134
+ { id: 3, name: "Carol" },
135
+ ]
136
+
137
+ ActiveRecord::Base.transaction isolation: :buffered_mutations do
138
+ Author.upsert_all(values)
139
+ end
140
+
141
+ authors = Author.all.order(:name)
142
+
143
+ assert_equal 3, authors.length
144
+ assert_equal "Alice", authors[0].name
145
+ assert_equal "Bob", authors[1].name
146
+ assert_equal "Carol", authors[2].name
147
+ end
148
+ end
149
+ end
150
+ end
@@ -19,6 +19,9 @@ module ActiveRecord
19
19
  def setup
20
20
  super
21
21
 
22
+ @original_verbosity = $VERBOSE
23
+ $VERBOSE = nil
24
+
22
25
  singer = Singer.create first_name: "Pete", last_name: "Allison"
23
26
  album = Album.create title: "Musical Jeans", singer: singer
24
27
  Track.create title: "Increased Headline", album: album, singer: singer
@@ -30,6 +33,8 @@ module ActiveRecord
30
33
  Track.delete_all
31
34
  Album.delete_all
32
35
  Singer.delete_all
36
+
37
+ $VERBOSE = @original_verbosity
33
38
  end
34
39
 
35
40
  # Runs the given block in a transaction with the given isolation level, or without a transaction if isolation is
@@ -30,19 +30,19 @@ 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
+ col_json: { kind: "user_renamed", change: %w[jack john]},
34
34
  col_array_string: ["string1", nil, "string2"],
35
- col_array_int64: [100, nil, 200],
36
- col_array_float64: [3.14, nil, 2.0/3.0],
37
- col_array_numeric: [6.626, nil, 3.20],
38
- col_array_bool: [true, nil, false],
35
+ col_array_int64: [100, nil, 200, "300"],
36
+ col_array_float64: [3.14, nil, 2.0/3.0, "3.14"],
37
+ col_array_numeric: [6.626, nil, 3.20, "400"],
38
+ col_array_bool: [true, nil, false, "false"],
39
39
  col_array_bytes: [StringIO.new("bytes1"), nil, StringIO.new("bytes2")],
40
- col_array_date: [::Date.new(2021, 6, 23), nil, ::Date.new(2021, 6, 24)],
40
+ col_array_date: [::Date.new(2021, 6, 23), nil, ::Date.new(2021, 6, 24), "2021-06-25"],
41
41
  col_array_timestamp: [::Time.new(2021, 6, 23, 17, 8, 21, "+02:00"), nil, \
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
+ ::Time.new(2021, 6, 24, 17, 8, 21, "+02:00"), "2021-06-25 17:08:21 +02:00"],
43
+ col_array_json: [{ kind: "user_renamed", change: %w[jack john]}, nil, \
44
+ { kind: "user_renamed", change: %w[alice meredith]},
45
+ "{\"kind\":\"user_renamed\",\"change\":[\"bob\",\"carol\"]}"]
46
46
  end
47
47
 
48
48
  def test_create_record
@@ -66,24 +66,25 @@ module ActiveRecord
66
66
  assert_equal ::Date.new(2021, 6, 23), record.col_date
67
67
  assert_equal ::Time.new(2021, 6, 23, 17, 8, 21, "+02:00").utc, record.col_timestamp.utc
68
68
  assert_equal ({"kind" => "user_renamed", "change" => %w[jack john]}),
69
- record.col_json unless ENV["SPANNER_EMULATOR_HOST"]
69
+ record.col_json
70
70
 
71
71
  assert_equal ["string1", nil, "string2"], record.col_array_string
72
- assert_equal [100, nil, 200], record.col_array_int64
73
- assert_equal [3.14, nil, 2.0/3.0], record.col_array_float64
74
- assert_equal [6.626, nil, 3.20], record.col_array_numeric
75
- assert_equal [true, nil, false], record.col_array_bool
72
+ assert_equal [100, nil, 200, 300], record.col_array_int64
73
+ assert_equal [3.14, nil, 2.0/3.0, 3.14], record.col_array_float64
74
+ assert_equal [6.626, nil, 3.20, 400], record.col_array_numeric
75
+ assert_equal [true, nil, false, false], record.col_array_bool
76
76
  assert_equal [StringIO.new("bytes1"), nil, StringIO.new("bytes2")].map { |bytes| bytes&.read },
77
77
  record.col_array_bytes.map { |bytes| bytes&.read }
78
- assert_equal [::Date.new(2021, 6, 23), nil, ::Date.new(2021, 6, 24)], record.col_array_date
78
+ assert_equal [::Date.new(2021, 6, 23), nil, ::Date.new(2021, 6, 24), ::Date.new(2021, 06, 25)], record.col_array_date
79
79
  assert_equal [::Time.new(2021, 6, 23, 17, 8, 21, "+02:00"), \
80
80
  nil, \
81
- ::Time.new(2021, 6, 24, 17, 8, 21, "+02:00")].map { |timestamp| timestamp&.utc },
81
+ ::Time.new(2021, 6, 24, 17, 8, 21, "+02:00"), ::Time.new(2021, 6, 25, 17, 8, 21, "+02:00")].map { |timestamp| timestamp&.utc },
82
82
  record.col_array_timestamp.map { |timestamp| timestamp&.utc}
83
83
  assert_equal [{"kind" => "user_renamed", "change" => %w[jack john]}, \
84
84
  nil, \
85
- {"kind" => "user_renamed", "change" => %w[alice meredith]}],
86
- record.col_array_json unless ENV["SPANNER_EMULATOR_HOST"]
85
+ {"kind" => "user_renamed", "change" => %w[alice meredith]}, \
86
+ "{\"kind\":\"user_renamed\",\"change\":[\"bob\",\"carol\"]}"],
87
+ record.col_array_json
87
88
  end
88
89
  end
89
90
 
@@ -98,7 +99,7 @@ module ActiveRecord
98
99
  col_bool: false, col_bytes: StringIO.new("new bytes"),
99
100
  col_date: ::Date.new(2021, 6, 28),
100
101
  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]},
102
+ col_json: { kind: "user_created", change: %w[jack alice]},
102
103
  col_array_string: ["new string 1", "new string 2"],
103
104
  col_array_int64: [300, 200, 100],
104
105
  col_array_float64: [1.1, 2.2, 3.3],
@@ -107,9 +108,7 @@ module ActiveRecord
107
108
  col_array_bytes: [StringIO.new("new bytes 1"), StringIO.new("new bytes 2")],
108
109
  col_array_date: [::Date.new(2021, 6, 28)],
109
110
  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]}]
111
+ col_array_json: [{ kind: "user_created", change: %w[jack alice]}]
113
112
  end
114
113
 
115
114
  # Verify that the record was updated.
@@ -123,7 +122,7 @@ module ActiveRecord
123
122
  assert_equal ::Date.new(2021, 6, 28), record.col_date
124
123
  assert_equal ::Time.new(2021, 6, 28, 11, 22, 21, "+02:00").utc, record.col_timestamp.utc
125
124
  assert_equal ({"kind" => "user_created", "change" => %w[jack alice]}),
126
- record.col_json unless ENV["SPANNER_EMULATOR_HOST"]
125
+ record.col_json
127
126
 
128
127
  assert_equal ["new string 1", "new string 2"], record.col_array_string
129
128
  assert_equal [300, 200, 100], record.col_array_int64
@@ -135,7 +134,7 @@ module ActiveRecord
135
134
  assert_equal [::Date.new(2021, 6, 28)], record.col_array_date
136
135
  assert_equal [::Time.utc(2020, 12, 31, 0, 0, 0)], record.col_array_timestamp.map(&:utc)
137
136
  assert_equal [{"kind" => "user_created", "change" => %w[jack alice]}],
138
- record.col_array_json unless ENV["SPANNER_EMULATOR_HOST"]
137
+ record.col_array_json
139
138
  end
140
139
  end
141
140
 
@@ -18,8 +18,6 @@ module ActiveRecord
18
18
  end
19
19
 
20
20
  def test_set_json
21
- return if ENV["SPANNER_EMULATOR_HOST"]
22
-
23
21
  expected_hash = {"key"=>"value", "array_key"=>%w[value1 value2]}
24
22
  record = TestTypeModel.new details: {key: "value", array_key: %w[value1 value2]}
25
23
 
@@ -4,9 +4,14 @@
4
4
  # license that can be found in the LICENSE file or at
5
5
  # https://opensource.org/licenses/MIT.
6
6
 
7
+ require "composite_primary_keys"
8
+
7
9
  class Album < ActiveRecord::Base
10
+ # Register both primary key columns with composite_primary_keys
11
+ self.primary_keys = :singerid, :albumid
12
+
8
13
  # The relationship with singer is not really a foreign key, but an INTERLEAVE IN relationship. We still need to
9
14
  # use the `foreign_key` attribute to indicate which column to use for the relationship.
10
- belongs_to :singer, foreign_key: "singerid"
11
- has_many :tracks, foreign_key: "albumid", dependent: :delete_all
15
+ belongs_to :singer, foreign_key: :singerid
16
+ has_many :tracks, foreign_key: [:singerid, :albumid], dependent: :delete_all
12
17
  end
@@ -5,6 +5,6 @@
5
5
  # https://opensource.org/licenses/MIT.
6
6
 
7
7
  class Singer < ActiveRecord::Base
8
- has_many :albums, foreign_key: "singerid", dependent: :delete_all
9
- has_many :tracks, foreign_key: "singerid"
8
+ has_many :albums, foreign_key: :singerid, dependent: :delete_all
9
+ has_many :tracks, foreign_key: :singerid
10
10
  end
@@ -5,8 +5,11 @@
5
5
  # https://opensource.org/licenses/MIT.
6
6
 
7
7
  class Track < ActiveRecord::Base
8
- belongs_to :album, foreign_key: "albumid"
9
- belongs_to :singer, foreign_key: "singerid", counter_cache: true
8
+ # Register both primary key columns with composite_primary_keys
9
+ self.primary_keys = :singerid, :albumid, :trackid
10
+
11
+ belongs_to :album, foreign_key: [:singerid, :albumid]
12
+ belongs_to :singer, foreign_key: :singerid, counter_cache: true
10
13
 
11
14
  def initialize attributes = nil
12
15
  super
@@ -17,8 +17,7 @@ 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
+ t.column :col_json, :json
22
21
 
23
22
  t.column :col_array_string, :string, array: true
24
23
  t.column :col_array_int64, :bigint, array: true
@@ -28,8 +27,7 @@ ActiveRecord::Schema.define do
28
27
  t.column :col_array_bytes, :binary, array: true
29
28
  t.column :col_array_date, :date, array: true
30
29
  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"]
30
+ t.column :col_array_json, :json, array: true
33
31
  end
34
32
 
35
33
  create_table :firms do |t|
@@ -208,7 +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
+ t.json :details
212
212
  end
213
213
  end
214
214
 
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.required_ruby_version = ">= 2.5"
26
26
 
27
27
  spec.add_dependency "google-cloud-spanner", "~> 2.10"
28
- spec.add_runtime_dependency "activerecord", "~> 6.1.4"
28
+ spec.add_runtime_dependency "activerecord", [">= 6.0.0", "< 7.1"]
29
29
 
30
30
  spec.add_development_dependency "autotest-suffix", "~> 1.1"
31
31
  spec.add_development_dependency "bundler", "~> 2.0"
@@ -142,17 +142,17 @@ Replace `[PROJECT_ID]` with the project id you are currently using.
142
142
  ### Create database
143
143
 
144
144
  You now can run the following command to create the database:
145
- ```shell
146
- ./bin/rails db:create
147
- ```
148
- You should see output like the following:
149
- ```
150
- Created database 'blog_dev'
151
- ```
145
+
146
+ ```shell
147
+ ./bin/rails db:create
148
+ ```
149
+
150
+ You should see output like the following: `Created database 'blog_dev'`
151
+
152
152
  ### Generate a Model and apply the migration
153
153
  1. Use the model generato to define a model:
154
154
  ```shell
155
- bin/rails generate model Article title:string body:text
155
+ ./bin/rails generate model Article title:string body:text
156
156
  ```
157
157
  1. Apply the migration:
158
158
  ```shell
@@ -0,0 +1,164 @@
1
+ # Sample - Interleaved Tables
2
+
3
+ This example shows how to use interleaved tables with the Spanner ActiveRecord adapter.
4
+ Interleaved tables use composite primary keys. This is not natively supported by ActiveRecord.
5
+ It is therefore necessary to use the `composite_primary_keys` (https://github.com/composite-primary-keys/composite_primary_keys)
6
+ gem to enable the use of interleaved tables.
7
+
8
+ See https://cloud.google.com/spanner/docs/schema-and-data-model#creating-interleaved-tables for more information
9
+ on interleaved tables if you are not familiar with this concept.
10
+
11
+ ## Creating Interleaved Tables in ActiveRecord
12
+ You can create interleaved tables using migrations in ActiveRecord by using the following Spanner ActiveRecord specific
13
+ methods that are defined on `TableDefinition`:
14
+ * `interleave_in`: Specifies which parent table a child table should be interleaved in and optionally whether
15
+ deletes of a parent record should automatically cascade delete all child records.
16
+ * `parent_key`: Creates a column that is a reference to (a part of) the primary key of the parent table. Each child
17
+ table must include all the primary key columns of the parent table as a `parent_key`.
18
+
19
+ Cloud Spanner requires a child table to include the exact same primary key columns as the parent table in addition to
20
+ the primary key column(s) of the child table. This means that the default `id` primary key column of ActiveRecord is
21
+ not usable in combination with interleaved tables. Instead each primary key column should be prefixed with the table
22
+ name of the table that it references, or use some other unique name.
23
+
24
+ ## Example Data Model
25
+ This example uses the following table schema:
26
+
27
+ ```sql
28
+ CREATE TABLE singers (
29
+ singerid INT64 NOT NULL,
30
+ first_name STRING(MAX),
31
+ last_name STRING(MAX)
32
+ ) PRIMARY KEY (singerid);
33
+
34
+ CREATE TABLE albums (
35
+ albumid INT64 NOT NULL,
36
+ singerid INT64 NOT NULL,
37
+ title STRING(MAX)
38
+ ) PRIMARY KEY (singerid, albumid), INTERLEAVE IN PARENT singers;
39
+
40
+ CREATE TABLE tracks (
41
+ trackid INT64 NOT NULL,
42
+ singerid INT64 NOT NULL,
43
+ albumid INT64 NOT NULL,
44
+ title STRING(MAX),
45
+ duration NUMERIC
46
+ ) PRIMARY KEY (singerid, albumid, trackid), INTERLEAVE IN PARENT albums ON DELETE CASCADE;
47
+ ```
48
+
49
+ This schema can be created in ActiveRecord as follows:
50
+
51
+ ```ruby
52
+ create_table :singers, id: false do |t|
53
+ # Explicitly define the primary key with a custom name to prevent all primary key columns from being named `id`.
54
+ t.primary_key :singerid
55
+ t.string :first_name
56
+ t.string :last_name
57
+ end
58
+
59
+ create_table :albums, id: false do |t|
60
+ # Interleave the `albums` table in the parent table `singers`.
61
+ t.interleave_in :singers
62
+ t.primary_key :albumid
63
+ # `singerid` is defined as a `parent_key` which makes it a part of the primary key in the table definition, but
64
+ # it is not presented to ActiveRecord as part of the primary key, to prevent ActiveRecord from considering this
65
+ # to be an entity with a composite primary key (which is not supported by ActiveRecord).
66
+ t.parent_key :singerid
67
+ t.string :title
68
+ end
69
+
70
+ create_table :tracks, id: false do |t|
71
+ # Interleave the `tracks` table in the parent table `albums` and cascade delete all tracks that belong to an
72
+ # album when an album is deleted.
73
+ t.interleave_in :albums, :cascade
74
+ # Add `trackid` as the primary key in the table definition. Add the other key parts as
75
+ # a `parent_key`.
76
+ t.primary_key :trackid
77
+ # `singerid` and `albumid` form the parent key of `tracks`. These are part of the primary key definition in the
78
+ # database, but are presented as parent keys to ActiveRecord.
79
+ t.parent_key :singerid
80
+ t.parent_key :albumid
81
+ t.string :title
82
+ t.numeric :duration
83
+ end
84
+ ```
85
+
86
+ ## Models for Interleaved Tables
87
+ The model definition for an interleaved table (a child table) must use the `primary_keys=col1, col2, ...`
88
+ function from the `composite_primary_key` gem.
89
+
90
+ An interleaved table parent/child relationship must be modelled as a `belongs_to`/`has_many` association in
91
+ ActiveRecord. As the columns that are used to reference a parent record use a custom column name, it is required to also
92
+ include the custom column name(s) in the `belongs_to` and `has_many` definitions.
93
+
94
+ Instances of these models can be used in the same way as any other association in ActiveRecord, but with a couple of
95
+ inherent limitations:
96
+ * It is not possible to change the parent record of a child record. For instance, changing the singer of an album in the
97
+ above example is impossible, as Cloud Spanner does not allow such an update.
98
+ * It is not possible to de-reference a parent record by setting it to null.
99
+ * It is only possible to delete a parent record with existing child records, if the child records are also deleted. This
100
+ can be done by enabling ON DELETE CASCADE in Cloud Spanner, or by deleting the child records using ActiveRecord.
101
+
102
+ ### Example Models
103
+
104
+ ```ruby
105
+ class Singer < ActiveRecord::Base
106
+ # `albums` is defined as INTERLEAVE IN PARENT `singers`.
107
+ # The primary key of `albums` is (`singerid`, `albumid`).
108
+ has_many :albums, foreign_key: :singerid
109
+
110
+ # `tracks` is defined as INTERLEAVE IN PARENT `albums`.
111
+ # The primary key of `tracks` is (`singerid`, `albumid`, `trackid`).
112
+ # The `singerid` column can be used to associate tracks with a singer without the need to go through albums.
113
+ # Note also that the inclusion of `singerid` as a column in `tracks` is required in order to make `tracks` a child
114
+ # table of `albums` which has primary key (`singerid`, `albumid`).
115
+ has_many :tracks, foreign_key: :singerid
116
+ end
117
+
118
+ class Album < ActiveRecord::Base
119
+ # Use the `composite_primary_key` gem to create a composite primary key definition for the model.
120
+ self.primary_keys = :singerid, :albumid
121
+
122
+ # `albums` is defined as INTERLEAVE IN PARENT `singers`.
123
+ # The primary key of `singers` is `singerid`.
124
+ belongs_to :singer, foreign_key: :singerid
125
+
126
+ # `tracks` is defined as INTERLEAVE IN PARENT `albums`.
127
+ # The primary key of `albums` is (`singerid`, `albumid`).
128
+ has_many :tracks, foreign_key: [:singerid, :albumid]
129
+ end
130
+
131
+ class Track < ActiveRecord::Base
132
+ # Use the `composite_primary_key` gem to create a composite primary key definition for the model.
133
+ self.primary_keys = :singerid, :albumid, :trackid
134
+
135
+ # `tracks` is defined as INTERLEAVE IN PARENT `albums`. The primary key of `albums` is (`singerid`, `albumid`).
136
+ belongs_to :album, foreign_key: [:singerid, :albumid]
137
+
138
+ # `tracks` also has a `singerid` column that can be used to associate a Track with a Singer.
139
+ belongs_to :singer, foreign_key: :singerid
140
+
141
+ # Override the default initialize method to automatically set the singer attribute when an album is given.
142
+ def initialize attributes = nil
143
+ super
144
+ self.singer ||= album&.singer
145
+ end
146
+
147
+ def album=value
148
+ super
149
+ # Ensure the singer of this track is equal to the singer of the album that is set.
150
+ self.singer = value&.singer
151
+ end
152
+ end
153
+ ```
154
+
155
+ ## Running the Sample
156
+
157
+ The sample will automatically start a Spanner Emulator in a docker container and execute the sample
158
+ against that emulator. The emulator will automatically be stopped when the application finishes.
159
+
160
+ Run the application with the command
161
+
162
+ ```bash
163
+ bundle exec rake run
164
+ ```
@@ -0,0 +1,13 @@
1
+ # Copyright 2022 Google LLC
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ require_relative "../config/environment"
8
+ require "sinatra/activerecord/rake"
9
+
10
+ desc "Sample showing how to work with interleaved tables in ActiveRecord."
11
+ task :run do
12
+ Dir.chdir("..") { sh "bundle exec rake run[interleaved-tables]" }
13
+ end