typed_eav 0.2.1 → 0.3.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 +110 -0
- data/README.md +165 -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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
# Multi-filter query orchestrator. Given a model + a list of filter hashes
|
|
5
|
+
# + an already-resolved scope tuple (or `EntityQuery::ALL_SCOPES`), composes
|
|
6
|
+
# an `ActiveRecord::Relation` by fanning out across filters and dispatching
|
|
7
|
+
# each per-filter predicate to `TypedEAV::QueryBuilder`.
|
|
8
|
+
#
|
|
9
|
+
# FilterQuery.new(
|
|
10
|
+
# model: Contact,
|
|
11
|
+
# filters: [{ name: "age", op: :gt, value: 21 }],
|
|
12
|
+
# scope: "t1",
|
|
13
|
+
# parent_scope: nil,
|
|
14
|
+
# ).to_relation
|
|
15
|
+
#
|
|
16
|
+
# ## Two altitudes (ADR-0002)
|
|
17
|
+
#
|
|
18
|
+
# `QueryBuilder` stays the per-field SQL primitive (knows nothing about
|
|
19
|
+
# multi-filter composition or scope collision). `FilterQuery` is the
|
|
20
|
+
# orchestrator that knows about input shape, partition lookup, collision
|
|
21
|
+
# precedence, and per-filter chaining.
|
|
22
|
+
#
|
|
23
|
+
# ## Scope shape
|
|
24
|
+
#
|
|
25
|
+
# `scope:` is either `EntityQuery::ALL_SCOPES` (atomic-bypass under
|
|
26
|
+
# `TypedEAV.unscoped { }` — the multimap branch) or a `String | nil`
|
|
27
|
+
# already-resolved scope value. `parent_scope:` is `String | nil`. Scope
|
|
28
|
+
# resolution and sentinel handling live in `EntityQuery#resolve_scope`;
|
|
29
|
+
# this class works on resolved tuples only.
|
|
30
|
+
class FilterQuery
|
|
31
|
+
FILTER_KEYS = %w[name n op operator value v].freeze
|
|
32
|
+
private_constant :FILTER_KEYS
|
|
33
|
+
|
|
34
|
+
def initialize(model:, filters:, scope:, parent_scope:)
|
|
35
|
+
@model = model
|
|
36
|
+
@raw_filters = filters
|
|
37
|
+
@scope = scope
|
|
38
|
+
@parent_scope = parent_scope
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_relation
|
|
42
|
+
filters = normalize_filters(@raw_filters)
|
|
43
|
+
defs = lookup_definitions
|
|
44
|
+
|
|
45
|
+
if all_scopes?
|
|
46
|
+
apply_multimap_filters(filters, TypedEAV::Partition.definitions_multimap_by_name(defs))
|
|
47
|
+
else
|
|
48
|
+
apply_single_scope_filters(filters, TypedEAV::Partition.definitions_by_name(defs))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
attr_reader :model
|
|
55
|
+
|
|
56
|
+
# ── Input normalization ─────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
# Accepts splat args, a single Array, a single filter Hash, a hash-of-
|
|
59
|
+
# hashes (form params), or `ActionController::Parameters`. Returns an
|
|
60
|
+
# Array of plain Hashes (each a filter spec with :name/:n, :op/:operator,
|
|
61
|
+
# :value/:v keys).
|
|
62
|
+
def normalize_filters(filters)
|
|
63
|
+
flattened = filters.map { |f| coerce_to_h(f) }
|
|
64
|
+
flattened = expand_single_argument(flattened) if flattened.size == 1
|
|
65
|
+
Array(flattened)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def coerce_to_h(filter)
|
|
69
|
+
filter.respond_to?(:to_unsafe_h) ? filter.to_unsafe_h : filter
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def expand_single_argument(filters)
|
|
73
|
+
inner = coerce_to_h(filters.first)
|
|
74
|
+
return inner if inner.is_a?(Array)
|
|
75
|
+
return [inner] unless inner.is_a?(Hash)
|
|
76
|
+
return [inner] if filter_hash?(inner)
|
|
77
|
+
|
|
78
|
+
inner.values
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def filter_hash?(hash)
|
|
82
|
+
hash.keys.any? { |k| FILTER_KEYS.include?(k.to_s) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# ── Partition lookup ───────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def all_scopes?
|
|
88
|
+
@scope.equal?(TypedEAV::EntityQuery::ALL_SCOPES)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def lookup_definitions
|
|
92
|
+
if all_scopes?
|
|
93
|
+
TypedEAV::Partition.visible_fields(entity_type: model.name, mode: :all_partitions)
|
|
94
|
+
else
|
|
95
|
+
TypedEAV::Partition.visible_fields(
|
|
96
|
+
entity_type: model.name,
|
|
97
|
+
scope: @scope,
|
|
98
|
+
parent_scope: @parent_scope,
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# ── Filter dispatch ────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def apply_single_scope_filters(filters, fields_by_name)
|
|
106
|
+
filters.inject(model.all) do |query, filter|
|
|
107
|
+
spec = parse_filter(filter)
|
|
108
|
+
field = fields_by_name[spec[:name]] || raise_unknown_field(spec[:name], fields_by_name.keys)
|
|
109
|
+
matching_ids = TypedEAV::QueryBuilder.entity_ids(field, spec[:operator], spec[:value])
|
|
110
|
+
query.where(id: matching_ids)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def apply_multimap_filters(filters, fields_multimap)
|
|
115
|
+
filters.inject(model.all) do |query, filter|
|
|
116
|
+
spec = parse_filter(filter)
|
|
117
|
+
fields = fields_multimap[spec[:name]]
|
|
118
|
+
raise_unknown_field(spec[:name], fields_multimap.keys) unless fields&.any?
|
|
119
|
+
|
|
120
|
+
union_ids = union_entity_ids(fields, spec[:operator], spec[:value])
|
|
121
|
+
query.where(id: union_ids)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# OR-across all field_ids that share the same name (across tenants),
|
|
126
|
+
# while preserving AND between filters via the chained `.where`. Use the
|
|
127
|
+
# underlying Value scope (`.filter`) and `pluck(:entity_id)` to collapse
|
|
128
|
+
# to a plain integer array we can union across tenants.
|
|
129
|
+
def union_entity_ids(fields, operator, value)
|
|
130
|
+
fields.flat_map { |f| TypedEAV::QueryBuilder.filter(f, operator, value).pluck(:entity_id) }.uniq
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parse_filter(filter)
|
|
134
|
+
h = filter.to_h.with_indifferent_access
|
|
135
|
+
{
|
|
136
|
+
name: (h[:n] || h[:name]).to_s,
|
|
137
|
+
operator: (h[:op] || h[:operator] || :eq).to_sym,
|
|
138
|
+
value: h.key?(:v) ? h[:v] : h[:value],
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def raise_unknown_field(name, available)
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"Unknown typed field '#{name}' for #{model.name}. " \
|
|
145
|
+
"Available fields: #{available.join(", ")}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module HasTypedEAV
|
|
5
|
+
# Per-record API mixed into host AR models by the `has_typed_eav` macro.
|
|
6
|
+
# Reads/writes typed values via field name, returns scope/parent_scope
|
|
7
|
+
# via the configured accessor methods, and builds the collision-collapsed
|
|
8
|
+
# per-instance definition map (delegating to `Partition.definitions_by_name`
|
|
9
|
+
# so the class-query path and the instance path share one source of truth).
|
|
10
|
+
module InstanceMethods
|
|
11
|
+
# The field definitions available for this record
|
|
12
|
+
def typed_eav_definitions
|
|
13
|
+
self.class.typed_eav_definitions(
|
|
14
|
+
scope: typed_eav_scope,
|
|
15
|
+
parent_scope: typed_eav_parent_scope,
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Current scope value (for multi-tenant)
|
|
20
|
+
def typed_eav_scope
|
|
21
|
+
return nil unless self.class.typed_eav_scope_method
|
|
22
|
+
|
|
23
|
+
send(self.class.typed_eav_scope_method)&.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Current parent_scope value (for two-level partitioning).
|
|
27
|
+
#
|
|
28
|
+
# Returns nil for models that did not declare `parent_scope_method:` —
|
|
29
|
+
# the method is defined unconditionally so callers (e.g. the Value-side
|
|
30
|
+
# cross-axis validator) can `respond_to?` and read uniformly without
|
|
31
|
+
# branching on `parent_scope_method` configuration. Mirrors the
|
|
32
|
+
# `&.to_s` normalization on `typed_eav_scope`.
|
|
33
|
+
def typed_eav_parent_scope
|
|
34
|
+
return nil unless self.class.typed_eav_parent_scope_method
|
|
35
|
+
|
|
36
|
+
send(self.class.typed_eav_parent_scope_method)&.to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Build missing values with defaults for all available fields.
|
|
40
|
+
# Useful in forms to show all fields even when no value exists yet.
|
|
41
|
+
#
|
|
42
|
+
# Iterates the collision-collapsed view (`typed_eav_defs_by_name`)
|
|
43
|
+
# rather than the raw definitions list. Otherwise, when a record's
|
|
44
|
+
# scope partition has both a global (scope=NULL) and a same-name
|
|
45
|
+
# scoped field, `for_entity` returns BOTH rows and the form would
|
|
46
|
+
# render two inputs for the same name — but only the scoped one
|
|
47
|
+
# round-trips on save (it wins in `typed_eav_defs_by_name`).
|
|
48
|
+
def initialize_typed_values
|
|
49
|
+
existing_field_ids = typed_values.loaded? ? typed_values.map(&:field_id) : typed_values.pluck(:field_id)
|
|
50
|
+
|
|
51
|
+
typed_eav_defs_by_name.each_value do |field|
|
|
52
|
+
next if existing_field_ids.include?(field.id)
|
|
53
|
+
|
|
54
|
+
typed_values.build(field: field, value: field.default_value)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
typed_values
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Bulk assign values by field NAME. Coexists with (rather than replaces)
|
|
61
|
+
# the `accepts_nested_attributes_for :typed_values` setter declared
|
|
62
|
+
# on the host model, which accepts entries keyed by field ID.
|
|
63
|
+
#
|
|
64
|
+
# The nested-attributes setter is the standard Rails form contract
|
|
65
|
+
# (forms post `field_id` as a hidden input per value row). This setter
|
|
66
|
+
# takes entries keyed by field *name* and translates them to field
|
|
67
|
+
# IDs before handing off to the nested-attributes setter. It also
|
|
68
|
+
# enforces the `types:` restriction declared on `has_typed_eav` and
|
|
69
|
+
# supports `_destroy: true` for removing a value by name.
|
|
70
|
+
#
|
|
71
|
+
# record.typed_eav_attributes = [
|
|
72
|
+
# { name: "age", value: 30 },
|
|
73
|
+
# { name: "email", value: "test@example.com" },
|
|
74
|
+
# { name: "old_field", _destroy: true },
|
|
75
|
+
# ]
|
|
76
|
+
#
|
|
77
|
+
# Pick the one that fits: forms -> typed_values_attributes=, scripting
|
|
78
|
+
# -> typed_eav_attributes=. They can't both run in the same save.
|
|
79
|
+
def typed_eav_attributes=(attributes)
|
|
80
|
+
fields_by_name = typed_eav_defs_by_name
|
|
81
|
+
values_by_field_id = typed_values.index_by(&:field_id)
|
|
82
|
+
|
|
83
|
+
nested = normalize_typed_eav_attributes(attributes).filter_map do |attrs|
|
|
84
|
+
build_or_update_typed_value(attrs, fields_by_name, values_by_field_id)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
self.typed_values_attributes = nested if nested.any?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
alias typed_eav= typed_eav_attributes=
|
|
91
|
+
|
|
92
|
+
# Get a specific field's value by name. Honors an already-loaded
|
|
93
|
+
# `typed_values` association so list-page callers that preloaded
|
|
94
|
+
# `typed_values: :field` don't trigger a fresh query per record.
|
|
95
|
+
#
|
|
96
|
+
# On a global+scoped name collision, prefer the value bound to the
|
|
97
|
+
# winning field_id (scoped wins). Without this guard, a stray value
|
|
98
|
+
# row attached to a shadowed global field would surface here even
|
|
99
|
+
# though writes route through the scoped winner.
|
|
100
|
+
def typed_eav_value(name)
|
|
101
|
+
winning = typed_eav_defs_by_name[name.to_s]
|
|
102
|
+
# Skip orphans (`v.field` nil — definition deleted out from under
|
|
103
|
+
# the value via raw SQL or a missing FK cascade) so a stray row
|
|
104
|
+
# can't crash the read path with NoMethodError.
|
|
105
|
+
candidates = loaded_typed_values_with_fields.select { |v| v.field && v.field.name == name.to_s }
|
|
106
|
+
select_winning_value(candidates, winning)&.value
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Set a specific field's value by name
|
|
110
|
+
def set_typed_eav_value(name, value)
|
|
111
|
+
field = typed_eav_defs_by_name[name.to_s]
|
|
112
|
+
return unless field
|
|
113
|
+
|
|
114
|
+
existing = typed_values.detect { |v| v.field_id == field.id }
|
|
115
|
+
if existing
|
|
116
|
+
existing.value = value
|
|
117
|
+
else
|
|
118
|
+
typed_values.build(field: field, value: value)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Hash of all field values: { "field_name" => value, ... }. Same
|
|
123
|
+
# preload semantics as `typed_eav_value` — respects already-loaded
|
|
124
|
+
# associations instead of rebuilding the relation.
|
|
125
|
+
#
|
|
126
|
+
# Collision-safe: on a global+scoped name overlap, the value attached
|
|
127
|
+
# to the winning field_id wins (scoped). Without this guard, a stray
|
|
128
|
+
# row tied to a shadowed global field could surface here even though
|
|
129
|
+
# writes route through the scoped winner.
|
|
130
|
+
def typed_eav_hash
|
|
131
|
+
winning_ids_by_name = typed_eav_defs_by_name.transform_values(&:id)
|
|
132
|
+
|
|
133
|
+
loaded_typed_values_with_fields.each_with_object({}) do |tv, hash|
|
|
134
|
+
# Skip orphans (`tv.field` nil — definition deleted out from under
|
|
135
|
+
# the value) so the hash isn't crashy when stale rows linger.
|
|
136
|
+
next unless tv.field
|
|
137
|
+
|
|
138
|
+
assign_hash_value(hash, tv, winning_ids_by_name)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Selects the candidate value for `typed_eav_value`. On a collision,
|
|
145
|
+
# prefer the row attached to the winning field_id; otherwise fall back
|
|
146
|
+
# to the first orphan/non-collision candidate.
|
|
147
|
+
def select_winning_value(candidates, winning)
|
|
148
|
+
return candidates.first unless winning
|
|
149
|
+
|
|
150
|
+
candidates.detect { |v| (v.field_id || v.field&.id) == winning.id } || candidates.first
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Hash-builder helper for `typed_eav_hash`. When a winner is registered
|
|
154
|
+
# for the name, only its row may surface (scoped-beats-global). When
|
|
155
|
+
# no winner is registered (definition deleted while values remain),
|
|
156
|
+
# fall back to first-wins so the hash isn't lossy.
|
|
157
|
+
def assign_hash_value(hash, value_row, winning_ids_by_name)
|
|
158
|
+
name = value_row.field.name
|
|
159
|
+
winning_id = winning_ids_by_name[name]
|
|
160
|
+
effective_id = value_row.field_id || value_row.field&.id
|
|
161
|
+
|
|
162
|
+
if winning_id
|
|
163
|
+
hash[name] = value_row.value if effective_id == winning_id
|
|
164
|
+
else
|
|
165
|
+
hash[name] = value_row.value unless hash.key?(name)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Normalize the input to `typed_eav_attributes=` into an Array of
|
|
170
|
+
# plain Hashes. Accepts ActionController::Parameters, hash-of-hashes
|
|
171
|
+
# (form params), Array, or any Enumerable.
|
|
172
|
+
def normalize_typed_eav_attributes(attributes)
|
|
173
|
+
attributes = attributes.to_h if attributes.respond_to?(:permitted?)
|
|
174
|
+
attributes = attributes.values if attributes.is_a?(Hash)
|
|
175
|
+
Array(attributes)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Translate a single name-keyed attribute hash into the corresponding
|
|
179
|
+
# nested-attributes entry (id-keyed), or builds a new value row in-place.
|
|
180
|
+
# Returns nil when the field is unknown, the field type is excluded by
|
|
181
|
+
# the host's `types:` restriction, or when the new-row path already
|
|
182
|
+
# added to the association (no nested-attributes entry needed).
|
|
183
|
+
def build_or_update_typed_value(attrs, fields_by_name, values_by_field_id)
|
|
184
|
+
attrs = attrs.to_h.with_indifferent_access
|
|
185
|
+
field = fields_by_name[attrs[:name]]
|
|
186
|
+
return nil unless field
|
|
187
|
+
return nil if typed_eav_type_disallowed?(field)
|
|
188
|
+
|
|
189
|
+
existing = values_by_field_id[field.id]
|
|
190
|
+
return { id: existing&.id, _destroy: true } if destroy_flag?(attrs)
|
|
191
|
+
return { id: existing.id, value: attrs[:value] } if existing
|
|
192
|
+
|
|
193
|
+
typed_values.build(field: field, value: attrs[:value])
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def typed_eav_type_disallowed?(field)
|
|
198
|
+
allowed = self.class.allowed_typed_eav_types
|
|
199
|
+
allowed&.exclude?(field.field_type_name)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def destroy_flag?(attrs)
|
|
203
|
+
ActiveRecord::Type::Boolean.new.cast(attrs[:_destroy])
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Returns typed_values with their fields, preferring already-loaded
|
|
207
|
+
# associations. Callers on list pages should preload with
|
|
208
|
+
# `includes(typed_values: :field)`; this method keeps the happy path
|
|
209
|
+
# fast without forcing that contract.
|
|
210
|
+
def loaded_typed_values_with_fields
|
|
211
|
+
if typed_values.loaded?
|
|
212
|
+
# Don't re-query if the caller already preloaded; ensure each value's
|
|
213
|
+
# field is materialized (fall back to per-row load if the nested
|
|
214
|
+
# `:field` was not preloaded).
|
|
215
|
+
typed_values.to_a
|
|
216
|
+
else
|
|
217
|
+
typed_values.includes(:field).to_a
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Field definitions indexed by name with deterministic collision
|
|
222
|
+
# handling: when both a global (scope=NULL) and a scoped field share
|
|
223
|
+
# a name, the most-specific (scoped) definition wins. Delegates to
|
|
224
|
+
# `TypedEAV::Partition.definitions_by_name` so the class-query path
|
|
225
|
+
# and the instance path share one source of truth.
|
|
226
|
+
#
|
|
227
|
+
# ## Bulk-write memoization (Phase 06 plan 05)
|
|
228
|
+
#
|
|
229
|
+
# `bulk_set_typed_eav_values` sets `Thread.current[:typed_eav_bulk_defs_memo]`
|
|
230
|
+
# to a Hash before its records loop. We consult it here so the per-
|
|
231
|
+
# record `typed_eav_attributes=` call does NOT issue a fresh
|
|
232
|
+
# `typed_eav_definitions` SELECT per record. AR's per-block query
|
|
233
|
+
# cache (`ActiveRecord::Base.cache`) is invalidated by every write —
|
|
234
|
+
# because each record's INSERT clears the cache — so cache-do alone
|
|
235
|
+
# cannot keep field-definition reads N+1-free across the bulk loop.
|
|
236
|
+
# The thread-local memo is the explicit fallback documented in plan
|
|
237
|
+
# 06-05 §T3 notes; it pre-warms once per `[host_class, scope,
|
|
238
|
+
# parent_scope]` tuple and reuses across every record in that tuple.
|
|
239
|
+
#
|
|
240
|
+
# Outside a bulk operation the memo is nil and we fall through to
|
|
241
|
+
# the standard read path — zero overhead.
|
|
242
|
+
def typed_eav_defs_by_name
|
|
243
|
+
memo = Thread.current[:typed_eav_bulk_defs_memo]
|
|
244
|
+
if memo
|
|
245
|
+
key = [self.class.name, typed_eav_scope, typed_eav_parent_scope]
|
|
246
|
+
memo[key] ||= TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
|
|
247
|
+
else
|
|
248
|
+
TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|