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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -2
  3. data/README.md +79 -1505
  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/bulk_read.rb +143 -22
  38. data/lib/typed_eav/entity_query.rb +154 -2
  39. data/lib/typed_eav/has_typed_eav/dirty_tracking.rb +207 -0
  40. data/lib/typed_eav/has_typed_eav.rb +6 -2
  41. data/lib/typed_eav/scalar_query.rb +228 -0
  42. data/lib/typed_eav/schema_portability/preview.rb +379 -0
  43. data/lib/typed_eav/schema_portability.rb +20 -0
  44. data/lib/typed_eav/version.rb +1 -1
  45. data/lib/typed_eav.rb +1 -0
  46. 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.
@@ -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.
@@ -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
- # ## Pipeline (one batched definition query + one bulk value preload)
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. preload_values — single SELECT across ALL records
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
- # - 1 SELECT typed_eav_values WHERE entity_type=? AND entity_id IN (?)
22
- # - 1 SELECT typed_eav_fields WHERE id IN (?) (via includes)
23
- # - 1 SELECT typed_eav_fields for the union of requested partitions
24
- #
25
- # Total: 3 queries — independent of record count and partition cardinality.
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
- tuples_by_record = group_by_tuple(records)
46
- winning_ids_by_tuple = winning_ids_by_tuple(tuples_by_record.values.uniq)
47
- values_by_record_id = preload_values(records)
68
+ selected_names = normalize_fields
69
+ return empty_results(records) if selected_names == []
48
70
 
49
- build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id)
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 { |fields_by_name| fields_by_name.transform_values(&:id) }
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
- rows = TypedEAV::Value
84
- .includes(:field)
85
- .where(entity_type: host_class.polymorphic_name, entity_id: records.map(&:id))
86
- .to_a
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
- def build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id)
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)