typed_eav 0.6.0 → 0.7.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 +157 -0
- data/README.md +241 -60
- data/app/models/typed_eav/field/base.rb +77 -27
- data/app/models/typed_eav/field/currency.rb +43 -0
- data/app/models/typed_eav/field/file.rb +1 -1
- data/app/models/typed_eav/field/image.rb +1 -1
- data/app/models/typed_eav/field/reference.rb +11 -0
- data/app/models/typed_eav/section.rb +11 -5
- data/app/models/typed_eav/value.rb +95 -32
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +1 -1
- data/db/migrate/20260712000000_enforce_parent_scope_invariant.rb +4 -0
- data/db/migrate/20260816000000_use_partial_covering_scalar_indexes.rb +198 -0
- data/lib/typed_eav/bulk_read.rb +41 -9
- data/lib/typed_eav/bulk_upsert.rb +141 -0
- data/lib/typed_eav/bulk_write.rb +65 -39
- data/lib/typed_eav/config.rb +19 -21
- data/lib/typed_eav/csv_mapper.rb +1 -1
- data/lib/typed_eav/engine.rb +16 -27
- data/lib/typed_eav/entity_query.rb +32 -7
- data/lib/typed_eav/event_dispatcher.rb +21 -30
- data/lib/typed_eav/field/typed_storage.rb +48 -0
- data/lib/typed_eav/field_deletion.rb +75 -0
- data/lib/typed_eav/query_builder.rb +17 -27
- data/lib/typed_eav/registry.rb +7 -8
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioning/subscriber.rb +25 -29
- data/lib/typed_eav/versioning.rb +59 -41
- data/lib/typed_eav.rb +2 -0
- metadata +4 -1
data/README.md
CHANGED
|
@@ -2,22 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Add dynamic custom fields to ActiveRecord models at runtime, backed by **native database typed columns** instead of jsonb blobs.
|
|
4
4
|
|
|
5
|
-
TypedEAV uses a hybrid EAV (Entity-Attribute-Value) pattern where each value type gets its own column (`integer_value`, `date_value`, `string_value`, etc.) in the values table. This
|
|
5
|
+
TypedEAV uses a hybrid EAV (Entity-Attribute-Value) pattern where each value type gets its own column (`integer_value`, `date_value`, `string_value`, etc.) in the values table. This lets PostgreSQL index, sort, and enforce constraints on custom field data while the Field owns input normalization and validation.
|
|
6
6
|
|
|
7
7
|
## Why Typed Columns?
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
JSONB is a useful fit when an application owns stable paths and wants expression B-tree indexes or GIN containment indexes. A single JSONB document is not inherently faster or slower than TypedEAV; the right choice depends on access patterns, selectivity, update shape, and operational constraints. For example, an application-owned expression index may support a stable path:
|
|
10
10
|
|
|
11
11
|
```sql
|
|
12
12
|
CAST(value_meta->>'const' AS bigint) = 42
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
This
|
|
16
|
-
|
|
17
|
-
- **No B-tree indexes** on the actual values (only GIN for jsonb containment)
|
|
18
|
-
- **Runtime CAST overhead** on every query
|
|
19
|
-
- **No database-level type enforcement** (a "number" could be stored as a string)
|
|
20
|
-
- **The query planner can't optimize** range scans, sorts, or joins
|
|
15
|
+
This can work well for stable, known paths. It does not provide the same column-level schema and typed-value contract as TypedEAV, and arbitrary paths still require application-owned index and validation decisions. GIN is useful for containment workloads; expression B-trees are useful for selected stable scalar paths.
|
|
21
16
|
|
|
22
17
|
TypedEAV stores values in native columns, so queries become:
|
|
23
18
|
|
|
@@ -25,7 +20,7 @@ TypedEAV stores values in native columns, so queries become:
|
|
|
25
20
|
WHERE integer_value = 42
|
|
26
21
|
```
|
|
27
22
|
|
|
28
|
-
|
|
23
|
+
TypedEAV supplies stable typed columns and ordinary per-type indexes. Range scans and sorts can use those indexes, while each Field casts and validates the operand according to its own semantics before the query reaches the typed column. Neither design is a universal storage winner; choose from measured workload fit.
|
|
29
24
|
|
|
30
25
|
## Compatibility
|
|
31
26
|
|
|
@@ -157,7 +152,7 @@ contact.typed_eav_hash # => { "age" => 40, "status" => "active", ..
|
|
|
157
152
|
|
|
158
153
|
### 4. Query with the DSL
|
|
159
154
|
|
|
160
|
-
|
|
155
|
+
Queries use native typed columns and the indexes shipped for each type. The Field remains the owner of operand casting and validation; the query builder receives a field-normalized value rather than applying a generic Active Record cast.
|
|
161
156
|
|
|
162
157
|
```ruby
|
|
163
158
|
# Short form - single field filter
|
|
@@ -217,25 +212,108 @@ Contact.where(company_id: 42)
|
|
|
217
212
|
| `:is_null` | all | Value is NULL |
|
|
218
213
|
| `:is_not_null` | all | Value is not NULL |
|
|
219
214
|
|
|
215
|
+
### Optional trigram indexing for string search
|
|
216
|
+
|
|
217
|
+
TypedEAV keeps its partial-covering `text_pattern_ops` B-tree as the default
|
|
218
|
+
string index. Equality uses that B-tree, while `:starts_with`, `:contains`, and
|
|
219
|
+
`:ends_with` use `ILIKE`; `:not_contains` uses `NOT ILIKE`. The gem does not
|
|
220
|
+
require or install `pg_trgm` and does not create a trigram index automatically.
|
|
221
|
+
|
|
222
|
+
An application with frequent positive `ILIKE` searches containing at least
|
|
223
|
+
three useful characters may evaluate its own partial GIN index. This is a
|
|
224
|
+
workload decision: the representative benchmark used GIN for measured prefix,
|
|
225
|
+
contains, suffix, and escaped-literal patterns, but not for `NOT ILIKE` or
|
|
226
|
+
one/two-character probes. It does not prove that every positive pattern or
|
|
227
|
+
selectivity will benefit. A `lower(string_value) LIKE ...` expression index is
|
|
228
|
+
not equivalent to TypedEAV's public `ILIKE`, and the benchmark did not justify
|
|
229
|
+
GiST.
|
|
230
|
+
|
|
231
|
+
Application owners should check extension availability and deploy-role
|
|
232
|
+
privileges in preproduction, then create the extension and index in their own
|
|
233
|
+
migrations. Use nontransactional `CREATE INDEX CONCURRENTLY`, a stable
|
|
234
|
+
application-specific name, and workload-specific `EXPLAIN (ANALYZE, BUFFERS,
|
|
235
|
+
WAL, SETTINGS)` plus storage and write-WAL measurements. Rollback should drop
|
|
236
|
+
only the application-owned index concurrently; do not drop the database-wide
|
|
237
|
+
extension because other objects may share it. See
|
|
238
|
+
[ADR 0009](docs/adr/0009-string-search-indexing.md) and the
|
|
239
|
+
[benchmark guide](bench/README.md#phase-3-string-search-benchmark) for the
|
|
240
|
+
operator matrix, measured costs, SQL, and evidence limits.
|
|
241
|
+
|
|
242
|
+
### Optional planner statistics for correlated field/value predicates
|
|
243
|
+
|
|
244
|
+
TypedEAV does not install PostgreSQL extended-statistics objects. An application
|
|
245
|
+
whose own plans persistently misestimate `field_id = ... AND typed_value = ...`
|
|
246
|
+
may evaluate application-owned `dependencies` statistics for that exact typed
|
|
247
|
+
column. Dependency statistics apply to compatible equality and `IN` clauses,
|
|
248
|
+
not range predicates. `mcv` describes common value combinations, while
|
|
249
|
+
`ndistinct` primarily informs distinct-group estimates; neither should be added
|
|
250
|
+
without workload evidence.
|
|
251
|
+
|
|
252
|
+
The representative PostgreSQL 17 benchmark found better aggregate equality
|
|
253
|
+
estimates from dependencies, but no plan-shape or demonstrated runtime benefit.
|
|
254
|
+
Its combined object mirrored MCV on the four changed probes because matching MCV
|
|
255
|
+
groups supplied those estimates. The experiment's target of 100 was a controlled
|
|
256
|
+
input, not a universal recommendation. One probe labeled common-date equality
|
|
257
|
+
actually queried an absent date and returned zero rows; it is not evidence about
|
|
258
|
+
common-date estimates.
|
|
259
|
+
|
|
260
|
+
Applications should own stable names and DDL, select targets from representative
|
|
261
|
+
data, run `ANALYZE`, and compare estimated/actual rows, plans, runtime, planning
|
|
262
|
+
cost, maintenance cost, and data churn before retaining an object. Coordinate
|
|
263
|
+
ownership in shared databases, inspect catalog definitions before changing
|
|
264
|
+
objects, and drop only application-owned statistics during rollback. See
|
|
265
|
+
[ADR 0010](docs/adr/0010-planner-statistics-policy.md) and the
|
|
266
|
+
[benchmark guide](bench/README.md#phase-4a-planner-extended-statistics) for safe
|
|
267
|
+
evaluation SQL and evidence limits.
|
|
268
|
+
|
|
269
|
+
### Multi-filter query strategy
|
|
270
|
+
|
|
271
|
+
TypedEAV retains its current multi-filter query shape: it resolves each field,
|
|
272
|
+
builds the corresponding typed value subquery, and chains those results onto
|
|
273
|
+
the host relation with `id IN (...)`. There is no adaptive strategy or alternate
|
|
274
|
+
production query API.
|
|
275
|
+
|
|
276
|
+
A PostgreSQL 17 benchmark compared the shipped shape with `INTERSECT`,
|
|
277
|
+
correlated `EXISTS`, and direct grouped `HAVING` under resource-capped
|
|
278
|
+
co-tenancy. The run retained 2,940 attempts, including 622 right-censored
|
|
279
|
+
timeouts, and 294 representative identity oracles. Twelve oracles timed out, so
|
|
280
|
+
representative equivalence is unproved even though all 282 completed oracles
|
|
281
|
+
matched and the smaller 98-oracle smoke matched. Alternatives remain
|
|
282
|
+
research-only. Grouped `HAVING` is additionally ineligible for missing-value,
|
|
283
|
+
host-universe complement, and empty-filter semantics.
|
|
284
|
+
|
|
285
|
+
The result also does not establish valid buffer comparisons or 20-distinct-
|
|
286
|
+
field scaling. A parser defect made every derived buffer total a false zero;
|
|
287
|
+
nonzero counters remain recoverable from the retained raw plans. The
|
|
288
|
+
20-predicate workloads repeat ten fields, and the skewed 10/20 workloads repeat
|
|
289
|
+
five. Future research must repair and validate buffer extraction, exercise
|
|
290
|
+
actual 10/20 distinct fields, complete every representative equivalence oracle,
|
|
291
|
+
cover the full scope/NULL/missing/polymorphic/error contract, and show the
|
|
292
|
+
pre-registered p95, planning-time, buffer, and plan-shape gates before any
|
|
293
|
+
adaptive or replacement proposal. See
|
|
294
|
+
[ADR 0011](docs/adr/0011-multi-filter-query-strategy.md) and the
|
|
295
|
+
[benchmark guide](bench/README.md#phase-4b-multi-filter-query-shapes).
|
|
296
|
+
|
|
220
297
|
### How Type Inference Works
|
|
221
298
|
|
|
222
|
-
|
|
299
|
+
The owning Field casts and validates query operands before SQL generation;
|
|
300
|
+
Active Record supplies the SQL bind plumbing:
|
|
223
301
|
|
|
224
302
|
```ruby
|
|
225
|
-
#
|
|
303
|
+
# The Integer Field casts and validates the operand before SQL generation
|
|
226
304
|
Contact.with_field("age", :gt, "21")
|
|
227
305
|
# SQL: WHERE integer_value > 21 (not '21')
|
|
228
306
|
|
|
229
|
-
#
|
|
307
|
+
# The Date Field owns date parsing and validation
|
|
230
308
|
Contact.with_field("birthday", :lt, "2000-01-01")
|
|
231
309
|
# SQL: WHERE date_value < '2000-01-01'::date
|
|
232
310
|
|
|
233
|
-
# Boolean
|
|
311
|
+
# The Boolean Field owns truthy/falsy casting
|
|
234
312
|
Contact.with_field("active", "true")
|
|
235
313
|
# SQL: WHERE boolean_value = TRUE
|
|
236
314
|
```
|
|
237
315
|
|
|
238
|
-
|
|
316
|
+
Field-owned casting keeps query operands aligned with write semantics, including strict range/array shapes and specialized fields such as Currency and Reference. The resulting normalized operand is bound against the Field's typed column; Active Record supplies SQL bind plumbing, not the field's domain semantics.
|
|
239
317
|
|
|
240
318
|
## Forms
|
|
241
319
|
|
|
@@ -373,6 +451,16 @@ end
|
|
|
373
451
|
|
|
374
452
|
Both are exception-safe via `ensure` and nest cleanly.
|
|
375
453
|
|
|
454
|
+
`unscoped` is an explicit administrative/analytics escape hatch, not the
|
|
455
|
+
ordinary tenant request path. It keeps every same-name definition across the
|
|
456
|
+
visible partitions and unions their matches for each filter. For broad audits
|
|
457
|
+
or migrations, bound the definition universe to the work you actually need and
|
|
458
|
+
batch the job at an application-owned boundary. TypedEAV does not prescribe a
|
|
459
|
+
universal limit or batch size; measure generated SQL, planning/execution,
|
|
460
|
+
memory, and workload interference in your application. Keep normal request
|
|
461
|
+
traffic on scoped resolution so global, scope-only, and full-tuple definitions
|
|
462
|
+
collapse to the most-specific match.
|
|
463
|
+
|
|
376
464
|
### Explicit `scope:` override
|
|
377
465
|
|
|
378
466
|
Any query method accepts `scope:` as an override for admin tools and tests:
|
|
@@ -487,12 +575,26 @@ within one workspace" has no semantic resolution path; the row would never
|
|
|
487
575
|
match any record's resolver. The paired partial unique indexes rely on this
|
|
488
576
|
invariant.
|
|
489
577
|
|
|
578
|
+
The shipped migration chain also includes
|
|
579
|
+
`EnforceParentScopeInvariant`, which declares the database check constraints
|
|
580
|
+
nontransactionally and validates them after its preflight, and
|
|
581
|
+
`UsePartialCoveringScalarIndexes`, which creates the six `*_present` indexes
|
|
582
|
+
before removing their legacy counterparts. Both migrations use
|
|
583
|
+
`disable_ddl_transaction!`; run them through the normal migration command and
|
|
584
|
+
do not wrap them in an application transaction.
|
|
585
|
+
|
|
490
586
|
### Name collisions across scopes
|
|
491
587
|
|
|
492
588
|
When both a global field (`scope: nil`) and a scoped field share a name, the **scoped definition wins** for the partition that owns it: forms render exactly one input (the scoped one), reads return the scoped value, and writes target the scoped row.
|
|
493
589
|
|
|
494
590
|
`TypedEAV.unscoped { Contact.where_typed_eav(...) }` OR-across every partition's matching `field_id` per filter (still AND-ing across filters), so cross-tenant audit queries see every partition's matches — they don't collapse to a single tenant.
|
|
495
591
|
|
|
592
|
+
Because that administrative path constructs work for every matching
|
|
593
|
+
definition, applications should narrow and batch high-cardinality audits rather
|
|
594
|
+
than treating `unscoped` as tenant-request routing. No built-in numeric
|
|
595
|
+
threshold is implied; choose operational bounds from measurements of the
|
|
596
|
+
consuming workload. See [ADR 0012](docs/adr/0012-cross-scope-administrative-query-policy.md).
|
|
597
|
+
|
|
496
598
|
## Field Types
|
|
497
599
|
|
|
498
600
|
| Type | Column | Ruby Type | Options |
|
|
@@ -785,7 +887,7 @@ exactly three method overrides.
|
|
|
785
887
|
|
|
786
888
|
- **`File`:** Same shape as `Field::Image` but without image-specific semantics. Stores `signed_id` in `string_value`; same operator set; same options (`allowed_content_types`, `max_size_bytes`). The Image vs File distinction is by `value.field.class` at runtime — apps that want strict image-only validation set `allowed_content_types: ["image/*"]` on `Field::Image`; `Field::File` is a general-purpose attachment slot.
|
|
787
889
|
|
|
788
|
-
- **Active Storage dependency:** Lazy soft-detect via `defined?(::ActiveStorage::Blob)`. The gem does NOT add Active Storage as a hard dependency — apps that never use Image/File never need to install it. To use Image or File fields, add `gem "activestorage"` to your Gemfile (
|
|
890
|
+
- **Active Storage dependency:** Lazy soft-detect via `defined?(::ActiveStorage::Blob)`. The gem does NOT add Active Storage as a hard dependency — apps that never use Image/File never need to install it. To use Image or File fields, add `gem "activestorage"` to your Gemfile (included in supported Rails versions via the `rails` meta-gem) and run `bin/rails active_storage:install` to create the `active_storage_blobs` / `active_storage_attachments` / `active_storage_variant_records` tables. The mirror precedent is `acts_as_tenant`, which is also soft-detected (see `Config::DEFAULT_SCOPE_RESOLVER`).
|
|
789
891
|
|
|
790
892
|
- **`on_image_attached` hook:** Fires from `after_commit` on `TypedEAV::Value` when a `Field::Image`-typed Value's attachment is added or replaced. Receives `(value, blob)`. Configure via `TypedEAV.configure { |c| c.on_image_attached = ->(v, b) { ... } }`. Hook ordering: runs AFTER versioning (Phase 4) and AFTER `on_value_change` (Phase 3) so it sees the persisted version row and the user-callback context. File attachments do NOT fire this hook — the name is image-specific by design. Use `on_value_change` for a generic value-mutation signal that covers File-typed Values too.
|
|
791
893
|
|
|
@@ -854,8 +956,8 @@ end
|
|
|
854
956
|
The `:rename` change_type fires whenever the field's `name` column changed
|
|
855
957
|
in the just-committed save, even when bundled with other attribute changes
|
|
856
958
|
(options, sort_order, default_value, etc.). The detection is intentionally
|
|
857
|
-
escalating
|
|
858
|
-
|
|
959
|
+
escalating so any registered consumer receives a rename event whenever the
|
|
960
|
+
persisted name changes.
|
|
859
961
|
|
|
860
962
|
`:update` on Value fires only when the typed value column changed. Saving
|
|
861
963
|
a Value record without modifying its typed column (e.g., touching only
|
|
@@ -867,6 +969,17 @@ bypasses AR callbacks. Only the Field `:destroy` event fires. Use
|
|
|
867
969
|
`field_dependent: :destroy` if your consumer needs per-Value events on
|
|
868
970
|
field deletion.
|
|
869
971
|
|
|
972
|
+
For a persisted `field_dependent: :destroy` field with a large population,
|
|
973
|
+
call `field.destroy_with_values_in_batches!(batch_size: 1_000)` outside an
|
|
974
|
+
open transaction. The opt-in API selects only that exact `field_id` in ordered
|
|
975
|
+
primary-key batches, calls `Value#destroy!` for callback/version behavior, and
|
|
976
|
+
commits each batch independently. A retry resumes from the remaining rows. The
|
|
977
|
+
Field is retained until a locked, bounded residual drain proves zero rows, then
|
|
978
|
+
its ordinary callback-preserving `destroy!` runs. The API rejects unsaved or
|
|
979
|
+
non-destroy fields, open transactions, invalid batch sizes, and mismatched
|
|
980
|
+
connection pools. Existing `destroy`/`destroy!`, `:nullify`, and `:restrict`
|
|
981
|
+
behavior is unchanged.
|
|
982
|
+
|
|
870
983
|
### Thread-local context with `with_context`
|
|
871
984
|
|
|
872
985
|
```ruby
|
|
@@ -904,16 +1017,15 @@ when `after_commit` fires; re-raising would surface a misleading
|
|
|
904
1017
|
"save failed" error.
|
|
905
1018
|
|
|
906
1019
|
This is the deliberate split with first-party features. Internal
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1020
|
+
observers used by `typed_eav` itself follow a different rule: their exceptions
|
|
1021
|
+
**propagate**. Transactional version-writing errors are separate: they
|
|
1022
|
+
propagate inside and roll back the source transaction.
|
|
910
1023
|
|
|
911
1024
|
### Ordering guarantee
|
|
912
1025
|
|
|
913
1026
|
When multiple subscribers are registered, they fire in this order:
|
|
914
1027
|
|
|
915
|
-
1. First-party
|
|
916
|
-
registration order. Errors propagate.
|
|
1028
|
+
1. First-party generic observers, in registration order. Errors propagate.
|
|
917
1029
|
2. The user proc on `Config.on_value_change` / `Config.on_field_change`,
|
|
918
1030
|
last. Errors are rescued and logged.
|
|
919
1031
|
|
|
@@ -972,8 +1084,8 @@ values. When enabled, each `:create` / `:update` / `:destroy` event on
|
|
|
972
1084
|
a Value writes a row to `typed_eav_value_versions` capturing the
|
|
973
1085
|
before-state, after-state, actor, context, and timestamp.
|
|
974
1086
|
|
|
975
|
-
Default off. Apps that don't enable it pay zero overhead —
|
|
976
|
-
|
|
1087
|
+
Default off. Apps that don't enable it pay zero overhead — transactional
|
|
1088
|
+
Value callbacks are not installed at boot
|
|
977
1089
|
at all when `Config.versioning = false`. Zero callable in the dispatcher
|
|
978
1090
|
chain, zero per-write method dispatch, zero per-write config read.
|
|
979
1091
|
|
|
@@ -1034,9 +1146,8 @@ value.history.limit(5).each { |v| ... }
|
|
|
1034
1146
|
`Value#history` returns versions where `value_id` matches the live Value
|
|
1035
1147
|
record. After the live Value is destroyed, the FK `ON DELETE SET NULL`
|
|
1036
1148
|
nullifies `value_id` on the existing version rows, and the new `:destroy`
|
|
1037
|
-
version is
|
|
1038
|
-
|
|
1039
|
-
writing a non-nil `value_id` would FK-fail at INSERT). So `Value#history`
|
|
1149
|
+
version is written by the transactional destroy callback with `value_id: nil`
|
|
1150
|
+
before the parent row is removed. So `Value#history`
|
|
1040
1151
|
cannot surface destroy versions, and after Value destruction it can no
|
|
1041
1152
|
longer be called at all.
|
|
1042
1153
|
|
|
@@ -1116,8 +1227,8 @@ value.revert_to(target)
|
|
|
1116
1227
|
```
|
|
1117
1228
|
|
|
1118
1229
|
`revert_to` writes the targeted version's `before_value` columns back
|
|
1119
|
-
via `self[col] = …` and `save!`. The
|
|
1120
|
-
|
|
1230
|
+
via `self[col] = …` and `save!`. The transactional version callback writes a
|
|
1231
|
+
NEW version row whose
|
|
1121
1232
|
`after_value` reflects the targeted version's `before_value`. The
|
|
1122
1233
|
audit log is append-only — every revert is itself versioned.
|
|
1123
1234
|
|
|
@@ -1145,19 +1256,18 @@ manually using `version.before_value` as the seed state.
|
|
|
1145
1256
|
|
|
1146
1257
|
### Hook ordering guarantee
|
|
1147
1258
|
|
|
1148
|
-
Versioning is
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
the version row is persisted:
|
|
1152
|
-
|
|
1259
|
+
Versioning is installed as boot-latched transactional callbacks on `Value`,
|
|
1260
|
+
and the public callback remains an after-commit observer. The version row is
|
|
1261
|
+
written in the source transaction.
|
|
1153
1262
|
```
|
|
1154
|
-
Value#save! →
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1263
|
+
Value#save! → transactional Value callback → ValueVersion.create!
|
|
1264
|
+
→ after_commit → EventDispatcher.dispatch_value_change:
|
|
1265
|
+
1. ... any other generic internal observers ...
|
|
1266
|
+
2. Config.on_value_change user proc # sees the persisted version
|
|
1158
1267
|
```
|
|
1159
1268
|
|
|
1160
|
-
Internal
|
|
1269
|
+
Internal observer errors propagate. Transactional version-writing errors also
|
|
1270
|
+
propagate inside and roll back the source transaction.
|
|
1161
1271
|
User proc errors are rescued and logged via `Rails.logger.error` —
|
|
1162
1272
|
the save itself already committed.
|
|
1163
1273
|
|
|
@@ -1214,14 +1324,8 @@ RSpec.describe "my versioning behavior", :event_callbacks, :real_commits do
|
|
|
1214
1324
|
before do
|
|
1215
1325
|
TypedEAV.registry.register("Contact", versioned: true)
|
|
1216
1326
|
TypedEAV::Config.versioning = true
|
|
1217
|
-
#
|
|
1218
|
-
#
|
|
1219
|
-
# engine-boot-registered subscriber is gone for the duration of
|
|
1220
|
-
# the example. Re-register explicitly inside the before block.
|
|
1221
|
-
# The hook's ensure block restores the snapshot — no leak.
|
|
1222
|
-
TypedEAV::EventDispatcher.register_internal_value_change(
|
|
1223
|
-
TypedEAV::Versioning::Subscriber.method(:call),
|
|
1224
|
-
)
|
|
1327
|
+
# Transactional Value callbacks are boot-latched and remain installed;
|
|
1328
|
+
# the hook isolates only public and generic EventDispatcher observers.
|
|
1225
1329
|
end
|
|
1226
1330
|
after { TypedEAV.registry.register("Contact", versioned: false) }
|
|
1227
1331
|
|
|
@@ -1231,12 +1335,11 @@ RSpec.describe "my versioning behavior", :event_callbacks, :real_commits do
|
|
|
1231
1335
|
end
|
|
1232
1336
|
```
|
|
1233
1337
|
|
|
1234
|
-
The `:event_callbacks` around hook in `spec/spec_helper.rb`
|
|
1235
|
-
restores `Config.versioning`, `Config.actor_resolver`, and
|
|
1236
|
-
EventDispatcher
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
above is required for any spec that needs the subscriber to fire. The
|
|
1338
|
+
The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshots and
|
|
1339
|
+
restores `Config.versioning`, `Config.actor_resolver`, and generic
|
|
1340
|
+
EventDispatcher observer lists around each example. Transactional Value
|
|
1341
|
+
callback installation is tested independently through callback-chain and
|
|
1342
|
+
boot-latch specs. The
|
|
1240
1343
|
`:real_commits` hook disables transactional fixtures (so `after_commit`
|
|
1241
1344
|
fires durably) and cleans up `TypedEAV::ValueVersion` rows in
|
|
1242
1345
|
FK-respecting order between examples.
|
|
@@ -1261,7 +1364,7 @@ The gem creates five tables:
|
|
|
1261
1364
|
|
|
1262
1365
|
## Architecture
|
|
1263
1366
|
|
|
1264
|
-
Internal module layout as of 0.
|
|
1367
|
+
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.
|
|
1265
1368
|
|
|
1266
1369
|
### Macro entry: `HasTypedEav`
|
|
1267
1370
|
|
|
@@ -1306,19 +1409,55 @@ TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
|
|
|
1306
1409
|
|
|
1307
1410
|
`typed_eav_hash_for(records)` (the plural read) routes through `TypedEAV::BulkRead`. Given a record collection and an effective `(scope, parent_scope)`, it:
|
|
1308
1411
|
|
|
1309
|
-
1.
|
|
1310
|
-
2.
|
|
1311
|
-
|
|
1412
|
+
1. Resolves visible definitions and groups the requested field IDs.
|
|
1413
|
+
2. Loads definitions, values, and field associations through one batched
|
|
1414
|
+
definition query, one values query, and one field-association preload
|
|
1415
|
+
(three SQL queries total; no host-table query).
|
|
1416
|
+
3. Returns a `{record_id => {field_name => value}}` map while skipping orphaned
|
|
1417
|
+
values and preserving logical missingness.
|
|
1418
|
+
|
|
1419
|
+
The final production characterization reduced the 1,002 SQL statements observed
|
|
1420
|
+
across 1,000 scopes to three for the same BulkRead shape. This is a statement-
|
|
1421
|
+
count result, not a representative throughput claim; applications should still
|
|
1422
|
+
measure their own scope cardinality, selected fields, hydration, and contention.
|
|
1312
1423
|
|
|
1313
1424
|
Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMethods` and use the same partition helpers but without batching.
|
|
1314
1425
|
|
|
1315
1426
|
### Bulk writes: `BulkWrite`
|
|
1316
1427
|
|
|
1317
|
-
`bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`,
|
|
1428
|
+
`bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`,
|
|
1429
|
+
and `bulk_set_typed_eav_values_per_record(values_by_record)` is its sibling for
|
|
1430
|
+
record-varying hashes. Both are semantic writers that:
|
|
1318
1431
|
|
|
1319
1432
|
1. Memoizes field definitions for the call via `Thread.current[:typed_eav_bulk_defs_memo]`.
|
|
1320
1433
|
2. Validates each attribute against its field type's cast contract.
|
|
1321
|
-
3.
|
|
1434
|
+
3. Saves each host through the normal callback/validation path inside an outer
|
|
1435
|
+
transaction with per-record savepoints.
|
|
1436
|
+
|
|
1437
|
+
`bulk_set_typed_eav_values_per_record` uses records as Hash keys, so two AR
|
|
1438
|
+
instances of the same persisted row collapse to one entry; sequence separate
|
|
1439
|
+
calls for two ordered updates, while the uniform Array API preserves duplicate
|
|
1440
|
+
instances and caller order.
|
|
1441
|
+
|
|
1442
|
+
Under `transaction: :all`, per-record validation failures are captured at their
|
|
1443
|
+
savepoints while other successes can commit, but an uncaught exception rolls
|
|
1444
|
+
the entire outer transaction back. The default `transaction: :all` commits the
|
|
1445
|
+
whole successful batch or rolls it back;
|
|
1446
|
+
`transaction: :chunks, chunk_size: N` commits completed chunks while isolating
|
|
1447
|
+
later failures, preserving earlier committed chunks. Both forms require the
|
|
1448
|
+
host, Field, and Value pools to match.
|
|
1449
|
+
`bulk_upsert_typed_eav_values` is a separate reduced-semantics fast path: it
|
|
1450
|
+
casts and validates typed values, then performs one PostgreSQL upsert while
|
|
1451
|
+
omitting host saves, persistence callbacks, delete shorthand, and versioning.
|
|
1452
|
+
|
|
1453
|
+
Callers must pass `acknowledge_reduced_semantics: true`. The same values hash
|
|
1454
|
+
applies to every record; records must be persisted and unique, and string or
|
|
1455
|
+
symbol field keys that normalize to the same name are rejected. The return
|
|
1456
|
+
value is the integer number of value rows upserted, not a semantic
|
|
1457
|
+
`successes`/`errors_by_record` result. Value casting, domain/entity/partition
|
|
1458
|
+
checks, and Value validation callbacks remain; host callbacks and validations,
|
|
1459
|
+
Value persistence callbacks, versioning, delete shorthand, and per-record
|
|
1460
|
+
savepoint isolation are skipped.
|
|
1322
1461
|
|
|
1323
1462
|
`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.
|
|
1324
1463
|
|
|
@@ -1391,6 +1530,48 @@ The definitions helpers used to live as class methods on `HasTypedEav` before 0.
|
|
|
1391
1530
|
|
|
1392
1531
|
`TypedEAV::SchemaPortability` and `TypedEAV::CSVMapper` (Phase-6 modules) are deliberately decoupled from the core read/write path per [ADR-0005](docs/adr/0005-keep-phase-six-modules-independent.md). They depend on the public `has_typed_eav` macro surface, never on internal modules.
|
|
1393
1532
|
|
|
1533
|
+
### Bulk operation guarantees
|
|
1534
|
+
|
|
1535
|
+
`bulk_upsert_typed_eav_values` is an explicit reduced-semantics API: it
|
|
1536
|
+
prevalidates/casts values and performs a PostgreSQL upsert, while intentionally
|
|
1537
|
+
omitting host callbacks and versioning. Use the regular bulk writer when those
|
|
1538
|
+
semantics are required; chunked semantic transactions are opt-in.
|
|
1539
|
+
|
|
1540
|
+
The fast path still casts and runs domain, entity, partition, and validation
|
|
1541
|
+
callbacks before its single upsert against the exact entity/field conflict
|
|
1542
|
+
target; it omits host saves/host callbacks, Value persistence callbacks,
|
|
1543
|
+
delete shorthand, and versioning. It requires one shared connection pool and
|
|
1544
|
+
returns validation errors before SQL. `:all` is one unit; `:chunks` commits
|
|
1545
|
+
completed chunks before a later failure. Semantic writes retain host saves,
|
|
1546
|
+
per-record savepoint/error isolation, and one outer `:all` transaction.
|
|
1547
|
+
|
|
1548
|
+
BulkWrite evidence is intentionally bounded to the exercised 100- and 1,000-host
|
|
1549
|
+
lanes. It does not establish 10,000- or 100,000-host throughput, nor does it
|
|
1550
|
+
justify a universal batch size or storage choice.
|
|
1551
|
+
|
|
1552
|
+
### Operational guarantees
|
|
1553
|
+
|
|
1554
|
+
The semantic writer preserves the caller's transaction and callback/versioning
|
|
1555
|
+
contract. Version rows are written in the source transaction, so a rollback
|
|
1556
|
+
rolls back the Value mutation and its audit row together. The reduced-semantics
|
|
1557
|
+
upsert is intentionally separate and does not claim those callbacks or audit
|
|
1558
|
+
guarantees.
|
|
1559
|
+
|
|
1560
|
+
Field deletion has a callback-preserving, keyset-batched path that locks and
|
|
1561
|
+
destroys only the exact field's Values before bounded finalization. It scales by
|
|
1562
|
+
bounded primary-key batches and preserves the Field if a batch fails; it is not
|
|
1563
|
+
a claim of unbounded deletion throughput.
|
|
1564
|
+
|
|
1565
|
+
### Default backfill narrowing
|
|
1566
|
+
|
|
1567
|
+
`Field::Base#backfill_default!` optionally accepts an exact-host
|
|
1568
|
+
`ActiveRecord::Relation` to SQL-narrow eligible entities before batching. The default
|
|
1569
|
+
all-host behavior remains unchanged; partition checks, batch transactions,
|
|
1570
|
+
callbacks, validations, idempotence, versions, and errors remain in force.
|
|
1571
|
+
Typed storage defines logical missingness across all declared cells, so a
|
|
1572
|
+
partially populated multi-cell value is present while a fully empty Currency
|
|
1573
|
+
value is missing.
|
|
1574
|
+
|
|
1394
1575
|
## License
|
|
1395
1576
|
|
|
1396
1577
|
MIT
|