typed_eav 0.7.1 → 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 +63 -1
- data/README.md +206 -1
- data/lib/typed_eav/bulk_read.rb +143 -22
- data/lib/typed_eav/entity_query.rb +154 -2
- data/lib/typed_eav/has_typed_eav/dirty_tracking.rb +207 -0
- data/lib/typed_eav/has_typed_eav.rb +6 -2
- 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.rb +1 -0
- metadata +4 -1
|
@@ -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:
|
|
@@ -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
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module HasTypedEAV
|
|
5
|
+
# Read-only, on-demand dirty tracking for typed Value rows that are part
|
|
6
|
+
# of a host association's in-memory target.
|
|
7
|
+
#
|
|
8
|
+
# Pending comparisons follow Active Record's own dirty state; saved
|
|
9
|
+
# snapshots use the host lifecycle, not a separate Value mutation registry.
|
|
10
|
+
# A typed Value
|
|
11
|
+
# retains its database snapshot through `attribute_in_database`, while
|
|
12
|
+
# autosave clears its ordinary dirty state after a successful host save.
|
|
13
|
+
# Consequently failed saves and outer transaction rollbacks retain the
|
|
14
|
+
# same correction-friendly state that Active Record exposes on the child.
|
|
15
|
+
#
|
|
16
|
+
# The association target is inspected without loading it. Named and
|
|
17
|
+
# nested assignment paths already place their touched Values in that
|
|
18
|
+
# target; callers using direct Value assignment should use a loaded
|
|
19
|
+
# `typed_values` association. A Value loaded independently from the host
|
|
20
|
+
# is intentionally outside this API because observing it would require an
|
|
21
|
+
# eager query of every Value row or a new global mutation registry.
|
|
22
|
+
module DirtyTracking
|
|
23
|
+
extend ActiveSupport::Concern
|
|
24
|
+
|
|
25
|
+
included do
|
|
26
|
+
# Register before has_typed_eav declares the autosave association.
|
|
27
|
+
# Active Model prepends after_* callbacks, so the later autosave
|
|
28
|
+
# callback executes after this capture. The public save wrappers
|
|
29
|
+
# restore prior state when a later save callback raises.
|
|
30
|
+
after_create :_typed_eav_capture_saved_changes
|
|
31
|
+
after_update :_typed_eav_capture_saved_changes
|
|
32
|
+
after_destroy :_typed_eav_clear_saved_changes
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Pending typed-value changes keyed by effective Field name.
|
|
36
|
+
#
|
|
37
|
+
# The returned hash and pairs are fresh objects. Before-values are
|
|
38
|
+
# reconstructed from the stored physical cells through the Field's
|
|
39
|
+
# public logical reader, so multi-cell fields retain the same shape as
|
|
40
|
+
# `typed_eav_value` rather than exposing storage-column details.
|
|
41
|
+
def typed_eav_changes
|
|
42
|
+
values = typed_eav_pending_values
|
|
43
|
+
return {} if values.empty?
|
|
44
|
+
|
|
45
|
+
names_by_field_id = typed_eav_effective_field_names
|
|
46
|
+
changes_by_field_id = typed_eav_change_pairs(values, names_by_field_id)
|
|
47
|
+
|
|
48
|
+
changes_by_field_id.each_with_object({}) do |(field_id, pair), changes|
|
|
49
|
+
name = names_by_field_id[field_id]
|
|
50
|
+
next unless name
|
|
51
|
+
next if pair[0] == pair[1]
|
|
52
|
+
|
|
53
|
+
changes[name] = pair.map(&:deep_dup)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Logical changes from the most recent successful host save. The value
|
|
58
|
+
# is intentionally available in host `after_save` callbacks, where the
|
|
59
|
+
# child autosave has already consumed ordinary dirty state. This is
|
|
60
|
+
# successful-save state, not evidence that an outer transaction later
|
|
61
|
+
# committed and not a replacement for ValueVersion history.
|
|
62
|
+
def saved_typed_eav_changes
|
|
63
|
+
(@typed_eav_saved_changes || {}).deep_dup
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Bracket the complete public save call, including callbacks that run
|
|
67
|
+
# after the model's create/update callbacks. An around_save callback
|
|
68
|
+
# cannot rescue an after_save callback that is compiled outside its
|
|
69
|
+
# around sequence, but these wrappers can restore the previous snapshot
|
|
70
|
+
# for both false returns and raised errors.
|
|
71
|
+
def save(...)
|
|
72
|
+
_typed_eav_track_save { super }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def save!(...)
|
|
76
|
+
_typed_eav_track_save { super }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Capture from the host lifecycle before autosave runs. Because this
|
|
82
|
+
# callback was registered before the association's autosave callback,
|
|
83
|
+
# before_save mutations and marked-for-destruction Values are still
|
|
84
|
+
# observable here.
|
|
85
|
+
def _typed_eav_capture_saved_changes
|
|
86
|
+
# A record-level rollback callback can be skipped after a later
|
|
87
|
+
# failed validation; use Rails' transaction-level callback instead.
|
|
88
|
+
self.class.current_transaction.after_rollback { _typed_eav_clear_saved_changes }
|
|
89
|
+
@typed_eav_saved_changes = typed_eav_changes.deep_dup
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Keep failed saves from replacing the last successful result. A normal
|
|
93
|
+
# return of false is handled here as well as exceptions raised by save
|
|
94
|
+
# callbacks or autosave. If the failed save opened a transaction that
|
|
95
|
+
# rolled back, Active Record may have cleared the snapshot before this
|
|
96
|
+
# wrapper resumes; restoring `previous` here preserves the failed-save
|
|
97
|
+
# contract. A successful save leaves the new snapshot in place until a
|
|
98
|
+
# later outer rollback callback clears it.
|
|
99
|
+
def _typed_eav_track_save
|
|
100
|
+
previous = @typed_eav_saved_changes
|
|
101
|
+
result = yield
|
|
102
|
+
|
|
103
|
+
@typed_eav_saved_changes = previous unless result
|
|
104
|
+
result
|
|
105
|
+
rescue StandardError
|
|
106
|
+
@typed_eav_saved_changes = previous
|
|
107
|
+
raise
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def _typed_eav_clear_saved_changes
|
|
111
|
+
@typed_eav_saved_changes = {}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
public
|
|
115
|
+
|
|
116
|
+
# Reload replaces the host's association target. Clear the separate
|
|
117
|
+
# saved snapshot at the same boundary so it cannot outlive the state it
|
|
118
|
+
# describes.
|
|
119
|
+
def reload(...)
|
|
120
|
+
super.tap { _typed_eav_clear_saved_changes }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Reading `target` is the important lazy boundary: calling the public
|
|
126
|
+
# dirty API on an untouched host never issues a typed_values SELECT.
|
|
127
|
+
# `build` adds to target without marking the association loaded, so the
|
|
128
|
+
# direct-association path is covered as well.
|
|
129
|
+
def typed_eav_pending_values
|
|
130
|
+
typed_values.target.select do |value|
|
|
131
|
+
next false if value.destroyed?
|
|
132
|
+
next false if value.new_record? && value.marked_for_destruction?
|
|
133
|
+
|
|
134
|
+
typed_eav_value_pending?(value)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def typed_eav_value_pending?(value)
|
|
139
|
+
return true if value.marked_for_destruction? && value.persisted?
|
|
140
|
+
return false unless value.changed?
|
|
141
|
+
|
|
142
|
+
field = value.field
|
|
143
|
+
return false unless field
|
|
144
|
+
|
|
145
|
+
field.class.value_columns.any? do |column|
|
|
146
|
+
value.will_save_change_to_attribute?(column)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Resolve names only after finding a changed Value. This keeps ordinary
|
|
151
|
+
# reads and saves free of definition queries while preserving the same
|
|
152
|
+
# collision precedence used by `typed_eav_value` and `typed_eav_hash`.
|
|
153
|
+
def typed_eav_effective_field_names
|
|
154
|
+
typed_eav_defs_by_name.each_with_object({}) do |(name, field), names|
|
|
155
|
+
names[field.id] = name
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def typed_eav_change_pairs(values, names_by_field_id)
|
|
160
|
+
values.each_with_object({}) do |value, changes|
|
|
161
|
+
change = typed_eav_change_for(value, names_by_field_id)
|
|
162
|
+
next unless change
|
|
163
|
+
|
|
164
|
+
field_id, pair = change
|
|
165
|
+
merge_typed_eav_change(changes, field_id, pair)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def typed_eav_change_for(value, names_by_field_id)
|
|
170
|
+
field = value.field
|
|
171
|
+
field_id = value.field_id || field&.id
|
|
172
|
+
return unless field && field_id && names_by_field_id.key?(field_id)
|
|
173
|
+
|
|
174
|
+
before = typed_eav_before_value(value, field)
|
|
175
|
+
after = value.marked_for_destruction? ? nil : field.read_value(value)
|
|
176
|
+
return if before == after
|
|
177
|
+
|
|
178
|
+
[field_id, [before, after]]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# One Value row per field is the normal shape. Keep the first non-nil
|
|
182
|
+
# before-state when a target temporarily contains both an old marked row
|
|
183
|
+
# and a replacement build; this preserves the original persisted
|
|
184
|
+
# baseline while the final target row supplies the after-state.
|
|
185
|
+
def merge_typed_eav_change(changes, field_id, pair)
|
|
186
|
+
return changes[field_id] = pair unless changes.key?(field_id)
|
|
187
|
+
|
|
188
|
+
changes[field_id][0] = pair[0] if changes[field_id][0].nil? && !pair[0].nil?
|
|
189
|
+
changes[field_id][1] = pair[1]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Use the original stored cells as a lightweight Value snapshot and
|
|
193
|
+
# invoke the Field's logical reader. This is intentionally not a second
|
|
194
|
+
# representation of multi-cell values; Field#read_value remains the
|
|
195
|
+
# single source of truth for both current and prior values.
|
|
196
|
+
def typed_eav_before_value(value, field)
|
|
197
|
+
return nil if value.new_record?
|
|
198
|
+
|
|
199
|
+
snapshot = value.dup
|
|
200
|
+
field.class.value_columns.each do |column|
|
|
201
|
+
snapshot[column] = value.attribute_in_database(column)
|
|
202
|
+
end
|
|
203
|
+
field.read_value(snapshot)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -31,15 +31,18 @@ module TypedEAV
|
|
|
31
31
|
# ## Architecture (ADR-0002, 0.3.0 refactor)
|
|
32
32
|
#
|
|
33
33
|
# This file holds the macro entry + macro-time guards. Per-record API
|
|
34
|
-
# lives in `TypedEAV::HasTypedEAV::InstanceMethods
|
|
34
|
+
# lives in `TypedEAV::HasTypedEAV::InstanceMethods`, with pending/saved
|
|
35
|
+
# change tracking in `TypedEAV::HasTypedEAV::DirtyTracking`. Class-level query
|
|
35
36
|
# orchestration lives in `TypedEAV::EntityQuery` (extended onto the host
|
|
36
37
|
# class), which delegates the heavy lifting to `TypedEAV::FilterQuery`
|
|
37
|
-
# (where_typed_eav)
|
|
38
|
+
# (where_typed_eav), `TypedEAV::ScalarQuery` (ordering/summaries), and
|
|
39
|
+
# `TypedEAV::BulkRead` (typed_eav_hash_for).
|
|
38
40
|
# `bulk_set_typed_eav_values` continues to delegate to `TypedEAV::BulkWrite`.
|
|
39
41
|
module HasTypedEAV
|
|
40
42
|
extend ActiveSupport::Concern
|
|
41
43
|
|
|
42
44
|
autoload :InstanceMethods, "typed_eav/has_typed_eav/instance_methods"
|
|
45
|
+
autoload :DirtyTracking, "typed_eav/has_typed_eav/dirty_tracking"
|
|
43
46
|
|
|
44
47
|
class_methods do
|
|
45
48
|
# Register this model as having typed fields.
|
|
@@ -82,6 +85,7 @@ module TypedEAV
|
|
|
82
85
|
default: types && types.map(&:to_s).freeze
|
|
83
86
|
|
|
84
87
|
include InstanceMethods
|
|
88
|
+
include DirtyTracking
|
|
85
89
|
extend TypedEAV::EntityQuery
|
|
86
90
|
|
|
87
91
|
has_many :typed_values,
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
# SQL-backed operations over one scalar typed field.
|
|
5
|
+
#
|
|
6
|
+
# Entity-level wrappers resolve ambient/explicit scope and preserve Rails'
|
|
7
|
+
# current relation delegation. This object owns the field-definition lookup,
|
|
8
|
+
# scalar support gate, and correlated value expression so no host or Value
|
|
9
|
+
# records are hydrated merely to order a relation.
|
|
10
|
+
class ScalarQuery
|
|
11
|
+
SCALAR_COLUMNS = %i[
|
|
12
|
+
boolean_value
|
|
13
|
+
date_value
|
|
14
|
+
datetime_value
|
|
15
|
+
decimal_value
|
|
16
|
+
integer_value
|
|
17
|
+
string_value
|
|
18
|
+
text_value
|
|
19
|
+
].freeze
|
|
20
|
+
DIRECTIONS = %i[asc desc].freeze
|
|
21
|
+
NULL_PLACEMENTS = %i[first last].freeze
|
|
22
|
+
AGGREGATE_OPERATIONS = %i[min max sum].freeze
|
|
23
|
+
|
|
24
|
+
# rubocop:disable Metrics/ParameterLists -- the query object receives the resolved public API inputs explicitly.
|
|
25
|
+
def initialize(model:, name:, scope:, parent_scope:, direction: :asc, nulls: :last)
|
|
26
|
+
@model = model
|
|
27
|
+
@name = normalize_name(name)
|
|
28
|
+
@direction = normalize_option(direction, DIRECTIONS, "direction")
|
|
29
|
+
@nulls = normalize_option(nulls, NULL_PLACEMENTS, "nulls")
|
|
30
|
+
@scope = scope
|
|
31
|
+
@parent_scope = parent_scope
|
|
32
|
+
end
|
|
33
|
+
# rubocop:enable Metrics/ParameterLists
|
|
34
|
+
|
|
35
|
+
def order_relation
|
|
36
|
+
raise_all_scopes!
|
|
37
|
+
|
|
38
|
+
field = field_for_name
|
|
39
|
+
column = scalar_column_for(field)
|
|
40
|
+
relation = @model.all
|
|
41
|
+
host_table = relation.arel_table
|
|
42
|
+
value_table = TypedEAV::Value.arel_table
|
|
43
|
+
value_query = value_subquery(value_table, host_table, column, field)
|
|
44
|
+
|
|
45
|
+
relation.reorder(
|
|
46
|
+
Arel.sql("(#{value_query.to_sql}) #{@direction.to_s.upcase} NULLS #{@nulls.to_s.upcase}"),
|
|
47
|
+
host_table[@model.primary_key].asc,
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def distinct_values(limit:)
|
|
52
|
+
raise_all_scopes!
|
|
53
|
+
|
|
54
|
+
field = field_for_name
|
|
55
|
+
column = scalar_column_for(field)
|
|
56
|
+
relation = value_relation(field).select(column).distinct
|
|
57
|
+
relation = relation.order(Arel.sql("#{quoted_value_column(column)} ASC NULLS LAST"))
|
|
58
|
+
|
|
59
|
+
relation.limit(validate_limit(limit)).pluck(column)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def count_distinct_values
|
|
63
|
+
raise_all_scopes!
|
|
64
|
+
|
|
65
|
+
field = field_for_name
|
|
66
|
+
column = scalar_column_for(field)
|
|
67
|
+
distinct_relation = value_relation(field).select(column).distinct
|
|
68
|
+
aliased_relation = TypedEAV::Value.from(
|
|
69
|
+
"(#{distinct_relation.to_sql}) #{quoted_table_name("typed_eav_distinct_values")}",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
aliased_relation.count
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def value_counts(limit:)
|
|
76
|
+
raise_all_scopes!
|
|
77
|
+
|
|
78
|
+
field = field_for_name
|
|
79
|
+
column = scalar_column_for(field)
|
|
80
|
+
count_sql = Arel.sql("COUNT(DISTINCT #{quoted_value_column(:entity_id)})")
|
|
81
|
+
|
|
82
|
+
value_relation(field)
|
|
83
|
+
.group(column)
|
|
84
|
+
.order(Arel.sql("#{quoted_value_column(column)} ASC NULLS LAST"))
|
|
85
|
+
.limit(validate_limit(limit))
|
|
86
|
+
.pluck(column, count_sql)
|
|
87
|
+
.to_h
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def aggregate(operation:)
|
|
91
|
+
raise_all_scopes!
|
|
92
|
+
|
|
93
|
+
operation = normalize_option(operation, AGGREGATE_OPERATIONS, "operation")
|
|
94
|
+
field = field_for_name
|
|
95
|
+
column = numeric_column_for(field)
|
|
96
|
+
relation = value_relation(field)
|
|
97
|
+
|
|
98
|
+
case operation
|
|
99
|
+
when :min then relation.minimum(column)
|
|
100
|
+
when :max then relation.maximum(column)
|
|
101
|
+
when :sum then relation.sum(column)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def normalize_name(name)
|
|
108
|
+
unless name.is_a?(String) || name.is_a?(Symbol)
|
|
109
|
+
raise ArgumentError, "typed field name must be a non-empty String or Symbol"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
normalized = name.to_s
|
|
113
|
+
return normalized if normalized.present?
|
|
114
|
+
|
|
115
|
+
raise ArgumentError, "typed field name must be a non-empty String or Symbol"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_option(value, allowed, label)
|
|
119
|
+
normalized = value.to_sym if value.is_a?(String) || value.is_a?(Symbol)
|
|
120
|
+
return normalized if allowed.include?(normalized)
|
|
121
|
+
|
|
122
|
+
formatted = allowed.map { |option| ":#{option}" }.join(", ")
|
|
123
|
+
raise ArgumentError, "typed field #{label} must be one of #{formatted}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def raise_all_scopes!
|
|
127
|
+
return unless @scope.equal?(TypedEAV::EntityQuery::ALL_SCOPES)
|
|
128
|
+
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"typed scalar queries across all partitions are ambiguous; leave `TypedEAV.unscoped` " \
|
|
131
|
+
"and select an explicit scope"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def field_for_name
|
|
135
|
+
fields = TypedEAV::Partition.visible_fields(
|
|
136
|
+
entity_type: @model.polymorphic_name,
|
|
137
|
+
scope: @scope,
|
|
138
|
+
parent_scope: @parent_scope,
|
|
139
|
+
)
|
|
140
|
+
field = TypedEAV::Partition.definitions_by_name(fields)[@name]
|
|
141
|
+
return field if field
|
|
142
|
+
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"Unknown typed field '#{@name}' for #{@model.name}. " \
|
|
145
|
+
"Available fields: #{TypedEAV::Partition.definitions_by_name(fields).keys.join(", ")}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def scalar_column_for(field)
|
|
149
|
+
columns = field.class.value_columns
|
|
150
|
+
if columns.length != 1 || SCALAR_COLUMNS.exclude?(columns.first)
|
|
151
|
+
raise ArgumentError,
|
|
152
|
+
"Typed field '#{field.name}' (#{field.field_type_name}) is not a supported scalar field for typed queries"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
columns.first
|
|
156
|
+
rescue NotImplementedError
|
|
157
|
+
raise ArgumentError,
|
|
158
|
+
"Typed field '#{field.name}' (#{field.field_type_name}) is not a supported scalar field for typed queries"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def numeric_column_for(field)
|
|
162
|
+
unless field.is_a?(TypedEAV::Field::Integer) || field.is_a?(TypedEAV::Field::Decimal)
|
|
163
|
+
raise ArgumentError,
|
|
164
|
+
"Typed field '#{field.name}' (#{field.field_type_name}) is not a supported numeric field"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
columns = field.class.value_columns
|
|
168
|
+
if columns.length != 1 || %i[decimal_value integer_value].exclude?(columns.first)
|
|
169
|
+
raise ArgumentError,
|
|
170
|
+
"Typed field '#{field.name}' (#{field.field_type_name}) is not a supported numeric field"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
columns.first
|
|
174
|
+
rescue NotImplementedError
|
|
175
|
+
raise ArgumentError,
|
|
176
|
+
"Typed field '#{field.name}' (#{field.field_type_name}) is not a supported numeric field"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def value_relation(field)
|
|
180
|
+
TypedEAV::Value.where(
|
|
181
|
+
field_id: field.id,
|
|
182
|
+
entity_type: @model.polymorphic_name,
|
|
183
|
+
entity_id: host_id_relation,
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def host_id_relation
|
|
188
|
+
# `all` is intentional: EntityQuery's relation delegation exposes the
|
|
189
|
+
# caller relation through the model's current scope at this boundary.
|
|
190
|
+
# rubocop:disable Rails/RedundantActiveRecordAllMethod
|
|
191
|
+
relation = @model.all.unscope(:select)
|
|
192
|
+
# rubocop:enable Rails/RedundantActiveRecordAllMethod
|
|
193
|
+
alias_name = "typed_eav_host_ids"
|
|
194
|
+
qualified_primary_key = "#{quoted_table_name(alias_name)}.#{quoted_column_name(@model.primary_key)}"
|
|
195
|
+
wrapped_sql = "(#{relation.to_sql}) #{quoted_table_name(alias_name)}"
|
|
196
|
+
|
|
197
|
+
@model.base_class.unscoped.from(wrapped_sql).select(Arel.sql(qualified_primary_key))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def quoted_value_column(column)
|
|
201
|
+
"#{quoted_table_name(TypedEAV::Value.table_name)}.#{quoted_column_name(column)}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def quoted_table_name(table_name)
|
|
205
|
+
TypedEAV::Value.connection.quote_table_name(table_name)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def quoted_column_name(column_name)
|
|
209
|
+
TypedEAV::Value.connection.quote_column_name(column_name)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def validate_limit(limit)
|
|
213
|
+
unless limit.is_a?(Integer) && limit.positive? && limit <= 1_000
|
|
214
|
+
raise ArgumentError, "typed scalar query limit must be a positive Integer no greater than 1000"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
limit
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def value_subquery(value_table, host_table, column, field)
|
|
221
|
+
predicate = value_table[:field_id].eq(field.id)
|
|
222
|
+
.and(value_table[:entity_type].eq(@model.polymorphic_name))
|
|
223
|
+
.and(value_table[:entity_id].eq(host_table[@model.primary_key]))
|
|
224
|
+
|
|
225
|
+
value_table.project(value_table[column]).where(predicate)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|