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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +634 -2
- data/app/models/typed_eav/field/base.rb +552 -6
- data/app/models/typed_eav/field/currency.rb +125 -0
- data/app/models/typed_eav/field/file.rb +98 -0
- data/app/models/typed_eav/field/image.rb +152 -0
- data/app/models/typed_eav/field/percentage.rb +100 -0
- data/app/models/typed_eav/field/reference.rb +230 -0
- data/app/models/typed_eav/section.rb +114 -4
- data/app/models/typed_eav/value.rb +461 -11
- data/app/models/typed_eav/value_version.rb +96 -0
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
- data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
- data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
- data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
- data/lib/typed_eav/bulk_write.rb +147 -0
- data/lib/typed_eav/column_mapping.rb +46 -0
- data/lib/typed_eav/config.rb +215 -19
- data/lib/typed_eav/csv_mapper.rb +158 -0
- data/lib/typed_eav/currency_storage_contract.rb +46 -0
- data/lib/typed_eav/engine.rb +117 -0
- data/lib/typed_eav/event_dispatcher.rb +151 -0
- data/lib/typed_eav/field_storage_contract.rb +68 -0
- data/lib/typed_eav/has_typed_eav.rb +455 -58
- data/lib/typed_eav/partition.rb +64 -0
- data/lib/typed_eav/query_builder.rb +39 -3
- data/lib/typed_eav/registry.rb +48 -9
- data/lib/typed_eav/schema_portability.rb +250 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioned.rb +73 -0
- data/lib/typed_eav/versioning/subscriber.rb +161 -0
- data/lib/typed_eav/versioning.rb +94 -0
- data/lib/typed_eav.rb +180 -12
- 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
|
|
35
|
-
# resolution: when
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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
|
|
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:
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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
|
|
146
|
-
# `TypedEAV.unscoped { }` (ALL_SCOPES) or a
|
|
147
|
-
# query. Under ALL_SCOPES the same name can
|
|
148
|
-
# across multiple tenant partitions; collapsing
|
|
149
|
-
# would silently drop all but one tenant's
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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::
|
|
313
|
+
TypedEAV::Partition.visible_fields(entity_type: name, mode: :all_partitions)
|
|
234
314
|
else
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
#
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
#
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|