typed_eav 0.7.0 → 0.8.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 +94 -2
- data/README.md +223 -3
- data/lib/typed_eav/bulk_read.rb +146 -58
- data/lib/typed_eav/bulk_upsert.rb +10 -14
- data/lib/typed_eav/entity_query.rb +156 -4
- data/lib/typed_eav/filter_query.rb +2 -2
- data/lib/typed_eav/has_typed_eav/dirty_tracking.rb +207 -0
- data/lib/typed_eav/has_typed_eav/instance_methods.rb +2 -2
- data/lib/typed_eav/has_typed_eav.rb +7 -3
- data/lib/typed_eav/partition/definition_batch.rb +70 -0
- data/lib/typed_eav/partition.rb +2 -0
- data/lib/typed_eav/scalar_query.rb +228 -0
- data/lib/typed_eav/schema_portability/preview.rb +379 -0
- data/lib/typed_eav/schema_portability.rb +20 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioned.rb +8 -6
- data/lib/typed_eav.rb +1 -0
- metadata +5 -1
data/lib/typed_eav/bulk_read.rb
CHANGED
|
@@ -6,58 +6,119 @@ module TypedEAV
|
|
|
6
6
|
# `HasTypedEAV::InstanceMethods#typed_eav_hash`. N+1-free regardless of
|
|
7
7
|
# record count or field count.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
9
|
+
# `fields:` is an optional projection of field names. A nil selection keeps
|
|
10
|
+
# the all-fields behavior; a non-nil selection is normalized to unique
|
|
11
|
+
# strings, and only the selected winning definitions' values are loaded.
|
|
12
|
+
# Unknown names are ignored, as are names that are not defined for a
|
|
13
|
+
# particular record's partition tuple. An empty selection returns one empty
|
|
14
|
+
# inner hash per record without querying definitions or values.
|
|
15
|
+
#
|
|
16
|
+
# `source: :database` (the default) always reads a fresh Value graph. The
|
|
17
|
+
# explicit `source: :preloaded` mode projects only the already-loaded
|
|
18
|
+
# `typed_values` targets and their loaded `field` associations; it never
|
|
19
|
+
# falls back to an association query. Incomplete preload requirements raise
|
|
20
|
+
# `ArgumentError` before projection. The preloaded mode is therefore a
|
|
21
|
+
# snapshot of caller-owned in-memory records and can include unsaved Value
|
|
22
|
+
# assignments; it never saves or mutates those records.
|
|
23
|
+
#
|
|
24
|
+
# ## Pipeline
|
|
10
25
|
#
|
|
11
26
|
# 1. validate_records! — nil -> ArgumentError; single-class invariant
|
|
12
27
|
# 2. group_by_tuple — `[typed_eav_scope, typed_eav_parent_scope]`
|
|
13
|
-
# 3. winning_ids_by_tuple — one
|
|
14
|
-
#
|
|
15
|
-
# 4.
|
|
28
|
+
# 3. winning_ids_by_tuple — one `Partition::DefinitionBatch` query, then
|
|
29
|
+
# extract the winning field ids per tuple
|
|
30
|
+
# 4. load_values — fresh `:database` SELECT across ALL records,
|
|
31
|
+
# restricted to selected field IDs when
|
|
32
|
+
# `fields:` is used; or `:preloaded` association
|
|
33
|
+
# targets after a strict loaded-state check
|
|
16
34
|
# 5. build_result_hash — per-record inner hash; orphan-skip + winning-id
|
|
17
35
|
# precedence mirrored from the instance path.
|
|
18
36
|
#
|
|
19
37
|
# ## Query bound
|
|
20
38
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
39
|
+
# Database mode performs up to three SELECTs — one bulk Value query, one
|
|
40
|
+
# field-association query for those values, and one definition query for the
|
|
41
|
+
# union of requested partitions. The preloaded mode performs one fresh
|
|
42
|
+
# definition SELECT and reads Value/field association targets in memory;
|
|
43
|
+
# `fields: []` bypasses even that definition query. Both bounds are
|
|
44
|
+
# independent of record count and partition cardinality.
|
|
26
45
|
#
|
|
27
46
|
# ## Single-class invariant
|
|
28
47
|
#
|
|
29
|
-
# The polymorphic value query
|
|
30
|
-
#
|
|
31
|
-
# subclasses pass
|
|
48
|
+
# The polymorphic value query targets the host's canonical Rails
|
|
49
|
+
# `polymorphic_name`, so an STI subclass reads rows stored for its base
|
|
50
|
+
# class. Mixed, unrelated input would still be invalid; STI subclasses pass
|
|
51
|
+
# via `records.all?(host_class)`.
|
|
32
52
|
class BulkRead
|
|
33
|
-
def initialize(host_class:, records:)
|
|
53
|
+
def initialize(host_class:, records:, fields: nil, source: :database)
|
|
34
54
|
@host_class = host_class
|
|
35
55
|
@records = records
|
|
56
|
+
@fields = fields
|
|
57
|
+
@source = source
|
|
36
58
|
end
|
|
37
59
|
|
|
38
60
|
def to_hash
|
|
61
|
+
validate_source!
|
|
62
|
+
|
|
39
63
|
records = coerce_records
|
|
40
64
|
return {} if records.empty?
|
|
41
65
|
|
|
42
66
|
validate_record_classes!(records)
|
|
43
67
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
values_by_record_id = preload_values(records)
|
|
68
|
+
selected_names = normalize_fields
|
|
69
|
+
return empty_results(records) if selected_names == []
|
|
47
70
|
|
|
48
|
-
|
|
71
|
+
tuples_by_record = group_by_tuple(records)
|
|
72
|
+
winning_ids_by_tuple = winning_ids_by_tuple(tuples_by_record.values.uniq, selected_names)
|
|
73
|
+
values_by_record_id = if @source == :preloaded
|
|
74
|
+
preloaded_values(records, tuples_by_record, winning_ids_by_tuple, selected_names)
|
|
75
|
+
else
|
|
76
|
+
preload_values(records, winning_ids_by_tuple, selected_names)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id, selected_names)
|
|
49
80
|
end
|
|
50
81
|
|
|
51
82
|
private
|
|
52
83
|
|
|
53
84
|
attr_reader :host_class
|
|
54
85
|
|
|
86
|
+
def validate_source!
|
|
87
|
+
return if %i[database preloaded].include?(@source)
|
|
88
|
+
|
|
89
|
+
raise ArgumentError, "typed_eav_hash_for source must be :database or :preloaded"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_fields
|
|
93
|
+
return nil if @fields.nil?
|
|
94
|
+
|
|
95
|
+
names = if @fields.is_a?(String) || @fields.is_a?(Symbol)
|
|
96
|
+
[@fields]
|
|
97
|
+
elsif @fields.respond_to?(:to_a)
|
|
98
|
+
@fields.to_a
|
|
99
|
+
else
|
|
100
|
+
raise ArgumentError, "typed_eav_hash_for fields must be an Enumerable of String/Symbol names"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
names.map do |name|
|
|
104
|
+
next name if name.is_a?(String)
|
|
105
|
+
next name.to_s if name.is_a?(Symbol)
|
|
106
|
+
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"typed_eav_hash_for fields must contain only String or Symbol names; got #{name.inspect}"
|
|
109
|
+
end.uniq
|
|
110
|
+
end
|
|
111
|
+
|
|
55
112
|
def coerce_records
|
|
56
113
|
raise ArgumentError, "typed_eav_hash_for requires an Enumerable of records, got nil" if @records.nil?
|
|
57
114
|
|
|
58
115
|
@records.to_a
|
|
59
116
|
end
|
|
60
117
|
|
|
118
|
+
def empty_results(records)
|
|
119
|
+
records.to_h { |record| [record.id, {}] }
|
|
120
|
+
end
|
|
121
|
+
|
|
61
122
|
def validate_record_classes!(records)
|
|
62
123
|
return if records.all?(host_class)
|
|
63
124
|
|
|
@@ -72,60 +133,82 @@ module TypedEAV
|
|
|
72
133
|
records.index_with { |r| [r.typed_eav_scope, r.typed_eav_parent_scope] }
|
|
73
134
|
end
|
|
74
135
|
|
|
75
|
-
def winning_ids_by_tuple(tuples)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
136
|
+
def winning_ids_by_tuple(tuples, selected_names)
|
|
137
|
+
TypedEAV::Partition::DefinitionBatch
|
|
138
|
+
.resolve(entity_type: host_class.polymorphic_name, tuples: tuples)
|
|
139
|
+
.transform_values do |fields_by_name|
|
|
140
|
+
fields_by_name.each_with_object({}) do |(name, field), winners|
|
|
141
|
+
next if selected_names&.exclude?(name)
|
|
79
142
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
visible = candidate_tuples.flat_map { |candidate| definitions_by_tuple.fetch(candidate, []) }
|
|
84
|
-
[tuple, TypedEAV::Partition.definitions_by_name(visible).transform_values(&:id)]
|
|
85
|
-
end
|
|
143
|
+
winners[name] = field.id
|
|
144
|
+
end
|
|
145
|
+
end
|
|
86
146
|
end
|
|
87
147
|
|
|
88
|
-
def
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
|
|
103
|
-
AND typed_eav_fields.parent_scope IS NULL
|
|
104
|
-
)
|
|
105
|
-
)
|
|
106
|
-
SQL
|
|
148
|
+
def preload_values(records, winning_ids_by_tuple, selected_names)
|
|
149
|
+
relation = TypedEAV::Value
|
|
150
|
+
.includes(:field)
|
|
151
|
+
.where(entity_type: host_class.polymorphic_name, entity_id: records.map(&:id))
|
|
152
|
+
|
|
153
|
+
if selected_names
|
|
154
|
+
field_ids = winning_ids_by_tuple.values.flat_map(&:values).uniq
|
|
155
|
+
return {} if field_ids.empty?
|
|
156
|
+
|
|
157
|
+
relation = relation.where(field_id: field_ids)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
rows = relation.to_a
|
|
161
|
+
rows.group_by(&:entity_id)
|
|
107
162
|
end
|
|
108
163
|
|
|
109
|
-
|
|
110
|
-
|
|
164
|
+
# Reuse a caller's fully-preloaded association graph without allowing an
|
|
165
|
+
# accidental association reader to issue an N+1 query. `target` is used
|
|
166
|
+
# deliberately: unlike `record.typed_values`, it never loads an unloaded
|
|
167
|
+
# association. For a selected projection, filter each target by the
|
|
168
|
+
# winning field IDs before checking field associations; unselected Values
|
|
169
|
+
# therefore need not have their `field` association loaded. The all-fields
|
|
170
|
+
# path retains the stricter check over every Value, including orphan rows
|
|
171
|
+
# whose loaded field target is nil.
|
|
172
|
+
def preloaded_values(records, tuples_by_record, winning_ids_by_tuple, selected_names)
|
|
173
|
+
unloaded_records = records.reject { |record| record.association(:typed_values).loaded? }
|
|
174
|
+
if unloaded_records.any?
|
|
175
|
+
raise ArgumentError,
|
|
176
|
+
"typed_eav_hash_for source: :preloaded requires the typed_values association " \
|
|
177
|
+
"to be preloaded for every record"
|
|
178
|
+
end
|
|
111
179
|
|
|
112
|
-
|
|
180
|
+
records.to_h do |record|
|
|
181
|
+
values = record.association(:typed_values).target
|
|
182
|
+
if selected_names
|
|
183
|
+
tuple = tuples_by_record.fetch(record)
|
|
184
|
+
selected_ids = winning_ids_by_tuple.fetch(tuple, {}).values
|
|
185
|
+
values = values.select { |value| selected_ids.include?(effective_field_id(value)) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
unloaded_values = values.reject { |value| value.association(:field).loaded? }
|
|
189
|
+
if unloaded_values.any?
|
|
190
|
+
raise ArgumentError,
|
|
191
|
+
"typed_eav_hash_for source: :preloaded requires the field association " \
|
|
192
|
+
"to be preloaded for every typed value"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
[record.id, values]
|
|
196
|
+
end
|
|
113
197
|
end
|
|
114
198
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
rows.group_by(&:entity_id)
|
|
199
|
+
def effective_field_id(value)
|
|
200
|
+
return value.field_id if value.field_id
|
|
201
|
+
|
|
202
|
+
association = value.association(:field)
|
|
203
|
+
association.loaded? ? association.target&.id : nil
|
|
121
204
|
end
|
|
122
205
|
|
|
123
|
-
def build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id)
|
|
206
|
+
def build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id, selected_names)
|
|
124
207
|
records.each_with_object({}) do |record, result|
|
|
125
208
|
tuple_key = tuples_by_record[record]
|
|
126
209
|
winning_ids_by_name = winning_ids_by_tuple.fetch(tuple_key, {})
|
|
127
210
|
rows = values_by_record_id.fetch(record.id, [])
|
|
128
|
-
result[record.id] = inner_hash_for(rows, winning_ids_by_name)
|
|
211
|
+
result[record.id] = inner_hash_for(rows, winning_ids_by_name, selected_names)
|
|
129
212
|
end
|
|
130
213
|
end
|
|
131
214
|
|
|
@@ -136,12 +219,17 @@ module TypedEAV
|
|
|
136
219
|
# for the name, only its row may surface (scoped-beats-global collision
|
|
137
220
|
# precedence). When no winner is registered (definition deleted while
|
|
138
221
|
# values remain), fall back to first-wins so the hash isn't lossy.
|
|
139
|
-
def inner_hash_for(value_rows, winning_ids_by_name)
|
|
222
|
+
def inner_hash_for(value_rows, winning_ids_by_name, selected_names)
|
|
140
223
|
value_rows.each_with_object({}) do |tv, inner|
|
|
141
224
|
next unless tv.field
|
|
142
225
|
|
|
143
226
|
name = tv.field.name
|
|
144
227
|
winning_id = winning_ids_by_name[name]
|
|
228
|
+
# A selected field ID may be visible for another record's partition
|
|
229
|
+
# tuple because the value preload spans all requested records. In a
|
|
230
|
+
# projection, a name without a winner for this tuple is absent rather
|
|
231
|
+
# than eligible for the all-fields stale-row fallback below.
|
|
232
|
+
next if selected_names && !winning_id
|
|
145
233
|
next assign_with_precedence(inner, name, tv, winning_id) if winning_id
|
|
146
234
|
|
|
147
235
|
inner[name] = tv.value unless inner.key?(name)
|
|
@@ -8,7 +8,8 @@ module TypedEAV
|
|
|
8
8
|
# persistence callbacks, versioning, or per-record error isolation. Callers must
|
|
9
9
|
# acknowledge those semantics explicitly. It pre-casts and validates every
|
|
10
10
|
# row in a transaction unit before issuing one upsert batch on the host
|
|
11
|
-
# model's connection.
|
|
11
|
+
# model's connection. Field definitions for every tuple in that unit resolve
|
|
12
|
+
# in one batch; scope precedence and field casting remain authoritative.
|
|
12
13
|
module BulkUpsert
|
|
13
14
|
TYPE_COLUMNS = %i[
|
|
14
15
|
string_value text_value boolean_value integer_value decimal_value date_value datetime_value json_value
|
|
@@ -36,19 +37,14 @@ module TypedEAV
|
|
|
36
37
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- tuple grouping, validation, and row construction are one durability unit.
|
|
37
38
|
def write_unit(host_class, records, values_by_field_name)
|
|
38
39
|
tuples = records.map { |record| [record.typed_eav_scope, record.typed_eav_parent_scope] }.uniq
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
parent_scope: tuple[1],
|
|
48
|
-
),
|
|
49
|
-
)
|
|
50
|
-
[tuple, fields]
|
|
51
|
-
end
|
|
40
|
+
# Resolve every exact tuple in this transaction unit with one query.
|
|
41
|
+
# The shared resolver preserves global/scope/full-tuple precedence per
|
|
42
|
+
# tuple and never observes EntityQuery's administrative ALL_SCOPES
|
|
43
|
+
# bypass, so tenants remain isolated inside an unscoped block.
|
|
44
|
+
fields_by_tuple = TypedEAV::Partition::DefinitionBatch.resolve(
|
|
45
|
+
entity_type: host_class.polymorphic_name,
|
|
46
|
+
tuples: tuples,
|
|
47
|
+
)
|
|
52
48
|
rows = records.flat_map do |record|
|
|
53
49
|
fields = fields_by_tuple.fetch([record.typed_eav_scope, record.typed_eav_parent_scope])
|
|
54
50
|
values_by_field_name.filter_map do |name, raw|
|
|
@@ -94,6 +94,139 @@ module TypedEAV
|
|
|
94
94
|
end
|
|
95
95
|
# rubocop:enable Metrics/ParameterLists
|
|
96
96
|
|
|
97
|
+
# Order this host relation by a scalar typed field in SQL.
|
|
98
|
+
#
|
|
99
|
+
# Contact.order_typed_eav("score")
|
|
100
|
+
# Contact.where(active: true).order_typed_eav("score", direction: :desc)
|
|
101
|
+
#
|
|
102
|
+
# `direction:` accepts :asc or :desc. `nulls:` accepts :first or :last
|
|
103
|
+
# and defaults to :last for both directions. Missing value rows and rows
|
|
104
|
+
# whose selected typed cell is explicitly NULL are both SQL NULLs and
|
|
105
|
+
# therefore share the requested placement. Equal values are ordered by
|
|
106
|
+
# the host primary key ascending so pagination has a stable tie-break.
|
|
107
|
+
#
|
|
108
|
+
# The method keeps the caller's current relation scope (including normal
|
|
109
|
+
# Active Record filters, limits, and offsets) through Rails relation
|
|
110
|
+
# delegation. Typed ordering has explicit precedence and replaces any
|
|
111
|
+
# prior host ordering while retaining those filters and pagination.
|
|
112
|
+
#
|
|
113
|
+
# Scope kwargs choose the visible field definition and follow the same
|
|
114
|
+
# ambient/explicit/nil resolution as `where_typed_eav`; they do not add a
|
|
115
|
+
# host tenant predicate. Applications should keep host filtering in the
|
|
116
|
+
# caller relation. `TypedEAV.unscoped` is rejected because ordering across
|
|
117
|
+
# multiple same-name partition definitions is ambiguous; use an explicit
|
|
118
|
+
# scope when one field definition must win.
|
|
119
|
+
#
|
|
120
|
+
# Only single-cell scalar fields backed by the native scalar columns are
|
|
121
|
+
# supported. Collection and multi-cell fields raise ArgumentError rather
|
|
122
|
+
# than silently choosing one physical cell.
|
|
123
|
+
# rubocop:disable Metrics/ParameterLists -- mirrors the existing query wrappers' public partition kwargs.
|
|
124
|
+
def order_typed_eav(name, direction: :asc, nulls: :last, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
|
|
125
|
+
resolved = resolve_scope(scope, parent_scope)
|
|
126
|
+
effective_scope, effective_parent = scope_pair(resolved)
|
|
127
|
+
|
|
128
|
+
TypedEAV::ScalarQuery.new(
|
|
129
|
+
model: self,
|
|
130
|
+
name: name,
|
|
131
|
+
direction: direction,
|
|
132
|
+
nulls: nulls,
|
|
133
|
+
scope: effective_scope,
|
|
134
|
+
parent_scope: effective_parent,
|
|
135
|
+
).order_relation
|
|
136
|
+
end
|
|
137
|
+
# rubocop:enable Metrics/ParameterLists
|
|
138
|
+
|
|
139
|
+
# Return distinct values for a scalar typed field without hydrating host
|
|
140
|
+
# or Value records. Values are ordered by the native database column and
|
|
141
|
+
# limited before they are transferred to Ruby; an explicit NULL is
|
|
142
|
+
# returned as `nil`, while hosts with no value row are absent.
|
|
143
|
+
#
|
|
144
|
+
# `scope:` and `parent_scope:` choose the visible field definition with
|
|
145
|
+
# the same ambient/explicit/nil semantics as `where_typed_eav`. They do
|
|
146
|
+
# not add host predicates; keep tenant or other host filtering in the
|
|
147
|
+
# caller relation. The current relation's filters, limit, and offset are
|
|
148
|
+
# applied through a host-ID subquery.
|
|
149
|
+
# rubocop:disable Metrics/ParameterLists -- mirrors the existing query wrappers' public partition kwargs.
|
|
150
|
+
def distinct_typed_eav_values(name, limit: 100, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
|
|
151
|
+
resolved = resolve_scope(scope, parent_scope)
|
|
152
|
+
effective_scope, effective_parent = scope_pair(resolved)
|
|
153
|
+
|
|
154
|
+
TypedEAV::ScalarQuery.new(
|
|
155
|
+
model: self,
|
|
156
|
+
name: name,
|
|
157
|
+
scope: effective_scope,
|
|
158
|
+
parent_scope: effective_parent,
|
|
159
|
+
).distinct_values(limit: limit)
|
|
160
|
+
end
|
|
161
|
+
# rubocop:enable Metrics/ParameterLists
|
|
162
|
+
|
|
163
|
+
# Count exact distinct values for a scalar typed field. The count is
|
|
164
|
+
# calculated in SQL, including one explicit-NULL category as `nil`; hosts
|
|
165
|
+
# without a value row remain absent. Caller relation filters and
|
|
166
|
+
# pagination are applied through the same host-ID subquery as the bounded
|
|
167
|
+
# distinct-value API.
|
|
168
|
+
# rubocop:disable Metrics/ParameterLists -- mirrors the existing query wrappers' public partition kwargs.
|
|
169
|
+
def count_distinct_typed_eav_values(name, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
|
|
170
|
+
resolved = resolve_scope(scope, parent_scope)
|
|
171
|
+
effective_scope, effective_parent = scope_pair(resolved)
|
|
172
|
+
|
|
173
|
+
TypedEAV::ScalarQuery.new(
|
|
174
|
+
model: self,
|
|
175
|
+
name: name,
|
|
176
|
+
scope: effective_scope,
|
|
177
|
+
parent_scope: effective_parent,
|
|
178
|
+
).count_distinct_values
|
|
179
|
+
end
|
|
180
|
+
# rubocop:enable Metrics/ParameterLists
|
|
181
|
+
|
|
182
|
+
# Return an insertion-ordered `{ value => host_count }` hash for a scalar
|
|
183
|
+
# typed field. Values are grouped and counted in SQL using distinct host
|
|
184
|
+
# identities; explicit NULL is represented by a `nil` key and hosts with
|
|
185
|
+
# no value row are omitted. The result is ordered by the native value
|
|
186
|
+
# column with NULL last and capped by `limit:` before transfer to Ruby.
|
|
187
|
+
#
|
|
188
|
+
# The caller relation's filters, joins, distinctness, limit, and offset
|
|
189
|
+
# determine the host-ID subquery. Scope kwargs only choose the visible
|
|
190
|
+
# field definition and do not add host predicates.
|
|
191
|
+
# rubocop:disable Metrics/ParameterLists -- mirrors the existing query wrappers' public partition kwargs.
|
|
192
|
+
def typed_eav_value_counts(name, limit: 100, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
|
|
193
|
+
resolved = resolve_scope(scope, parent_scope)
|
|
194
|
+
effective_scope, effective_parent = scope_pair(resolved)
|
|
195
|
+
|
|
196
|
+
TypedEAV::ScalarQuery.new(
|
|
197
|
+
model: self,
|
|
198
|
+
name: name,
|
|
199
|
+
scope: effective_scope,
|
|
200
|
+
parent_scope: effective_parent,
|
|
201
|
+
).value_counts(limit: limit)
|
|
202
|
+
end
|
|
203
|
+
# rubocop:enable Metrics/ParameterLists
|
|
204
|
+
|
|
205
|
+
# Compute a database-backed numeric aggregate for a typed field.
|
|
206
|
+
# `operation:` is required and accepts :min, :max, or :sum. Integer fields return
|
|
207
|
+
# Integer results; Decimal and Percentage fields return BigDecimal
|
|
208
|
+
# results. Missing rows and explicit NULL cells are ignored. Empty
|
|
209
|
+
# min/max queries return nil, while empty sums return the typed zero.
|
|
210
|
+
#
|
|
211
|
+
# The caller relation's filters, joins, distinctness, limit, and offset
|
|
212
|
+
# are preserved through a host-ID subquery. Scope kwargs choose the
|
|
213
|
+
# visible definition and do not add host predicates. Only Integer,
|
|
214
|
+
# Decimal, and Percentage field families are supported; references,
|
|
215
|
+
# text, collections, and multi-cell fields raise ArgumentError.
|
|
216
|
+
# rubocop:disable Metrics/ParameterLists -- mirrors the existing query wrappers' public partition kwargs.
|
|
217
|
+
def aggregate_typed_eav(name, operation:, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
|
|
218
|
+
resolved = resolve_scope(scope, parent_scope)
|
|
219
|
+
effective_scope, effective_parent = scope_pair(resolved)
|
|
220
|
+
|
|
221
|
+
TypedEAV::ScalarQuery.new(
|
|
222
|
+
model: self,
|
|
223
|
+
name: name,
|
|
224
|
+
scope: effective_scope,
|
|
225
|
+
parent_scope: effective_parent,
|
|
226
|
+
).aggregate(operation: operation)
|
|
227
|
+
end
|
|
228
|
+
# rubocop:enable Metrics/ParameterLists
|
|
229
|
+
|
|
97
230
|
# Returns field definitions for this entity type.
|
|
98
231
|
#
|
|
99
232
|
# `scope:` and `parent_scope:` behavior:
|
|
@@ -103,10 +236,10 @@ module TypedEAV
|
|
|
103
236
|
def typed_eav_definitions(scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
|
|
104
237
|
resolved = resolve_scope(scope, parent_scope)
|
|
105
238
|
if resolved.equal?(ALL_SCOPES)
|
|
106
|
-
TypedEAV::Partition.visible_fields(entity_type:
|
|
239
|
+
TypedEAV::Partition.visible_fields(entity_type: polymorphic_name, mode: :all_partitions)
|
|
107
240
|
else
|
|
108
241
|
s, ps = resolved
|
|
109
|
-
TypedEAV::Partition.visible_fields(entity_type:
|
|
242
|
+
TypedEAV::Partition.visible_fields(entity_type: polymorphic_name, scope: s, parent_scope: ps)
|
|
110
243
|
end
|
|
111
244
|
end
|
|
112
245
|
|
|
@@ -115,8 +248,27 @@ module TypedEAV
|
|
|
115
248
|
# `HasTypedEAV::InstanceMethods#typed_eav_hash`. N+1-free regardless of
|
|
116
249
|
# record count or field count. See `TypedEAV::BulkRead` for the pipeline
|
|
117
250
|
# and query bound.
|
|
118
|
-
|
|
119
|
-
|
|
251
|
+
#
|
|
252
|
+
# `fields:` optionally limits the projection to selected String/Symbol
|
|
253
|
+
# names. Names are normalized to strings and de-duplicated; unknown names
|
|
254
|
+
# and names absent from a record's partition are ignored. Omitting
|
|
255
|
+
# `fields:` preserves the existing all-fields behavior. Passing `[]`
|
|
256
|
+
# returns one empty inner hash per supplied record without querying field
|
|
257
|
+
# definitions or values.
|
|
258
|
+
#
|
|
259
|
+
# `source: :database` (default) performs a fresh batched read even when
|
|
260
|
+
# the records already have typed values loaded. `source: :preloaded` is an
|
|
261
|
+
# explicit snapshot mode: `typed_values` and every retained value's
|
|
262
|
+
# `field` association must already be loaded, otherwise `ArgumentError` is
|
|
263
|
+
# raised instead of introducing an N+1 query. It reuses unsaved in-memory
|
|
264
|
+
# values and never saves or mutates caller records.
|
|
265
|
+
def typed_eav_hash_for(records, fields: nil, source: :database)
|
|
266
|
+
TypedEAV::BulkRead.new(
|
|
267
|
+
host_class: self,
|
|
268
|
+
records: records,
|
|
269
|
+
fields: fields,
|
|
270
|
+
source: source,
|
|
271
|
+
).to_hash
|
|
120
272
|
end
|
|
121
273
|
|
|
122
274
|
# Bulk write API. Sets the same `values_by_field_name` Hash on every
|
|
@@ -106,10 +106,10 @@ module TypedEAV
|
|
|
106
106
|
|
|
107
107
|
def lookup_definitions
|
|
108
108
|
if all_scopes?
|
|
109
|
-
TypedEAV::Partition.visible_fields(entity_type: model.
|
|
109
|
+
TypedEAV::Partition.visible_fields(entity_type: model.polymorphic_name, mode: :all_partitions)
|
|
110
110
|
else
|
|
111
111
|
TypedEAV::Partition.visible_fields(
|
|
112
|
-
entity_type: model.
|
|
112
|
+
entity_type: model.polymorphic_name,
|
|
113
113
|
scope: @scope,
|
|
114
114
|
parent_scope: @parent_scope,
|
|
115
115
|
)
|