typed_eav 0.2.1 → 0.3.1
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 +134 -0
- data/README.md +287 -47
- data/app/models/typed_eav/field/base.rb +28 -159
- data/app/models/typed_eav/field/currency.rb +54 -20
- data/app/models/typed_eav/field/date.rb +16 -1
- data/app/models/typed_eav/field/date_time.rb +16 -1
- data/app/models/typed_eav/field/decimal.rb +9 -1
- data/app/models/typed_eav/field/email.rb +13 -16
- data/app/models/typed_eav/field/integer.rb +6 -1
- data/app/models/typed_eav/field/long_text.rb +17 -1
- data/app/models/typed_eav/field/multi_select.rb +12 -9
- data/app/models/typed_eav/field/optionable.rb +59 -0
- data/app/models/typed_eav/field/percentage.rb +6 -6
- data/app/models/typed_eav/field/range_bounded.rb +71 -0
- data/app/models/typed_eav/field/select.rb +9 -10
- data/app/models/typed_eav/field/text.rb +11 -29
- data/app/models/typed_eav/field/url.rb +14 -16
- data/app/models/typed_eav/field/validated_string.rb +87 -0
- data/app/models/typed_eav/value.rb +9 -9
- data/lib/typed_eav/bulk_read.rb +124 -0
- data/lib/typed_eav/engine.rb +1 -1
- data/lib/typed_eav/entity_query.rb +186 -0
- data/lib/typed_eav/field/typed_storage.rb +205 -0
- data/lib/typed_eav/filter_query.rb +148 -0
- data/lib/typed_eav/has_typed_eav/instance_methods.rb +253 -0
- data/lib/typed_eav/has_typed_eav.rb +29 -793
- data/lib/typed_eav/partition.rb +51 -11
- data/lib/typed_eav/query_builder.rb +6 -7
- data/lib/typed_eav/scope_tuple.rb +116 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioning/subscriber.rb +7 -6
- data/lib/typed_eav.rb +23 -64
- metadata +10 -4
- data/lib/typed_eav/column_mapping.rb +0 -110
- data/lib/typed_eav/currency_storage_contract.rb +0 -46
- data/lib/typed_eav/field_storage_contract.rb +0 -68
|
@@ -28,46 +28,18 @@ module TypedEAV
|
|
|
28
28
|
# Contact.with_field("age", :gt, 21)
|
|
29
29
|
# Contact.with_field("status", "active") # :eq is default
|
|
30
30
|
#
|
|
31
|
+
# ## Architecture (ADR-0002, 0.3.0 refactor)
|
|
32
|
+
#
|
|
33
|
+
# This file holds the macro entry + macro-time guards. Per-record API
|
|
34
|
+
# lives in `TypedEAV::HasTypedEAV::InstanceMethods`. Class-level query
|
|
35
|
+
# orchestration lives in `TypedEAV::EntityQuery` (extended onto the host
|
|
36
|
+
# class), which delegates the heavy lifting to `TypedEAV::FilterQuery`
|
|
37
|
+
# (where_typed_eav) and `TypedEAV::BulkRead` (typed_eav_hash_for).
|
|
38
|
+
# `bulk_set_typed_eav_values` continues to delegate to `TypedEAV::BulkWrite`.
|
|
31
39
|
module HasTypedEAV
|
|
32
40
|
extend ActiveSupport::Concern
|
|
33
41
|
|
|
34
|
-
|
|
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
|
|
55
|
-
# (ClassQueryMethods#where_typed_eav) and the instance path
|
|
56
|
-
# (InstanceMethods#typed_eav_defs_by_name) so the two can't drift.
|
|
57
|
-
def self.definitions_by_name(defs)
|
|
58
|
-
defs.to_a
|
|
59
|
-
.sort_by { |d| [d.scope.nil? ? 0 : 1, d.parent_scope.nil? ? 0 : 1] }
|
|
60
|
-
.index_by(&:name)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Indexes field definitions by name into a multi-map (one name →
|
|
64
|
-
# array of fields). Used by the class-query path under
|
|
65
|
-
# `TypedEAV.unscoped { }`, where the same field name may legitimately
|
|
66
|
-
# exist across multiple tenant partitions and we must OR-across all
|
|
67
|
-
# matching field_ids per filter rather than collapse to a single row.
|
|
68
|
-
def self.definitions_multimap_by_name(defs)
|
|
69
|
-
defs.to_a.group_by(&:name)
|
|
70
|
-
end
|
|
42
|
+
autoload :InstanceMethods, "typed_eav/has_typed_eav/instance_methods"
|
|
71
43
|
|
|
72
44
|
class_methods do
|
|
73
45
|
# Register this model as having typed fields.
|
|
@@ -81,19 +53,13 @@ module TypedEAV
|
|
|
81
53
|
# `scope_method:`. Optional; nil means the model uses a
|
|
82
54
|
# single-axis partition. REQUIRES `scope_method:` to also
|
|
83
55
|
# be set — declaring `parent_scope_method:` alone raises
|
|
84
|
-
# `ArgumentError` at class load
|
|
56
|
+
# `ArgumentError` at class load.
|
|
85
57
|
# types: - restrict which field types are allowed (array of symbols)
|
|
86
|
-
# e.g. [:text, :integer, :boolean]
|
|
87
|
-
# default: all types
|
|
58
|
+
# e.g. [:text, :integer, :boolean]; default: all types
|
|
88
59
|
# versioned: - Phase 04 opt-in: when true, mutations to typed values on
|
|
89
60
|
# this entity type are recorded in typed_eav_value_versions.
|
|
90
61
|
# Requires `TypedEAV.config.versioning = true` (the gem-
|
|
91
|
-
# level master switch — default false).
|
|
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).
|
|
62
|
+
# level master switch — default false).
|
|
97
63
|
#
|
|
98
64
|
# Configuration error: `parent_scope_method:` without `scope_method:` raises
|
|
99
65
|
# `ArgumentError` at class load time. This closes the silent dead-letter mode
|
|
@@ -103,33 +69,20 @@ module TypedEAV
|
|
|
103
69
|
#
|
|
104
70
|
# Public DSL macro modeled on `acts_as_*`; renaming would break callers.
|
|
105
71
|
def has_typed_eav(scope_method: nil, parent_scope_method: nil, types: nil, versioned: false) # rubocop:disable Naming/PredicatePrefix
|
|
106
|
-
|
|
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
|
|
72
|
+
validate_has_typed_eav_options!(scope_method, parent_scope_method)
|
|
118
73
|
|
|
119
74
|
# class_attribute rather than cattr_accessor: class variables are
|
|
120
75
|
# copied-on-write across subclasses and reload well under Rails'
|
|
121
76
|
# code reloader. Normalize the types list to strings once so hot
|
|
122
77
|
# paths (type-restriction validation, `typed_eav_attributes=`)
|
|
123
78
|
# don't have to re-map per call.
|
|
124
|
-
class_attribute :typed_eav_scope_method, instance_accessor: false,
|
|
125
|
-
|
|
126
|
-
class_attribute :typed_eav_parent_scope_method, instance_accessor: false,
|
|
127
|
-
default: parent_scope_method
|
|
79
|
+
class_attribute :typed_eav_scope_method, instance_accessor: false, default: scope_method
|
|
80
|
+
class_attribute :typed_eav_parent_scope_method, instance_accessor: false, default: parent_scope_method
|
|
128
81
|
class_attribute :allowed_typed_eav_types, instance_accessor: false,
|
|
129
82
|
default: types && types.map(&:to_s).freeze
|
|
130
83
|
|
|
131
84
|
include InstanceMethods
|
|
132
|
-
extend
|
|
85
|
+
extend TypedEAV::EntityQuery
|
|
133
86
|
|
|
134
87
|
has_many :typed_values,
|
|
135
88
|
class_name: "TypedEAV::Value",
|
|
@@ -140,741 +93,24 @@ module TypedEAV
|
|
|
140
93
|
|
|
141
94
|
accepts_nested_attributes_for :typed_values, allow_destroy: true
|
|
142
95
|
|
|
143
|
-
# Register with the global registry
|
|
144
96
|
TypedEAV.registry.register(name, types: types, versioned: versioned)
|
|
145
97
|
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# ──────────────────────────────────────────────────
|
|
149
|
-
# Class-level query methods
|
|
150
|
-
# ──────────────────────────────────────────────────
|
|
151
|
-
module ClassQueryMethods
|
|
152
|
-
# Sentinel for the `scope:` kwarg default. Distinguishes "kwarg not
|
|
153
|
-
# passed → resolve from ambient" (UNSET_SCOPE) from "explicitly nil →
|
|
154
|
-
# filter to global-only fields" (preserves prior behavior).
|
|
155
|
-
UNSET_SCOPE = Object.new.freeze
|
|
156
|
-
|
|
157
|
-
# Sentinel returned by `resolve_scope` inside an `unscoped { }` block.
|
|
158
|
-
# Signals the caller to skip the scope filter entirely (return fields
|
|
159
|
-
# across all partitions, not just global).
|
|
160
|
-
ALL_SCOPES = Object.new.freeze
|
|
161
|
-
|
|
162
|
-
# Query by custom field values. Accepts an array of filter hashes
|
|
163
|
-
# or a hash of hashes (from form params).
|
|
164
|
-
#
|
|
165
|
-
# Each filter needs:
|
|
166
|
-
# :name or :n - the field name
|
|
167
|
-
# :op or :operator - the operator (default: :eq)
|
|
168
|
-
# :value or :v - the comparison value
|
|
169
|
-
#
|
|
170
|
-
# Contact.where_typed_eav(
|
|
171
|
-
# { name: "age", op: :gt, value: 21 },
|
|
172
|
-
# { name: "city", value: "Portland" } # op defaults to :eq
|
|
173
|
-
# )
|
|
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
|
-
#
|
|
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.
|
|
186
|
-
def where_typed_eav(*filters, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
|
|
187
|
-
# Normalize input: accept splat args, a single array, a single filter hash,
|
|
188
|
-
# a hash-of-hashes (form params), or ActionController::Parameters.
|
|
189
|
-
filters = filters.map { |f| f.respond_to?(:to_unsafe_h) ? f.to_unsafe_h : f }
|
|
190
|
-
|
|
191
|
-
if filters.size == 1
|
|
192
|
-
inner = filters.first
|
|
193
|
-
inner = inner.to_unsafe_h if inner.respond_to?(:to_unsafe_h)
|
|
194
|
-
|
|
195
|
-
if inner.is_a?(Array)
|
|
196
|
-
filters = inner
|
|
197
|
-
elsif inner.is_a?(Hash)
|
|
198
|
-
# A single filter hash has keys like :name/:n, :op, :value/:v.
|
|
199
|
-
# A hash-of-hashes (form params) has values that are all hashes.
|
|
200
|
-
filter_keys = %i[name n op operator value v].map(&:to_s)
|
|
201
|
-
filters = if inner.keys.any? { |k| filter_keys.include?(k.to_s) }
|
|
202
|
-
[inner]
|
|
203
|
-
else
|
|
204
|
-
inner.values
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
filters = Array(filters)
|
|
210
|
-
|
|
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)
|
|
218
|
-
all_scopes = resolved.equal?(ALL_SCOPES)
|
|
219
|
-
|
|
220
|
-
defs = if all_scopes
|
|
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)
|
|
226
|
-
else
|
|
227
|
-
s, ps = resolved
|
|
228
|
-
TypedEAV::Partition.visible_fields(entity_type: name, scope: s, parent_scope: ps)
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
if all_scopes
|
|
232
|
-
fields_multimap = HasTypedEAV.definitions_multimap_by_name(defs)
|
|
233
|
-
|
|
234
|
-
filters.inject(all) do |query, filter|
|
|
235
|
-
filter = filter.to_h.with_indifferent_access
|
|
236
|
-
|
|
237
|
-
name = filter[:n] || filter[:name]
|
|
238
|
-
operator = (filter[:op] || filter[:operator] || :eq).to_sym
|
|
239
|
-
value = filter.key?(:v) ? filter[:v] : filter[:value]
|
|
240
|
-
|
|
241
|
-
matching_fields = fields_multimap[name.to_s]
|
|
242
|
-
unless matching_fields&.any?
|
|
243
|
-
raise ArgumentError, "Unknown typed field '#{name}' for #{self.name}. " \
|
|
244
|
-
"Available fields: #{fields_multimap.keys.join(", ")}"
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
# OR-across all field_ids that share this name (across tenants),
|
|
248
|
-
# while preserving AND between filters via the chained `.where`.
|
|
249
|
-
# Use the underlying Value scope (`.filter(...)`) and pluck
|
|
250
|
-
# entity_ids — `entity_ids` returns a relation, and pluck collapses
|
|
251
|
-
# it to a plain integer array we can union across tenants.
|
|
252
|
-
union_ids = matching_fields.flat_map do |f|
|
|
253
|
-
TypedEAV::QueryBuilder.filter(f, operator, value).pluck(:entity_id)
|
|
254
|
-
end.uniq
|
|
255
|
-
|
|
256
|
-
query.where(id: union_ids)
|
|
257
|
-
end
|
|
258
|
-
else
|
|
259
|
-
fields_by_name = HasTypedEAV.definitions_by_name(defs)
|
|
260
|
-
|
|
261
|
-
filters.inject(all) do |query, filter|
|
|
262
|
-
filter = filter.to_h.with_indifferent_access
|
|
263
|
-
|
|
264
|
-
name = filter[:n] || filter[:name]
|
|
265
|
-
operator = (filter[:op] || filter[:operator] || :eq).to_sym
|
|
266
|
-
value = filter.key?(:v) ? filter[:v] : filter[:value]
|
|
267
|
-
|
|
268
|
-
field = fields_by_name[name.to_s]
|
|
269
|
-
unless field
|
|
270
|
-
raise ArgumentError, "Unknown typed field '#{name}' for #{self.name}. " \
|
|
271
|
-
"Available fields: #{fields_by_name.keys.join(", ")}"
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
matching_ids = TypedEAV::QueryBuilder.entity_ids(field, operator, value)
|
|
275
|
-
query.where(id: matching_ids)
|
|
276
|
-
end
|
|
277
|
-
end
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
# Shorthand for single-field queries.
|
|
281
|
-
#
|
|
282
|
-
# Contact.with_field("age", :gt, 21)
|
|
283
|
-
# Contact.with_field("active", true) # op defaults to :eq
|
|
284
|
-
# Contact.with_field("name", :contains, "smith")
|
|
285
|
-
#
|
|
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)
|
|
290
|
-
if value.nil? && !operator_or_value.is_a?(Symbol)
|
|
291
|
-
# Two-arg form: with_field("name", "value") implies :eq
|
|
292
|
-
where_typed_eav(
|
|
293
|
-
{ name: name, op: :eq, value: operator_or_value },
|
|
294
|
-
scope: scope, parent_scope: parent_scope,
|
|
295
|
-
)
|
|
296
|
-
else
|
|
297
|
-
where_typed_eav(
|
|
298
|
-
{ name: name, op: operator_or_value, value: value },
|
|
299
|
-
scope: scope, parent_scope: parent_scope,
|
|
300
|
-
)
|
|
301
|
-
end
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
# Returns field definitions for this entity type.
|
|
305
|
-
#
|
|
306
|
-
# `scope:` and `parent_scope:` behavior:
|
|
307
|
-
# - omitted → resolve from ambient (`with_scope` → resolver → raise/nil)
|
|
308
|
-
# - passed a value → use verbatim (explicit override; admin/test path)
|
|
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)
|
|
312
|
-
if resolved.equal?(ALL_SCOPES)
|
|
313
|
-
TypedEAV::Partition.visible_fields(entity_type: name, mode: :all_partitions)
|
|
314
|
-
else
|
|
315
|
-
s, ps = resolved
|
|
316
|
-
TypedEAV::Partition.visible_fields(entity_type: name, scope: s, parent_scope: ps)
|
|
317
|
-
end
|
|
318
|
-
end
|
|
319
|
-
|
|
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
|
|
398
|
-
|
|
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
|
|
445
|
-
|
|
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
98
|
|
|
545
99
|
private
|
|
546
100
|
|
|
547
|
-
#
|
|
548
|
-
#
|
|
549
|
-
#
|
|
550
|
-
#
|
|
551
|
-
#
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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.
|
|
568
|
-
return ALL_SCOPES if TypedEAV.unscoped?
|
|
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
|
-
|
|
601
|
-
# Models that did NOT opt into scoping must NOT see ambient scope.
|
|
602
|
-
# If the host declared `has_typed_eav` without `scope_method:`, it
|
|
603
|
-
# has no per-instance scope accessor, so `Value#validate_field_scope_matches_entity`
|
|
604
|
-
# would reject any attempt to attach a scoped field anyway. Honoring
|
|
605
|
-
# ambient scope here would surface scoped field definitions that the
|
|
606
|
-
# model can never actually use — confusing in admin/forms — and would
|
|
607
|
-
# leak cross-model ambient state into a model that never opted in.
|
|
608
|
-
# An explicit `scope:` kwarg (handled above) still overrides this, so
|
|
609
|
-
# admin/test paths retain the ability to query arbitrary scopes.
|
|
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.
|
|
621
|
-
resolved = TypedEAV.current_scope
|
|
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]
|
|
633
|
-
end
|
|
634
|
-
|
|
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
|
|
638
|
-
end
|
|
639
|
-
end
|
|
640
|
-
|
|
641
|
-
# ──────────────────────────────────────────────────
|
|
642
|
-
# Instance methods
|
|
643
|
-
# ──────────────────────────────────────────────────
|
|
644
|
-
module InstanceMethods
|
|
645
|
-
# The field definitions available for this record
|
|
646
|
-
def typed_eav_definitions
|
|
647
|
-
self.class.typed_eav_definitions(
|
|
648
|
-
scope: typed_eav_scope,
|
|
649
|
-
parent_scope: typed_eav_parent_scope,
|
|
650
|
-
)
|
|
651
|
-
end
|
|
652
|
-
|
|
653
|
-
# Current scope value (for multi-tenant)
|
|
654
|
-
def typed_eav_scope
|
|
655
|
-
return nil unless self.class.typed_eav_scope_method
|
|
656
|
-
|
|
657
|
-
send(self.class.typed_eav_scope_method)&.to_s
|
|
658
|
-
end
|
|
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
|
-
|
|
673
|
-
# Build missing values with defaults for all available fields.
|
|
674
|
-
# Useful in forms to show all fields even when no value exists yet.
|
|
675
|
-
#
|
|
676
|
-
# Iterates the collision-collapsed view (`typed_eav_defs_by_name`)
|
|
677
|
-
# rather than the raw definitions list. Otherwise, when a record's
|
|
678
|
-
# scope partition has both a global (scope=NULL) and a same-name
|
|
679
|
-
# scoped field, `for_entity` returns BOTH rows and the form would
|
|
680
|
-
# render two inputs for the same name — but only the scoped one
|
|
681
|
-
# round-trips on save (it wins in `typed_eav_defs_by_name`).
|
|
682
|
-
def initialize_typed_values
|
|
683
|
-
existing_field_ids = typed_values.loaded? ? typed_values.map(&:field_id) : typed_values.pluck(:field_id)
|
|
684
|
-
|
|
685
|
-
typed_eav_defs_by_name.each_value do |field|
|
|
686
|
-
next if existing_field_ids.include?(field.id)
|
|
687
|
-
|
|
688
|
-
typed_values.build(field: field, value: field.default_value)
|
|
689
|
-
end
|
|
690
|
-
|
|
691
|
-
typed_values
|
|
692
|
-
end
|
|
693
|
-
|
|
694
|
-
# Bulk assign values by field NAME. Coexists with (rather than replaces)
|
|
695
|
-
# the `accepts_nested_attributes_for :typed_values` setter declared above,
|
|
696
|
-
# which accepts entries keyed by field ID.
|
|
697
|
-
#
|
|
698
|
-
# Why both exist:
|
|
699
|
-
#
|
|
700
|
-
# * The nested-attributes setter (`typed_values_attributes=`) is the
|
|
701
|
-
# standard Rails form contract. HTML form builders emit `field_id`
|
|
702
|
-
# as a hidden input per value row, so when a form posts back, the
|
|
703
|
-
# params look like:
|
|
704
|
-
# { typed_values_attributes: [
|
|
705
|
-
# { id: 12, field_id: 4, value: "40" }, ...
|
|
706
|
-
# ] }
|
|
707
|
-
# `accepts_nested_attributes_for` matches existing values by `id`.
|
|
708
|
-
#
|
|
709
|
-
# * This setter (`typed_eav_attributes=` / `typed_eav=`) takes
|
|
710
|
-
# entries keyed by field *name* and translates them to field IDs
|
|
711
|
-
# before handing off to the nested-attributes setter. It also
|
|
712
|
-
# enforces the `types:` restriction declared on `has_typed_eav`
|
|
713
|
-
# (rejecting entries for disallowed field types) and supports
|
|
714
|
-
# `_destroy: true` for removing a value by name. This is the
|
|
715
|
-
# ergonomic path for console/seed code:
|
|
716
|
-
# record.typed_eav_attributes = [
|
|
717
|
-
# { name: "age", value: 30 },
|
|
718
|
-
# { name: "email", value: "test@example.com" },
|
|
719
|
-
# { name: "old_field", _destroy: true },
|
|
720
|
-
# ]
|
|
721
|
-
#
|
|
722
|
-
# Pick the one that fits: forms -> typed_values_attributes=, scripting
|
|
723
|
-
# -> typed_eav_attributes=. They can't both run in the same save.
|
|
724
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
725
|
-
|
|
726
|
-
# rubocop:disable Metrics/AbcSize -- branches on existing/new/destroy and type-restriction in one place; splitting would obscure the precedence rules.
|
|
727
|
-
def typed_eav_attributes=(attributes)
|
|
728
|
-
attributes = attributes.to_h if attributes.respond_to?(:permitted?)
|
|
729
|
-
attributes = attributes.values if attributes.is_a?(Hash)
|
|
730
|
-
attributes = Array(attributes)
|
|
731
|
-
|
|
732
|
-
fields_by_name = typed_eav_defs_by_name
|
|
733
|
-
values_by_field_id = typed_values.index_by(&:field_id)
|
|
734
|
-
|
|
735
|
-
nested = attributes.filter_map do |attrs|
|
|
736
|
-
attrs = attrs.to_h.with_indifferent_access
|
|
737
|
-
|
|
738
|
-
field = fields_by_name[attrs[:name]]
|
|
739
|
-
next unless field
|
|
740
|
-
|
|
741
|
-
# Enforce type restrictions. Normalized to strings at registration
|
|
742
|
-
# time (see `has_typed_eav`), so no per-call mapping.
|
|
743
|
-
allowed = self.class.allowed_typed_eav_types
|
|
744
|
-
next if allowed&.exclude?(field.field_type_name)
|
|
745
|
-
|
|
746
|
-
existing = values_by_field_id[field.id]
|
|
747
|
-
|
|
748
|
-
if ActiveRecord::Type::Boolean.new.cast(attrs[:_destroy])
|
|
749
|
-
{ id: existing&.id, _destroy: true }
|
|
750
|
-
elsif existing
|
|
751
|
-
{ id: existing.id, value: attrs[:value] }
|
|
752
|
-
else
|
|
753
|
-
typed_values.build(field: field, value: attrs[:value])
|
|
754
|
-
nil # build already added it, skip nested_attributes
|
|
755
|
-
end
|
|
756
|
-
end.compact
|
|
757
|
-
|
|
758
|
-
self.typed_values_attributes = nested if nested.any?
|
|
759
|
-
end
|
|
760
|
-
|
|
761
|
-
# rubocop:enable Metrics/AbcSize
|
|
762
|
-
alias typed_eav= typed_eav_attributes=
|
|
763
|
-
|
|
764
|
-
# Get a specific field's value by name. Honors an already-loaded
|
|
765
|
-
# `typed_values` association so list-page callers that preloaded
|
|
766
|
-
# `typed_values: :field` don't trigger a fresh query per record.
|
|
767
|
-
#
|
|
768
|
-
# On a global+scoped name collision, prefer the value bound to the
|
|
769
|
-
# winning field_id (scoped wins). Without this guard, a stray value
|
|
770
|
-
# row attached to a shadowed global field would surface here even
|
|
771
|
-
# though writes route through the scoped winner.
|
|
772
|
-
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity -- name-collision precedence + orphan guard + already-loaded preload reuse.
|
|
773
|
-
def typed_eav_value(name)
|
|
774
|
-
winning = typed_eav_defs_by_name[name.to_s]
|
|
775
|
-
# Skip orphans (`v.field` nil — definition deleted out from under the
|
|
776
|
-
# value via raw SQL or a missing FK cascade) so a stray row can't
|
|
777
|
-
# crash the read path with NoMethodError.
|
|
778
|
-
candidates = loaded_typed_values_with_fields.select { |v| v.field && v.field.name == name.to_s }
|
|
779
|
-
tv = if winning && candidates.any? { |v| (v.field_id || v.field&.id) == winning.id }
|
|
780
|
-
candidates.detect { |v| (v.field_id || v.field&.id) == winning.id }
|
|
781
|
-
else
|
|
782
|
-
candidates.first
|
|
783
|
-
end
|
|
784
|
-
tv&.value
|
|
785
|
-
end
|
|
786
|
-
# rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
|
|
787
|
-
|
|
788
|
-
# Set a specific field's value by name
|
|
789
|
-
def set_typed_eav_value(name, value)
|
|
790
|
-
field = typed_eav_defs_by_name[name.to_s]
|
|
791
|
-
return unless field
|
|
792
|
-
|
|
793
|
-
existing = typed_values.detect { |v| v.field_id == field.id }
|
|
794
|
-
if existing
|
|
795
|
-
existing.value = value
|
|
796
|
-
else
|
|
797
|
-
typed_values.build(field: field, value: value)
|
|
798
|
-
end
|
|
799
|
-
end
|
|
800
|
-
|
|
801
|
-
# Hash of all field values: { "field_name" => value, ... }. Same
|
|
802
|
-
# preload semantics as `typed_eav_value` — respects already-loaded
|
|
803
|
-
# associations instead of rebuilding the relation.
|
|
804
|
-
#
|
|
805
|
-
# Collision-safe: on a global+scoped name overlap, the value attached
|
|
806
|
-
# to the winning field_id wins (scoped). Without this guard, a stray
|
|
807
|
-
# row tied to a shadowed global field could surface here even though
|
|
808
|
-
# writes route through the scoped winner.
|
|
809
|
-
def typed_eav_hash
|
|
810
|
-
winning_ids_by_name = typed_eav_defs_by_name.transform_values(&:id)
|
|
811
|
-
rows = loaded_typed_values_with_fields
|
|
812
|
-
|
|
813
|
-
rows.each_with_object({}) do |tv, hash|
|
|
814
|
-
# Skip orphans (`tv.field` nil — definition deleted out from under
|
|
815
|
-
# the value) so the hash isn't crashy when stale rows linger.
|
|
816
|
-
next unless tv.field
|
|
817
|
-
|
|
818
|
-
name = tv.field.name
|
|
819
|
-
winning_id = winning_ids_by_name[name]
|
|
820
|
-
effective_id = tv.field_id || tv.field&.id
|
|
821
|
-
|
|
822
|
-
# A winner is registered for this name: only its row is allowed.
|
|
823
|
-
# If no winner is registered (definition deleted while values
|
|
824
|
-
# remain), fall back to first-wins so the hash isn't lossy.
|
|
825
|
-
if winning_id
|
|
826
|
-
hash[name] = tv.value if effective_id == winning_id
|
|
827
|
-
else
|
|
828
|
-
hash[name] = tv.value unless hash.key?(name)
|
|
829
|
-
end
|
|
830
|
-
end
|
|
831
|
-
end
|
|
832
|
-
|
|
833
|
-
private
|
|
834
|
-
|
|
835
|
-
# Returns typed_values with their fields, preferring already-loaded
|
|
836
|
-
# associations. Callers on list pages should preload with
|
|
837
|
-
# `includes(typed_values: :field)`; this method keeps the happy path
|
|
838
|
-
# fast without forcing that contract.
|
|
839
|
-
def loaded_typed_values_with_fields
|
|
840
|
-
if typed_values.loaded?
|
|
841
|
-
# Don't re-query if the caller already preloaded; ensure each value's
|
|
842
|
-
# field is materialized (fall back to per-row load if the nested
|
|
843
|
-
# `:field` was not preloaded).
|
|
844
|
-
typed_values.to_a
|
|
845
|
-
else
|
|
846
|
-
typed_values.includes(:field).to_a
|
|
847
|
-
end
|
|
848
|
-
end
|
|
849
|
-
|
|
850
|
-
# Field definitions indexed by name with deterministic collision handling:
|
|
851
|
-
# when both a global (scope=NULL) and a scoped field share a name, the
|
|
852
|
-
# scoped definition wins. Delegates to `HasTypedEAV.definitions_by_name`
|
|
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.
|
|
870
|
-
def typed_eav_defs_by_name
|
|
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
|
|
101
|
+
# Macro-time configuration guard. Failing fast at class-load time is
|
|
102
|
+
# strictly better than at query time because the misconfiguration is
|
|
103
|
+
# static (a property of the macro call, not of the request). Closes
|
|
104
|
+
# the silent dead-letter mode that would otherwise route every
|
|
105
|
+
# parent-scope-aware query to the global-only branch.
|
|
106
|
+
def validate_has_typed_eav_options!(scope_method, parent_scope_method)
|
|
107
|
+
return unless parent_scope_method && !scope_method
|
|
108
|
+
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"has_typed_eav: `parent_scope_method:` requires `scope_method:` to also be set. " \
|
|
111
|
+
"A model declaring parent_scope without scope is a configuration error — " \
|
|
112
|
+
"ambient resolution would silently return [nil, nil] and queries would dead-letter. " \
|
|
113
|
+
"Either add `scope_method: :your_scope_method` or remove `parent_scope_method:`."
|
|
878
114
|
end
|
|
879
115
|
end
|
|
880
116
|
end
|