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
-
#bulk_set_typed_eav_values(records, values_by_field_name, version_grouping: :default) ⇒ Object
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) ⇒ Object
Query by custom field values.
-
#with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE) ⇒ 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. 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.
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 |