typed_eav 0.1.0 → 0.2.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 +80 -0
- data/README.md +634 -2
- data/app/models/typed_eav/field/base.rb +552 -6
- data/app/models/typed_eav/field/currency.rb +125 -0
- data/app/models/typed_eav/field/file.rb +98 -0
- data/app/models/typed_eav/field/image.rb +152 -0
- data/app/models/typed_eav/field/percentage.rb +100 -0
- data/app/models/typed_eav/field/reference.rb +230 -0
- data/app/models/typed_eav/section.rb +114 -4
- data/app/models/typed_eav/value.rb +461 -11
- data/app/models/typed_eav/value_version.rb +96 -0
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
- data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
- data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
- data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
- data/lib/typed_eav/bulk_write.rb +147 -0
- data/lib/typed_eav/column_mapping.rb +46 -0
- data/lib/typed_eav/config.rb +215 -19
- data/lib/typed_eav/csv_mapper.rb +158 -0
- data/lib/typed_eav/currency_storage_contract.rb +46 -0
- data/lib/typed_eav/engine.rb +117 -0
- data/lib/typed_eav/event_dispatcher.rb +151 -0
- data/lib/typed_eav/field_storage_contract.rb +68 -0
- data/lib/typed_eav/has_typed_eav.rb +455 -58
- data/lib/typed_eav/partition.rb +64 -0
- data/lib/typed_eav/query_builder.rb +39 -3
- data/lib/typed_eav/registry.rb +48 -9
- data/lib/typed_eav/schema_portability.rb +250 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioned.rb +73 -0
- data/lib/typed_eav/versioning/subscriber.rb +161 -0
- data/lib/typed_eav/versioning.rb +94 -0
- data/lib/typed_eav.rb +180 -12
- metadata +35 -1
|
@@ -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
|
|
89
|
+
field.storage_contract.read(self)
|
|
36
90
|
end
|
|
37
91
|
|
|
38
92
|
def value=(val)
|
|
39
|
-
if
|
|
40
|
-
#
|
|
41
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|