typed_eav 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +634 -2
  4. data/app/models/typed_eav/field/base.rb +552 -6
  5. data/app/models/typed_eav/field/currency.rb +125 -0
  6. data/app/models/typed_eav/field/file.rb +98 -0
  7. data/app/models/typed_eav/field/image.rb +152 -0
  8. data/app/models/typed_eav/field/percentage.rb +100 -0
  9. data/app/models/typed_eav/field/reference.rb +230 -0
  10. data/app/models/typed_eav/section.rb +114 -4
  11. data/app/models/typed_eav/value.rb +461 -11
  12. data/app/models/typed_eav/value_version.rb +96 -0
  13. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
  14. data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
  15. data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
  16. data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
  17. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
  18. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
  19. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
  20. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
  21. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
  22. data/lib/typed_eav/bulk_write.rb +147 -0
  23. data/lib/typed_eav/column_mapping.rb +46 -0
  24. data/lib/typed_eav/config.rb +215 -19
  25. data/lib/typed_eav/csv_mapper.rb +158 -0
  26. data/lib/typed_eav/currency_storage_contract.rb +46 -0
  27. data/lib/typed_eav/engine.rb +117 -0
  28. data/lib/typed_eav/event_dispatcher.rb +151 -0
  29. data/lib/typed_eav/field_storage_contract.rb +68 -0
  30. data/lib/typed_eav/has_typed_eav.rb +455 -58
  31. data/lib/typed_eav/partition.rb +64 -0
  32. data/lib/typed_eav/query_builder.rb +39 -3
  33. data/lib/typed_eav/registry.rb +48 -9
  34. data/lib/typed_eav/schema_portability.rb +250 -0
  35. data/lib/typed_eav/version.rb +1 -1
  36. data/lib/typed_eav/versioned.rb +73 -0
  37. data/lib/typed_eav/versioning/subscriber.rb +161 -0
  38. data/lib/typed_eav/versioning.rb +94 -0
  39. data/lib/typed_eav.rb +180 -12
  40. metadata +35 -1
@@ -31,15 +31,33 @@ module TypedEAV
31
31
  module HasTypedEAV
32
32
  extend ActiveSupport::Concern
33
33
 
34
- # Indexes field definitions by name with deterministic collision
35
- # resolution: when a global (scope=NULL) and a scoped field share a
36
- # name, the scoped row wins. `for_entity(name, scope:)` returns both
37
- # rows on a collision, and a bare `index_by(&:name)` would let DB row
38
- # order pick the winner. Shared by the class-query path
34
+ # Indexes field definitions by name with deterministic three-way
35
+ # collision resolution: when global (scope=NULL, parent_scope=NULL),
36
+ # scope-only (scope set, parent_scope=NULL), and full-triple (both set)
37
+ # fields share a name, the most-specific row wins.
38
+ #
39
+ # Sort key `[scope.nil? ? 0 : 1, parent_scope.nil? ? 0 : 1]` orders rows:
40
+ # [0, 0] global (least specific) → comes first
41
+ # [1, 0] scope-only (middle)
42
+ # [1, 1] full triple (most specific) → comes last
43
+ #
44
+ # `index_by(&:name)` keeps the LAST entry on duplicate keys (Rails
45
+ # convention via `Array#to_h`), so most-specific wins. The two-key sort
46
+ # extends the prior "scoped beats global" rule into "two-key beats
47
+ # one-key beats global" without changing the index_by-last-wins
48
+ # mechanism. The `(scope=NULL, parent_scope=NOT NULL)` slot is unreachable
49
+ # by construction (orphan-parent invariant in Field::Base), so the
50
+ # ordering is exhaustive across the three valid shapes.
51
+ #
52
+ # `for_entity(name, scope:, parent_scope:)` returns the union across
53
+ # all three shapes on a collision, and a bare `index_by(&:name)` would
54
+ # let DB row order pick the winner. Shared by the class-query path
39
55
  # (ClassQueryMethods#where_typed_eav) and the instance path
40
56
  # (InstanceMethods#typed_eav_defs_by_name) so the two can't drift.
41
57
  def self.definitions_by_name(defs)
42
- defs.to_a.sort_by { |d| d.scope.nil? ? 0 : 1 }.index_by(&:name)
58
+ defs.to_a
59
+ .sort_by { |d| [d.scope.nil? ? 0 : 1, d.parent_scope.nil? ? 0 : 1] }
60
+ .index_by(&:name)
43
61
  end
44
62
 
45
63
  # Indexes field definitions by name into a multi-map (one name →
@@ -55,13 +73,49 @@ module TypedEAV
55
73
  # Register this model as having typed fields.
56
74
  #
57
75
  # Options:
58
- # scope_method: - method name that returns a scope value (e.g. :tenant_id)
59
- # for multi-tenant field isolation
60
- # types: - restrict which field types are allowed (array of symbols)
61
- # e.g. [:text, :integer, :boolean]
62
- # default: all types
76
+ # scope_method: - method name that returns a scope value (e.g. :tenant_id)
77
+ # for multi-tenant field isolation. Optional; nil means
78
+ # the model is "global" (no per-tenant partitioning).
79
+ # parent_scope_method: - method name that returns a parent_scope value
80
+ # (e.g. :workspace_id) for two-level partitioning under
81
+ # `scope_method:`. Optional; nil means the model uses a
82
+ # single-axis partition. REQUIRES `scope_method:` to also
83
+ # be set — declaring `parent_scope_method:` alone raises
84
+ # `ArgumentError` at class load (see below).
85
+ # types: - restrict which field types are allowed (array of symbols)
86
+ # e.g. [:text, :integer, :boolean]
87
+ # default: all types
88
+ # versioned: - Phase 04 opt-in: when true, mutations to typed values on
89
+ # this entity type are recorded in typed_eav_value_versions.
90
+ # Requires `TypedEAV.config.versioning = true` (the gem-
91
+ # level master switch — default false). Apps that want
92
+ # versioning per-entity rather than globally toggle this
93
+ # alongside the master switch in their initializer.
94
+ # Alternative API: `include TypedEAV::Versioned` AFTER
95
+ # `has_typed_eav` does the same thing (see
96
+ # lib/typed_eav/versioned.rb).
97
+ #
98
+ # Configuration error: `parent_scope_method:` without `scope_method:` raises
99
+ # `ArgumentError` at class load time. This closes the silent dead-letter mode
100
+ # where ambient scope resolution would short-circuit to `[nil, nil]` for a model
101
+ # declaring parent_scope but no scope, routing every query to the global-only
102
+ # branch and silently discarding the parent_scope intent.
103
+ #
63
104
  # Public DSL macro modeled on `acts_as_*`; renaming would break callers.
64
- def has_typed_eav(scope_method: nil, types: nil) # rubocop:disable Naming/PredicatePrefix
105
+ def has_typed_eav(scope_method: nil, parent_scope_method: nil, types: nil, versioned: false) # rubocop:disable Naming/PredicatePrefix
106
+ # Macro-time configuration guard. Failing fast at class-load time is strictly
107
+ # better than at query time because the misconfiguration is static (a
108
+ # property of the macro call, not of the request). Closes the silent
109
+ # dead-letter mode that would otherwise route every parent-scope-aware
110
+ # query to the global-only branch.
111
+ if parent_scope_method && !scope_method
112
+ raise ArgumentError,
113
+ "has_typed_eav: `parent_scope_method:` requires `scope_method:` to also be set. " \
114
+ "A model declaring parent_scope without scope is a configuration error — " \
115
+ "ambient resolution would silently return [nil, nil] and queries would dead-letter. " \
116
+ "Either add `scope_method: :your_scope_method` or remove `parent_scope_method:`."
117
+ end
118
+
65
119
  # class_attribute rather than cattr_accessor: class variables are
66
120
  # copied-on-write across subclasses and reload well under Rails'
67
121
  # code reloader. Normalize the types list to strings once so hot
@@ -69,6 +123,8 @@ module TypedEAV
69
123
  # don't have to re-map per call.
70
124
  class_attribute :typed_eav_scope_method, instance_accessor: false,
71
125
  default: scope_method
126
+ class_attribute :typed_eav_parent_scope_method, instance_accessor: false,
127
+ default: parent_scope_method
72
128
  class_attribute :allowed_typed_eav_types, instance_accessor: false,
73
129
  default: types && types.map(&:to_s).freeze
74
130
 
@@ -85,7 +141,7 @@ module TypedEAV
85
141
  accepts_nested_attributes_for :typed_values, allow_destroy: true
86
142
 
87
143
  # Register with the global registry
88
- TypedEAV.registry.register(name, types: types)
144
+ TypedEAV.registry.register(name, types: types, versioned: versioned)
89
145
  end
90
146
  end
91
147
 
@@ -116,8 +172,18 @@ module TypedEAV
116
172
  # { name: "city", value: "Portland" } # op defaults to :eq
117
173
  # )
118
174
  #
175
+ # `scope:` and `parent_scope:` behavior:
176
+ # - omitted → resolve from ambient (`with_scope` → resolver → raise/nil)
177
+ # - passed a value → use verbatim (explicit override; admin/test path)
178
+ # - passed nil → filter to global-only on that axis (prior behavior)
179
+ #
180
+ # Single-scope BC: callers that don't pass `parent_scope:` see no
181
+ # behavior change. The kwarg defaults to `UNSET_SCOPE` — ambient
182
+ # resolution applies if the model declares `parent_scope_method:`,
183
+ # otherwise resolves to nil.
184
+ #
119
185
  # 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.
120
- def where_typed_eav(*filters, scope: UNSET_SCOPE)
186
+ def where_typed_eav(*filters, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
121
187
  # Normalize input: accept splat args, a single array, a single filter hash,
122
188
  # a hash-of-hashes (form params), or ActionController::Parameters.
123
189
  filters = filters.map { |f| f.respond_to?(:to_unsafe_h) ? f.to_unsafe_h : f }
@@ -142,19 +208,24 @@ module TypedEAV
142
208
 
143
209
  filters = Array(filters)
144
210
 
145
- # Resolve the scope once so we can branch on whether we're inside
146
- # `TypedEAV.unscoped { }` (ALL_SCOPES) or a normal single-scope
147
- # query. Under ALL_SCOPES the same name can legitimately appear
148
- # across multiple tenant partitions; collapsing to one definition
149
- # would silently drop all but one tenant's matches. See the
150
- # multimap branch below.
151
- resolved = resolve_scope(scope)
211
+ # Resolve the (scope, parent_scope) tuple once so we can branch on
212
+ # whether we're inside `TypedEAV.unscoped { }` (ALL_SCOPES) or a
213
+ # normal single-scope query. Under ALL_SCOPES the same name can
214
+ # legitimately appear across multiple tenant partitions; collapsing
215
+ # to one definition would silently drop all but one tenant's
216
+ # matches. See the multimap branch below.
217
+ resolved = resolve_scope(scope, parent_scope)
152
218
  all_scopes = resolved.equal?(ALL_SCOPES)
153
219
 
154
220
  defs = if all_scopes
155
- TypedEAV::Field::Base.where(entity_type: name)
221
+ # Multimap branch is structurally unchanged — atomic-bypass
222
+ # per CONTEXT.md drops both scope AND parent_scope predicates.
223
+ # The OR-collapse at field_id level naturally OR's across all
224
+ # (scope, parent_scope) combinations.
225
+ TypedEAV::Partition.visible_fields(entity_type: name, mode: :all_partitions)
156
226
  else
157
- TypedEAV::Field::Base.for_entity(name, scope: resolved)
227
+ s, ps = resolved
228
+ TypedEAV::Partition.visible_fields(entity_type: name, scope: s, parent_scope: ps)
158
229
  end
159
230
 
160
231
  if all_scopes
@@ -212,44 +283,321 @@ module TypedEAV
212
283
  # Contact.with_field("active", true) # op defaults to :eq
213
284
  # Contact.with_field("name", :contains, "smith")
214
285
  #
215
- def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE)
286
+ # Accepts both `scope:` and `parent_scope:` kwargs with the same
287
+ # ambient/explicit/nil semantics as `where_typed_eav`. Single-scope
288
+ # callers (no `parent_scope:`) are unaffected.
289
+ def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
216
290
  if value.nil? && !operator_or_value.is_a?(Symbol)
217
291
  # Two-arg form: with_field("name", "value") implies :eq
218
- where_typed_eav({ name: name, op: :eq, value: operator_or_value }, scope: scope)
292
+ where_typed_eav(
293
+ { name: name, op: :eq, value: operator_or_value },
294
+ scope: scope, parent_scope: parent_scope,
295
+ )
219
296
  else
220
- where_typed_eav({ name: name, op: operator_or_value, value: value }, scope: scope)
297
+ where_typed_eav(
298
+ { name: name, op: operator_or_value, value: value },
299
+ scope: scope, parent_scope: parent_scope,
300
+ )
221
301
  end
222
302
  end
223
303
 
224
304
  # Returns field definitions for this entity type.
225
305
  #
226
- # `scope:` behavior:
306
+ # `scope:` and `parent_scope:` behavior:
227
307
  # - omitted → resolve from ambient (`with_scope` → resolver → raise/nil)
228
308
  # - passed a value → use verbatim (explicit override; admin/test path)
229
- # - passed nil → filter to global-only fields (prior behavior preserved)
230
- def typed_eav_definitions(scope: UNSET_SCOPE)
231
- resolved = resolve_scope(scope)
309
+ # - passed nil → filter to global-only on that axis (prior behavior preserved)
310
+ def typed_eav_definitions(scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
311
+ resolved = resolve_scope(scope, parent_scope)
232
312
  if resolved.equal?(ALL_SCOPES)
233
- TypedEAV::Field::Base.where(entity_type: name)
313
+ TypedEAV::Partition.visible_fields(entity_type: name, mode: :all_partitions)
234
314
  else
235
- TypedEAV::Field::Base.for_entity(name, scope: resolved)
315
+ s, ps = resolved
316
+ TypedEAV::Partition.visible_fields(entity_type: name, scope: s, parent_scope: ps)
236
317
  end
237
318
  end
238
319
 
239
- private
320
+ # Bulk read API. Returns `{ record_id => { field_name => value } }` for
321
+ # an Enumerable of host records — the class-method bulk variant of
322
+ # `InstanceMethods#typed_eav_hash`. N+1-free regardless of record count
323
+ # or field count.
324
+ #
325
+ # Why this method exists: list pages and reports often need typed values
326
+ # for many records at once. Calling `record.typed_eav_hash` per record
327
+ # issues 2 queries per record (value preload + field preload) — that's
328
+ # 200 queries for 100 records. This method collapses to:
329
+ #
330
+ # - 1 SELECT typed_eav_values WHERE entity_type=? AND entity_id IN (?)
331
+ # - 1 SELECT typed_eav_fields WHERE id IN (?) (via includes)
332
+ # - 1 SELECT typed_eav_fields per unique partition tuple
333
+ # (for `typed_eav_definitions` / `winning_ids_by_name` per tuple)
334
+ #
335
+ # Total: 2 + (unique partition tuples) queries — typically 3 or 4 in
336
+ # practice, INDEPENDENT of record count.
337
+ #
338
+ # We group records by `[typed_eav_scope, typed_eav_parent_scope]` BEFORE
339
+ # the value preload because field-collision resolution
340
+ # (`HasTypedEAV.definitions_by_name`) varies by partition: name "age"
341
+ # in tenant_1 may have a different field_id than "age" in tenant_2,
342
+ # and the global+scoped collision precedence must be applied per-tuple.
343
+ # The value preload itself is a single query regardless — `WHERE
344
+ # entity_type=? AND entity_id IN (?)` is a single index seek.
345
+ #
346
+ # Orphan-skip + winning-id precedence mirrored from the per-record
347
+ # instance method (`#typed_eav_hash`, lines 584–606). Class-query path
348
+ # and instance path share the same `HasTypedEAV.definitions_by_name`
349
+ # collision-precedence helper so the two cannot drift.
350
+ #
351
+ # Phase 7 cache integration deferred per 06-CONTEXT.md §Open Questions.
352
+ # This method ships preload-only; it does not call any cache primitive
353
+ # and does not collide with the future Phase 7 `with_all_typed_values`
354
+ # scope or `typed_eav_hash_cached` alias.
355
+ #
356
+ # The Metrics/* disables at the top of `where_typed_eav` (line 185)
357
+ # cover this method too — the partition-tuple grouping + single
358
+ # preload + collision-precedence loop genuinely belong together, same
359
+ # rationale as the where_typed_eav disable.
360
+ def typed_eav_hash_for(records)
361
+ # Input validation — fail fast with a recovery hint (CONVENTIONS §Error
362
+ # messages). nil is a programmer error (a real empty list is `[]`).
363
+ raise ArgumentError, "typed_eav_hash_for requires an Enumerable of records, got nil" if records.nil?
364
+
365
+ # Coerce to Array. Accepts AR::Relation, Array, lazy enumerable. We
366
+ # need to iterate twice (group_by + map(&:id) + final iteration) so
367
+ # materialize once.
368
+ records = records.to_a
369
+ return {} if records.empty?
370
+
371
+ # Single-class invariant: the polymorphic value query (`entity_type:
372
+ # name`) targets ONE class; mixed-class input would silently miss
373
+ # rows of the other class. The defensive check is cheap (one `all?`
374
+ # pass) and surfaces the misuse loudly. STI subclasses pass via
375
+ # `Class#===` which dispatches to `is_a?` (covariant). `all?(self)`
376
+ # is the rubocop-preferred form for a kind-of check.
377
+ unless records.all?(self)
378
+ classes = records.map { |r| r.class.name }.uniq
379
+ raise ArgumentError,
380
+ "typed_eav_hash_for expects records of class #{name} (or its subclasses); " \
381
+ "got mixed classes: #{classes.join(", ")}"
382
+ end
383
+
384
+ # Group by partition tuple BEFORE field-definition lookup. Ruby Hash
385
+ # handles nil keys cleanly, so `[nil, nil]` (the global partition) is
386
+ # a valid tuple — no special casing.
387
+ groups = records.group_by { |r| [r.typed_eav_scope, r.typed_eav_parent_scope] }
388
+
389
+ # Build per-tuple `winning_ids_by_name`. One `typed_eav_definitions`
390
+ # query per unique tuple; reuse the resulting map for every record in
391
+ # that tuple. `HasTypedEAV.definitions_by_name` is the SHARED
392
+ # collision-precedence function — same one `typed_eav_defs_by_name`
393
+ # uses on the instance side. Reusing it guarantees parity.
394
+ winning_ids_by_tuple = groups.keys.each_with_object({}) do |(s, ps), memo|
395
+ defs = typed_eav_definitions(scope: s, parent_scope: ps)
396
+ memo[[s, ps]] = HasTypedEAV.definitions_by_name(defs).transform_values(&:id)
397
+ end
240
398
 
241
- # Resolves the scope kwarg into a concrete value for field-definition
242
- # lookup. See `typed_eav_definitions` docs for kwarg semantics.
243
- # Raises `TypedEAV::ScopeRequired` when the model declares
244
- # `scope_method:` but ambient scope can't be resolved and fail-closed
245
- # mode is enabled.
246
- def resolve_scope(explicit)
247
- # Explicit override (including explicit nil) — use verbatim.
248
- return TypedEAV.normalize_scope(explicit) unless explicit.equal?(UNSET_SCOPE)
399
+ # Single-shot value preload across ALL records, regardless of how
400
+ # many partition tuples they span. `includes(:field)` triggers a
401
+ # second query that batch-loads every referenced field row — Rails'
402
+ # standard preload pattern. Splitting this per-tuple would issue N
403
+ # value queries instead of 1; the SQL `WHERE entity_id IN (...)`
404
+ # is a single index seek.
405
+ #
406
+ # We use the explicit `entity_type: name` form rather than
407
+ # `where(entity: records)` because empty `records` would have made
408
+ # the `where(entity:)` form ambiguous earlier — but we already
409
+ # short-circuited on empty, so either works. Explicit form reads
410
+ # cleaner.
411
+ value_rows = TypedEAV::Value
412
+ .includes(:field)
413
+ .where(entity_type: name, entity_id: records.map(&:id))
414
+ .to_a
415
+ values_by_record_id = value_rows.group_by(&:entity_id)
416
+
417
+ # Build the result hash. Records with no values produce a `{}` inner
418
+ # hash — present in the result so callers can uniformly index by id.
419
+ records.each_with_object({}) do |record, result|
420
+ inner = {}
421
+ tuple_key = [record.typed_eav_scope, record.typed_eav_parent_scope]
422
+ winning_ids_by_name = winning_ids_by_tuple.fetch(tuple_key, {})
423
+
424
+ values_by_record_id.fetch(record.id, []).each do |tv|
425
+ # Skip orphans (`tv.field` nil — definition deleted via raw SQL
426
+ # or a Phase 02 `:nullify` cascade). Same fail-soft contract as
427
+ # `#typed_eav_hash` line 591.
428
+ next unless tv.field
429
+
430
+ field_name = tv.field.name
431
+ winning_id = winning_ids_by_name[field_name]
432
+ effective_id = tv.field_id || tv.field&.id
433
+
434
+ # When a winner IS registered: only its row is allowed (collision
435
+ # precedence — scoped beats global). When no winner is registered
436
+ # (definition deleted while values remain), fall back to first-
437
+ # wins so the hash isn't lossy. Mirrors `#typed_eav_hash` lines
438
+ # 600–605.
439
+ if winning_id
440
+ inner[field_name] = tv.value if effective_id == winning_id
441
+ else
442
+ inner[field_name] = tv.value unless inner.key?(field_name)
443
+ end
444
+ end
249
445
 
250
- # Inside `TypedEAV.unscoped { }` — skip the scope filter entirely.
446
+ result[record.id] = inner
447
+ end
448
+ end
449
+
450
+ # Bulk write API. Sets the same `values_by_field_name` Hash on every
451
+ # record in `records` inside ONE outer ActiveRecord transaction with a
452
+ # SAVEPOINT-PER-RECORD failure-isolation envelope. Bad records roll
453
+ # back their savepoint and surface in `errors_by_record`; good records
454
+ # commit when the outer transaction commits.
455
+ #
456
+ # ## Failure isolation contract (CONTEXT-locked, 06-CONTEXT.md line 26)
457
+ #
458
+ # outer transaction
459
+ # ├── savepoint(record_1) → record.typed_eav_attributes = vbn; record.save
460
+ # ├── savepoint(record_2) → ditto
461
+ # └── savepoint(record_N) → ditto
462
+ #
463
+ # The savepoint-per-record-INSIDE-an-outer-transaction structure is
464
+ # preserved under EVERY `version_grouping:` value (none / per_record /
465
+ # per_field). It is NEVER relaxed to per-record TOP-LEVEL transactions
466
+ # — that path was rejected at CONTEXT time because per-record top-level
467
+ # transactions break Phase 3 hook semantics (after_commit fires on
468
+ # outer commit, not savepoint release; see Plan 06-05 §Discrepancy
469
+ # awareness).
470
+ #
471
+ # ## Why we delegate to record.typed_eav_attributes=
472
+ #
473
+ # The instance setter at lines 632–664 ALREADY enforces:
474
+ # * Field-name resolution via `typed_eav_defs_by_name` (collision-
475
+ # precedence-correct).
476
+ # * `allowed_typed_eav_types` restriction (silently skips fields whose
477
+ # type was excluded by `has_typed_eav types: [...]`).
478
+ # * Cross-tenant guards via `validate_field_scope_matches_entity` on
479
+ # the resulting Value rows.
480
+ # Bulk write reuses this path; it does NOT bypass any of these.
481
+ #
482
+ # ## Errors-by-record key choice
483
+ #
484
+ # The Hash KEY is the record itself (NOT record.id). Records that fail
485
+ # validation BEFORE getting an id (new records that never persist) have
486
+ # nil ids; using id as the key would collapse multiple unsaved-record
487
+ # failures into one entry and lose information. Callers can still
488
+ # `result[:errors_by_record].keys.map(&:id)` if they want id-keyed
489
+ # output.
490
+ #
491
+ # ## errors_by_record value shape
492
+ #
493
+ # `record.errors.messages.transform_keys(&:to_s)` — string field-name
494
+ # keys, Array<String> messages. Mirrors `CSVMapper::Result#errors` so
495
+ # callers can write one error-handling path that consumes both shapes.
496
+ # `errors.messages` is the modern AR API (the older `errors.to_h` is
497
+ # flagged by Rails/DeprecatedActiveModelErrorsMethods); both return the
498
+ # same shape `{attribute => [messages]}`.
499
+ #
500
+ # ## version_grouping default sentinel
501
+ #
502
+ # The kwarg's literal default is `:default`. The first step of the
503
+ # method body resolves it: `:per_record` when versioning is on, `:none`
504
+ # when versioning is off. Callers omitting the kwarg "just work" in
505
+ # both versioning environments — they don't need to branch on
506
+ # `TypedEAV.config.versioning`. Explicit `:per_record`/`:per_field`
507
+ # passed with versioning OFF raises ArgumentError loudly (the caller's
508
+ # intent — group versions — cannot be satisfied with versioning off,
509
+ # and silently no-op'ing would mask a misconfiguration). Explicit
510
+ # `:none` is ALWAYS valid (explicit opt-out — same payload as the
511
+ # default-resolved case under versioning-off).
512
+ #
513
+ # ## Snapshot mechanism for version_group_id propagation
514
+ #
515
+ # The `:per_record` and `:per_field` paths stamp `pending_version_group_id`
516
+ # on each affected Value object BEFORE save inside the per-record
517
+ # `with_context` block. The Phase 4 versioning subscriber prefers the
518
+ # per-Value snapshot over `context[:version_group_id]` so the UUID
519
+ # survives the outer-transaction `after_commit` boundary even after
520
+ # `with_context` has unwound. See `lib/typed_eav/versioning/subscriber.rb`
521
+ # line 127 for the read-side; the stamping happens below per-record.
522
+ # `with_context(version_group_id: ...)` is retained as a belt-and-
523
+ # suspenders fallback so any future after_commit-inside-savepoint
524
+ # dispatch path also works.
525
+ #
526
+ # ## rubocop scope
527
+ #
528
+ # The Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength,
529
+ # Metrics/PerceivedComplexity disables on `where_typed_eav` (line 185
530
+ # of this file) are still active at this point — they cover the entire
531
+ # ClassQueryMethods module body and re-enable only at line 629 (after
532
+ # `typed_eav_attributes=`). The bulk-write transaction + savepoint +
533
+ # error-capture + version-group-id snapshot legitimately belong
534
+ # together for the same reason `where_typed_eav` does — splitting
535
+ # hurts readability of the failure-isolation invariant.
536
+ def bulk_set_typed_eav_values(records, values_by_field_name, version_grouping: :default)
537
+ TypedEAV::BulkWrite.execute(
538
+ host_class: self,
539
+ records: records,
540
+ values_by_field_name: values_by_field_name,
541
+ version_grouping: version_grouping,
542
+ )
543
+ end
544
+
545
+ private
546
+
547
+ # Resolves the scope and parent_scope kwargs into a concrete tuple for
548
+ # field-definition lookup. See `typed_eav_definitions` docs for kwarg
549
+ # semantics.
550
+ #
551
+ # Returns one of:
552
+ # - `ALL_SCOPES` — inside `TypedEAV.unscoped { }`, atomic bypass.
553
+ # - `[scope, parent_scope]` — both elements are String or nil.
554
+ # Raises:
555
+ # - `TypedEAV::ScopeRequired` when the model declares `scope_method:`
556
+ # but ambient scope can't be resolved and `require_scope` is true.
557
+ #
558
+ # Resolver-callable contract violations (`Config.scope_resolver`
559
+ # returning a bare scalar) raise `ArgumentError` directly inside
560
+ # `TypedEAV.current_scope` (plan 02), BEFORE this method consumes the
561
+ # value. By the time `resolve_scope` calls `TypedEAV.current_scope`,
562
+ # the result is guaranteed to be `nil` or a 2-element Array — no shape
563
+ # check is duplicated here; that would be dead code.
564
+ def resolve_scope(explicit_scope, explicit_parent_scope)
565
+ # Inside `TypedEAV.unscoped { }` — atomic bypass, drops both predicates
566
+ # entirely (per CONTEXT.md). The multimap branch in `where_typed_eav`
567
+ # handles ALL_SCOPES; do not narrow to per-axis predicates inside unscoped.
251
568
  return ALL_SCOPES if TypedEAV.unscoped?
252
569
 
570
+ # Determine the explicit-overrides path. If EITHER kwarg was passed
571
+ # explicitly (i.e., not UNSET_SCOPE), normalize what was given and skip
572
+ # ambient resolution entirely. Mixing explicit + ambient resolution
573
+ # within one call would be confusing; explicit wins for the whole tuple.
574
+ explicit_given = !explicit_scope.equal?(UNSET_SCOPE) || !explicit_parent_scope.equal?(UNSET_SCOPE)
575
+
576
+ if explicit_given
577
+ # Per-slot normalize: an explicit kwarg passes through `normalize_scope`
578
+ # to coerce scalars/AR-records to strings, with UNSET_SCOPE collapsing
579
+ # to nil for the corresponding slot. We pass `[value, nil]` to extract
580
+ # the first slot and `[nil, value]` to extract the second so the
581
+ # public `normalize_scope` BC contract (used by with_scope) handles
582
+ # the per-slot coercion uniformly.
583
+ s = if explicit_scope.equal?(UNSET_SCOPE)
584
+ nil
585
+ else
586
+ TypedEAV.normalize_scope([explicit_scope, nil]).first
587
+ end
588
+ ps = if explicit_parent_scope.equal?(UNSET_SCOPE)
589
+ nil
590
+ else
591
+ TypedEAV.normalize_scope([nil, explicit_parent_scope]).last
592
+ end
593
+ # Orphan-parent invariant at the read layer: a request for parent_scope
594
+ # without scope is dead-letter (no rows can match — the Field-level
595
+ # validator forbids `(scope=NULL, parent_scope=NOT NULL)` rows). Don't
596
+ # raise here — just narrow the predicate. The Field-level invariant
597
+ # (plan 03) prevents the corresponding write.
598
+ return [s, ps]
599
+ end
600
+
253
601
  # Models that did NOT opt into scoping must NOT see ambient scope.
254
602
  # If the host declared `has_typed_eav` without `scope_method:`, it
255
603
  # has no per-instance scope accessor, so `Value#validate_field_scope_matches_entity`
@@ -259,23 +607,34 @@ module TypedEAV
259
607
  # leak cross-model ambient state into a model that never opted in.
260
608
  # An explicit `scope:` kwarg (handled above) still overrides this, so
261
609
  # admin/test paths retain the ability to query arbitrary scopes.
262
- return nil unless typed_eav_scope_method
263
-
264
- # Ambient resolver (via `with_scope` stack or configured lambda).
610
+ #
611
+ # NOTE: a model with `parent_scope_method:` but no `scope_method:` is
612
+ # impossible to construct the macro-time guard in `has_typed_eav`
613
+ # raises `ArgumentError` at class-load time. If we get here, either
614
+ # both are set or only `scope_method` is set, never only
615
+ # `parent_scope_method`.
616
+ return [nil, nil] unless typed_eav_scope_method
617
+
618
+ # Ambient resolver (via `with_scope` stack or configured lambda). The
619
+ # return value is already validated as `nil | [a, b]` by
620
+ # `TypedEAV.current_scope` — no shape check needed here.
265
621
  resolved = TypedEAV.current_scope
266
- return resolved unless resolved.nil?
267
-
268
- # Fail-closed: the model opted into scoping (`scope_method:` declared)
269
- # but nothing resolved. Raise so data can't leak across partitions.
270
- if typed_eav_scope_method && TypedEAV.config.require_scope
271
- raise TypedEAV::ScopeRequired,
272
- "No ambient scope resolvable for #{name}. " \
273
- "Wrap the call in `TypedEAV.with_scope(value) { ... }`, " \
274
- "configure `TypedEAV.config.scope_resolver`, or use " \
275
- "`TypedEAV.unscoped { ... }` to deliberately bypass."
622
+ if resolved.nil?
623
+ # Fail-closed: the model opted into scoping (`scope_method:` declared)
624
+ # but nothing resolved. Raise so data can't leak across partitions.
625
+ if TypedEAV.config.require_scope
626
+ raise TypedEAV::ScopeRequired,
627
+ "No ambient scope resolvable for #{name}. " \
628
+ "Wrap the call in `TypedEAV.with_scope(value) { ... }`, " \
629
+ "configure `TypedEAV.config.scope_resolver`, or use " \
630
+ "`TypedEAV.unscoped { ... }` to deliberately bypass."
631
+ end
632
+ return [nil, nil]
276
633
  end
277
634
 
278
- nil
635
+ # `resolved` is guaranteed to be a 2-element Array by current_scope's
636
+ # contract (plan 02). Return verbatim — both halves already normalized.
637
+ resolved
279
638
  end
280
639
  end
281
640
 
@@ -285,7 +644,10 @@ module TypedEAV
285
644
  module InstanceMethods
286
645
  # The field definitions available for this record
287
646
  def typed_eav_definitions
288
- self.class.typed_eav_definitions(scope: typed_eav_scope)
647
+ self.class.typed_eav_definitions(
648
+ scope: typed_eav_scope,
649
+ parent_scope: typed_eav_parent_scope,
650
+ )
289
651
  end
290
652
 
291
653
  # Current scope value (for multi-tenant)
@@ -295,6 +657,19 @@ module TypedEAV
295
657
  send(self.class.typed_eav_scope_method)&.to_s
296
658
  end
297
659
 
660
+ # Current parent_scope value (for two-level partitioning).
661
+ #
662
+ # Returns nil for models that did not declare `parent_scope_method:` —
663
+ # the method is defined unconditionally so callers (e.g. the Value-side
664
+ # cross-axis validator) can `respond_to?` and read uniformly without
665
+ # branching on `parent_scope_method` configuration. Mirrors the
666
+ # `&.to_s` normalization on `typed_eav_scope`.
667
+ def typed_eav_parent_scope
668
+ return nil unless self.class.typed_eav_parent_scope_method
669
+
670
+ send(self.class.typed_eav_parent_scope_method)&.to_s
671
+ end
672
+
298
673
  # Build missing values with defaults for all available fields.
299
674
  # Useful in forms to show all fields even when no value exists yet.
300
675
  #
@@ -476,8 +851,30 @@ module TypedEAV
476
851
  # when both a global (scope=NULL) and a scoped field share a name, the
477
852
  # scoped definition wins. Delegates to `HasTypedEAV.definitions_by_name`
478
853
  # so the class-query path and the instance path share one source of truth.
854
+ #
855
+ # ## Bulk-write memoization (Phase 06 plan 05)
856
+ #
857
+ # `bulk_set_typed_eav_values` sets `Thread.current[:typed_eav_bulk_defs_memo]`
858
+ # to a Hash before its records loop. We consult it here so the per-
859
+ # record `typed_eav_attributes=` call does NOT issue a fresh
860
+ # `typed_eav_definitions` SELECT per record. AR's per-block query
861
+ # cache (`ActiveRecord::Base.cache`) is invalidated by every write —
862
+ # because each record's INSERT clears the cache — so cache-do alone
863
+ # cannot keep field-definition reads N+1-free across the bulk loop.
864
+ # The thread-local memo is the explicit fallback documented in plan
865
+ # 06-05 §T3 notes; it pre-warms once per `[host_class, scope,
866
+ # parent_scope]` tuple and reuses across every record in that tuple.
867
+ #
868
+ # Outside a bulk operation the memo is nil and we fall through to
869
+ # the standard read path — zero overhead.
479
870
  def typed_eav_defs_by_name
480
- HasTypedEAV.definitions_by_name(typed_eav_definitions)
871
+ memo = Thread.current[:typed_eav_bulk_defs_memo]
872
+ if memo
873
+ key = [self.class.name, typed_eav_scope, typed_eav_parent_scope]
874
+ memo[key] ||= HasTypedEAV.definitions_by_name(typed_eav_definitions)
875
+ else
876
+ HasTypedEAV.definitions_by_name(typed_eav_definitions)
877
+ end
481
878
  end
482
879
  end
483
880
  end