typed_eav 0.1.0 → 0.2.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +634 -2
  4. data/app/models/typed_eav/field/base.rb +552 -6
  5. data/app/models/typed_eav/field/currency.rb +125 -0
  6. data/app/models/typed_eav/field/file.rb +98 -0
  7. data/app/models/typed_eav/field/image.rb +152 -0
  8. data/app/models/typed_eav/field/percentage.rb +100 -0
  9. data/app/models/typed_eav/field/reference.rb +230 -0
  10. data/app/models/typed_eav/section.rb +114 -4
  11. data/app/models/typed_eav/value.rb +461 -11
  12. data/app/models/typed_eav/value_version.rb +96 -0
  13. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
  14. data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
  15. data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
  16. data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
  17. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
  18. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
  19. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
  20. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
  21. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
  22. data/lib/typed_eav/bulk_write.rb +147 -0
  23. data/lib/typed_eav/column_mapping.rb +46 -0
  24. data/lib/typed_eav/config.rb +215 -19
  25. data/lib/typed_eav/csv_mapper.rb +158 -0
  26. data/lib/typed_eav/currency_storage_contract.rb +46 -0
  27. data/lib/typed_eav/engine.rb +117 -0
  28. data/lib/typed_eav/event_dispatcher.rb +151 -0
  29. data/lib/typed_eav/field_storage_contract.rb +68 -0
  30. data/lib/typed_eav/has_typed_eav.rb +455 -58
  31. data/lib/typed_eav/partition.rb +64 -0
  32. data/lib/typed_eav/query_builder.rb +39 -3
  33. data/lib/typed_eav/registry.rb +48 -9
  34. data/lib/typed_eav/schema_portability.rb +250 -0
  35. data/lib/typed_eav/version.rb +1 -1
  36. data/lib/typed_eav/versioned.rb +73 -0
  37. data/lib/typed_eav/versioning/subscriber.rb +161 -0
  38. data/lib/typed_eav/versioning.rb +94 -0
  39. data/lib/typed_eav.rb +180 -12
  40. metadata +36 -2
@@ -4,12 +4,53 @@ module TypedEAV
4
4
  class Value < ApplicationRecord
5
5
  self.table_name = "typed_eav_values"
6
6
 
7
+ # Sentinel for distinguishing "no value: kwarg given" from "value: nil
8
+ # given explicitly". Used by Value#initialize (substitutes UNSET_VALUE
9
+ # when the :value kwarg is missing) and Value#value= (treats the
10
+ # sentinel as the trigger to populate field.default_value):
11
+ #
12
+ # typed_values.create(field: f) # → triggers default population
13
+ # typed_values.create(field: f, value: nil) # → stores nil (no default)
14
+ # typed_values.create(field: f, value: 42) # → stores 42
15
+ #
16
+ # Mirrors the UNSET_SCOPE / ALL_SCOPES public-sentinel pattern in
17
+ # lib/typed_eav/has_typed_eav.rb (intentionally NOT private_constant —
18
+ # advanced callers may want `val.equal?(TypedEAV::Value::UNSET_VALUE)`
19
+ # checks in their own code). The freeze prevents accidental mutation
20
+ # that would break `.equal?` identity for any caller holding a reference.
21
+ UNSET_VALUE = Object.new.freeze
22
+
7
23
  # ── Associations ──
8
24
 
9
25
  belongs_to :entity, polymorphic: true, inverse_of: :typed_values
26
+
27
+ # `field` is optional because the Phase 02 cascade migration changed the
28
+ # FK to ON DELETE SET NULL — orphaned Value rows (`field_id IS NULL`)
29
+ # are an expected outcome when `field_dependent: :nullify` is used.
30
+ # Read-path guards in `InstanceMethods#typed_eav_value` and
31
+ # `#typed_eav_hash` silently skip them; the write-path validators below
32
+ # (`validate_value`, `validate_entity_matches_field`,
33
+ # `validate_field_scope_matches_entity`) all `return unless field`
34
+ # already, so optional belongs_to does not weaken any write-path
35
+ # invariant — see RESEARCH §Area 3 orphan-safety audit.
10
36
  belongs_to :field,
11
37
  class_name: "TypedEAV::Field::Base",
12
- inverse_of: :values
38
+ inverse_of: :values,
39
+ optional: true
40
+
41
+ # Append-only audit log of mutations to this Value. Written by
42
+ # TypedEAV::Versioning::Subscriber (plan 04-02) when the host entity
43
+ # opted into versioning AND `config.versioning = true`. Read via
44
+ # `value.versions.order(changed_at: :desc)` (or the convenience
45
+ # `value.history` alias added in plan 04-03).
46
+ #
47
+ # `dependent: nil` (the implicit AR default) — version rows are
48
+ # preserved when the Value is destroyed (the FK is ON DELETE SET NULL,
49
+ # nulling value_id; the row remains queryable by (entity_type,
50
+ # entity_id, field_id)).
51
+ has_many :versions,
52
+ class_name: "TypedEAV::ValueVersion",
53
+ inverse_of: :value
13
54
 
14
55
  # ── Validations ──
15
56
 
@@ -29,18 +70,51 @@ module TypedEAV
29
70
  # and `value` reads it back as a Ruby Integer. No custom caster needed
30
71
  # for storage - the database column type IS the caster.
31
72
 
73
+ # Logical value of this Value record as defined by its field type.
74
+ #
75
+ # Single-cell field types return `self[value_column]` — the typed
76
+ # column's scalar (Integer, String, BigDecimal, etc.). Multi-cell
77
+ # types (Phase 05: Currency) return a composite (e.g.,
78
+ # `{amount: BigDecimal, currency: String}`) composed from multiple
79
+ # typed columns by the field's `read_value` override.
80
+ #
81
+ # The dispatch through `field.read_value(self)` is the single read-side
82
+ # extension point — Value remains oblivious to multi-cell types. Single-
83
+ # cell behavior is unchanged: Field::Base#read_value's default returns
84
+ # `value_record[self.class.value_column]`, which equals
85
+ # `self[value_column]`.
32
86
  def value
33
87
  return nil unless field
34
88
 
35
- self[value_column]
89
+ field.storage_contract.read(self)
36
90
  end
37
91
 
38
92
  def value=(val)
39
- if field
40
- # Cast through the field type, then write to the native column.
41
- # Rails will further cast via the column type on save.
93
+ if val.equal?(UNSET_VALUE)
94
+ # Sentinel branch: caller did NOT pass an explicit `value:` kwarg.
95
+ # Apply the field's configured default if field is already assigned;
96
+ # otherwise stash the sentinel in @pending_value to be resolved later
97
+ # by apply_pending_value (parallel to the explicit-value pending path
98
+ # below). Without this branch, `typed_values.create(field: f)` would
99
+ # silently leave the typed column nil even when the field declares a
100
+ # default — losing the configuration the caller already paid to set.
101
+ if field
102
+ apply_field_default
103
+ else
104
+ @pending_value = UNSET_VALUE
105
+ end
106
+ elsif field
107
+ # Cast through the field type, then dispatch the write to the field's
108
+ # `write_value(self, casted)`. For single-cell types, write_value's
109
+ # default writes `self[value_column] = casted` — behaviorally
110
+ # identical to the prior direct write. For multi-cell types
111
+ # (Phase 05 Currency), write_value unpacks the composite casted
112
+ # value across multiple typed columns. Without this dispatch, a
113
+ # Currency cast result (a Hash) would be written verbatim to
114
+ # decimal_value, raising TypeMismatch at save time.
115
+ # Rails will further cast each column on save via its column type.
42
116
  casted, invalid = field.cast(val)
43
- self[value_column] = casted
117
+ field.storage_contract.write(self, casted)
44
118
  @cast_was_invalid = invalid
45
119
  else
46
120
  # Field not yet assigned - stash for later
@@ -53,19 +127,264 @@ module TypedEAV
53
127
  field.class.value_column
54
128
  end
55
129
 
130
+ # Phase 06 bulk-operations correlation tag — TRANSIENT in-memory ivar
131
+ # (NOT a DB column, NOT validated, NOT persisted). Stamped by
132
+ # `Entity.bulk_set_typed_eav_values` on each affected Value object
133
+ # BEFORE `record.save` inside the per-record `with_context` block. The
134
+ # Phase 04 versioning subscriber reads it preferentially over
135
+ # `context[:version_group_id]` so the UUID survives the outer-transaction
136
+ # `after_commit` boundary even after `with_context` has unwound (the
137
+ # `with_context` block lexically pops on yield-return; by the time the
138
+ # outer transaction's after_commit chain fires, `TypedEAV.current_context`
139
+ # would observe an empty Hash, but the per-Value snapshot persists in
140
+ # the AR object's @pending_version_group_id ivar).
141
+ #
142
+ # Mirrors the existing in-memory ivar pattern at `value=` line 118
143
+ # (`@cast_was_invalid`): a transient flag stamped during the write path
144
+ # and read by a downstream observer (validate_value / subscriber). No
145
+ # accessor magic — plain attr_accessor; the ivar is allocated lazily on
146
+ # first write.
147
+ #
148
+ # Non-bulk callers do not stamp this ivar and the Phase 4 subscriber
149
+ # falls back to `context[:version_group_id]` (which the existing
150
+ # `with_context(version_group_id: uuid) { ... }` callers already set).
151
+ # Backward compatible: every pre-Phase-6 caller path continues to work
152
+ # unchanged.
153
+ attr_accessor :pending_version_group_id
154
+
155
+ # Append-only audit log of mutations to this Value, ordered most-
156
+ # recent-first. Returns a relation that can be chained (`.where`,
157
+ # `.limit`, `.pluck`).
158
+ #
159
+ # Implemented as an instance method (not `has_many ... -> { order(...) }`)
160
+ # so the ordering is explicit at the call site for documentation
161
+ # purposes — readers see `value.history.first` and know they're getting
162
+ # the most-recent version. Hidden default-scope ordering is harder to
163
+ # discover and easier to accidentally override.
164
+ #
165
+ # Tie-breaks on id when multiple versions share a changed_at (rare —
166
+ # requires same-second writes from concurrent threads or a backfill
167
+ # script that pinned a single timestamp). Without the secondary id
168
+ # ordering, callers iterating `history` after a same-second batch
169
+ # would see non-deterministic order across DB executions.
170
+ #
171
+ # Survives Value destruction: even after `value.destroy!` and the FK
172
+ # nulls value_id on the version rows, the version rows are still
173
+ # queryable via the entity reference. `history` returns nothing in
174
+ # that case (the `versions` association is keyed on value_id and
175
+ # returns no rows when value_id is NULL on all rows). Use
176
+ # `TypedEAV::ValueVersion.where(entity: contact, field_id: field.id).order(changed_at: :desc)`
177
+ # to query orphaned audit history (the README §"Versioning" §"Querying
178
+ # full audit history" subsection documents this fallback).
179
+ def history
180
+ versions.order(changed_at: :desc, id: :desc)
181
+ end
182
+
183
+ # 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.
189
+ #
190
+ # This is the locked CONTEXT contract (04-CONTEXT.md §`Value#revert_to`
191
+ # semantics): revert is itself versioned. Append-only audit trail
192
+ # preserved. Matches PaperTrail / Audited industry conventions.
193
+ #
194
+ # ## What revert_to does NOT do
195
+ #
196
+ # - Does NOT use `update_columns` to skip callbacks. That would write
197
+ # the columns silently and produce NO new version row — the audit
198
+ # log would lose the revert event entirely. The locked CONTEXT
199
+ # decision is explicit about this.
200
+ # - Does NOT inject a synthetic `reverted_from_version_id` into the
201
+ # new version row's context. If the caller wants to record the
202
+ # intent, they wrap the call in `TypedEAV.with_context(
203
+ # reverted_from_version_id: v.id) { value.revert_to(v) }`. The
204
+ # subscriber captures the active context as-is.
205
+ # - Does NOT fire if the targeted version's source Value was destroyed
206
+ # (`version.value_id` is nil per plan 04-02's destroy-event handling).
207
+ # Raises ArgumentError. Cannot save! a destroyed AR record back into
208
+ # existence — caller must create a new Value manually using
209
+ # `version.before_value` as the seed state.
210
+ # - Does NOT fire if the targeted version is a :create (before_value
211
+ # is `{}` — empty — and there's nothing to revert to). Raises
212
+ # ArgumentError.
213
+ # - Does NOT cross-Value: raises ArgumentError if `version.value_id !=
214
+ # self.id`. Cross-Value reverts are a misuse pattern (the caller
215
+ # passed the wrong record), not a feature.
216
+ #
217
+ # ## Revertable version types
218
+ #
219
+ # Only :update versions are revertable in practice:
220
+ # - :create → fails empty-before_value check.
221
+ # - :destroy → fails value_id-nil check (source Value gone).
222
+ # - :update → succeeds (assuming same-Value).
223
+ # Documented in §Plan-time decisions §A.
224
+ #
225
+ # ## Multi-cell forward-compat
226
+ #
227
+ # Iterates `field.class.value_columns` (plural) to handle Phase 05
228
+ # Currency (and any future multi-cell type). For all 17 current
229
+ # single-cell types, value_columns returns [value_column] and the
230
+ # loop runs once.
231
+ # 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
+ def revert_to(version)
233
+ # 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
237
+ # reverted because we can't save! a destroyed AR record back into
238
+ # existence. This check covers all destroy versions.
239
+ if version.value_id.nil?
240
+ raise ArgumentError,
241
+ "Cannot revert version##{version.id}: source Value was destroyed " \
242
+ "(version.value_id is nil). To restore a destroyed entity's typed " \
243
+ "values, create a new Value record manually using version.before_value " \
244
+ "as the seed state."
245
+ end
246
+
247
+ # Check 2: version must have a before-state to revert TO. :create
248
+ # versions have empty before_value (`{}` — locked semantic per
249
+ # 04-CONTEXT.md §"Version row jsonb shape"). There is nothing to
250
+ # revert to — the create represents the first state of the Value.
251
+ # Apps that want "revert to initial creation state" semantically want
252
+ # to reset to the field's default value, which is a different operation.
253
+ if version.before_value.empty?
254
+ raise ArgumentError,
255
+ "Cannot revert to version##{version.id}: before_value is empty (this " \
256
+ "version represents a :create event with no before-state). Choose a " \
257
+ "later :update version to revert from."
258
+ end
259
+
260
+ # Check 3: cross-Value guard. Caller must pass a version belonging to
261
+ # this Value. Naming both ids in the error message helps inline debug.
262
+ unless version.value_id == id
263
+ raise ArgumentError,
264
+ "Cannot revert Value##{id} to a version belonging to Value##{version.value_id} " \
265
+ "(value_id mismatch). Pass a version returned by #{self.class.name.demodulize}#history."
266
+ end
267
+
268
+ # Restore each typed column from the version's before_value snapshot.
269
+ # value_columns (plural) handles multi-cell types like Phase 05 Currency.
270
+ # We use `self[col] = …` (raw column write) instead of `self.value = …`
271
+ # (cast through the field type) because:
272
+ # 1. value.before_value already stores cast values (the subscriber
273
+ # writes `value[col]` which is the cast value AR returned).
274
+ # 2. self.value = expects the field's "logical" value shape (a single
275
+ # scalar for single-cell types, a {amount, currency} hash for
276
+ # Currency in Phase 05). Reconstructing that shape from
277
+ # before_value's per-column hash adds complexity for zero benefit
278
+ # since the per-column values are exactly what we need.
279
+ field.storage_contract.value_columns.each do |col|
280
+ self[col] = version.before_value[col.to_s]
281
+ end
282
+
283
+ save!
284
+ end
285
+ # rubocop:enable Metrics/AbcSize
286
+
287
+ # Override AR's initialize so missing `:value` kwarg → UNSET_VALUE
288
+ # substitution. This is the only mechanism that lets us distinguish
289
+ # "no value given" from "value: nil given" (both leave the typed column
290
+ # nil; the difference can only be observed at construction time). The
291
+ # sentinel then flows through `value=` and (if field is unset) into
292
+ # `@pending_value`, where `apply_pending_value` resolves it to the
293
+ # field's configured default once field becomes available.
294
+ #
295
+ # `accepts_nested_attributes_for` paths and `set_typed_eav_value` always
296
+ # pass an explicit `value:` (never missing the key), so they bypass this
297
+ # substitution and continue to behave as before.
298
+ def initialize(attributes = nil, &)
299
+ if attributes.is_a?(Hash)
300
+ attrs = attributes.dup
301
+ attrs[:value] = UNSET_VALUE unless attrs.key?(:value) || attrs.key?("value")
302
+ super(attrs, &)
303
+ elsif defined?(ActionController::Parameters) && attributes.is_a?(ActionController::Parameters)
304
+ # Permitted params hash-like: convert to a plain hash for the key check,
305
+ # then re-pass. Same UNSET_VALUE substitution rule.
306
+ attrs = attributes.to_h
307
+ attrs[:value] = UNSET_VALUE unless attrs.key?(:value) || attrs.key?("value")
308
+ super(attrs, &)
309
+ else
310
+ # nil, scalar, or any other shape AR's initialize accepts unchanged.
311
+ super
312
+ end
313
+ end
314
+
56
315
  # ── Callbacks ──
57
316
 
58
317
  after_initialize :apply_pending_value
59
318
 
319
+ # Phase 03 event dispatch. THREE explicit `after_commit ..., on: :X`
320
+ # declarations rather than the after_create_commit/after_update_commit/
321
+ # after_destroy_commit alias trio: Rails 8.1 has a documented alias
322
+ # collision where reusing the same method name across the alias forms
323
+ # causes only the LAST registration to win (each alias points at
324
+ # `after_commit` internally and the second declaration overwrites the
325
+ # first). The explicit `on:` form sidesteps the bug entirely.
326
+ #
327
+ # Each callback forwards to a private `_dispatch_value_change_*` method
328
+ # that delegates to TypedEAV::EventDispatcher. Models stay thin — all
329
+ # dispatch policy (internal-vs-user proc ordering, error rescue, context
330
+ # injection) lives in EventDispatcher and is unit-testable without AR.
331
+ after_commit :_dispatch_value_change_create, on: :create
332
+ after_commit :_dispatch_value_change_update, on: :update
333
+ after_commit :_dispatch_value_change_destroy, on: :destroy
334
+
335
+ # 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
339
+ # ("an image was attached"), not mutational; running last avoids
340
+ # polluting earlier hooks' snapshots / context with attachment-
341
+ # derived state.
342
+ #
343
+ # The `on: %i[create update]` filter mirrors the value-change pattern.
344
+ # The dispatcher itself further narrows to "field is Field::Image"
345
+ # AND "string_value just changed" AND "attachment is attached" — so
346
+ # plain Text/Integer Value writes pay only the after_commit hop, no
347
+ # callable invocation, no association probe. Non-Image typed Values
348
+ # (Field::File, every other built-in) explicitly do NOT fire this
349
+ # hook — File-attached has no parallel hook by ROADMAP design.
350
+ after_commit :_dispatch_image_attached, on: %i[create update]
351
+
60
352
  private
61
353
 
62
354
  def apply_pending_value
63
355
  return unless @pending_value && field
64
356
 
65
- self.value = @pending_value
357
+ if @pending_value.equal?(UNSET_VALUE)
358
+ # Sentinel-pending branch: dispatch directly to apply_field_default.
359
+ # We deliberately do NOT route through `self.value =` here because
360
+ # value= would re-trigger the sentinel branch with field present,
361
+ # giving the same outcome but obscuring the dispatch — keeping the
362
+ # call explicit makes the parallel between value= and this branch
363
+ # easy to follow.
364
+ apply_field_default
365
+ else
366
+ self.value = @pending_value
367
+ end
66
368
  @pending_value = nil
67
369
  end
68
370
 
371
+ # Writes the field's configured default to the typed column(s) via the
372
+ # `field.apply_default_to(self)` dispatch. Does NOT route through value=
373
+ # because field.default_value is already cast via
374
+ # cast(default_value_meta["v"]).first — re-casting would be redundant.
375
+ # Field-side validate_default_value (field/base.rb) catches invalid raw
376
+ # defaults at field save time, so what apply_default_to writes is always
377
+ # either a castable value or nil.
378
+ #
379
+ # Multi-cell forward-compat: single-cell types fall through to
380
+ # `self[value_column] = field.default_value` (Field::Base default).
381
+ # Currency / future multi-cell types override `apply_default_to` to
382
+ # populate multiple columns from a composite default. The dispatch
383
+ # preserves the bypass-Value#value= contract end-to-end.
384
+ def apply_field_default
385
+ field.storage_contract.apply_default(self)
386
+ end
387
+
69
388
  def validate_value
70
389
  return unless field
71
390
 
@@ -135,15 +454,146 @@ module TypedEAV
135
454
  # field with the same entity_type but a different scope would still
136
455
  # attach. Reject unless the field's scope matches the entity's
137
456
  # typed_eav_scope (globals, scope=NULL, remain shared).
457
+ #
458
+ # Two-axis check: when `field.parent_scope` is set, also enforce that
459
+ # `entity.typed_eav_parent_scope` matches. The Field-level orphan-parent
460
+ # invariant (`Field::Base#validate_parent_scope_invariant`) guarantees
461
+ # `field.parent_scope.present?` implies `field.scope.present?`, so the
462
+ # scope-axis check above has already validated the scope half by the
463
+ # time we reach the parent_scope branch. Same `errors.add(:field, :invalid)`
464
+ # error key/value as today — no new symbol introduced.
465
+ # rubocop:disable Metrics/AbcSize -- two axis-checks (scope + parent_scope) with respond_to? + match guards belong in one validator; splitting would obscure that they share a single error symbol and that the parent_scope branch trusts the Field-level orphan-parent invariant.
138
466
  def validate_field_scope_matches_entity
139
467
  return unless field && entity
140
- return if field.scope.nil?
141
- return unless entity.respond_to?(:typed_eav_scope)
142
468
 
143
- entity_scope = entity.typed_eav_scope
144
- return if entity_scope && field.scope == entity_scope.to_s
469
+ # Scope axis: skip when the field is global (scope nil). Otherwise the
470
+ # entity must declare typed_eav_scope (host opted into has_typed_eav)
471
+ # and its scope must match the field's.
472
+ if field.scope.present?
473
+ return errors.add(:field, :invalid) unless entity.respond_to?(:typed_eav_scope)
474
+
475
+ entity_scope = entity.typed_eav_scope
476
+ return errors.add(:field, :invalid) unless entity_scope && field.scope == entity_scope.to_s
477
+ end
478
+
479
+ # Parent-scope axis: only fires when field.parent_scope is set. The
480
+ # `respond_to?(:typed_eav_parent_scope)` check is redundant for hosts
481
+ # that went through `has_typed_eav` (the InstanceMethods mixin defines
482
+ # the method unconditionally now), but kept for the rare path where
483
+ # external code instantiates Value records bypassing has_typed_eav —
484
+ # the same pattern as the scope-axis check above.
485
+ return if field.parent_scope.blank?
486
+
487
+ return errors.add(:field, :invalid) unless entity.respond_to?(:typed_eav_parent_scope)
488
+
489
+ entity_parent_scope = entity.typed_eav_parent_scope
490
+ return if entity_parent_scope && field.parent_scope == entity_parent_scope.to_s
145
491
 
146
492
  errors.add(:field, :invalid)
147
493
  end
494
+ # rubocop:enable Metrics/AbcSize
495
+
496
+ # ── Phase 03 event dispatch ──
497
+ #
498
+ # All three forwarders short-circuit when `field.nil?` (orphan Value:
499
+ # field_id NULLed by the Phase 02 ON DELETE SET NULL FK when a Field
500
+ # with field_dependent: :nullify was destroyed). The event contract
501
+ # 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
504
+ # at the model boundary rather than push the nil-guard downstream.
505
+ #
506
+ # Update filter (Phase 04 fix): only fire :update when ANY of the typed
507
+ # 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,
509
+ # value_columns returns [value_column], so this is behaviorally
510
+ # identical to the singular form Phase 03 shipped. For Phase 05
511
+ # Currency (two-cell), a change to either column correctly fires the
512
+ # event. Without this fix, Phase 05 Currency would have a latent bug
513
+ # where changes to the second cell alone are silently dropped at the
514
+ # dispatch gate (Scout §3 / Discrepancy D3 from plan 04-01).
515
+ #
516
+ # A Value row's only meaningful change for downstream consumers is
517
+ # its typed columns — field_id repointing or other bookkeeping shifts
518
+ # are out-of-spec for the event contract. Without this filter, Phase 04
519
+ # versioning would pile up no-op version rows (every audit-trail
520
+ # commit) and Phase 07 matview would refresh on bookkeeping-only writes.
521
+
522
+ def _dispatch_value_change_create
523
+ return unless field
524
+
525
+ TypedEAV::EventDispatcher.dispatch_value_change(self, :create)
526
+ end
527
+
528
+ def _dispatch_value_change_update
529
+ return unless field
530
+ # Forward-compat with Phase 05 Currency (and any future multi-cell
531
+ # field type): check if ANY of the typed columns the field uses
532
+ # changed in the just-committed save. Phase 04 plan 02 introduces
533
+ # `value_columns` plural in lib/typed_eav/column_mapping.rb; for
534
+ # all 17 current single-cell types, value_columns returns
535
+ # [value_column], so this filter is behaviorally identical to the
536
+ # prior singular form. For Phase 05 Currency
537
+ # (value_columns → [:decimal_value, :string_value]), a change to
538
+ # either cell now correctly fires the :update event — without this
539
+ # plural fix, a Currency change to only the string_value (currency
540
+ # code) cell would silently be missed by the dispatch gate, and
541
+ # Phase 04 versioning would never see it (Scout §3 / Discrepancy D3
542
+ # from plan 04-01).
543
+ return unless field.storage_contract.changed?(self)
544
+
545
+ TypedEAV::EventDispatcher.dispatch_value_change(self, :update)
546
+ end
547
+
548
+ def _dispatch_value_change_destroy
549
+ return unless field
550
+
551
+ TypedEAV::EventDispatcher.dispatch_value_change(self, :destroy)
552
+ end
553
+
554
+ # Phase 05 on_image_attached dispatch.
555
+ #
556
+ # Fires Config.on_image_attached(value, blob) when ALL of:
557
+ # 1. ::ActiveStorage::Blob is defined (lazy soft-detect; the
558
+ # `:attachment` association doesn't exist when AS is unloaded).
559
+ # 2. field is non-nil AND is a Field::Image (NOT Field::File —
560
+ # File-attached has no parallel hook by ROADMAP design).
561
+ # 3. self responds to :attachment (engine boot registered the
562
+ # has_one_attached macro on TypedEAV::Value).
563
+ # 4. attachment.attached? — there's an actual blob to pass.
564
+ # 5. string_value (the signed_id storage column) just changed in
565
+ # the committed save — filters out unrelated updates that
566
+ # happen to leave an existing attachment in place.
567
+ # 6. Config.on_image_attached is non-nil (no-op when not configured;
568
+ # zero overhead for apps that don't use the hook).
569
+ #
570
+ # Error policy: rescues StandardError and logs via Rails.logger. The
571
+ # after_commit chain MUST NOT crash on hook failures — the row is
572
+ # already committed and the user's save call has returned. Mirrors
573
+ # the on_value_change error-isolation precedent (EventDispatcher's
574
+ # user-callback rescue policy at 03-CONTEXT.md §User-callback error
575
+ # policy).
576
+ #
577
+ # Why not in EventDispatcher: this hook is image-specific, not a
578
+ # value-change generalization. EventDispatcher's contract is a
579
+ # `(value, change_type, context)` tuple; on_image_attached's
580
+ # contract is `(value, blob)`. Routing through EventDispatcher would
581
+ # require a fourth dispatch surface; the model-side after_commit is
582
+ # the simplest fit.
583
+ def _dispatch_image_attached
584
+ return unless defined?(::ActiveStorage::Blob)
585
+ return unless field
586
+ return unless field.is_a?(TypedEAV::Field::Image)
587
+ return unless respond_to?(:attachment)
588
+ return unless attachment.attached?
589
+ return unless saved_change_to_string_value?
590
+
591
+ hook = TypedEAV.config.on_image_attached
592
+ return if hook.nil?
593
+
594
+ hook.call(self, attachment.blob)
595
+ rescue StandardError => e
596
+ Rails.logger.error("TypedEAV on_image_attached hook raised: #{e.class}: #{e.message}")
597
+ end
148
598
  end
149
599
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Append-only audit log row for a TypedEAV::Value mutation. One row per
5
+ # :create / :update / :destroy event when the host entity opted into
6
+ # versioning AND the gem-level master switch is on. Written by
7
+ # TypedEAV::Versioning::Subscriber (plan 04-02), read via
8
+ # Value#history (plan 04-03) or directly via this model.
9
+ #
10
+ # ## Schema invariants (locked at 04-CONTEXT.md)
11
+ #
12
+ # - `before_value` and `after_value` are jsonb hashes keyed by typed-
13
+ # column name (e.g., {"integer_value": 42}). Empty {} means "no
14
+ # recorded value" (typical of a :create event's before, or a
15
+ # :destroy event's after). {"<col>": null} means "recorded nil"
16
+ # (a deliberate clear). The two are NOT semantically equivalent.
17
+ # - `change_type` is one of "create" | "update" | "destroy" — string,
18
+ # not enum, for forward compat. Phase 04 ships these three; future
19
+ # phases may extend the validator.
20
+ # - `value_id` and `field_id` are nullable: ON DELETE SET NULL on both
21
+ # FKs preserves the audit log when the live Value/Field is destroyed
22
+ # (matches Phase 02's cascade pattern for typed_eav_values.field_id).
23
+ # - `(entity_type, entity_id)` is the durable identity. Even after the
24
+ # Value row is destroyed and value_id is NULLed, the entity tuple
25
+ # tells consumers which host record this version belonged to.
26
+ # - `changed_by` is a plain string — Lead's plan-time decision (see
27
+ # 04-01-PLAN §Plan-time decisions §1). Apps resolve to AR records
28
+ # on the read side: `User.find_by(id: version.changed_by)`.
29
+ #
30
+ # ## Why no default_scope
31
+ #
32
+ # Append-only logs benefit from explicit ordering at the call site
33
+ # (`Value#history.order(changed_at: :desc)`) rather than a hidden
34
+ # default_scope that consumers must learn to override. Mirrors the
35
+ # gem's existing convention (no default_scope on Value, Field::Base,
36
+ # Section, Option). The idx_te_vvs_value covering index serves the
37
+ # `(value_id, changed_at DESC)` query path natively.
38
+ class ValueVersion < ApplicationRecord
39
+ self.table_name = "typed_eav_value_versions"
40
+
41
+ # ── Associations ──
42
+
43
+ # Source Value. optional: true because the FK is ON DELETE SET NULL
44
+ # — destroying the live Value preserves the version row but NULLs
45
+ # the FK column. Without optional: true, AR's belongs_to validator
46
+ # would reject any version row whose source Value was destroyed.
47
+ belongs_to :value,
48
+ class_name: "TypedEAV::Value",
49
+ inverse_of: :versions,
50
+ optional: true
51
+
52
+ # Source Field. Same rationale as :value above. Stored separately
53
+ # from `value.field` because (a) the live Value's field_id may itself
54
+ # be NULL post-Phase-02 cascade, (b) the version row may outlive
55
+ # both Value and Field, and (c) callers asking "what field did this
56
+ # version belong to" want a direct lookup, not a two-hop chain.
57
+ # No `inverse_of:` — Field::Base does not declare a reverse
58
+ # `has_many :versions` association (the audit log is queried by
59
+ # value_id or by (entity_type, entity_id), not by field_id from
60
+ # the field side).
61
+ belongs_to :field,
62
+ class_name: "TypedEAV::Field::Base",
63
+ optional: true,
64
+ inverse_of: false
65
+
66
+ # Polymorphic entity. NOT optional — every version row is durably
67
+ # tied to its host entity tuple even when value_id / field_id are
68
+ # NULLed. Consumers query history by (entity_type, entity_id).
69
+ belongs_to :entity, polymorphic: true
70
+
71
+ # ── Validations ──
72
+
73
+ # change_type closed set. Mirrors the Phase 02 field_dependent
74
+ # validator pattern (field/base.rb:56-59) — string column +
75
+ # inclusion validator narrows the set at the model layer while
76
+ # keeping schema migrations additive.
77
+ CHANGE_TYPES = %w[create update destroy].freeze
78
+
79
+ validates :change_type, inclusion: {
80
+ in: CHANGE_TYPES,
81
+ message: "must be one of: #{CHANGE_TYPES.join(", ")}",
82
+ }
83
+ # Explicit entity_type / entity_id presence validators kept alongside the
84
+ # `belongs_to :entity, polymorphic: true` declaration above (which would
85
+ # implicitly enforce both already). Plan 04-01 must_haves list both as
86
+ # named validators so a `validates :entity_type, presence: true` grep
87
+ # locates them — readability over DRY here.
88
+ validates :entity_type, presence: true
89
+ validates :entity_id, presence: true # rubocop:disable Rails/RedundantPresenceValidationOnBelongsTo -- explicit per plan 04-01 must_haves; redundancy is intentional documentation
90
+ validates :changed_at, presence: true
91
+ # No validation on before_value / after_value shape — the subscriber
92
+ # is the only writer and its contract is locked at plan 04-02. App
93
+ # code that bypasses the subscriber and writes raw rows directly is
94
+ # explicitly out of scope for Phase 04.
95
+ end
96
+ end