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 +4 -4
- data/.github/workflows/pipeline.yml +1 -1
- data/CHANGELOG.md +4 -2
- data/Gemfile.lock +1 -1
- data/lib/declare_schema/model/deferred_field_spec.rb +38 -0
- data/lib/declare_schema/model/field_spec.rb +21 -8
- data/lib/declare_schema/model.rb +131 -64
- data/lib/declare_schema/version.rb +1 -1
- data/lib/declare_schema.rb +1 -0
- data/lib/generators/declare_schema/migration/migrator.rb +10 -1
- data/spec/lib/declare_schema/deferred_field_spec_spec.rb +28 -0
- data/spec/lib/declare_schema/field_spec_spec.rb +0 -32
- data/spec/lib/declare_schema/migration_generator_spec.rb +124 -4
- data/spec/lib/generators/declare_schema/migration/migrator_default_pk_type_spec.rb +5 -5
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 929f250672103aa42777cb918340e5281d100fdaf19cedb5f7aaa96504e66a8c
|
|
4
|
+
data.tar.gz: 377a3f2aaae4fa4b6294a4c4a9edd672a8626a505be6a07e06b953a28cb78ffc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8d05a979184d7e93041a462b1269b7d71a6a675f37b8914e27728db0f2d28878d0436cc10eba4bd107f1cb57355c1321147659842367e9231ebb32eec5d14b79
|
|
7
|
+
data.tar.gz: 3c361cb45d57de4c4aac717155b354f34d0c654b4881c29742a9f611cf7ca8efd0c9297e70433dd7b2df4df70751bf3c1d0463824ccc8eb77f5f9a1d836e711d
|
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
|
-
## [
|
|
7
|
+
## [4.0.0] - 2026-05-05
|
|
8
8
|
### Added
|
|
9
|
-
-
|
|
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
|
@@ -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,
|
|
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
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
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
|
-
|
|
129
|
+
self
|
|
129
130
|
end
|
|
130
131
|
|
|
131
|
-
|
|
132
|
-
|
|
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
|
data/lib/declare_schema/model.rb
CHANGED
|
@@ -226,81 +226,98 @@ module DeclareSchema
|
|
|
226
226
|
end
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
#
|
|
260
|
-
#
|
|
261
|
-
#
|
|
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
|
-
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
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
|
|
276
|
-
|
|
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
|
-
#
|
|
283
|
-
#
|
|
284
|
-
#
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
|
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`).
|
|
347
|
-
#
|
|
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)]
|
data/lib/declare_schema.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
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-
|
|
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
|