declare_schema 3.0.0 → 3.1.0.colin.2

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.
@@ -100,6 +100,7 @@ module DeclareSchema
100
100
  _add_validations_for_field(name, type, args, options)
101
101
  _add_index_for_field(name, args, **options)
102
102
  _add_scopes_for_field(name, type, **options)
103
+ name.to_s == _declared_primary_key and raise ArgumentError, "no need to declare a field spec for the primary key #{name.inspect}"
103
104
  field_specs[name] = ::DeclareSchema::Model::FieldSpec.new(self, name, type, position: field_specs.size, **options)
104
105
  attr_order << name unless attr_order.include?(name)
105
106
  end
@@ -201,62 +202,105 @@ module DeclareSchema
201
202
  super
202
203
 
203
204
  reflection = reflections[name.to_s] or raise "Couldn't find reflection #{name} in #{reflections.keys}"
204
- foreign_key_column = reflection.foreign_key or raise "Couldn't find foreign_key for #{name} in #{reflection.inspect}"
205
- foreign_key_column_options = column_options.dup
206
-
207
- # Note: the foreign key limit: should match the primary key limit:. (If there is a foreign key constraint,
208
- # those limits _must_ match.) We'd like to call _infer_fk_limit and get the limit right from the PK.
209
- # But we can't here, because that will mess up the autoloader to follow every belongs_to association right
210
- # when it is declared. So instead we assume :bigint (integer limit: 8) below, while also registering this
211
- # pre_migration: callback to double-check that assumption Just In Time--right before we generate a migration.
212
- #
213
- # The one downside of this approach is that application code that asks the field_spec for the declared
214
- # foreign key limit: will always get 8 back even if this is a grandfathered foreign key that points to
215
- # a limit: 4 primary key. It seems unlikely that any application code would do this.
216
- foreign_key_column_options[:pre_migration] = ->(field_spec) do
217
- if (inferred_limit = _infer_fk_limit(foreign_key_column, reflection))
218
- field_spec.sql_options[:limit] = inferred_limit
219
- end
220
- end
205
+ foreign_key_column_name = reflection.foreign_key or raise "Couldn't find foreign_key for #{name} in #{reflection.inspect}"
221
206
 
222
- declare_field(foreign_key_column.to_sym, :bigint, **foreign_key_column_options)
207
+ field_specs[foreign_key_column_name] = _infer_foreign_key_field_spec(foreign_key_column_name, reflection, column_options)
223
208
 
224
209
  if reflection.options[:polymorphic]
225
210
  foreign_type = options[:foreign_type] || "#{name}_type"
226
211
  _declare_polymorphic_type_field(foreign_type, column_options)
227
212
  if ::DeclareSchema.default_generate_indexing && index_options
228
- index([foreign_type, foreign_key_column], **index_options)
213
+ index([foreign_type, foreign_key_column_name], **index_options)
229
214
  end
230
215
  else
231
216
  if ::DeclareSchema.default_generate_indexing && index_options
232
- index([foreign_key_column], **index_options)
217
+ index([foreign_key_column_name], **index_options)
233
218
  end
234
219
 
235
220
  if ::DeclareSchema.default_generate_foreign_keys && constraint_name != false
236
- constraint(foreign_key_column, constraint_name: constraint_name || index_options&.[](:name), parent_class_name: reflection.class_name, dependent: dependent_delete)
221
+ constraint(foreign_key_column_name,
222
+ constraint_name: constraint_name || index_options&.[](:name),
223
+ parent_class_name: reflection.class_name,
224
+ dependent: dependent_delete)
237
225
  end
238
226
  end
239
227
  end
240
228
 
241
- def _infer_fk_limit(foreign_key_column, reflection)
229
+ # Returns a FieldSpec for the foreign key column of a belongs_to association.
230
+ # - For a polymorphic association, the FK uses `DeclareSchema.default_generated_primary_key_type`
231
+ # (mirroring `config.generators.primary_key_type`, default :bigint), or :integer with the
232
+ # existing column's limit if the column already exists in the database.
233
+ # - For a non-polymorphic association, the FK should mirror the primary key it points
234
+ # at (same data type, same options like limit:, charset:, etc.). However we cannot
235
+ # load the parent model right now (at `belongs_to` time) without risking dependency
236
+ # cycles between models, so we install a `resolver:` callback. The migration
237
+ # generator calls that resolver at generation time -- after all models are
238
+ # eager-loaded -- and the resolver returns a fully-mirrored FieldSpec that the
239
+ # generator swaps in for this default_spec.
240
+ def _infer_foreign_key_field_spec(foreign_key_column_name, reflection, column_options)
242
241
  if reflection.options[:polymorphic]
243
- if (foreign_key_column = _column(foreign_key_column)) && foreign_key_column.type == :integer
244
- foreign_key_column.limit
242
+ if (foreign_key_column = _column(foreign_key_column_name)) && foreign_key_column.type == :integer
243
+ # grandfather foreign key column to match what's in the database
244
+ column_options = column_options.merge(limit: foreign_key_column.limit)
245
245
  end
246
+ FieldSpec.new(self, foreign_key_column_name, DeclareSchema.default_generated_primary_key_type, position: field_specs.size, **column_options)
246
247
  else
247
- klass = reflection.klass or raise "Couldn't find belongs_to klass for #{name} in #{reflection.inspect}"
248
- if (pk_id_type = klass._table_options&.[](:id))
249
- if pk_id_type == :integer
250
- 4
251
- end
252
- else
253
- if klass.table_exists? && (pk_column = klass.columns_hash[klass._declared_primary_key])
254
- pk_id_type = pk_column.type
255
- if pk_id_type == :integer
256
- pk_column.limit
257
- end
258
- end
248
+ # Capture only what we need from `reflection` (no `reflection.klass` here -- that
249
+ # would force the parent model to load, which is exactly the cycle we are avoiding).
250
+ # `reflection.klass` is resolved lazily inside the block below.
251
+ resolver = ->(default_spec) do
252
+ _resolve_belongs_to_foreign_key_field_spec(reflection, default_spec)
259
253
  end
254
+ FieldSpec.new(self, foreign_key_column_name, DeclareSchema.default_generated_primary_key_type,
255
+ position: field_specs.size, resolver:, **column_options)
256
+ end
257
+ end
258
+
259
+ # Called at migration generation time to mirror the parent model's primary key.
260
+ # Always returns a FieldSpec: the default_spec unchanged when the parent class is not
261
+ # a declare_schema model (we can't ask for its PK spec, so the configured default PK
262
+ # type is the best we can offer without inspecting the DB), otherwise a fully mirrored
263
+ # FieldSpec.
264
+ #
265
+ # Reconciliation with the live DB: if the parent's PK column already exists in the
266
+ # database with the same Rails type but a different :limit (e.g. a legacy table where
267
+ # `id` is INT(4) but the model now declares the default :bigint), prefer the live
268
+ # column's :limit so the FK matches what's actually on disk. This preserves the
269
+ # behavior of the old DB-column lookup (formerly `fk_field_options`) without
270
+ # overriding intentional type changes.
271
+ def _resolve_belongs_to_foreign_key_field_spec(reflection, default_spec)
272
+ klass = reflection.klass or
273
+ raise "Couldn't find belongs_to klass for #{reflection.name} on #{name} in #{reflection.inspect}"
274
+
275
+ if klass.respond_to?(:_primary_key_field_spec)
276
+ _mirror_parent_primary_key(klass, default_spec)
277
+ else
278
+ default_spec
279
+ end
280
+ end
281
+
282
+ # Build a FieldSpec for the FK by mirroring the parent's declared primary key,
283
+ # then reconciling against the live DB column when it differs only in :limit
284
+ # (see _resolve_belongs_to_foreign_key_field_spec for the full rationale).
285
+ def _mirror_parent_primary_key(klass, default_spec)
286
+ spec = klass._primary_key_field_spec.foreign_key_field_spec(
287
+ default_spec.model, default_spec.name,
288
+ position: default_spec.position, null: default_spec.null
289
+ )
290
+
291
+ # Look up the parent's live PK column directly (not via _column, whose
292
+ # @table_exists memoization can pin to a stale value when the parent table
293
+ # is created after the model class is first defined). The rescue covers
294
+ # the table-doesn't-exist-yet case (greenfield migration).
295
+ live_pk_column = klass.columns_hash[klass._declared_primary_key.to_s] rescue nil
296
+ if live_pk_column && live_pk_column.type == spec.type && live_pk_column.limit && live_pk_column.limit != spec.limit
297
+ FieldSpec.new(
298
+ spec.model, spec.name, spec.type,
299
+ position: spec.position,
300
+ **spec.options.merge(limit: live_pk_column.limit)
301
+ )
302
+ else
303
+ spec
260
304
  end
261
305
  end
262
306
 
@@ -273,8 +317,42 @@ module DeclareSchema
273
317
  end
274
318
  end
275
319
 
320
+ # Returns a FieldSpec for a foreign key pointing to the primary key of this model.
321
+ # Exactly matches the primary key type.
322
+ def _foreign_key_field_spec(model, foreign_key, position:, null:)
323
+ _primary_key_field_spec.foreign_key_field_spec(model, foreign_key, position:, null:)
324
+ end
325
+
326
+ def _primary_key_field_spec
327
+ declared_primary_key = _declared_primary_key
328
+ field_specs[declared_primary_key] || _primary_key_field_spec_from_table_options(declared_primary_key) or
329
+ raise "Declared primary key #{declared_primary_key.inspect} not found in field_specs or _table_options #{_table_options.inspect} for #{name}"
330
+ end
331
+
332
+ def _primary_key_field_spec_from_table_options(declared_primary_key)
333
+ # _table_options is nil on STI subclasses that never call `declare_schema` themselves:
334
+ # they inherit `field_specs` etc. via `inheriting_cattr_reader`, but `@_table_options`
335
+ # is a plain class-instance variable on each class, so the subclass's reader returns
336
+ # nil. Treat that the same as an empty options hash and fall through to
337
+ # `default_generated_primary_key_type` below.
338
+ type, options = _parse_pk_table_options(_table_options&.[](declared_primary_key.to_sym))
339
+ type ||= DeclareSchema.default_generated_primary_key_type
340
+ FieldSpec.new(self, declared_primary_key, type, **options)
341
+ end
342
+
276
343
  private
277
344
 
345
+ # `declare_schema id: ...` accepts either a Hash (`id: { type: :integer, limit: 4 }`)
346
+ # or a bare type Symbol (`id: :integer`). Returns [type, options_hash], with type == nil
347
+ # when value is neither (caller falls back to a default).
348
+ def _parse_pk_table_options(value)
349
+ case value
350
+ when Hash then [value[:type], value.except(:type)]
351
+ when Symbol then [value, {}]
352
+ else [nil, {}]
353
+ end
354
+ end
355
+
278
356
  # if this is a derived class, returns the base class's _declared_primary_key
279
357
  # otherwise, returns 'id'
280
358
  def _default_declared_primary_key
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeclareSchema
4
- VERSION = "3.0.0"
4
+ VERSION = "3.1.0.colin.2"
5
5
  end
@@ -162,17 +162,21 @@ module DeclareSchema
162
162
  end
163
163
 
164
164
  def current_adapter(model_class = ActiveRecord::Base)
165
- if Rails::VERSION::MAJOR >= 6.1
166
- model_class.connection_db_config.adapter
167
- else
168
- model_class.connection_config[:adapter]
169
- end
165
+ model_class.connection_db_config.adapter
166
+ end
167
+
168
+ # Default primary key type for generated foreign keys when we have no parent
169
+ # model to mirror (e.g. polymorphic FKs, non-declare_schema parents). Mirrors
170
+ # `config.generators.primary_key_type` so apps that override the Rails default
171
+ # get consistent behavior. Memoized — Rails config is set at boot.
172
+ def default_generated_primary_key_type
173
+ @default_generated_primary_key_type ||=
174
+ Rails.application.config.generators.options.dig(:active_record, :primary_key_type) || :bigint
170
175
  end
171
176
  end
172
177
  end
173
178
 
174
179
  require 'declare_schema/extensions/active_record/fields_declaration'
175
- require 'declare_schema/field_declaration_dsl'
176
180
  require 'declare_schema/model'
177
181
  require 'declare_schema/model/field_spec'
178
182
  require 'declare_schema/model/index_definition'
@@ -80,15 +80,13 @@ module Generators
80
80
  self.class.native_types
81
81
  end
82
82
 
83
- # list habtm join tables
83
+ # return habtm reflections keyed by join table name (so that habtm from each side has just one entry)
84
84
  def habtm_tables
85
- reflections = Hash.new { |h, k| h[k] = [] }
86
- ActiveRecord::Base.send(:descendants).map do |c|
85
+ ActiveRecord::Base.send(:descendants).each_with_object({}) do |c, result|
87
86
  c.reflect_on_all_associations(:has_and_belongs_to_many).each do |a|
88
- reflections[a.join_table] << a
87
+ result[a.join_table] ||= a
89
88
  end
90
89
  end
91
- reflections
92
90
  end
93
91
 
94
92
  # Returns an array of model classes and an array of table names
@@ -189,11 +187,7 @@ module Generators
189
187
  models, db_tables = models_and_tables
190
188
  models_by_table_name = {}
191
189
  models.each do |m|
192
- m.try(:field_specs)&.each do |_name, field_spec|
193
- if (pre_migration = field_spec.options.delete(:pre_migration))
194
- pre_migration.call(field_spec)
195
- end
196
- end
190
+ m.try(:field_specs)&.transform_values!(&:resolve)
197
191
 
198
192
  if !models_by_table_name.has_key?(m.table_name)
199
193
  models_by_table_name[m.table_name] = m
@@ -204,8 +198,8 @@ module Generators
204
198
  end
205
199
  end
206
200
  # generate shims for HABTM models
207
- habtm_tables.each do |name, reflections|
208
- models_by_table_name[name] = ::DeclareSchema::Model::HabtmModelShim.from_reflection(reflections.first)
201
+ habtm_tables.each do |name, reflection|
202
+ models_by_table_name[name] = ::DeclareSchema::Model::HabtmModelShim.from_reflection(reflection)
209
203
  end
210
204
  model_table_names = models_by_table_name.keys
211
205
 
@@ -376,21 +370,26 @@ module Generators
376
370
  ::DeclareSchema::SchemaChange::ColumnRename.new(new_table_name, old_name, new_name)
377
371
  end
378
372
 
379
- to_add.sort_by! { |c| model.field_specs[c]&.position || 0 }
373
+ to_add.sort_by! { model.field_specs[_1]&.position || 0 }
380
374
 
381
- adds = to_add.map do |c|
375
+ adds = to_add.map do |col_name_to_add|
382
376
  type, options =
383
- if (spec = model.field_specs[c])
384
- [spec.type, spec.sql_options.merge(fk_field_options(model, c)).compact]
377
+ if (spec = model.field_specs[col_name_to_add])
378
+ [spec.type, spec.sql_options.compact]
385
379
  else
386
- [:integer, {}]
380
+ # No FieldSpec for this column: it's the declared PK appended at line 366
381
+ # because it isn't in the DB yet. Use the configured default PK type so
382
+ # apps that override config.generators.primary_key_type get consistent
383
+ # behavior; the actual PRIMARY KEY designation is added separately by
384
+ # PrimaryKeyChange.
385
+ [::DeclareSchema.default_generated_primary_key_type, {}]
387
386
  end
388
- ::DeclareSchema::SchemaChange::ColumnAdd.new(new_table_name, c, type, **options)
387
+ ::DeclareSchema::SchemaChange::ColumnAdd.new(new_table_name, col_name_to_add, type, **options)
389
388
  end
390
389
 
391
- removes = to_remove.map do |c|
392
- old_type, old_options = add_column_back(model, current_table_name, c)
393
- ::DeclareSchema::SchemaChange::ColumnRemove.new(new_table_name, c, old_type, **old_options)
390
+ removes = to_remove.map do |col_name_to_remove|
391
+ old_type, old_options = add_column_back(model, current_table_name, col_name_to_remove)
392
+ ::DeclareSchema::SchemaChange::ColumnRemove.new(new_table_name, col_name_to_remove, old_type, **old_options)
394
393
  end
395
394
 
396
395
  old_names = to_rename.invert
@@ -402,13 +401,12 @@ module Generators
402
401
  spec_attrs = spec.schema_attributes(column)
403
402
  column_declaration = ::DeclareSchema::Model::Column.new(model, current_table_name, column)
404
403
  col_attrs = column_declaration.schema_attributes
405
- normalized_schema_attrs = spec_attrs.merge(fk_field_options(model, col_name_to_change))
406
404
 
407
- if !::DeclareSchema::Model::Column.equivalent_schema_attributes?(normalized_schema_attrs, col_attrs)
408
- type = normalized_schema_attrs.delete(:type) or raise "no :type found in #{normalized_schema_attrs.inspect}"
405
+ if !::DeclareSchema::Model::Column.equivalent_schema_attributes?(spec_attrs, col_attrs)
406
+ type = spec_attrs.delete(:type) or raise "no :type found in #{spec_attrs.inspect}"
409
407
  old_type, old_options = change_column_back(model, current_table_name, orig_col_name)
410
408
  changes << ::DeclareSchema::SchemaChange::ColumnChange.new(new_table_name, col_name_to_change,
411
- new_type: type, new_options: normalized_schema_attrs,
409
+ new_type: type, new_options: spec_attrs,
412
410
  old_type: old_type, old_options: old_options)
413
411
  end
414
412
  end
@@ -546,22 +544,6 @@ module Generators
546
544
  end
547
545
  end
548
546
 
549
- def fk_field_options(model, field_name)
550
- if (foreign_key = model.constraint_definitions.find { |fk| field_name == fk.foreign_key_column })
551
- parent_columns = connection.columns(foreign_key.parent_table_name) rescue []
552
- pk_limit =
553
- if (pk_column = parent_columns.find { |column| column.name.to_s == "id" }) # right now foreign keys assume id is the target
554
- pk_column.limit
555
- else
556
- 8
557
- end
558
-
559
- { limit: pk_limit }
560
- else
561
- {}
562
- end
563
- end
564
-
565
547
  def change_table_options(model, current_table_name)
566
548
  old_options_definition = ::DeclareSchema::Model::TableOptionsDefinition.for_model(model, current_table_name)
567
549
  new_options_definition = ::DeclareSchema::Model::TableOptionsDefinition.new(model.table_name, **table_options_for_model(model))
@@ -25,6 +25,38 @@ RSpec.describe DeclareSchema::Model::FieldSpec do
25
25
  end
26
26
  end
27
27
 
28
+ describe '#resolve' do
29
+ let(:default_spec) { described_class.new(model, :advertiser_id, :integer, limit: 8, null: false, position: 1) }
30
+ let(:mirrored_spec) { described_class.new(model, :advertiser_id, :string, limit: 36, null: false, position: 1) }
31
+
32
+ context 'when no resolver was supplied' do
33
+ it 'returns self' do
34
+ expect(default_spec.resolve).to equal(default_spec)
35
+ end
36
+ end
37
+
38
+ context 'when a resolver was supplied' do
39
+ it 'invokes the resolver with self and returns its result' do
40
+ captured = nil
41
+ spec_under_test = described_class.new(model, :advertiser_id, :integer, limit: 8, null: false, position: 1,
42
+ resolver: ->(spec) { captured = spec; mirrored_spec })
43
+
44
+ expect(spec_under_test.resolve).to equal(mirrored_spec)
45
+ expect(captured).to equal(spec_under_test)
46
+ end
47
+
48
+ it 'memoizes the resolver result' do
49
+ call_count = 0
50
+ spec_under_test = described_class.new(model, :advertiser_id, :integer, limit: 8, null: false, position: 1,
51
+ resolver: ->(_spec) { call_count += 1; mirrored_spec })
52
+
53
+ 3.times { spec_under_test.resolve }
54
+
55
+ expect(call_count).to eq(1)
56
+ end
57
+ end
58
+ end
59
+
28
60
  describe '#schema_attributes' do
29
61
  describe 'integer 4' do
30
62
  subject { described_class.new(model, :price, :integer, limit: 4, null: false, position: 0) }
@@ -988,6 +988,140 @@ RSpec.describe 'DeclareSchema Migration Generator' do
988
988
  nuke_model_class(Creative)
989
989
  end
990
990
 
991
+ context 'foreign keys mirror non-default primary key types' do
992
+ after do
993
+ nuke_model_class(SpecialOrder) if defined?(SpecialOrder)
994
+ nuke_model_class(Order) if defined?(Order)
995
+ nuke_model_class(LineItem) if defined?(LineItem)
996
+ nuke_model_class(Tag) if defined?(Tag)
997
+ nuke_model_class(Product) if defined?(Product)
998
+ nuke_model_class(ProductsTagsClass) if defined?(ProductsTagsClass)
999
+ end
1000
+
1001
+ it 'belongs_to defers parent class lookup to migration time (no dependency cycle)' do
1002
+ # LineItem.belongs_to :order is allowed to mention :order before Order is loaded.
1003
+ # If we eagerly resolved reflection.klass at belongs_to time this would raise
1004
+ # NameError: uninitialized constant LineItem::Order.
1005
+ expect do
1006
+ class LineItem < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1007
+ declare_schema { integer :quantity, null: false }
1008
+ belongs_to :order
1009
+ end
1010
+ end.to_not raise_error
1011
+ end
1012
+
1013
+ it 'belongs_to mirrors the parent primary key type and limit (declared via _table_options)' do
1014
+ # Order has a non-default :string primary key; LineItem.belongs_to :order should
1015
+ # produce a :string foreign key with the same limit -- without forcing Order to
1016
+ # load when LineItem is defined.
1017
+ class LineItem < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1018
+ declare_schema do
1019
+ integer :quantity, null: false
1020
+ end
1021
+ belongs_to :order
1022
+ end
1023
+
1024
+ class Order < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1025
+ self.primary_key = "id"
1026
+ declare_schema id: { type: :string, limit: 36 } do
1027
+ string :customer_email, limit: 250, null: false
1028
+ end
1029
+ end
1030
+
1031
+ up, _ = Generators::DeclareSchema::Migration::Migrator.run
1032
+
1033
+ expect(up).to match(/t\.string\s+:order_id, limit: 36, null: false/)
1034
+ end
1035
+
1036
+ context 'when belongs_to points at an STI subclass' do
1037
+ # An STI subclass inherits field_specs (via inheriting_cattr_reader) and the
1038
+ # `_primary_key_field_spec` class method (mixed into its parent), but it never
1039
+ # calls `declare_schema` on itself, so its own `@_table_options` is nil. When a
1040
+ # belongs_to points at the STI subclass, the migrator's resolver calls
1041
+ # `klass._primary_key_field_spec`, which previously crashed with
1042
+ # `NoMethodError: undefined method '[]' for nil` because
1043
+ # `_primary_key_field_spec_from_table_options` indexed `_table_options` without
1044
+ # a nil guard.
1045
+ before do
1046
+ class Order < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1047
+ declare_schema do
1048
+ string :customer_email, limit: 250, null: false
1049
+ end
1050
+ end
1051
+
1052
+ class SpecialOrder < Order # rubocop:disable Lint/ConstantDefinitionInBlock
1053
+ end
1054
+
1055
+ class LineItem < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1056
+ declare_schema do
1057
+ integer :quantity, null: false
1058
+ end
1059
+ belongs_to :special_order
1060
+ end
1061
+ end
1062
+
1063
+ let(:up) { Generators::DeclareSchema::Migration::Migrator.run.first }
1064
+
1065
+ it 'mirrors the base class primary key without crashing' do
1066
+ expect(up).to match(/t\.integer\s+:special_order_id, limit: 8, null: false/)
1067
+ end
1068
+ end
1069
+
1070
+ context 'when a HABTM join table has a parent PK type that drifts from the live column' do
1071
+ before do
1072
+ conn = ActiveRecord::Base.connection
1073
+ conn.create_table(:products) { |t| t.string :name, limit: 100, null: false }
1074
+ conn.create_table(:tags) { |t| t.string :label, limit: 50, null: false }
1075
+ conn.create_table(:products_tags, primary_key: [:product_id, :tag_id]) do |t|
1076
+ t.bigint :product_id, null: false
1077
+ t.bigint :tag_id, null: false
1078
+ end
1079
+ conn.schema_cache.clear!
1080
+
1081
+ class Product < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1082
+ declare_schema do
1083
+ string :name, limit: 100, null: false
1084
+ end
1085
+ has_and_belongs_to_many :tags
1086
+ end
1087
+
1088
+ class Tag < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1089
+ declare_schema id: { type: :string, limit: 36 } do
1090
+ string :label, limit: 50, null: false
1091
+ end
1092
+ has_and_belongs_to_many :products
1093
+ end
1094
+ end
1095
+
1096
+ let(:up) { Generators::DeclareSchema::Migration::Migrator.run.first }
1097
+
1098
+ it 'generates change_column on the join table without crashing' do
1099
+ expect(up).to match(/change_column\s+:products_tags,\s+:tag_id,\s+:string/)
1100
+ end
1101
+ end
1102
+
1103
+ it 'HABTM mirrors both parents primary key types (different non-default types)' do
1104
+ class Product < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1105
+ declare_schema id: { type: :string, limit: 36 } do
1106
+ string :name, limit: 100, null: false
1107
+ end
1108
+ has_and_belongs_to_many :tags
1109
+ end
1110
+
1111
+ class Tag < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1112
+ declare_schema id: { type: :integer, limit: 4 } do
1113
+ string :label, limit: 50, null: false
1114
+ end
1115
+ has_and_belongs_to_many :products
1116
+ end
1117
+
1118
+ up, _ = Generators::DeclareSchema::Migration::Migrator.run
1119
+
1120
+ expect(up).to match(/t\.string\s+:product_id, limit: 36, null: false/)
1121
+ expect(up).to match(/t\.integer\s+:tag_id, limit: 4, null: false/)
1122
+ end
1123
+ end
1124
+
991
1125
  context 'models with the same parent foreign key relation' do
992
1126
  include_context 'skip if' do
993
1127
  let(:adapter) { 'sqlite3' }
@@ -41,7 +41,12 @@ RSpec.describe DeclareSchema::Model::HabtmModelShim do
41
41
  end
42
42
 
43
43
  describe 'instance methods' do
44
- subject { described_class.new(join_table, foreign_keys, parent_table_names, connection: connection) }
44
+ let(:parent_models) { [User, Customer] }
45
+ let(:parents) { foreign_keys.zip(parent_models) }
46
+
47
+ subject do
48
+ described_class.new(join_table, parents, connection: connection)
49
+ end
45
50
 
46
51
  describe '#initialize' do
47
52
  it 'stores initialization attributes' do
@@ -88,8 +93,8 @@ RSpec.describe DeclareSchema::Model::HabtmModelShim do
88
93
  end
89
94
 
90
95
  describe '#primary_key' do
91
- it 'returns false because there is no single-column PK for ActiveRecord to use' do
92
- expect(subject.primary_key).to eq(false)
96
+ it 'returns the composite of the two foreign keys (alphabetically sorted)' do
97
+ expect(subject.primary_key).to eq(["customer_id", "user_id"])
93
98
  end
94
99
  end
95
100
 
@@ -116,14 +121,15 @@ RSpec.describe DeclareSchema::Model::HabtmModelShim do
116
121
  let(:foreign_keys_and_table_names) { [["advertiser_id", "advertisers"], ["campaign_id", "campaigns"]] }
117
122
  let(:foreign_keys) { foreign_keys_and_table_names.map(&:first) }
118
123
  let(:parent_table_names) { foreign_keys_and_table_names.map(&:last) }
124
+ let(:parent_models) { [Table1, Table2] }
119
125
 
120
126
  before do
121
- class Table1 < ActiveRecord::Base
122
- self.table_name = 'advertiser_campaign'
127
+ class Table1 < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
128
+ self.table_name = 'advertisers'
123
129
  end
124
130
 
125
- class Table2 < ActiveRecord::Base
126
- self.table_name = 'tracking_pixel'
131
+ class Table2 < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
132
+ self.table_name = 'campaigns'
127
133
  end
128
134
  end
129
135
 
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'generators/declare_schema/support/thor_shell'
4
+
5
+ # Verifies that when the migrator has to add a declared primary key column
6
+ # that has no corresponding FieldSpec (because it was appended to `to_add`
7
+ # at lib/generators/declare_schema/migration/migrator.rb:366), the column
8
+ # type is derived from `DeclareSchema.default_generated_primary_key_type`
9
+ # rather than a hardcoded `:integer`. This matters for apps that override
10
+ # `config.generators.primary_key_type`.
11
+ RSpec.describe 'DeclareSchema.default_generated_primary_key_type integration with Migrator' do
12
+ include_context 'prepare test app'
13
+
14
+ before do
15
+ if current_adapter == 'mysql2'
16
+ ActiveRecord::Base.connection.execute("CREATE TABLE foos (id int PRIMARY KEY, name varchar(250))")
17
+ else
18
+ ActiveRecord::Base.connection.execute("CREATE TABLE foos (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, name varchar(250))")
19
+ end
20
+ ActiveRecord::Base.connection.schema_cache.clear!
21
+
22
+ allow_any_instance_of(DeclareSchema::Support::ThorShell)
23
+ .to receive(:ask).with(/one of the rename choices or press enter to keep/) { 'drop id' }
24
+ end
25
+
26
+ it 'derives the new PK column type from DeclareSchema.default_generated_primary_key_type rather than a hardcoded :integer' do
27
+ allow(::DeclareSchema).to receive(:default_generated_primary_key_type).and_return(:string)
28
+
29
+ class Foo < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
30
+ declare_schema do
31
+ string :name, limit: 250
32
+ end
33
+ self.primary_key = "foo_id"
34
+ end
35
+
36
+ up, _down = Generators::DeclareSchema::Migration::Migrator.run
37
+
38
+ expect(up).to include("add_column :foos, :foo_id, :string")
39
+ expect(up).not_to include("add_column :foos, :foo_id, :integer")
40
+ end
41
+
42
+ it 'falls through to the configured Rails default (:bigint) when the helper is not stubbed' do
43
+ class Foo < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
44
+ declare_schema do
45
+ string :name, limit: 250
46
+ end
47
+ self.primary_key = "foo_id"
48
+ end
49
+
50
+ up, _down = Generators::DeclareSchema::Migration::Migrator.run
51
+
52
+ expect(up).to include("add_column :foos, :foo_id, #{::DeclareSchema.default_generated_primary_key_type.inspect}")
53
+ end
54
+ end