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.
@@ -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
- # ## Pipeline (one batched definition query + one bulk value preload)
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 exact-partition definition query, then
14
- # `Partition.definitions_by_name` per tuple
15
- # 4. preload_values single SELECT across ALL records
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
- # - 1 SELECT typed_eav_values WHERE entity_type=? AND entity_id IN (?)
22
- # - 1 SELECT typed_eav_fields WHERE id IN (?) (via includes)
23
- # - 1 SELECT typed_eav_fields for the union of requested partitions
24
- #
25
- # Total: 3 queries independent of record count and partition cardinality.
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 (`entity_type: host_class.name`) targets ONE
30
- # class; mixed-class input would silently miss rows of the other class. STI
31
- # subclasses pass via `records.all?(host_class)`.
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
- tuples_by_record = group_by_tuple(records)
45
- winning_ids_by_tuple = winning_ids_by_tuple(tuples_by_record.values.uniq)
46
- values_by_record_id = preload_values(records)
68
+ selected_names = normalize_fields
69
+ return empty_results(records) if selected_names == []
47
70
 
48
- build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id)
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
- definitions_by_tuple = batched_definitions(tuples).group_by do |definition|
77
- [definition.scope, definition.parent_scope]
78
- end
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
- tuples.to_h do |tuple|
81
- s, ps = tuple
82
- candidate_tuples = [[nil, nil], [s, nil], [s, ps]].uniq
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 batched_definitions(tuples)
89
- tuples.each { |scope, parent_scope| validate_tuple!(scope, parent_scope) }
90
- relation = TypedEAV::Field::Base.where(entity_type: host_class.name)
91
- requested = tuples.map { |scope, parent_scope| { "scope" => scope, "parent_scope" => parent_scope } }
92
- relation.where(<<~SQL.squish, requested.to_json).to_a
93
- typed_eav_fields.scope IS NULL AND typed_eav_fields.parent_scope IS NULL
94
- OR EXISTS (
95
- SELECT 1
96
- FROM jsonb_to_recordset(?::jsonb) AS requested(scope text, parent_scope text)
97
- WHERE (
98
- typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
99
- AND typed_eav_fields.parent_scope IS NOT DISTINCT FROM requested.parent_scope
100
- )
101
- OR (
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
- def validate_tuple!(scope, parent_scope)
110
- return if TypedEAV::ScopeTuple.invariant_satisfied?(scope, parent_scope)
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
- raise ArgumentError, "parent_scope cannot be set when scope is blank"
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 preload_values(records)
116
- rows = TypedEAV::Value
117
- .includes(:field)
118
- .where(entity_type: host_class.name, entity_id: records.map(&:id))
119
- .to_a
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. Scope resolution and field casting remain authoritative.
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
- fields_by_tuple = tuples.to_h do |tuple|
40
- # Use the exact tuple at the partition layer. EntityQuery treats
41
- # explicit scope kwargs as ALL_SCOPES inside TypedEAV.unscoped;
42
- # that administrative bypass must not cross-contaminate tenants.
43
- fields = TypedEAV::Partition.definitions_by_name(
44
- TypedEAV::Partition.visible_fields(
45
- entity_type: host_class.polymorphic_name,
46
- scope: tuple[0],
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: name, mode: :all_partitions)
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: name, scope: s, parent_scope: ps)
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
- 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
@@ -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.name, mode: :all_partitions)
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.name,
112
+ entity_type: model.polymorphic_name,
113
113
  scope: @scope,
114
114
  parent_scope: @parent_scope,
115
115
  )