typed_eav 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -2
- data/README.md +223 -3
- data/lib/typed_eav/bulk_read.rb +146 -58
- data/lib/typed_eav/bulk_upsert.rb +10 -14
- data/lib/typed_eav/entity_query.rb +156 -4
- data/lib/typed_eav/filter_query.rb +2 -2
- data/lib/typed_eav/has_typed_eav/dirty_tracking.rb +207 -0
- data/lib/typed_eav/has_typed_eav/instance_methods.rb +2 -2
- data/lib/typed_eav/has_typed_eav.rb +7 -3
- data/lib/typed_eav/partition/definition_batch.rb +70 -0
- data/lib/typed_eav/partition.rb +2 -0
- data/lib/typed_eav/scalar_query.rb +228 -0
- data/lib/typed_eav/schema_portability/preview.rb +379 -0
- data/lib/typed_eav/schema_portability.rb +20 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioned.rb +8 -6
- data/lib/typed_eav.rb +1 -0
- metadata +5 -1
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,7 +7,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
|
|
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
|
+
|
|
71
|
+
## [0.7.1] - 2026-09-04
|
|
72
|
+
|
|
73
|
+
A focused patch release for inherited Active Record hosts and multi-partition
|
|
74
|
+
bulk upserts. No new database migrations or public API changes are required.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- Resolve inherited and namespaced Active Record host definitions, filters,
|
|
79
|
+
bulk reads, writes, and registry/versioning opt-ins through Rails'
|
|
80
|
+
canonical polymorphic name. STI subclasses now consistently share their
|
|
81
|
+
base class's EAV schema and stored values while queries retain their host
|
|
82
|
+
class restriction. Partition isolation and most-specific field precedence
|
|
83
|
+
are preserved. (#42)
|
|
84
|
+
|
|
85
|
+
### Performance
|
|
86
|
+
|
|
87
|
+
- Batch BulkUpsert field-definition resolution into one SELECT per transaction
|
|
88
|
+
unit while preserving exact tuple isolation and definition precedence. A
|
|
89
|
+
20-partition regression case now issues one definition SELECT instead of 20;
|
|
90
|
+
chunked transactions intentionally issue one per chunk. BulkRead and
|
|
91
|
+
BulkUpsert share the internal batched resolver. This is a query-count
|
|
92
|
+
improvement, not a universal throughput claim. (#43)
|
|
93
|
+
|
|
94
|
+
### Compatibility and verification
|
|
95
|
+
|
|
96
|
+
- BulkUpsert's reduced-semantics acknowledgement, validation, transaction
|
|
97
|
+
boundaries, and callback/versioning behavior are unchanged.
|
|
98
|
+
- Regression coverage includes true STI and namespaced hosts, public
|
|
99
|
+
read/filter/write paths, partition isolation, definition precedence, and
|
|
100
|
+
all/chunk transaction behavior.
|
|
11
101
|
|
|
12
102
|
## [0.7.0] - 2026-08-18
|
|
13
103
|
|
|
@@ -580,7 +670,9 @@ worked examples.
|
|
|
580
670
|
|
|
581
671
|
Initial release.
|
|
582
672
|
|
|
583
|
-
[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
|
|
675
|
+
[0.7.1]: https://github.com/dchuk/typed_eav/releases/tag/v0.7.1
|
|
584
676
|
[0.7.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.7.0
|
|
585
677
|
[0.6.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.6.0
|
|
586
678
|
[0.5.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.5.0
|
data/README.md
CHANGED
|
@@ -119,6 +119,11 @@ tags.field_options.create!([
|
|
|
119
119
|
])
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
When deriving `entity_type` from a model class, use
|
|
123
|
+
`Contact.polymorphic_name`. Rails stores polymorphic associations under that
|
|
124
|
+
canonical name, which is the base-class type for STI hosts and respects the
|
|
125
|
+
application's namespaced-polymorphism setting.
|
|
126
|
+
|
|
122
127
|
### 3. Set values on records
|
|
123
128
|
|
|
124
129
|
```ruby
|
|
@@ -192,6 +197,78 @@ Contact.where(company_id: 42)
|
|
|
192
197
|
.limit(25)
|
|
193
198
|
```
|
|
194
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
|
+
|
|
195
272
|
### Available Operators
|
|
196
273
|
|
|
197
274
|
| Operator | Works On | Description |
|
|
@@ -1077,6 +1154,55 @@ Production code rarely calls either — they exist for test isolation and
|
|
|
1077
1154
|
for the rare case where a host app wants to fully unwire the gem in a
|
|
1078
1155
|
specific request lifecycle.
|
|
1079
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
|
+
|
|
1080
1206
|
## Versioning
|
|
1081
1207
|
|
|
1082
1208
|
`typed_eav` ships an opt-in append-only audit log for changes to typed
|
|
@@ -1157,7 +1283,7 @@ directly:
|
|
|
1157
1283
|
|
|
1158
1284
|
```ruby
|
|
1159
1285
|
TypedEAV::ValueVersion
|
|
1160
|
-
.where(entity_type: contact.class.
|
|
1286
|
+
.where(entity_type: contact.class.polymorphic_name, entity_id: contact.id, field_id: age_field.id)
|
|
1161
1287
|
.order(changed_at: :desc, id: :desc)
|
|
1162
1288
|
# => [<ValueVersion change_type: "destroy" before: {"integer_value" => 42} after: {} value_id: nil>,
|
|
1163
1289
|
# <ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42} value_id: nil>,
|
|
@@ -1176,7 +1302,7 @@ exports) — drop the `field_id` filter:
|
|
|
1176
1302
|
|
|
1177
1303
|
```ruby
|
|
1178
1304
|
TypedEAV::ValueVersion
|
|
1179
|
-
.where(entity_type: contact.class.
|
|
1305
|
+
.where(entity_type: contact.class.polymorphic_name, entity_id: contact.id)
|
|
1180
1306
|
.order(changed_at: :desc, id: :desc)
|
|
1181
1307
|
# => all version rows for every typed field on this contact, most-recent-first.
|
|
1182
1308
|
# Includes :create, :update, and :destroy events across every field the
|
|
@@ -1362,9 +1488,50 @@ The gem creates five tables:
|
|
|
1362
1488
|
create, update, and destroy events; it retains durable entity identity even
|
|
1363
1489
|
when the live Value row is later removed
|
|
1364
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
|
+
|
|
1365
1532
|
## Architecture
|
|
1366
1533
|
|
|
1367
|
-
|
|
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.
|
|
1368
1535
|
|
|
1369
1536
|
### Macro entry: `HasTypedEav`
|
|
1370
1537
|
|
|
@@ -1405,6 +1572,11 @@ TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
|
|
|
1405
1572
|
|
|
1406
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`.
|
|
1407
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
|
+
|
|
1408
1580
|
### Bulk reads: `BulkRead`
|
|
1409
1581
|
|
|
1410
1582
|
`typed_eav_hash_for(records)` (the plural read) routes through `TypedEAV::BulkRead`. Given a record collection and an effective `(scope, parent_scope)`, it:
|
|
@@ -1416,6 +1588,10 @@ TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
|
|
|
1416
1588
|
3. Returns a `{record_id => {field_name => value}}` map while skipping orphaned
|
|
1417
1589
|
values and preserving logical missingness.
|
|
1418
1590
|
|
|
1591
|
+
Definitions, filters, reads, registry entries, and writes all use the host's
|
|
1592
|
+
Rails `polymorphic_name`, so an STI leaf class reads and queries the same rows
|
|
1593
|
+
written under its base-class polymorphic type.
|
|
1594
|
+
|
|
1419
1595
|
The final production characterization reduced the 1,002 SQL statements observed
|
|
1420
1596
|
across 1,000 scopes to three for the same BulkRead shape. This is a statement-
|
|
1421
1597
|
count result, not a representative throughput claim; applications should still
|
|
@@ -1423,6 +1599,44 @@ measure their own scope cardinality, selected fields, hydration, and contention.
|
|
|
1423
1599
|
|
|
1424
1600
|
Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMethods` and use the same partition helpers but without batching.
|
|
1425
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
|
+
|
|
1426
1640
|
### Bulk writes: `BulkWrite`
|
|
1427
1641
|
|
|
1428
1642
|
`bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`,
|
|
@@ -1459,6 +1673,12 @@ checks, and Value validation callbacks remain; host callbacks and validations,
|
|
|
1459
1673
|
Value persistence callbacks, versioning, delete shorthand, and per-record
|
|
1460
1674
|
savepoint isolation are skipped.
|
|
1461
1675
|
|
|
1676
|
+
Within each `transaction: :all` unit—or each requested chunk—the upsert path
|
|
1677
|
+
resolves every record partition through one batched field-definition SELECT.
|
|
1678
|
+
It shares BulkRead's internal tuple resolver, retaining global, scope-only, and
|
|
1679
|
+
full-tuple precedence independently for each record without broadening tenant
|
|
1680
|
+
visibility.
|
|
1681
|
+
|
|
1462
1682
|
`BulkWrite` and `BulkRead` are siblings — one read path, one write path — but they don't share a base class. Per [ADR-0005](docs/adr/0005-keep-phase-six-modules-independent.md), keeping them independent preserves the option to evolve each on its own schedule.
|
|
1463
1683
|
|
|
1464
1684
|
### Per-record reads/writes: `InstanceMethods`
|