Class: TypedEAV::Value

Inherits:
ApplicationRecord show all
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 42

Mirrors 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

Instance Method Summary collapse

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_idObject

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

#historyObject

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

#valueObject

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_columnObject

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