declare_schema 3.1.0.colin.3 → 4.0.1.colin.1

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: 2ddb4bb46a974562b49103da5ead57c6ece3d2e7ef76acee4e5b8415df3da761
4
- data.tar.gz: 652addeb7c6a6a3c4e1e81dbcdf91250d7c5e8884a1f9084e5dcad61b13abab6
3
+ metadata.gz: '09079a0e965a85eba22eade7bc713fcacf35d4fd6f874e9d8b41eb36f8e6901e'
4
+ data.tar.gz: b7f0fa06ec76c298d2b9248ef10b6a1010d36bf2edb3c3a667f57790d98bb6b5
5
5
  SHA512:
6
- metadata.gz: ac38c743e5e469e23a0aee102e8dd6222f6f40004d74dfd8f5503b14e93ee9ce1a2c314f4468f4c110d0821a4b6717f97de32492636c9925252db4a49d278931
7
- data.tar.gz: c6758a1c6a3f389cb59a0a0e4b5aace6a02f83d91e943eb1fb9d9d8639ab6bd499b383ced560bf9e58f1934a90460d25607c2b25b62cf2cd024cb0067e3681c6
6
+ metadata.gz: 5cb66b34c4d70e0673106ee94fec485e7e5eec25e34b75a64a94e0d3fea9f6f822157dadc8b662ba57af9878c3686807be1ab97b01ce89b8de3b3354c2dfd3a9
7
+ data.tar.gz: 6359500ff616315a48dd3921a9fac1b9d51e3a24c1dfb1b761160b048da1ef8222d036f60b6e5d2d5e5b74de5f12e44c2db70717a985257be420d78fcbe7f64d
@@ -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,20 @@ 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.1] - 2026-05-06
8
+ ### Fixed
9
+ - `DeferredFieldSpec` (the lazy stand-in introduced in 4.0.0 for `belongs_to`
10
+ foreign-key field specs) now transparently delegates the `FieldSpec` read
11
+ API (`.options`, `.type`, `.limit`, `.null`, etc.) to its memoized resolved
12
+ spec. Application code that reads `Model.field_specs[name].options` at
13
+ runtime would raise `NoMethodError: undefined method 'options' for an
14
+ instance of DeclareSchema::Model::DeferredFieldSpec`.
15
+
16
+ ## [4.0.0] - 2026-05-05
8
17
  ### Added
9
- - Add HABTM support for arbitrary primary key in the referenced table (rather than just :bigint).
18
+ - Generalized `belongs_to` foreign keys to always match the primary key they point at, including
19
+ in HABTM intersection tables. (Previously this was just a special case that matched up
20
+ :integer vs :bigint.)
10
21
 
11
22
  ### Removed
12
23
  - 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.3)
4
+ declare_schema (4.0.1.colin.1)
5
5
  rails (>= 7.0)
6
6
 
7
7
  GEM
@@ -12,7 +12,7 @@ module DeclareSchema
12
12
  # The migrator calls {#resolve} on every value in `field_specs` at the start
13
13
  # of migration generation (see `Migrator#generate`); for plain {FieldSpec}s
14
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
15
+ # invokes the resolver block with the default spec and returns the produced
16
16
  # {FieldSpec}.
17
17
  class DeferredFieldSpec
18
18
  # @param default_spec [FieldSpec] the eager placeholder spec
@@ -24,14 +24,26 @@ module DeclareSchema
24
24
  @resolver = resolver
25
25
  end
26
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.
27
+ # Resolve and memoize the produced FieldSpec. Memoization matters because
28
+ # application code can hit several FieldSpec accessors per request (e.g.
29
+ # ModelReport reads `.options`, ApplicationModel reads `.limit`); without
30
+ # it each one would re-run the resolver and re-touch `reflection.klass`.
31
31
  #
32
32
  # @return [FieldSpec]
33
33
  def resolve
34
- @resolver.call(@default_spec)
34
+ @resolved ||= @resolver.call(@default_spec)
35
+ end
36
+
37
+ def respond_to_missing?(name, include_private = false)
38
+ resolve.respond_to?(name, include_private) || super
39
+ end
40
+
41
+ def method_missing(name, *args, **kwargs, &)
42
+ if resolve.respond_to?(name)
43
+ resolve.public_send(name, *args, **kwargs, &)
44
+ else
45
+ super
46
+ end
35
47
  end
36
48
  end
37
49
  end
@@ -371,13 +371,15 @@ module DeclareSchema
371
371
 
372
372
  # @param klass [Class] any ActiveRecord class
373
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. Reads
375
- # via `columns_hash` directly (not `_column`, whose `@table_exists` memoization
376
- # can pin a stale answer) so a parent table being created in this same
377
- # migration run is honored.
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.
378
379
  def _pk_column_for(klass)
379
- pk_name = klass.respond_to?(:_declared_primary_key) ? klass._declared_primary_key : klass.primary_key
380
- klass.columns_hash[pk_name.to_s] if pk_name
380
+ if (pk_name = klass.try(:_declared_primary_key) || klass.primary_key)
381
+ klass.columns_hash[pk_name]
382
+ end
381
383
  rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
382
384
  nil
383
385
  end
@@ -391,13 +393,25 @@ module DeclareSchema
391
393
  # the column type doesn't round-trip cleanly
392
394
  def _field_spec_options_from_pk_column(column)
393
395
  if column&.type == :integer
394
- column.limit == 8 ? [:bigint, {}] : [:integer, { limit: column.limit || 4 }]
396
+ if column.limit == 8
397
+ [:bigint, {}]
398
+ else
399
+ [:integer, { limit: column.limit || 4 }]
400
+ end
395
401
  end
396
402
  end
397
403
 
398
404
  # `declare_schema id: ...` accepts either a Hash (`id: { type: :integer, limit: 4 }`)
399
- # or a bare type Symbol (`id: :integer`). Returns [type, options_hash], with type == nil
400
- # 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`.
401
415
  def _parse_pk_table_options(value)
402
416
  case value
403
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.3"
4
+ VERSION = "4.0.1.colin.1"
5
5
  end
@@ -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|
@@ -24,5 +24,61 @@ RSpec.describe DeclareSchema::Model::DeferredFieldSpec do
24
24
  expect(deferred.resolve).to equal(mirrored_spec)
25
25
  expect(captured).to equal(default_spec)
26
26
  end
27
+
28
+ it 'memoizes the resolver result so repeated calls invoke it only once' do
29
+ call_count = 0
30
+ deferred = described_class.new(default_spec) do |_|
31
+ call_count += 1
32
+ mirrored_spec
33
+ end
34
+
35
+ 3.times { deferred.resolve }
36
+
37
+ expect(call_count).to eq(1)
38
+ end
39
+ end
40
+
41
+ # Application code (e.g. ModelReport reading `field_specs[name].options[:rr_report_options]`)
42
+ # reads from `field_specs` without first calling `.resolve`. The deferred spec must
43
+ # therefore quack like the resolved FieldSpec; otherwise we get NoMethodError at runtime
44
+ # in apps that read FieldSpec attributes, AND we'd return a wrong default-typed answer
45
+ # (e.g. :bigint when the parent's PK is actually :integer or :binary).
46
+ describe 'FieldSpec API delegation' do
47
+ let(:deferred) { described_class.new(default_spec) { mirrored_spec } }
48
+
49
+ it 'delegates #options to the resolved spec (parent-mirrored, not default)' do
50
+ expect(deferred.options).to eq(mirrored_spec.options)
51
+ end
52
+
53
+ it 'delegates #type to the resolved spec' do
54
+ expect(deferred.type).to eq(:string)
55
+ end
56
+
57
+ it 'delegates #limit to the resolved spec' do
58
+ expect(deferred.limit).to eq(36)
59
+ end
60
+
61
+ it 'delegates #null to the resolved spec' do
62
+ expect(deferred.null).to eq(false)
63
+ end
64
+
65
+ it 'reports respond_to? for delegated methods' do
66
+ expect(deferred).to respond_to(:options, :type, :limit, :null, :name, :position)
67
+ end
68
+
69
+ it 'invokes the resolver only once across many delegated reads' do
70
+ call_count = 0
71
+ deferred = described_class.new(default_spec) do |_|
72
+ call_count += 1
73
+ mirrored_spec
74
+ end
75
+
76
+ deferred.options
77
+ deferred.type
78
+ deferred.limit
79
+ deferred.null
80
+
81
+ expect(call_count).to eq(1)
82
+ end
27
83
  end
28
84
  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.3
4
+ version: 4.0.1.colin.1
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-04 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