Class: TypedEAV::Value
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- TypedEAV::Value
- Defined in:
- app/models/typed_eav/value.rb
Constant Summary collapse
- UNSET_VALUE =
Sentinel for distinguishing "no value: kwarg given" from "value: nil given explicitly". Used by Value#initialize (substitutes UNSET_VALUE when the :value kwarg is missing) and Value#value= (treats the sentinel as the trigger to populate field.default_value):
typed_values.create(field: f) # → triggers default population typed_values.create(field: f, value: nil) # → stores nil (no default) typed_values.create(field: f, value: 42) # → stores 42Mirrors the UNSET_SCOPE / ALL_SCOPES public-sentinel pattern in lib/typed_eav/has_typed_eav.rb (intentionally NOT private_constant — advanced callers may want
val.equal?(TypedEAV::Value::UNSET_VALUE)checks in their own code). The freeze prevents accidental mutation that would break.equal?identity for any caller holding a reference. Object.new.freeze
Instance Attribute Summary collapse
-
#pending_version_group_id ⇒ Object
Phase 06 bulk-operations correlation tag — TRANSIENT in-memory ivar (NOT a DB column, NOT validated, NOT persisted).
Instance Method Summary collapse
-
#history ⇒ Object
Append-only audit log of mutations to this Value, ordered most- recent-first.
-
#initialize(attributes = nil) ⇒ Value
constructor
Override AR's initialize so missing
:valuekwarg → UNSET_VALUE substitution. -
#revert_to(version) ⇒ Object
Revert this Value's typed columns to the state recorded in
version.before_value, then save!. -
#value ⇒ Object
Logical value of this Value record as defined by its field type.
- #value=(val) ⇒ Object
-
#value_column ⇒ Object
Which column this value lives in.
Constructor Details
#initialize(attributes = nil) ⇒ Value
Override AR's initialize so missing :value kwarg → UNSET_VALUE
substitution. This is the only mechanism that lets us distinguish
"no value given" from "value: nil given" (both leave the typed column
nil; the difference can only be observed at construction time). The
sentinel then flows through value= and (if field is unset) into
@pending_value, where apply_pending_value resolves it to the
field's configured default once field becomes available.
accepts_nested_attributes_for paths and set_typed_eav_value always
pass an explicit value: (never missing the key), so they bypass this
substitution and continue to behave as before.
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 |
# File 'app/models/typed_eav/value.rb', line 298 def initialize(attributes = nil, &) if attributes.is_a?(Hash) attrs = attributes.dup attrs[:value] = UNSET_VALUE unless attrs.key?(:value) || attrs.key?("value") super(attrs, &) elsif defined?(ActionController::Parameters) && attributes.is_a?(ActionController::Parameters) # Permitted params hash-like: convert to a plain hash for the key check, # then re-pass. Same UNSET_VALUE substitution rule. attrs = attributes.to_h attrs[:value] = UNSET_VALUE unless attrs.key?(:value) || attrs.key?("value") super(attrs, &) else # nil, scalar, or any other shape AR's initialize accepts unchanged. super end end |
Instance Attribute Details
#pending_version_group_id ⇒ Object
Phase 06 bulk-operations correlation tag — TRANSIENT in-memory ivar
(NOT a DB column, NOT validated, NOT persisted). Stamped by
Entity.bulk_set_typed_eav_values on each affected Value object
BEFORE record.save inside the per-record with_context block. The
Phase 04 versioning subscriber reads it preferentially over
context[:version_group_id] so the UUID survives the outer-transaction
after_commit boundary even after with_context has unwound (the
with_context block lexically pops on yield-return; by the time the
outer transaction's after_commit chain fires, TypedEAV.current_context
would observe an empty Hash, but the per-Value snapshot persists in
the AR object's @pending_version_group_id ivar).
Mirrors the existing in-memory ivar pattern at value= line 118
(@cast_was_invalid): a transient flag stamped during the write path
and read by a downstream observer (validate_value / subscriber). No
accessor magic — plain attr_accessor; the ivar is allocated lazily on
first write.
Non-bulk callers do not stamp this ivar and the Phase 4 subscriber
falls back to context[:version_group_id] (which the existing
with_context(version_group_id: uuid) { ... } callers already set).
Backward compatible: every pre-Phase-6 caller path continues to work
unchanged.
153 154 155 |
# File 'app/models/typed_eav/value.rb', line 153 def pending_version_group_id @pending_version_group_id end |
Instance Method Details
#history ⇒ Object
Append-only audit log of mutations to this Value, ordered most-
recent-first. Returns a relation that can be chained (.where,
.limit, .pluck).
Implemented as an instance method (not has_many ... -> { order(...) })
so the ordering is explicit at the call site for documentation
purposes — readers see value.history.first and know they're getting
the most-recent version. Hidden default-scope ordering is harder to
discover and easier to accidentally override.
Tie-breaks on id when multiple versions share a changed_at (rare —
requires same-second writes from concurrent threads or a backfill
script that pinned a single timestamp). Without the secondary id
ordering, callers iterating history after a same-second batch
would see non-deterministic order across DB executions.
Survives Value destruction: even after value.destroy! and the FK
nulls value_id on the version rows, the version rows are still
queryable via the entity reference. history returns nothing in
that case (the versions association is keyed on value_id and
returns no rows when value_id is NULL on all rows). Use
TypedEAV::ValueVersion.where(entity: contact, field_id: field.id).order(changed_at: :desc)
to query orphaned audit history (the README §"Versioning" §"Querying
full audit history" subsection documents this fallback).
179 180 181 |
# File 'app/models/typed_eav/value.rb', line 179 def history versions.order(changed_at: :desc, id: :desc) end |
#revert_to(version) ⇒ Object
Revert this Value's typed columns to the state recorded in
version.before_value, then save!. The save fires the existing
after_commit :_dispatch_value_change_update chain; EventDispatcher
routes through TypedEAV::Versioning::Subscriber (slot 0); a NEW
version row is written where after_value reflects the targeted
version's before_value.
This is the locked CONTEXT contract (04-CONTEXT.md §Value#revert_to
semantics): revert is itself versioned. Append-only audit trail
preserved. Matches PaperTrail / Audited industry conventions.
What revert_to does NOT do
- Does NOT use
update_columnsto skip callbacks. That would write the columns silently and produce NO new version row — the audit log would lose the revert event entirely. The locked CONTEXT decision is explicit about this. - Does NOT inject a synthetic
reverted_from_version_idinto the new version row's context. If the caller wants to record the intent, they wrap the call inTypedEAV.with_context( reverted_from_version_id: v.id) { value.revert_to(v) }. The subscriber captures the active context as-is. - Does NOT fire if the targeted version's source Value was destroyed
(
version.value_idis nil per plan 04-02's destroy-event handling). Raises ArgumentError. Cannot save! a destroyed AR record back into existence — caller must create a new Value manually usingversion.before_valueas the seed state. - Does NOT fire if the targeted version is a :create (before_value
is
{}— empty — and there's nothing to revert to). Raises ArgumentError. - Does NOT cross-Value: raises ArgumentError if
version.value_id != self.id. Cross-Value reverts are a misuse pattern (the caller passed the wrong record), not a feature.
Revertable version types
Only :update versions are revertable in practice:
- :create → fails empty-before_value check.
- :destroy → fails value_id-nil check (source Value gone).
- :update → succeeds (assuming same-Value).
Documented in §Plan-time decisions §A.
Multi-cell forward-compat
Iterates field.class.value_columns (plural) to handle Phase 05
Currency (and any future multi-cell type). For all 17 current
single-cell types, value_columns returns [value_column] and the
loop runs once.
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 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'app/models/typed_eav/value.rb', line 232 def revert_to(version) # Check 1: source Value must still exist. plan 04-02's subscriber writes # value_id: nil for :destroy events (because the parent typed_eav_values # row is gone by after_commit on :destroy time and FK ON DELETE SET NULL # would FK-fail at INSERT otherwise). A destroy version cannot be # reverted because we can't save! a destroyed AR record back into # existence. This check covers all destroy versions. if version.value_id.nil? raise ArgumentError, "Cannot revert version##{version.id}: source Value was destroyed " \ "(version.value_id is nil). To restore a destroyed entity's typed " \ "values, create a new Value record manually using version.before_value " \ "as the seed state." end # Check 2: version must have a before-state to revert TO. :create # versions have empty before_value (`{}` — locked semantic per # 04-CONTEXT.md §"Version row jsonb shape"). There is nothing to # revert to — the create represents the first state of the Value. # Apps that want "revert to initial creation state" semantically want # to reset to the field's default value, which is a different operation. if version.before_value.empty? raise ArgumentError, "Cannot revert to version##{version.id}: before_value is empty (this " \ "version represents a :create event with no before-state). Choose a " \ "later :update version to revert from." end # Check 3: cross-Value guard. Caller must pass a version belonging to # this Value. Naming both ids in the error message helps inline debug. unless version.value_id == id raise ArgumentError, "Cannot revert Value##{id} to a version belonging to Value##{version.value_id} " \ "(value_id mismatch). Pass a version returned by #{self.class.name.demodulize}#history." end # Restore each typed column from the version's before_value snapshot. # value_columns (plural) handles multi-cell types like Phase 05 Currency. # We use `self[col] = …` (raw column write) instead of `self.value = …` # (cast through the field type) because: # 1. value.before_value already stores cast values (the subscriber # writes `value[col]` which is the cast value AR returned). # 2. self.value = expects the field's "logical" value shape (a single # scalar for single-cell types, a {amount, currency} hash for # Currency in Phase 05). Reconstructing that shape from # before_value's per-column hash adds complexity for zero benefit # since the per-column values are exactly what we need. field.class.value_columns.each do |col| self[col] = version.before_value[col.to_s] end save! end |
#value ⇒ Object
Logical value of this Value record as defined by its field type.
Single-cell field types return self[value_column] — the typed
column's scalar (Integer, String, BigDecimal, etc.). Multi-cell
types (Phase 05: Currency) return a composite (e.g.,
{amount: BigDecimal, currency: String}) composed from multiple
typed columns by the field's read_value override.
The dispatch through field.read_value(self) is the single read-side
extension point — Value remains oblivious to multi-cell types. Single-
cell behavior is unchanged: Field::Base#read_value's default returns
value_record[self.class.value_column], which equals
self[value_column].
86 87 88 89 90 |
# File 'app/models/typed_eav/value.rb', line 86 def value return nil unless field field.read_value(self) end |
#value=(val) ⇒ Object
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
# File 'app/models/typed_eav/value.rb', line 92 def value=(val) if val.equal?(UNSET_VALUE) # Sentinel branch: caller did NOT pass an explicit `value:` kwarg. # Apply the field's configured default if field is already assigned; # otherwise stash the sentinel in @pending_value to be resolved later # by apply_pending_value (parallel to the explicit-value pending path # below). Without this branch, `typed_values.create(field: f)` would # silently leave the typed column nil even when the field declares a # default — losing the configuration the caller already paid to set. if field apply_field_default else @pending_value = UNSET_VALUE end elsif field # Cast through the field type, then dispatch the write to the field's # `write_value(self, casted)`. For single-cell types, write_value's # default writes `self[value_column] = casted` — behaviorally # identical to the prior direct write. For multi-cell types # (Phase 05 Currency), write_value unpacks the composite casted # value across multiple typed columns. Without this dispatch, a # Currency cast result (a Hash) would be written verbatim to # decimal_value, raising TypeMismatch at save time. # Rails will further cast each column on save via its column type. casted, invalid = field.cast(val) field.write_value(self, casted) @cast_was_invalid = invalid else # Field not yet assigned - stash for later @pending_value = val end end |
#value_column ⇒ Object
Which column this value lives in
126 127 128 |
# File 'app/models/typed_eav/value.rb', line 126 def value_column field.class.value_column end |