Architecture
Current internal module layout. Most consumers never reach for these directly — the public surface is the has_typed_eav macro and the instance/class methods it installs — but the split matters if you’re extending the gem, debugging an integration, or evaluating it for production. Decisions are anchored by ADR-0001 through ADR-0013.
Macro entry: HasTypedEav
lib/typed_eav/has_typed_eav.rb (~120 LOC) is the macro shell. When you call has_typed_eav on an AR model, it:
extendsTypedEAV::EntityQueryonto the class (class-level query methods).includesTypedEAV::HasTypedEav::InstanceMethods(per-record accessors).- Wires scope/parent-scope kwargs into the model’s class-level configuration.
- Registers the model with
TypedEAV::Registry.
The macro is intentionally thin. All real behavior lives in the modules it pulls in.
Class-level reads: two-altitude query pattern
Contact.where_typed_eav(...) ← public class method
│
▼
TypedEAV::EntityQuery ← high altitude: orchestrator
• resolves scope/parent_scope from ambient context or explicit kwargs
• owns the UNSET_SCOPE / ALL_SCOPES sentinels
• delegates to FilterQuery
│
▼
TypedEAV::FilterQuery ← multi-filter composition
• normalizes filter input shapes (positional, hash, hash-of-hashes)
• looks up field definitions via TypedEAV::Partition
• per filter, asks QueryBuilder for the SQL fragment
• unions/intersects per-field entity-id sets
• returns an ActiveRecord::Relation scoped to the host model
│
▼
TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
• turns a single (field, op, value) into a WHERE clause against typed_eav_values
• knows about typed-column projections (integer_value, string_value, etc.)
• knows about operator-specific column choice (currency-cents vs currency-code)
QueryBuilder is the single place that decides “given this field and this operator, which column and which SQL fragment?” FilterQuery never builds SQL fragments directly; EntityQuery never touches columns. Splitting the two altitudes keeps custom field types extending only the column-mapping surface (value_column, operators, operator_column) without ever subclassing FilterQuery.
Scalar ordering and summaries are a separate EntityQuery delegation to
ScalarQuery: it resolves one winning definition, checks scalar support, and
builds SQL over the field’s declared native column. It does not add operators
to the filter DSL or load the host/Value graph to calculate summaries.
Per-record reads/writes: InstanceMethods
lib/typed_eav/has_typed_eav/instance_methods.rb (~250 LOC) holds the per-record API:
typed_eav_value(name)/typed_eav_hash— readsset_typed_eav_value(name, value)/typed_eav_attributes=(aliased astyped_eav=) — writestyped_eav_definitions— resolved field-definitions map for the host recordtyped_eav_scope/typed_eav_parent_scope— scope resolution per record
Every method uses TypedEAV::Partition.definitions_by_name so the collision-precedence rules for ambient/explicit/parent scopes are computed in one place.
Partition visibility: Partition
Host applications that need to inspect effective schema should use the
documented-public TypedEAV::Partition seam rather than rebuilding tuple
predicates. It exposes visible_fields, effective_fields_by_name,
definitions_by_name, definitions_multimap_by_name, visible_sections,
and find_visible_section!. These methods preserve global, scope-only, and
full-tuple precedence; ADR-0006 additionally fixes include-missing set
composition at the FilterQuery altitude.
Field types and storage: Field::TypedStorage
TypedEAV::Field::Base is the STI parent of every field type. The shared storage surface lives in the TypedEAV::Field::TypedStorage concern (lib/typed_eav/field/typed_storage.rb, ~200 LOC), auto-included on Field::Base. Per ADR-0001, it provides:
- Class DSL:
value_column,value_columns,operators,operator_column,supported_operators— describe where typed values live and which operators they support. - Instance override points:
read_value(record),write_value(record, casted),apply_default(record)— the three methods a multi-cell field type overrides. - Concrete snapshot helpers:
value_changed?,before_snapshot,after_snapshot— derived automatically fromvalue_columns; not overridable.
Custom multi-cell field types subclass Field::Base directly and override only the three instance methods. See Multi-cell field types for Currency as the canonical worked example.
Field families: intermediate STI bases
Per ADR-0004, three intermediate STI parents factor shared validation behavior out of Field::Base:
TypedEAV::Field::ValidatedString— parent ofText,Email,Url. Owns string-length and pattern-validation helpers includingmax_gte_min_length(which now covers Email/Url, not just Text).TypedEAV::Field::RangeBounded— parent ofInteger,Decimal,Date,DateTime(andPercentage < Decimal). Owns range-validation helpers includingvalidates :max, comparison: { greater_than_or_equal_to: :min }(which now covers Date/DateTime, not just Integer/Decimal).TypedEAV::Field::Optionable— a Rails concern included bySelectandMultiSelect. Owns the public-facing sortedallowed_valuesreader and the option-inclusion validators.
Color, Boolean, Json, and the array field types (TextArray, IntegerArray, DecimalArray, DateArray) remain direct children of Field::Base. See Family intermediate bases for extension examples.
Scope tuple normalization: ScopeTuple
TypedEAV::ScopeTuple (lib/typed_eav/scope_tuple.rb, ~120 LOC) is the canonical source of truth for the (scope, parent_scope) partition tuple. It provides:
normalize_permissive(scope)— coerces input to a tuple; tolerates bare scalars (used bywith_scope,normalize_scope,Field#validate_parent_scope_invariant).normalize_strict(scope)— same shape, but raises on bare-scalar input (used bycurrent_scope; preserves Phase-1’s asymmetric contract thatConfig.scope_resolvermust return a tuple).invariant_satisfied?(scope, parent_scope)— Boolean check for the orphan-parent invariant (parent_scopeset withoutscope= invalid).
Each calling site keeps its own response policy (raise / AR error / silent narrow) using the Boolean return — ScopeTuple is a predicate, not an enforcer.
Partition tuple helpers: Partition
TypedEAV::Partition (lib/typed_eav/partition.rb, ~100 LOC) owns the (entity_type, scope, parent_scope) precedence rules:
definitions_by_name(model, scope, parent_scope)— returns the field-definitions map for a single resolved partition.definitions_multimap_by_name(model)— returns the cross-partition multimap used byunscoped { }blocks.visible_fields(model, scope, parent_scope)/visible_sections(...)— scope-respecting field/section iteration with the orphan-parent invariant inlined viaScopeTuple.invariant_satisfied?.
The definitions helpers used to live as class methods on HasTypedEav before 0.3.0. They moved to Partition per ADR-0002 because they describe the partition domain, not the macro.
Events: EventDispatcher
TypedEAV::EventDispatcher (lib/typed_eav/event_dispatcher.rb, ~150 LOC) is the broker for on_value_change and on_field_change callbacks. Per ADR-0003, it intentionally stays a broker rather than getting absorbed into either Value or Field — its multi-publisher / multi-subscriber shape doesn’t belong on either model. See Event hooks for the public callback contract.
Schema portability and CSV: independent modules
TypedEAV::SchemaPortability and TypedEAV::CSVMapper (Phase-6 modules) are deliberately decoupled from the core read/write path per ADR-0005. They depend on the public has_typed_eav macro surface, never on internal modules.
See Bulk operations for bulk reads, writes, and their guarantees.