typed_eav 0.8.0 → 0.8.1
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 +25 -2
- data/README.md +79 -1710
- data/RELEASING.md +81 -0
- data/docs/adr/0001-collapse-column-mapping-stack.md +28 -0
- data/docs/adr/0002-entity-query-orchestration.md +33 -0
- data/docs/adr/0003-keep-event-dispatcher-broker.md +41 -0
- data/docs/adr/0004-field-family-intermediate-bases.md +49 -0
- data/docs/adr/0005-keep-phase-six-modules-independent.md +48 -0
- data/docs/adr/0006-include-missing-via-set-complement.md +77 -0
- data/docs/adr/0007-visibility-versus-mutation-relations.md +33 -0
- data/docs/adr/0008-partial-covering-scalar-indexes.md +83 -0
- data/docs/adr/0009-string-search-indexing.md +110 -0
- data/docs/adr/0010-planner-statistics-policy.md +109 -0
- data/docs/adr/0011-multi-filter-query-strategy.md +111 -0
- data/docs/adr/0012-cross-scope-administrative-query-policy.md +76 -0
- data/docs/adr/0013-durable-versioning-and-field-deletion.md +125 -0
- data/docs/adr/index.md +101 -0
- data/docs/getting-started.md +79 -0
- data/docs/guides/architecture.md +125 -0
- data/docs/guides/bulk-operations.md +205 -0
- data/docs/guides/csv-import.md +88 -0
- data/docs/guides/development.md +73 -0
- data/docs/guides/events-and-versioning.md +360 -0
- data/docs/guides/fields.md +342 -0
- data/docs/guides/performance.md +107 -0
- data/docs/guides/queries.md +188 -0
- data/docs/guides/schema.md +134 -0
- data/docs/guides/scoping.md +254 -0
- data/docs/guides/upgrading.md +26 -0
- data/docs/guides/usage.md +259 -0
- data/docs/index.md +44 -0
- data/docs/maintaining.md +82 -0
- data/docs/reference/api.md +133 -0
- data/docs/reference/configuration.md +64 -0
- data/docs/reference/index.md +16 -0
- data/lib/typed_eav/version.rb +1 -1
- metadata +35 -1
data/README.md
CHANGED
|
@@ -1,31 +1,22 @@
|
|
|
1
1
|
# TypedEAV
|
|
2
2
|
|
|
3
|
-
Add
|
|
3
|
+
Add runtime-defined custom fields to Active Record models, with typed values,
|
|
4
|
+
validation, and SQL filtering, sorting, and summaries.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Scalar values use native PostgreSQL columns such as `integer_value` and
|
|
7
|
+
`date_value`; collection and JSON fields use JSONB. Field definitions control
|
|
8
|
+
casting and validation, and can be shared globally or scoped to tenants.
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
CAST(value_meta->>'const' AS bigint) = 42
|
|
13
|
-
```
|
|
14
|
-
|
|
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.
|
|
16
|
-
|
|
17
|
-
TypedEAV stores values in native columns, so queries become:
|
|
18
|
-
|
|
19
|
-
```sql
|
|
20
|
-
WHERE integer_value = 42
|
|
21
|
-
```
|
|
22
|
-
|
|
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.
|
|
10
|
+
TypedEAV is useful when users define fields at runtime and your application
|
|
11
|
+
needs to query them through a consistent typed interface. JSONB also works well
|
|
12
|
+
for many workloads; choose based on your query patterns and operational needs.
|
|
13
|
+
See [Storage and performance](docs/guides/performance.md) for the tradeoffs,
|
|
14
|
+
indexing guidance, and benchmark evidence.
|
|
24
15
|
|
|
25
16
|
## Compatibility
|
|
26
17
|
|
|
27
18
|
The canonical support contract lives in
|
|
28
|
-
[`.github/compatibility.json`](.github/compatibility.json). Typed EAV supports:
|
|
19
|
+
[`.github/compatibility.json`](https://github.com/dchuk/typed_eav/blob/main/.github/compatibility.json). Typed EAV supports:
|
|
29
20
|
|
|
30
21
|
| Runtime | Supported versions |
|
|
31
22
|
|---|---|
|
|
@@ -53,624 +44,46 @@ bin/rails typed_eav:install:migrations
|
|
|
53
44
|
bin/rails db:migrate
|
|
54
45
|
```
|
|
55
46
|
|
|
47
|
+
PostgreSQL is required; MySQL and SQLite are not supported. For existing
|
|
48
|
+
installations, see [Upgrading](docs/guides/upgrading.md) and the
|
|
49
|
+
[Changelog](CHANGELOG.md).
|
|
50
|
+
|
|
56
51
|
## Quick Start
|
|
57
52
|
|
|
58
|
-
|
|
53
|
+
Assuming your application already has a `Contact` model and table:
|
|
59
54
|
|
|
60
55
|
```ruby
|
|
61
56
|
class Contact < ApplicationRecord
|
|
62
57
|
has_typed_eav
|
|
63
58
|
end
|
|
64
59
|
|
|
65
|
-
# With multi-tenant scoping:
|
|
66
|
-
class Contact < ApplicationRecord
|
|
67
|
-
has_typed_eav scope_method: :tenant_id
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# With restricted field types:
|
|
71
|
-
class Contact < ApplicationRecord
|
|
72
|
-
has_typed_eav types: [:text, :integer, :boolean, :select]
|
|
73
|
-
end
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
### 2. Create field definitions
|
|
77
|
-
|
|
78
|
-
```ruby
|
|
79
|
-
# Simple fields
|
|
80
|
-
TypedEAV::Field::Text.create!(
|
|
81
|
-
name: "nickname",
|
|
82
|
-
entity_type: "Contact"
|
|
83
|
-
)
|
|
84
|
-
|
|
85
60
|
TypedEAV::Field::Integer.create!(
|
|
86
61
|
name: "age",
|
|
87
|
-
entity_type:
|
|
88
|
-
required: true,
|
|
62
|
+
entity_type: Contact.polymorphic_name,
|
|
89
63
|
options: { min: 0, max: 150 }
|
|
90
64
|
)
|
|
91
65
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
options: { max_date: Date.today.to_s }
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
# Select field with options
|
|
99
|
-
status = TypedEAV::Field::Select.create!(
|
|
100
|
-
name: "status",
|
|
101
|
-
entity_type: "Contact",
|
|
102
|
-
required: true
|
|
103
|
-
)
|
|
104
|
-
status.field_options.create!([
|
|
105
|
-
{ label: "Active", value: "active", sort_order: 1 },
|
|
106
|
-
{ label: "Inactive", value: "inactive", sort_order: 2 },
|
|
107
|
-
{ label: "Lead", value: "lead", sort_order: 3 },
|
|
108
|
-
])
|
|
109
|
-
|
|
110
|
-
# Multi-select (stored as json array)
|
|
111
|
-
tags = TypedEAV::Field::MultiSelect.create!(
|
|
112
|
-
name: "tags",
|
|
113
|
-
entity_type: "Contact"
|
|
114
|
-
)
|
|
115
|
-
tags.field_options.create!([
|
|
116
|
-
{ label: "VIP", value: "vip" },
|
|
117
|
-
{ label: "Partner", value: "partner" },
|
|
118
|
-
{ label: "Prospect", value: "prospect" },
|
|
119
|
-
])
|
|
120
|
-
```
|
|
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
|
-
|
|
127
|
-
### 3. Set values on records
|
|
128
|
-
|
|
129
|
-
```ruby
|
|
130
|
-
contact = Contact.new(name: "Darrin")
|
|
131
|
-
|
|
132
|
-
# Individual assignment
|
|
133
|
-
contact.set_typed_eav_value("age", 40)
|
|
134
|
-
contact.set_typed_eav_value("status", "active")
|
|
135
|
-
|
|
136
|
-
# Bulk assignment by field NAME (ergonomic for scripting / seeds)
|
|
137
|
-
contact.typed_eav_attributes = [
|
|
138
|
-
{ name: "age", value: 40 },
|
|
139
|
-
{ name: "status", value: "active" },
|
|
140
|
-
{ name: "tags", value: ["vip", "partner"] },
|
|
141
|
-
]
|
|
66
|
+
contact = Contact.new
|
|
67
|
+
contact.set_typed_eav_value("age", "40")
|
|
68
|
+
contact.save! # Supply any other attributes your Contact model requires.
|
|
142
69
|
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
contact.typed_values_attributes = [
|
|
146
|
-
{ id: 12, field_id: 4, value: "40" },
|
|
147
|
-
{ field_id: 7, value: "active" },
|
|
148
|
-
]
|
|
70
|
+
contact.typed_eav_value("age") # => 40 (Integer)
|
|
71
|
+
contact.typed_eav_hash # => { "age" => 40 }
|
|
149
72
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
# Reading
|
|
153
|
-
contact.typed_eav_value("age") # => 40 (Ruby Integer)
|
|
154
|
-
contact.typed_eav_value("status") # => "active"
|
|
155
|
-
contact.typed_eav_hash # => { "age" => 40, "status" => "active", ... }
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
### 4. Query with the DSL
|
|
159
|
-
|
|
160
|
-
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
|
-
|
|
162
|
-
```ruby
|
|
163
|
-
# Short form - single field filter
|
|
164
|
-
Contact.with_field("age", :gt, 21)
|
|
165
|
-
Contact.with_field("status", "active") # :eq is the default operator
|
|
166
|
-
Contact.with_field("nickname", :contains, "smith")
|
|
167
|
-
|
|
168
|
-
# Chain them
|
|
169
|
-
Contact.with_field("age", :gteq, 18)
|
|
170
|
-
.with_field("status", "active")
|
|
171
|
-
.with_field("tags", :any_eq, "vip")
|
|
172
|
-
|
|
173
|
-
# Multi-filter form (good for search UIs)
|
|
174
|
-
Contact.where_typed_eav(
|
|
175
|
-
{ name: "age", op: :gt, value: 21 },
|
|
176
|
-
{ name: "status", op: :eq, value: "active" },
|
|
177
|
-
{ name: "city", op: :contains, value: "port" },
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
# Compact keys (for URL params / form submissions)
|
|
181
|
-
Contact.where_typed_eav(
|
|
182
|
-
{ n: "age", op: :gt, v: 21 },
|
|
183
|
-
{ n: "status", v: "active" },
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
# With scoping
|
|
187
|
-
Contact.where_typed_eav(
|
|
188
|
-
{ name: "priority", op: :eq, value: "high" },
|
|
189
|
-
scope: current_tenant.id
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
# Combine with standard ActiveRecord
|
|
193
|
-
Contact.where(company_id: 42)
|
|
194
|
-
.with_field("status", "active")
|
|
195
|
-
.with_field("age", :gteq, 21)
|
|
196
|
-
.order(:name)
|
|
73
|
+
Contact.with_field("age", :gteq, 21)
|
|
74
|
+
.order_typed_eav("age", direction: :desc)
|
|
197
75
|
.limit(25)
|
|
198
76
|
```
|
|
199
77
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
272
|
-
### Available Operators
|
|
273
|
-
|
|
274
|
-
| Operator | Works On | Description |
|
|
275
|
-
|----------|----------|-------------|
|
|
276
|
-
| `:eq` | all | Equal (default) |
|
|
277
|
-
| `:not_eq` | all | Not equal (NULL-safe) |
|
|
278
|
-
| `:gt` | numeric, date, datetime | Greater than |
|
|
279
|
-
| `:gteq` | numeric, date, datetime | Greater than or equal |
|
|
280
|
-
| `:lt` | numeric, date, datetime | Less than |
|
|
281
|
-
| `:lteq` | numeric, date, datetime | Less than or equal |
|
|
282
|
-
| `:between` | numeric, date, datetime | Between (pass Range or Array) |
|
|
283
|
-
| `:contains` | text, long_text | ILIKE %value% |
|
|
284
|
-
| `:not_contains` | text, long_text | NOT ILIKE %value% |
|
|
285
|
-
| `:starts_with` | text, long_text | ILIKE value% |
|
|
286
|
-
| `:ends_with` | text, long_text | ILIKE %value |
|
|
287
|
-
| `:any_eq` | json arrays | Array contains element |
|
|
288
|
-
| `:all_eq` | json arrays | Array contains all elements |
|
|
289
|
-
| `:is_null` | all | Value is NULL |
|
|
290
|
-
| `:is_not_null` | all | Value is not NULL |
|
|
291
|
-
|
|
292
|
-
### Optional trigram indexing for string search
|
|
293
|
-
|
|
294
|
-
TypedEAV keeps its partial-covering `text_pattern_ops` B-tree as the default
|
|
295
|
-
string index. Equality uses that B-tree, while `:starts_with`, `:contains`, and
|
|
296
|
-
`:ends_with` use `ILIKE`; `:not_contains` uses `NOT ILIKE`. The gem does not
|
|
297
|
-
require or install `pg_trgm` and does not create a trigram index automatically.
|
|
298
|
-
|
|
299
|
-
An application with frequent positive `ILIKE` searches containing at least
|
|
300
|
-
three useful characters may evaluate its own partial GIN index. This is a
|
|
301
|
-
workload decision: the representative benchmark used GIN for measured prefix,
|
|
302
|
-
contains, suffix, and escaped-literal patterns, but not for `NOT ILIKE` or
|
|
303
|
-
one/two-character probes. It does not prove that every positive pattern or
|
|
304
|
-
selectivity will benefit. A `lower(string_value) LIKE ...` expression index is
|
|
305
|
-
not equivalent to TypedEAV's public `ILIKE`, and the benchmark did not justify
|
|
306
|
-
GiST.
|
|
307
|
-
|
|
308
|
-
Application owners should check extension availability and deploy-role
|
|
309
|
-
privileges in preproduction, then create the extension and index in their own
|
|
310
|
-
migrations. Use nontransactional `CREATE INDEX CONCURRENTLY`, a stable
|
|
311
|
-
application-specific name, and workload-specific `EXPLAIN (ANALYZE, BUFFERS,
|
|
312
|
-
WAL, SETTINGS)` plus storage and write-WAL measurements. Rollback should drop
|
|
313
|
-
only the application-owned index concurrently; do not drop the database-wide
|
|
314
|
-
extension because other objects may share it. See
|
|
315
|
-
[ADR 0009](docs/adr/0009-string-search-indexing.md) and the
|
|
316
|
-
[benchmark guide](bench/README.md#phase-3-string-search-benchmark) for the
|
|
317
|
-
operator matrix, measured costs, SQL, and evidence limits.
|
|
318
|
-
|
|
319
|
-
### Optional planner statistics for correlated field/value predicates
|
|
320
|
-
|
|
321
|
-
TypedEAV does not install PostgreSQL extended-statistics objects. An application
|
|
322
|
-
whose own plans persistently misestimate `field_id = ... AND typed_value = ...`
|
|
323
|
-
may evaluate application-owned `dependencies` statistics for that exact typed
|
|
324
|
-
column. Dependency statistics apply to compatible equality and `IN` clauses,
|
|
325
|
-
not range predicates. `mcv` describes common value combinations, while
|
|
326
|
-
`ndistinct` primarily informs distinct-group estimates; neither should be added
|
|
327
|
-
without workload evidence.
|
|
328
|
-
|
|
329
|
-
The representative PostgreSQL 17 benchmark found better aggregate equality
|
|
330
|
-
estimates from dependencies, but no plan-shape or demonstrated runtime benefit.
|
|
331
|
-
Its combined object mirrored MCV on the four changed probes because matching MCV
|
|
332
|
-
groups supplied those estimates. The experiment's target of 100 was a controlled
|
|
333
|
-
input, not a universal recommendation. One probe labeled common-date equality
|
|
334
|
-
actually queried an absent date and returned zero rows; it is not evidence about
|
|
335
|
-
common-date estimates.
|
|
336
|
-
|
|
337
|
-
Applications should own stable names and DDL, select targets from representative
|
|
338
|
-
data, run `ANALYZE`, and compare estimated/actual rows, plans, runtime, planning
|
|
339
|
-
cost, maintenance cost, and data churn before retaining an object. Coordinate
|
|
340
|
-
ownership in shared databases, inspect catalog definitions before changing
|
|
341
|
-
objects, and drop only application-owned statistics during rollback. See
|
|
342
|
-
[ADR 0010](docs/adr/0010-planner-statistics-policy.md) and the
|
|
343
|
-
[benchmark guide](bench/README.md#phase-4a-planner-extended-statistics) for safe
|
|
344
|
-
evaluation SQL and evidence limits.
|
|
345
|
-
|
|
346
|
-
### Multi-filter query strategy
|
|
347
|
-
|
|
348
|
-
TypedEAV retains its current multi-filter query shape: it resolves each field,
|
|
349
|
-
builds the corresponding typed value subquery, and chains those results onto
|
|
350
|
-
the host relation with `id IN (...)`. There is no adaptive strategy or alternate
|
|
351
|
-
production query API.
|
|
352
|
-
|
|
353
|
-
A PostgreSQL 17 benchmark compared the shipped shape with `INTERSECT`,
|
|
354
|
-
correlated `EXISTS`, and direct grouped `HAVING` under resource-capped
|
|
355
|
-
co-tenancy. The run retained 2,940 attempts, including 622 right-censored
|
|
356
|
-
timeouts, and 294 representative identity oracles. Twelve oracles timed out, so
|
|
357
|
-
representative equivalence is unproved even though all 282 completed oracles
|
|
358
|
-
matched and the smaller 98-oracle smoke matched. Alternatives remain
|
|
359
|
-
research-only. Grouped `HAVING` is additionally ineligible for missing-value,
|
|
360
|
-
host-universe complement, and empty-filter semantics.
|
|
361
|
-
|
|
362
|
-
The result also does not establish valid buffer comparisons or 20-distinct-
|
|
363
|
-
field scaling. A parser defect made every derived buffer total a false zero;
|
|
364
|
-
nonzero counters remain recoverable from the retained raw plans. The
|
|
365
|
-
20-predicate workloads repeat ten fields, and the skewed 10/20 workloads repeat
|
|
366
|
-
five. Future research must repair and validate buffer extraction, exercise
|
|
367
|
-
actual 10/20 distinct fields, complete every representative equivalence oracle,
|
|
368
|
-
cover the full scope/NULL/missing/polymorphic/error contract, and show the
|
|
369
|
-
pre-registered p95, planning-time, buffer, and plan-shape gates before any
|
|
370
|
-
adaptive or replacement proposal. See
|
|
371
|
-
[ADR 0011](docs/adr/0011-multi-filter-query-strategy.md) and the
|
|
372
|
-
[benchmark guide](bench/README.md#phase-4b-multi-filter-query-shapes).
|
|
373
|
-
|
|
374
|
-
### How Type Inference Works
|
|
375
|
-
|
|
376
|
-
The owning Field casts and validates query operands before SQL generation;
|
|
377
|
-
Active Record supplies the SQL bind plumbing:
|
|
378
|
-
|
|
379
|
-
```ruby
|
|
380
|
-
# The Integer Field casts and validates the operand before SQL generation
|
|
381
|
-
Contact.with_field("age", :gt, "21")
|
|
382
|
-
# SQL: WHERE integer_value > 21 (not '21')
|
|
383
|
-
|
|
384
|
-
# The Date Field owns date parsing and validation
|
|
385
|
-
Contact.with_field("birthday", :lt, "2000-01-01")
|
|
386
|
-
# SQL: WHERE date_value < '2000-01-01'::date
|
|
387
|
-
|
|
388
|
-
# The Boolean Field owns truthy/falsy casting
|
|
389
|
-
Contact.with_field("active", "true")
|
|
390
|
-
# SQL: WHERE boolean_value = TRUE
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
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.
|
|
78
|
+
Fields cast and validate both assigned values and query operands. Queries
|
|
79
|
+
return Active Record relations, so you can combine them with ordinary host
|
|
80
|
+
filters. Use `Contact.polymorphic_name` when creating definitions to respect
|
|
81
|
+
Rails' STI and namespaced-polymorphism settings.
|
|
394
82
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
```erb
|
|
400
|
-
<%= form_with model: @contact do |f| %>
|
|
401
|
-
<%= f.text_field :name %>
|
|
402
|
-
|
|
403
|
-
<%= render_typed_value_inputs(form: f, record: @contact) %>
|
|
404
|
-
|
|
405
|
-
<%= f.submit %>
|
|
406
|
-
<% end %>
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
The helper emits one input per available field, including the hidden `id` / `field_id` markers required by `accepts_nested_attributes_for`. Permit the nested shape in your controller — the `value: []` form is required for array/multi-select types:
|
|
410
|
-
|
|
411
|
-
```ruby
|
|
412
|
-
def contact_params
|
|
413
|
-
params.require(:contact).permit(
|
|
414
|
-
:name,
|
|
415
|
-
typed_values_attributes: [
|
|
416
|
-
:id, :field_id, :_destroy, :value, { value: [] }
|
|
417
|
-
]
|
|
418
|
-
)
|
|
419
|
-
end
|
|
420
|
-
```
|
|
421
|
-
|
|
422
|
-
For list pages, preload the field association to avoid N+1:
|
|
423
|
-
|
|
424
|
-
```ruby
|
|
425
|
-
@contacts = Contact.includes(typed_values: :field).all
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
## Admin Scaffold
|
|
429
|
-
|
|
430
|
-
To manage field definitions through a UI, run the scaffold generator:
|
|
431
|
-
|
|
432
|
-
```bash
|
|
433
|
-
bin/rails g typed_eav:scaffold
|
|
434
|
-
bin/rails db:migrate
|
|
435
|
-
```
|
|
436
|
-
|
|
437
|
-
This copies a controller, views, helper, Stimulus controllers, and an initializer into your app, and adds routes mounted at `/typed_eav_fields`.
|
|
438
|
-
|
|
439
|
-
**Security**: the generated controller ships with `authorize_typed_eav_admin!` returning `head :not_found` by default — fail-closed. Edit the method directly in `app/controllers/typed_eav_controller.rb` to wire it to your auth system:
|
|
440
|
-
|
|
441
|
-
```ruby
|
|
442
|
-
def authorize_typed_eav_admin!
|
|
443
|
-
return if current_user&.admin?
|
|
444
|
-
head :not_found
|
|
445
|
-
end
|
|
446
|
-
```
|
|
447
|
-
|
|
448
|
-
Defining `authorize_typed_eav_admin!` in `ApplicationController` does **not** override it — the scaffold sets it on its own controller.
|
|
449
|
-
|
|
450
|
-
## Multi-Tenant Scoping
|
|
451
|
-
|
|
452
|
-
Field definitions are partitioned by a `scope` column so multiple tenants (or accounts, workspaces, orgs — any partition key your app uses) can each define their own fields without collisions. Fields with `scope = NULL` are global, visible to every partition.
|
|
453
|
-
|
|
454
|
-
### Declaring a scoped model
|
|
455
|
-
|
|
456
|
-
```ruby
|
|
457
|
-
class Contact < ApplicationRecord
|
|
458
|
-
has_typed_eav scope_method: :tenant_id
|
|
459
|
-
end
|
|
460
|
-
```
|
|
461
|
-
|
|
462
|
-
`scope_method:` names an instance method on your model. When the record reads its own field definitions (e.g., in a form), that method tells TypedEAV which partition the record belongs to.
|
|
463
|
-
|
|
464
|
-
### Class-level queries resolve scope automatically
|
|
465
|
-
|
|
466
|
-
Queries like `Contact.where_typed_eav(...)` consult an **ambient scope resolver** — no need to pass `scope:` on every call:
|
|
467
|
-
|
|
468
|
-
```ruby
|
|
469
|
-
# The resolver tells TypedEAV which partition is active.
|
|
470
|
-
Contact.where_typed_eav({ name: "age", op: :gt, value: 21 })
|
|
471
|
-
```
|
|
472
|
-
|
|
473
|
-
The resolver chain (highest priority first):
|
|
474
|
-
|
|
475
|
-
1. Explicit `scope:` keyword argument on the query
|
|
476
|
-
2. Active `TypedEAV.with_scope(value) { ... }` block
|
|
477
|
-
3. Configured `TypedEAV.config.scope_resolver` callable
|
|
478
|
-
4. `nil`
|
|
479
|
-
|
|
480
|
-
If every step returns `nil` and the model declared `scope_method:`, queries raise `TypedEAV::ScopeRequired` — the **fail-closed default**. This is the whole point: forgetting to set scope can't silently leak other partitions' data.
|
|
481
|
-
|
|
482
|
-
### Wiring the resolver
|
|
483
|
-
|
|
484
|
-
Pick the pattern that matches your app and set it once in `config/initializers/typed_eav.rb`:
|
|
485
|
-
|
|
486
|
-
```ruby
|
|
487
|
-
TypedEAV.configure do |c|
|
|
488
|
-
# acts_as_tenant (auto-detected — no config needed if loaded)
|
|
489
|
-
# c.scope_resolver = -> { ActsAsTenant.current_tenant&.id }
|
|
490
|
-
|
|
491
|
-
# Rails CurrentAttributes
|
|
492
|
-
# c.scope_resolver = -> { Current.account&.id }
|
|
493
|
-
|
|
494
|
-
# Custom class
|
|
495
|
-
# c.scope_resolver = -> { MyApp::Tenancy.current_workspace_id }
|
|
496
|
-
|
|
497
|
-
# Subdomain / session / thread-local
|
|
498
|
-
# c.scope_resolver = -> { Thread.current[:org_id] }
|
|
499
|
-
|
|
500
|
-
# Disable ambient resolution entirely
|
|
501
|
-
# c.scope_resolver = nil
|
|
502
|
-
|
|
503
|
-
c.require_scope = true # fail-closed (default). Set false for gradual adoption.
|
|
504
|
-
end
|
|
505
|
-
```
|
|
506
|
-
|
|
507
|
-
The resolver MUST return a 2-element Array `[scope, parent_scope]`. Each slot
|
|
508
|
-
accepts a raw value (`"t1"`, `42`), an AR record (TypedEAV calls `.id.to_s`
|
|
509
|
-
on anything that responds to `#id`), or `nil`. If you don't use parent_scope,
|
|
510
|
-
return `[scope, nil]`. A bare scalar return raises `ArgumentError` at the
|
|
511
|
-
next ambient query — see [Migrating from v0.1.x](#migrating-from-v01x) for
|
|
512
|
-
the upgrade path.
|
|
513
|
-
|
|
514
|
-
### Block APIs
|
|
515
|
-
|
|
516
|
-
```ruby
|
|
517
|
-
# Run a block with a specific ambient scope (background jobs, console, rake tasks):
|
|
518
|
-
TypedEAV.with_scope(tenant_id) do
|
|
519
|
-
Contact.where_typed_eav({ name: "status", op: :eq, value: "active" })
|
|
520
|
-
end
|
|
521
|
-
|
|
522
|
-
# Escape hatch for admin tools, migrations, or cross-tenant audits:
|
|
523
|
-
TypedEAV.unscoped do
|
|
524
|
-
Contact.where_typed_eav({ name: "status", op: :eq, value: "active" })
|
|
525
|
-
# returns matches across ALL partitions
|
|
526
|
-
end
|
|
527
|
-
```
|
|
528
|
-
|
|
529
|
-
Both are exception-safe via `ensure` and nest cleanly.
|
|
530
|
-
|
|
531
|
-
`unscoped` is an explicit administrative/analytics escape hatch, not the
|
|
532
|
-
ordinary tenant request path. It keeps every same-name definition across the
|
|
533
|
-
visible partitions and unions their matches for each filter. For broad audits
|
|
534
|
-
or migrations, bound the definition universe to the work you actually need and
|
|
535
|
-
batch the job at an application-owned boundary. TypedEAV does not prescribe a
|
|
536
|
-
universal limit or batch size; measure generated SQL, planning/execution,
|
|
537
|
-
memory, and workload interference in your application. Keep normal request
|
|
538
|
-
traffic on scoped resolution so global, scope-only, and full-tuple definitions
|
|
539
|
-
collapse to the most-specific match.
|
|
540
|
-
|
|
541
|
-
### Explicit `scope:` override
|
|
542
|
-
|
|
543
|
-
Any query method accepts `scope:` as an override for admin tools and tests:
|
|
544
|
-
|
|
545
|
-
```ruby
|
|
546
|
-
Contact.where_typed_eav({ name: "status", value: "active" }, scope: "t1")
|
|
547
|
-
Contact.with_field("age", :gt, 21, scope: "t1")
|
|
548
|
-
```
|
|
549
|
-
|
|
550
|
-
Explicit wins over ambient. Passing `scope: nil` explicitly (as opposed to omitting the kwarg) means "filter to global fields only" — useful for admin UIs that want to see unscoped field definitions without activating `unscoped` mode.
|
|
551
|
-
|
|
552
|
-
### Background jobs
|
|
553
|
-
|
|
554
|
-
ActiveJob (including Sidekiq via the ActiveJob adapter) wraps every `perform` in Rails' executor, which already clears `ActiveSupport::CurrentAttributes` between jobs — so if your resolver reads from `Current.account`, each job starts clean. For raw `Sidekiq::Job` (no ActiveJob), wrap the job body manually:
|
|
555
|
-
|
|
556
|
-
```ruby
|
|
557
|
-
class ExportJob
|
|
558
|
-
include Sidekiq::Job
|
|
559
|
-
|
|
560
|
-
def perform(tenant_id, ...)
|
|
561
|
-
TypedEAV.with_scope(tenant_id) do
|
|
562
|
-
Contact.where_typed_eav(...)
|
|
563
|
-
end
|
|
564
|
-
end
|
|
565
|
-
end
|
|
566
|
-
```
|
|
567
|
-
|
|
568
|
-
### Disabling enforcement for gradual adoption
|
|
569
|
-
|
|
570
|
-
If your app has existing typed-eav queries that don't yet pass scope, flip `require_scope` to `false` in the initializer. When no scope resolves, queries fall back to **global fields only** (definitions stored with `scope: nil`) instead of raising — they do **not** return all partitions' fields. Audit and fix callers, then flip back to `true`.
|
|
571
|
-
|
|
572
|
-
To intentionally query across every partition (admin tools, migrations, cross-tenant audits), use the explicit escape hatch `TypedEAV.unscoped { ... }` rather than relying on `require_scope = false`.
|
|
573
|
-
|
|
574
|
-
### Two-level scoping (`parent_scope`)
|
|
575
|
-
|
|
576
|
-
When a single tenant axis isn't enough — say, `tenant_id` for the customer AND
|
|
577
|
-
`workspace_id` for an in-tenant partition — declare both:
|
|
578
|
-
|
|
579
|
-
```ruby
|
|
580
|
-
class Project < ApplicationRecord
|
|
581
|
-
has_typed_eav scope_method: :tenant_id, parent_scope_method: :workspace_id
|
|
582
|
-
end
|
|
583
|
-
```
|
|
584
|
-
|
|
585
|
-
Field (and section) definitions partition on the tuple `(entity_type, scope,
|
|
586
|
-
parent_scope)`. A `Project` record reads field definitions in three precedence
|
|
587
|
-
layers: a full-triple `(scope, parent_scope)` match wins, then `(scope, nil)`
|
|
588
|
-
(tenant-wide), then `(nil, nil)` (truly global). The same precedence applies
|
|
589
|
-
to the class-level query path.
|
|
590
|
-
|
|
591
|
-
`parent_scope_method:` requires `scope_method:` — declaring it without a scope
|
|
592
|
-
method raises at macro-expansion time (no host can have a parent partition
|
|
593
|
-
without a scope partition).
|
|
594
|
-
|
|
595
|
-
Both `with_scope` and the configured `scope_resolver` carry the tuple now:
|
|
596
|
-
|
|
597
|
-
```ruby
|
|
598
|
-
TypedEAV.with_scope(["t1", "w1"]) do
|
|
599
|
-
Project.where_typed_eav({ name: "status", value: "active" })
|
|
600
|
-
end
|
|
601
|
-
|
|
602
|
-
# Single-axis call still works (parent_scope = nil):
|
|
603
|
-
TypedEAV.with_scope("t1") do
|
|
604
|
-
Contact.where_typed_eav({ name: "age", op: :gt, value: 21 })
|
|
605
|
-
end
|
|
606
|
-
|
|
607
|
-
# Custom resolver — MUST return [scope, parent_scope]:
|
|
608
|
-
TypedEAV.configure do |c|
|
|
609
|
-
c.scope_resolver = -> { [Current.tenant&.id, Current.workspace&.id] }
|
|
610
|
-
end
|
|
611
|
-
```
|
|
612
|
-
|
|
613
|
-
Per-query overrides accept `parent_scope:` alongside `scope:` on
|
|
614
|
-
`where_typed_eav`, `with_field`, and `typed_eav_definitions`:
|
|
615
|
-
|
|
616
|
-
```ruby
|
|
617
|
-
Project.where_typed_eav(
|
|
618
|
-
{ name: "priority", value: "high" },
|
|
619
|
-
scope: "t1",
|
|
620
|
-
parent_scope: "w1",
|
|
621
|
-
)
|
|
622
|
-
```
|
|
623
|
-
|
|
624
|
-
When `acts_as_tenant` is loaded, the auto-detected `DEFAULT_SCOPE_RESOLVER`
|
|
625
|
-
returns `[ActsAsTenant.current_tenant, nil]` — the parent_scope slot is `nil`
|
|
626
|
-
because the tenant gem has no parent-scope analog. Configure your own resolver
|
|
627
|
-
when you need both axes.
|
|
628
|
-
|
|
629
|
-
### Migrating from v0.1.x
|
|
630
|
-
|
|
631
|
-
The resolver-callable contract is a **breaking change**: any custom
|
|
632
|
-
`Config.scope_resolver` lambda must now return `[scope, parent_scope]` (a
|
|
633
|
-
2-element Array) instead of a bare scalar. A scalar return raises
|
|
634
|
-
`ArgumentError` at the next ambient query so the failure is loud, not silent.
|
|
635
|
-
If you don't use parent_scope, return `[scope, nil]`.
|
|
636
|
-
|
|
637
|
-
Run `bin/rails typed_eav:install:migrations` to copy the new
|
|
638
|
-
`AddParentScopeToTypedEavPartitions` migration into your app, then
|
|
639
|
-
`bin/rails db:migrate`. The migration is safe on production: it adds a
|
|
640
|
-
nullable `parent_scope` column (catalog-only, instantaneous) and uses
|
|
641
|
-
`CREATE INDEX CONCURRENTLY` for all index changes, so existing rows aren't
|
|
642
|
-
rewritten. Existing fields end up with `parent_scope = NULL` (the
|
|
643
|
-
global-parent shape) and continue to work for every single-scope caller.
|
|
644
|
-
|
|
645
|
-
See the [CHANGELOG](CHANGELOG.md) for the full upgrade checklist.
|
|
646
|
-
|
|
647
|
-
### Orphan-parent invariant
|
|
648
|
-
|
|
649
|
-
A `Field` or `Section` row with `parent_scope` set and `scope` blank is
|
|
650
|
-
invalid — model-level validation rejects it on save. Reason: a "global field
|
|
651
|
-
within one workspace" has no semantic resolution path; the row would never
|
|
652
|
-
match any record's resolver. The paired partial unique indexes rely on this
|
|
653
|
-
invariant.
|
|
654
|
-
|
|
655
|
-
The shipped migration chain also includes
|
|
656
|
-
`EnforceParentScopeInvariant`, which declares the database check constraints
|
|
657
|
-
nontransactionally and validates them after its preflight, and
|
|
658
|
-
`UsePartialCoveringScalarIndexes`, which creates the six `*_present` indexes
|
|
659
|
-
before removing their legacy counterparts. Both migrations use
|
|
660
|
-
`disable_ddl_transaction!`; run them through the normal migration command and
|
|
661
|
-
do not wrap them in an application transaction.
|
|
662
|
-
|
|
663
|
-
### Name collisions across scopes
|
|
664
|
-
|
|
665
|
-
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.
|
|
666
|
-
|
|
667
|
-
`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.
|
|
668
|
-
|
|
669
|
-
Because that administrative path constructs work for every matching
|
|
670
|
-
definition, applications should narrow and batch high-cardinality audits rather
|
|
671
|
-
than treating `unscoped` as tenant-request routing. No built-in numeric
|
|
672
|
-
threshold is implied; choose operational bounds from measurements of the
|
|
673
|
-
consuming workload. See [ADR 0012](docs/adr/0012-cross-scope-administrative-query-policy.md).
|
|
83
|
+
See [Reading, writing, and forms](docs/guides/usage.md) for bulk assignment,
|
|
84
|
+
nested attributes, form helpers, and the admin scaffold, or
|
|
85
|
+
[Querying typed fields](docs/guides/queries.md) for operators, multi-field
|
|
86
|
+
filters, sorting, distinct values, counts, and numeric aggregates.
|
|
674
87
|
|
|
675
88
|
## Field Types
|
|
676
89
|
|
|
@@ -699,1098 +112,54 @@ consuming workload. See [ADR 0012](docs/adr/0012-cross-scope-administrative-quer
|
|
|
699
112
|
| `File` | `string_value` (signed_id) + `:attachment` has_one_attached | String (Active Storage signed_id) | `allowed_content_types`, `max_size_bytes` |
|
|
700
113
|
| `Reference` | `integer_value` (FK) | Integer (target record ID) | `target_entity_type`, `target_scope` |
|
|
701
114
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
)
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
##
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
```ruby
|
|
734
|
-
# app/models/fields/phone.rb
|
|
735
|
-
module Fields
|
|
736
|
-
class Phone < TypedEAV::Field::Base
|
|
737
|
-
value_column :string_value
|
|
738
|
-
operators :eq, :contains, :starts_with, :is_null, :is_not_null
|
|
739
|
-
|
|
740
|
-
def cast(raw)
|
|
741
|
-
# Strip everything but digits and +; never rejects as invalid
|
|
742
|
-
[raw&.to_s&.gsub(/[^\d+]/, ""), false]
|
|
743
|
-
end
|
|
744
|
-
end
|
|
745
|
-
end
|
|
746
|
-
|
|
747
|
-
# Register it
|
|
748
|
-
TypedEAV.configure do |c|
|
|
749
|
-
c.register_field_type :phone, "Fields::Phone"
|
|
750
|
-
end
|
|
751
|
-
```
|
|
752
|
-
|
|
753
|
-
### Family intermediate bases (extension points)
|
|
754
|
-
|
|
755
|
-
`Field::Base` is the universal parent, but three intermediate family
|
|
756
|
-
bases collapse the most common per-leaf duplication. Pick the right
|
|
757
|
-
parent and you inherit the family's validation surface for free.
|
|
758
|
-
|
|
759
|
-
- **`TypedEAV::Field::ValidatedString`** — subclass when your custom
|
|
760
|
-
type stores in `string_value` and wants a min/max-length + regex-pattern
|
|
761
|
-
validation surface. Inherits `value_column :string_value`,
|
|
762
|
-
`store_accessor :options, :min_length, :max_length, :pattern`,
|
|
763
|
-
numericality validators on `min_length` / `max_length`, a
|
|
764
|
-
`max_gte_min_length` guard that rejects inverted bounds at field-save,
|
|
765
|
-
and a `validate_pattern_syntax` guard that rejects bad regexes at
|
|
766
|
-
field-save. The default `validate_typed_value(record, val)` runs
|
|
767
|
-
`validate_length` plus `validate_pattern if pattern.present?`. Override
|
|
768
|
-
it and call `super` to layer on a format-specific check (the built-in
|
|
769
|
-
`Field::Email` / `Field::Url` are the canonical pattern).
|
|
770
|
-
|
|
771
|
-
```ruby
|
|
772
|
-
class Fields::Slug < TypedEAV::Field::ValidatedString
|
|
773
|
-
SLUG_FORMAT = /\A[a-z0-9-]+\z/
|
|
774
|
-
|
|
775
|
-
def cast(raw)
|
|
776
|
-
[raw&.to_s&.strip&.downcase, false]
|
|
777
|
-
end
|
|
778
|
-
|
|
779
|
-
def validate_typed_value(record, val)
|
|
780
|
-
super # length + pattern from the family base
|
|
781
|
-
record.errors.add(:value, "is not a valid slug") unless SLUG_FORMAT.match?(val.to_s)
|
|
782
|
-
end
|
|
783
|
-
end
|
|
784
|
-
```
|
|
785
|
-
|
|
786
|
-
- **`TypedEAV::Field::RangeBounded`** — subclass when your custom type
|
|
787
|
-
stores a single comparable value (numeric or temporal) constrained by
|
|
788
|
-
a min/max bound. Each leaf still declares its own `value_column` and
|
|
789
|
-
its own `store_accessor` (key names vary by family member: `:min`/`:max`
|
|
790
|
-
for numeric; `:min_date`/`:max_date` for date;
|
|
791
|
-
`:min_datetime`/`:max_datetime` for datetime). The family base
|
|
792
|
-
provides protected `validate_range` / `validate_date_range` /
|
|
793
|
-
`validate_datetime_range` helpers. Each leaf should pair its
|
|
794
|
-
`store_accessor` with the macro
|
|
795
|
-
`validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min`
|
|
796
|
-
(or the analogous form for the leaf's key names) so inverted bounds
|
|
797
|
-
fail at field-save.
|
|
798
|
-
|
|
799
|
-
```ruby
|
|
800
|
-
class Fields::Score < TypedEAV::Field::RangeBounded
|
|
801
|
-
value_column :integer_value
|
|
802
|
-
|
|
803
|
-
store_accessor :options, :min, :max
|
|
804
|
-
validates :max, comparison: { greater_than_or_equal_to: :min }, allow_nil: true, if: :min
|
|
805
|
-
|
|
806
|
-
def cast(raw)
|
|
807
|
-
raw.nil? ? [nil, false] : [Integer(raw.to_s, exception: false), raw.to_s.empty? ? false : true]
|
|
808
|
-
end
|
|
809
|
-
|
|
810
|
-
def validate_typed_value(record, val)
|
|
811
|
-
validate_range(record, val)
|
|
812
|
-
end
|
|
813
|
-
end
|
|
814
|
-
```
|
|
815
|
-
|
|
816
|
-
- **`TypedEAV::Field::Optionable`** — `include` this concern when your
|
|
817
|
-
custom type's valid values are drawn from a `Field::Option` set.
|
|
818
|
-
Provides `optionable? = true`, a public-facing sorted
|
|
819
|
-
`allowed_values` helper, and protected
|
|
820
|
-
`validate_option_inclusion` / `validate_multi_option_inclusion`
|
|
821
|
-
helpers. Mixin (not inheritance) because option-set field types may
|
|
822
|
-
use different `value_column`s — the built-in `Field::Select` stores in
|
|
823
|
-
`string_value` while `Field::MultiSelect` stores in `json_value`, and
|
|
824
|
-
both stay as direct children of `Field::Base`.
|
|
825
|
-
|
|
826
|
-
```ruby
|
|
827
|
-
class Fields::Tag < TypedEAV::Field::Base
|
|
828
|
-
include TypedEAV::Field::Optionable
|
|
829
|
-
|
|
830
|
-
value_column :string_value
|
|
831
|
-
operators :eq, :not_eq, :is_null, :is_not_null
|
|
832
|
-
|
|
833
|
-
def cast(raw)
|
|
834
|
-
[raw&.to_s, false]
|
|
835
|
-
end
|
|
836
|
-
|
|
837
|
-
def validate_typed_value(record, val)
|
|
838
|
-
validate_option_inclusion(record, val)
|
|
839
|
-
end
|
|
840
|
-
end
|
|
841
|
-
```
|
|
842
|
-
|
|
843
|
-
The rule of thumb: subclass an intermediate family base when the new
|
|
844
|
-
field type shares its storage and validation surface with the family;
|
|
845
|
-
include `Optionable` when it draws values from an option set; subclass
|
|
846
|
-
`Field::Base` directly (as the `Phone` example above does) when none of
|
|
847
|
-
the family surfaces fit. `validate_array_size` lives on `Field::Base`
|
|
848
|
-
itself — its callers span unrelated families.
|
|
849
|
-
|
|
850
|
-
### Multi-cell field types
|
|
851
|
-
|
|
852
|
-
External field types may store their logical value across multiple typed
|
|
853
|
-
columns. The entire storage surface lives directly on `Field::Base` via
|
|
854
|
-
the `Field::TypedStorage` concern, so a custom multi-cell type is just a
|
|
855
|
-
`Field::Base` subclass that overrides three instance methods.
|
|
856
|
-
|
|
857
|
-
**Class-level DSL** (declared at class load time):
|
|
858
|
-
|
|
859
|
-
- `value_column :col` – single-cell sugar; declares the primary cell.
|
|
860
|
-
- `value_columns :a, :b, ...` – plural form for multi-cell types. The
|
|
861
|
-
primary cell is `value_columns.first`. Both forms share storage;
|
|
862
|
-
`value_column` and `value_columns` are interchangeable getters/setters.
|
|
863
|
-
- `operators :eq, :gt, ...` – restrict the supported operator set.
|
|
864
|
-
- `self.operator_column(op)` – override to route different operators to
|
|
865
|
-
different cells. Defaults to `value_columns.first`.
|
|
866
|
-
|
|
867
|
-
**Override-point instance methods** (the entire extension surface for
|
|
868
|
-
multi-cell types):
|
|
869
|
-
|
|
870
|
-
- `read_value(record)` – compose the logical value from the cells.
|
|
871
|
-
- `write_value(record, casted)` – unpack the casted value across cells.
|
|
872
|
-
- `apply_default(record)` – populate cells from `default_value`.
|
|
873
|
-
|
|
874
|
-
The defaults target `value_columns.first`, so single-cell field types
|
|
875
|
-
keep working without overrides. The three methods are paired – override
|
|
876
|
-
all three or your reads will see a multi-cell shape that writes / defaults
|
|
877
|
-
cannot produce.
|
|
878
|
-
|
|
879
|
-
**Concrete snapshot helpers** (NOT overridable; derived from
|
|
880
|
-
`value_columns`):
|
|
881
|
-
|
|
882
|
-
- `value_changed?(record)` – true iff any cell saw a saved change.
|
|
883
|
-
- `before_snapshot(record, change_type)` / `after_snapshot(record, change_type)`
|
|
884
|
-
– per-cell hashes keyed by string column names; powers the versioning
|
|
885
|
-
jsonb shape.
|
|
886
|
-
|
|
887
|
-
Custom multi-cell type example (matches the built-in `Field::Currency`):
|
|
888
|
-
|
|
889
|
-
```ruby
|
|
890
|
-
class Fields::Money < TypedEAV::Field::Base
|
|
891
|
-
AMOUNT_COLUMN = :decimal_value
|
|
892
|
-
CURRENCY_COLUMN = :string_value
|
|
893
|
-
|
|
894
|
-
value_columns AMOUNT_COLUMN, CURRENCY_COLUMN
|
|
895
|
-
operators :eq, :gt, :lt, :gteq, :lteq, :between, :currency_eq, :is_null, :is_not_null
|
|
896
|
-
|
|
897
|
-
def self.operator_column(operator)
|
|
898
|
-
operator == :currency_eq ? CURRENCY_COLUMN : AMOUNT_COLUMN
|
|
899
|
-
end
|
|
900
|
-
|
|
901
|
-
def read_value(value_record)
|
|
902
|
-
amount = value_record[AMOUNT_COLUMN]
|
|
903
|
-
currency = value_record[CURRENCY_COLUMN]
|
|
904
|
-
return nil if amount.nil? && currency.nil?
|
|
905
|
-
|
|
906
|
-
{ amount: amount, currency: currency }
|
|
907
|
-
end
|
|
908
|
-
|
|
909
|
-
def write_value(value_record, casted)
|
|
910
|
-
if casted.nil?
|
|
911
|
-
value_record[AMOUNT_COLUMN] = nil
|
|
912
|
-
value_record[CURRENCY_COLUMN] = nil
|
|
913
|
-
else
|
|
914
|
-
value_record[AMOUNT_COLUMN] = casted[:amount]
|
|
915
|
-
value_record[CURRENCY_COLUMN] = casted[:currency]
|
|
916
|
-
end
|
|
917
|
-
end
|
|
918
|
-
|
|
919
|
-
def apply_default(value_record)
|
|
920
|
-
default = default_value
|
|
921
|
-
return unless default.is_a?(Hash)
|
|
922
|
-
|
|
923
|
-
value_record[AMOUNT_COLUMN] = default[:amount] || default["amount"]
|
|
924
|
-
value_record[CURRENCY_COLUMN] = default[:currency] || default["currency"]
|
|
925
|
-
end
|
|
926
|
-
end
|
|
927
|
-
```
|
|
928
|
-
|
|
929
|
-
The built-in `Field::Currency` is the canonical multi-cell consumer of
|
|
930
|
-
these extension points and reads as a normal `Field::Base` subclass with
|
|
931
|
-
exactly three method overrides.
|
|
932
|
-
|
|
933
|
-
### Built-in field types
|
|
934
|
-
|
|
935
|
-
- **`Currency`:** Stores `{amount: BigDecimal, currency: String}` across two typed columns (`decimal_value` for the amount; `string_value` for the ISO 4217 currency code). Multi-cell storage is declared via `value_columns :decimal_value, :string_value`; reads, writes, and default application override `read_value`, `write_value`, and `apply_default` directly on `Field::Currency`. Operators: `:eq`, `:gt`, `:lt`, `:gteq`, `:lteq`, `:between` target the amount; `:currency_eq` targets the currency code; `:is_null` / `:is_not_null` target the amount column (a Currency value is null when its amount is null). Cast input MUST be a hash with `:amount` and/or `:currency` keys — bare numeric/string values are rejected with `:invalid` to enforce explicit currency dimension at write time. Options: `default_currency` (String ISO code, applied as fallback only when an amount is given without an explicit currency), `allowed_currencies` (Array of ISO codes; `validate_typed_value` enforces inclusion). Versioning snapshots automatically capture both columns because the snapshot helpers iterate `value_columns`. The `:currency_eq` operator is registered ONLY on `Field::Currency`; the QueryBuilder operator-validation gate rejects it with a clear `ArgumentError` if invoked on any other field type.
|
|
936
|
-
|
|
937
|
-
```ruby
|
|
938
|
-
Contact.where_typed_eav(name: "price", op: :currency_eq, value: "USD")
|
|
939
|
-
Contact.where_typed_eav(name: "price", op: :between, value: [50, 150])
|
|
940
|
-
```
|
|
941
|
-
|
|
942
|
-
- **`Percentage`:** A `Field::Decimal` subclass storing the underlying fraction in 0..1 (inclusive). The `:percent` representation is a format-time concern — call `field.format(value)` with `display_as: :percent` to render `0.75` as `"75.0%"`. Options: `decimal_places` (Integer >= 0, default 2; format-time precision only — does NOT alter what's stored in `decimal_value`), `display_as` (`:fraction` default, or `:percent`). Validation: out-of-range values (e.g., `1.5`) fail with the message `"must be between 0.0 and 1.0"`. Storage and operator semantics inherit from `Field::Decimal`.
|
|
943
|
-
|
|
944
|
-
```ruby
|
|
945
|
-
pf = TypedEAV::Field::Percentage.create!(
|
|
946
|
-
name: "discount", entity_type: "Order", scope: tenant_id,
|
|
947
|
-
options: { display_as: :percent, decimal_places: 1 },
|
|
948
|
-
)
|
|
949
|
-
pf.format(BigDecimal("0.755")) # => "75.5%"
|
|
950
|
-
```
|
|
951
|
-
|
|
952
|
-
- **`Image`:** Active Storage-backed field type. Stores the attached blob's `signed_id` (a String) in `string_value`. Operators: `:eq`, `:is_null`, `:is_not_null`. Options: `allowed_content_types` (Array of strings; supports exact matches like `"image/png"` and `image/*` family wildcards), `max_size_bytes` (Integer; nil disables the cap). The single `:attachment` has_one_attached association is declared on `TypedEAV::Value` at engine boot when Active Storage is loaded; otherwise `Field::Image#cast` raises `NotImplementedError` with an actionable install message. The `:attachment` association is shared with `Field::File` — Image vs File is a class-identity distinction (used by the `on_image_attached` hook), not a separate association.
|
|
953
|
-
|
|
954
|
-
```ruby
|
|
955
|
-
field = TypedEAV::Field::Image.create!(
|
|
956
|
-
name: "avatar", entity_type: "Contact",
|
|
957
|
-
options: { allowed_content_types: %w[image/png image/jpeg image/webp], max_size_bytes: 5_000_000 },
|
|
958
|
-
)
|
|
959
|
-
value = TypedEAV::Value.create!(entity: contact, field: field)
|
|
960
|
-
value.attachment.attach(io: file_io, filename: "avatar.png", content_type: "image/png")
|
|
961
|
-
value.update!(string_value: value.attachment.blob.signed_id)
|
|
962
|
-
value.value # => the signed_id String
|
|
963
|
-
```
|
|
964
|
-
|
|
965
|
-
- **`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.
|
|
966
|
-
|
|
967
|
-
- **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`).
|
|
968
|
-
|
|
969
|
-
- **`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.
|
|
970
|
-
|
|
971
|
-
```ruby
|
|
972
|
-
TypedEAV.configure do |c|
|
|
973
|
-
c.on_image_attached = ->(value, blob) {
|
|
974
|
-
ProcessImageJob.perform_later(value.id, blob.id)
|
|
975
|
-
}
|
|
976
|
-
end
|
|
977
|
-
```
|
|
978
|
-
|
|
979
|
-
- **`Reference`:** Foreign-key field type. Stores the target record's integer ID in `integer_value`. Operators: `:eq`, `:is_null`, `:is_not_null`, `:references` (explicit narrowing — does NOT inherit `:integer_value`'s `:gt`/`:lt`/`:between` defaults; arithmetic comparisons on FKs don't carry useful semantics). The `:references` operator accepts AR record instances OR Integer IDs at query time, normalizing via `field.cast` (a class-mismatched record routes to `base.none` rather than `:is_null`). Options: `target_entity_type` (REQUIRED — String class name of the target model, validated to constantize at field save), `target_scope` (OPTIONAL — when set, the field is REJECTED at save time if `target_entity_type` is not registered with `has_typed_eav scope_method:` (Gating Decision 2); when set with a scoped target, value-time validation rejects writes whose target's `typed_eav_scope` does not match `target_scope` via a `target_partition_matches?` helper structurally parallel to Phase 1's `entity_partition_axis_matches?` but on the target axis). Cross-scope safety mirrors the existing `Value#validate_field_scope_matches_entity` guard pattern applied to the target rather than the source.
|
|
980
|
-
|
|
981
|
-
```ruby
|
|
982
|
-
rf = TypedEAV::Field::Reference.create!(
|
|
983
|
-
name: "manager", entity_type: "Contact", scope: tenant_id,
|
|
984
|
-
options: { target_entity_type: "Contact", target_scope: tenant_id },
|
|
985
|
-
)
|
|
986
|
-
TypedEAV::Value.create!(entity: alice, field: rf, value: bob) # accepts AR record
|
|
987
|
-
TypedEAV::Value.create!(entity: alice, field: rf, value: bob.id) # accepts Integer FK
|
|
988
|
-
Contact.where_typed_eav(name: "manager", op: :references, value: bob) # filter by record
|
|
989
|
-
Contact.where_typed_eav(name: "manager", op: :references, value: 42) # filter by FK
|
|
990
|
-
```
|
|
991
|
-
|
|
992
|
-
- **Summary:** The built-in field types **Image, File, Reference, Currency, Percentage** all preserve the cast-tuple contract (`[casted, invalid?]`), the operator-dispatch model (`supported_operators` + `operator_column` for multi-cell types), and the no-hardcoded-attribute-references foundational principle. The multi-cell extension surface (`read_value`, `write_value`, `apply_default`, and `operator_column`) is the canonical way to build any future external multi-cell field type.
|
|
993
|
-
|
|
994
|
-
## Validation Behavior
|
|
995
|
-
|
|
996
|
-
A few non-obvious contracts worth knowing about up front:
|
|
997
|
-
|
|
998
|
-
- **Required + blank**: `required: true` fields reject empty strings, whitespace-only strings, and arrays whose every element is nil/blank/whitespace.
|
|
999
|
-
- **Array all-or-nothing cast**: integer/decimal/date arrays mark the **whole** value invalid (stored as `nil`) when any element fails to cast. There is no silent partial — a failed form re-renders with the original input intact so the user can correct the bad element.
|
|
1000
|
-
- **`Integer` array rejects fractional input**: `"1.9"` is rejected rather than truncated to `1`. Same rules as the scalar `Integer` field.
|
|
1001
|
-
- **`Json` parses string input**: a JSON string posted from a form is parsed; parse failures surface as `:invalid` rather than being stored as the literal string.
|
|
1002
|
-
- **`TextArray` does not support `:contains`**: it backs a jsonb column where SQL `LIKE` doesn't apply. Use `:any_eq` for "array contains element".
|
|
1003
|
-
- **Orphaned values are skipped**: if a field row is deleted while values remain, `typed_eav_value` and `typed_eav_hash` silently skip the orphans rather than raising.
|
|
1004
|
-
- **Cross-scope writes are rejected**: assigning a `Value` to a record whose `typed_eav_scope` doesn't match the field's `scope` adds a validation error on `:field`. The same guard covers the `parent_scope` axis.
|
|
1005
|
-
- **Orphan-parent rows rejected**: a `Field` or `Section` row with `parent_scope` set but `scope` blank is invalid. The `Value`-side guard rejects cross-`(scope, parent_scope)` writes too.
|
|
1006
|
-
- **Event hooks fire from `after_commit`**: the `on_value_change` and `on_field_change` callbacks fire after the database write is durable; their exceptions never break a save. See §"Event hooks" for the full contract.
|
|
1007
|
-
- **Versioning is opt-in**: When enabled (`TypedEAV.config.versioning = true` on the gem; `versioned: true` per host), every `:create` / `:update` / `:destroy` event on a Value writes an append-only audit row in `typed_eav_value_versions`. See §"Versioning" for the full contract.
|
|
1008
|
-
- **`label` is cosmetic, `name` is the machine key**: A field's optional `label` is free-text human display, independent of the slug `name`. Render via `display_name`, which returns `label` when present else `name.humanize`. `label` has no uniqueness or format constraints (only a 255-char max) and never affects ordering, lookup, partitioning, or rename detection — editing only `label` fires `on_field_change` with `:update`, never `:rename`. Existing rows (`label` NULL) render unchanged. Schema export round-trips the raw `label` (legacy payloads without a `label` key import as NULL); snapshot export carries the resolved `display_name`.
|
|
1009
|
-
|
|
1010
|
-
## Event hooks
|
|
1011
|
-
|
|
1012
|
-
`typed_eav` fires `after_commit` events for value and field changes. Use them
|
|
1013
|
-
for audit logs, search-index synchronization, cache invalidation, or any
|
|
1014
|
-
out-of-band reaction that must wait until the database write is durable.
|
|
1015
|
-
|
|
1016
|
-
### Public callback slots
|
|
1017
|
-
|
|
1018
|
-
```ruby
|
|
1019
|
-
TypedEAV.configure do |c|
|
|
1020
|
-
c.on_value_change = ->(value, change_type, context) {
|
|
1021
|
-
# change_type ∈ [:create, :update, :destroy]
|
|
1022
|
-
# context is a frozen Hash (see `with_context` below) — read-only
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
c.on_field_change = ->(field, change_type) {
|
|
1026
|
-
# change_type ∈ [:create, :update, :destroy, :rename]
|
|
1027
|
-
# NOTE: no context arg — field changes are CRUD-on-config, not
|
|
1028
|
-
# per-entity user actions
|
|
1029
|
-
}
|
|
1030
|
-
end
|
|
1031
|
-
```
|
|
1032
|
-
|
|
1033
|
-
The `:rename` change_type fires whenever the field's `name` column changed
|
|
1034
|
-
in the just-committed save, even when bundled with other attribute changes
|
|
1035
|
-
(options, sort_order, default_value, etc.). The detection is intentionally
|
|
1036
|
-
escalating so any registered consumer receives a rename event whenever the
|
|
1037
|
-
persisted name changes.
|
|
1038
|
-
|
|
1039
|
-
`:update` on Value fires only when the typed value column changed. Saving
|
|
1040
|
-
a Value record without modifying its typed column (e.g., touching only
|
|
1041
|
-
bookkeeping columns) is a no-op for event dispatch.
|
|
1042
|
-
|
|
1043
|
-
`field_dependent: :nullify` cascades produce **no** Value `:destroy`
|
|
1044
|
-
events. The FK `ON DELETE SET NULL` runs at the database level and
|
|
1045
|
-
bypasses AR callbacks. Only the Field `:destroy` event fires. Use
|
|
1046
|
-
`field_dependent: :destroy` if your consumer needs per-Value events on
|
|
1047
|
-
field deletion.
|
|
1048
|
-
|
|
1049
|
-
For a persisted `field_dependent: :destroy` field with a large population,
|
|
1050
|
-
call `field.destroy_with_values_in_batches!(batch_size: 1_000)` outside an
|
|
1051
|
-
open transaction. The opt-in API selects only that exact `field_id` in ordered
|
|
1052
|
-
primary-key batches, calls `Value#destroy!` for callback/version behavior, and
|
|
1053
|
-
commits each batch independently. A retry resumes from the remaining rows. The
|
|
1054
|
-
Field is retained until a locked, bounded residual drain proves zero rows, then
|
|
1055
|
-
its ordinary callback-preserving `destroy!` runs. The API rejects unsaved or
|
|
1056
|
-
non-destroy fields, open transactions, invalid batch sizes, and mismatched
|
|
1057
|
-
connection pools. Existing `destroy`/`destroy!`, `:nullify`, and `:restrict`
|
|
1058
|
-
behavior is unchanged.
|
|
1059
|
-
|
|
1060
|
-
### Thread-local context with `with_context`
|
|
1061
|
-
|
|
1062
|
-
```ruby
|
|
1063
|
-
TypedEAV.with_context(request_id: request.uuid, actor_id: current_user.id) do
|
|
1064
|
-
contact.update!(typed_eav: { phone: "555-1234" })
|
|
1065
|
-
# on_value_change receives { request_id: "...", actor_id: 42 } as context
|
|
1066
|
-
end
|
|
1067
|
-
```
|
|
1068
|
-
|
|
1069
|
-
`with_context` is a thread-local stack with shallow per-key merge:
|
|
1070
|
-
|
|
1071
|
-
```ruby
|
|
1072
|
-
TypedEAV.with_context(request_id: "abc") do
|
|
1073
|
-
TypedEAV.with_context(source: :bulk) do
|
|
1074
|
-
# current context: { request_id: "abc", source: :bulk }
|
|
1075
|
-
end
|
|
1076
|
-
# current context: { request_id: "abc" }
|
|
1077
|
-
end
|
|
1078
|
-
# current context: {}
|
|
1079
|
-
```
|
|
1080
|
-
|
|
1081
|
-
The current-context hash is frozen — callbacks cannot mutate it. Outer
|
|
1082
|
-
context is restored on exit even if the inner block raises.
|
|
1083
|
-
|
|
1084
|
-
`TypedEAV.current_context` returns the current frozen Hash (or a shared
|
|
1085
|
-
frozen `{}` when no `with_context` block is active). It's safe to call
|
|
1086
|
-
from any code path; it never returns nil.
|
|
1087
|
-
|
|
1088
|
-
### Error policy
|
|
1089
|
-
|
|
1090
|
-
User callbacks (`Config.on_value_change`, `Config.on_field_change`) are
|
|
1091
|
-
rescued — exceptions are logged via `Rails.logger.error` and **do not
|
|
1092
|
-
propagate** to the user's save call. The save row is already committed
|
|
1093
|
-
when `after_commit` fires; re-raising would surface a misleading
|
|
1094
|
-
"save failed" error.
|
|
1095
|
-
|
|
1096
|
-
This is the deliberate split with first-party features. Internal
|
|
1097
|
-
observers used by `typed_eav` itself follow a different rule: their exceptions
|
|
1098
|
-
**propagate**. Transactional version-writing errors are separate: they
|
|
1099
|
-
propagate inside and roll back the source transaction.
|
|
1100
|
-
|
|
1101
|
-
### Ordering guarantee
|
|
1102
|
-
|
|
1103
|
-
When multiple subscribers are registered, they fire in this order:
|
|
1104
|
-
|
|
1105
|
-
1. First-party generic observers, in registration order. Errors propagate.
|
|
1106
|
-
2. The user proc on `Config.on_value_change` / `Config.on_field_change`,
|
|
1107
|
-
last. Errors are rescued and logged.
|
|
1108
|
-
|
|
1109
|
-
Reassigning `Config.on_value_change` after gem initialization does **not**
|
|
1110
|
-
disable internal subscribers — they live on a separate dispatcher list
|
|
1111
|
-
and survive `Config.reset!`.
|
|
1112
|
-
|
|
1113
|
-
### Test isolation
|
|
1114
|
-
|
|
1115
|
-
Test files that exercise event hooks should opt in to the `:event_callbacks`
|
|
1116
|
-
metadata:
|
|
1117
|
-
|
|
1118
|
-
```ruby
|
|
1119
|
-
RSpec.describe "my feature", :event_callbacks do
|
|
1120
|
-
it "fires the hook" do
|
|
1121
|
-
captured = []
|
|
1122
|
-
TypedEAV::Config.on_value_change = ->(v, t, _ctx) { captured << [v.id, t] }
|
|
1123
|
-
contact.update!(typed_eav: { phone: "555-1234" })
|
|
1124
|
-
expect(captured).to include([be_a(Integer), :update])
|
|
1125
|
-
end
|
|
1126
|
-
end
|
|
1127
|
-
```
|
|
1128
|
-
|
|
1129
|
-
The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshots and
|
|
1130
|
-
restores Config user procs and the internal-subscriber lists around each
|
|
1131
|
-
example, so test mutations don't leak across examples and engine-load
|
|
1132
|
-
registrations from later phases stay intact.
|
|
1133
|
-
|
|
1134
|
-
Integration specs that create real AR records and need `after_commit` to
|
|
1135
|
-
fire durably should additionally opt in to `:real_commits`:
|
|
1136
|
-
|
|
1137
|
-
```ruby
|
|
1138
|
-
RSpec.describe "my model", :event_callbacks, :real_commits do
|
|
1139
|
-
# ...
|
|
1140
|
-
end
|
|
1141
|
-
```
|
|
1142
|
-
|
|
1143
|
-
`:real_commits` disables transactional fixtures for the example and
|
|
1144
|
-
manually deletes typed_eav rows in FK order after.
|
|
1145
|
-
|
|
1146
|
-
### Reset semantics
|
|
1147
|
-
|
|
1148
|
-
| Method | What it resets |
|
|
115
|
+
See [Field types and validation](docs/guides/fields.md) for options, casting
|
|
116
|
+
rules, attachments, sections, and custom single- or multi-column field types.
|
|
117
|
+
|
|
118
|
+
## Essential Behavior
|
|
119
|
+
|
|
120
|
+
- **Scoping selects definitions, not host records.** Apply tenant and
|
|
121
|
+
authorization filters to the host relation. Scoped models require scope by
|
|
122
|
+
default; global definitions can be overridden by more specific partitions.
|
|
123
|
+
See [Multi-tenant scoping](docs/guides/scoping.md).
|
|
124
|
+
- **Missing rows and explicit NULLs differ.** Filtering, sorting, summaries,
|
|
125
|
+
and reads have documented rules for each. See
|
|
126
|
+
[Queries](docs/guides/queries.md) and [Bulk reads](docs/guides/bulk-operations.md#bulk-reads-bulkread).
|
|
127
|
+
- **The admin scaffold requires authorization.** Its generated controller
|
|
128
|
+
returns `404` until you implement `authorize_typed_eav_admin!` directly on
|
|
129
|
+
that controller. See [Admin scaffold](docs/guides/usage.md#admin-scaffold).
|
|
130
|
+
- **Versioning is opt-in.** Audit rows share the value transaction; public
|
|
131
|
+
change hooks run after commit. In-memory dirty tracking is separate from
|
|
132
|
+
audit history. See [Events and versioning](docs/guides/events-and-versioning.md)
|
|
133
|
+
and [Typed-value changes](docs/guides/usage.md#in-memory-typed-value-changes).
|
|
134
|
+
- **Bulk write APIs have different guarantees.** The regular writer saves
|
|
135
|
+
hosts with callbacks and validations. Fast upsert requires explicit
|
|
136
|
+
acknowledgment that host saves, persistence callbacks, and versioning are
|
|
137
|
+
skipped. See [Bulk operations](docs/guides/bulk-operations.md).
|
|
138
|
+
|
|
139
|
+
## Documentation
|
|
140
|
+
|
|
141
|
+
Start at the [documentation home](docs/index.md), or browse the
|
|
142
|
+
[public API and configuration reference](docs/reference/index.md).
|
|
143
|
+
|
|
144
|
+
| Guide | Covers |
|
|
1149
145
|
|---|---|
|
|
1150
|
-
|
|
|
1151
|
-
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
1206
|
-
## Versioning
|
|
1207
|
-
|
|
1208
|
-
`typed_eav` ships an opt-in append-only audit log for changes to typed
|
|
1209
|
-
values. When enabled, each `:create` / `:update` / `:destroy` event on
|
|
1210
|
-
a Value writes a row to `typed_eav_value_versions` capturing the
|
|
1211
|
-
before-state, after-state, actor, context, and timestamp.
|
|
1212
|
-
|
|
1213
|
-
Default off. Apps that don't enable it pay zero overhead — transactional
|
|
1214
|
-
Value callbacks are not installed at boot
|
|
1215
|
-
at all when `Config.versioning = false`. Zero callable in the dispatcher
|
|
1216
|
-
chain, zero per-write method dispatch, zero per-write config read.
|
|
1217
|
-
|
|
1218
|
-
### Enabling versioning
|
|
1219
|
-
|
|
1220
|
-
Two steps:
|
|
1221
|
-
|
|
1222
|
-
```ruby
|
|
1223
|
-
# 1. Set the gem-level master switch in an initializer.
|
|
1224
|
-
# config/initializers/typed_eav.rb
|
|
1225
|
-
TypedEAV.configure do |c|
|
|
1226
|
-
c.versioning = true
|
|
1227
|
-
c.actor_resolver = -> { Current.user } # optional; nil is permissive
|
|
1228
|
-
end
|
|
1229
|
-
|
|
1230
|
-
# 2. Opt the host model in. Either via the kwarg form:
|
|
1231
|
-
class Contact < ApplicationRecord
|
|
1232
|
-
has_typed_eav scope_method: :tenant_id, versioned: true
|
|
1233
|
-
end
|
|
1234
|
-
|
|
1235
|
-
# Or via the concern (equivalent — pick whichever fits your conventions):
|
|
1236
|
-
class Contact < ApplicationRecord
|
|
1237
|
-
has_typed_eav scope_method: :tenant_id
|
|
1238
|
-
include TypedEAV::Versioned
|
|
1239
|
-
end
|
|
1240
|
-
```
|
|
1241
|
-
|
|
1242
|
-
The two opt-in forms produce identical Registry state. The kwarg form is
|
|
1243
|
-
preferred for new code; the concern form fits codebases with established
|
|
1244
|
-
mixin-based feature wiring.
|
|
1245
|
-
|
|
1246
|
-
### Querying history
|
|
1247
|
-
|
|
1248
|
-
```ruby
|
|
1249
|
-
contact.typed_eav_attributes = [{ name: "age", value: 41 }]
|
|
1250
|
-
contact.save!
|
|
1251
|
-
contact.typed_eav_attributes = [{ name: "age", value: 42 }]
|
|
1252
|
-
contact.save!
|
|
1253
|
-
|
|
1254
|
-
value = contact.typed_values.find_by(field: age_field)
|
|
1255
|
-
value.history # most-recent-first relation
|
|
1256
|
-
# => [<ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42}>,
|
|
1257
|
-
# <ValueVersion change_type: "create" before: {} after: {"integer_value" => 41}>]
|
|
1258
|
-
|
|
1259
|
-
value.history.first.changed_by # => "42" (User#42 — coerced to id.to_s)
|
|
1260
|
-
value.history.first.context # => { "request_id" => "abc-123" } if with_context was active
|
|
1261
|
-
```
|
|
1262
|
-
|
|
1263
|
-
`value.history` is a chainable relation. Filter, paginate, pluck:
|
|
1264
|
-
|
|
1265
|
-
```ruby
|
|
1266
|
-
value.history.where(change_type: "update").pluck(:changed_at, :changed_by)
|
|
1267
|
-
value.history.limit(5).each { |v| ... }
|
|
1268
|
-
```
|
|
1269
|
-
|
|
1270
|
-
### Querying full audit history (including destroy events)
|
|
1271
|
-
|
|
1272
|
-
`Value#history` returns versions where `value_id` matches the live Value
|
|
1273
|
-
record. After the live Value is destroyed, the FK `ON DELETE SET NULL`
|
|
1274
|
-
nullifies `value_id` on the existing version rows, and the new `:destroy`
|
|
1275
|
-
version is written by the transactional destroy callback with `value_id: nil`
|
|
1276
|
-
before the parent row is removed. So `Value#history`
|
|
1277
|
-
cannot surface destroy versions, and after Value destruction it can no
|
|
1278
|
-
longer be called at all.
|
|
1279
|
-
|
|
1280
|
-
To query the FULL audit history for a given (entity, field), including
|
|
1281
|
-
destroy events and post-destruction lookup, use the entity-scoped query
|
|
1282
|
-
directly:
|
|
1283
|
-
|
|
1284
|
-
```ruby
|
|
1285
|
-
TypedEAV::ValueVersion
|
|
1286
|
-
.where(entity_type: contact.class.polymorphic_name, entity_id: contact.id, field_id: age_field.id)
|
|
1287
|
-
.order(changed_at: :desc, id: :desc)
|
|
1288
|
-
# => [<ValueVersion change_type: "destroy" before: {"integer_value" => 42} after: {} value_id: nil>,
|
|
1289
|
-
# <ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42} value_id: nil>,
|
|
1290
|
-
# <ValueVersion change_type: "create" before: {} after: {"integer_value" => 41} value_id: nil>]
|
|
1291
|
-
```
|
|
1292
|
-
|
|
1293
|
-
This pattern is the canonical way to surface "what happened to this
|
|
1294
|
-
field on this entity" across the full lifecycle, including post-destroy.
|
|
1295
|
-
The `entity_type` + `entity_id` columns remain the durable identity even
|
|
1296
|
-
after the parent Value row is gone, and `field_id` survives because
|
|
1297
|
-
destroying a Value does not destroy its Field.
|
|
1298
|
-
|
|
1299
|
-
For broader audit views — "show all version history across all fields
|
|
1300
|
-
for a given entity" (e.g., admin entity-history pages, compliance
|
|
1301
|
-
exports) — drop the `field_id` filter:
|
|
1302
|
-
|
|
1303
|
-
```ruby
|
|
1304
|
-
TypedEAV::ValueVersion
|
|
1305
|
-
.where(entity_type: contact.class.polymorphic_name, entity_id: contact.id)
|
|
1306
|
-
.order(changed_at: :desc, id: :desc)
|
|
1307
|
-
# => all version rows for every typed field on this contact, most-recent-first.
|
|
1308
|
-
# Includes :create, :update, and :destroy events across every field the
|
|
1309
|
-
# entity has ever had a typed value for.
|
|
1310
|
-
```
|
|
1311
|
-
|
|
1312
|
-
The field-scoped query (with `field_id:`) is the common case for
|
|
1313
|
-
"history of a single field"; the entity-scoped query (without `field_id:`)
|
|
1314
|
-
is the broad-audit case for "all version history across all fields for
|
|
1315
|
-
this entity".
|
|
1316
|
-
|
|
1317
|
-
### Version row jsonb shape
|
|
1318
|
-
|
|
1319
|
-
`before_value` and `after_value` are jsonb hashes keyed by typed-column
|
|
1320
|
-
name:
|
|
1321
|
-
|
|
1322
|
-
| Field type | Snapshot shape (single key) |
|
|
1323
|
-
|---|---|
|
|
1324
|
-
| `text`, `email`, `url`, `color` | `{"string_value": "..."}` |
|
|
1325
|
-
| `long_text` | `{"text_value": "..."}` |
|
|
1326
|
-
| `integer` | `{"integer_value": 42}` |
|
|
1327
|
-
| `decimal` | `{"decimal_value": "10.5"}` |
|
|
1328
|
-
| `boolean` | `{"boolean_value": true}` |
|
|
1329
|
-
| `date` | `{"date_value": "2026-05-05"}` |
|
|
1330
|
-
| `date_time` | `{"datetime_value": "2026-05-05T12:00:00Z"}` |
|
|
1331
|
-
| `select` | `{"string_value": "..."}` |
|
|
1332
|
-
| `multi_select`, `*_array`, `json` | `{"json_value": [...]}` |
|
|
1333
|
-
|
|
1334
|
-
Multi-cell field types (e.g., `Currency`) produce two-key snapshots:
|
|
1335
|
-
`{"decimal_value": "99.99", "string_value": "USD"}`. The version row's
|
|
1336
|
-
snapshot asks the field's storage contract for its cells, so new field
|
|
1337
|
-
types get the right shape automatically.
|
|
1338
|
-
|
|
1339
|
-
`{}` (empty hash) and `{"<col>": null}` are distinct semantics:
|
|
1340
|
-
|
|
1341
|
-
- `{}` means **no recorded value** — typical of `before_value` on a
|
|
1342
|
-
`:create` event, or `after_value` on a `:destroy` event.
|
|
1343
|
-
- `{"<col>": null}` means **recorded nil** — the user explicitly
|
|
1344
|
-
cleared the cell.
|
|
1345
|
-
|
|
1346
|
-
### Reverting
|
|
1347
|
-
|
|
1348
|
-
```ruby
|
|
1349
|
-
target = value.history.find_by(change_type: "update")
|
|
1350
|
-
value.revert_to(target)
|
|
1351
|
-
# value's typed columns now match target.before_value.
|
|
1352
|
-
# A NEW version row is written capturing the revert (append-only).
|
|
1353
|
-
```
|
|
1354
|
-
|
|
1355
|
-
`revert_to` writes the targeted version's `before_value` columns back
|
|
1356
|
-
via `self[col] = …` and `save!`. The transactional version callback writes a
|
|
1357
|
-
NEW version row whose
|
|
1358
|
-
`after_value` reflects the targeted version's `before_value`. The
|
|
1359
|
-
audit log is append-only — every revert is itself versioned.
|
|
1360
|
-
|
|
1361
|
-
To record the intent of the revert, wrap the call in `with_context`:
|
|
1362
|
-
|
|
1363
|
-
```ruby
|
|
1364
|
-
TypedEAV.with_context(reverted_from_version_id: target.id, actor: current_user) do
|
|
1365
|
-
value.revert_to(target)
|
|
1366
|
-
end
|
|
1367
|
-
# The new version row's `context` column captures both keys.
|
|
1368
|
-
```
|
|
1369
|
-
|
|
1370
|
-
`revert_to` raises `ArgumentError` in three documented conditions, checked in order:
|
|
1371
|
-
|
|
1372
|
-
- when `version.value_id` is nil (the source Value was destroyed — destroy
|
|
1373
|
-
versions have `value_id: nil` per the locked subscriber contract; you
|
|
1374
|
-
can't restore a destroyed AR record by `save!`);
|
|
1375
|
-
- when the version's `before_value` is empty (the version represents a
|
|
1376
|
-
`:create` event with no before-state to revert to);
|
|
1377
|
-
- when the version belongs to a different Value (`value_id` mismatch).
|
|
1378
|
-
|
|
1379
|
-
In practice only `:update` versions are revertable. To restore a
|
|
1380
|
-
destroyed entity's typed values, create a new `TypedEAV::Value` record
|
|
1381
|
-
manually using `version.before_value` as the seed state.
|
|
1382
|
-
|
|
1383
|
-
### Hook ordering guarantee
|
|
1384
|
-
|
|
1385
|
-
Versioning is installed as boot-latched transactional callbacks on `Value`,
|
|
1386
|
-
and the public callback remains an after-commit observer. The version row is
|
|
1387
|
-
written in the source transaction.
|
|
1388
|
-
```
|
|
1389
|
-
Value#save! → transactional Value callback → ValueVersion.create!
|
|
1390
|
-
→ after_commit → EventDispatcher.dispatch_value_change:
|
|
1391
|
-
1. ... any other generic internal observers ...
|
|
1392
|
-
2. Config.on_value_change user proc # sees the persisted version
|
|
1393
|
-
```
|
|
1394
|
-
|
|
1395
|
-
Internal observer errors propagate. Transactional version-writing errors also
|
|
1396
|
-
propagate inside and roll back the source transaction.
|
|
1397
|
-
User proc errors are rescued and logged via `Rails.logger.error` —
|
|
1398
|
-
the save itself already committed.
|
|
1399
|
-
|
|
1400
|
-
### Actor resolution
|
|
1401
|
-
|
|
1402
|
-
`Config.actor_resolver` mirrors `Config.scope_resolver`'s callable shape
|
|
1403
|
-
but returns whatever the app chooses (an AR record, a string, an integer,
|
|
1404
|
-
nil). The subscriber coerces non-nil returns via `id.to_s` (for AR
|
|
1405
|
-
records) or `to_s` (for scalars) before storing in the `changed_by`
|
|
1406
|
-
column (string, nullable).
|
|
1407
|
-
|
|
1408
|
-
`nil` is the documented permissive sentinel: system writes, migrations,
|
|
1409
|
-
console-without-actor, and background jobs without a `with_context(actor:
|
|
1410
|
-
...)` wrap all flow through with `changed_by: nil`. This is intentional —
|
|
1411
|
-
forcing every Versioned write to have an actor would reject every console
|
|
1412
|
-
save and every migration backfill, which is hostile-by-default for a gem.
|
|
1413
|
-
|
|
1414
|
-
Apps that need stricter enforcement do it inside the resolver:
|
|
1415
|
-
|
|
1416
|
-
```ruby
|
|
1417
|
-
c.actor_resolver = -> { Current.user || raise(MyApp::ActorRequired) }
|
|
1418
|
-
```
|
|
1419
|
-
|
|
1420
|
-
`Config.reset!` (documented in §"Event hooks") also resets `Config.versioning`
|
|
1421
|
-
to `false` and `Config.actor_resolver` to `nil`.
|
|
1422
|
-
|
|
1423
|
-
### What versioning does not do
|
|
1424
|
-
|
|
1425
|
-
- **No branching/merging across version chains.** Phase 4 ships event-log
|
|
1426
|
-
shape only. Roadmap explicitly defers branching to a future design.
|
|
1427
|
-
- **No snapshot storage by default.** `typed_eav_value_versions` is an
|
|
1428
|
-
event log — one row per change, not a full-row snapshot. For
|
|
1429
|
-
high-volume apps that want snapshot storage, extend `ValueVersion` in
|
|
1430
|
-
your own code (the gem keeps the event-log shape canonical so future
|
|
1431
|
-
upgrades don't break your extension).
|
|
1432
|
-
- **No automatic `reverted_from_version_id` injection.** Use
|
|
1433
|
-
`with_context` to record revert intent; the gem captures whatever
|
|
1434
|
-
context the caller set.
|
|
1435
|
-
- **No per-Field versioning toggle.** Opt-in is per-entity (host model)
|
|
1436
|
-
in Phase 4. Per-field granularity may land later if a real need
|
|
1437
|
-
surfaces.
|
|
1438
|
-
- **No GIN indexes on `before_value` / `after_value` content.** Apps
|
|
1439
|
-
that need to query inside the snapshot jsonb add their own indexes.
|
|
1440
|
-
Phase 4 ships only the temporal indexes (`changed_at DESC` keyed on
|
|
1441
|
-
`value_id`, `(entity_type, entity_id)`, and `field_id`).
|
|
1442
|
-
|
|
1443
|
-
### Test isolation
|
|
1444
|
-
|
|
1445
|
-
Specs that exercise versioning should opt into the `:event_callbacks`
|
|
1446
|
-
and `:real_commits` metadata flags (see §"Event hooks" — same pattern):
|
|
1447
|
-
|
|
1448
|
-
```ruby
|
|
1449
|
-
RSpec.describe "my versioning behavior", :event_callbacks, :real_commits do
|
|
1450
|
-
before do
|
|
1451
|
-
TypedEAV.registry.register("Contact", versioned: true)
|
|
1452
|
-
TypedEAV::Config.versioning = true
|
|
1453
|
-
# Transactional Value callbacks are boot-latched and remain installed;
|
|
1454
|
-
# the hook isolates only public and generic EventDispatcher observers.
|
|
1455
|
-
end
|
|
1456
|
-
after { TypedEAV.registry.register("Contact", versioned: false) }
|
|
1457
|
-
|
|
1458
|
-
it "writes a version row" do
|
|
1459
|
-
# ...
|
|
1460
|
-
end
|
|
1461
|
-
end
|
|
1462
|
-
```
|
|
1463
|
-
|
|
1464
|
-
The `:event_callbacks` around hook in `spec/spec_helper.rb` snapshots and
|
|
1465
|
-
restores `Config.versioning`, `Config.actor_resolver`, and generic
|
|
1466
|
-
EventDispatcher observer lists around each example. Transactional Value
|
|
1467
|
-
callback installation is tested independently through callback-chain and
|
|
1468
|
-
boot-latch specs. The
|
|
1469
|
-
`:real_commits` hook disables transactional fixtures (so `after_commit`
|
|
1470
|
-
fires durably) and cleans up `TypedEAV::ValueVersion` rows in
|
|
1471
|
-
FK-respecting order between examples.
|
|
1472
|
-
|
|
1473
|
-
## Database Support
|
|
1474
|
-
|
|
1475
|
-
Requires PostgreSQL. The `text_pattern_ops` index on `string_value` and the jsonb `@>` containment operator are Postgres-specific. MySQL/SQLite support would require removing those index types and changing the array query operators.
|
|
1476
|
-
|
|
1477
|
-
As of v0.2.0, the paired partial unique indexes cover the three-key partition tuple `(entity_type, scope, parent_scope)`. The orphan-parent invariant means the `WHERE scope IS NULL` partials don't include `parent_scope` — a global row always has `parent_scope` NULL too.
|
|
1478
|
-
|
|
1479
|
-
## Schema
|
|
1480
|
-
|
|
1481
|
-
The gem creates five tables:
|
|
1482
|
-
|
|
1483
|
-
- `typed_eav_fields` - field definitions (STI, one row per field per entity type)
|
|
1484
|
-
- `typed_eav_values` - values (one row per entity per field, with typed columns)
|
|
1485
|
-
- `typed_eav_options` - allowed values for select/multi-select fields
|
|
1486
|
-
- `typed_eav_sections` - optional UI grouping
|
|
1487
|
-
- `typed_eav_value_versions` - opt-in, append-only audit history for Value
|
|
1488
|
-
create, update, and destroy events; it retains durable entity identity even
|
|
1489
|
-
when the live Value row is later removed
|
|
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
|
-
|
|
1532
|
-
## Architecture
|
|
1533
|
-
|
|
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.
|
|
1535
|
-
|
|
1536
|
-
### Macro entry: `HasTypedEav`
|
|
1537
|
-
|
|
1538
|
-
`lib/typed_eav/has_typed_eav.rb` (~120 LOC) is the macro shell. When you call `has_typed_eav` on an AR model, it:
|
|
1539
|
-
|
|
1540
|
-
1. `extend`s `TypedEAV::EntityQuery` onto the class (class-level query methods).
|
|
1541
|
-
2. `include`s `TypedEAV::HasTypedEav::InstanceMethods` (per-record accessors).
|
|
1542
|
-
3. Wires scope/parent-scope kwargs into the model's class-level configuration.
|
|
1543
|
-
4. Registers the model with `TypedEAV::Registry`.
|
|
1544
|
-
|
|
1545
|
-
The macro is intentionally thin. All real behavior lives in the modules it pulls in.
|
|
1546
|
-
|
|
1547
|
-
### Class-level reads: two-altitude query pattern
|
|
1548
|
-
|
|
1549
|
-
```
|
|
1550
|
-
Contact.where_typed_eav(...) ← public class method
|
|
1551
|
-
│
|
|
1552
|
-
▼
|
|
1553
|
-
TypedEAV::EntityQuery ← high altitude: orchestrator
|
|
1554
|
-
• resolves scope/parent_scope from ambient context or explicit kwargs
|
|
1555
|
-
• owns the UNSET_SCOPE / ALL_SCOPES sentinels
|
|
1556
|
-
• delegates to FilterQuery
|
|
1557
|
-
│
|
|
1558
|
-
▼
|
|
1559
|
-
TypedEAV::FilterQuery ← multi-filter composition
|
|
1560
|
-
• normalizes filter input shapes (positional, hash, hash-of-hashes)
|
|
1561
|
-
• looks up field definitions via TypedEAV::Partition
|
|
1562
|
-
• per filter, asks QueryBuilder for the SQL fragment
|
|
1563
|
-
• unions/intersects per-field entity-id sets
|
|
1564
|
-
• returns an ActiveRecord::Relation scoped to the host model
|
|
1565
|
-
│
|
|
1566
|
-
▼
|
|
1567
|
-
TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
|
|
1568
|
-
• turns a single (field, op, value) into a WHERE clause against typed_eav_values
|
|
1569
|
-
• knows about typed-column projections (integer_value, string_value, etc.)
|
|
1570
|
-
• knows about operator-specific column choice (currency-cents vs currency-code)
|
|
1571
|
-
```
|
|
1572
|
-
|
|
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`.
|
|
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
|
-
|
|
1580
|
-
### Bulk reads: `BulkRead`
|
|
1581
|
-
|
|
1582
|
-
`typed_eav_hash_for(records)` (the plural read) routes through `TypedEAV::BulkRead`. Given a record collection and an effective `(scope, parent_scope)`, it:
|
|
1583
|
-
|
|
1584
|
-
1. Resolves visible definitions and groups the requested field IDs.
|
|
1585
|
-
2. Loads definitions, values, and field associations through one batched
|
|
1586
|
-
definition query, one values query, and one field-association preload
|
|
1587
|
-
(three SQL queries total; no host-table query).
|
|
1588
|
-
3. Returns a `{record_id => {field_name => value}}` map while skipping orphaned
|
|
1589
|
-
values and preserving logical missingness.
|
|
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
|
-
|
|
1595
|
-
The final production characterization reduced the 1,002 SQL statements observed
|
|
1596
|
-
across 1,000 scopes to three for the same BulkRead shape. This is a statement-
|
|
1597
|
-
count result, not a representative throughput claim; applications should still
|
|
1598
|
-
measure their own scope cardinality, selected fields, hydration, and contention.
|
|
1599
|
-
|
|
1600
|
-
Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMethods` and use the same partition helpers but without batching.
|
|
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
|
-
|
|
1640
|
-
### Bulk writes: `BulkWrite`
|
|
1641
|
-
|
|
1642
|
-
`bulk_set_typed_eav_values(records, attrs)` routes through `TypedEAV::BulkWrite`,
|
|
1643
|
-
and `bulk_set_typed_eav_values_per_record(values_by_record)` is its sibling for
|
|
1644
|
-
record-varying hashes. Both are semantic writers that:
|
|
1645
|
-
|
|
1646
|
-
1. Memoizes field definitions for the call via `Thread.current[:typed_eav_bulk_defs_memo]`.
|
|
1647
|
-
2. Validates each attribute against its field type's cast contract.
|
|
1648
|
-
3. Saves each host through the normal callback/validation path inside an outer
|
|
1649
|
-
transaction with per-record savepoints.
|
|
1650
|
-
|
|
1651
|
-
`bulk_set_typed_eav_values_per_record` uses records as Hash keys, so two AR
|
|
1652
|
-
instances of the same persisted row collapse to one entry; sequence separate
|
|
1653
|
-
calls for two ordered updates, while the uniform Array API preserves duplicate
|
|
1654
|
-
instances and caller order.
|
|
1655
|
-
|
|
1656
|
-
Under `transaction: :all`, per-record validation failures are captured at their
|
|
1657
|
-
savepoints while other successes can commit, but an uncaught exception rolls
|
|
1658
|
-
the entire outer transaction back. The default `transaction: :all` commits the
|
|
1659
|
-
whole successful batch or rolls it back;
|
|
1660
|
-
`transaction: :chunks, chunk_size: N` commits completed chunks while isolating
|
|
1661
|
-
later failures, preserving earlier committed chunks. Both forms require the
|
|
1662
|
-
host, Field, and Value pools to match.
|
|
1663
|
-
`bulk_upsert_typed_eav_values` is a separate reduced-semantics fast path: it
|
|
1664
|
-
casts and validates typed values, then performs one PostgreSQL upsert while
|
|
1665
|
-
omitting host saves, persistence callbacks, delete shorthand, and versioning.
|
|
1666
|
-
|
|
1667
|
-
Callers must pass `acknowledge_reduced_semantics: true`. The same values hash
|
|
1668
|
-
applies to every record; records must be persisted and unique, and string or
|
|
1669
|
-
symbol field keys that normalize to the same name are rejected. The return
|
|
1670
|
-
value is the integer number of value rows upserted, not a semantic
|
|
1671
|
-
`successes`/`errors_by_record` result. Value casting, domain/entity/partition
|
|
1672
|
-
checks, and Value validation callbacks remain; host callbacks and validations,
|
|
1673
|
-
Value persistence callbacks, versioning, delete shorthand, and per-record
|
|
1674
|
-
savepoint isolation are skipped.
|
|
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
|
-
|
|
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.
|
|
1683
|
-
|
|
1684
|
-
### Per-record reads/writes: `InstanceMethods`
|
|
1685
|
-
|
|
1686
|
-
`lib/typed_eav/has_typed_eav/instance_methods.rb` (~250 LOC) holds the per-record API:
|
|
1687
|
-
|
|
1688
|
-
- `typed_eav_value(name)` / `typed_eav_hash` — reads
|
|
1689
|
-
- `set_typed_eav_value(name, value)` / `typed_eav_attributes=` (aliased as `typed_eav=`) — writes
|
|
1690
|
-
- `typed_eav_definitions` — resolved field-definitions map for the host record
|
|
1691
|
-
- `typed_eav_scope` / `typed_eav_parent_scope` — scope resolution per record
|
|
1692
|
-
|
|
1693
|
-
Every method uses `TypedEAV::Partition.definitions_by_name` so the collision-precedence rules for ambient/explicit/parent scopes are computed in one place.
|
|
1694
|
-
|
|
1695
|
-
### Partition visibility: `Partition`
|
|
1696
|
-
|
|
1697
|
-
Host applications that need to inspect effective schema should use the
|
|
1698
|
-
documented-public `TypedEAV::Partition` seam rather than rebuilding tuple
|
|
1699
|
-
predicates. It exposes `visible_fields`, `effective_fields_by_name`,
|
|
1700
|
-
`definitions_by_name`, `definitions_multimap_by_name`, `visible_sections`,
|
|
1701
|
-
and `find_visible_section!`. These methods preserve global, scope-only, and
|
|
1702
|
-
full-tuple precedence; ADR-0006 additionally fixes include-missing set
|
|
1703
|
-
composition at the `FilterQuery` altitude.
|
|
1704
|
-
|
|
1705
|
-
### Field types and storage: `Field::TypedStorage`
|
|
1706
|
-
|
|
1707
|
-
`TypedEAV::Field::Base` is the STI parent of every field type. The shared storage surface lives in the `TypedEAV::Field::TypedStorage` concern (`lib/typed_eav/field/typed_storage.rb`, ~200 LOC), auto-included on `Field::Base`. Per [ADR-0001](docs/adr/0001-collapse-column-mapping-stack.md), it provides:
|
|
1708
|
-
|
|
1709
|
-
- **Class DSL**: `value_column`, `value_columns`, `operators`, `operator_column`, `supported_operators` — describe where typed values live and which operators they support.
|
|
1710
|
-
- **Instance override points**: `read_value(record)`, `write_value(record, casted)`, `apply_default(record)` — the three methods a multi-cell field type overrides.
|
|
1711
|
-
- **Concrete snapshot helpers**: `value_changed?`, `before_snapshot`, `after_snapshot` — derived automatically from `value_columns`; not overridable.
|
|
1712
|
-
|
|
1713
|
-
Custom multi-cell field types subclass `Field::Base` directly and override only the three instance methods. See §[Multi-cell field types](#multi-cell-field-types) for `Currency` as the canonical worked example.
|
|
1714
|
-
|
|
1715
|
-
### Field families: intermediate STI bases
|
|
1716
|
-
|
|
1717
|
-
Per [ADR-0004](docs/adr/0004-field-family-intermediate-bases.md), three intermediate STI parents factor shared validation behavior out of `Field::Base`:
|
|
1718
|
-
|
|
1719
|
-
- **`TypedEAV::Field::ValidatedString`** — parent of `Text`, `Email`, `Url`. Owns string-length and pattern-validation helpers including `max_gte_min_length` (which now covers Email/Url, not just Text).
|
|
1720
|
-
- **`TypedEAV::Field::RangeBounded`** — parent of `Integer`, `Decimal`, `Date`, `DateTime` (and `Percentage < Decimal`). Owns range-validation helpers including `validates :max, comparison: { greater_than_or_equal_to: :min }` (which now covers Date/DateTime, not just Integer/Decimal).
|
|
1721
|
-
- **`TypedEAV::Field::Optionable`** — a Rails concern included by `Select` and `MultiSelect`. Owns the public-facing sorted `allowed_values` reader and the option-inclusion validators.
|
|
1722
|
-
|
|
1723
|
-
`Color`, `Boolean`, `Json`, and the array field types (`TextArray`, `IntegerArray`, `DecimalArray`, `DateArray`) remain direct children of `Field::Base`. See §[Family intermediate bases](#family-intermediate-bases-extension-points) for extension examples.
|
|
1724
|
-
|
|
1725
|
-
### Scope tuple normalization: `ScopeTuple`
|
|
1726
|
-
|
|
1727
|
-
`TypedEAV::ScopeTuple` (`lib/typed_eav/scope_tuple.rb`, ~120 LOC) is the canonical source of truth for the `(scope, parent_scope)` partition tuple. It provides:
|
|
1728
|
-
|
|
1729
|
-
- `normalize_permissive(scope)` — coerces input to a tuple; tolerates bare scalars (used by `with_scope`, `normalize_scope`, `Field#validate_parent_scope_invariant`).
|
|
1730
|
-
- `normalize_strict(scope)` — same shape, but raises on bare-scalar input (used by `current_scope`; preserves Phase-1's asymmetric contract that `Config.scope_resolver` must return a tuple).
|
|
1731
|
-
- `invariant_satisfied?(scope, parent_scope)` — Boolean check for the orphan-parent invariant (`parent_scope` set without `scope` = invalid).
|
|
1732
|
-
|
|
1733
|
-
Each calling site keeps its own response policy (raise / AR error / silent narrow) using the Boolean return — `ScopeTuple` is a predicate, not an enforcer.
|
|
1734
|
-
|
|
1735
|
-
### Partition tuple helpers: `Partition`
|
|
1736
|
-
|
|
1737
|
-
`TypedEAV::Partition` (`lib/typed_eav/partition.rb`, ~100 LOC) owns the `(entity_type, scope, parent_scope)` precedence rules:
|
|
1738
|
-
|
|
1739
|
-
- `definitions_by_name(model, scope, parent_scope)` — returns the field-definitions map for a single resolved partition.
|
|
1740
|
-
- `definitions_multimap_by_name(model)` — returns the cross-partition multimap used by `unscoped { }` blocks.
|
|
1741
|
-
- `visible_fields(model, scope, parent_scope)` / `visible_sections(...)` — scope-respecting field/section iteration with the orphan-parent invariant inlined via `ScopeTuple.invariant_satisfied?`.
|
|
1742
|
-
|
|
1743
|
-
The definitions helpers used to live as class methods on `HasTypedEav` before 0.3.0. They moved to `Partition` per [ADR-0002](docs/adr/0002-entity-query-orchestration.md) because they describe the partition domain, not the macro.
|
|
1744
|
-
|
|
1745
|
-
### Events: `EventDispatcher`
|
|
1746
|
-
|
|
1747
|
-
`TypedEAV::EventDispatcher` (`lib/typed_eav/event_dispatcher.rb`, ~150 LOC) is the broker for `on_value_change` and `on_field_change` callbacks. Per [ADR-0003](docs/adr/0003-keep-event-dispatcher-broker.md), it intentionally stays a broker rather than getting absorbed into either `Value` or `Field` — its multi-publisher / multi-subscriber shape doesn't belong on either model. See §[Event hooks](#event-hooks) for the public callback contract.
|
|
1748
|
-
|
|
1749
|
-
### Schema portability and CSV: independent modules
|
|
1750
|
-
|
|
1751
|
-
`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.
|
|
1752
|
-
|
|
1753
|
-
### Bulk operation guarantees
|
|
1754
|
-
|
|
1755
|
-
`bulk_upsert_typed_eav_values` is an explicit reduced-semantics API: it
|
|
1756
|
-
prevalidates/casts values and performs a PostgreSQL upsert, while intentionally
|
|
1757
|
-
omitting host callbacks and versioning. Use the regular bulk writer when those
|
|
1758
|
-
semantics are required; chunked semantic transactions are opt-in.
|
|
1759
|
-
|
|
1760
|
-
The fast path still casts and runs domain, entity, partition, and validation
|
|
1761
|
-
callbacks before its single upsert against the exact entity/field conflict
|
|
1762
|
-
target; it omits host saves/host callbacks, Value persistence callbacks,
|
|
1763
|
-
delete shorthand, and versioning. It requires one shared connection pool and
|
|
1764
|
-
returns validation errors before SQL. `:all` is one unit; `:chunks` commits
|
|
1765
|
-
completed chunks before a later failure. Semantic writes retain host saves,
|
|
1766
|
-
per-record savepoint/error isolation, and one outer `:all` transaction.
|
|
1767
|
-
|
|
1768
|
-
BulkWrite evidence is intentionally bounded to the exercised 100- and 1,000-host
|
|
1769
|
-
lanes. It does not establish 10,000- or 100,000-host throughput, nor does it
|
|
1770
|
-
justify a universal batch size or storage choice.
|
|
1771
|
-
|
|
1772
|
-
### Operational guarantees
|
|
1773
|
-
|
|
1774
|
-
The semantic writer preserves the caller's transaction and callback/versioning
|
|
1775
|
-
contract. Version rows are written in the source transaction, so a rollback
|
|
1776
|
-
rolls back the Value mutation and its audit row together. The reduced-semantics
|
|
1777
|
-
upsert is intentionally separate and does not claim those callbacks or audit
|
|
1778
|
-
guarantees.
|
|
1779
|
-
|
|
1780
|
-
Field deletion has a callback-preserving, keyset-batched path that locks and
|
|
1781
|
-
destroys only the exact field's Values before bounded finalization. It scales by
|
|
1782
|
-
bounded primary-key batches and preserves the Field if a batch fails; it is not
|
|
1783
|
-
a claim of unbounded deletion throughput.
|
|
1784
|
-
|
|
1785
|
-
### Default backfill narrowing
|
|
1786
|
-
|
|
1787
|
-
`Field::Base#backfill_default!` optionally accepts an exact-host
|
|
1788
|
-
`ActiveRecord::Relation` to SQL-narrow eligible entities before batching. The default
|
|
1789
|
-
all-host behavior remains unchanged; partition checks, batch transactions,
|
|
1790
|
-
callbacks, validations, idempotence, versions, and errors remain in force.
|
|
1791
|
-
Typed storage defines logical missingness across all declared cells, so a
|
|
1792
|
-
partially populated multi-cell value is present while a fully empty Currency
|
|
1793
|
-
value is missing.
|
|
146
|
+
| [Reading, writing, and forms](docs/guides/usage.md) | Assignment by name or ID, nested forms, admin scaffold, pending and saved changes |
|
|
147
|
+
| [Querying typed fields](docs/guides/queries.md) | Operators, filter composition, sorting, distinct values, counts, aggregates |
|
|
148
|
+
| [Multi-tenant scoping](docs/guides/scoping.md) | Resolvers, two-level partitions, precedence, background jobs, administrative queries |
|
|
149
|
+
| [Field types and validation](docs/guides/fields.md) | Built-in types, options, sections, attachments, custom types, validation contracts |
|
|
150
|
+
| [Bulk operations](docs/guides/bulk-operations.md) | Selective and preloaded reads, semantic writes, fast upsert, transactions, default backfills |
|
|
151
|
+
| [Events and versioning](docs/guides/events-and-versioning.md) | Callbacks, context, actor resolution, audit history, reverting |
|
|
152
|
+
| [CSV mapping](docs/guides/csv-import.md) | Header/index mapping, typed casting, row errors, saving mapped values |
|
|
153
|
+
| [Database schema and portability](docs/guides/schema.md) | Tables, PostgreSQL requirements, read-only schema previews |
|
|
154
|
+
| [Storage and performance](docs/guides/performance.md) | JSONB tradeoffs, optional indexes and statistics, query strategy, evidence limits |
|
|
155
|
+
|
|
156
|
+
## Development and Upgrades
|
|
157
|
+
|
|
158
|
+
- [Architecture](docs/guides/architecture.md) and [design decisions](docs/adr/)
|
|
159
|
+
- [Development and test isolation](docs/guides/development.md)
|
|
160
|
+
- [Benchmark guide](bench/README.md)
|
|
161
|
+
- [Upgrading](docs/guides/upgrading.md) and [Changelog](CHANGELOG.md)
|
|
162
|
+
- [Release process](RELEASING.md)
|
|
1794
163
|
|
|
1795
164
|
## License
|
|
1796
165
|
|