activerecord-spanner-adapter 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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