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
|
@@ -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
|
|
@@ -262,7 +262,7 @@ module TypedEAV
|
|
|
262
262
|
# because each record's INSERT clears the cache — so cache-do alone
|
|
263
263
|
# cannot keep field-definition reads N+1-free across the bulk loop.
|
|
264
264
|
# The thread-local memo is the explicit fallback documented in plan
|
|
265
|
-
# 06-05 §T3 notes; it pre-warms once per `[
|
|
265
|
+
# 06-05 §T3 notes; it pre-warms once per `[polymorphic_name, scope,
|
|
266
266
|
# parent_scope]` tuple and reuses across every record in that tuple.
|
|
267
267
|
#
|
|
268
268
|
# Outside a bulk operation the memo is nil and we fall through to
|
|
@@ -270,7 +270,7 @@ module TypedEAV
|
|
|
270
270
|
def typed_eav_defs_by_name
|
|
271
271
|
memo = Thread.current[:typed_eav_bulk_defs_memo]
|
|
272
272
|
if memo
|
|
273
|
-
key = [self.class.
|
|
273
|
+
key = [self.class.polymorphic_name, typed_eav_scope, typed_eav_parent_scope]
|
|
274
274
|
memo[key] ||= TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
|
|
275
275
|
else
|
|
276
276
|
TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
|
|
@@ -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,
|
|
@@ -93,7 +97,7 @@ module TypedEAV
|
|
|
93
97
|
|
|
94
98
|
accepts_nested_attributes_for :typed_values, allow_destroy: true
|
|
95
99
|
|
|
96
|
-
TypedEAV.registry.register(
|
|
100
|
+
TypedEAV.registry.register(polymorphic_name, types: types, versioned: versioned)
|
|
97
101
|
end
|
|
98
102
|
|
|
99
103
|
private
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Partition
|
|
5
|
+
# Internal batched resolver for field definitions visible to many exact
|
|
6
|
+
# partition tuples. It performs one definition SELECT, then applies the
|
|
7
|
+
# same global -> scope -> full-tuple precedence as `definitions_by_name`
|
|
8
|
+
# independently for every requested tuple.
|
|
9
|
+
class DefinitionBatch
|
|
10
|
+
class << self
|
|
11
|
+
def resolve(entity_type:, tuples:)
|
|
12
|
+
new(entity_type: entity_type, tuples: tuples).resolve
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(entity_type:, tuples:)
|
|
17
|
+
@entity_type = entity_type
|
|
18
|
+
@tuples = tuples.uniq
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve
|
|
22
|
+
tuples.each { |scope, parent_scope| validate_tuple!(scope, parent_scope) }
|
|
23
|
+
return {} if tuples.empty?
|
|
24
|
+
|
|
25
|
+
definitions_by_tuple = load_definitions.group_by do |definition|
|
|
26
|
+
[definition.scope, definition.parent_scope]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
tuples.to_h do |tuple|
|
|
30
|
+
visible = candidate_tuples(tuple).flat_map { |candidate| definitions_by_tuple.fetch(candidate, []) }
|
|
31
|
+
[tuple, TypedEAV::Partition.definitions_by_name(visible)]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :entity_type, :tuples
|
|
38
|
+
|
|
39
|
+
def load_definitions
|
|
40
|
+
requested = tuples.map { |scope, parent_scope| { "scope" => scope, "parent_scope" => parent_scope } }
|
|
41
|
+
TypedEAV::Field::Base.where(entity_type: entity_type).where(<<~SQL.squish, requested.to_json).to_a
|
|
42
|
+
typed_eav_fields.scope IS NULL AND typed_eav_fields.parent_scope IS NULL
|
|
43
|
+
OR EXISTS (
|
|
44
|
+
SELECT 1
|
|
45
|
+
FROM jsonb_to_recordset(?::jsonb) AS requested(scope text, parent_scope text)
|
|
46
|
+
WHERE (
|
|
47
|
+
typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
|
|
48
|
+
AND typed_eav_fields.parent_scope IS NOT DISTINCT FROM requested.parent_scope
|
|
49
|
+
)
|
|
50
|
+
OR (
|
|
51
|
+
typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
|
|
52
|
+
AND typed_eav_fields.parent_scope IS NULL
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
SQL
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def candidate_tuples(tuple)
|
|
59
|
+
scope, parent_scope = tuple
|
|
60
|
+
[[nil, nil], [scope, nil], [scope, parent_scope]].uniq
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate_tuple!(scope, parent_scope)
|
|
64
|
+
return if TypedEAV::ScopeTuple.invariant_satisfied?(scope, parent_scope)
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, "parent_scope cannot be set when scope is blank"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/typed_eav/partition.rb
CHANGED
|
@@ -8,6 +8,8 @@ module TypedEAV
|
|
|
8
8
|
# values. Ambient resolution (`TypedEAV.current_scope`, `with_scope`,
|
|
9
9
|
# `unscoped`) stays with the adapters that know their calling context.
|
|
10
10
|
module Partition
|
|
11
|
+
autoload :DefinitionBatch, "typed_eav/partition/definition_batch"
|
|
12
|
+
|
|
11
13
|
# Frozen orphan-parent ArgumentError message. Kept as a module constant
|
|
12
14
|
# so both `visible_fields` and `visible_sections` raise the same string
|
|
13
15
|
# without re-allocating per call. The string is the wire-stable BC error
|
|
@@ -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
|