typed_eav 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -1
- data/README.md +206 -1
- data/lib/typed_eav/bulk_read.rb +143 -22
- data/lib/typed_eav/entity_query.rb +154 -2
- data/lib/typed_eav/has_typed_eav/dirty_tracking.rb +207 -0
- data/lib/typed_eav/has_typed_eav.rb +6 -2
- data/lib/typed_eav/scalar_query.rb +228 -0
- data/lib/typed_eav/schema_portability/preview.rb +379 -0
- data/lib/typed_eav/schema_portability.rb +20 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 026326e328ecf153d7ba72eeac001470ba67307ed17bf5cef68f52548b6abd47
|
|
4
|
+
data.tar.gz: 6a76dce02983e81312d67662de6837804b51e691ab0d79dbfb13cacc2b25c19c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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`,
|
data/lib/typed_eav/bulk_read.rb
CHANGED
|
@@ -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
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
values_by_record_id = preload_values(records)
|
|
68
|
+
selected_names = normalize_fields
|
|
69
|
+
return empty_results(records) if selected_names == []
|
|
48
70
|
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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)
|