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
|
@@ -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.
|