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.
rubocop:disable Metrics/ClassLength -- Base retains the public field
lifecycle API; this change only adds the shared default-validation adapter.
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
- DefaultValidationTarget =
Struct.new(:errors).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!(relation: nil) ⇒ 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
-
#destroy_with_values_in_batches!(batch_size: 1_000) ⇒ Object
Explicit large-population deletion.
-
#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, #cast_query_operand, #logical_value_missing?, #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.
413 414 415 416 417 418 419 |
# File 'app/models/typed_eav/field/base.rb', line 413 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.
423 424 425 |
# File 'app/models/typed_eav/field/base.rb', line 423 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.
309 310 311 312 313 314 315 |
# File 'app/models/typed_eav/field/base.rb', line 309 def allowed_option_values if .loaded? .map(&:value) else .pluck(:value) end end |
#array_field? ⇒ Boolean
298 299 300 |
# File 'app/models/typed_eav/field/base.rb', line 298 def array_field? false end |
#backfill_default!(relation: nil) ⇒ Object
Backfills existing entities with this field's configured default value.
relation: optionally narrows the exact host model in SQL; the
default remains the complete entity class relation.
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 logically present typed value for this field. A Value row whose declared cells are all nil is still a candidate for backfill; the rule is all-cell logical missingness, not row existence.
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.) rubocop:disable Metrics/AbcSize -- backfill orchestration keeps the relation guard, batching, transaction, and partition work together.
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 |
# File 'app/models/typed_eav/field/base.rb', line 372 def backfill_default!(relation: nil) entity_class = entity_type.constantize column = self.class.value_column entity_relation = relation || entity_class.all unless entity_relation.is_a?(ActiveRecord::Relation) && entity_relation.klass == entity_class raise ArgumentError, "relation must be an ActiveRecord::Relation for #{entity_class.name}" end # 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_relation.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.
268 269 270 |
# File 'app/models/typed_eav/field/base.rb', line 268 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.
320 321 322 |
# File 'app/models/typed_eav/field/base.rb', line 320 def clear_option_cache! # no-op end |
#default_value ⇒ Object
── Default value handling ──
Stored in default_value_meta as
247 248 249 |
# File 'app/models/typed_eav/field/base.rb', line 247 def default_value cast(["v"]).first end |
#default_value=(val) ⇒ Object
251 252 253 |
# File 'app/models/typed_eav/field/base.rb', line 251 def default_value=(val) ["v"] = val end |
#destroy_with_values_in_batches!(batch_size: 1_000) ⇒ Object
Explicit large-population deletion. The default destroy and
destroy! lifecycle remains unchanged; callers opt into resumable
keyset batches through this method.
86 87 88 |
# File 'app/models/typed_eav/field/base.rb', line 86 def destroy_with_values_in_batches!(batch_size: 1_000) TypedEAV::FieldDeletion.destroy!(self, batch_size: batch_size) 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.
294 295 296 |
# File 'app/models/typed_eav/field/base.rb', line 294 def display_name label.presence || name.humanize end |
#field_type_name ⇒ Object
── Introspection ──
284 285 286 |
# File 'app/models/typed_eav/field/base.rb', line 284 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.
229 230 231 232 233 234 235 236 237 238 239 240 241 |
# File 'app/models/typed_eav/field/base.rb', line 229 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.
183 184 185 186 187 188 189 190 191 |
# File 'app/models/typed_eav/field/base.rb', line 183 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
193 194 195 196 197 198 199 200 201 |
# File 'app/models/typed_eav/field/base.rb', line 193 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
214 215 216 217 218 219 220 221 222 223 |
# File 'app/models/typed_eav/field/base.rb', line 214 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
203 204 205 206 207 208 209 210 211 212 |
# File 'app/models/typed_eav/field/base.rb', line 203 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
302 303 304 |
# File 'app/models/typed_eav/field/base.rb', line 302 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.
439 440 441 |
# File 'app/models/typed_eav/field/base.rb', line 439 def validate_typed_value(record, val) # no-op by default end |