Class: TypedEAV::Field::Base

Inherits:
ApplicationRecord show all
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.

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

Instance Method Summary collapse

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_valuesObject

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 field_options.loaded?
    field_options.map(&:value)
  else
    field_options.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

Returns:



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_valueObject

── 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(default_value_meta["v"]).first
end

#default_value=(val) ⇒ Object



226
227
228
# File 'app/models/typed_eav/field/base.rb', line 226

def default_value=(val)
  default_value_meta["v"] = val
end

#field_type_nameObject



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_higherObject

── 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_lowerObject



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_bottomObject



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_topObject



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

Returns:



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_contractObject



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