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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +157 -0
- data/README.md +241 -60
- data/app/models/typed_eav/field/base.rb +77 -27
- data/app/models/typed_eav/field/currency.rb +43 -0
- data/app/models/typed_eav/field/file.rb +1 -1
- data/app/models/typed_eav/field/image.rb +1 -1
- data/app/models/typed_eav/field/reference.rb +11 -0
- data/app/models/typed_eav/section.rb +11 -5
- data/app/models/typed_eav/value.rb +95 -32
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +1 -1
- data/db/migrate/20260712000000_enforce_parent_scope_invariant.rb +4 -0
- data/db/migrate/20260816000000_use_partial_covering_scalar_indexes.rb +198 -0
- data/lib/typed_eav/bulk_read.rb +41 -9
- data/lib/typed_eav/bulk_upsert.rb +141 -0
- data/lib/typed_eav/bulk_write.rb +65 -39
- data/lib/typed_eav/config.rb +19 -21
- data/lib/typed_eav/csv_mapper.rb +1 -1
- data/lib/typed_eav/engine.rb +16 -27
- data/lib/typed_eav/entity_query.rb +32 -7
- data/lib/typed_eav/event_dispatcher.rb +21 -30
- data/lib/typed_eav/field/typed_storage.rb +48 -0
- data/lib/typed_eav/field_deletion.rb +75 -0
- data/lib/typed_eav/query_builder.rb +17 -27
- data/lib/typed_eav/registry.rb +7 -8
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioning/subscriber.rb +25 -29
- data/lib/typed_eav/versioning.rb +59 -41
- data/lib/typed_eav.rb +2 -0
- metadata +4 -1
|
@@ -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
|
|
321
|
-
# whose
|
|
322
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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 =
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
604
|
-
#
|
|
605
|
-
|
|
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
|
|
661
|
+
elsif logical_value_missing?(existing)
|
|
612
662
|
existing.update!(value: default_value)
|
|
613
663
|
end
|
|
614
|
-
# else:
|
|
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 —
|
|
650
|
-
#
|
|
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.
|
|
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.
|
|
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 =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
141
|
+
# The transactional version writer reads it preferentially over
|
|
135
142
|
# `context[:version_group_id]` so the UUID survives the outer-transaction
|
|
136
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
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
|
|
235
|
-
#
|
|
236
|
-
#
|
|
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
|
-
#
|
|
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 —
|
|
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 —
|
|
337
|
-
#
|
|
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
|
|
391
|
+
return unless instance_variable_defined?(:@pending_value) && field
|
|
356
392
|
|
|
357
|
-
|
|
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 =
|
|
405
|
+
self.value = pending_value
|
|
367
406
|
end
|
|
368
|
-
|
|
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
|
|
503
|
-
# versioning and
|
|
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
|
|
563
|
+
# Update filter: only fire :update when ANY of the typed
|
|
507
564
|
# columns the field uses changed (value_columns plural — added in
|
|
508
|
-
#
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
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.
|
|
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,
|