Skip to content

Commit 877773b

Browse files
authored
Merge pull request #29 from Invoca/TECH-4995/restore-model-pk-detection
TECH-4995: fix bug detecting compound primary keys in Rails 4
2 parents faf35f1 + e77738b commit 877773b

File tree

7 files changed

+97
-29
lines changed

7 files changed

+97
-29
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44

55
Note: this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.4.1] - Unreleased
8+
### Fixed
9+
- Fixed a bug detecting compound primary keys in Rails 4.
10+
711
## [0.4.0] - 2020-11-20
812
### Added
913
- Fields may be declared with `serialize: true` (any value with a valid `.to_yaml` stored as YAML),
@@ -61,6 +65,7 @@ using the appropriate Rails configuration attributes.
6165
### Added
6266
- Initial version from https://github.com/Invoca/hobo_fields v4.1.0.
6367

68+
[0.4.1]: https://github.com/Invoca/declare_schema/compare/v0.4.0...v0.4.1
6469
[0.4.0]: https://github.com/Invoca/declare_schema/compare/v0.3.1...v0.4.0
6570
[0.3.1]: https://github.com/Invoca/declare_schema/compare/v0.3.0...v0.3.1
6671
[0.3.0]: https://github.com/Invoca/declare_schema/compare/v0.2.0...v0.3.0

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
declare_schema (0.4.0)
4+
declare_schema (0.4.1)
55
rails (>= 4.2)
66

77
GEM

lib/declare_schema/model/index_definition.rb

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,9 @@ class << self
3737
def for_model(model, old_table_name = nil)
3838
t = old_table_name || model.table_name
3939

40-
primary_key_columns = Array(model.connection.primary_key(t)).presence || begin
41-
cols = model.connection.columns(t)
42-
Array(
43-
if cols.any? { |col| col.name == 'id' }
44-
'id'
45-
else
46-
cols.find { |col| col.type.to_s.include?('int') }&.name or raise "could not guess primary key for #{t} in #{cols.inspect}"
47-
end
48-
)
49-
end
40+
primary_key_columns = Array(model.connection.primary_key(t)).presence || sqlite_compound_primary_key(model, t) or
41+
raise "could not find primary key for table #{t} in #{model.connection.columns(t).inspect}"
42+
5043
primary_key_found = false
5144
index_definitions = model.connection.indexes(t).map do |i|
5245
model.ignore_indexes.include?(i.name) and next
@@ -55,7 +48,8 @@ def for_model(model, old_table_name = nil)
5548
raise "primary key on #{t} was not unique on #{primary_key_columns} (was unique=#{i.unique} on #{i.columns})"
5649
primary_key_found = true
5750
elsif i.columns == primary_key_columns && i.unique
58-
raise "found primary key index on #{t}.#{primary_key_columns} but it was called #{i.name}"
51+
# skip this primary key index since we'll create it below, with PRIMARY_KEY_NAME
52+
next
5953
end
6054
new(model, i.columns, name: i.name, unique: i.unique, where: i.where, table_name: old_table_name)
6155
end.compact
@@ -65,6 +59,30 @@ def for_model(model, old_table_name = nil)
6559
end
6660
index_definitions
6761
end
62+
63+
private
64+
65+
# This is the old approach which is still needed for SQLite
66+
def sqlite_compound_primary_key(model, table)
67+
ActiveRecord::Base.connection.class.name.match?(/SQLite3Adapter/) or return nil
68+
69+
connection = model.connection.dup
70+
71+
class << connection # defeat Rails MySQL driver code that skips the primary key by changing its name to a symbol
72+
def each_hash(result)
73+
super do |hash|
74+
if hash[:Key_name] == PRIMARY_KEY_NAME
75+
hash[:Key_name] = PRIMARY_KEY_NAME.to_sym
76+
end
77+
yield hash
78+
end
79+
end
80+
end
81+
82+
pk_index = connection.indexes(table).find { |index| index.name.to_s == PRIMARY_KEY_NAME } or return nil
83+
84+
Array(pk_index.columns)
85+
end
6886
end
6987

7088
def primary_key?

lib/declare_schema/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module DeclareSchema
4-
VERSION = "0.4.0"
4+
VERSION = "0.4.1"
55
end

spec/lib/declare_schema/api_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class Advert < #{active_record_base_class}
3939

4040
load_models
4141

42-
unless Rails::VERSION::MAJOR >= 6
42+
if Rails::VERSION::MAJOR == 5
4343
# TODO: get this to work on Travis for Rails 6
4444
generate_migrations '-n', '-m'
4545
end

spec/lib/declare_schema/migration_generator_spec.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ class Advert < ActiveRecord::Base
4343
ActiveRecord::Migration.class_eval(up)
4444
expect(Advert.columns.map(&:name)).to eq(["id", "name"])
4545

46+
if Rails::VERSION::MAJOR < 5
47+
# Rails 4 sqlite driver doesn't create PK properly. Fix that by dropping and recreating.
48+
ActiveRecord::Base.connection.execute("drop table adverts")
49+
ActiveRecord::Base.connection.execute('CREATE TABLE "adverts" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(255))')
50+
end
51+
4652
class Advert < ActiveRecord::Base
4753
fields do
4854
name :string, limit: 255, null: true
@@ -610,9 +616,8 @@ class Advertisement < ActiveRecord::Base
610616
# Dropping tables is where the automatic down-migration really comes in handy:
611617

612618
rails4_table_create = <<~EOS.strip
613-
create_table "adverts", id: false, force: :cascade do |t|
614-
t.integer "id", limit: 8
615-
t.string "name", limit: 255
619+
create_table "adverts", force: :cascade do |t|
620+
t.string "name", limit: 255
616621
end
617622
EOS
618623

spec/lib/declare_schema/model/index_definition_spec.rb

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,25 @@
1313
before do
1414
load File.expand_path('../prepare_testapp.rb', __dir__)
1515

16-
class IndexSettingsTestModel < ActiveRecord::Base
16+
class IndexDefinitionTestModel < ActiveRecord::Base
1717
fields do
1818
name :string, limit: 127, index: true
1919

2020
timestamps
2121
end
2222
end
23+
24+
class IndexDefinitionCompoundIndexModel < ActiveRecord::Base
25+
fields do
26+
fk1_id :integer
27+
fk2_id :integer
28+
29+
timestamps
30+
end
31+
end
2332
end
2433

25-
let(:model_class) { IndexSettingsTestModel }
34+
let(:model_class) { IndexDefinitionTestModel }
2635

2736
describe 'instance methods' do
2837
let(:model) { model_class.new }
@@ -53,28 +62,59 @@ class IndexSettingsTestModel < ActiveRecord::Base
5362
context 'with a migrated database' do
5463
before do
5564
ActiveRecord::Base.connection.execute <<~EOS
56-
CREATE TABLE index_settings_test_models (
65+
CREATE TABLE index_definition_test_models (
5766
id INTEGER NOT NULL PRIMARY KEY,
5867
name TEXT NOT NULL
5968
)
6069
EOS
6170
ActiveRecord::Base.connection.execute <<~EOS
62-
CREATE UNIQUE INDEX index_settings_test_models_on_name ON index_settings_test_models(name)
71+
CREATE UNIQUE INDEX index_definition_test_models_on_name ON index_definition_test_models(name)
72+
EOS
73+
ActiveRecord::Base.connection.execute <<~EOS
74+
CREATE TABLE index_definition_compound_index_models (
75+
fk1_id INTEGER NULL,
76+
fk2_id INTEGER NULL,
77+
PRIMARY KEY (fk1_id, fk2_id)
78+
)
6379
EOS
6480
ActiveRecord::Base.connection.schema_cache.clear!
6581
end
6682

6783
describe 'for_model' do
6884
subject { described_class.for_model(model_class) }
6985

70-
it 'returns the indexes for the model' do
71-
expect(subject.size).to eq(2), subject.inspect
72-
expect([:name, :columns, :unique].map { |attr| subject[0].send(attr) }).to eq(
73-
['index_settings_test_models_on_name', ['name'], true]
74-
)
75-
expect([:name, :columns, :unique].map { |attr| subject[1].send(attr) }).to eq(
76-
['PRIMARY', ['id'], true]
77-
)
86+
context 'with single-column PK' do
87+
it 'returns the indexes for the model' do
88+
expect(subject.size).to eq(2), subject.inspect
89+
expect([:name, :columns, :unique].map { |attr| subject[0].send(attr) }).to eq(
90+
['index_definition_test_models_on_name', ['name'], true]
91+
)
92+
expect([:name, :columns, :unique].map { |attr| subject[1].send(attr) }).to eq(
93+
['PRIMARY', ['id'], true]
94+
)
95+
end
96+
end
97+
98+
context 'with compound-column PK' do
99+
let(:model_class) { IndexDefinitionCompoundIndexModel }
100+
101+
it 'returns the indexes for the model' do
102+
# Simulate MySQL for Rails 4 work-around
103+
if Rails::VERSION::MAJOR < 5
104+
expect(model_class.connection).to receive(:primary_key).with('index_definition_compound_index_models').and_return(nil)
105+
connection_stub = instance_double(ActiveRecord::ConnectionAdapters::SQLite3Adapter, "connection")
106+
expect(connection_stub).to receive(:indexes).
107+
with('index_definition_compound_index_models').
108+
and_return([DeclareSchema::Model::IndexDefinition.new(model_class, ['fk1_id', 'fk2_id'], name: 'PRIMARY')])
109+
110+
expect(model_class.connection).to receive(:dup).and_return(connection_stub)
111+
end
112+
113+
expect(subject.size).to eq(1), subject.inspect
114+
expect([:name, :columns, :unique].map { |attr| subject[0].send(attr) }).to eq(
115+
['PRIMARY', ['fk1_id', 'fk2_id'], true]
116+
)
117+
end
78118
end
79119
end
80120
end

0 commit comments

Comments
 (0)