Class: TypedEAV::Field::Base
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- TypedEAV::Field::Base
- Includes:
- ColumnMapping
- Defined in:
- app/models/typed_eav/field/base.rb
Overview
rubocop:disable Metrics/ClassLength – Field::Base is the central STI parent: associations, validations, cascade dispatch, partition-aware ordering helpers, default-value handling, and the partition-aware backfill all live here together because they share the (entity_type, scope, parent_scope) partition contract. Splitting into concerns would scatter that contract and obscure the cross-cutting invariants the validators and helpers enforce together.
Direct Known Subclasses
Boolean, Color, Currency, Date, DateArray, DateTime, Decimal, DecimalArray, Email, File, Image, Integer, IntegerArray, Json, LongText, MultiSelect, Reference, Select, Text, TextArray, Url
Constant Summary collapse
- RESERVED_NAMES =
── Validations ──
%w[id type class created_at updated_at].freeze
Constants included from ColumnMapping
ColumnMapping::DEFAULT_OPERATORS_BY_COLUMN, ColumnMapping::FALLBACK_OPERATORS
Class Method Summary collapse
-
.export_schema(entity_type:, scope: nil, parent_scope: nil) ⇒ Object
Backward-compatible public entry point.
-
.import_schema(hash, on_conflict: :error) ⇒ Object
Backward-compatible public entry point.
-
.storage_contract_class(contract_class = nil) ⇒ Object
── Introspection ──.
Instance Method Summary collapse
-
#allowed_option_values ⇒ Object
Allowed option values for select/multi-select validation.
-
#apply_default_to(value_record) ⇒ Object
Writes this field’s configured default to the given Value record.
- #array_field? ⇒ Boolean
-
#backfill_default! ⇒ Object
Backfills existing entities with this field’s configured default value.
-
#cast(raw) ⇒ Object
── Type casting ── Returns a tuple: [casted_value, invalid?].
-
#clear_option_cache! ⇒ Object
Kept for backward compatibility but now a no-op since we don’t cache.
-
#default_value ⇒ Object
── Default value handling ── Stored in default_value_meta as <raw_value> so the jsonb column can hold any type’s default without an extra typed column.
- #default_value=(val) ⇒ Object
- #field_type_name ⇒ Object
-
#insert_at(position) ⇒ Object
Insert at 1-based position.
-
#move_higher ⇒ Object
── Display ordering ──.
- #move_lower ⇒ Object
- #move_to_bottom ⇒ Object
- #move_to_top ⇒ Object
- #optionable? ⇒ Boolean
-
#read_value(value_record) ⇒ Object
Returns the logical value for this field as stored on the given Value record.
- #storage_contract ⇒ Object
-
#validate_typed_value(record, val) ⇒ Object
── Per-type value validation (polymorphic dispatch from Value) ──.
-
#write_value(value_record, casted) ⇒ Object
Writes a casted value to the given Value record.
Class Method Details
.export_schema(entity_type:, scope: nil, parent_scope: nil) ⇒ Object
Backward-compatible public entry point. Implementation lives in SchemaPortability so Field::Base does not carry schema projection, conflict policy, option replacement, and section import details.
427 428 429 430 431 432 433 |
# File 'app/models/typed_eav/field/base.rb', line 427 def self.export_schema(entity_type:, scope: nil, parent_scope: nil) TypedEAV::SchemaPortability.export_schema( entity_type: entity_type, scope: scope, parent_scope: parent_scope, ) end |
.import_schema(hash, on_conflict: :error) ⇒ Object
Backward-compatible public entry point. Implementation lives in SchemaPortability; this delegator preserves existing callers.
437 438 439 |
# File 'app/models/typed_eav/field/base.rb', line 437 def self.import_schema(hash, on_conflict: :error) TypedEAV::SchemaPortability.import_schema(hash, on_conflict: on_conflict) end |
.storage_contract_class(contract_class = nil) ⇒ Object
── Introspection ──
313 314 315 316 317 318 319 |
# File 'app/models/typed_eav/field/base.rb', line 313 def self.storage_contract_class(contract_class = nil) if contract_class @storage_contract_class = contract_class else @storage_contract_class || TypedEAV::FieldStorageContract end end |
Instance Method Details
#allowed_option_values ⇒ Object
Allowed option values for select/multi-select validation. When ‘field_options` is already loaded (eager-load path), read from memory instead of issuing a fresh `pluck` query.
340 341 342 343 344 345 346 |
# File 'app/models/typed_eav/field/base.rb', line 340 def allowed_option_values if .loaded? .map(&:value) else .pluck(:value) end end |
#apply_default_to(value_record) ⇒ Object
Writes this field’s configured default to the given Value record. Default writes ‘value_record = default_value`, bypassing Value#value= to avoid re-casting an already-cast default (default_value is cast at field save time via validate_default_value). Override in multi-cell types to populate multiple columns from a composite default (e.g., Field::Currency unpacks `default_value`’s ‘…, currency: …` hash into decimal_value + string_value).
Called from Value#apply_field_default in two contexts:
1. Initial value assignment when no `value:` kwarg was passed
(UNSET_VALUE sentinel resolution path).
2. Pending-value resolution (apply_pending_value branch where
@pending_value was UNSET_VALUE and the field arrived later).
307 308 309 |
# File 'app/models/typed_eav/field/base.rb', line 307 def apply_default_to(value_record) value_record[self.class.value_column] = default_value end |
#array_field? ⇒ Boolean
329 330 331 |
# File 'app/models/typed_eav/field/base.rb', line 329 def array_field? false end |
#backfill_default! ⇒ Object
Backfills existing entities with this field’s configured default value. Iterates entities of ‘entity_type` in batches of 1000 via `find_in_batches`, filtering each batch member by the field’s (scope, parent_scope) partition. Each WHOLE batch runs inside one transaction so:
- a long-running backfill can be interrupted and resumed (each
completed batch is committed; the caller re-runs to pick up where
they stopped — the skip rule re-checks each batch member),
- per-batch transaction overhead is bounded: at 1M entities × 1000
per batch, this is ~1000 transactions, not 1M.
Skip rule (per-record, applied INSIDE the batch loop): skip when the entity already has a non-nil typed value for this field. A Value row whose typed column is nil is still a candidate for backfill — the skip rule is “non-nil typed column,” not “Value row exists” (matches CONTEXT.md).
Partition match: when field.scope is non-nil, the entity must respond to typed_eav_scope and the value must match field.scope (as String). When field.parent_scope is non-nil, same check for typed_eav_parent_scope. When field.scope is nil (global field), no scope filter — iterate all entities of entity_type.
Why find_in_batches (not find_each): we need the batch as a unit so the transaction boundary aligns with the batch boundary. find_each yields records one-at-a-time, which would either force per-record transactions (wrong — burns overhead, contradicts CONTEXT.md) or require us to buffer batches manually outside AR’s batching logic.
Why explicit ‘value: default_value` (not the UNSET_VALUE sentinel): backfill knows the default, so passing it explicitly bypasses the sentinel resolution path on Value#value=. Explicit `value: x` continues to store x in both pre-sentinel and post-sentinel code, which keeps backfill BC-safe regardless of plan ordering.
Synchronous by default. For async dispatch, define your own job:
class BackfillJob < ApplicationJob
def perform(field_id) = TypedEAV::Field::Base.find(field_id).backfill_default!
end
BackfillJob.perform_later(field.id)
(Documented inline as RDoc; not built-in to keep the gem dep-free.)
398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 |
# File 'app/models/typed_eav/field/base.rb', line 398 def backfill_default! # Short-circuit: nothing to backfill if no default configured. We # explicitly do NOT write nil rows — backfill is for propagating a # configured default, not for materializing empty Value rows. return if default_value.nil? entity_class = entity_type.constantize column = self.class.value_column entity_class.find_in_batches(batch_size: 1000) do |batch| # One transaction per batch (NOT per record). If the transaction # raises mid-batch, the WHOLE batch rolls back and the exception # surfaces; prior batches stay committed. Caller re-runs idempotently # because the per-record skip rule re-checks each entity. ActiveRecord::Base.transaction(requires_new: true) do batch.each do |entity| next unless partition_matches?(entity) backfill_one(entity, column) end end end end |
#cast(raw) ⇒ Object
── Type casting ──Returns a tuple: [casted_value, invalid?].
-
casted_value is the coerced value (or nil when raw is nil/blank)
-
invalid? is true when raw was non-empty but unparseable for this type; Value#validate_value uses the flag to surface :invalid errors (vs :blank for nil-from-nil).
Subclasses override to enforce type semantics. Default is an identity pass-through that never flags invalid.
Callers that only need the coerced value should use ‘cast(raw).first`.
243 244 245 |
# File 'app/models/typed_eav/field/base.rb', line 243 def cast(raw) [raw, false] end |
#clear_option_cache! ⇒ Object
Kept for backward compatibility but now a no-op since we don’t cache.
349 350 351 |
# File 'app/models/typed_eav/field/base.rb', line 349 def clear_option_cache! # no-op end |
#default_value ⇒ Object
── Default value handling ──Stored in default_value_meta as <raw_value> so the jsonb column can hold any type’s default without an extra typed column.
222 223 224 |
# File 'app/models/typed_eav/field/base.rb', line 222 def default_value cast(["v"]).first end |
#default_value=(val) ⇒ Object
226 227 228 |
# File 'app/models/typed_eav/field/base.rb', line 226 def default_value=(val) ["v"] = val end |
#field_type_name ⇒ Object
325 326 327 |
# File 'app/models/typed_eav/field/base.rb', line 325 def field_type_name self.class.name.demodulize.underscore end |
#insert_at(position) ⇒ Object
Insert at 1-based position. Clamps position to [1, partition_count]: insert_at(0) and any non-positive value behaves as move_to_top; insert_at(999) on a 5-item partition behaves as move_to_bottom. Mirrors acts_as_list’s clamp behavior.
204 205 206 207 208 209 210 211 212 213 214 215 216 |
# File 'app/models/typed_eav/field/base.rb', line 204 def insert_at(position) reorder_within_partition do |siblings| idx = siblings.index { |r| r.id == id } next siblings if idx.nil? target = position.clamp(1, siblings.size) - 1 next siblings if idx == target moving = siblings.delete_at(idx) siblings.insert(target, moving) siblings end end |
#move_higher ⇒ Object
── Display ordering ──
Partition-aware ordering helpers, keyed by (entity_type, scope, parent_scope). Names mirror acts_as_list for muscle memory; the implementation is in-house per CONVENTIONS.md “one hard dep, soft-detect everything else” — adopting acts_as_list as a runtime dep would force every consumer to pull it in.
Race semantics: each operation runs inside an AR transaction and acquires a partition-level row lock via ‘for_entity(…).order(:id).lock(“FOR UPDATE”)`. This issues SELECT … FOR UPDATE on every member of the partition (including self) in deterministic ID order — concurrent reorders within the same partition serialize on the lock acquisition, and the deterministic order prevents deadlocks across threads. Cross-partition operations never block each other because they lock disjoint row sets.
Why a partition-level lock (not with_lock on self): two threads moving DIFFERENT records within the SAME partition would both pass a per-record lock on self and race on the sibling list / normalization. The partition-level FOR UPDATE is the only correct serialization boundary.
Sort-order semantics: every operation normalizes the partition’s sort_order column to consecutive integers 1..N (no gaps) on completion. Records with sort_order: nil are positioned after all positioned rows during normalization (Postgres NULLS LAST).
Boundary moves are no-ops, not errors. move_higher on the top item returns without raising; move_lower on the bottom item likewise.
158 159 160 161 162 163 164 165 166 |
# File 'app/models/typed_eav/field/base.rb', line 158 def move_higher reorder_within_partition do |siblings| idx = siblings.index { |r| r.id == id } next siblings if idx.nil? || idx.zero? # already at top, or not in partition siblings[idx], siblings[idx - 1] = siblings[idx - 1], siblings[idx] siblings end end |
#move_lower ⇒ Object
168 169 170 171 172 173 174 175 176 |
# File 'app/models/typed_eav/field/base.rb', line 168 def move_lower reorder_within_partition do |siblings| idx = siblings.index { |r| r.id == id } next siblings if idx.nil? || idx == siblings.size - 1 # already at bottom siblings[idx], siblings[idx + 1] = siblings[idx + 1], siblings[idx] siblings end end |
#move_to_bottom ⇒ Object
189 190 191 192 193 194 195 196 197 198 |
# File 'app/models/typed_eav/field/base.rb', line 189 def move_to_bottom reorder_within_partition do |siblings| idx = siblings.index { |r| r.id == id } next siblings if idx.nil? || idx == siblings.size - 1 moving = siblings.delete_at(idx) siblings.push(moving) siblings end end |
#move_to_top ⇒ Object
178 179 180 181 182 183 184 185 186 187 |
# File 'app/models/typed_eav/field/base.rb', line 178 def move_to_top reorder_within_partition do |siblings| idx = siblings.index { |r| r.id == id } next siblings if idx.nil? || idx.zero? moving = siblings.delete_at(idx) siblings.unshift(moving) siblings end end |
#optionable? ⇒ Boolean
333 334 335 |
# File 'app/models/typed_eav/field/base.rb', line 333 def optionable? false end |
#read_value(value_record) ⇒ Object
Returns the logical value for this field as stored on the given Value record. Default reads ‘value_record`. Override in multi-cell field types to compose a hash from multiple columns (e.g., Field::Currency returns `r, currency: r`).
Called from Value#value. The Value#value ‘return nil unless field` guard runs before this method, so `self` is always set.
275 276 277 |
# File 'app/models/typed_eav/field/base.rb', line 275 def read_value(value_record) value_record[self.class.value_column] end |
#storage_contract ⇒ Object
321 322 323 |
# File 'app/models/typed_eav/field/base.rb', line 321 def storage_contract @storage_contract ||= self.class.storage_contract_class.new(self) end |
#validate_typed_value(record, val) ⇒ Object
── Per-type value validation (polymorphic dispatch from Value) ──
Default no-op. Subclasses override to enforce their constraints (length, range, pattern, option inclusion, array size, etc.) and add errors to ‘record.errors`. Shared helpers below (validate_length, validate_pattern, validate_range, etc.) are available to subclasses.
447 448 449 |
# File 'app/models/typed_eav/field/base.rb', line 447 def validate_typed_value(record, val) # no-op by default end |
#write_value(value_record, casted) ⇒ Object
Writes a casted value to the given Value record. Default writes ‘value_record = casted`. Override in multi- cell types to unpack a composite casted value into multiple columns (e.g., Field::Currency unpacks `BigDecimal, currency: String` into decimal_value + string_value).
Called from Value#value=. The cast invariant is preserved: ‘casted` is whatever the field’s ‘cast(raw)` returned as the first element. For single-cell types that’s a scalar; for Currency it’s a Hash. Without this dispatch, a Currency cast result (a Hash) would be written to a single typed column, raising TypeMismatch at save time.
290 291 292 |
# File 'app/models/typed_eav/field/base.rb', line 290 def write_value(value_record, casted) value_record[self.class.value_column] = casted end |