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

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