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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -2
  3. data/README.md +79 -1710
  4. data/RELEASING.md +81 -0
  5. data/docs/adr/0001-collapse-column-mapping-stack.md +28 -0
  6. data/docs/adr/0002-entity-query-orchestration.md +33 -0
  7. data/docs/adr/0003-keep-event-dispatcher-broker.md +41 -0
  8. data/docs/adr/0004-field-family-intermediate-bases.md +49 -0
  9. data/docs/adr/0005-keep-phase-six-modules-independent.md +48 -0
  10. data/docs/adr/0006-include-missing-via-set-complement.md +77 -0
  11. data/docs/adr/0007-visibility-versus-mutation-relations.md +33 -0
  12. data/docs/adr/0008-partial-covering-scalar-indexes.md +83 -0
  13. data/docs/adr/0009-string-search-indexing.md +110 -0
  14. data/docs/adr/0010-planner-statistics-policy.md +109 -0
  15. data/docs/adr/0011-multi-filter-query-strategy.md +111 -0
  16. data/docs/adr/0012-cross-scope-administrative-query-policy.md +76 -0
  17. data/docs/adr/0013-durable-versioning-and-field-deletion.md +125 -0
  18. data/docs/adr/index.md +101 -0
  19. data/docs/getting-started.md +79 -0
  20. data/docs/guides/architecture.md +125 -0
  21. data/docs/guides/bulk-operations.md +205 -0
  22. data/docs/guides/csv-import.md +88 -0
  23. data/docs/guides/development.md +73 -0
  24. data/docs/guides/events-and-versioning.md +360 -0
  25. data/docs/guides/fields.md +342 -0
  26. data/docs/guides/performance.md +107 -0
  27. data/docs/guides/queries.md +188 -0
  28. data/docs/guides/schema.md +134 -0
  29. data/docs/guides/scoping.md +254 -0
  30. data/docs/guides/upgrading.md +26 -0
  31. data/docs/guides/usage.md +259 -0
  32. data/docs/index.md +44 -0
  33. data/docs/maintaining.md +82 -0
  34. data/docs/reference/api.md +133 -0
  35. data/docs/reference/configuration.md +64 -0
  36. data/docs/reference/index.md +16 -0
  37. data/lib/typed_eav/version.rb +1 -1
  38. metadata +35 -1
@@ -0,0 +1,134 @@
1
+ ---
2
+ title: "Database schema and portability"
3
+ ---
4
+
5
+ # Database schema and portability
6
+
7
+ [Documentation home](../index.md)
8
+
9
+ ## Database Support
10
+
11
+ 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.
12
+
13
+ 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.
14
+
15
+ ## Schema
16
+
17
+ The gem creates five tables:
18
+
19
+ - `typed_eav_fields` - field definitions (STI, one row per field per entity type)
20
+ - `typed_eav_values` - values (one row per entity per field, with typed columns)
21
+ - `typed_eav_options` - allowed values for select/multi-select fields
22
+ - `typed_eav_sections` - optional UI grouping
23
+ - `typed_eav_value_versions` - opt-in, append-only audit history for Value
24
+ create, update, and destroy events; it retains durable entity identity even
25
+ when the live Value row is later removed
26
+
27
+ ## Exporting and importing definitions
28
+
29
+ Use the portable export to copy field and section definitions between databases:
30
+
31
+ ```ruby
32
+ schema = TypedEAV::SchemaPortability.export_schema(
33
+ entity_type: Contact.polymorphic_name, scope: "t1", parent_scope: nil
34
+ )
35
+ File.write("contact-schema.json", JSON.pretty_generate(schema))
36
+
37
+ # In the destination application, after installing the gem's migrations:
38
+ schema = JSON.parse(File.read("contact-schema.json"))
39
+ result = TypedEAV::SchemaPortability.import_schema(schema, on_conflict: :error)
40
+ result # => {"created" => 3, "updated" => 0, "skipped" => 0,
41
+ # "unchanged" => 0, "errors" => []} (example counts)
42
+ ```
43
+
44
+ The string-keyed envelope has `schema_version: 1`, `entity_type`, `scope`,
45
+ `parent_scope`, `fields`, and `sections`. Export selects the **exact tuple**;
46
+ it does not merge inherited global definitions. Export each required partition
47
+ separately. It includes field types, raw labels, defaults, options, option rows,
48
+ and section properties. It excludes host records, Value rows, audit history,
49
+ database IDs, and field-to-section associations. It is not a database backup.
50
+ Custom field classes used by an export must also exist in the receiving app.
51
+
52
+ Import matches fields by name and partition, and sections by code and partition.
53
+ Identical entries increment `unchanged`. Divergent entries raise `ArgumentError`
54
+ under the default `:error` policy; `:skip` increments `skipped`, and `:overwrite`
55
+ updates the definition and increments `updated`. New entries increment `created`.
56
+ Counts combine fields and sections. Overwriting an optionable field replaces
57
+ its option rows; omitted definitions are retained. Existing values are neither
58
+ converted nor backfilled when defaults or constraints change.
59
+
60
+ Import runs in one transaction. Unsupported schema versions or conflict policies,
61
+ divergent definitions under `:error`, and all field type swaps raise
62
+ `ArgumentError`; model validation failures raise Active Record exceptions.
63
+ Failures roll back the import and propagate: `errors` is currently an empty
64
+ compatibility slot, **not** a collected failure report.
65
+
66
+ The importer uses each entry's partition identity. To deliberately copy to a
67
+ different tenant, update `entity_type`, `scope`, and `parent_scope` in both the
68
+ envelope and **every** field and section entry before previewing/importing.
69
+ Changing only the envelope does not retarget the imported rows. Preview first
70
+ when importing into an existing partition.
71
+
72
+ ## Snapshot schema for application exports
73
+
74
+ ```ruby
75
+ snapshot = TypedEAV::SchemaPortability.export_snapshot_schema(
76
+ entity_type: Contact.polymorphic_name, scope: "t1"
77
+ )
78
+ snapshot["snapshot_schema_version"] # => 1
79
+ snapshot["fields"].map { |field| [field["name"], field["display_name"]] }
80
+ ```
81
+
82
+ This read-only Hash is a smaller projection for applications packaging typed
83
+ values with enough field metadata to render or interpret them later. Each field
84
+ contains `name`, `field_type_name`, resolved `display_name`, `required`,
85
+ `sort_order`, and `options`; optionable fields also contain `options_data`.
86
+ Non-optionable fields omit that key. Fields are ordered by `sort_order`.
87
+
88
+ Unlike the portable export, the snapshot omits sections, partition identity,
89
+ STI class names, defaults, and `field_dependent`. Capture the owning partition
90
+ and values separately if your application needs them. Consumers should check
91
+ `snapshot_schema_version`; `field_type_name` survives namespace moves but changes
92
+ if a field's leaf class is renamed. There is no matching snapshot-import API:
93
+ use `export_schema` / `import_schema` for definition round trips.
94
+
95
+ ## Read-only schema previews
96
+
97
+ ```ruby
98
+ schema = TypedEAV::SchemaPortability.export_schema(
99
+ entity_type: "Contact", scope: "t1"
100
+ )
101
+ schema["fields"].first["required"] = true
102
+ preview = TypedEAV::SchemaPortability.preview_schema(schema, on_conflict: :overwrite)
103
+ preview["fields"].first["changes"]
104
+ # => {"required" => {"from" => false, "to" => true}}
105
+ preview["risks"] # => ["required_false_to_true"]
106
+ ```
107
+
108
+ The preview compares a version-1 portable export with the database's exact
109
+ target partition. It requires the envelope's `entity_type`, `scope`, and
110
+ `parent_scope` to match every entry; mixed-target payloads and duplicate
111
+ identities are rejected. This intentionally stricter preview input does not
112
+ change `import_schema` or silently retarget definitions.
113
+
114
+ The plain Hash result contains envelope metadata, `summary`, `fields`,
115
+ `sections`, `risks`, and `importable`. Each entry includes its exact `identity`,
116
+ `status` (`unchanged`, `added`, `changed`, or `conflict`), conditional `action`,
117
+ and attribute `changes` with `from`/`to` values. Field entries also contain
118
+ option-row `added`, `removed`, and `changed` lists, matched by option value.
119
+ Raw option ordering and key-presence differences remain visible because the
120
+ importer compares the complete exported payload, not just equivalent settings.
121
+
122
+ `on_conflict: :error` blocks divergent definitions; `:skip` leaves them alone;
123
+ `:overwrite` predicts an update. Type swaps always produce an error action,
124
+ even under skip/overwrite. Risks flag type changes, removed options, newly
125
+ required fields, and changes to options, defaults, or field dependencies.
126
+ Omitted target definitions are **not deletions** and are not listed as such.
127
+
128
+ `importable: true` means no known conflict-policy/type-swap blocker was found,
129
+ not that validation or a later import is guaranteed to succeed. Actions are
130
+ conditional predictions: a blocking error aborts the existing transactional
131
+ import, including otherwise acceptable additions. Previewing does not save
132
+ definitions, run mutation/validation callbacks, enqueue jobs, execute DDL, or
133
+ convert values. It is an advisory snapshot, not a lock or reservation; model
134
+ validations and concurrent changes still apply to the actual import.
@@ -0,0 +1,254 @@
1
+ ---
2
+ title: "Multi-tenant scoping"
3
+ ---
4
+
5
+ # Multi-tenant scoping
6
+
7
+ [Documentation home](../index.md)
8
+
9
+ **Scope selects field definitions; it does not authorize or filter host records. Apply tenant and authorization filters to the host relation.**
10
+
11
+ ## Multi-Tenant Scoping
12
+
13
+ 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.
14
+
15
+ ### Declaring a scoped model
16
+
17
+ ```ruby
18
+ class Contact < ApplicationRecord
19
+ has_typed_eav scope_method: :tenant_id
20
+ end
21
+ ```
22
+
23
+ `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.
24
+
25
+ ### Class-level queries resolve scope automatically
26
+
27
+ Queries like `Contact.where_typed_eav(...)` consult an **ambient scope resolver** — no need to pass `scope:` on every call:
28
+
29
+ ```ruby
30
+ # The resolver tells TypedEAV which partition is active.
31
+ Contact.where_typed_eav({ name: "age", op: :gt, value: 21 })
32
+ ```
33
+
34
+ The resolver chain (highest priority first):
35
+
36
+ 1. Explicit `scope:` keyword argument on the query
37
+ 2. Active `TypedEAV.with_scope(value) { ... }` block
38
+ 3. Configured `TypedEAV.config.scope_resolver` callable
39
+ 4. `nil`
40
+
41
+ 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.
42
+
43
+ ### Wiring the resolver
44
+
45
+ Pick the pattern that matches your app and set it once in `config/initializers/typed_eav.rb`:
46
+
47
+ ```ruby
48
+ TypedEAV.configure do |c|
49
+ # acts_as_tenant (auto-detected — no config needed if loaded)
50
+ # c.scope_resolver = -> { [ActsAsTenant.current_tenant&.id, nil] }
51
+
52
+ # Rails CurrentAttributes
53
+ # c.scope_resolver = -> { [Current.account&.id, nil] }
54
+
55
+ # Custom class
56
+ # c.scope_resolver = -> { [MyApp::Tenancy.current_workspace_id, nil] }
57
+
58
+ # Subdomain / session / thread-local
59
+ # c.scope_resolver = -> { [Thread.current[:org_id], nil] }
60
+
61
+ # Disable ambient resolution entirely
62
+ # c.scope_resolver = nil
63
+
64
+ c.require_scope = true # fail-closed (default). Set false for gradual adoption.
65
+ end
66
+ ```
67
+
68
+ The resolver MUST return a 2-element Array `[scope, parent_scope]`. Each slot
69
+ accepts a raw value (`"t1"`, `42`), an AR record (TypedEAV calls `.id.to_s`
70
+ on anything that responds to `#id`), or `nil`. If you don't use parent_scope,
71
+ return `[scope, nil]`. A bare scalar return raises `ArgumentError` at the
72
+ next ambient query — see [Migrating from v0.1.x](upgrading.md#migrating-from-v01x) for
73
+ the upgrade path.
74
+
75
+ ### Block APIs
76
+
77
+ ```ruby
78
+ # Run a block with a specific ambient scope (background jobs, console, rake tasks):
79
+ TypedEAV.with_scope(tenant_id) do
80
+ Contact.where_typed_eav({ name: "status", op: :eq, value: "active" })
81
+ end
82
+
83
+ # Escape hatch for admin tools, migrations, or cross-tenant audits:
84
+ TypedEAV.unscoped do
85
+ Contact.where_typed_eav({ name: "status", op: :eq, value: "active" })
86
+ # returns matches across ALL partitions
87
+ end
88
+ ```
89
+
90
+ Both are exception-safe via `ensure` and nest cleanly.
91
+
92
+ `unscoped` is an explicit administrative/analytics escape hatch, not the
93
+ ordinary tenant request path. It keeps every same-name definition across the
94
+ visible partitions and unions their matches for each filter. For broad audits
95
+ or migrations, bound the definition universe to the work you actually need and
96
+ batch the job at an application-owned boundary. TypedEAV does not prescribe a
97
+ universal limit or batch size; measure generated SQL, planning/execution,
98
+ memory, and workload interference in your application. Keep normal request
99
+ traffic on scoped resolution so global, scope-only, and full-tuple definitions
100
+ collapse to the most-specific match.
101
+
102
+ ### Explicit `scope:` override
103
+
104
+ Any query method accepts `scope:` as an override for admin tools and tests:
105
+
106
+ ```ruby
107
+ Contact.where_typed_eav({ name: "status", value: "active" }, scope: "t1")
108
+ Contact.with_field("age", :gt, 21, scope: "t1")
109
+ ```
110
+
111
+ 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.
112
+
113
+ ### Background jobs
114
+
115
+ 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:
116
+
117
+ ```ruby
118
+ class ExportJob
119
+ include Sidekiq::Job
120
+
121
+ def perform(tenant_id, ...)
122
+ TypedEAV.with_scope(tenant_id) do
123
+ Contact.where_typed_eav(...)
124
+ end
125
+ end
126
+ end
127
+ ```
128
+
129
+ ### Disabling enforcement for gradual adoption
130
+
131
+ 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`.
132
+
133
+ 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`.
134
+
135
+ ### Two-level scoping (`parent_scope`)
136
+
137
+ When a single tenant axis isn't enough — say, `tenant_id` for the customer AND
138
+ `workspace_id` for an in-tenant partition — declare both:
139
+
140
+ ```ruby
141
+ class Project < ApplicationRecord
142
+ has_typed_eav scope_method: :tenant_id, parent_scope_method: :workspace_id
143
+ end
144
+ ```
145
+
146
+ Field (and section) definitions partition on the tuple `(entity_type, scope,
147
+ parent_scope)`. A `Project` record reads field definitions in three precedence
148
+ layers: a full-triple `(scope, parent_scope)` match wins, then `(scope, nil)`
149
+ (tenant-wide), then `(nil, nil)` (truly global). The same precedence applies
150
+ to the class-level query path.
151
+
152
+ `parent_scope_method:` requires `scope_method:` — declaring it without a scope
153
+ method raises at macro-expansion time (no host can have a parent partition
154
+ without a scope partition).
155
+
156
+ Both `with_scope` and the configured `scope_resolver` carry the tuple now:
157
+
158
+ ```ruby
159
+ TypedEAV.with_scope(["t1", "w1"]) do
160
+ Project.where_typed_eav({ name: "status", value: "active" })
161
+ end
162
+
163
+ # Single-axis call still works (parent_scope = nil):
164
+ TypedEAV.with_scope("t1") do
165
+ Contact.where_typed_eav({ name: "age", op: :gt, value: 21 })
166
+ end
167
+
168
+ # Custom resolver — MUST return [scope, parent_scope]:
169
+ TypedEAV.configure do |c|
170
+ c.scope_resolver = -> { [Current.tenant&.id, Current.workspace&.id] }
171
+ end
172
+ ```
173
+
174
+ Per-query overrides accept `parent_scope:` alongside `scope:` on
175
+ `where_typed_eav`, `with_field`, and `typed_eav_definitions`:
176
+
177
+ ```ruby
178
+ Project.where_typed_eav(
179
+ { name: "priority", value: "high" },
180
+ scope: "t1",
181
+ parent_scope: "w1",
182
+ )
183
+ ```
184
+
185
+ When `acts_as_tenant` is loaded, the auto-detected `DEFAULT_SCOPE_RESOLVER`
186
+ returns `[ActsAsTenant.current_tenant, nil]` — the parent_scope slot is `nil`
187
+ because the tenant gem has no parent-scope analog. Configure your own resolver
188
+ when you need both axes.
189
+
190
+ ### Orphan-parent invariant
191
+
192
+ A `Field` or `Section` row with `parent_scope` set and `scope` blank is
193
+ invalid — model-level validation rejects it on save. Reason: a "global field
194
+ within one workspace" has no semantic resolution path; the row would never
195
+ match any record's resolver. The paired partial unique indexes rely on this
196
+ invariant.
197
+
198
+ The shipped migration chain also includes
199
+ `EnforceParentScopeInvariant`, which declares the database check constraints
200
+ nontransactionally and validates them after its preflight, and
201
+ `UsePartialCoveringScalarIndexes`, which creates the six `*_present` indexes
202
+ before removing their legacy counterparts. Both migrations use
203
+ `disable_ddl_transaction!`; run them through the normal migration command and
204
+ do not wrap them in an application transaction.
205
+
206
+ ### Name collisions across scopes
207
+
208
+ 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.
209
+
210
+ `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.
211
+
212
+ Because that administrative path constructs work for every matching
213
+ definition, applications should narrow and batch high-cardinality audits rather
214
+ than treating `unscoped` as tenant-request routing. No built-in numeric
215
+ threshold is implied; choose operational bounds from measurements of the
216
+ consuming workload. See [ADR 0012](../adr/0012-cross-scope-administrative-query-policy.md).
217
+
218
+ ## Partition helpers for custom management interfaces
219
+
220
+ These helpers take an **explicit, already-resolved tuple**; they do not consult
221
+ ambient scope or apply application authorization. For example, after authorizing
222
+ access to a tenant's field management page:
223
+
224
+ ```ruby
225
+ partition = {
226
+ entity_type: Contact.polymorphic_name,
227
+ scope: current_tenant.id.to_s,
228
+ parent_scope: nil
229
+ }
230
+ visible = TypedEAV::Partition.visible_fields(**partition) # AR relation
231
+ fields = TypedEAV::Partition.effective_fields_by_name(**partition) # name => Field
232
+ sections = TypedEAV::Partition.visible_sections(**partition).active.sorted
233
+ section = TypedEAV::Partition.find_visible_section!(params[:section_id], **partition)
234
+ ```
235
+
236
+ `visible_fields` includes global, scope-only, and full-tuple definitions.
237
+ `effective_fields_by_name` collapses same-name collisions to the most specific
238
+ field. If you already loaded a visibility-filtered collection, use
239
+ `definitions_by_name(collection)` for the same collapse or
240
+ `definitions_multimap_by_name(collection)` for a name-to-array grouping. Those
241
+ two collection helpers do not filter the input for you.
242
+
243
+ Sections use the same visibility layers, without name-based collision collapse.
244
+ `find_visible_section!` returns a section by ID within those layers and raises
245
+ `ActiveRecord::RecordNotFound` for missing, blank, or out-of-partition IDs.
246
+ Visibility includes inherited definitions; before editing a global definition,
247
+ apply your application's permission policy for changing shared schema.
248
+
249
+ The visibility helpers accept `mode: :all_partitions` as an explicit admin bypass.
250
+ In that mode `effective_fields_by_name` returns arrays per name, not single fields.
251
+ Always supply `entity_type` for an interface serving one model: `visible_fields`
252
+ alone allows it to be omitted. Under ordinary `mode: :partition`, `scope: nil`
253
+ means global-only; a nonblank parent without a scope raises `ArgumentError`.
254
+ Unknown modes raise `ArgumentError` as well.
@@ -0,0 +1,26 @@
1
+ ---
2
+ title: "Upgrading"
3
+ nav_group: Project
4
+ ---
5
+
6
+ # Upgrading
7
+
8
+ [Documentation home](../index.md)
9
+
10
+ ### Migrating from v0.1.x
11
+
12
+ The resolver-callable contract is a **breaking change**: any custom
13
+ `Config.scope_resolver` lambda must now return `[scope, parent_scope]` (a
14
+ 2-element Array) instead of a bare scalar. A scalar return raises
15
+ `ArgumentError` at the next ambient query so the failure is loud, not silent.
16
+ If you don't use parent_scope, return `[scope, nil]`.
17
+
18
+ Run `bin/rails typed_eav:install:migrations` to copy the new
19
+ `AddParentScopeToTypedEavPartitions` migration into your app, then
20
+ `bin/rails db:migrate`. The migration is safe on production: it adds a
21
+ nullable `parent_scope` column (catalog-only, instantaneous) and uses
22
+ `CREATE INDEX CONCURRENTLY` for all index changes, so existing rows aren't
23
+ rewritten. Existing fields end up with `parent_scope = NULL` (the
24
+ global-parent shape) and continue to work for every single-scope caller.
25
+
26
+ See the [CHANGELOG](https://github.com/dchuk/typed_eav/blob/main/CHANGELOG.md) for the full upgrade checklist.
@@ -0,0 +1,259 @@
1
+ ---
2
+ title: "Reading, writing, and forms"
3
+ ---
4
+
5
+ # Reading, writing, and forms
6
+
7
+ [Documentation home](../index.md)
8
+
9
+ ## Model setup
10
+
11
+ ```ruby
12
+ class Contact < ApplicationRecord
13
+ has_typed_eav
14
+ end
15
+
16
+ # With multi-tenant scoping:
17
+ class Contact < ApplicationRecord
18
+ has_typed_eav scope_method: :tenant_id
19
+ end
20
+
21
+ # With restricted field types:
22
+ class Contact < ApplicationRecord
23
+ has_typed_eav types: [:text, :integer, :boolean, :select]
24
+ end
25
+ ```
26
+
27
+ ## Field definitions
28
+
29
+ ```ruby
30
+ # Simple fields
31
+ TypedEAV::Field::Text.create!(
32
+ name: "nickname",
33
+ entity_type: "Contact"
34
+ )
35
+
36
+ TypedEAV::Field::Integer.create!(
37
+ name: "age",
38
+ entity_type: "Contact",
39
+ required: true,
40
+ options: { min: 0, max: 150 }
41
+ )
42
+
43
+ TypedEAV::Field::Date.create!(
44
+ name: "birthday",
45
+ entity_type: "Contact",
46
+ options: { max_date: Date.today.to_s }
47
+ )
48
+
49
+ # Select field with options
50
+ status = TypedEAV::Field::Select.create!(
51
+ name: "status",
52
+ entity_type: "Contact",
53
+ required: true
54
+ )
55
+ status.field_options.create!([
56
+ { label: "Active", value: "active", sort_order: 1 },
57
+ { label: "Inactive", value: "inactive", sort_order: 2 },
58
+ { label: "Lead", value: "lead", sort_order: 3 },
59
+ ])
60
+
61
+ # Multi-select (stored as json array)
62
+ tags = TypedEAV::Field::MultiSelect.create!(
63
+ name: "tags",
64
+ entity_type: "Contact"
65
+ )
66
+ tags.field_options.create!([
67
+ { label: "VIP", value: "vip" },
68
+ { label: "Partner", value: "partner" },
69
+ { label: "Prospect", value: "prospect" },
70
+ ])
71
+ ```
72
+
73
+ When deriving `entity_type` from a model class, use
74
+ `Contact.polymorphic_name`. Rails stores polymorphic associations under that
75
+ canonical name, which is the base-class type for STI hosts and respects the
76
+ application's namespaced-polymorphism setting.
77
+
78
+ ## Reading and writing
79
+
80
+ ```ruby
81
+ contact = Contact.new(name: "Darrin")
82
+
83
+ # Individual assignment
84
+ contact.set_typed_eav_value("age", 40)
85
+ contact.set_typed_eav_value("status", "active")
86
+
87
+ # Bulk assignment by field NAME (ergonomic for scripting / seeds)
88
+ contact.typed_eav_attributes = [
89
+ { name: "age", value: 40 },
90
+ { name: "status", value: "active" },
91
+ { name: "tags", value: ["vip", "partner"] },
92
+ ]
93
+
94
+ # Bulk assignment by field ID (standard Rails form contract).
95
+ # Your form templates emit this shape when you use fields_for :typed_values.
96
+ contact.typed_values_attributes = [
97
+ { id: 12, field_id: 4, value: "40" },
98
+ { field_id: 7, value: "active" },
99
+ ]
100
+
101
+ contact.save!
102
+
103
+ # Reading
104
+ contact.typed_eav_value("age") # => 40 (Ruby Integer)
105
+ contact.typed_eav_value("status") # => "active"
106
+ contact.typed_eav_hash # => { "age" => 40, "status" => "active", ... }
107
+ ```
108
+
109
+ ## Defaults for new and existing records
110
+
111
+ ```ruby
112
+ field = TypedEAV::Field::Integer.create!(
113
+ name: "score", entity_type: Contact.polymorphic_name,
114
+ default_value: 10, options: { min: 0 }
115
+ )
116
+ contact = Contact.new(name: "Ada")
117
+ contact.initialize_typed_values
118
+ contact.typed_eav_value("score") # => 10
119
+ contact.save!
120
+
121
+ # Apply this field's default to an existing population:
122
+ field.backfill_default!(relation: Contact.where(active: true))
123
+ ```
124
+
125
+ Set the record's tenant/workspace before initializing values.
126
+ `initialize_typed_values` builds one unsaved Value for each effective definition
127
+ that has no row yet, using that field's default (possibly `nil`). It returns
128
+ the `typed_values` association, leaves existing values alone, and saves nothing
129
+ until the host is saved. The form helper uses this initialization behavior.
130
+ Creating a field or reading a missing value does not populate existing hosts.
131
+
132
+ When building a Value directly, omitting `value:` applies the field default;
133
+ passing `value: nil` explicitly stores NULL instead:
134
+
135
+ ```ruby
136
+ contact.typed_values.build(field: field) # default of 10
137
+ contact.typed_values.build(field: field, value: nil) # explicit NULL
138
+ # These illustrate alternatives; do not create both for the same host/field.
139
+ ```
140
+
141
+ Defaults are cast and validated when the field is saved. Backfill is synchronous
142
+ and idempotent: it creates missing rows or replaces fully NULL typed values,
143
+ while preserving existing non-NULL values, including `false` and zero. Multi-cell
144
+ fields count as present if any declared typed cell is non-NULL. A `nil` default
145
+ performs no writes.
146
+
147
+ Without `relation:`, backfill scans the field's host class. A supplied relation
148
+ must belong to that exact host class (an Array or a subclass relation raises
149
+ `ArgumentError`); it narrows the population before batches of 1,000. Partition
150
+ checks still apply. Each batch uses a transaction and normal Value validations,
151
+ callbacks, and enabled versioning. Failures propagate and roll back the current
152
+ batch; previous batches remain committed unless enclosed in a caller transaction.
153
+ The method has no documented count/result report. For background processing,
154
+ call it from an application-owned job. See [bulk operations](bulk-operations.md)
155
+ for related write guarantees.
156
+
157
+ ## Forms
158
+
159
+ Wire typed fields into Rails forms via nested attributes:
160
+
161
+ ```erb
162
+ <%= form_with model: @contact do |f| %>
163
+ <%= f.text_field :name %>
164
+
165
+ <%= render_typed_value_inputs(form: f, record: @contact) %>
166
+
167
+ <%= f.submit %>
168
+ <% end %>
169
+ ```
170
+
171
+ 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:
172
+
173
+ ```ruby
174
+ def contact_params
175
+ params.require(:contact).permit(
176
+ :name,
177
+ typed_values_attributes: [
178
+ :id, :field_id, :_destroy, :value, { value: [] }
179
+ ]
180
+ )
181
+ end
182
+ ```
183
+
184
+ For list pages, preload the field association to avoid N+1:
185
+
186
+ ```ruby
187
+ @contacts = Contact.includes(typed_values: :field).all
188
+ ```
189
+
190
+ ## Admin Scaffold
191
+
192
+ To manage field definitions through a UI, run the scaffold generator:
193
+
194
+ ```bash
195
+ bin/rails g typed_eav:scaffold
196
+ bin/rails db:migrate
197
+ ```
198
+
199
+ This copies a controller, views, helper, Stimulus controllers, and an initializer into your app, and adds routes mounted at `/typed_eav_fields`.
200
+
201
+ **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:
202
+
203
+ ```ruby
204
+ def authorize_typed_eav_admin!
205
+ return if current_user&.admin?
206
+ head :not_found
207
+ end
208
+ ```
209
+
210
+ Defining `authorize_typed_eav_admin!` in `ApplicationController` does **not** override it — the scaffold sets it on its own controller.
211
+
212
+ ## In-memory typed-value changes
213
+
214
+ ```ruby
215
+ contact.set_typed_eav_value("age", 41)
216
+ contact.typed_eav_changes # => {"age" => [40, 41]}
217
+ contact.save!
218
+ contact.typed_eav_changes # => {}
219
+ ```
220
+
221
+ `typed_eav_changes` reports logical `[before, after]` pairs for pending changes
222
+ on this host's in-memory `typed_values` target. It covers named setters,
223
+ `typed_eav_attributes=`, nested `typed_values_attributes=`, association builds,
224
+ edits to target Values, and `mark_for_destruction`/nested `_destroy`. Multi-cell
225
+ fields such as Currency retain their logical shape; returned hashes, pairs,
226
+ and mutable values are copies. Same-value assignments, reversions, and logical
227
+ `nil`-to-`nil` changes are omitted, including creating/removing a NULL value row.
228
+ Invalid input reports the cast logical result without discarding validation errors.
229
+
230
+ Failed saves retain pending state; successful saves and reload clear it. An
231
+ outer rollback follows Active Record's restored child dirty state. The API
232
+ resolves effective field names using this record's partition precedence, and
233
+ does not load all persisted Values merely to inspect an untouched host.
234
+
235
+ After saving, `saved_typed_eav_changes` exposes the most recent successful
236
+ host save's logical pairs:
237
+
238
+ ```ruby
239
+ contact.set_typed_eav_value("age", 42)
240
+ contact.save!
241
+ contact.saved_typed_eav_changes # => {"age" => [41, 42]}
242
+ contact.typed_eav_changes # => {}
243
+ ```
244
+
245
+ Saved changes are available in normal host `after_save` callbacks, including
246
+ values assigned by `before_save`. Each successful save replaces the snapshot;
247
+ a no-op save replaces it with `{}`. Failed validation or a save-callback
248
+ exception preserves the previous successful snapshot. Reload and an outer
249
+ transaction rollback clear saved changes. These are successful-save semantics,
250
+ not proof of a durable commit: use `after_commit` when external effects must
251
+ wait for commit. Exceptions after a transaction has already committed cannot
252
+ undo persisted data. Neither dirty API requires versioning or adds audit rows.
253
+
254
+ This is in-memory editing state, not audit history. Independently loaded/saved
255
+ Values, reassignment of an existing Value's field identity, SQL/`delete_all`,
256
+ and collection operations that immediately remove rows from the host target
257
+ are not tracked. Use nested destruction or `mark_for_destruction` for tracked
258
+ removal. Reduced `BulkUpsert` does not update unrelated in-memory host objects;
259
+ reload them after external writes. Bulk reads do not create dirty state.