activerecord-spanner-adapter 1.1.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
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