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_scope` NIL → references to any entity type (scoped or unscoped) are accepted; no cross-scope check at value save time.
-
‘target_scope` SET + `target_entity_type` is unscoped (no `has_typed_eav scope_method:`) → field save FAILS with explicit error. Mirrors the `field.scope.present?` guard pattern in `Value#validate_field_scope_matches_entity` (value.rb:403-408) — fail fast at field-config time rather than letting every value save dead-letter.
-
‘target_scope` SET + target scoped + target’s ‘typed_eav_scope` does not match `target_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 expected `typed_eav_scope` value for target records. Type-loose comparison (`to_s == to_s`) matches the Phase 1 `entity_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 ColumnMapping
ColumnMapping::DEFAULT_OPERATORS_BY_COLUMN, ColumnMapping::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, #apply_default_to, #array_field?, #backfill_default!, #clear_option_cache!, #default_value, #default_value=, export_schema, #field_type_name, import_schema, #insert_at, #move_higher, #move_lower, #move_to_bottom, #move_to_top, #optionable?, #read_value, #storage_contract, storage_contract_class, #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 |