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 ‘:value` kwarg → 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` 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` (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_columns` to 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_id` into the new version row’s context. If the caller wants to record the intent, they wrap the call in ‘TypedEAV.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_id` is 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 using ‘version.before_value` as 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.storage_contract.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` — the typed column’s scalar (Integer, String, BigDecimal, etc.). Multi-cell types (Phase 05: Currency) return a composite (e.g., ‘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`, which equals `self`.
86 87 88 89 90 |
# File 'app/models/typed_eav/value.rb', line 86 def value return nil unless field field.storage_contract.read(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.storage_contract.write(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 |