Module: TypedEAV::HasTypedEAV::ClassQueryMethods

Defined in:
lib/typed_eav/has_typed_eav.rb

Overview

──────────────────────────────────────────────────Class-level query methods ──────────────────────────────────────────────────

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

#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. Bad records roll back their savepoint and surface in `errors_by_record`; good records commit when the outer transaction commits.

## Failure isolation contract (CONTEXT-locked, 06-CONTEXT.md line 26)

outer transaction
├── savepoint(record_1) → record.typed_eav_attributes = vbn; record.save
├── savepoint(record_2) → ditto
└── savepoint(record_N) → ditto

The savepoint-per-record-INSIDE-an-outer-transaction structure is preserved under EVERY ‘version_grouping:` value (none / per_record / per_field). It is NEVER relaxed to per-record TOP-LEVEL transactions — that path was rejected at CONTEXT time because per-record top-level transactions break Phase 3 hook semantics (after_commit fires on outer commit, not savepoint release; see Plan 06-05 §Discrepancy awareness).

## Why we delegate to record.typed_eav_attributes=

The instance setter at lines 632–664 ALREADY enforces:

* Field-name resolution via `typed_eav_defs_by_name` (collision-
  precedence-correct).
* `allowed_typed_eav_types` restriction (silently skips fields whose
  type was excluded by `has_typed_eav types: [...]`).
* Cross-tenant guards via `validate_field_scope_matches_entity` on
  the resulting Value rows.

Bulk write reuses this path; it does NOT bypass any of these.

## Errors-by-record key choice

The Hash KEY is the record itself (NOT record.id). Records that fail validation BEFORE getting an id (new records that never persist) have nil ids; using id as the key would collapse multiple unsaved-record failures into one entry and lose information. Callers can still ‘result.keys.map(&:id)` if they want id-keyed output.

## errors_by_record value shape

‘record.errors.messages.transform_keys(&:to_s)` — string field-name keys, Array<String> messages. Mirrors `CSVMapper::Result#errors` so callers can write one error-handling path that consumes both shapes. `errors.messages` is the modern AR API (the older `errors.to_h` is flagged by Rails/DeprecatedActiveModelErrorsMethods); both return the same shape `=> [messages]`.

## version_grouping default sentinel

The kwarg’s literal default is ‘:default`. The first step of the method body resolves it: `:per_record` when versioning is on, `:none` when versioning is off. Callers omitting the kwarg “just work” in both versioning environments — they don’t need to branch on ‘TypedEAV.config.versioning`. Explicit `:per_record`/`:per_field` passed with versioning OFF raises ArgumentError loudly (the caller’s intent — group versions — cannot be satisfied with versioning off, and silently no-op’ing would mask a misconfiguration). Explicit ‘:none` is ALWAYS valid (explicit opt-out — same payload as the default-resolved case under versioning-off).

## Snapshot mechanism for version_group_id propagation

The ‘:per_record` and `:per_field` paths stamp `pending_version_group_id` on each affected Value object BEFORE save inside the per-record `with_context` block. The Phase 4 versioning subscriber prefers the per-Value snapshot over `context` so the UUID survives the outer-transaction `after_commit` boundary even after `with_context` has unwound. See `lib/typed_eav/versioning/subscriber.rb` line 127 for the read-side; the stamping happens below per-record. `with_context(version_group_id: …)` is retained as a belt-and- suspenders fallback so any future after_commit-inside-savepoint dispatch path also works.

## rubocop scope

The Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity disables on ‘where_typed_eav` (line 185 of this file) are still active at this point — they cover the entire ClassQueryMethods module body and re-enable only at line 629 (after `typed_eav_attributes=`). The bulk-write transaction + savepoint + error-capture + version-group-id snapshot legitimately belong together for the same reason `where_typed_eav` does — splitting hurts readability of the failure-isolation invariant.



536
537
538
539
540
541
542
543
# File 'lib/typed_eav/has_typed_eav.rb', line 536

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

#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)


310
311
312
313
314
315
316
317
318
# File 'lib/typed_eav/has_typed_eav.rb', line 310

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 `InstanceMethods#typed_eav_hash`. N+1-free regardless of record count or field count.

Why this method exists: list pages and reports often need typed values for many records at once. Calling ‘record.typed_eav_hash` per record issues 2 queries per record (value preload + field preload) — that’s 200 queries for 100 records. This method collapses to:

- 1 SELECT typed_eav_values WHERE entity_type=? AND entity_id IN (?)
- 1 SELECT typed_eav_fields WHERE id IN (?)        (via includes)
- 1 SELECT typed_eav_fields per unique partition tuple
  (for `typed_eav_definitions` / `winning_ids_by_name` per tuple)

Total: 2 + (unique partition tuples) queries — typically 3 or 4 in practice, INDEPENDENT of record count.

We group records by ‘[typed_eav_scope, typed_eav_parent_scope]` BEFORE the value preload because field-collision resolution (`HasTypedEAV.definitions_by_name`) varies by partition: name “age” in tenant_1 may have a different field_id than “age” in tenant_2, and the global+scoped collision precedence must be applied per-tuple. The value preload itself is a single query regardless — `WHERE entity_type=? AND entity_id IN (?)` is a single index seek.

Orphan-skip + winning-id precedence mirrored from the per-record instance method (‘#typed_eav_hash`, lines 584–606). Class-query path and instance path share the same `HasTypedEAV.definitions_by_name` collision-precedence helper so the two cannot drift.

Phase 7 cache integration deferred per 06-CONTEXT.md §Open Questions. This method ships preload-only; it does not call any cache primitive and does not collide with the future Phase 7 ‘with_all_typed_values` scope or `typed_eav_hash_cached` alias.

The Metrics/* disables at the top of ‘where_typed_eav` (line 185) cover this method too — the partition-tuple grouping + single preload + collision-precedence loop genuinely belong together, same rationale as the where_typed_eav disable.

Raises:

  • (ArgumentError)


360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
# File 'lib/typed_eav/has_typed_eav.rb', line 360

def typed_eav_hash_for(records)
  # Input validation — fail fast with a recovery hint (CONVENTIONS §Error
  # messages). nil is a programmer error (a real empty list is `[]`).
  raise ArgumentError, "typed_eav_hash_for requires an Enumerable of records, got nil" if records.nil?

  # Coerce to Array. Accepts AR::Relation, Array, lazy enumerable. We
  # need to iterate twice (group_by + map(&:id) + final iteration) so
  # materialize once.
  records = records.to_a
  return {} if records.empty?

  # Single-class invariant: the polymorphic value query (`entity_type:
  # name`) targets ONE class; mixed-class input would silently miss
  # rows of the other class. The defensive check is cheap (one `all?`
  # pass) and surfaces the misuse loudly. STI subclasses pass via
  # `Class#===` which dispatches to `is_a?` (covariant). `all?(self)`
  # is the rubocop-preferred form for a kind-of check.
  unless records.all?(self)
    classes = records.map { |r| r.class.name }.uniq
    raise ArgumentError,
          "typed_eav_hash_for expects records of class #{name} (or its subclasses); " \
          "got mixed classes: #{classes.join(", ")}"
  end

  # Group by partition tuple BEFORE field-definition lookup. Ruby Hash
  # handles nil keys cleanly, so `[nil, nil]` (the global partition) is
  # a valid tuple — no special casing.
  groups = records.group_by { |r| [r.typed_eav_scope, r.typed_eav_parent_scope] }

  # Build per-tuple `winning_ids_by_name`. One `typed_eav_definitions`
  # query per unique tuple; reuse the resulting map for every record in
  # that tuple. `HasTypedEAV.definitions_by_name` is the SHARED
  # collision-precedence function — same one `typed_eav_defs_by_name`
  # uses on the instance side. Reusing it guarantees parity.
  winning_ids_by_tuple = groups.keys.each_with_object({}) do |(s, ps), memo|
    defs = typed_eav_definitions(scope: s, parent_scope: ps)
    memo[[s, ps]] = HasTypedEAV.definitions_by_name(defs).transform_values(&:id)
  end

  # Single-shot value preload across ALL records, regardless of how
  # many partition tuples they span. `includes(:field)` triggers a
  # second query that batch-loads every referenced field row — Rails'
  # standard preload pattern. Splitting this per-tuple would issue N
  # value queries instead of 1; the SQL `WHERE entity_id IN (...)`
  # is a single index seek.
  #
  # We use the explicit `entity_type: name` form rather than
  # `where(entity: records)` because empty `records` would have made
  # the `where(entity:)` form ambiguous earlier — but we already
  # short-circuited on empty, so either works. Explicit form reads
  # cleaner.
  value_rows = TypedEAV::Value
               .includes(:field)
               .where(entity_type: name, entity_id: records.map(&:id))
               .to_a
  values_by_record_id = value_rows.group_by(&:entity_id)

  # Build the result hash. Records with no values produce a `{}` inner
  # hash — present in the result so callers can uniformly index by id.
  records.each_with_object({}) do |record, result|
    inner = {}
    tuple_key = [record.typed_eav_scope, record.typed_eav_parent_scope]
    winning_ids_by_name = winning_ids_by_tuple.fetch(tuple_key, {})

    values_by_record_id.fetch(record.id, []).each do |tv|
      # Skip orphans (`tv.field` nil — definition deleted via raw SQL
      # or a Phase 02 `:nullify` cascade). Same fail-soft contract as
      # `#typed_eav_hash` line 591.
      next unless tv.field

      field_name = tv.field.name
      winning_id = winning_ids_by_name[field_name]
      effective_id = tv.field_id || tv.field&.id

      # When a winner IS registered: only its row is allowed (collision
      # precedence — scoped beats global). When no winner is registered
      # (definition deleted while values remain), fall back to first-
      # wins so the hash isn't lossy. Mirrors `#typed_eav_hash` lines
      # 600–605.
      if winning_id
        inner[field_name] = tv.value if effective_id == winning_id
      else
        inner[field_name] = tv.value unless inner.key?(field_name)
      end
    end

    result[record.id] = inner
  end
end

#where_typed_eav(*filters, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ 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)

Single-scope BC: callers that don’t pass ‘parent_scope:` see no behavior change. The kwarg defaults to `UNSET_SCOPE` — ambient resolution applies if the model declares `parent_scope_method:`, otherwise resolves to nil.

rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity – input normalization + multimap branch + filter dispatch genuinely belong together; splitting hurts readability of the scope-collision logic.



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/typed_eav/has_typed_eav.rb', line 186

def where_typed_eav(*filters, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
  # Normalize input: accept splat args, a single array, a single filter hash,
  # a hash-of-hashes (form params), or ActionController::Parameters.
  filters = filters.map { |f| f.respond_to?(:to_unsafe_h) ? f.to_unsafe_h : f }

  if filters.size == 1
    inner = filters.first
    inner = inner.to_unsafe_h if inner.respond_to?(:to_unsafe_h)

    if inner.is_a?(Array)
      filters = inner
    elsif inner.is_a?(Hash)
      # A single filter hash has keys like :name/:n, :op, :value/:v.
      # A hash-of-hashes (form params) has values that are all hashes.
      filter_keys = %i[name n op operator value v].map(&:to_s)
      filters = if inner.keys.any? { |k| filter_keys.include?(k.to_s) }
                  [inner]
                else
                  inner.values
                end
    end
  end

  filters = Array(filters)

  # Resolve the (scope, parent_scope) tuple once so we can branch on
  # whether we're inside `TypedEAV.unscoped { }` (ALL_SCOPES) or a
  # normal single-scope query. Under ALL_SCOPES the same name can
  # legitimately appear across multiple tenant partitions; collapsing
  # to one definition would silently drop all but one tenant's
  # matches. See the multimap branch below.
  resolved = resolve_scope(scope, parent_scope)
  all_scopes = resolved.equal?(ALL_SCOPES)

  defs = if all_scopes
           # Multimap branch is structurally unchanged — atomic-bypass
           # per CONTEXT.md drops both scope AND parent_scope predicates.
           # The OR-collapse at field_id level naturally OR's across all
           # (scope, parent_scope) combinations.
           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

  if all_scopes
    fields_multimap = HasTypedEAV.definitions_multimap_by_name(defs)

    filters.inject(all) do |query, filter|
      filter = filter.to_h.with_indifferent_access

      name     = filter[:n] || filter[:name]
      operator = (filter[:op] || filter[:operator] || :eq).to_sym
      value    = filter.key?(:v) ? filter[:v] : filter[:value]

      matching_fields = fields_multimap[name.to_s]
      unless matching_fields&.any?
        raise ArgumentError, "Unknown typed field '#{name}' for #{self.name}. " \
                             "Available fields: #{fields_multimap.keys.join(", ")}"
      end

      # OR-across all field_ids that share this name (across tenants),
      # while preserving AND between filters via the chained `.where`.
      # Use the underlying Value scope (`.filter(...)`) and pluck
      # entity_ids — `entity_ids` returns a relation, and pluck collapses
      # it to a plain integer array we can union across tenants.
      union_ids = matching_fields.flat_map do |f|
        TypedEAV::QueryBuilder.filter(f, operator, value).pluck(:entity_id)
      end.uniq

      query.where(id: union_ids)
    end
  else
    fields_by_name = HasTypedEAV.definitions_by_name(defs)

    filters.inject(all) do |query, filter|
      filter = filter.to_h.with_indifferent_access

      name     = filter[:n] || filter[:name]
      operator = (filter[:op] || filter[:operator] || :eq).to_sym
      value    = filter.key?(:v) ? filter[:v] : filter[:value]

      field = fields_by_name[name.to_s]
      unless field
        raise ArgumentError, "Unknown typed field '#{name}' for #{self.name}. " \
                             "Available fields: #{fields_by_name.keys.join(", ")}"
      end

      matching_ids = TypedEAV::QueryBuilder.entity_ids(field, operator, value)
      query.where(id: matching_ids)
    end
  end
end

#with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ 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.



289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/typed_eav/has_typed_eav.rb', line 289

def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
  if value.nil? && !operator_or_value.is_a?(Symbol)
    # Two-arg form: with_field("name", "value") implies :eq
    where_typed_eav(
      { name: name, op: :eq, value: operator_or_value },
      scope: scope, parent_scope: parent_scope,
    )
  else
    where_typed_eav(
      { name: name, op: operator_or_value, value: value },
      scope: scope, parent_scope: parent_scope,
    )
  end
end