Class: TypedEAV::Field::Base
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- TypedEAV::Field::Base
- Includes:
- TypedStorage
- Defined in:
- app/models/typed_eav/field/base.rb
Overview
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. Validation helpers that were previously co-located (length /
pattern / range / option-inclusion) have moved down to the new family
bases (Field::ValidatedString, Field::RangeBounded,
Field::Optionable) per ADR-0004; validate_array_size stays here
because its callers span unrelated families.
Direct Known Subclasses
Boolean, Color, Currency, DateArray, DecimalArray, File, Image, IntegerArray, Json, LongText, MultiSelect, RangeBounded, Reference, Select, TextArray, ValidatedString
Constant Summary collapse
- RESERVED_NAMES =
── Validations ──
%w[id type class created_at updated_at].freeze
Constants included from TypedStorage
TypedStorage::DEFAULT_OPERATORS_BY_COLUMN, TypedStorage::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.
Instance Method Summary collapse
-
#allowed_option_values ⇒ Object
Allowed option values for select/multi-select validation.
- #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
so the jsonb column can hold any type's default without an extra typed column. - #default_value=(val) ⇒ Object
-
#display_name ⇒ Object
Canonical human-facing display string (issue #21).
-
#field_type_name ⇒ Object
── Introspection ──.
-
#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
-
#validate_typed_value(record, val) ⇒ Object
── Per-type value validation (polymorphic dispatch from Value) ──.
Methods included from TypedStorage
#after_snapshot, #apply_default, #before_snapshot, #read_value, #value_changed?, #write_value
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.
387 388 389 390 391 392 393 |
# File 'app/models/typed_eav/field/base.rb', line 387 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.
397 398 399 |
# File 'app/models/typed_eav/field/base.rb', line 397 def self.import_schema(hash, on_conflict: :error) TypedEAV::SchemaPortability.import_schema(hash, on_conflict: on_conflict) 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.
291 292 293 294 295 296 297 |
# File 'app/models/typed_eav/field/base.rb', line 291 def allowed_option_values if .loaded? .map(&:value) else .pluck(:value) end end |
#array_field? ⇒ Boolean
280 281 282 |
# File 'app/models/typed_eav/field/base.rb', line 280 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.)
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 |
# File 'app/models/typed_eav/field/base.rb', line 351 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 eligible = batch.select { |entity| partition_matches?(entity) } existing_by_entity_id = TypedEAV::Value .where( entity_type: entity_class.polymorphic_name, entity_id: eligible.map(&:id), field_id: id, ) .index_by(&:entity_id) eligible.each do |entity| backfill_one(entity, column, existing_by_entity_id[entity.id]) 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.
250 251 252 |
# File 'app/models/typed_eav/field/base.rb', line 250 def cast(raw) [raw, false] end |
#clear_option_cache! ⇒ Object
Kept for backward compatibility but now a no-op since we don't cache. TypedEAV does not invoke this hook internally; overriding it is not a supported option-lifecycle extension point.
302 303 304 |
# File 'app/models/typed_eav/field/base.rb', line 302 def clear_option_cache! # no-op end |
#default_value ⇒ Object
── Default value handling ──
Stored in default_value_meta as
229 230 231 |
# File 'app/models/typed_eav/field/base.rb', line 229 def default_value cast(["v"]).first end |
#default_value=(val) ⇒ Object
233 234 235 |
# File 'app/models/typed_eav/field/base.rb', line 233 def default_value=(val) ["v"] = val end |
#display_name ⇒ Object
Canonical human-facing display string (issue #21). Returns the
free-text label when present, otherwise falls back to humanizing the
machine slug name. This is the ONE accessor all rendering should use;
name stays the immutable machine key. A blank ("") label falls back
too (via presence), so existing rows (label NULL) render exactly as
they did before this column existed.
276 277 278 |
# File 'app/models/typed_eav/field/base.rb', line 276 def display_name label.presence || name.humanize end |
#field_type_name ⇒ Object
── Introspection ──
266 267 268 |
# File 'app/models/typed_eav/field/base.rb', line 266 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.
211 212 213 214 215 216 217 218 219 220 221 222 223 |
# File 'app/models/typed_eav/field/base.rb', line 211 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.
165 166 167 168 169 170 171 172 173 |
# File 'app/models/typed_eav/field/base.rb', line 165 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
175 176 177 178 179 180 181 182 183 |
# File 'app/models/typed_eav/field/base.rb', line 175 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
196 197 198 199 200 201 202 203 204 205 |
# File 'app/models/typed_eav/field/base.rb', line 196 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
185 186 187 188 189 190 191 192 193 194 |
# File 'app/models/typed_eav/field/base.rb', line 185 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
284 285 286 |
# File 'app/models/typed_eav/field/base.rb', line 284 def optionable? false 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. Family-specific helpers live on the
appropriate family base / concern (ValidatedString provides
validate_length / validate_pattern; RangeBounded provides
validate_range / validate_date_range / validate_datetime_range;
Optionable provides validate_option_inclusion /
validate_multi_option_inclusion). validate_array_size stays
here because its callers (MultiSelect via Optionable AND
IntegerArray directly) don't share a family.
413 414 415 |
# File 'app/models/typed_eav/field/base.rb', line 413 def validate_typed_value(record, val) # no-op by default end |