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. rubocop:disable Metrics/ClassLength -- Base retains the public field lifecycle API; this change only adds the shared default-validation adapter.

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

Instance Method Summary collapse

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



309
310
311
312
313
314
315
# File 'app/models/typed_eav/field/base.rb', line 309

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

#array_field?Boolean

Returns:



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_valueObject

── Default value handling ── Stored in default_value_meta as so the jsonb column can hold any type's default without an extra typed column.



247
248
249
# File 'app/models/typed_eav/field/base.rb', line 247

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

#default_value=(val) ⇒ Object



251
252
253
# File 'app/models/typed_eav/field/base.rb', line 251

def default_value=(val)
  default_value_meta["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_nameObject

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_nameObject

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



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_lowerObject



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_bottomObject



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_topObject



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

Returns:



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