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.
@@ -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
- def typed_eav_hash_for(records)
119
- TypedEAV::BulkRead.new(host_class: self, records: records).to_hash
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`. Class-level query
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) and `TypedEAV::BulkRead` (typed_eav_hash_for).
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