typed_eav 0.7.1 → 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 +87 -2
- data/README.md +79 -1505
- 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/bulk_read.rb +143 -22
- data/lib/typed_eav/entity_query.rb +154 -2
- data/lib/typed_eav/has_typed_eav/dirty_tracking.rb +207 -0
- data/lib/typed_eav/has_typed_eav.rb +6 -2
- data/lib/typed_eav/scalar_query.rb +228 -0
- data/lib/typed_eav/schema_portability/preview.rb +379 -0
- data/lib/typed_eav/schema_portability.rb +20 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav.rb +1 -0
- metadata +38 -1
data/docs/index.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Documentation"
|
|
3
|
+
nav_group: Start here
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# TypedEAV documentation
|
|
7
|
+
|
|
8
|
+
Add runtime-defined custom fields to Active Record models using native PostgreSQL
|
|
9
|
+
columns for scalar values and JSONB for collections. Define fields globally or
|
|
10
|
+
per tenant, validate input, and query values through Active Record relations.
|
|
11
|
+
|
|
12
|
+
## Start here
|
|
13
|
+
|
|
14
|
+
1. [Install and try TypedEAV](getting-started.md).
|
|
15
|
+
2. [Define fields, assign values, and build forms](guides/usage.md).
|
|
16
|
+
3. [Configure tenant scoping](guides/scoping.md) if your application has tenants.
|
|
17
|
+
4. [Filter, sort, and summarize values](guides/queries.md).
|
|
18
|
+
|
|
19
|
+
## Guides
|
|
20
|
+
|
|
21
|
+
| Task | Guide |
|
|
22
|
+
|---|---|
|
|
23
|
+
| Choose field types and create custom types | [Fields and validation](guides/fields.md) |
|
|
24
|
+
| Build forms, manage fields, and inspect pending changes | [Reading, writing, and forms](guides/usage.md) |
|
|
25
|
+
| Configure tenants and inspect effective definitions | [Scoping and partitions](guides/scoping.md) |
|
|
26
|
+
| Search and summarize custom values | [Queries](guides/queries.md) |
|
|
27
|
+
| Read and update many records | [Bulk operations](guides/bulk-operations.md) |
|
|
28
|
+
| Map CSV rows to custom fields | [CSV mapping](guides/csv-import.md) |
|
|
29
|
+
| Move field definitions between environments | [Schema portability](guides/schema.md) |
|
|
30
|
+
| React to changes and keep audit history | [Events and versioning](guides/events-and-versioning.md) |
|
|
31
|
+
| Evaluate indexes and performance tradeoffs | [Storage and performance](guides/performance.md) |
|
|
32
|
+
| Upgrade an existing installation | [Upgrading](guides/upgrading.md) |
|
|
33
|
+
|
|
34
|
+
## Reference and development
|
|
35
|
+
|
|
36
|
+
- [Public API and configuration](reference/index.md)
|
|
37
|
+
- [Architecture](guides/architecture.md) and [design decisions](adr/index.md)
|
|
38
|
+
- [Development and test isolation](guides/development.md)
|
|
39
|
+
- [Maintaining and publishing these docs](maintaining.md)
|
|
40
|
+
- [Changelog](changelog.md)
|
|
41
|
+
- [Source and issues](https://github.com/dchuk/typed_eav)
|
|
42
|
+
|
|
43
|
+
These pages describe the code on the default branch. Check the changelog and
|
|
44
|
+
release tag when using an older gem version.
|
data/docs/maintaining.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Maintainers"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Maintaining and publishing the documentation
|
|
6
|
+
|
|
7
|
+
## Update docs with the API
|
|
8
|
+
|
|
9
|
+
Review documentation after every repository change, including code, tests, dependencies,
|
|
10
|
+
configuration, tooling, CI, migrations, and releases. Update affected pages in
|
|
11
|
+
the same change; if reader-facing behavior is unchanged, explain why no docs
|
|
12
|
+
update was needed in the handoff. Record user-visible changes in the root
|
|
13
|
+
`CHANGELOG.md` under Unreleased. A public API change includes its documentation
|
|
14
|
+
in the same change. Keep the
|
|
15
|
+
README focused on installation and discovery; maintain detailed usage here.
|
|
16
|
+
|
|
17
|
+
For each public capability, document a practical example, accepted inputs and
|
|
18
|
+
defaults, return value, and relevant failure behavior. Link its API reference to
|
|
19
|
+
its guide. Check claims against implementation and specs, especially transaction,
|
|
20
|
+
NULL, tenant, and callback behavior. A method being mentioned is not sufficient
|
|
21
|
+
coverage if users cannot work out how to call it.
|
|
22
|
+
|
|
23
|
+
Add new pages to the [documentation home](index.md) or the
|
|
24
|
+
[reference index](reference/index.md). Keep Markdown links relative between
|
|
25
|
+
published pages; `jekyll-relative-links` converts them to site URLs. Link to
|
|
26
|
+
GitHub for files outside `docs/`. Give new pages YAML front matter with a title
|
|
27
|
+
so Jekyll renders them. The sidebar automatically lists pages by `nav_group`
|
|
28
|
+
(assigned by folder defaults in `_config.yml`, or overridden in front matter).
|
|
29
|
+
Use `nav_exclude: true` for supporting pages that should be reachable only through
|
|
30
|
+
a summary page. Individual ADRs use this setting; their user-facing summary
|
|
31
|
+
appears once under Project. The active page is highlighted; mobile navigation uses an expandable menu.
|
|
32
|
+
|
|
33
|
+
The [changelog](changelog.md) is generated at build time from the root
|
|
34
|
+
`CHANGELOG.md` by `_plugins/changelog.rb`. Edit that source file only and rebuild
|
|
35
|
+
to preview changes. The workflow also runs when that file changes. Use ordinary Markdown headings and fenced examples.
|
|
36
|
+
|
|
37
|
+
## Preview locally
|
|
38
|
+
|
|
39
|
+
The documentation dependencies are isolated from the gem's development bundle.
|
|
40
|
+
From the gem repository:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
cd docs
|
|
44
|
+
bundle install
|
|
45
|
+
bundle exec jekyll serve
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Open `http://localhost:4000/typed_eav/`. The default layout uses the Minima theme;
|
|
49
|
+
there is no separate frontend application to maintain.
|
|
50
|
+
|
|
51
|
+
To build and check links from the repository root:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll build --source docs --destination docs/_site
|
|
55
|
+
python3 script/check_docs_site.py docs/_site /typed_eav
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The checker verifies local page/asset targets and heading anchors in generated
|
|
59
|
+
HTML. It does not make network requests or certify the accuracy of examples.
|
|
60
|
+
Review examples against the relevant gem specs as part of each change.
|
|
61
|
+
|
|
62
|
+
## Publish on GitHub Pages
|
|
63
|
+
|
|
64
|
+
The repository includes `.github/workflows/docs.yml`. Pull requests build and
|
|
65
|
+
check the site; pushes to `main` and manual runs on `main` also deploy it.
|
|
66
|
+
|
|
67
|
+
1. Commit and push the documentation and workflow to the gem repository.
|
|
68
|
+
2. In the repository's **Settings → Pages**, set **Source** to **GitHub Actions**.
|
|
69
|
+
3. Run the **Documentation** workflow on `main`, or push a documentation change.
|
|
70
|
+
4. Check the deployment URL in the workflow's `github-pages` environment.
|
|
71
|
+
|
|
72
|
+
The configured project URL is `https://dchuk.github.io/typed_eav/`. It becomes
|
|
73
|
+
available after the first successful deployment. For a different repository or
|
|
74
|
+
custom domain, update `url` and `baseurl` in `_config.yml` and the link-check
|
|
75
|
+
prefix in the workflow.
|
|
76
|
+
|
|
77
|
+
The site includes guides, references, and ADRs. Internal goal plans and
|
|
78
|
+
`improvement-program.md` are excluded, as are dependency and build files. Build
|
|
79
|
+
output is ignored by Git; GitHub Actions uploads the generated site directly.
|
|
80
|
+
|
|
81
|
+
See GitHub's [custom Pages workflow documentation](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages)
|
|
82
|
+
for repository permissions and deployment settings.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Public API reference"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Public API reference
|
|
6
|
+
|
|
7
|
+
This is an index of application-facing entry points. Linked guides explain
|
|
8
|
+
examples, supported types, validation, and transaction boundaries. Internal
|
|
9
|
+
query builders, dispatchers, caches, and callback methods are not extension APIs.
|
|
10
|
+
|
|
11
|
+
## Host models and records
|
|
12
|
+
|
|
13
|
+
Declare `has_typed_eav(scope_method: nil, parent_scope_method: nil, types: nil,
|
|
14
|
+
versioned: false)` on an Active Record model. Scope methods name record accessors;
|
|
15
|
+
`parent_scope_method` requires `scope_method`. `types: nil` allows registered
|
|
16
|
+
types; an array restricts them. Versioning additionally requires the global
|
|
17
|
+
boot-time switch. Use the host's `polymorphic_name` for `entity_type`.
|
|
18
|
+
|
|
19
|
+
| Record method | Contract |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `typed_values` | Active Record association; normal saves validate and persist values with the host. |
|
|
22
|
+
| `typed_eav_definitions` | Visible definitions for this record's partition, including same-name candidates. |
|
|
23
|
+
| `typed_eav_scope`, `typed_eav_parent_scope` | Configured accessor values normalized to strings, or `nil`. |
|
|
24
|
+
| `initialize_typed_values` | Build missing effective fields with defaults in memory; returns `typed_values`. Save separately. |
|
|
25
|
+
| `typed_eav_value(name)` | Logical Ruby value, or `nil` when absent. |
|
|
26
|
+
| `set_typed_eav_value(name, value)` | Stage a value by effective field name; unknown names are ignored. Save separately. |
|
|
27
|
+
| `typed_eav_hash` | Hash of field names to logical values. |
|
|
28
|
+
| `typed_eav_attributes=` / `typed_eav=` | Named entries such as `[{name: "age", value: 30}]`; supports `_destroy`. |
|
|
29
|
+
| `typed_values_attributes=` | Rails nested attributes using `field_id`, optional Value `id`, `value`, and `_destroy`. |
|
|
30
|
+
| `typed_eav_changes` | Pending logical changes as `{name => [before, after]}`. |
|
|
31
|
+
| `saved_typed_eav_changes` | Logical changes from the latest successful host save; reload/rollback clears them. |
|
|
32
|
+
|
|
33
|
+
See [reading, writing, and forms](../guides/usage.md),
|
|
34
|
+
[defaults](../guides/usage.md#defaults-for-new-and-existing-records), and [scoping](../guides/scoping.md).
|
|
35
|
+
|
|
36
|
+
## Host queries
|
|
37
|
+
|
|
38
|
+
These methods are available on the host class and through Active Record relations.
|
|
39
|
+
The query methods below (including `typed_eav_definitions`) accept `scope:` and
|
|
40
|
+
`parent_scope:`. Omitted keywords use ambient resolution; explicit `nil` selects
|
|
41
|
+
the global axis. These select **field definitions**, not authorized host rows:
|
|
42
|
+
start with an appropriately filtered host relation.
|
|
43
|
+
|
|
44
|
+
| Method | Result and defaults |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `where_typed_eav(*filters, include_missing: false)` | Active Record relation; filters use `name`, `op` (default `:eq`), and `value`. |
|
|
47
|
+
| `with_field(name, operator_or_value = nil, value = nil, include_missing: false)` | Single-filter relation; two-argument non-Symbol value implies equality. |
|
|
48
|
+
| `order_typed_eav(name, direction: :asc, nulls: :last)` | Relation ordered by a supported scalar field, then host primary key. Replaces prior ordering. |
|
|
49
|
+
| `distinct_typed_eav_values(name, limit: 100)` | Array of distinct scalar values in database order. |
|
|
50
|
+
| `count_distinct_typed_eav_values(name)` | Integer distinct count, including an explicit NULL category. |
|
|
51
|
+
| `typed_eav_value_counts(name, limit: 100)` | Hash of scalar values to counts. |
|
|
52
|
+
| `aggregate_typed_eav(name, operation:)` | `:min`, `:max`, or `:sum` for Integer/Decimal/Percentage; typed number or `nil` for empty min/max. |
|
|
53
|
+
| `typed_eav_definitions` | Relation of visible field definitions; does not collapse names. |
|
|
54
|
+
|
|
55
|
+
Missing rows differ from explicit NULL rows. `include_missing: true` broadens
|
|
56
|
+
`:is_null` and is ignored by other operators. Scalar APIs reject unsupported
|
|
57
|
+
field families and all-partition ambiguity with `ArgumentError`. See
|
|
58
|
+
[queries](../guides/queries.md) for operators, NULL behavior, limits, and errors.
|
|
59
|
+
|
|
60
|
+
## Bulk operations
|
|
61
|
+
|
|
62
|
+
| Host class method | Result and defaults |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `typed_eav_hash_for(records, fields: nil, source: :database)` | `{record.id => {field_name => value}}`; optional selected field names; `source: :preloaded` reads loaded associations. |
|
|
65
|
+
| `bulk_set_typed_eav_values(records, values_by_field_name, version_grouping: :default, transaction: :all, chunk_size: nil)` | Normal host saves; returns `{successes: [...], errors_by_record: {record => errors_hash}}`. |
|
|
66
|
+
| `bulk_set_typed_eav_values_per_record(values_by_record, version_grouping: :default, transaction: :all, chunk_size: nil)` | Same result, with `{record => {field_name => value}}` input. |
|
|
67
|
+
| `bulk_upsert_typed_eav_values(records, values_by_field_name, acknowledge_reduced_semantics: false, transaction: :all, chunk_size: nil)` | Written row count; requires explicit acknowledgement, skips persistence callbacks and audit versions. |
|
|
68
|
+
|
|
69
|
+
Normal bulk writes collect validation failures per record; an unexpected exception
|
|
70
|
+
can roll back the active transaction. `transaction: :chunks` requires a positive
|
|
71
|
+
`chunk_size`; prior chunks remain committed on later failure. See
|
|
72
|
+
[bulk operations](../guides/bulk-operations.md) before choosing either write path.
|
|
73
|
+
|
|
74
|
+
## Field definitions and values
|
|
75
|
+
|
|
76
|
+
Use `TypedEAV::Field::<Type>.create!` and ordinary Active Record updates for field
|
|
77
|
+
metadata; `field_options` holds Select/MultiSelect choices. The
|
|
78
|
+
[fields guide](../guides/fields.md) covers every built-in type and its options.
|
|
79
|
+
|
|
80
|
+
| API | Purpose |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `field.default_value` / `default_value=` | Read/set the logical default. Explicit value assignment can override it with `nil`. |
|
|
83
|
+
| `field.backfill_default!(relation: nil)` | Synchronously apply a configured default to eligible existing hosts in batches; no count/result contract. |
|
|
84
|
+
| `field.destroy!` | Apply the field's `field_dependent` policy. |
|
|
85
|
+
| `field.destroy_with_values_in_batches!(batch_size: 1_000)` | Resumable deletion for persisted `field_dependent: :destroy` fields; requires no open transaction. |
|
|
86
|
+
| `field.move_higher`, `move_lower`, `move_to_top`, `move_to_bottom`, `insert_at(position)` | Reorder within the field's partition. |
|
|
87
|
+
| `field.field_type_name`, `display_name`, `array_field?`, `optionable?`, `allowed_option_values` | Metadata for rendering and field-management interfaces. |
|
|
88
|
+
| `field.cast(raw)` | Custom-type protocol returning `[cast_value, invalid]`; casting alone does not establish validity. |
|
|
89
|
+
| `value.value` / `value=` | Read/write the field's logical value, including multi-cell types. |
|
|
90
|
+
| `value.history` | Versions ordered newest first by timestamp and ID. |
|
|
91
|
+
| `value.revert_to(version)` | Save the version's **before** state and write a new version; rejects another Value's version, create versions, and destroyed source Values. |
|
|
92
|
+
|
|
93
|
+
See [default initialization and backfill](../guides/usage.md#defaults-for-new-and-existing-records),
|
|
94
|
+
[field deletion](../guides/events-and-versioning.md#public-callback-slots), and
|
|
95
|
+
[custom types](../guides/fields.md#custom-field-types) for their contracts. Full history after Value deletion requires querying
|
|
96
|
+
`TypedEAV::ValueVersion` by entity and field identity; see
|
|
97
|
+
[events and versioning](../guides/events-and-versioning.md).
|
|
98
|
+
|
|
99
|
+
## Schema and CSV
|
|
100
|
+
|
|
101
|
+
| API | Result |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `TypedEAV::SchemaPortability.export_schema(entity_type:, scope: nil, parent_scope: nil)` | String-keyed versioned schema Hash for an exact partition, including fields and sections; excludes values. |
|
|
104
|
+
| `TypedEAV::SchemaPortability.preview_schema(hash, on_conflict: :error)` | Read-only JSON-safe comparison and predicted actions; advisory, not a reservation or validation guarantee. |
|
|
105
|
+
| `TypedEAV::SchemaPortability.import_schema(hash, on_conflict: :error)` | Counts under `"created"`, `"updated"`, `"skipped"`, `"unchanged"`, plus `"errors"`; import runs in a transaction and failures can raise. |
|
|
106
|
+
| `TypedEAV::SchemaPortability.export_snapshot_schema(entity_type:, scope: nil, parent_scope: nil)` | Lean versioned field projection; not a full import payload or a value backup. |
|
|
107
|
+
| `TypedEAV::CSVMapper.row_to_attributes(row, mapping, fields_by_name: nil)` | Result with frozen `attributes`/`errors` Hashes and `success?`/`failure?`. Does not save records. |
|
|
108
|
+
|
|
109
|
+
`Field::Base.export_schema` and `.import_schema` remain compatible delegators.
|
|
110
|
+
Conflict policies are `:error`, `:skip`, and `:overwrite`; type swaps are refused.
|
|
111
|
+
CSV mapping uses uniformly String header keys or Integer index keys, with field
|
|
112
|
+
names as values. Typed mode casts via supplied definitions, reports cast errors,
|
|
113
|
+
and skips unknown fields. Normal model validation must still run.
|
|
114
|
+
See [schema portability](../guides/schema.md) and [CSV imports](../guides/csv-import.md).
|
|
115
|
+
|
|
116
|
+
## Partition helpers
|
|
117
|
+
|
|
118
|
+
`TypedEAV::Partition` expects explicitly resolved scope values; it does not consult
|
|
119
|
+
ambient scope. Visibility methods accept `scope: nil`, `parent_scope: nil`, and
|
|
120
|
+
`mode: :partition`. Use `mode: :all_partitions` only for an authorized admin bypass.
|
|
121
|
+
Invalid modes and orphan parent scopes raise `ArgumentError` in partition mode.
|
|
122
|
+
|
|
123
|
+
| Method | Result |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `visible_fields(entity_type: nil, ...)` | Field relation, including global, scope-only, and full-tuple candidates. |
|
|
126
|
+
| `effective_fields_by_name(entity_type:, ...)` | `{name => field}` with most-specific precedence; all-partitions mode instead returns `{name => [fields]}`. |
|
|
127
|
+
| `definitions_by_name(defs)` | Collapse supplied definitions by name, most-specific winning. Supply only the intended visible set. |
|
|
128
|
+
| `definitions_multimap_by_name(defs)` | Group supplied definitions as `{name => [fields]}` without collapsing. |
|
|
129
|
+
| `visible_sections(entity_type:, ...)` | Relation of sections visible to the tuple. |
|
|
130
|
+
| `find_visible_section!(id, entity_type:, ...)` | Visible Section, or `ActiveRecord::RecordNotFound`. |
|
|
131
|
+
|
|
132
|
+
These helpers constrain definition visibility; application authorization still
|
|
133
|
+
belongs to the caller. See [scoping](../guides/scoping.md).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Configuration reference"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Configuration reference
|
|
6
|
+
|
|
7
|
+
Configure the gem in a Rails initializer:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
TypedEAV.configure do |config|
|
|
11
|
+
config.scope_resolver = -> { [Current.account&.id, Current.workspace&.id] }
|
|
12
|
+
config.require_scope = true
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The example assumes those are the same partition identifiers returned by the
|
|
17
|
+
host's declared accessors. `TypedEAV.config` returns the configuration object;
|
|
18
|
+
`configure` also accepts a block.
|
|
19
|
+
|
|
20
|
+
| Setting | Default | Contract |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| `scope_resolver` | Auto-detect `ActsAsTenant` | Callable returning `nil` or exactly `[scope, parent_scope]`. Default returns `[ActsAsTenant.current_tenant, nil]` when available, otherwise `nil`. Bare scalars raise `ArgumentError`. |
|
|
23
|
+
| `require_scope` | `true` | Scoped host class queries raise `TypedEAV::ScopeRequired` when scope cannot be resolved. |
|
|
24
|
+
| `versioning` | `false` | Boot-time master switch for transactional audit callbacks; host must also declare `versioned: true`. |
|
|
25
|
+
| `actor_resolver` | `nil` | Optional callable supplying audit actor identity. Missing actor is allowed; applications can enforce stricter requirements in the resolver. |
|
|
26
|
+
| `on_value_change` | `nil` | `->(value, change_type, context) { ... }`; after commit, with `:create`, `:update`, or `:destroy`. |
|
|
27
|
+
| `on_field_change` | `nil` | `->(field, change_type) { ... }`; after commit, with `:create`, `:update`, `:destroy`, or `:rename`. |
|
|
28
|
+
| `on_image_attached` | `nil` | `->(value, blob) { ... }`; image attachment notification after commit, when Active Storage is available. |
|
|
29
|
+
|
|
30
|
+
Public callback errors are logged rather than propagated to the completed save.
|
|
31
|
+
Versioning is independent of the public callback slots. Configure the versioning
|
|
32
|
+
switch before the engine installs callbacks, not dynamically per request.
|
|
33
|
+
See [events and versioning](../guides/events-and-versioning.md) for ordering,
|
|
34
|
+
actor normalization, payloads, and audit transaction guarantees.
|
|
35
|
+
|
|
36
|
+
## Field type registration
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
TypedEAV.configure do |config|
|
|
40
|
+
config.register_field_type :phone, "MyApp::Fields::Phone"
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`config.field_types` maps type-name Symbols to class-name Strings;
|
|
45
|
+
`config.type_names` lists registered names; `config.field_class_for(:phone)`
|
|
46
|
+
constantizes a registered class and raises `ArgumentError` for an unknown type.
|
|
47
|
+
Use registration to extend built-ins and the host macro's `types:` option to
|
|
48
|
+
restrict a model. See [custom field types](../guides/fields.md).
|
|
49
|
+
|
|
50
|
+
## Block-scoped state
|
|
51
|
+
|
|
52
|
+
| API | Contract |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `TypedEAV.with_scope(value) { ... }` | Temporary ambient scope; accepts a scalar, `[scope, parent_scope]`, or `nil`. Nested blocks restore prior state. |
|
|
55
|
+
| `TypedEAV.current_scope` | Resolved normalized tuple, or `nil`; consults the block stack before the resolver. |
|
|
56
|
+
| `TypedEAV.unscoped { ... }` | Explicit all-partition query mode; differs from `scope: nil` (global definitions only). |
|
|
57
|
+
| `TypedEAV.unscoped?` | Whether that bypass is active. |
|
|
58
|
+
| `TypedEAV.with_context(**kwargs) { ... }` | Temporary merged context for value events and versions; nested blocks restore prior state. |
|
|
59
|
+
| `TypedEAV.current_context` | Frozen current context Hash, empty when unset. |
|
|
60
|
+
|
|
61
|
+
Scope resolution is for field definitions. Continue to filter and authorize host
|
|
62
|
+
records in the application. Scalar sorting and summaries reject all-partition
|
|
63
|
+
mode because same-name definitions are ambiguous. See
|
|
64
|
+
[scoping](../guides/scoping.md) and [queries](../guides/queries.md).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Reference"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Reference
|
|
6
|
+
|
|
7
|
+
Use these pages to locate a method, check a default, or find its detailed guide.
|
|
8
|
+
|
|
9
|
+
- [Public API](api.md): host records, queries, bulk operations, fields, schema/CSV, and partition helpers.
|
|
10
|
+
- [Configuration](configuration.md): initializer settings, type registration, scope blocks, and event context.
|
|
11
|
+
|
|
12
|
+
For an introduction, start with the [documentation home](../index.md). Reference
|
|
13
|
+
entries describe application-facing contracts rather than every Ruby method in
|
|
14
|
+
the implementation.
|
|
15
|
+
|
|
16
|
+
- [Design decisions](../adr/index.md): the rationale behind user-facing behavior and extension choices.
|
data/lib/typed_eav/bulk_read.rb
CHANGED
|
@@ -6,23 +6,42 @@ module TypedEAV
|
|
|
6
6
|
# `HasTypedEAV::InstanceMethods#typed_eav_hash`. N+1-free regardless of
|
|
7
7
|
# record count or field count.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
9
|
+
# `fields:` is an optional projection of field names. A nil selection keeps
|
|
10
|
+
# the all-fields behavior; a non-nil selection is normalized to unique
|
|
11
|
+
# strings, and only the selected winning definitions' values are loaded.
|
|
12
|
+
# Unknown names are ignored, as are names that are not defined for a
|
|
13
|
+
# particular record's partition tuple. An empty selection returns one empty
|
|
14
|
+
# inner hash per record without querying definitions or values.
|
|
15
|
+
#
|
|
16
|
+
# `source: :database` (the default) always reads a fresh Value graph. The
|
|
17
|
+
# explicit `source: :preloaded` mode projects only the already-loaded
|
|
18
|
+
# `typed_values` targets and their loaded `field` associations; it never
|
|
19
|
+
# falls back to an association query. Incomplete preload requirements raise
|
|
20
|
+
# `ArgumentError` before projection. The preloaded mode is therefore a
|
|
21
|
+
# snapshot of caller-owned in-memory records and can include unsaved Value
|
|
22
|
+
# assignments; it never saves or mutates those records.
|
|
23
|
+
#
|
|
24
|
+
# ## Pipeline
|
|
10
25
|
#
|
|
11
26
|
# 1. validate_records! — nil -> ArgumentError; single-class invariant
|
|
12
27
|
# 2. group_by_tuple — `[typed_eav_scope, typed_eav_parent_scope]`
|
|
13
28
|
# 3. winning_ids_by_tuple — one `Partition::DefinitionBatch` query, then
|
|
14
29
|
# extract the winning field ids per tuple
|
|
15
|
-
# 4.
|
|
30
|
+
# 4. load_values — fresh `:database` SELECT across ALL records,
|
|
31
|
+
# restricted to selected field IDs when
|
|
32
|
+
# `fields:` is used; or `:preloaded` association
|
|
33
|
+
# targets after a strict loaded-state check
|
|
16
34
|
# 5. build_result_hash — per-record inner hash; orphan-skip + winning-id
|
|
17
35
|
# precedence mirrored from the instance path.
|
|
18
36
|
#
|
|
19
37
|
# ## Query bound
|
|
20
38
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
39
|
+
# Database mode performs up to three SELECTs — one bulk Value query, one
|
|
40
|
+
# field-association query for those values, and one definition query for the
|
|
41
|
+
# union of requested partitions. The preloaded mode performs one fresh
|
|
42
|
+
# definition SELECT and reads Value/field association targets in memory;
|
|
43
|
+
# `fields: []` bypasses even that definition query. Both bounds are
|
|
44
|
+
# independent of record count and partition cardinality.
|
|
26
45
|
#
|
|
27
46
|
# ## Single-class invariant
|
|
28
47
|
#
|
|
@@ -31,34 +50,75 @@ module TypedEAV
|
|
|
31
50
|
# class. Mixed, unrelated input would still be invalid; STI subclasses pass
|
|
32
51
|
# via `records.all?(host_class)`.
|
|
33
52
|
class BulkRead
|
|
34
|
-
def initialize(host_class:, records:)
|
|
53
|
+
def initialize(host_class:, records:, fields: nil, source: :database)
|
|
35
54
|
@host_class = host_class
|
|
36
55
|
@records = records
|
|
56
|
+
@fields = fields
|
|
57
|
+
@source = source
|
|
37
58
|
end
|
|
38
59
|
|
|
39
60
|
def to_hash
|
|
61
|
+
validate_source!
|
|
62
|
+
|
|
40
63
|
records = coerce_records
|
|
41
64
|
return {} if records.empty?
|
|
42
65
|
|
|
43
66
|
validate_record_classes!(records)
|
|
44
67
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
values_by_record_id = preload_values(records)
|
|
68
|
+
selected_names = normalize_fields
|
|
69
|
+
return empty_results(records) if selected_names == []
|
|
48
70
|
|
|
49
|
-
|
|
71
|
+
tuples_by_record = group_by_tuple(records)
|
|
72
|
+
winning_ids_by_tuple = winning_ids_by_tuple(tuples_by_record.values.uniq, selected_names)
|
|
73
|
+
values_by_record_id = if @source == :preloaded
|
|
74
|
+
preloaded_values(records, tuples_by_record, winning_ids_by_tuple, selected_names)
|
|
75
|
+
else
|
|
76
|
+
preload_values(records, winning_ids_by_tuple, selected_names)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id, selected_names)
|
|
50
80
|
end
|
|
51
81
|
|
|
52
82
|
private
|
|
53
83
|
|
|
54
84
|
attr_reader :host_class
|
|
55
85
|
|
|
86
|
+
def validate_source!
|
|
87
|
+
return if %i[database preloaded].include?(@source)
|
|
88
|
+
|
|
89
|
+
raise ArgumentError, "typed_eav_hash_for source must be :database or :preloaded"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_fields
|
|
93
|
+
return nil if @fields.nil?
|
|
94
|
+
|
|
95
|
+
names = if @fields.is_a?(String) || @fields.is_a?(Symbol)
|
|
96
|
+
[@fields]
|
|
97
|
+
elsif @fields.respond_to?(:to_a)
|
|
98
|
+
@fields.to_a
|
|
99
|
+
else
|
|
100
|
+
raise ArgumentError, "typed_eav_hash_for fields must be an Enumerable of String/Symbol names"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
names.map do |name|
|
|
104
|
+
next name if name.is_a?(String)
|
|
105
|
+
next name.to_s if name.is_a?(Symbol)
|
|
106
|
+
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"typed_eav_hash_for fields must contain only String or Symbol names; got #{name.inspect}"
|
|
109
|
+
end.uniq
|
|
110
|
+
end
|
|
111
|
+
|
|
56
112
|
def coerce_records
|
|
57
113
|
raise ArgumentError, "typed_eav_hash_for requires an Enumerable of records, got nil" if @records.nil?
|
|
58
114
|
|
|
59
115
|
@records.to_a
|
|
60
116
|
end
|
|
61
117
|
|
|
118
|
+
def empty_results(records)
|
|
119
|
+
records.to_h { |record| [record.id, {}] }
|
|
120
|
+
end
|
|
121
|
+
|
|
62
122
|
def validate_record_classes!(records)
|
|
63
123
|
return if records.all?(host_class)
|
|
64
124
|
|
|
@@ -73,26 +133,82 @@ module TypedEAV
|
|
|
73
133
|
records.index_with { |r| [r.typed_eav_scope, r.typed_eav_parent_scope] }
|
|
74
134
|
end
|
|
75
135
|
|
|
76
|
-
def winning_ids_by_tuple(tuples)
|
|
136
|
+
def winning_ids_by_tuple(tuples, selected_names)
|
|
77
137
|
TypedEAV::Partition::DefinitionBatch
|
|
78
138
|
.resolve(entity_type: host_class.polymorphic_name, tuples: tuples)
|
|
79
|
-
.transform_values
|
|
139
|
+
.transform_values do |fields_by_name|
|
|
140
|
+
fields_by_name.each_with_object({}) do |(name, field), winners|
|
|
141
|
+
next if selected_names&.exclude?(name)
|
|
142
|
+
|
|
143
|
+
winners[name] = field.id
|
|
144
|
+
end
|
|
145
|
+
end
|
|
80
146
|
end
|
|
81
147
|
|
|
82
|
-
def preload_values(records)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
148
|
+
def preload_values(records, winning_ids_by_tuple, selected_names)
|
|
149
|
+
relation = TypedEAV::Value
|
|
150
|
+
.includes(:field)
|
|
151
|
+
.where(entity_type: host_class.polymorphic_name, entity_id: records.map(&:id))
|
|
152
|
+
|
|
153
|
+
if selected_names
|
|
154
|
+
field_ids = winning_ids_by_tuple.values.flat_map(&:values).uniq
|
|
155
|
+
return {} if field_ids.empty?
|
|
156
|
+
|
|
157
|
+
relation = relation.where(field_id: field_ids)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
rows = relation.to_a
|
|
87
161
|
rows.group_by(&:entity_id)
|
|
88
162
|
end
|
|
89
163
|
|
|
90
|
-
|
|
164
|
+
# Reuse a caller's fully-preloaded association graph without allowing an
|
|
165
|
+
# accidental association reader to issue an N+1 query. `target` is used
|
|
166
|
+
# deliberately: unlike `record.typed_values`, it never loads an unloaded
|
|
167
|
+
# association. For a selected projection, filter each target by the
|
|
168
|
+
# winning field IDs before checking field associations; unselected Values
|
|
169
|
+
# therefore need not have their `field` association loaded. The all-fields
|
|
170
|
+
# path retains the stricter check over every Value, including orphan rows
|
|
171
|
+
# whose loaded field target is nil.
|
|
172
|
+
def preloaded_values(records, tuples_by_record, winning_ids_by_tuple, selected_names)
|
|
173
|
+
unloaded_records = records.reject { |record| record.association(:typed_values).loaded? }
|
|
174
|
+
if unloaded_records.any?
|
|
175
|
+
raise ArgumentError,
|
|
176
|
+
"typed_eav_hash_for source: :preloaded requires the typed_values association " \
|
|
177
|
+
"to be preloaded for every record"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
records.to_h do |record|
|
|
181
|
+
values = record.association(:typed_values).target
|
|
182
|
+
if selected_names
|
|
183
|
+
tuple = tuples_by_record.fetch(record)
|
|
184
|
+
selected_ids = winning_ids_by_tuple.fetch(tuple, {}).values
|
|
185
|
+
values = values.select { |value| selected_ids.include?(effective_field_id(value)) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
unloaded_values = values.reject { |value| value.association(:field).loaded? }
|
|
189
|
+
if unloaded_values.any?
|
|
190
|
+
raise ArgumentError,
|
|
191
|
+
"typed_eav_hash_for source: :preloaded requires the field association " \
|
|
192
|
+
"to be preloaded for every typed value"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
[record.id, values]
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def effective_field_id(value)
|
|
200
|
+
return value.field_id if value.field_id
|
|
201
|
+
|
|
202
|
+
association = value.association(:field)
|
|
203
|
+
association.loaded? ? association.target&.id : nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id, selected_names)
|
|
91
207
|
records.each_with_object({}) do |record, result|
|
|
92
208
|
tuple_key = tuples_by_record[record]
|
|
93
209
|
winning_ids_by_name = winning_ids_by_tuple.fetch(tuple_key, {})
|
|
94
210
|
rows = values_by_record_id.fetch(record.id, [])
|
|
95
|
-
result[record.id] = inner_hash_for(rows, winning_ids_by_name)
|
|
211
|
+
result[record.id] = inner_hash_for(rows, winning_ids_by_name, selected_names)
|
|
96
212
|
end
|
|
97
213
|
end
|
|
98
214
|
|
|
@@ -103,12 +219,17 @@ module TypedEAV
|
|
|
103
219
|
# for the name, only its row may surface (scoped-beats-global collision
|
|
104
220
|
# precedence). When no winner is registered (definition deleted while
|
|
105
221
|
# values remain), fall back to first-wins so the hash isn't lossy.
|
|
106
|
-
def inner_hash_for(value_rows, winning_ids_by_name)
|
|
222
|
+
def inner_hash_for(value_rows, winning_ids_by_name, selected_names)
|
|
107
223
|
value_rows.each_with_object({}) do |tv, inner|
|
|
108
224
|
next unless tv.field
|
|
109
225
|
|
|
110
226
|
name = tv.field.name
|
|
111
227
|
winning_id = winning_ids_by_name[name]
|
|
228
|
+
# A selected field ID may be visible for another record's partition
|
|
229
|
+
# tuple because the value preload spans all requested records. In a
|
|
230
|
+
# projection, a name without a winner for this tuple is absent rather
|
|
231
|
+
# than eligible for the all-fields stale-row fallback below.
|
|
232
|
+
next if selected_names && !winning_id
|
|
112
233
|
next assign_with_precedence(inner, name, tv, winning_id) if winning_id
|
|
113
234
|
|
|
114
235
|
inner[name] = tv.value unless inner.key?(name)
|