Module: TypedEAV::Field::TypedStorage

Extended by:
ActiveSupport::Concern
Included in:
Base
Defined in:
lib/typed_eav/field/typed_storage.rb

Overview

One concern owns the entire native-typed-column storage seam.

Field types declare WHICH typed column(s) hold their value via the class-level DSL (value_column, value_columns, operators, operator_column), and override three instance methods to compose multi-cell value shapes (read_value, write_value, apply_default). Snapshot/change-detection helpers (value_changed?, before_snapshot, after_snapshot) are concrete and derive from value_columns; they are NOT overridable — the snapshot shape is a versioning-coupled invariant.

Class-level DSL

value_column :integer_value           # single-cell sugar; primary cell
value_columns :decimal_value, :string_value  # plural form
operators :eq, :gt, :is_null          # restrict supported operators
operator_column(:currency_eq)         # override to route ops to cells

Both value_column and value_columns share the same @value_columns class instance variable. value_column returns the first element of value_columns, preserving the single-cell sugar shape.

Override-point instance methods (the entire extension surface)

  • read_value(record) — compose the logical value from the cells.
  • write_value(record, casted) — unpack the casted value across cells.
  • apply_default(record) — populate cells from the field's default.

The default implementations target value_columns.first (single-cell behavior). Multi-cell types override ALL THREE — overriding just one creates an asymmetry where reads see the multi-cell shape but writes / defaults populate only one column (or vice versa).

Concrete (non-overridable) snapshot helpers

  • value_changed?(record) — true iff ANY value_columns column has a saved_change_to_attribute? — used by the Value :update dispatch gate so multi-cell types fire the event when only the second cell changed.
  • before_snapshot(record, change_type) — per-column hash keyed by string column names. :create returns {}.
  • after_snapshot(record, change_type) — per-column hash keyed by string column names. :destroy returns {}.

Snapshot keys are stringified so query patterns like WHERE before_value->>'integer_value' = '42' work uniformly.

Constant Summary collapse

DEFAULT_OPERATORS_BY_COLUMN =
{
  boolean_value: %i[eq not_eq is_null is_not_null],
  string_value: %i[eq not_eq contains not_contains starts_with ends_with is_null is_not_null],
  text_value: %i[eq not_eq contains not_contains starts_with ends_with is_null is_not_null],
  integer_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
  decimal_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
  date_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
  datetime_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
  json_value: %i[contains is_null is_not_null],
}.freeze
FALLBACK_OPERATORS =
%i[eq not_eq is_null is_not_null].freeze

Instance Method Summary collapse

Instance Method Details

#after_snapshot(value_record, change_type) ⇒ Object

Post-change snapshot keyed by string column names.

  • :create / :update → => value_record[col]
  • :destroy → {} (no after state)


228
229
230
231
232
233
234
235
236
237
# File 'lib/typed_eav/field/typed_storage.rb', line 228

def after_snapshot(value_record, change_type)
  case change_type.to_sym
  when :create, :update
    self.class.value_columns.to_h { |column| [column.to_s, value_record[column]] }
  when :destroy
    {}
  else
    raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
  end
end

#apply_default(value_record) ⇒ Object

Writes this field's configured default to value_record. Default writes default_value to the primary cell, bypassing Value#value= to avoid re-casting an already-cast default. Override in multi-cell types to populate multiple cells from a composite default.



154
155
156
# File 'lib/typed_eav/field/typed_storage.rb', line 154

def apply_default(value_record)
  value_record[self.class.value_columns.first] = default_value
end

#before_snapshot(value_record, change_type) ⇒ Object

Pre-change snapshot keyed by string column names.

  • :create → {} (no before state)
  • :update → => attribute_before_last_save(col)
  • :destroy → => value_record[col] (in-memory on the destroyed AR record per Phase 03 P04 live-validation)


210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/typed_eav/field/typed_storage.rb', line 210

def before_snapshot(value_record, change_type)
  case change_type.to_sym
  when :create
    {}
  when :update
    self.class.value_columns.to_h do |column|
      [column.to_s, value_record.attribute_before_last_save(column.to_s)]
    end
  when :destroy
    self.class.value_columns.to_h { |column| [column.to_s, value_record[column]] }
  else
    raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
  end
end

#cast_query_operand(operator, raw) ⇒ Object

Normalize values before QueryBuilder emits SQL. Keeping this beside the write caster makes query and write semantics use the same rules.



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/typed_eav/field/typed_storage.rb', line 167

def cast_query_operand(operator, raw)
  case operator.to_sym
  when :between
    bounds = if raw.is_a?(Range)
               [raw.begin, raw.end]
             elsif raw.is_a?(Array) && raw.length == 2
               raw
             end
    raise ArgumentError, ":between expects a Range or two-element Array" unless bounds

    casted = bounds.map { |bound| cast_query_value(bound) }
    casted.first..casted.last
  when :any_eq
    raise ArgumentError, ":any_eq expects a single array element" if raw.is_a?(Array)

    values = cast_query_value([raw])
    values&.first
  when :all_eq
    raise ArgumentError, ":all_eq expects an Array" unless raw.is_a?(Array)

    cast_query_value(raw)
  else
    cast_query_value(raw)
  end
end

#logical_value_missing?(value_record) ⇒ Boolean

A logical value is missing only when every physical storage cell is nil. Multi-cell fields may legitimately be partially populated; such a row is present and must not be overwritten by a default backfill.

Returns:



161
162
163
# File 'lib/typed_eav/field/typed_storage.rb', line 161

def logical_value_missing?(value_record)
  self.class.value_columns.all? { |column| value_record[column].nil? }
end

#read_value(value_record) ⇒ Object

Returns the logical value for this field as stored on value_record. Default reads the primary cell. Override in multi-cell types to compose a hash (e.g., Field::Currency returns {amount: r[:decimal_value], currency: r[:string_value]}).



139
140
141
# File 'lib/typed_eav/field/typed_storage.rb', line 139

def read_value(value_record)
  value_record[self.class.value_columns.first]
end

#value_changed?(value_record) ⇒ Boolean

True iff ANY of the field's value_columns had a saved change in the just-committed save. Used by Value's :update dispatch gate so multi-cell types correctly fire the event when only the second cell changed (regression case Phase 5 D3).

Returns:



199
200
201
202
203
# File 'lib/typed_eav/field/typed_storage.rb', line 199

def value_changed?(value_record)
  self.class.value_columns.any? do |column|
    value_record.saved_change_to_attribute?(column)
  end
end

#write_value(value_record, casted) ⇒ Object

Writes a casted value to value_record. Default writes the primary cell. Override in multi-cell types to unpack the casted value across multiple cells.



146
147
148
# File 'lib/typed_eav/field/typed_storage.rb', line 146

def write_value(value_record, casted)
  value_record[self.class.value_columns.first] = casted
end