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_scopeinside anunscoped { }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
-
#aggregate_typed_eav(name, operation:, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Compute a database-backed numeric aggregate for a typed field.
-
#bulk_set_typed_eav_values(records, values_by_field_name, version_grouping: :default, transaction: :all, chunk_size: nil) ⇒ Object
Bulk write API.
-
#bulk_set_typed_eav_values_per_record(values_by_record, version_grouping: :default, transaction: :all, chunk_size: nil) ⇒ Object
Per-record-varying bulk write API.
-
#bulk_upsert_typed_eav_values(records, values_by_field_name, acknowledge_reduced_semantics: false, transaction: :all, chunk_size: nil) ⇒ Object
Reduced-semantics SQL bulk upsert.
-
#count_distinct_typed_eav_values(name, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Count exact distinct values for a scalar typed field.
-
#distinct_typed_eav_values(name, limit: 100, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Return distinct values for a scalar typed field without hydrating host or Value records.
-
#order_typed_eav(name, direction: :asc, nulls: :last, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Order this host relation by a scalar typed field in SQL.
-
#typed_eav_definitions(scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Returns field definitions for this entity type.
-
#typed_eav_hash_for(records, fields: nil, source: :database) ⇒ Object
Bulk read API.
-
#typed_eav_value_counts(name, limit: 100, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Return an insertion-ordered
{ value => host_count }hash for a scalar typed field. -
#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
#aggregate_typed_eav(name, operation:, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Compute a database-backed numeric aggregate for a typed field.
operation: is required and accepts :min, :max, or :sum. Integer fields return
Integer results; Decimal and Percentage fields return BigDecimal
results. Missing rows and explicit NULL cells are ignored. Empty
min/max queries return nil, while empty sums return the typed zero.
The caller relation's filters, joins, distinctness, limit, and offset are preserved through a host-ID subquery. Scope kwargs choose the visible definition and do not add host predicates. Only Integer, Decimal, and Percentage field families are supported; references, text, collections, and multi-cell fields raise ArgumentError. rubocop:disable Metrics/ParameterLists -- mirrors the existing query wrappers' public partition kwargs.
217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/typed_eav/entity_query.rb', line 217 def aggregate_typed_eav(name, operation:, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) resolved = resolve_scope(scope, parent_scope) effective_scope, effective_parent = scope_pair(resolved) TypedEAV::ScalarQuery.new( model: self, name: name, scope: effective_scope, parent_scope: effective_parent, ).aggregate(operation: operation) end |
#bulk_set_typed_eav_values(records, values_by_field_name, version_grouping: :default, transaction: :all, chunk_size: nil) ⇒ Object
Bulk write API. Sets the same values_by_field_name Hash on every
record in records using an outer transaction with per-record savepoints
(transaction: :all) or one such envelope per committed chunk
(transaction: :chunks). See TypedEAV::BulkWrite for the error and
version_grouping: contracts.
279 280 281 282 283 284 285 286 287 288 289 290 |
# File 'lib/typed_eav/entity_query.rb', line 279 def bulk_set_typed_eav_values( records, values_by_field_name, version_grouping: :default, transaction: :all, chunk_size: nil ) TypedEAV::BulkWrite.execute( host_class: self, records: records, values_by_field_name: values_by_field_name, version_grouping: version_grouping, transaction: transaction, chunk_size: chunk_size, ) end |
#bulk_set_typed_eav_values_per_record(values_by_record, version_grouping: :default, transaction: :all, chunk_size: nil) ⇒ 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 transaction/chunk and
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.
354 355 356 357 358 359 360 361 362 363 364 |
# File 'lib/typed_eav/entity_query.rb', line 354 def bulk_set_typed_eav_values_per_record( values_by_record, version_grouping: :default, transaction: :all, chunk_size: nil ) TypedEAV::BulkWrite.execute_per_record( host_class: self, values_by_record: values_by_record, version_grouping: version_grouping, transaction: transaction, chunk_size: chunk_size, ) end |
#bulk_upsert_typed_eav_values(records, values_by_field_name, acknowledge_reduced_semantics: false, transaction: :all, chunk_size: nil) ⇒ Object
Reduced-semantics SQL bulk upsert. This deliberately separate API
requires acknowledge_reduced_semantics: true; it retains Value
prevalidation/casting and domain/partition checks while skipping host and
Value persistence lifecycle callbacks, versioning, and delete shorthand.
370 371 372 373 374 375 376 377 378 379 380 381 |
# File 'lib/typed_eav/entity_query.rb', line 370 def bulk_upsert_typed_eav_values( records, values_by_field_name, acknowledge_reduced_semantics: false, transaction: :all, chunk_size: nil ) TypedEAV::BulkUpsert.execute( host_class: self, records: records, values_by_field_name: values_by_field_name, acknowledge_reduced_semantics: acknowledge_reduced_semantics, transaction: transaction, chunk_size: chunk_size, ) end |
#count_distinct_typed_eav_values(name, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Count exact distinct values for a scalar typed field. The count is
calculated in SQL, including one explicit-NULL category as nil; hosts
without a value row remain absent. Caller relation filters and
pagination are applied through the same host-ID subquery as the bounded
distinct-value API.
rubocop:disable Metrics/ParameterLists -- mirrors the existing query wrappers' public partition kwargs.
169 170 171 172 173 174 175 176 177 178 179 |
# File 'lib/typed_eav/entity_query.rb', line 169 def count_distinct_typed_eav_values(name, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) resolved = resolve_scope(scope, parent_scope) effective_scope, effective_parent = scope_pair(resolved) TypedEAV::ScalarQuery.new( model: self, name: name, scope: effective_scope, parent_scope: effective_parent, ).count_distinct_values end |
#distinct_typed_eav_values(name, limit: 100, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Return distinct values for a scalar typed field without hydrating host
or Value records. Values are ordered by the native database column and
limited before they are transferred to Ruby; an explicit NULL is
returned as nil, while hosts with no value row are absent.
scope: and parent_scope: choose the visible field definition with
the same ambient/explicit/nil semantics as where_typed_eav. They do
not add host predicates; keep tenant or other host filtering in the
caller relation. The current relation's filters, limit, and offset are
applied through a host-ID subquery.
rubocop:disable Metrics/ParameterLists -- mirrors the existing query wrappers' public partition kwargs.
150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/typed_eav/entity_query.rb', line 150 def distinct_typed_eav_values(name, limit: 100, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) resolved = resolve_scope(scope, parent_scope) effective_scope, effective_parent = scope_pair(resolved) TypedEAV::ScalarQuery.new( model: self, name: name, scope: effective_scope, parent_scope: effective_parent, ).distinct_values(limit: limit) end |
#order_typed_eav(name, direction: :asc, nulls: :last, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Order this host relation by a scalar typed field in SQL.
Contact.order_typed_eav("score")
Contact.where(active: true).order_typed_eav("score", direction: :desc)
direction: accepts :asc or :desc. nulls: accepts :first or :last
and defaults to :last for both directions. Missing value rows and rows
whose selected typed cell is explicitly NULL are both SQL NULLs and
therefore share the requested placement. Equal values are ordered by
the host primary key ascending so pagination has a stable tie-break.
The method keeps the caller's current relation scope (including normal Active Record filters, limits, and offsets) through Rails relation delegation. Typed ordering has explicit precedence and replaces any prior host ordering while retaining those filters and pagination.
Scope kwargs choose the visible field definition and follow the same
ambient/explicit/nil resolution as where_typed_eav; they do not add a
host tenant predicate. Applications should keep host filtering in the
caller relation. TypedEAV.unscoped is rejected because ordering across
multiple same-name partition definitions is ambiguous; use an explicit
scope when one field definition must win.
Only single-cell scalar fields backed by the native scalar columns are supported. Collection and multi-cell fields raise ArgumentError rather than silently choosing one physical cell. rubocop:disable Metrics/ParameterLists -- mirrors the existing query wrappers' public partition kwargs.
124 125 126 127 128 129 130 131 132 133 134 135 136 |
# File 'lib/typed_eav/entity_query.rb', line 124 def order_typed_eav(name, direction: :asc, nulls: :last, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) resolved = resolve_scope(scope, parent_scope) effective_scope, effective_parent = scope_pair(resolved) TypedEAV::ScalarQuery.new( model: self, name: name, direction: direction, nulls: nulls, scope: effective_scope, parent_scope: effective_parent, ).order_relation 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)
236 237 238 239 240 241 242 243 244 |
# File 'lib/typed_eav/entity_query.rb', line 236 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: polymorphic_name, mode: :all_partitions) else s, ps = resolved TypedEAV::Partition.visible_fields(entity_type: polymorphic_name, scope: s, parent_scope: ps) end end |
#typed_eav_hash_for(records, fields: nil, source: :database) ⇒ 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.
fields: optionally limits the projection to selected String/Symbol
names. Names are normalized to strings and de-duplicated; unknown names
and names absent from a record's partition are ignored. Omitting
fields: preserves the existing all-fields behavior. Passing []
returns one empty inner hash per supplied record without querying field
definitions or values.
source: :database (default) performs a fresh batched read even when
the records already have typed values loaded. source: :preloaded is an
explicit snapshot mode: typed_values and every retained value's
field association must already be loaded, otherwise ArgumentError is
raised instead of introducing an N+1 query. It reuses unsaved in-memory
values and never saves or mutates caller records.
265 266 267 268 269 270 271 272 |
# File 'lib/typed_eav/entity_query.rb', line 265 def typed_eav_hash_for(records, fields: nil, source: :database) TypedEAV::BulkRead.new( host_class: self, records: records, fields: fields, source: source, ).to_hash end |
#typed_eav_value_counts(name, limit: 100, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ Object
Return an insertion-ordered { value => host_count } hash for a scalar
typed field. Values are grouped and counted in SQL using distinct host
identities; explicit NULL is represented by a nil key and hosts with
no value row are omitted. The result is ordered by the native value
column with NULL last and capped by limit: before transfer to Ruby.
The caller relation's filters, joins, distinctness, limit, and offset determine the host-ID subquery. Scope kwargs only choose the visible field definition and do not add host predicates. rubocop:disable Metrics/ParameterLists -- mirrors the existing query wrappers' public partition kwargs.
192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/typed_eav/entity_query.rb', line 192 def typed_eav_value_counts(name, limit: 100, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) resolved = resolve_scope(scope, parent_scope) effective_scope, effective_parent = scope_pair(resolved) TypedEAV::ScalarQuery.new( model: self, name: name, scope: effective_scope, parent_scope: effective_parent, ).value_counts(limit: limit) 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
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.
rubocop:disable Metrics/ParameterLists -- preserves the public positional and partition keyword API.
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/typed_eav/entity_query.rb', line 79 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 |