Module: TypedEAV::EntityQuery
- Defined in:
- lib/typed_eav/entity_query.rb
Overview
Class-level query orchestration extended onto host AR models by the ‘has_typed_eav` macro. Owns the `UNSET_SCOPE` / `ALL_SCOPES` sentinels and the `resolve_scope` chain; delegates the heavy lifting to `FilterQuery` (multi-filter SQL composition) and `BulkRead` (bulk per-record reads). `bulk_set_typed_eav_values` stays as a 3-line wrapper around the existing `BulkWrite` executor.
Constant Summary collapse
- UNSET_SCOPE =
Sentinel for the ‘scope:` kwarg default. Distinguishes “kwarg not passed -> resolve from ambient” (UNSET_SCOPE) from “explicitly nil -> filter to global-only fields” (preserves prior behavior).
Object.new.freeze
- ALL_SCOPES =
Sentinel returned by ‘resolve_scope` inside an `unscoped { }` block. Signals the caller to skip the scope filter entirely (return fields across all partitions, not just global).
Object.new.freeze
Instance Method Summary collapse
-
#bulk_set_typed_eav_values(records, values_by_field_name, version_grouping: :default) ⇒ Object
Bulk write API.
-
#bulk_set_typed_eav_values_per_record(values_by_record, version_grouping: :default) ⇒ Object
Per-record-varying bulk write API.
-
#typed_eav_definitions(scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Returns field definitions for this entity type.
-
#typed_eav_hash_for(records) ⇒ Object
Bulk read API.
-
#where_typed_eav(*filters, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE, include_missing: false) ⇒ Object
Query by custom field values.
-
#with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE, include_missing: false) ⇒ Object
Shorthand for single-field queries.
Instance Method Details
#bulk_set_typed_eav_values(records, values_by_field_name, version_grouping: :default) ⇒ Object
Bulk write API. Sets the same ‘values_by_field_name` Hash on every record in `records` inside ONE outer ActiveRecord transaction with a SAVEPOINT-PER-RECORD failure-isolation envelope. See `TypedEAV::BulkWrite` for the transaction shape, error-aggregation contract, and the `version_grouping:` semantics.
118 119 120 121 122 123 124 125 |
# File 'lib/typed_eav/entity_query.rb', line 118 def bulk_set_typed_eav_values(records, values_by_field_name, version_grouping: :default) TypedEAV::BulkWrite.execute( host_class: self, records: records, values_by_field_name: values_by_field_name, version_grouping: version_grouping, ) end |
#bulk_set_typed_eav_values_per_record(values_by_record, version_grouping: :default) ⇒ Object
Per-record-varying bulk write API. Sibling to ‘bulk_set_typed_eav_values` for callers (sync importers, per-row updaters) where each record carries its own values hash. Routes through the same outer-transaction-plus- savepoint envelope and returns the same `{ successes: […], errors_by_record: { record => errors_hash } }` shape. See `TypedEAV::BulkWrite` for the transaction shape and the `version_grouping:` semantics.
## Input shape
Contact.bulk_set_typed_eav_values_per_record(
alice => { "name" => "Alice", "age" => 31 },
bob => { "name" => "Bob", "city" => "Portland" },
)
‘values_by_record` is `Hash<host_record, Hash<field_name, value>>`. Field-name keys may be strings or symbols (normalized to strings). Ruby’s insertion-ordered Hash invariant determines record iteration order — callers can rely on it.
## AR persisted-record hash-key collision gotcha
Two distinct in-memory instances of the **same persisted row** (e.g. ‘Contact.find(1)` and `Contact.find(1)`) collide as Hash keys because AR’s ‘eql?`/`hash` is defined by `class + id`. In a Hash, the second instance silently overwrites the first’s value entry and only ONE save runs. If you need to apply two updates to the same row in caller order, sequence the calls outside the Hash —this API iterates whatever the Hash holds.
The sibling ‘bulk_set_typed_eav_values(records, vbn)` API takes an Array of records and is unaffected — duplicate in-memory instances iterate each instance separately. The internal `execute_pairs` helper preserves that contract for both surfaces.
## Sparse-update semantic
Unlisted fields on a record are not touched. To delete a value, pass the destroy-marker hash:
Contact.bulk_set_typed_eav_values_per_record(
alice => { "old_field" => { _destroy: true } },
)
## Mixed-scope records
Records in one call may span multiple partitions: r1 in workspace 1 and r2 in workspace 2 in the same call resolve their own scopes independently. Each record’s ‘typed_eav_attributes=` consults the field definitions for its own `[scope, parent_scope]`. The thread- local definition memo keys `[host_class, scope, parent_scope]` collect one entry per distinct partition touched — no collisions. Callers spanning multiple partitions should wrap the call in `TypedEAV.unscoped { … }` so each record can apply its own scope.
## ‘:per_field` union semantic
When ‘version_grouping: :per_field`, the per-field UUIDs span the union of field names across all records. Records that both write `“name”` share one UUID for that cell; a record writing `“city”` (that no other record writes) gets its own UUID for `“city”`. Overlapping fields share a version group across records.
189 190 191 192 193 194 195 |
# File 'lib/typed_eav/entity_query.rb', line 189 def bulk_set_typed_eav_values_per_record(values_by_record, version_grouping: :default) TypedEAV::BulkWrite.execute_per_record( host_class: self, values_by_record: values_by_record, version_grouping: version_grouping, ) end |
#typed_eav_definitions(scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Returns field definitions for this entity type.
‘scope:` and `parent_scope:` behavior:
- omitted -> resolve from ambient (`with_scope` -> resolver -> raise/nil)
- passed a value -> use verbatim (explicit override; admin/test path)
- passed nil -> filter to global-only on that axis (prior behavior preserved)
94 95 96 97 98 99 100 101 102 |
# File 'lib/typed_eav/entity_query.rb', line 94 def typed_eav_definitions(scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) resolved = resolve_scope(scope, parent_scope) if resolved.equal?(ALL_SCOPES) TypedEAV::Partition.visible_fields(entity_type: name, mode: :all_partitions) else s, ps = resolved TypedEAV::Partition.visible_fields(entity_type: name, scope: s, parent_scope: ps) end end |
#typed_eav_hash_for(records) ⇒ Object
Bulk read API. Returns ‘{ record_id => { field_name => value } }` for an Enumerable of host records — the class-method bulk variant of `HasTypedEAV::InstanceMethods#typed_eav_hash`. N+1-free regardless of record count or field count. See `TypedEAV::BulkRead` for the pipeline and query bound.
109 110 111 |
# File 'lib/typed_eav/entity_query.rb', line 109 def typed_eav_hash_for(records) TypedEAV::BulkRead.new(host_class: self, records: records).to_hash end |
#where_typed_eav(*filters, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE, include_missing: false) ⇒ Object
Query by custom field values. Accepts an array of filter hashes or a hash of hashes (from form params).
Each filter needs:
:name or :n - the field name
:op or :operator - the operator (default: :eq)
:value or :v - the comparison value
Contact.where_typed_eav(
{ name: "age", op: :gt, value: 21 },
{ name: "city", value: "Portland" } # op defaults to :eq
)
‘scope:` and `parent_scope:` behavior:
- omitted -> resolve from ambient (`with_scope` -> resolver -> raise/nil)
- passed a value -> use verbatim (explicit override; admin/test path)
- passed nil -> filter to global-only on that axis (prior behavior)
‘include_missing:` behavior (opt-in, default `false`):
- Only meaningful when paired with `:is_null`. When `true`, the
`:is_null` predicate broadens to the user-intuitive "is empty"
semantic: matches hosts with **no non-NULL value** for the field —
including hosts that have no `typed_eav_values` row at all
(Reading A from ADR-0006).
- With `:is_not_null`, the kwarg is a no-op (lets filter UIs pass
it uniformly without branching per operator).
- With any other operator (`:eq`, `:gt`, `:contains`, `:between`,
`:starts_with`, `:references`, etc.), the kwarg is silently
ignored.
50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/typed_eav/entity_query.rb', line 50 def where_typed_eav(*filters, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE, include_missing: false) resolved = resolve_scope(scope, parent_scope) effective_scope, effective_parent = scope_pair(resolved) TypedEAV::FilterQuery.new( model: self, filters: filters, scope: effective_scope, parent_scope: effective_parent, include_missing: include_missing, ).to_relation end |
#with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE, include_missing: false) ⇒ Object
Shorthand for single-field queries.
Contact.with_field("age", :gt, 21)
Contact.with_field("active", true) # op defaults to :eq
Contact.with_field("name", :contains, "smith")
Accepts both ‘scope:` and `parent_scope:` kwargs with the same ambient/explicit/nil semantics as `where_typed_eav`. Single-scope callers (no `parent_scope:`) are unaffected.
‘include_missing:` (opt-in, default `false`) is forwarded to `where_typed_eav` unchanged. See its RDoc for full semantics — in short: meaningful only with `:is_null` (Reading A “no non-NULL value,” includes no-row hosts), no-op with `:is_not_null`, silently ignored otherwise.
78 79 80 81 82 83 84 85 86 |
# File 'lib/typed_eav/entity_query.rb', line 78 def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE, include_missing: false) filter = if value.nil? && !operator_or_value.is_a?(Symbol) # Two-arg form: with_field("name", "value") implies :eq { name: name, op: :eq, value: operator_or_value } else { name: name, op: operator_or_value, value: value } end where_typed_eav(filter, scope: scope, parent_scope: parent_scope, include_missing: include_missing) end |