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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 312b444b0c7b29eb298af1ef5bba02d3ba56ad811399b3ffcd2dfd2afd88267b
4
- data.tar.gz: cfa3ca85088eaff46dff6657d098f9fcf38fec9129b53792cfb37cc19e4358ad
3
+ metadata.gz: 026326e328ecf153d7ba72eeac001470ba67307ed17bf5cef68f52548b6abd47
4
+ data.tar.gz: 6a76dce02983e81312d67662de6837804b51e691ab0d79dbfb13cacc2b25c19c
5
5
  SHA512:
6
- metadata.gz: abcb3cde1dc9d09da1ea014165c245d74aa2ef2f5fd566ecc9f86343b8e30b1ca32bcc10eb9735a06a23641bb0ccef7c1078d2be288a724a20b6d7e44468e6d9
7
- data.tar.gz: b259edd0ece6e756569a151194368fc0adda83063d56683f9113d6373c0eede174cb545f9d1ca0ccc70e2dd512239494299f3da8608567aa5e47989e3b780eb3
6
+ metadata.gz: 1677489ecff776e1914305db8d9681a6cb52891282c6602b3d6b12acb99b89d2263bca877606e686e64d260ecff1f5170ac0f6a733b8f7f5592447d5f8852b90
7
+ data.tar.gz: 528cbe73ee76daa4e2bb50806b1e148483a68ac9563e2b3c49a3110ee153fba00bd7a6f79612824dc018fc7d13bfcde7e81ed1b0daa67190b401320ee9ea1e5f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-09-04
11
+
12
+ New SQL-backed query APIs, selective/preloaded bulk reads, logical dirty
13
+ tracking, and read-only schema previews. Existing read defaults are preserved;
14
+ no database migrations or new runtime dependencies are required.
15
+
16
+ ### Added
17
+
18
+ - Read-only `SchemaPortability.preview_schema` compares exact-partition
19
+ portable schemas with current definitions, exposing field/section/option
20
+ differences, conditional import actions, and risk flags. Type swaps remain
21
+ blocked and omitted definitions are not treated as deletions. Previewing
22
+ never applies changes or guarantees a later import's success. (#51)
23
+ - `saved_typed_eav_changes` exposes the last successful host save's logical
24
+ changes inside `after_save` callbacks. Failed saves preserve the prior
25
+ snapshot; no-op saves, reload, and rollback have explicit lifecycle
26
+ semantics independent of audit/versioning. (#48)
27
+ - Database-backed `aggregate_typed_eav` min/max/sum for Integer and Decimal
28
+ field families, including Percentage. Decimal precision, caller host sets,
29
+ and explicit empty/NULL semantics are preserved; references and multi-cell
30
+ Currency are rejected instead of silently aggregated. (#50)
31
+ - SQL-backed distinct typed values, exact distinct counts, and bounded
32
+ per-value host counts. Caller host filters/pagination and field-definition
33
+ precedence are retained; explicit NULL, false, and empty strings have
34
+ documented semantics without host or Value hydration. (#49)
35
+ - Explicit `source: :preloaded` bulk reads reuse caller-loaded Value graphs,
36
+ including unsaved edits, with one batched definition lookup and no duplicate
37
+ Value hydration. Incomplete required preloads fail clearly; fresh database
38
+ reads remain the default. (#46)
39
+ - Pending logical typed-value changes via `typed_eav_changes`, covering
40
+ host-associated named/nested edits and marked removals without loading
41
+ untouched Value collections. Failed saves retain validation feedback and
42
+ pending changes; this API is separate from persisted audit history. (#47)
43
+ - Database-backed `order_typed_eav` for scalar fields, with explicit NULL
44
+ placement and stable primary-key tie-breaking. Caller host filters and
45
+ partition-definition precedence are preserved without Value hydration. (#44)
46
+ - Field-selective bulk reads with `typed_eav_hash_for(records, fields: names)`.
47
+ Selected winning field IDs narrow value loading before hydration; omitted
48
+ `fields:` preserves all-fields reads. Empty, duplicate, unknown, and
49
+ partition-specific names have explicit projection semantics. (#45)
50
+
51
+ ### Compatibility and usage notes
52
+
53
+ - Typed sorting and summaries select one effective field definition. Scope
54
+ arguments do not authorize or filter host records: retain tenant and access
55
+ filters on the caller's Active Record relation. All-partitions mode and
56
+ unsupported collection/multi-cell operations fail explicitly.
57
+ - Distinct lists and grouped counts default to 100 values (maximum 1,000),
58
+ ordered by value rather than frequency. Exact distinct counts include the
59
+ explicit NULL category; missing rows are omitted. Numeric min/max/sum retain
60
+ Integer/BigDecimal types and do not aggregate reference IDs or currencies.
61
+ - Preloaded reads are explicit in-memory value snapshots with a current
62
+ definition lookup; database reads remain the default. Dirty/saved changes
63
+ describe host-associated editing and save state, not durable audit history
64
+ or independently executed bulk SQL writes.
65
+ - Schema previews are advisory, exact-partition comparisons. They neither
66
+ apply changes nor reserve the schema or guarantee a later import's success.
67
+ - Local verification: 1,404 examples, 0 failures; 160 Ruby files lint-clean.
68
+ The release workflow additionally verifies the supported compatibility
69
+ matrix and the exact packaged artifact before publication.
70
+
10
71
  ## [0.7.1] - 2026-09-04
11
72
 
12
73
  A focused patch release for inherited Active Record hosts and multi-partition
@@ -609,7 +670,8 @@ worked examples.
609
670
 
610
671
  Initial release.
611
672
 
612
- [Unreleased]: https://github.com/dchuk/typed_eav/compare/v0.7.1...HEAD
673
+ [Unreleased]: https://github.com/dchuk/typed_eav/compare/v0.8.0...HEAD
674
+ [0.8.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.8.0
613
675
  [0.7.1]: https://github.com/dchuk/typed_eav/releases/tag/v0.7.1
614
676
  [0.7.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.7.0
615
677
  [0.6.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.6.0
data/README.md CHANGED
@@ -197,6 +197,78 @@ Contact.where(company_id: 42)
197
197
  .limit(25)
198
198
  ```
199
199
 
200
+ ### Sorting by a typed field
201
+
202
+ ```ruby
203
+ Contact.where(tenant_id: "t1")
204
+ .order_typed_eav("age", direction: :desc, nulls: :last, scope: "t1")
205
+ .limit(25)
206
+ ```
207
+
208
+ `order_typed_eav` returns an Active Record relation and orders in PostgreSQL,
209
+ without loading typed values into Ruby. It replaces prior ordering while
210
+ preserving host filters, STI restrictions, limits, and offsets. `direction:`
211
+ accepts `:asc` (default) or `:desc`; `nulls:` accepts `:first` or `:last`
212
+ (default in either direction). Missing rows and explicit NULLs share that
213
+ placement. Equal values use the host primary key ascending as a stable tie-break.
214
+
215
+ Scope arguments (or the ambient scope) select the winning field definition;
216
+ **they do not filter host records by tenant**. Keep authorization/tenant filters
217
+ on the caller relation. All-partitions `TypedEAV.unscoped` is rejected for this
218
+ API: choose one effective definition instead. Single native scalar cells are
219
+ supported, using their stored values (for example, reference IDs and attachment
220
+ signed IDs, not display labels). JSON/array and multi-cell fields such as
221
+ Currency are rejected rather than assigned an implicit ordering.
222
+
223
+ ### Distinct values and grouped counts
224
+
225
+ ```ruby
226
+ contacts = Contact.where(tenant_id: "t1")
227
+ contacts.distinct_typed_eav_values("status", scope: "t1", limit: 100)
228
+ # => ["active", "paused", nil]
229
+ contacts.typed_eav_value_counts("status", scope: "t1", limit: 100)
230
+ # => {"active" => 24, "paused" => 3, nil => 2}
231
+ contacts.count_distinct_typed_eav_values("status", scope: "t1")
232
+ # => 3
233
+ ```
234
+
235
+ These scalar queries run in SQL without hydrating hosts or Values. Results
236
+ use native ascending value order, with explicit NULL (`nil`) last. Missing
237
+ value rows contribute nothing; `false` and empty strings remain real values.
238
+ Grouped counts count host identities, not duplicate rows introduced by joins.
239
+ The caller's filters, STI restrictions, distinctness, and pagination determine
240
+ the host set before summarization. Scope arguments choose field-definition
241
+ visibility, not host authorization, just as with typed sorting.
242
+
243
+ Lists and grouped-count hashes default to 100 values and accept a positive
244
+ Integer `limit:` up to 1,000. They are truncated in value order, not ranked by
245
+ frequency. Compare their length with `count_distinct_typed_eav_values` to
246
+ detect truncation; that exact SQL count includes one NULL category and returns
247
+ only an Integer, regardless of cardinality. It still requires database work
248
+ over the matching set. Collection/multi-cell fields and all-partitions mode
249
+ are unsupported, matching scalar sorting.
250
+
251
+ ### Numeric aggregates
252
+
253
+ ```ruby
254
+ Contact.where(tenant_id: "t1").aggregate_typed_eav(
255
+ "score", operation: :sum, scope: "t1"
256
+ )
257
+ ```
258
+
259
+ `aggregate_typed_eav` requires `operation: :min`, `:max`, or `:sum` and returns
260
+ one SQL-calculated scalar over the caller's host set. Integer fields return
261
+ Integers; Decimal and Percentage fields preserve `BigDecimal` precision, with
262
+ no Float conversion. Percentage values remain stored fractions, not formatted
263
+ percent strings. Missing rows and explicit NULLs are ignored. With no non-NULL
264
+ values, min/max return `nil` and sum returns the field's typed zero.
265
+
266
+ The same host filtering, partition visibility, STI, and pagination rules as
267
+ distinct queries apply. Only Integer/Decimal families (including Percentage)
268
+ with a single numeric cell are supported. Reference IDs, text, collections,
269
+ and multi-cell Currency are rejected; the gem does not silently sum identifiers
270
+ or combine currencies. No values or host records are loaded to compute the result.
271
+
200
272
  ### Available Operators
201
273
 
202
274
  | Operator | Works On | Description |
@@ -1082,6 +1154,55 @@ Production code rarely calls either — they exist for test isolation and
1082
1154
  for the rare case where a host app wants to fully unwire the gem in a
1083
1155
  specific request lifecycle.
1084
1156
 
1157
+ ## In-memory typed-value changes
1158
+
1159
+ ```ruby
1160
+ contact.set_typed_eav_value("age", 41)
1161
+ contact.typed_eav_changes # => {"age" => [40, 41]}
1162
+ contact.save!
1163
+ contact.typed_eav_changes # => {}
1164
+ ```
1165
+
1166
+ `typed_eav_changes` reports logical `[before, after]` pairs for pending changes
1167
+ on this host's in-memory `typed_values` target. It covers named setters,
1168
+ `typed_eav_attributes=`, nested `typed_values_attributes=`, association builds,
1169
+ edits to target Values, and `mark_for_destruction`/nested `_destroy`. Multi-cell
1170
+ fields such as Currency retain their logical shape; returned hashes, pairs,
1171
+ and mutable values are copies. Same-value assignments, reversions, and logical
1172
+ `nil`-to-`nil` changes are omitted, including creating/removing a NULL value row.
1173
+ Invalid input reports the cast logical result without discarding validation errors.
1174
+
1175
+ Failed saves retain pending state; successful saves and reload clear it. An
1176
+ outer rollback follows Active Record's restored child dirty state. The API
1177
+ resolves effective field names using this record's partition precedence, and
1178
+ does not load all persisted Values merely to inspect an untouched host.
1179
+
1180
+ After saving, `saved_typed_eav_changes` exposes the most recent successful
1181
+ host save's logical pairs:
1182
+
1183
+ ```ruby
1184
+ contact.set_typed_eav_value("age", 42)
1185
+ contact.save!
1186
+ contact.saved_typed_eav_changes # => {"age" => [41, 42]}
1187
+ contact.typed_eav_changes # => {}
1188
+ ```
1189
+
1190
+ Saved changes are available in normal host `after_save` callbacks, including
1191
+ values assigned by `before_save`. Each successful save replaces the snapshot;
1192
+ a no-op save replaces it with `{}`. Failed validation or a save-callback
1193
+ exception preserves the previous successful snapshot. Reload and an outer
1194
+ transaction rollback clear saved changes. These are successful-save semantics,
1195
+ not proof of a durable commit: use `after_commit` when external effects must
1196
+ wait for commit. Exceptions after a transaction has already committed cannot
1197
+ undo persisted data. Neither dirty API requires versioning or adds audit rows.
1198
+
1199
+ This is in-memory editing state, not audit history. Independently loaded/saved
1200
+ Values, reassignment of an existing Value's field identity, SQL/`delete_all`,
1201
+ and collection operations that immediately remove rows from the host target
1202
+ are not tracked. Use nested destruction or `mark_for_destruction` for tracked
1203
+ removal. Reduced `BulkUpsert` does not update unrelated in-memory host objects;
1204
+ reload them after external writes. Bulk reads do not create dirty state.
1205
+
1085
1206
  ## Versioning
1086
1207
 
1087
1208
  `typed_eav` ships an opt-in append-only audit log for changes to typed
@@ -1367,9 +1488,50 @@ The gem creates five tables:
1367
1488
  create, update, and destroy events; it retains durable entity identity even
1368
1489
  when the live Value row is later removed
1369
1490
 
1491
+ ## Read-only schema previews
1492
+
1493
+ ```ruby
1494
+ schema = TypedEAV::SchemaPortability.export_schema(
1495
+ entity_type: "Contact", scope: "t1"
1496
+ )
1497
+ schema["fields"].first["required"] = true
1498
+ preview = TypedEAV::SchemaPortability.preview_schema(schema, on_conflict: :overwrite)
1499
+ preview["fields"].first["changes"]
1500
+ # => {"required" => {"from" => false, "to" => true}}
1501
+ preview["risks"] # => ["required_false_to_true"]
1502
+ ```
1503
+
1504
+ The preview compares a version-1 portable export with the database's exact
1505
+ target partition. It requires the envelope's `entity_type`, `scope`, and
1506
+ `parent_scope` to match every entry; mixed-target payloads and duplicate
1507
+ identities are rejected. This intentionally stricter preview input does not
1508
+ change `import_schema` or silently retarget definitions.
1509
+
1510
+ The plain Hash result contains envelope metadata, `summary`, `fields`,
1511
+ `sections`, `risks`, and `importable`. Each entry includes its exact `identity`,
1512
+ `status` (`unchanged`, `added`, `changed`, or `conflict`), conditional `action`,
1513
+ and attribute `changes` with `from`/`to` values. Field entries also contain
1514
+ option-row `added`, `removed`, and `changed` lists, matched by option value.
1515
+ Raw option ordering and key-presence differences remain visible because the
1516
+ importer compares the complete exported payload, not just equivalent settings.
1517
+
1518
+ `on_conflict: :error` blocks divergent definitions; `:skip` leaves them alone;
1519
+ `:overwrite` predicts an update. Type swaps always produce an error action,
1520
+ even under skip/overwrite. Risks flag type changes, removed options, newly
1521
+ required fields, and changes to options, defaults, or field dependencies.
1522
+ Omitted target definitions are **not deletions** and are not listed as such.
1523
+
1524
+ `importable: true` means no known conflict-policy/type-swap blocker was found,
1525
+ not that validation or a later import is guaranteed to succeed. Actions are
1526
+ conditional predictions: a blocking error aborts the existing transactional
1527
+ import, including otherwise acceptable additions. Previewing does not save
1528
+ definitions, run mutation/validation callbacks, enqueue jobs, execute DDL, or
1529
+ convert values. It is an advisory snapshot, not a lock or reservation; model
1530
+ validations and concurrent changes still apply to the actual import.
1531
+
1370
1532
  ## Architecture
1371
1533
 
1372
- Internal module layout as of 0.7.0. Most consumers never reach for these directly — the public surface is the `has_typed_eav` macro and the instance/class methods it installs — but the split matters if you're extending the gem, debugging an integration, or evaluating it for production. Decisions are anchored by ADR-0001 through ADR-0013.
1534
+ Current internal module layout. Most consumers never reach for these directly — the public surface is the `has_typed_eav` macro and the instance/class methods it installs — but the split matters if you're extending the gem, debugging an integration, or evaluating it for production. Decisions are anchored by ADR-0001 through ADR-0013.
1373
1535
 
1374
1536
  ### Macro entry: `HasTypedEav`
1375
1537
 
@@ -1410,6 +1572,11 @@ TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
1410
1572
 
1411
1573
  `QueryBuilder` is the single place that decides "given this field and this operator, which column and which SQL fragment?" `FilterQuery` never builds SQL fragments directly; `EntityQuery` never touches columns. Splitting the two altitudes keeps custom field types extending only the column-mapping surface (`value_column`, `operators`, `operator_column`) without ever subclassing `FilterQuery`.
1412
1574
 
1575
+ Scalar ordering and summaries are a separate `EntityQuery` delegation to
1576
+ `ScalarQuery`: it resolves one winning definition, checks scalar support, and
1577
+ builds SQL over the field's declared native column. It does not add operators
1578
+ to the filter DSL or load the host/Value graph to calculate summaries.
1579
+
1413
1580
  ### Bulk reads: `BulkRead`
1414
1581
 
1415
1582
  `typed_eav_hash_for(records)` (the plural read) routes through `TypedEAV::BulkRead`. Given a record collection and an effective `(scope, parent_scope)`, it:
@@ -1432,6 +1599,44 @@ measure their own scope cardinality, selected fields, hydration, and contention.
1432
1599
 
1433
1600
  Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMethods` and use the same partition helpers but without batching.
1434
1601
 
1602
+ Use `fields:` to load only the values needed by a view or export:
1603
+
1604
+ ```ruby
1605
+ Contact.typed_eav_hash_for(contacts, fields: [:name, :score])
1606
+ # => {123 => {"name" => "Ada", "score" => 42}, ...}
1607
+ ```
1608
+
1609
+ Omitting `fields:` (or passing `nil`) retains the all-fields behavior. A single
1610
+ String/Symbol or an enumerable of names is accepted; duplicates are removed.
1611
+ Unknown names and names unavailable in an individual record's partition are
1612
+ omitted, as are missing value rows. An explicitly stored NULL remains `nil`.
1613
+ `fields: []` returns an empty inner hash for each record without definition or
1614
+ value queries (the supplied collection itself may still need loading). Selected
1615
+ winning field IDs constrain the value query before hydration, so unrequested
1616
+ values and their field readers are not loaded or evaluated. Definition lookup
1617
+ remains batched across the records' partitions.
1618
+
1619
+ To explicitly reuse already-loaded values:
1620
+
1621
+ ```ruby
1622
+ contacts = Contact.where(tenant_id: "t1").includes(typed_values: :field).to_a
1623
+ Contact.typed_eav_hash_for(contacts, fields: [:name], source: :preloaded)
1624
+ ```
1625
+
1626
+ The default `source: :database` still fetches persisted values afresh, even
1627
+ when associations are loaded or edited in memory. `:preloaded` uses the caller's
1628
+ association targets, including unsaved Value builds/assignments, without saving
1629
+ or mutating them. It performs one fresh batched definition query to choose
1630
+ current winners, but no Value or field-association preload queries. This is a
1631
+ value snapshot, not a guarantee of current database contents or frozen schema.
1632
+
1633
+ Every host's `typed_values` association must be loaded, as must each retained
1634
+ Value's `field` association; incomplete preloads raise `ArgumentError` instead
1635
+ of silently issuing N+1 queries. With `fields:`, unselected Values need no field
1636
+ preload. With all fields selected, all field associations must be loaded
1637
+ (including loaded `nil` for orphans). `fields: []` needs neither associations
1638
+ nor definition/value queries. Only `:database` and `:preloaded` are valid sources.
1639
+
1435
1640
  ### Bulk writes: `BulkWrite`
1436
1641
 
1437
1642
  `bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`,
@@ -6,23 +6,42 @@ 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
28
  # 3. winning_ids_by_tuple — one `Partition::DefinitionBatch` query, then
14
29
  # extract the winning field ids per tuple
15
- # 4. preload_values single SELECT across ALL records
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
  #
@@ -31,34 +50,75 @@ module TypedEAV
31
50
  # class. Mixed, unrelated input would still be invalid; STI subclasses pass
32
51
  # via `records.all?(host_class)`.
33
52
  class BulkRead
34
- def initialize(host_class:, records:)
53
+ def initialize(host_class:, records:, fields: nil, source: :database)
35
54
  @host_class = host_class
36
55
  @records = records
56
+ @fields = fields
57
+ @source = source
37
58
  end
38
59
 
39
60
  def to_hash
61
+ validate_source!
62
+
40
63
  records = coerce_records
41
64
  return {} if records.empty?
42
65
 
43
66
  validate_record_classes!(records)
44
67
 
45
- tuples_by_record = group_by_tuple(records)
46
- winning_ids_by_tuple = winning_ids_by_tuple(tuples_by_record.values.uniq)
47
- values_by_record_id = preload_values(records)
68
+ selected_names = normalize_fields
69
+ return empty_results(records) if selected_names == []
48
70
 
49
- 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)
50
80
  end
51
81
 
52
82
  private
53
83
 
54
84
  attr_reader :host_class
55
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
+
56
112
  def coerce_records
57
113
  raise ArgumentError, "typed_eav_hash_for requires an Enumerable of records, got nil" if @records.nil?
58
114
 
59
115
  @records.to_a
60
116
  end
61
117
 
118
+ def empty_results(records)
119
+ records.to_h { |record| [record.id, {}] }
120
+ end
121
+
62
122
  def validate_record_classes!(records)
63
123
  return if records.all?(host_class)
64
124
 
@@ -73,26 +133,82 @@ module TypedEAV
73
133
  records.index_with { |r| [r.typed_eav_scope, r.typed_eav_parent_scope] }
74
134
  end
75
135
 
76
- def winning_ids_by_tuple(tuples)
136
+ def winning_ids_by_tuple(tuples, selected_names)
77
137
  TypedEAV::Partition::DefinitionBatch
78
138
  .resolve(entity_type: host_class.polymorphic_name, tuples: tuples)
79
- .transform_values { |fields_by_name| fields_by_name.transform_values(&:id) }
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)
142
+
143
+ winners[name] = field.id
144
+ end
145
+ end
80
146
  end
81
147
 
82
- def preload_values(records)
83
- rows = TypedEAV::Value
84
- .includes(:field)
85
- .where(entity_type: host_class.polymorphic_name, entity_id: records.map(&:id))
86
- .to_a
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
87
161
  rows.group_by(&:entity_id)
88
162
  end
89
163
 
90
- def build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id)
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
179
+
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
197
+ end
198
+
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
204
+ end
205
+
206
+ def build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id, selected_names)
91
207
  records.each_with_object({}) do |record, result|
92
208
  tuple_key = tuples_by_record[record]
93
209
  winning_ids_by_name = winning_ids_by_tuple.fetch(tuple_key, {})
94
210
  rows = values_by_record_id.fetch(record.id, [])
95
- 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)
96
212
  end
97
213
  end
98
214
 
@@ -103,12 +219,17 @@ module TypedEAV
103
219
  # for the name, only its row may surface (scoped-beats-global collision
104
220
  # precedence). When no winner is registered (definition deleted while
105
221
  # values remain), fall back to first-wins so the hash isn't lossy.
106
- def inner_hash_for(value_rows, winning_ids_by_name)
222
+ def inner_hash_for(value_rows, winning_ids_by_name, selected_names)
107
223
  value_rows.each_with_object({}) do |tv, inner|
108
224
  next unless tv.field
109
225
 
110
226
  name = tv.field.name
111
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
112
233
  next assign_with_precedence(inner, name, tv, winning_id) if winning_id
113
234
 
114
235
  inner[name] = tv.value unless inner.key?(name)