typed_eav 0.6.0 → 0.7.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.
@@ -11,6 +11,8 @@ module TypedEAV
11
11
  # bases (`Field::ValidatedString`, `Field::RangeBounded`,
12
12
  # `Field::Optionable`) per ADR-0004; `validate_array_size` stays here
13
13
  # because its callers span unrelated families.
14
+ # rubocop:disable Metrics/ClassLength -- Base retains the public field
15
+ # lifecycle API; this change only adds the shared default-validation adapter.
14
16
  class Base < ApplicationRecord
15
17
  self.table_name = "typed_eav_fields"
16
18
 
@@ -42,6 +44,7 @@ module TypedEAV
42
44
  # ── Validations ──
43
45
 
44
46
  RESERVED_NAMES = %w[id type class created_at updated_at].freeze
47
+ DefaultValidationTarget = Struct.new(:errors).freeze
45
48
 
46
49
  validates :name, presence: true, uniqueness: { scope: %i[entity_type scope parent_scope] }
47
50
  validates :name, exclusion: { in: RESERVED_NAMES, message: "is reserved" }
@@ -77,6 +80,13 @@ module TypedEAV
77
80
  # and `throw(:abort)`s, mirroring AR's `dependent: :restrict_with_error`.
78
81
  before_destroy :dispatch_field_dependent
79
82
 
83
+ # Explicit large-population deletion. The default `destroy` and
84
+ # `destroy!` lifecycle remains unchanged; callers opt into resumable
85
+ # keyset batches through this method.
86
+ def destroy_with_values_in_batches!(batch_size: 1_000)
87
+ TypedEAV::FieldDeletion.destroy!(self, batch_size: batch_size)
88
+ end
89
+
80
90
  # Phase 03 event dispatch. SINGLE callback (no `on:` filter) that
81
91
  # branches across the four change_types in `_dispatch_field_change`.
82
92
  # Three reasons for one-callback-with-branch over the three-callback-
@@ -128,6 +138,14 @@ module TypedEAV
128
138
  )
129
139
  }
130
140
 
141
+ # Exact mutation partition. Unlike `for_entity`, this relation does not
142
+ # widen either axis with fallback rows. It intentionally starts from the
143
+ # STI base class in `reorder_within_partition` so every field type shares
144
+ # one ordering partition.
145
+ scope :for_partition, lambda { |entity_type, scope: nil, parent_scope: nil|
146
+ where(entity_type: entity_type, scope: scope, parent_scope: parent_scope)
147
+ }
148
+
131
149
  scope :sorted, -> { order(sort_order: :asc, name: :asc) }
132
150
  scope :required_fields, -> { where(required: true) }
133
151
 
@@ -306,6 +324,8 @@ module TypedEAV
306
324
  # ── Backfill ──
307
325
 
308
326
  # Backfills existing entities with this field's configured default value.
327
+ # `relation:` optionally narrows the exact host model in SQL; the
328
+ # default remains the complete entity class relation.
309
329
  # Iterates entities of `entity_type` in batches of 1000 via
310
330
  # `find_in_batches`, filtering each batch member by the field's
311
331
  # (scope, parent_scope) partition. Each WHOLE batch runs inside one
@@ -317,10 +337,9 @@ module TypedEAV
317
337
  # per batch, this is ~1000 transactions, not 1M.
318
338
  #
319
339
  # Skip rule (per-record, applied INSIDE the batch loop): skip when the
320
- # entity already has a non-nil typed value for this field. A Value row
321
- # whose typed column is nil is still a candidate for backfill — the
322
- # skip rule is "non-nil typed column," not "Value row exists" (matches
323
- # CONTEXT.md).
340
+ # entity already has a logically present typed value for this field. A
341
+ # Value row whose declared cells are all nil is still a candidate for
342
+ # backfill; the rule is all-cell logical missingness, not row existence.
324
343
  #
325
344
  # Partition match: when field.scope is non-nil, the entity must respond
326
345
  # to typed_eav_scope and the value must match field.scope (as String).
@@ -348,16 +367,22 @@ module TypedEAV
348
367
  # BackfillJob.perform_later(field.id)
349
368
  #
350
369
  # (Documented inline as RDoc; not built-in to keep the gem dep-free.)
351
- def backfill_default!
370
+ # rubocop:disable Metrics/AbcSize -- backfill orchestration keeps the
371
+ # relation guard, batching, transaction, and partition work together.
372
+ def backfill_default!(relation: nil)
373
+ entity_class = entity_type.constantize
374
+ column = self.class.value_column
375
+ entity_relation = relation || entity_class.all
376
+ unless entity_relation.is_a?(ActiveRecord::Relation) && entity_relation.klass == entity_class
377
+ raise ArgumentError, "relation must be an ActiveRecord::Relation for #{entity_class.name}"
378
+ end
379
+
352
380
  # Short-circuit: nothing to backfill if no default configured. We
353
381
  # explicitly do NOT write nil rows — backfill is for propagating a
354
382
  # configured default, not for materializing empty Value rows.
355
383
  return if default_value.nil?
356
384
 
357
- entity_class = entity_type.constantize
358
- column = self.class.value_column
359
-
360
- entity_class.find_in_batches(batch_size: 1000) do |batch|
385
+ entity_relation.find_in_batches(batch_size: 1000) do |batch|
361
386
  # One transaction per batch (NOT per record). If the transaction
362
387
  # raises mid-batch, the WHOLE batch rolls back and the exception
363
388
  # surfaces; prior batches stay committed. Caller re-runs idempotently
@@ -378,6 +403,7 @@ module TypedEAV
378
403
  end
379
404
  end
380
405
  end
406
+ # rubocop:enable Metrics/AbcSize
381
407
 
382
408
  # ── Schema export / import ──
383
409
 
@@ -439,8 +465,35 @@ module TypedEAV
439
465
  raw = default_value_meta["v"]
440
466
  return if raw.nil?
441
467
 
442
- _, invalid = cast(raw)
443
- errors.add(:default_value, "is not valid for this field type") if invalid
468
+ casted, invalid = cast(raw)
469
+ if invalid
470
+ errors.add(:default_value, "is not valid for this field type")
471
+ return
472
+ end
473
+
474
+ # An unsaved optionable field may receive its options after this
475
+ # validation (for example through nested attributes). In that case
476
+ # there is no domain to validate against yet; once options are
477
+ # persisted or built on the association, the shared validator runs.
478
+ return if option_domain_undetermined?
479
+
480
+ validate_default_domain(casted)
481
+ end
482
+
483
+ def option_domain_undetermined?
484
+ return false unless optionable? && new_record?
485
+
486
+ association = association(:field_options)
487
+ !association.loaded? && association.target.empty?
488
+ end
489
+
490
+ def validate_default_domain(casted)
491
+ association = association(:field_options)
492
+ association.loaded! if optionable? && association.target.any?
493
+
494
+ target = DefaultValidationTarget.new(ActiveModel::Errors.new(self))
495
+ validate_typed_value(target, casted)
496
+ target.errors.each { |error| errors.add(:default_value, error.type, **error.options) }
444
497
  end
445
498
 
446
499
  # Enforces type restrictions set via `has_typed_eav types: [...]`.
@@ -527,11 +580,11 @@ module TypedEAV
527
580
  # locking + normalization scaffold.
528
581
  def reorder_within_partition
529
582
  self.class.transaction do
530
- locked = self.class
531
- .for_entity(entity_type, scope: scope, parent_scope: parent_scope)
532
- .order(:id)
533
- .lock("FOR UPDATE")
534
- .to_a
583
+ locked = TypedEAV::Field::Base
584
+ .for_partition(entity_type, scope: scope, parent_scope: parent_scope)
585
+ .order(:id)
586
+ .lock("FOR UPDATE")
587
+ .to_a
535
588
 
536
589
  # Sort the locked snapshot into display order (the lock was acquired
537
590
  # in :id order for deadlock safety; we reorder in memory for the
@@ -600,18 +653,15 @@ module TypedEAV
600
653
  # `value:` explicitly bypasses the UNSET_VALUE sentinel path on
601
654
  # Value#initialize (backfill knows the default; no need to re-
602
655
  # resolve via the sentinel).
603
- # - row exists with nil typed column → update to default. This is the
604
- # case the skip rule deliberately allows backfill to fix (a Value
605
- # row created via explicit `value: nil` is still a backfill
606
- # candidate per CONTEXT.md).
607
- # - row exists with non-nil typed column → skip (idempotence).
608
- def backfill_one(entity, column, existing)
656
+ # - row exists with all declared typed cells nil → update to default.
657
+ # - any declared typed cell non-nil skip (idempotence).
658
+ def backfill_one(entity, _column, existing)
609
659
  if existing.nil?
610
660
  TypedEAV::Value.create!(entity: entity, field: self, value: default_value)
611
- elsif existing[column].nil?
661
+ elsif logical_value_missing?(existing)
612
662
  existing.update!(value: default_value)
613
663
  end
614
- # else: row exists with non-nil typed column → skip (skip rule).
664
+ # else: at least one declared typed cell is non-nil → skip.
615
665
  end
616
666
 
617
667
  # ── Phase 03 event dispatch ──
@@ -646,9 +696,8 @@ module TypedEAV
646
696
  # Rename detection is structural: any save where the :name column changed
647
697
  # counts as a rename, even if combined with other attribute changes
648
698
  # (sort_order, options, default_value, field_dependent). This false-
649
- # positive bias is intentional — Phase 07's matview must regenerate
650
- # column DDL on rename; missing a rename combined with other edits would
651
- # corrupt the matview's column-name → field-name map.
699
+ # positive bias is intentional — a registered consumer must not miss a
700
+ # rename combined with other edits.
652
701
  #
653
702
  # `:name` is the only attribute name we hardcode in this callback, and
654
703
  # it's structural (the locked rename-detection mechanism per 03-CONTEXT.md),
@@ -667,5 +716,6 @@ module TypedEAV
667
716
  TypedEAV::EventDispatcher.dispatch_field_change(self, change_type)
668
717
  end
669
718
  end
719
+ # rubocop:enable Metrics/ClassLength
670
720
  end
671
721
  end
@@ -126,6 +126,49 @@ module TypedEAV
126
126
 
127
127
  [{ amount: amount_bd, currency: currency_str }, false]
128
128
  end
129
+
130
+ # Currency query operators target one component of the composite value.
131
+ def cast_query_operand(operator, raw)
132
+ return cast_currency_query(raw) if operator.to_sym == :currency_eq
133
+
134
+ bounds = if operator.to_sym == :between
135
+ if raw.is_a?(Range)
136
+ [raw.begin, raw.end]
137
+ elsif raw.is_a?(Array) && raw.length == 2
138
+ raw
139
+ else
140
+ raise ArgumentError, ":between expects a Range or two-element Array"
141
+ end
142
+ end
143
+ return bounds.map { |bound| cast_amount_query(bound) }.then { |pair| pair.first..pair.last } if bounds
144
+
145
+ cast_amount_query(raw)
146
+ end
147
+
148
+ private
149
+
150
+ def cast_amount_query(raw)
151
+ return nil if raw.nil?
152
+
153
+ amount = BigDecimal(raw.to_s, exception: false)
154
+ raise ArgumentError, "Invalid #{self.class.name} query operand: #{raw.inspect}" unless amount
155
+
156
+ amount
157
+ end
158
+
159
+ def cast_currency_query(raw)
160
+ return nil if raw.nil?
161
+
162
+ currency = raw.to_s.upcase
163
+ unless currency.match?(/\A[A-Z]{3}\z/)
164
+ raise ArgumentError, "Invalid #{self.class.name} query operand: #{raw.inspect}"
165
+ end
166
+
167
+ currency
168
+ end
169
+
170
+ public
171
+
129
172
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
130
173
 
131
174
  # Co-population validation + allowed_currencies inclusion.
@@ -43,7 +43,7 @@ module TypedEAV
43
43
  raise NotImplementedError,
44
44
  "TypedEAV::Field::File requires Active Storage. " \
45
45
  "Add `gem 'activestorage'` to your Gemfile (already " \
46
- "included via the `rails` meta-gem in Rails 7.1+) and " \
46
+ "included via the `rails` meta-gem in Rails 7.2+) and " \
47
47
  "run `bin/rails active_storage:install`."
48
48
  end
49
49
 
@@ -87,7 +87,7 @@ module TypedEAV
87
87
  raise NotImplementedError,
88
88
  "TypedEAV::Field::Image requires Active Storage. " \
89
89
  "Add `gem 'activestorage'` to your Gemfile (already " \
90
- "included via the `rails` meta-gem in Rails 7.1+) and " \
90
+ "included via the `rails` meta-gem in Rails 7.2+) and " \
91
91
  "run `bin/rails active_storage:install`."
92
92
  end
93
93
 
@@ -125,6 +125,17 @@ module TypedEAV
125
125
  [nil, true]
126
126
  end
127
127
 
128
+ # A class-mismatched record deliberately retains the historical empty
129
+ # relation behavior of :references instead of becoming a query error.
130
+ def cast_query_operand(operator, raw)
131
+ casted, invalid = cast(raw)
132
+ return invalid ? nil : casted if operator.to_sym == :references
133
+
134
+ raise ArgumentError, "Invalid #{self.class.name} query operand: #{raw.inspect}" if invalid
135
+
136
+ casted
137
+ end
138
+
128
139
  # Value-time validation: when target_scope is set on the field,
129
140
  # the target record's typed_eav_scope must match. When target_scope
130
141
  # is nil, no cross-scope check fires (the field author is declaring
@@ -29,6 +29,12 @@ module TypedEAV
29
29
  parent_scope: [parent_scope, nil].uniq,
30
30
  )
31
31
  }
32
+
33
+ # Exact mutation partition. Visibility intentionally widens through
34
+ # `for_entity`; ordering mutations must only normalize this exact tuple.
35
+ scope :for_partition, lambda { |entity_type, scope: nil, parent_scope: nil|
36
+ where(entity_type: entity_type, scope: scope, parent_scope: parent_scope)
37
+ }
32
38
  scope :sorted, -> { order(sort_order: :asc, name: :asc) }
33
39
 
34
40
  # ── Display ordering ──
@@ -110,11 +116,11 @@ module TypedEAV
110
116
 
111
117
  def reorder_within_partition
112
118
  self.class.transaction do
113
- locked = self.class
114
- .for_entity(entity_type, scope: scope, parent_scope: parent_scope)
115
- .order(:id)
116
- .lock("FOR UPDATE")
117
- .to_a
119
+ locked = TypedEAV::Section
120
+ .for_partition(entity_type, scope: scope, parent_scope: parent_scope)
121
+ .order(:id)
122
+ .lock("FOR UPDATE")
123
+ .to_a
118
124
 
119
125
  siblings = locked.sort_by { |r| [r.sort_order.nil? ? 1 : 0, r.sort_order || 0, r.name.to_s] }
120
126
 
@@ -54,12 +54,19 @@ module TypedEAV
54
54
 
55
55
  # ── Validations ──
56
56
 
57
- validates :field, uniqueness: { scope: %i[entity_type entity_id] }
57
+ # BulkUpsert rejects duplicate input keys globally and uses one database
58
+ # upsert for the unit. Skip only this persisted-row probe in that explicit
59
+ # context; all cast, entity, partition, and domain validators still run.
60
+ validates :field, uniqueness: { scope: %i[entity_type entity_id] }, unless: :bulk_upsert_validation?
58
61
  validate :validate_value
59
62
  validate :validate_entity_matches_field
60
63
  validate :validate_field_scope_matches_entity
61
64
  validate :validate_json_size
62
65
 
66
+ def bulk_upsert_validation?
67
+ validation_context == :bulk_upsert
68
+ end
69
+
63
70
  # ── Value access ──
64
71
  #
65
72
  # The magic here is that we delegate to the correct typed column
@@ -131,27 +138,37 @@ module TypedEAV
131
138
  # (NOT a DB column, NOT validated, NOT persisted). Stamped by
132
139
  # `Entity.bulk_set_typed_eav_values` on each affected Value object
133
140
  # BEFORE `record.save` inside the per-record `with_context` block. The
134
- # Phase 04 versioning subscriber reads it preferentially over
141
+ # The transactional version writer reads it preferentially over
135
142
  # `context[:version_group_id]` so the UUID survives the outer-transaction
136
- # `after_commit` boundary even after `with_context` has unwound (the
143
+ # source transaction boundary even after `with_context` has unwound (the
137
144
  # `with_context` block lexically pops on yield-return; by the time the
138
- # outer transaction's after_commit chain fires, `TypedEAV.current_context`
145
+ # outer transaction completes, `TypedEAV.current_context`
139
146
  # would observe an empty Hash, but the per-Value snapshot persists in
140
147
  # the AR object's @pending_version_group_id ivar).
141
148
  #
142
149
  # Mirrors the existing in-memory ivar pattern at `value=` line 118
143
150
  # (`@cast_was_invalid`): a transient flag stamped during the write path
144
- # and read by a downstream observer (validate_value / subscriber). No
151
+ # and read by the transactional version writer. No
145
152
  # accessor magic — plain attr_accessor; the ivar is allocated lazily on
146
153
  # first write.
147
154
  #
148
- # Non-bulk callers do not stamp this ivar and the Phase 4 subscriber
155
+ # Non-bulk callers do not stamp this ivar and the transactional writer
149
156
  # falls back to `context[:version_group_id]` (which the existing
150
157
  # `with_context(version_group_id: uuid) { ... }` callers already set).
151
158
  # Backward compatible: every pre-Phase-6 caller path continues to work
152
159
  # unchanged.
153
160
  attr_accessor :pending_version_group_id
154
161
 
162
+ # Consume the correlation marker exactly once. The public reader/writer
163
+ # remain available for bulk callers, while the subscriber gets an
164
+ # atomic-in-process read-and-clear operation that cannot contaminate a
165
+ # later mutation of the same AR object.
166
+ def consume_pending_version_group_id
167
+ group_id = pending_version_group_id
168
+ self.pending_version_group_id = nil
169
+ group_id
170
+ end
171
+
155
172
  # Append-only audit log of mutations to this Value, ordered most-
156
173
  # recent-first. Returns a relation that can be chained (`.where`,
157
174
  # `.limit`, `.pluck`).
@@ -181,11 +198,10 @@ module TypedEAV
181
198
  end
182
199
 
183
200
  # Revert this Value's typed columns to the state recorded in
184
- # `version.before_value`, then save!. The save fires the existing
185
- # `after_commit :_dispatch_value_change_update` chain; EventDispatcher
186
- # routes through TypedEAV::Versioning::Subscriber (slot 0); a NEW
187
- # version row is written where after_value reflects the targeted
188
- # version's before_value.
201
+ # `version.before_value`, then save!. The save fires the transactional
202
+ # version callback and the independent public after_commit dispatcher; a NEW
203
+ # version row is written where after_value reflects the targeted version's
204
+ # before_value.
189
205
  #
190
206
  # This is the locked CONTEXT contract (04-CONTEXT.md §`Value#revert_to`
191
207
  # semantics): revert is itself versioned. Append-only audit trail
@@ -231,9 +247,9 @@ module TypedEAV
231
247
  # rubocop:disable Metrics/AbcSize -- three guard clauses (each with a multi-line error message including ids) plus the column-iteration body genuinely belong together; splitting them would obscure the locked check ordering documented above. The ABC complexity is just over the 25 threshold and reflects the explicit error-message construction (not control-flow density).
232
248
  def revert_to(version)
233
249
  # Check 1: source Value must still exist. plan 04-02's subscriber writes
234
- # value_id: nil for :destroy events (because the parent typed_eav_values
235
- # row is gone by after_commit on :destroy time and FK ON DELETE SET NULL
236
- # would FK-fail at INSERT otherwise). A destroy version cannot be
250
+ # value_id: nil for :destroy events. The destroy writer deliberately
251
+ # records nil while the parent exists, so the audit row is
252
+ # association-free before deletion and remains so afterward. A destroy version cannot be
237
253
  # reverted because we can't save! a destroyed AR record back into
238
254
  # existence. This check covers all destroy versions.
239
255
  if version.value_id.nil?
@@ -316,7 +332,7 @@ module TypedEAV
316
332
 
317
333
  after_initialize :apply_pending_value
318
334
 
319
- # Phase 03 event dispatch. THREE explicit `after_commit ..., on: :X`
335
+ # Public event dispatch. THREE explicit `after_commit ..., on: :X`
320
336
  # declarations rather than the after_create_commit/after_update_commit/
321
337
  # after_destroy_commit alias trio: Rails 8.1 has a documented alias
322
338
  # collision where reusing the same method name across the alias forms
@@ -325,17 +341,20 @@ module TypedEAV
325
341
  # first). The explicit `on:` form sidesteps the bug entirely.
326
342
  #
327
343
  # Each callback forwards to a private `_dispatch_value_change_*` method
328
- # that delegates to TypedEAV::EventDispatcher. Models stay thin — all
344
+ # that delegates to TypedEAV::EventDispatcher. Models stay thin — public
329
345
  # dispatch policy (internal-vs-user proc ordering, error rescue, context
330
346
  # injection) lives in EventDispatcher and is unit-testable without AR.
331
347
  after_commit :_dispatch_value_change_create, on: :create
332
348
  after_commit :_dispatch_value_change_update, on: :update
333
349
  after_commit :_dispatch_value_change_destroy, on: :destroy
334
350
 
351
+ # Version rows are written by boot-installed transactional callbacks so
352
+ # they are part of the same source transaction. These after_commit dispatchers
353
+ # remain for public callbacks and other non-durable observers.
354
+
335
355
  # Phase 05 image-attached dispatch. Declared AFTER the value-change
336
- # callbacks so it runs LAST in the after_commit chain — Phase 04
337
- # versioning (slot 0 inside _dispatch_value_change_*) and Phase 03
338
- # on_value_change both fire before this. The hook is informational
356
+ # callbacks so it runs LAST in the after_commit chain — the transactional
357
+ # version callback and Phase 03 on_value_change both fire before this. The hook is informational
339
358
  # ("an image was attached"), not mutational; running last avoids
340
359
  # polluting earlier hooks' snapshots / context with attachment-
341
360
  # derived state.
@@ -348,13 +367,33 @@ module TypedEAV
348
367
  # (Field::File, every other built-in) explicitly do NOT fire this
349
368
  # hook — File-attached has no parallel hook by ROADMAP design.
350
369
  after_commit :_dispatch_image_attached, on: %i[create update]
370
+ after_rollback :clear_pending_version_group_id
371
+
372
+ # Active Record invokes these transaction-record hooks from an ensure
373
+ # path even when another callback raises. Finalize here rather than in a
374
+ # trailing after_commit callback so a skipped dispatch cannot leave a
375
+ # correlation marker attached to an object reused by the caller.
376
+ def committed!(should_run_callbacks: true)
377
+ super
378
+ ensure
379
+ clear_pending_version_group_id
380
+ end
381
+
382
+ def rolledback!(force_restore_state: false, should_run_callbacks: true)
383
+ super
384
+ ensure
385
+ clear_pending_version_group_id
386
+ end
351
387
 
352
388
  private
353
389
 
354
390
  def apply_pending_value
355
- return unless @pending_value && field
391
+ return unless instance_variable_defined?(:@pending_value) && field
356
392
 
357
- if @pending_value.equal?(UNSET_VALUE)
393
+ pending_value = @pending_value
394
+ remove_instance_variable(:@pending_value)
395
+
396
+ if pending_value.equal?(UNSET_VALUE)
358
397
  # Sentinel-pending branch: dispatch directly to apply_field_default.
359
398
  # We deliberately do NOT route through `self.value =` here because
360
399
  # value= would re-trigger the sentinel branch with field present,
@@ -363,9 +402,28 @@ module TypedEAV
363
402
  # easy to follow.
364
403
  apply_field_default
365
404
  else
366
- self.value = @pending_value
405
+ self.value = pending_value
367
406
  end
368
- @pending_value = nil
407
+ end
408
+
409
+ def clear_pending_version_group_id
410
+ self.pending_version_group_id = nil
411
+ end
412
+
413
+ def _write_version_create
414
+ TypedEAV::Versioning::Subscriber.call(self, :create, TypedEAV.current_context)
415
+ end
416
+
417
+ def _write_version_update
418
+ return unless field&.value_changed?(self)
419
+
420
+ TypedEAV::Versioning::Subscriber.call(self, :update, TypedEAV.current_context)
421
+ end
422
+
423
+ def _write_version_destroy
424
+ return unless field
425
+
426
+ TypedEAV::Versioning::Subscriber.call(self, :destroy, TypedEAV.current_context)
369
427
  end
370
428
 
371
429
  # Writes the field's configured default to the typed column(s) via the
@@ -390,7 +448,6 @@ module TypedEAV
390
448
 
391
449
  if @cast_was_invalid
392
450
  errors.add(:value, :invalid)
393
- @cast_was_invalid = false
394
451
  return
395
452
  end
396
453
 
@@ -499,13 +556,13 @@ module TypedEAV
499
556
  # field_id NULLed by the Phase 02 ON DELETE SET NULL FK when a Field
500
557
  # with field_dependent: :nullify was destroyed). The event contract
501
558
  # is `(value, change_type, context)` and consumers expect
502
- # `value.field` to be readable; an orphan would confuse Phase 04
503
- # versioning and Phase 07 matview consumers, so we drop the event
559
+ # `value.field` to be readable; an orphan would confuse the transactional
560
+ # versioning and other internal consumers, so we drop the event
504
561
  # at the model boundary rather than push the nil-guard downstream.
505
562
  #
506
- # Update filter (Phase 04 fix): only fire :update when ANY of the typed
563
+ # Update filter: only fire :update when ANY of the typed
507
564
  # columns the field uses changed (value_columns plural — added in
508
- # plan 04-02). For all 17 single-cell field types as of Phase 04,
565
+ # transactional writer). For all single-cell field types,
509
566
  # value_columns returns [value_column], so this is behaviorally
510
567
  # identical to the singular form Phase 03 shipped. For Phase 05
511
568
  # Currency (two-cell), a change to either column correctly fires the
@@ -515,21 +572,23 @@ module TypedEAV
515
572
  #
516
573
  # A Value row's only meaningful change for downstream consumers is
517
574
  # its typed columns — field_id repointing or other bookkeeping shifts
518
- # are out-of-spec for the event contract. Without this filter, Phase 04
575
+ # are out-of-spec for the event contract. Without this filter, the
519
576
  # versioning would pile up no-op version rows (every audit-trail
520
- # commit) and Phase 07 matview would refresh on bookkeeping-only writes.
577
+ # commit) and internal consumers would refresh on bookkeeping-only writes.
521
578
 
522
579
  def _dispatch_value_change_create
523
580
  return unless field
524
581
 
525
582
  TypedEAV::EventDispatcher.dispatch_value_change(self, :create)
583
+ ensure
584
+ clear_pending_version_group_id
526
585
  end
527
586
 
528
587
  def _dispatch_value_change_update
529
588
  return unless field
530
589
  # Forward-compat with Phase 05 Currency (and any future multi-cell
531
590
  # field type): check if ANY of the typed columns the field uses
532
- # changed in the just-committed save. Phase 04 plan 02 introduces
591
+ # changed in the just-committed save. The transactional writer
533
592
  # `value_columns` plural in lib/typed_eav/column_mapping.rb; for
534
593
  # all 17 current single-cell types, value_columns returns
535
594
  # [value_column], so this filter is behaviorally identical to the
@@ -538,17 +597,21 @@ module TypedEAV
538
597
  # either cell now correctly fires the :update event — without this
539
598
  # plural fix, a Currency change to only the string_value (currency
540
599
  # code) cell would silently be missed by the dispatch gate, and
541
- # Phase 04 versioning would never see it (Scout §3 / Discrepancy D3
600
+ # transactional version writer would never see it
542
601
  # from plan 04-01).
543
602
  return unless field.value_changed?(self)
544
603
 
545
604
  TypedEAV::EventDispatcher.dispatch_value_change(self, :update)
605
+ ensure
606
+ clear_pending_version_group_id
546
607
  end
547
608
 
548
609
  def _dispatch_value_change_destroy
549
610
  return unless field
550
611
 
551
612
  TypedEAV::EventDispatcher.dispatch_value_change(self, :destroy)
613
+ ensure
614
+ clear_pending_version_group_id
552
615
  end
553
616
 
554
617
  # Phase 05 on_image_attached dispatch.
@@ -44,7 +44,7 @@ class AddParentScopeToTypedEAVPartitions < ActiveRecord::Migration[7.1]
44
44
  # standard semantics, so we don't need `NULLS NOT DISTINCT` (PG ≥ 15).
45
45
  #
46
46
  # Option A (`nulls_not_distinct: true`) was rejected because the
47
- # gemspec floor is `rails >= 7.1` and there is no PG-server-version
47
+ # gemspec floor is `rails >= 7.2` and there is no PG-server-version
48
48
  # pin — consumer apps may run PG 12/13/14 where the option does
49
49
  # not exist.
50
50
  #
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class EnforceParentScopeInvariant < ActiveRecord::Migration[7.1]
4
+ # Strong Migrations requires check-constraint validation outside the default
5
+ # transaction so the catalog-only add and validation can proceed safely.
6
+ disable_ddl_transaction!
7
+
4
8
  CONSTRAINTS = {
5
9
  typed_eav_fields: :chk_te_fields_parent_scope_requires_scope,
6
10
  typed_eav_sections: :chk_te_sections_parent_scope_requires_scope,