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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a2ada6b62b88752f24052c1071bb3df30e1057e711045d26ecf1403b891d5b7
4
- data.tar.gz: 831ec65b5bb4b25ce13970213a5f626e327d9ab6972f86f21076363218fc3ef7
3
+ metadata.gz: 9b467bf41466950098792dc720fd39065b59cefa8c8bdd211e6f4cd4a3a4e816
4
+ data.tar.gz: ad927b97190f98d4aa9f6af41b0b32684ae3c1477266a30254a0133586370c31
5
5
  SHA512:
6
- metadata.gz: 781bb93f7a9d505864b7b242088b51921df1386c4859e112057bd8b1d3c7bcbee1e3f941af2bcf99324537c136b667464c6328630aa6bd7a769fb9ee52ace690
7
- data.tar.gz: 485369ec08bd2d3dac01edb57425dd003cace09581bf01fd4d7300a1d4142bc8a701a4d83f18b56c740c7b52f9d2c58d7af0eb3683bafb3e4e81afc59e2e2dc5
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
- * @jiren @skuruppu @dazuma @hengfengli @olavloite @xiangshen-dk
7
+ * @googleapis/yoshi-ruby @skuruppu @hengfengli @olavloite @xiangshen-dk
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.1.0"
2
+ ".": "1.2.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ### 1.2.0 (2022-08-03)
4
+
5
+ #### Features
6
+
7
+ * support composite primary keys for interleaved tables ([#175](https://github.com/googleapis/ruby-spanner-activerecord/issues/175))
8
+
3
9
  ### 1.1.0 (2022-06-24)
4
10
 
5
11
  #### Features
data/Gemfile CHANGED
@@ -8,6 +8,9 @@ gem "minitest", "~> 5.15.0"
8
8
  gem "pry", "~> 0.13.0"
9
9
  gem "pry-byebug", "~> 3.9.0"
10
10
 
11
+ # Required for samples and testing.
12
+ gem "composite_primary_keys"
13
+
11
14
  # Required for samples
12
- gem 'docker-api'
15
+ gem "docker-api"
13
16
  gem "sinatra-activerecord"
@@ -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
@@ -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
@@ -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,8 @@
1
+ development:
2
+ adapter: spanner
3
+ emulator_host: localhost:9010
4
+ project: test-project
5
+ instance: test-instance
6
+ database: testdb
7
+ pool: 5
8
+ timeout: 5000
@@ -0,0 +1,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
- primary_key = self.primary_key
102
- primary_key_value = nil
103
-
104
- if primary_key && values.is_a?(Hash)
105
- primary_key_value = values[primary_key]
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 `#{self.class.primary_key}` = @id AND `#{locking_column}` = @lock_version"
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
@@ -5,5 +5,5 @@
5
5
  # https://opensource.org/licenses/MIT.
6
6
 
7
7
  module ActiveRecordSpannerAdapter
8
- VERSION = "1.1.0".freeze
8
+ VERSION = "1.2.0".freeze
9
9
  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.1.0
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-06-24 00:00:00.000000000 Z
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