Class: TypedEAV::Field::Base

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

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

Instance Method Summary collapse

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_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.



276
277
278
279
280
281
282
# File 'app/models/typed_eav/field/base.rb', line 276

def allowed_option_values
  if field_options.loaded?
    field_options.map(&:value)
  else
    field_options.pluck(:value)
  end
end

#array_field?Boolean

Returns:



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_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.



224
225
226
# File 'app/models/typed_eav/field/base.rb', line 224

def default_value
  cast(default_value_meta["v"]).first
end

#default_value=(val) ⇒ Object



228
229
230
# File 'app/models/typed_eav/field/base.rb', line 228

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

#field_type_nameObject

── 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_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.



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_lowerObject



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_bottomObject



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_topObject



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

Returns:



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