declare_schema 3.1.0.colin.2 → 4.0.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: 1b65098860b02787bce136f3b694bcd294cdaaa6980716492bcf66b5ab9c4cc9
4
- data.tar.gz: 9d8ff3e2e7c2cc06886f59b8052ab0f4b2d4bac88ea46ac4ada831000b6cb0a8
3
+ metadata.gz: 929f250672103aa42777cb918340e5281d100fdaf19cedb5f7aaa96504e66a8c
4
+ data.tar.gz: 377a3f2aaae4fa4b6294a4c4a9edd672a8626a505be6a07e06b953a28cb78ffc
5
5
  SHA512:
6
- metadata.gz: 9e7456ba8c763846255997c5fb08a77cb094a199abea888f13c39a9a5dea8e099fe1e09f2a017931eb3b8f1c1e2e7c46b8752dda2510e7f2a892c3f0d2a29a43
7
- data.tar.gz: 38909be09762f69b40d245385bccf004520142838a95cd27f2c75ef0f82c292a65f301dda444b05e6ce30d76406f712f59c6a9160f8fea9588c1791d3448a8d3
6
+ metadata.gz: 8d05a979184d7e93041a462b1269b7d71a6a675f37b8914e27728db0f2d28878d0436cc10eba4bd107f1cb57355c1321147659842367e9231ebb32eec5d14b79
7
+ data.tar.gz: 3c361cb45d57de4c4aac717155b354f34d0c654b4881c29742a9f611cf7ca8efd0c9297e70433dd7b2df4df70751bf3c1d0463824ccc8eb77f5f9a1d836e711d
@@ -48,7 +48,7 @@ jobs:
48
48
  - uses: ruby/setup-ruby@v1
49
49
  with:
50
50
  ruby-version: ${{matrix.ruby}}
51
- bundler: latest
51
+ bundler: 2.5
52
52
  bundler-cache: true
53
53
  - name: Setup
54
54
  run: |
data/CHANGELOG.md CHANGED
@@ -4,9 +4,11 @@ Inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
4
4
 
5
5
  Note: this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [3.1.0] - Unreleased
7
+ ## [4.0.0] - 2026-05-05
8
8
  ### Added
9
- - Add HABTM support for arbitrary primary key in the referenced table (rather than just :bigint).
9
+ - Generalized `belongs_to` foreign keys to always match the primary key they point at, including
10
+ in HABTM intersection tables. (Previously this was just a special case that matched up
11
+ :integer vs :bigint.)
10
12
 
11
13
  ### Removed
12
14
  - Drop support for Rails 6.x. Minimum supported Rails is now 7.0.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- declare_schema (3.1.0.colin.2)
4
+ declare_schema (4.0.0)
5
5
  rails (>= 7.0)
6
6
 
7
7
  GEM
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeclareSchema
4
+ module Model
5
+ # A placeholder for a {FieldSpec} whose final shape can't be computed at field-
6
+ # declaration time. The only producer today is `belongs_to` between two
7
+ # declare_schema models: we cannot touch `reflection.klass` while declaring the
8
+ # FK without risking a model-load cycle, so we stash an eager default-typed
9
+ # FieldSpec along with a block that knows how to mirror the parent's PK once
10
+ # all models have been eager-loaded.
11
+ #
12
+ # The migrator calls {#resolve} on every value in `field_specs` at the start
13
+ # of migration generation (see `Migrator#generate`); for plain {FieldSpec}s
14
+ # `#resolve` is a no-op returning self, while for instances of this class it
15
+ # invokes the block (memoized) with the default spec and returns the produced
16
+ # {FieldSpec}.
17
+ class DeferredFieldSpec
18
+ # @param default_spec [FieldSpec] the eager placeholder spec
19
+ # @yieldparam default_spec [FieldSpec]
20
+ # @yieldreturn [FieldSpec] the resolved spec
21
+ def initialize(default_spec, &resolver)
22
+ resolver or raise ArgumentError, "DeferredFieldSpec requires a resolver block"
23
+ @default_spec = default_spec
24
+ @resolver = resolver
25
+ end
26
+
27
+ # Invoke the resolver block with the default spec and return its result.
28
+ # The migrator's `transform_values!(&:resolve)` swaps this DeferredFieldSpec
29
+ # out of `field_specs` for the produced FieldSpec, so resolve is called
30
+ # exactly once per instance in production -- no memoization needed.
31
+ #
32
+ # @return [FieldSpec]
33
+ def resolve
34
+ @resolver.call(@default_spec)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -46,13 +46,12 @@ module DeclareSchema
46
46
  define_method(option) { @options[option] }
47
47
  end
48
48
 
49
- def initialize(model, name, type, position: 0, resolver: nil, **options)
49
+ def initialize(model, name, type, position: 0, **options)
50
50
  @model = model
51
51
  @name = name.to_sym
52
52
  type.is_a?(Symbol) or raise ArgumentError, "type must be a Symbol; got #{type.inspect}"
53
53
  @type = TYPE_SYNONYMS[type] || type
54
54
  @position = position
55
- @resolver = resolver
56
55
  @options = options.dup
57
56
 
58
57
  @options.has_key?(:null) or @options[:null] = ::DeclareSchema.default_null
@@ -121,15 +120,29 @@ module DeclareSchema
121
120
  @sql_options = @options.slice(*SQL_OPTIONS)
122
121
  end
123
122
 
124
- # Returns the final FieldSpec, invoking the deferred-resolution callback if one
125
- # was supplied. Specs without a `resolver:` simply return `self`. Result is
126
- # memoized so repeated calls (e.g. across migration generation passes) are cheap.
123
+ # Duck-type counterpart to {DeferredFieldSpec#resolve}. A concrete FieldSpec is
124
+ # already its own resolved form, so this just returns self. Lets the migrator's
125
+ # `transform_values!(&:resolve)` loop dispatch uniformly across both classes.
126
+ #
127
+ # @return [FieldSpec] self
127
128
  def resolve
128
- @resolved ||= @resolver ? @resolver.call(self) : self
129
+ self
129
130
  end
130
131
 
131
- def foreign_key_field_spec(model, name, position:, null:)
132
- self.class.new(model, name, @type, position:, **@options.merge(null.nil? ? {} : { null: }))
132
+ # Build a FieldSpec for a foreign key that mirrors this PK's type/limit/charset/etc.
133
+ # Child column_options (e.g. `default:` from `belongs_to`) win over the parent's
134
+ # @options when they specify the same key.
135
+ #
136
+ # @param model [Class] the child ActiveRecord class
137
+ # @param name [Symbol] the FK column name (e.g. :user_id)
138
+ # @param position [Integer] FieldSpec position in the field_specs hash
139
+ # @param null [Boolean, nil] :null setting from `belongs_to`; nil leaves @options[:null] alone
140
+ # @param column_options [Hash] additional FK-side overrides (e.g. :default)
141
+ # @return [FieldSpec]
142
+ def foreign_key_field_spec(model, name, position:, null:, **column_options)
143
+ options = @options.merge(column_options)
144
+ options[:null] = null unless null.nil?
145
+ self.class.new(model, name, @type, position:, **options)
133
146
  end
134
147
 
135
148
  # returns the attributes for schema migrations as a Hash
@@ -226,81 +226,98 @@ module DeclareSchema
226
226
  end
227
227
  end
228
228
 
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.
229
+ # Build the FK column FieldSpec for a `belongs_to`.
230
+ #
231
+ # Polymorphic associations have no parent class to mirror, so we grandfather to
232
+ # the live FK column when present (see {#_field_spec_options_from_pk_column}) and
233
+ # fall back to {DeclareSchema.default_generated_primary_key_type} otherwise.
234
+ #
235
+ # Non-polymorphic associations mirror the parent's PK at migration-generation time
236
+ # via a deferred resolver block (see {#_resolve_belongs_to_foreign_key_field_spec}).
237
+ # The deferral avoids loading `reflection.klass` here, which can trigger
238
+ # dependency cycles between models.
239
+ #
240
+ # @param foreign_key_column_name [Symbol] e.g. :user_id
241
+ # @param reflection [ActiveRecord::Reflection::AbstractReflection]
242
+ # @param column_options [Hash] :null and (optionally) :default from `belongs_to`
243
+ # @return [FieldSpec]
240
244
  def _infer_foreign_key_field_spec(foreign_key_column_name, reflection, column_options)
241
245
  if reflection.options[:polymorphic]
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
- end
246
- FieldSpec.new(self, foreign_key_column_name, DeclareSchema.default_generated_primary_key_type, position: field_specs.size, **column_options)
246
+ # Mirror both type AND :limit from the live column -- mirroring only :limit
247
+ # doesn't help when the gem default is :bigint, since FieldSpec normalizes
248
+ # :bigint to (:integer, limit: 8) and discards the live :limit, generating a
249
+ # phantom int(4) -> bigint(8) change.
250
+ type, live_options = _field_spec_options_from_pk_column(_column(foreign_key_column_name))
251
+ type ||= DeclareSchema.default_generated_primary_key_type
252
+ FieldSpec.new(self, foreign_key_column_name, type,
253
+ position: field_specs.size, **column_options.merge(live_options || {}))
247
254
  else
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)
255
+ default_spec = FieldSpec.new(self, foreign_key_column_name, DeclareSchema.default_generated_primary_key_type,
256
+ position: field_specs.size, **column_options)
257
+ DeferredFieldSpec.new(default_spec) do |spec|
258
+ _resolve_belongs_to_foreign_key_field_spec(reflection, spec)
253
259
  end
254
- FieldSpec.new(self, foreign_key_column_name, DeclareSchema.default_generated_primary_key_type,
255
- position: field_specs.size, resolver:, **column_options)
256
260
  end
257
261
  end
258
262
 
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.
263
+ # Resolve the FK FieldSpec by mirroring the parent's PK at migration-generation time.
264
+ # Falls back to `default_spec` when there's nothing to mirror (greenfield
265
+ # non-declare_schema parent whose table can't be inspected yet).
264
266
  #
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.
267
+ # @param reflection [ActiveRecord::Reflection::AbstractReflection]
268
+ # @param default_spec [FieldSpec] the placeholder built at `belongs_to` time
269
+ # @return [FieldSpec]
271
270
  def _resolve_belongs_to_foreign_key_field_spec(reflection, default_spec)
272
271
  klass = reflection.klass or
273
272
  raise "Couldn't find belongs_to klass for #{reflection.name} on #{name} in #{reflection.inspect}"
274
273
 
275
- if klass.respond_to?(:_primary_key_field_spec)
276
- _mirror_parent_primary_key(klass, default_spec)
274
+ if (parent_pk_spec = _parent_pk_field_spec(klass))
275
+ # Forward child column options that should survive PK mirroring (currently just
276
+ # :default); otherwise they are silently dropped and a phantom change_column is
277
+ # emitted on every run.
278
+ child_options = default_spec.options.slice(:default)
279
+ fk_spec = parent_pk_spec.foreign_key_field_spec(
280
+ default_spec.model, default_spec.name,
281
+ position: default_spec.position, null: default_spec.null,
282
+ **child_options
283
+ )
284
+ _reconcile_fk_with_live_pk(klass, fk_spec)
277
285
  else
278
286
  default_spec
279
287
  end
280
288
  end
281
289
 
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
+ # The PK FieldSpec to mirror into a FK: the parent's declared spec when available
291
+ # (declare_schema parent), otherwise one synthesized from the parent's live PK
292
+ # column.
293
+ #
294
+ # @param klass [Class] the parent ActiveRecord class
295
+ # @return [FieldSpec, nil] nil when the parent is not a declare_schema model and
296
+ # its table can't be inspected (e.g. greenfield)
297
+ def _parent_pk_field_spec(klass)
298
+ if klass.respond_to?(:_primary_key_field_spec)
299
+ klass._primary_key_field_spec
300
+ else
301
+ type, options = _field_spec_options_from_pk_column(_pk_column_for(klass))
302
+ FieldSpec.new(klass, klass.primary_key, type, **options) if type
303
+ end
304
+ end
290
305
 
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
- )
306
+ # Preserve a legacy live PK :limit when the model has drifted to a different :limit
307
+ # (e.g. parent declares :bigint while live `id` is still INT(4)) -- without this the
308
+ # migrator would emit a phantom widening on every run.
309
+ #
310
+ # @param klass [Class] the parent ActiveRecord class
311
+ # @param fk_spec [FieldSpec] the proposed FK spec built from the parent's PK
312
+ # @return [FieldSpec] fk_spec with :limit overridden when reconciliation applies,
313
+ # otherwise fk_spec unchanged
314
+ def _reconcile_fk_with_live_pk(klass, fk_spec)
315
+ live_pk = _pk_column_for(klass)
316
+ if live_pk && live_pk.type == fk_spec.type && live_pk.limit && live_pk.limit != fk_spec.limit
317
+ FieldSpec.new(fk_spec.model, fk_spec.name, fk_spec.type,
318
+ position: fk_spec.position, **fk_spec.options.merge(limit: live_pk.limit))
302
319
  else
303
- spec
320
+ fk_spec
304
321
  end
305
322
  end
306
323
 
@@ -329,22 +346,72 @@ module DeclareSchema
329
346
  raise "Declared primary key #{declared_primary_key.inspect} not found in field_specs or _table_options #{_table_options.inspect} for #{name}"
330
347
  end
331
348
 
349
+ # Build the PK FieldSpec when no explicit `declare_schema id: ...` is given.
350
+ # Prefers the live PK column (so legacy `int(4)` PKs predating Rails's `:bigint`
351
+ # default aren't forced to bigint via FK mirroring) and falls back to the gem
352
+ # default for greenfield tables.
353
+ #
354
+ # _table_options is nil on STI subclasses that never call `declare_schema`
355
+ # themselves: they inherit `field_specs` via `inheriting_cattr_reader`, but
356
+ # `@_table_options` is a plain class-instance variable on each class, so the
357
+ # subclass's reader returns nil. We treat that the same as an empty options hash.
358
+ #
359
+ # @param declared_primary_key [String, Symbol] PK column name (typically 'id')
360
+ # @return [FieldSpec]
332
361
  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
362
  type, options = _parse_pk_table_options(_table_options&.[](declared_primary_key.to_sym))
339
- type ||= DeclareSchema.default_generated_primary_key_type
363
+ if type.nil?
364
+ type, options = _field_spec_options_from_pk_column(_pk_column_for(self)) ||
365
+ [DeclareSchema.default_generated_primary_key_type, {}]
366
+ end
340
367
  FieldSpec.new(self, declared_primary_key, type, **options)
341
368
  end
342
369
 
343
370
  private
344
371
 
372
+ # @param klass [Class] any ActiveRecord class
373
+ # @return [ActiveRecord::ConnectionAdapters::Column, nil] the live PK column, or
374
+ # nil when the table doesn't exist yet or can't otherwise be inspected, or
375
+ # when the model has a compound primary key (Array PK name misses
376
+ # `columns_hash`). Reads via `columns_hash` directly (not `_column`, whose
377
+ # `@table_exists` memoization can pin a stale answer) so a parent table
378
+ # being created in this same migration run is honored.
379
+ def _pk_column_for(klass)
380
+ if (pk_name = klass.try(:_declared_primary_key) || klass.primary_key)
381
+ klass.columns_hash[pk_name]
382
+ end
383
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
384
+ nil
385
+ end
386
+
387
+ # Translate a live PK column into FieldSpec arguments. AR returns :integer for both
388
+ # INT and BIGINT in MySQL; we distinguish by `limit` so callers can mirror the
389
+ # legacy width rather than the gem's :bigint default.
390
+ #
391
+ # @param column [ActiveRecord::ConnectionAdapters::Column, nil]
392
+ # @return [Array(Symbol, Hash), nil] [type, options] for FieldSpec.new, or nil if
393
+ # the column type doesn't round-trip cleanly
394
+ def _field_spec_options_from_pk_column(column)
395
+ if column&.type == :integer
396
+ if column.limit == 8
397
+ [:bigint, {}]
398
+ else
399
+ [:integer, { limit: column.limit || 4 }]
400
+ end
401
+ end
402
+ end
403
+
345
404
  # `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).
405
+ # or a bare type Symbol (`id: :integer`). Normalizes both shapes -- plus the
406
+ # absent / unrecognized case -- into a uniform `[type, options]` pair so the
407
+ # caller (`_primary_key_field_spec_from_table_options`) can dispatch on
408
+ # `type.nil?` to decide whether to fall back to live-PK inspection or the
409
+ # gem default.
410
+ #
411
+ # @param value [Hash, Symbol, nil] the per-PK entry from `_table_options`
412
+ # @return [Array(Symbol, Hash), Array(nil, Hash)] `[type, options]` where
413
+ # `type` is nil when `value` is neither a Hash nor a Symbol (signal to
414
+ # caller to fall back). `options` always excludes `:type`.
348
415
  def _parse_pk_table_options(value)
349
416
  case value
350
417
  when Hash then [value[:type], value.except(:type)]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeclareSchema
4
- VERSION = "3.1.0.colin.2"
4
+ VERSION = "4.0.0"
5
5
  end
@@ -179,6 +179,7 @@ end
179
179
  require 'declare_schema/extensions/active_record/fields_declaration'
180
180
  require 'declare_schema/model'
181
181
  require 'declare_schema/model/field_spec'
182
+ require 'declare_schema/model/deferred_field_spec'
182
183
  require 'declare_schema/model/index_definition'
183
184
  require 'declare_schema/model/foreign_key_definition'
184
185
  require 'declare_schema/model/table_options_definition'
@@ -80,7 +80,16 @@ module Generators
80
80
  self.class.native_types
81
81
  end
82
82
 
83
- # return habtm reflections keyed by join table name (so that habtm from each side has just one entry)
83
+ # Collect every HABTM reflection across the application, keyed by its join
84
+ # table name. Each join table is shared by both sides of a HABTM
85
+ # association, so iterating all descendants would yield two reflections
86
+ # per join table (one from each parent model). Keying by `join_table` and
87
+ # using `||=` keeps the first occurrence and silently drops the duplicate
88
+ # from the other side, so the migrator emits exactly one shim per join
89
+ # table.
90
+ #
91
+ # @return [Hash{String => ActiveRecord::Reflection::HasAndBelongsToManyReflection}]
92
+ # join table name -> the (first-seen) HABTM reflection that owns it
84
93
  def habtm_tables
85
94
  ActiveRecord::Base.send(:descendants).each_with_object({}) do |c, result|
86
95
  c.reflect_on_all_associations(:has_and_belongs_to_many).each do |a|
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe DeclareSchema::Model::DeferredFieldSpec do
4
+ include_context 'prepare test app'
5
+
6
+ let(:model) { double('model', _table_options: {}, _declared_primary_key: 'id') }
7
+ let(:default_spec) { DeclareSchema::Model::FieldSpec.new(model, :advertiser_id, :integer, limit: 8, null: false, position: 1) }
8
+ let(:mirrored_spec) { DeclareSchema::Model::FieldSpec.new(model, :advertiser_id, :string, limit: 36, null: false, position: 1) }
9
+
10
+ describe '#initialize' do
11
+ it 'requires a resolver block' do
12
+ expect { described_class.new(default_spec) }.to raise_error(ArgumentError, /resolver block/)
13
+ end
14
+ end
15
+
16
+ describe '#resolve' do
17
+ it 'invokes the resolver with the default spec and returns its result' do
18
+ captured = nil
19
+ deferred = described_class.new(default_spec) do |spec|
20
+ captured = spec
21
+ mirrored_spec
22
+ end
23
+
24
+ expect(deferred.resolve).to equal(mirrored_spec)
25
+ expect(captured).to equal(default_spec)
26
+ end
27
+ end
28
+ end
@@ -25,38 +25,6 @@ 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
-
60
28
  describe '#schema_attributes' do
61
29
  describe 'integer 4' do
62
30
  subject { described_class.new(model, :price, :integer, limit: 4, null: false, position: 0) }
@@ -996,6 +996,8 @@ RSpec.describe 'DeclareSchema Migration Generator' do
996
996
  nuke_model_class(Tag) if defined?(Tag)
997
997
  nuke_model_class(Product) if defined?(Product)
998
998
  nuke_model_class(ProductsTagsClass) if defined?(ProductsTagsClass)
999
+ nuke_model_class(LegacyChild) if defined?(LegacyChild)
1000
+ nuke_model_class(LegacyParent) if defined?(LegacyParent)
999
1001
  end
1000
1002
 
1001
1003
  it 'belongs_to defers parent class lookup to migration time (no dependency cycle)' do
@@ -1100,6 +1102,124 @@ RSpec.describe 'DeclareSchema Migration Generator' do
1100
1102
  end
1101
1103
  end
1102
1104
 
1105
+ context 'when a parent has no explicit PK declaration but the live PK column is int(4)' do
1106
+ # Legacy schemas where every PK predates the gem's `:bigint` default. Without
1107
+ # this behavior, declare_schema would force every belongs_to FK to bigint(8)
1108
+ # while the parent PK stays int(4) -- which MySQL refuses with
1109
+ # `Referencing column ... and referenced column ... in foreign key
1110
+ # constraint ... are incompatible`.
1111
+ before do
1112
+ conn = ActiveRecord::Base.connection
1113
+ conn.create_table(:legacy_parents, id: { type: :integer, limit: 4 }) do |t|
1114
+ t.string :name, limit: 100, null: false
1115
+ end
1116
+ conn.schema_cache.clear!
1117
+
1118
+ class LegacyParent < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1119
+ declare_schema do
1120
+ string :name, limit: 100, null: false
1121
+ end
1122
+ end
1123
+
1124
+ class LegacyChild < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1125
+ declare_schema do
1126
+ integer :amount, null: false
1127
+ end
1128
+ belongs_to :legacy_parent
1129
+ end
1130
+ end
1131
+
1132
+ let(:up) { Generators::DeclareSchema::Migration::Migrator.run.first }
1133
+
1134
+ it 'mirrors the live int(4) PK in the FK rather than the gem default :bigint' do
1135
+ expect(up).to match(/t\.integer\s+:legacy_parent_id, limit: 4, null: false/)
1136
+ expect(up).not_to match(/:legacy_parent_id[^\n]*limit: 8/)
1137
+ end
1138
+ end
1139
+
1140
+ context 'when belongs_to declares a column option such as default:' do
1141
+ # Regression: the resolver-built FK spec must preserve column options the child
1142
+ # passed to belongs_to (default:, etc.). Forgetting to merge them silently drops
1143
+ # them and generates a phantom change_column on every run.
1144
+ before do
1145
+ class DimParent < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1146
+ declare_schema do
1147
+ string :name, limit: 100, null: false
1148
+ end
1149
+ end
1150
+
1151
+ class DimChild < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1152
+ declare_schema do
1153
+ integer :amount, null: false
1154
+ end
1155
+ belongs_to :dim_parent, default: 1
1156
+ end
1157
+ end
1158
+
1159
+ let(:up) { Generators::DeclareSchema::Migration::Migrator.run.first }
1160
+
1161
+ it 'preserves the default: on the FK FieldSpec' do
1162
+ expect(up).to match(/:dim_parent_id[^\n]*default: 1/)
1163
+ end
1164
+ end
1165
+
1166
+ context 'when a polymorphic FK column already exists as int(4)' do
1167
+ # Polymorphic belongs_to has no parent klass to mirror; the gem grandfathers the
1168
+ # live FK column. Without the type-too fix, FieldSpec forces :bigint -> limit: 8
1169
+ # and a phantom int(4) -> bigint(8) change_column gets generated.
1170
+ before do
1171
+ conn = ActiveRecord::Base.connection
1172
+ conn.create_table(:legacy_owners, id: { type: :integer, limit: 4 }) do |t|
1173
+ t.integer :owner_id, limit: 4, null: false
1174
+ t.string :owner_type, limit: 100, null: false
1175
+ end
1176
+ conn.schema_cache.clear!
1177
+
1178
+ class LegacyOwner < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1179
+ declare_schema do
1180
+ # owner_id/owner_type already exist in the DB at int(4); declare_schema should mirror them
1181
+ end
1182
+ belongs_to :owner, polymorphic: true
1183
+ end
1184
+ end
1185
+
1186
+ let(:up) { Generators::DeclareSchema::Migration::Migrator.run.first }
1187
+
1188
+ it 'mirrors the live int(4) FK rather than forcing bigint(8)' do
1189
+ expect(up).not_to match(/:owner_id[^\n]*limit: 8/)
1190
+ end
1191
+ end
1192
+
1193
+ context 'when the parent class is not a declare_schema model' do
1194
+ # Mirrors the prior `int(4)` case but with a plain ActiveRecord parent that has
1195
+ # never opted into declare_schema. We still need to mirror its live PK column so
1196
+ # the FK doesn't drift to :bigint while the parent stays int(4).
1197
+ before do
1198
+ conn = ActiveRecord::Base.connection
1199
+ conn.create_table(:plain_parents, id: { type: :integer, limit: 4 }) do |t|
1200
+ t.string :name, limit: 100, null: false
1201
+ end
1202
+ conn.schema_cache.clear!
1203
+
1204
+ class PlainParent < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1205
+ end
1206
+
1207
+ class PlainChild < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1208
+ declare_schema do
1209
+ integer :amount, null: false
1210
+ end
1211
+ belongs_to :plain_parent
1212
+ end
1213
+ end
1214
+
1215
+ let(:up) { Generators::DeclareSchema::Migration::Migrator.run.first }
1216
+
1217
+ it 'mirrors the parent live int(4) PK in the FK rather than the gem default :bigint' do
1218
+ expect(up).to match(/t\.integer\s+:plain_parent_id, limit: 4, null: false/)
1219
+ expect(up).not_to match(/:plain_parent_id[^\n]*limit: 8/)
1220
+ end
1221
+ end
1222
+
1103
1223
  it 'HABTM mirrors both parents primary key types (different non-default types)' do
1104
1224
  class Product < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock
1105
1225
  declare_schema id: { type: :string, limit: 36 } do
@@ -1395,7 +1515,7 @@ RSpec.describe 'DeclareSchema Migration Generator' do
1395
1515
  belongs_to :ad_category, optional: true, null: false
1396
1516
  end
1397
1517
  expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional_true)
1398
- expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(false)
1518
+ expect(AdvertBelongsTo.field_specs['ad_category_id'].resolve.options[:null]).to eq(false)
1399
1519
  end
1400
1520
 
1401
1521
  it 'passes through optional: false, null: true' do
@@ -1406,7 +1526,7 @@ RSpec.describe 'DeclareSchema Migration Generator' do
1406
1526
  belongs_to :ad_category, optional: false, null: true
1407
1527
  end
1408
1528
  expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional_false)
1409
- expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(true)
1529
+ expect(AdvertBelongsTo.field_specs['ad_category_id'].resolve.options[:null]).to eq(true)
1410
1530
  end
1411
1531
  end
1412
1532
 
@@ -1420,7 +1540,7 @@ RSpec.describe 'DeclareSchema Migration Generator' do
1420
1540
  end
1421
1541
  EOS
1422
1542
  expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional_flag[nullable])
1423
- expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(nullable)
1543
+ expect(AdvertBelongsTo.field_specs['ad_category_id'].resolve.options[:null]).to eq(nullable)
1424
1544
  end
1425
1545
 
1426
1546
  it 'infers null: from optional:' do
@@ -1431,7 +1551,7 @@ RSpec.describe 'DeclareSchema Migration Generator' do
1431
1551
  end
1432
1552
  EOS
1433
1553
  expect(AdvertBelongsTo.reflections['ad_category'].options).to eq(optional_flag[nullable])
1434
- expect(AdvertBelongsTo.field_specs['ad_category_id'].options&.[](:null)).to eq(nullable)
1554
+ expect(AdvertBelongsTo.field_specs['ad_category_id'].resolve.options[:null]).to eq(nullable)
1435
1555
  end
1436
1556
  end
1437
1557
  end
@@ -12,11 +12,11 @@ RSpec.describe 'DeclareSchema.default_generated_primary_key_type integration wit
12
12
  include_context 'prepare test app'
13
13
 
14
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
15
+ # Cross-adapter DDL: `integer PRIMARY KEY NOT NULL` works on sqlite, mysql, and
16
+ # postgres. AUTOINCREMENT (sqlite) / AUTO_INCREMENT (mysql) / SERIAL (postgres)
17
+ # would all be valid, but the migrator inspects type/null, not auto-increment
18
+ # metadata, so we can skip the auto-increment dance entirely.
19
+ ActiveRecord::Base.connection.execute("CREATE TABLE foos (id integer PRIMARY KEY NOT NULL, name varchar(250))")
20
20
  ActiveRecord::Base.connection.schema_cache.clear!
21
21
 
22
22
  allow_any_instance_of(DeclareSchema::Support::ThorShell)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: declare_schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.0.colin.2
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Invoca Development adapted from hobo_fields by Tom Locke
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-03 00:00:00.000000000 Z
11
+ date: 2026-05-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -68,6 +68,7 @@ files:
68
68
  - lib/declare_schema/extensions/module.rb
69
69
  - lib/declare_schema/model.rb
70
70
  - lib/declare_schema/model/column.rb
71
+ - lib/declare_schema/model/deferred_field_spec.rb
71
72
  - lib/declare_schema/model/field_spec.rb
72
73
  - lib/declare_schema/model/foreign_key_definition.rb
73
74
  - lib/declare_schema/model/habtm_model_shim.rb
@@ -107,6 +108,7 @@ files:
107
108
  - spec/fixtures/migrations/sqlite3/will_generate_unique_constraint_names_rails_6.txt
108
109
  - spec/fixtures/migrations/sqlite3/will_generate_unique_constraint_names_rails_7.txt
109
110
  - spec/lib/declare_schema/api_spec.rb
111
+ - spec/lib/declare_schema/deferred_field_spec_spec.rb
110
112
  - spec/lib/declare_schema/field_spec_spec.rb
111
113
  - spec/lib/declare_schema/generator_spec.rb
112
114
  - spec/lib/declare_schema/interactive_primary_key_spec.rb