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 <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
── 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.
363 364 365 366 367 368 369 |
# File 'app/models/typed_eav/field/base.rb', line 363 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.
373 374 375 |
# File 'app/models/typed_eav/field/base.rb', line 373 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.
276 277 278 279 280 281 282 |
# File 'app/models/typed_eav/field/base.rb', line 276 def allowed_option_values if .loaded? .map(&:value) else .pluck(:value) end end |
#array_field? ⇒ Boolean
265 266 267 |
# File 'app/models/typed_eav/field/base.rb', line 265 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.)
334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 |
# File 'app/models/typed_eav/field/base.rb', line 334 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`.
245 246 247 |
# File 'app/models/typed_eav/field/base.rb', line 245 def cast(raw) [raw, false] end |
#clear_option_cache! ⇒ Object
Kept for backward compatibility but now a no-op since we don’t cache.
285 286 287 |
# File 'app/models/typed_eav/field/base.rb', line 285 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.
224 225 226 |
# File 'app/models/typed_eav/field/base.rb', line 224 def default_value cast(["v"]).first end |
#default_value=(val) ⇒ Object
228 229 230 |
# File 'app/models/typed_eav/field/base.rb', line 228 def default_value=(val) ["v"] = val end |
#field_type_name ⇒ Object
── Introspection ──
261 262 263 |
# File 'app/models/typed_eav/field/base.rb', line 261 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.
206 207 208 209 210 211 212 213 214 215 216 217 218 |
# File 'app/models/typed_eav/field/base.rb', line 206 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.
160 161 162 163 164 165 166 167 168 |
# File 'app/models/typed_eav/field/base.rb', line 160 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
170 171 172 173 174 175 176 177 178 |
# File 'app/models/typed_eav/field/base.rb', line 170 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
191 192 193 194 195 196 197 198 199 200 |
# File 'app/models/typed_eav/field/base.rb', line 191 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
180 181 182 183 184 185 186 187 188 189 |
# File 'app/models/typed_eav/field/base.rb', line 180 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
269 270 271 |
# File 'app/models/typed_eav/field/base.rb', line 269 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.
389 390 391 |
# File 'app/models/typed_eav/field/base.rb', line 389 def validate_typed_value(record, val) # no-op by default end |