activerecord-spanner-adapter 1.1.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.
- checksums.yaml +4 -4
- data/.github/CODEOWNERS +1 -1
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +6 -0
- data/Gemfile +4 -1
- data/acceptance/cases/interleaved_associations/has_many_associations_using_interleaved_test.rb +12 -8
- data/acceptance/cases/transactions/optimistic_locking_test.rb +5 -0
- data/acceptance/models/album.rb +7 -2
- data/acceptance/models/singer.rb +2 -2
- data/acceptance/models/track.rb +5 -2
- data/examples/snippets/interleaved-tables/README.md +164 -0
- data/examples/snippets/interleaved-tables/Rakefile +13 -0
- data/examples/snippets/interleaved-tables/application.rb +126 -0
- data/examples/snippets/interleaved-tables/config/database.yml +8 -0
- data/examples/snippets/interleaved-tables/db/migrate/01_create_tables.rb +44 -0
- data/examples/snippets/interleaved-tables/db/schema.rb +32 -0
- data/examples/snippets/interleaved-tables/db/seeds.rb +40 -0
- data/examples/snippets/interleaved-tables/models/album.rb +20 -0
- data/examples/snippets/interleaved-tables/models/singer.rb +18 -0
- data/examples/snippets/interleaved-tables/models/track.rb +28 -0
- data/lib/activerecord_spanner_adapter/base.rb +91 -25
- data/lib/activerecord_spanner_adapter/relation.rb +21 -0
- data/lib/activerecord_spanner_adapter/version.rb +1 -1
- metadata +13 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9b467bf41466950098792dc720fd39065b59cefa8c8bdd211e6f4cd4a3a4e816
|
4
|
+
data.tar.gz: ad927b97190f98d4aa9f6af41b0b32684ae3c1477266a30254a0133586370c31
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6a954866b8f423ad0f4784026c9564cd2f3b551a03c3eff215b77d824fb066df987bf65e53d9e030a0347110ff94f3e7e2e8b754398a577573cd9a573cece8ff
|
7
|
+
data.tar.gz: 808eb7c9f202667c01fe0870ee09714b9d1ca04f75eb260940ae75766958b99137c29d72ff86e519d01d4cc0f52c0d92084aa19bb2ddb5e02e688caecc8bbfda
|
data/.github/CODEOWNERS
CHANGED
@@ -4,4 +4,4 @@
|
|
4
4
|
# For syntax help see:
|
5
5
|
# https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax
|
6
6
|
|
7
|
-
* @
|
7
|
+
* @googleapis/yoshi-ruby @skuruppu @hengfengli @olavloite @xiangshen-dk
|
data/CHANGELOG.md
CHANGED
data/Gemfile
CHANGED
data/acceptance/cases/interleaved_associations/has_many_associations_using_interleaved_test.rb
CHANGED
@@ -20,6 +20,8 @@ module ActiveRecord
|
|
20
20
|
|
21
21
|
def setup
|
22
22
|
super
|
23
|
+
@original_verbosity = $VERBOSE
|
24
|
+
$VERBOSE = nil
|
23
25
|
|
24
26
|
@singer = Singer.create first_name: "FirstName1", last_name: "LastName1"
|
25
27
|
|
@@ -35,6 +37,8 @@ module ActiveRecord
|
|
35
37
|
def teardown
|
36
38
|
Album.destroy_all
|
37
39
|
Singer.destroy_all
|
40
|
+
|
41
|
+
$VERBOSE = @original_verbosity
|
38
42
|
end
|
39
43
|
|
40
44
|
def test_has_many
|
@@ -73,8 +77,8 @@ module ActiveRecord
|
|
73
77
|
|
74
78
|
def test_create_and_destroy_associated_records
|
75
79
|
singer2 = Singer.new first_name: "First", last_name: "Last"
|
76
|
-
singer2.albums.build title: "New Title 1"
|
77
|
-
singer2.albums.build title: "New Title 2"
|
80
|
+
singer2.albums.build title: "New Title 1", albumid: Album.next_sequence_value
|
81
|
+
singer2.albums.build title: "New Title 2", albumid: Album.next_sequence_value
|
78
82
|
singer2.save!
|
79
83
|
|
80
84
|
singer2.reload
|
@@ -91,8 +95,8 @@ module ActiveRecord
|
|
91
95
|
|
92
96
|
def test_create_and_destroy_nested_associated_records
|
93
97
|
album3 = Album.new singer: singer, title: "Title 3"
|
94
|
-
album3.tracks.build title: "Title3_1", duration: 2.5, singer: singer
|
95
|
-
album3.tracks.build title: "Title3_2", singer: singer
|
98
|
+
album3.tracks.build title: "Title3_1", duration: 2.5, singer: singer, trackid: Track.next_sequence_value
|
99
|
+
album3.tracks.build title: "Title3_2", singer: singer, trackid: Track.next_sequence_value
|
96
100
|
album3.save!
|
97
101
|
|
98
102
|
album3.reload
|
@@ -110,8 +114,8 @@ module ActiveRecord
|
|
110
114
|
|
111
115
|
def test_create_and_delete_associated_records
|
112
116
|
singer2 = Singer.new first_name: "First", last_name: "Last"
|
113
|
-
singer2.albums.build title: "Album - 11"
|
114
|
-
singer2.albums.build title: "Album - 12"
|
117
|
+
singer2.albums.build title: "Album - 11", albumid: Album.next_sequence_value
|
118
|
+
singer2.albums.build title: "Album - 12", albumid: Album.next_sequence_value
|
115
119
|
singer2.save!
|
116
120
|
|
117
121
|
singer2.reload
|
@@ -128,8 +132,8 @@ module ActiveRecord
|
|
128
132
|
|
129
133
|
def test_create_and_delete_nested_associated_records
|
130
134
|
album3 = Album.new title: "Album 3", singer: singer
|
131
|
-
album3.tracks.build title: "Track - 31", singer: singer
|
132
|
-
album3.tracks.build title: "Track - 32", singer: singer
|
135
|
+
album3.tracks.build title: "Track - 31", singer: singer, trackid: Track.next_sequence_value
|
136
|
+
album3.tracks.build title: "Track - 32", singer: singer, trackid: Track.next_sequence_value
|
133
137
|
album3.save!
|
134
138
|
|
135
139
|
album3.reload
|
@@ -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
|
data/acceptance/models/album.rb
CHANGED
@@ -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:
|
11
|
-
has_many :tracks, foreign_key:
|
15
|
+
belongs_to :singer, foreign_key: :singerid
|
16
|
+
has_many :tracks, foreign_key: [:singerid, :albumid], dependent: :delete_all
|
12
17
|
end
|
data/acceptance/models/singer.rb
CHANGED
@@ -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:
|
9
|
-
has_many :tracks, foreign_key:
|
8
|
+
has_many :albums, foreign_key: :singerid, dependent: :delete_all
|
9
|
+
has_many :tracks, foreign_key: :singerid
|
10
10
|
end
|
data/acceptance/models/track.rb
CHANGED
@@ -5,8 +5,11 @@
|
|
5
5
|
# https://opensource.org/licenses/MIT.
|
6
6
|
|
7
7
|
class Track < ActiveRecord::Base
|
8
|
-
|
9
|
-
|
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
|
@@ -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
|
@@ -0,0 +1,126 @@
|
|
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 "io/console"
|
8
|
+
require_relative "../config/environment"
|
9
|
+
require_relative "models/singer"
|
10
|
+
require_relative "models/album"
|
11
|
+
require_relative "models/track"
|
12
|
+
|
13
|
+
class Application
|
14
|
+
def self.run
|
15
|
+
# List all singers, albums and tracks.
|
16
|
+
list_singers_albums_tracks
|
17
|
+
|
18
|
+
# Create a new album with some tracks.
|
19
|
+
create_new_album
|
20
|
+
|
21
|
+
# Try to update the singer of an album. This is not possible as albums are interleaved in singers.
|
22
|
+
update_singer_of_album
|
23
|
+
|
24
|
+
# Try to delete a singer that has at least one album. This is NOT possible as albums is NOT marked with
|
25
|
+
# ON DELETE CASCADE.
|
26
|
+
delete_singer_with_albums
|
27
|
+
|
28
|
+
# Try to delete an album that has at least one track. This IS possible as tracks IS marked with
|
29
|
+
# ON DELETE CASCADE.
|
30
|
+
delete_album_with_tracks
|
31
|
+
|
32
|
+
puts ""
|
33
|
+
puts "Press any key to end the application"
|
34
|
+
STDIN.getch
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.find_singer
|
38
|
+
singerid = Singer.all.sample.singerid
|
39
|
+
|
40
|
+
singer = Singer.find singerid
|
41
|
+
puts "Found singer: #{singer.first_name} #{singer.last_name}"
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.find_album
|
45
|
+
singer = Singer.all.sample
|
46
|
+
albumid = singer.albums.sample.albumid
|
47
|
+
|
48
|
+
album = Album.find [singer.singerid, albumid]
|
49
|
+
puts "Found album: #{album.title}"
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.list_singers_albums_tracks
|
53
|
+
puts ""
|
54
|
+
puts "Listing all singers with corresponding albums and tracks"
|
55
|
+
Singer.all.order("last_name, first_name").each do |singer|
|
56
|
+
puts "#{singer.first_name} #{singer.last_name} has #{singer.albums.count} albums:"
|
57
|
+
singer.albums.order("title").each do |album|
|
58
|
+
puts " #{album.title} has #{album.tracks.count} tracks:"
|
59
|
+
album.tracks.each do |track|
|
60
|
+
puts " #{track.title}"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.create_new_album
|
67
|
+
# Create a new album with some tracks.
|
68
|
+
puts ""
|
69
|
+
singer = Singer.all.sample
|
70
|
+
puts "Creating a new album for #{singer.first_name} #{singer.last_name}"
|
71
|
+
album = singer.albums.build title: "New Title"
|
72
|
+
# NOTE: When adding multiple elements to a collection, you *MUST* set the primary key value (i.e. trackid).
|
73
|
+
# Otherwise, ActiveRecord thinks that you are adding the same record multiple times and will only add one.
|
74
|
+
album.tracks.build title: "Track 1", duration: 3.5, singer: singer, trackid: Track.next_sequence_value
|
75
|
+
album.tracks.build title: "Track 2", duration: 3.6, singer: singer, trackid: Track.next_sequence_value
|
76
|
+
# This will save the album and corresponding tracks in one transaction.
|
77
|
+
album.save!
|
78
|
+
|
79
|
+
album.reload
|
80
|
+
puts "Album #{album.title} has #{album.tracks.count} tracks:"
|
81
|
+
album.tracks.order("title").each do |track|
|
82
|
+
puts " #{track.title} with duration #{track.duration}"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def self.update_singer_of_album
|
87
|
+
# It is not possible to change the singer of an album or the album of a track. This is because the associations
|
88
|
+
# between these are not traditional foreign keys, but an immutable parent-child relationship.
|
89
|
+
album = Album.all.sample
|
90
|
+
new_singer = Singer.all.where.not(singerid: album.singer).sample
|
91
|
+
# This will fail as we cannot assign a new singer to an album as it is an INTERLEAVE IN PARENT relationship.
|
92
|
+
begin
|
93
|
+
album.update! singer: new_singer
|
94
|
+
raise StandardError, "Unexpected error: Updating the singer of an album should not be possible."
|
95
|
+
rescue ActiveRecord::StatementInvalid
|
96
|
+
puts ""
|
97
|
+
puts "Failed to update the singer of an album. This is expected."
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def self.delete_singer_with_albums
|
102
|
+
# Deleting a singer that has albums is not possible, as the INTERLEAVE IN PARENT of albums is not marked with
|
103
|
+
# ON DELETE CASCADE.
|
104
|
+
singer = Album.all.sample.singer
|
105
|
+
begin
|
106
|
+
singer.delete
|
107
|
+
raise StandardError, "Unexpected error: Updating the singer of an album should not be possible."
|
108
|
+
rescue ActiveRecord::StatementInvalid
|
109
|
+
puts ""
|
110
|
+
puts "Failed to delete a singer that has #{singer.albums.count} albums. This is expected."
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def self.delete_album_with_tracks
|
115
|
+
# Deleting an album with tracks is supported, as the INTERLEAVE IN PARENT relationship between tracks and albums is
|
116
|
+
# marked with ON DELETE CASCADE.
|
117
|
+
puts ""
|
118
|
+
puts "Total track count: #{Track.count}"
|
119
|
+
album = Track.all.sample.album
|
120
|
+
puts "Deleting album #{album.title} with #{album.tracks.count} tracks"
|
121
|
+
album.delete
|
122
|
+
puts "Total track count after deletion: #{Track.count}"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
Application.run
|
@@ -0,0 +1,44 @@
|
|
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
|
+
class CreateTables < ActiveRecord::Migration[6.0]
|
8
|
+
def change
|
9
|
+
# Execute the entire migration as one DDL batch.
|
10
|
+
connection.ddl_batch do
|
11
|
+
create_table :singers, id: false do |t|
|
12
|
+
# Explicitly define the primary key with a custom name to prevent all primary key columns from being named `id`.
|
13
|
+
t.primary_key :singerid
|
14
|
+
t.string :first_name
|
15
|
+
t.string :last_name
|
16
|
+
end
|
17
|
+
|
18
|
+
create_table :albums, id: false do |t|
|
19
|
+
# Interleave the `albums` table in the parent table `singers`.
|
20
|
+
t.interleave_in :singers
|
21
|
+
t.primary_key :albumid
|
22
|
+
# `singerid` is defined as a `parent_key` which makes it a part of the primary key in the table definition, but
|
23
|
+
# it is not presented to ActiveRecord as part of the primary key, to prevent ActiveRecord from considering this
|
24
|
+
# to be an entity with a composite primary key (which is not supported by ActiveRecord).
|
25
|
+
t.parent_key :singerid
|
26
|
+
t.string :title
|
27
|
+
end
|
28
|
+
|
29
|
+
create_table :tracks, id: false do |t|
|
30
|
+
# Interleave the `tracks` table in the parent table `albums` and cascade delete all tracks that belong to an
|
31
|
+
# album when an album is deleted.
|
32
|
+
t.interleave_in :albums, :cascade
|
33
|
+
# `trackid` is considered the only primary key column by ActiveRecord.
|
34
|
+
t.primary_key :trackid
|
35
|
+
# `singerid` and `albumid` form the parent key of `tracks`. These are part of the primary key definition in the
|
36
|
+
# database, but are presented as parent keys to ActiveRecord.
|
37
|
+
t.parent_key :singerid
|
38
|
+
t.parent_key :albumid
|
39
|
+
t.string :title
|
40
|
+
t.numeric :duration
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,32 @@
|
|
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", primary_key: "albumid", id: { limit: 8 }, force: :cascade do |t|
|
16
|
+
t.integer "singerid", limit: 8, null: false
|
17
|
+
t.string "title"
|
18
|
+
end
|
19
|
+
|
20
|
+
create_table "singers", primary_key: "singerid", id: { limit: 8 }, force: :cascade do |t|
|
21
|
+
t.string "first_name"
|
22
|
+
t.string "last_name"
|
23
|
+
end
|
24
|
+
|
25
|
+
create_table "tracks", primary_key: "trackid", id: { limit: 8 }, force: :cascade do |t|
|
26
|
+
t.integer "singerid", limit: 8, null: false
|
27
|
+
t.integer "albumid", limit: 8, null: false
|
28
|
+
t.string "title"
|
29
|
+
t.decimal "duration"
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
@@ -0,0 +1,40 @@
|
|
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.rb"
|
8
|
+
require_relative "../models/singer"
|
9
|
+
require_relative "../models/album"
|
10
|
+
require_relative "../models/track"
|
11
|
+
|
12
|
+
first_names = %w[Pete Alice John Ethel Trudy Naomi Wendy Ruben Thomas Elly]
|
13
|
+
last_names = %w[Wendelson Allison Peterson Johnson Henderson Ericsson Aronson Tennet Courtou]
|
14
|
+
|
15
|
+
adjectives = %w[daily happy blue generous cooked bad open]
|
16
|
+
nouns = %w[windows potatoes bank street tree glass bottle]
|
17
|
+
|
18
|
+
verbs = %w[operate waste package chew yield express polish stress slip want cough campaign cultivate report park refer]
|
19
|
+
adverbs = %w[directly right hopefully personally economically privately supposedly consequently fully later urgently]
|
20
|
+
|
21
|
+
durations = [3.14, 5.4, 3.3, 4.1, 5.0, 3.2, 3.0, 3.5, 4.0, 4.5, 5.5, 6.0]
|
22
|
+
|
23
|
+
# This ensures all the records are inserted using one read/write transaction that will use mutations instead of DML.
|
24
|
+
ActiveRecord::Base.transaction isolation: :buffered_mutations do
|
25
|
+
singers = []
|
26
|
+
5.times do
|
27
|
+
singers << Singer.create(first_name: first_names.sample, last_name: last_names.sample)
|
28
|
+
end
|
29
|
+
|
30
|
+
albums = []
|
31
|
+
20.times do
|
32
|
+
singer = singers.sample
|
33
|
+
albums << Album.create(title: "#{adjectives.sample} #{nouns.sample}", singer: singer)
|
34
|
+
end
|
35
|
+
|
36
|
+
200.times do
|
37
|
+
album = albums.sample
|
38
|
+
Track.create title: "#{verbs.sample} #{adverbs.sample}", duration: durations.sample, album: album
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,20 @@
|
|
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 "composite_primary_keys"
|
8
|
+
|
9
|
+
class Album < ActiveRecord::Base
|
10
|
+
# Use the `composite_primary_key` gem to create a composite primary key definition for the model.
|
11
|
+
self.primary_keys = :singerid, :albumid
|
12
|
+
|
13
|
+
# `albums` is defined as INTERLEAVE IN PARENT `singers`.
|
14
|
+
# The primary key of `singers` is `singerid`.
|
15
|
+
belongs_to :singer, foreign_key: :singerid
|
16
|
+
|
17
|
+
# `tracks` is defined as INTERLEAVE IN PARENT `albums`.
|
18
|
+
# The primary key of `albums` is (`singerid`, `albumid`).
|
19
|
+
has_many :tracks, foreign_key: [:singerid, :albumid]
|
20
|
+
end
|
@@ -0,0 +1,18 @@
|
|
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
|
+
class Singer < ActiveRecord::Base
|
8
|
+
# `albums` is defined as INTERLEAVE IN PARENT `singers`.
|
9
|
+
# The primary key of `albums` is (`singerid`, `albumid`).
|
10
|
+
has_many :albums, foreign_key: :singerid
|
11
|
+
|
12
|
+
# `tracks` is defined as INTERLEAVE IN PARENT `albums`.
|
13
|
+
# The primary key of `tracks` is [`singerid`, `albumid`, `trackid`].
|
14
|
+
# The `singerid` column can be used to associate tracks with a singer without the need to go through albums.
|
15
|
+
# Note also that the inclusion of `singerid` as a column in `tracks` is required in order to make `tracks` a child
|
16
|
+
# table of `albums` which has primary key (`singerid`, `albumid`).
|
17
|
+
has_many :tracks, foreign_key: :singerid
|
18
|
+
end
|
@@ -0,0 +1,28 @@
|
|
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
|
+
class Track < ActiveRecord::Base
|
8
|
+
# Use the `composite_primary_key` gem to create a composite primary key definition for the model.
|
9
|
+
self.primary_keys = :singerid, :albumid, :trackid
|
10
|
+
|
11
|
+
# `tracks` is defined as INTERLEAVE IN PARENT `albums`. The primary key of `albums` is ()`singerid`, `albumid`).
|
12
|
+
belongs_to :album, foreign_key: [:singerid, :albumid]
|
13
|
+
|
14
|
+
# `tracks` also has a `singerid` column should be used to associate a Track with a Singer.
|
15
|
+
belongs_to :singer, foreign_key: :singerid
|
16
|
+
|
17
|
+
# Override the default initialize method to automatically set the singer attribute when an album is given.
|
18
|
+
def initialize attributes = nil
|
19
|
+
super
|
20
|
+
self.singer ||= album&.singer
|
21
|
+
end
|
22
|
+
|
23
|
+
def album=value
|
24
|
+
super
|
25
|
+
# Ensure the singer of this track is equal to the singer of the album that is set.
|
26
|
+
self.singer = value&.singer
|
27
|
+
end
|
28
|
+
end
|
@@ -4,6 +4,8 @@
|
|
4
4
|
# license that can be found in the LICENSE file or at
|
5
5
|
# https://opensource.org/licenses/MIT.
|
6
6
|
|
7
|
+
require "activerecord_spanner_adapter/relation"
|
8
|
+
|
7
9
|
module ActiveRecord
|
8
10
|
class TableMetadata # :nodoc:
|
9
11
|
# This attr_reader is private in ActiveRecord 6.0.x and public in 6.1.x. This makes sure it is always available in
|
@@ -41,6 +43,30 @@ module ActiveRecord
|
|
41
43
|
spanner_adapter? && connection&.current_spanner_transaction&.isolation == :buffered_mutations
|
42
44
|
end
|
43
45
|
|
46
|
+
def self._insert_record values
|
47
|
+
return super unless buffered_mutations? || (primary_key && values.is_a?(Hash))
|
48
|
+
|
49
|
+
return _buffer_record values, :insert if buffered_mutations?
|
50
|
+
|
51
|
+
primary_key_value =
|
52
|
+
if primary_key.is_a? Array
|
53
|
+
_set_composite_primary_key_values primary_key, values
|
54
|
+
else
|
55
|
+
_set_single_primary_key_value primary_key, values
|
56
|
+
end
|
57
|
+
if ActiveRecord::VERSION::MAJOR >= 7
|
58
|
+
im = Arel::InsertManager.new arel_table
|
59
|
+
im.insert(values.transform_keys { |name| arel_table[name] })
|
60
|
+
else
|
61
|
+
im = arel_table.compile_insert _substitute_values(values)
|
62
|
+
end
|
63
|
+
connection.insert(im, "#{self} Create", primary_key || false, primary_key_value)
|
64
|
+
end
|
65
|
+
|
66
|
+
def self._upsert_record values
|
67
|
+
_buffer_record values, :insert_or_update
|
68
|
+
end
|
69
|
+
|
44
70
|
def self.insert_all _attributes, _returning: nil, _unique_by: nil
|
45
71
|
raise NotImplementedError, "Cloud Spanner does not support skip_duplicates."
|
46
72
|
end
|
@@ -87,29 +113,13 @@ module ActiveRecord
|
|
87
113
|
end
|
88
114
|
end
|
89
115
|
|
90
|
-
def self._insert_record values
|
91
|
-
return super unless buffered_mutations?
|
92
|
-
|
93
|
-
_buffer_record values, :insert
|
94
|
-
end
|
95
|
-
|
96
|
-
def self._upsert_record values
|
97
|
-
_buffer_record values, :insert_or_update
|
98
|
-
end
|
99
|
-
|
100
116
|
def self._buffer_record values, method
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
primary_key_value ||= values[:"#{primary_key}"]
|
107
|
-
|
108
|
-
if !primary_key_value && prefetch_primary_key?
|
109
|
-
primary_key_value = next_sequence_value
|
110
|
-
values[primary_key] = primary_key_value
|
117
|
+
primary_key_value =
|
118
|
+
if primary_key.is_a? Array
|
119
|
+
_set_composite_primary_key_values primary_key, values
|
120
|
+
else
|
121
|
+
_set_single_primary_key_value primary_key, values
|
111
122
|
end
|
112
|
-
end
|
113
123
|
|
114
124
|
metadata = TableMetadata.new self, arel_table
|
115
125
|
columns, grpc_values = _create_grpc_values_for_insert metadata, values
|
@@ -128,6 +138,43 @@ module ActiveRecord
|
|
128
138
|
primary_key_value
|
129
139
|
end
|
130
140
|
|
141
|
+
def self._set_composite_primary_key_values primary_key, values
|
142
|
+
primary_key_value = []
|
143
|
+
primary_key.each do |col|
|
144
|
+
value = values[col]
|
145
|
+
|
146
|
+
if !value && prefetch_primary_key?
|
147
|
+
value =
|
148
|
+
if ActiveRecord::VERSION::MAJOR >= 7
|
149
|
+
ActiveModel::Attribute.from_database col, next_sequence_value, ActiveModel::Type::BigInteger.new
|
150
|
+
else
|
151
|
+
next_sequence_value
|
152
|
+
end
|
153
|
+
values[col] = value
|
154
|
+
end
|
155
|
+
if value.is_a? ActiveModel::Attribute
|
156
|
+
value = value.value
|
157
|
+
end
|
158
|
+
primary_key_value.append value
|
159
|
+
end
|
160
|
+
primary_key_value
|
161
|
+
end
|
162
|
+
|
163
|
+
def self._set_single_primary_key_value primary_key, values
|
164
|
+
primary_key_value = values[primary_key] || values[primary_key.to_sym]
|
165
|
+
|
166
|
+
if !primary_key_value && prefetch_primary_key?
|
167
|
+
primary_key_value = next_sequence_value
|
168
|
+
if ActiveRecord::VERSION::MAJOR >= 7
|
169
|
+
values[primary_key] = ActiveModel::Attribute.from_database primary_key, primary_key_value,
|
170
|
+
ActiveModel::Type::BigInteger.new
|
171
|
+
else
|
172
|
+
values[primary_key] = primary_key_value
|
173
|
+
end
|
174
|
+
end
|
175
|
+
primary_key_value
|
176
|
+
end
|
177
|
+
|
131
178
|
# Deletes all records of this class. This method will use mutations instead of DML if there is no active
|
132
179
|
# transaction, or if the active transaction has been created with the option isolation: :buffered_mutations.
|
133
180
|
def self.delete_all
|
@@ -289,15 +336,34 @@ module ActiveRecord
|
|
289
336
|
serialized_values
|
290
337
|
end
|
291
338
|
|
292
|
-
def _execute_version_check attempted_action
|
339
|
+
def _execute_version_check attempted_action # rubocop:disable Metrics/AbcSize
|
293
340
|
locking_column = self.class.locking_column
|
294
341
|
previous_lock_value = read_attribute_before_type_cast locking_column
|
295
342
|
|
343
|
+
primary_key = self.class.primary_key
|
344
|
+
if primary_key.is_a? Array
|
345
|
+
pk_sql = ""
|
346
|
+
params = {}
|
347
|
+
param_types = {}
|
348
|
+
id = id_in_database
|
349
|
+
primary_key.each_with_index do |col, idx|
|
350
|
+
pk_sql.concat "`#{col}`=@id#{idx}"
|
351
|
+
pk_sql.concat " AND " if idx < primary_key.length - 1
|
352
|
+
|
353
|
+
params["id#{idx}"] = id[idx]
|
354
|
+
param_types["id#{idx}"] = :INT64
|
355
|
+
end
|
356
|
+
params["lock_version"] = previous_lock_value
|
357
|
+
param_types["lock_version"] = :INT64
|
358
|
+
else
|
359
|
+
pk_sql = "`#{self.class.primary_key}` = @id"
|
360
|
+
params = { "id" => id_in_database, "lock_version" => previous_lock_value }
|
361
|
+
param_types = { "id" => :INT64, "lock_version" => :INT64 }
|
362
|
+
end
|
363
|
+
|
296
364
|
# We need to check the version using a SELECT query, as a mutation cannot include a WHERE clause.
|
297
365
|
sql = "SELECT 1 FROM `#{self.class.arel_table.name}` " \
|
298
|
-
"WHERE
|
299
|
-
params = { "id" => id_in_database, "lock_version" => previous_lock_value }
|
300
|
-
param_types = { "id" => :INT64, "lock_version" => :INT64 }
|
366
|
+
"WHERE #{pk_sql} AND `#{locking_column}` = @lock_version"
|
301
367
|
locked_row = self.class.connection.raw_connection.execute_query sql, params: params, types: param_types
|
302
368
|
raise ActiveRecord::StaleObjectError.new(self, attempted_action) unless locked_row.rows.any?
|
303
369
|
end
|
@@ -0,0 +1,21 @@
|
|
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
|
+
module ActiveRecord
|
8
|
+
module CpkExtension
|
9
|
+
def cpk_subquery stmt
|
10
|
+
return super unless spanner_adapter?
|
11
|
+
# The composite_primary_key gem will by default generate WHERE clauses using an IN clause with a multi-column
|
12
|
+
# sub select, e.g.: SELECT * FROM my_table WHERE (id1, id2) IN (SELECT id1, id2 FROM my_table WHERE ...).
|
13
|
+
# This is not supported in Cloud Spanner. Instead, composite_primary_key should generate an EXISTS clause.
|
14
|
+
cpk_exists_subquery stmt
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class Relation
|
19
|
+
prepend CpkExtension
|
20
|
+
end
|
21
|
+
end
|
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: 1.
|
4
|
+
version: 1.2.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: 2022-
|
11
|
+
date: 2022-08-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: google-cloud-spanner
|
@@ -387,6 +387,16 @@ files:
|
|
387
387
|
- examples/snippets/hints/db/seeds.rb
|
388
388
|
- examples/snippets/hints/models/album.rb
|
389
389
|
- examples/snippets/hints/models/singer.rb
|
390
|
+
- examples/snippets/interleaved-tables/README.md
|
391
|
+
- examples/snippets/interleaved-tables/Rakefile
|
392
|
+
- examples/snippets/interleaved-tables/application.rb
|
393
|
+
- examples/snippets/interleaved-tables/config/database.yml
|
394
|
+
- examples/snippets/interleaved-tables/db/migrate/01_create_tables.rb
|
395
|
+
- examples/snippets/interleaved-tables/db/schema.rb
|
396
|
+
- examples/snippets/interleaved-tables/db/seeds.rb
|
397
|
+
- examples/snippets/interleaved-tables/models/album.rb
|
398
|
+
- examples/snippets/interleaved-tables/models/singer.rb
|
399
|
+
- examples/snippets/interleaved-tables/models/track.rb
|
390
400
|
- examples/snippets/migrations/README.md
|
391
401
|
- examples/snippets/migrations/Rakefile
|
392
402
|
- examples/snippets/migrations/application.rb
|
@@ -492,6 +502,7 @@ files:
|
|
492
502
|
- lib/activerecord_spanner_adapter/index/column.rb
|
493
503
|
- lib/activerecord_spanner_adapter/information_schema.rb
|
494
504
|
- lib/activerecord_spanner_adapter/primary_key.rb
|
505
|
+
- lib/activerecord_spanner_adapter/relation.rb
|
495
506
|
- lib/activerecord_spanner_adapter/table.rb
|
496
507
|
- lib/activerecord_spanner_adapter/table/column.rb
|
497
508
|
- lib/activerecord_spanner_adapter/transaction.rb
|