Class: TypedEAV::Field::Reference
- Inherits:
-
Base
- Object
- ActiveRecord::Base
- ApplicationRecord
- Base
- TypedEAV::Field::Reference
- Defined in:
- app/models/typed_eav/field/reference.rb
Overview
Foreign-key field type. Stores the target record's integer ID in
integer_value. The :references operator accepts AR record
instances OR Integer IDs at query time, normalizing to the FK via
field.cast before predicate emission.
Phase 05 Gating Decision 2 (RESOLVED)
Reference target-scope validation runs along a DIFFERENT axis than
the existing Value#validate_field_scope_matches_entity guard
(which checks the SOURCE entity against the FIELD's scope).
Reference checks the TARGET entity against the FIELD's
target_scope option:
target_scopeNIL → references to any entity type (scoped or unscoped) are accepted; no cross-scope check at value save time.target_scopeSET +target_entity_typeis unscoped (nohas_typed_eav scope_method:) → field save FAILS with explicit error. Mirrors thefield.scope.present?guard pattern inValue#validate_field_scope_matches_entity(value.rb:403-408) — fail fast at field-config time rather than letting every value save dead-letter.target_scopeSET + target scoped + target'styped_eav_scopedoes not matchtarget_scope→ value save FAILS.
Operators (explicit narrowing)
:eq, :is_null, :is_not_null, :references. Does NOT inherit
:integer_value's default operator set (which includes :gt,
:lt, :between) since arithmetic comparisons on FKs don't
carry useful semantics. The :references operator is registered
ONLY on this class — QueryBuilder's operator-validation gate
rejects it on every other field type.
:references semantics are equivalent to :eq on integer_value
but additionally accept AR record instances (normalized via
field.cast). This gives ergonomic parity with Rails AR
association queries (Contact.where(manager: alice)) — the EAV
equivalent accepts a model instance directly. Allowing :eq to
accept AR records would require a casting fork inside
QueryBuilder.filter's :eq branch which would touch every other
field type; adding a separate operator symbol is the minimal path.
Options
target_entity_type: REQUIRED. String class name of the target AR model (e.g.,"Contact"). Validated to constantize.target_scope: OPTIONAL. The expectedtyped_eav_scopevalue for target records. Type-loose comparison (to_s == to_s) matches the Phase 1entity_partition_axis_matches?pattern.
Storage column
:integer_value. String FK targets and UUID FK targets are out
of scope (the dummy app and prevailing AR convention is integer
PK; UUID support would require schema changes to typed_eav_values
that are not Phase 5).
Constant Summary
Constants inherited from Base
Constants included from TypedStorage
TypedStorage::DEFAULT_OPERATORS_BY_COLUMN, TypedStorage::FALLBACK_OPERATORS
Instance Method Summary collapse
-
#cast(raw) ⇒ Object
Cast contract: - nil / blank → [nil, false] - Integer → [int, false] - numeric String (e.g., "42") → [int, false] - non-numeric String → [nil, true] - AR record matching target_entity_type → [record.id, false] - AR record of a different class → [nil, true] (configured for a specific target type — rejecting other types catches typos at write time) - any other shape → [nil, true].
-
#validate_typed_value(record, val) ⇒ Object
Value-time validation: when target_scope is set on the field, the target record's typed_eav_scope must match.
Methods inherited from Base
#allowed_option_values, #array_field?, #backfill_default!, #clear_option_cache!, #default_value, #default_value=, #display_name, export_schema, #field_type_name, import_schema, #insert_at, #move_higher, #move_lower, #move_to_bottom, #move_to_top, #optionable?
Methods included from TypedStorage
#after_snapshot, #apply_default, #before_snapshot, #read_value, #value_changed?, #write_value
Instance Method Details
#cast(raw) ⇒ Object
Cast contract:
- nil / blank → [nil, false]
- Integer → [int, false]
- numeric String (e.g., "42") → [int, false]
- non-numeric String → [nil, true]
- AR record matching target_entity_type → [record.id, false]
- AR record of a different class → [nil, true] (configured for a specific target type — rejecting other types catches typos at write time)
- any other shape → [nil, true]
Accepts both Integer FKs AND model instances for ergonomic
parity with Rails AR association API (belongs_to :manager; contact.manager = alice works whether alice is a Contact or
an id).
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'app/models/typed_eav/field/reference.rb', line 87 def cast(raw) return [nil, false] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?) # CRITICAL: top-level `::Integer` (and `::String` below). Inside # `module TypedEAV; module Field`, the bare `Integer` constant # resolves to TypedEAV::Field::Integer (a Field subclass), not # the Ruby Integer class — so `raw.is_a?(Integer)` would always # be false. Same hazard with String. The leading `::` anchors # constant lookup to ::Object and avoids the namespace shadow. return [raw, false] if raw.is_a?(::Integer) if raw.is_a?(::String) # Integer(...) with exception: false returns nil for non-numeric # input (including decimals like "1.5" — fractional FKs are # nonsense). Same rejection pattern as Field::Integer#cast. # `Integer(...)` is the Kernel method (not the constant — # method-call syntax routes through Kernel#Integer rather # than constant lookup, so the TypedEAV::Field::Integer # constant shadow that bites `is_a?(::Integer)` above does # NOT bite this call form). int = Integer(raw, exception: false) return [int, false] if int return [nil, true] end # AR record path: must match target_entity_type. Class-mismatch # is treated as :invalid at cast time so the error surface lines # up with other type-mismatch failures (cast-tuple invalid bit # → Value#validate_value → errors.add(:value, :invalid)). if raw.respond_to?(:id) && raw.class.respond_to?(:name) target_class = resolve_target_class return [nil, true] if target_class.nil? return [raw.id, false] if raw.is_a?(target_class) return [nil, true] end [nil, true] end |
#validate_typed_value(record, val) ⇒ Object
Value-time validation: when target_scope is set on the field, the target record's typed_eav_scope must match. When target_scope is nil, no cross-scope check fires (the field author is declaring "this reference is to a global/unscoped entity"). When target lookup fails (record was deleted or never existed), errors.add (:value, :invalid) — reuses the existing :invalid symbol from cast-time invalidation for UX consistency.
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'app/models/typed_eav/field/reference.rb', line 135 def validate_typed_value(record, val) return if val.nil? return if target_scope.blank? target_class = resolve_target_class # Field-save validators above already reject the (target_scope # set + unscoped target) combination. These guards are defense # in depth: if a Reference field somehow exists with a stale # target_class reference, fail soft at value save rather than # raise NoMethodError on `target_class.nil?` chains. return unless target_class return unless target_class.respond_to?(:typed_eav_scope_method) return unless target_class.typed_eav_scope_method target_record = target_class.find_by(id: val) if target_record.nil? record.errors.add(:value, :invalid) return end return if target_partition_matches?(target_record, target_scope) record.errors.add(:value, "target's scope does not match target_scope") end |