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.



314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'app/models/typed_eav/value.rb', line 314

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 The transactional version writer reads it preferentially over context[:version_group_id] so the UUID survives the outer-transaction source transaction boundary even after with_context has unwound (the with_context block lexically pops on yield-return; by the time the outer transaction completes, 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 the transactional version writer. No accessor magic — plain attr_accessor; the ivar is allocated lazily on first write.

Non-bulk callers do not stamp this ivar and the transactional writer 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.



160
161
162
# File 'app/models/typed_eav/value.rb', line 160

def pending_version_group_id
  @pending_version_group_id
end

Instance Method Details

#bulk_upsert_validation?Boolean

Returns:

  • (Boolean)


66
67
68
# File 'app/models/typed_eav/value.rb', line 66

def bulk_upsert_validation?
  validation_context == :bulk_upsert
end

#committed!(should_run_callbacks: true) ⇒ Object

Active Record invokes these transaction-record hooks from an ensure path even when another callback raises. Finalize here rather than in a trailing after_commit callback so a skipped dispatch cannot leave a correlation marker attached to an object reused by the caller.



376
377
378
379
380
# File 'app/models/typed_eav/value.rb', line 376

def committed!(should_run_callbacks: true)
  super
ensure
  clear_pending_version_group_id
end

#consume_pending_version_group_idObject

Consume the correlation marker exactly once. The public reader/writer remain available for bulk callers, while the subscriber gets an atomic-in-process read-and-clear operation that cannot contaminate a later mutation of the same AR object.



166
167
168
169
170
# File 'app/models/typed_eav/value.rb', line 166

def consume_pending_version_group_id
  group_id = pending_version_group_id
  self.pending_version_group_id = nil
  group_id
end

#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).



196
197
198
# File 'app/models/typed_eav/value.rb', line 196

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 transactional version callback and the independent public after_commit dispatcher; 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).



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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'app/models/typed_eav/value.rb', line 248

def revert_to(version)
  # Check 1: source Value must still exist. plan 04-02's subscriber writes
  # value_id: nil for :destroy events. The destroy writer deliberately
  # records nil while the parent exists, so the audit row is
  # association-free before deletion and remains so afterward. 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

#rolledback!(force_restore_state: false, should_run_callbacks: true) ⇒ Object



382
383
384
385
386
# File 'app/models/typed_eav/value.rb', line 382

def rolledback!(force_restore_state: false, should_run_callbacks: true)
  super
ensure
  clear_pending_version_group_id
end

#valueObject

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].



93
94
95
96
97
# File 'app/models/typed_eav/value.rb', line 93

def value
  return nil unless field

  field.read_value(self)
end

#value=(val) ⇒ Object



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
124
125
126
127
128
129
130
# File 'app/models/typed_eav/value.rb', line 99

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_columnObject

Which column this value lives in



133
134
135
# File 'app/models/typed_eav/value.rb', line 133

def value_column
  field.class.value_column
end