typed_eav 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -2
- data/README.md +79 -1710
- data/RELEASING.md +81 -0
- data/docs/adr/0001-collapse-column-mapping-stack.md +28 -0
- data/docs/adr/0002-entity-query-orchestration.md +33 -0
- data/docs/adr/0003-keep-event-dispatcher-broker.md +41 -0
- data/docs/adr/0004-field-family-intermediate-bases.md +49 -0
- data/docs/adr/0005-keep-phase-six-modules-independent.md +48 -0
- data/docs/adr/0006-include-missing-via-set-complement.md +77 -0
- data/docs/adr/0007-visibility-versus-mutation-relations.md +33 -0
- data/docs/adr/0008-partial-covering-scalar-indexes.md +83 -0
- data/docs/adr/0009-string-search-indexing.md +110 -0
- data/docs/adr/0010-planner-statistics-policy.md +109 -0
- data/docs/adr/0011-multi-filter-query-strategy.md +111 -0
- data/docs/adr/0012-cross-scope-administrative-query-policy.md +76 -0
- data/docs/adr/0013-durable-versioning-and-field-deletion.md +125 -0
- data/docs/adr/index.md +101 -0
- data/docs/getting-started.md +79 -0
- data/docs/guides/architecture.md +125 -0
- data/docs/guides/bulk-operations.md +205 -0
- data/docs/guides/csv-import.md +88 -0
- data/docs/guides/development.md +73 -0
- data/docs/guides/events-and-versioning.md +360 -0
- data/docs/guides/fields.md +342 -0
- data/docs/guides/performance.md +107 -0
- data/docs/guides/queries.md +188 -0
- data/docs/guides/schema.md +134 -0
- data/docs/guides/scoping.md +254 -0
- data/docs/guides/upgrading.md +26 -0
- data/docs/guides/usage.md +259 -0
- data/docs/index.md +44 -0
- data/docs/maintaining.md +82 -0
- data/docs/reference/api.md +133 -0
- data/docs/reference/configuration.md +64 -0
- data/docs/reference/index.md +16 -0
- data/lib/typed_eav/version.rb +1 -1
- metadata +35 -1
data/RELEASING.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Releasing typed_eav
|
|
2
|
+
|
|
3
|
+
A release is complete only when the same stable version exists in all three
|
|
4
|
+
places:
|
|
5
|
+
|
|
6
|
+
1. `lib/typed_eav/version.rb` and `CHANGELOG.md`
|
|
7
|
+
2. RubyGems
|
|
8
|
+
3. a published GitHub Release for the exact `vVERSION` tag
|
|
9
|
+
|
|
10
|
+
The newest stable GitHub Release must also be marked **Latest**. A successful
|
|
11
|
+
gem push by itself is not a completed release.
|
|
12
|
+
|
|
13
|
+
## Prepare the release
|
|
14
|
+
|
|
15
|
+
1. Confirm `main` is clean and up to date.
|
|
16
|
+
2. Move the relevant notes from `[Unreleased]` into a dated
|
|
17
|
+
`## [VERSION] - YYYY-MM-DD` section in `CHANGELOG.md`.
|
|
18
|
+
3. Add the matching link definition at the bottom of `CHANGELOG.md`:
|
|
19
|
+
|
|
20
|
+
```markdown
|
|
21
|
+
[VERSION]: https://github.com/dchuk/typed_eav/releases/tag/vVERSION
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
4. Set `TypedEAV::VERSION` in `lib/typed_eav/version.rb`.
|
|
25
|
+
5. Run the full release verification locally:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
bundle exec rspec
|
|
29
|
+
bundle exec rubocop
|
|
30
|
+
gem build typed_eav.gemspec
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
6. Commit the release preparation using the repository commit convention.
|
|
34
|
+
|
|
35
|
+
## Publish
|
|
36
|
+
|
|
37
|
+
Create and push an annotated `vVERSION` tag that points at the release commit.
|
|
38
|
+
The `Release` GitHub Actions workflow then:
|
|
39
|
+
|
|
40
|
+
1. verifies the exact tagged source and compatibility matrix;
|
|
41
|
+
2. builds and tests one checksummed gem artifact;
|
|
42
|
+
3. publishes that artifact to RubyGems using trusted publishing; and
|
|
43
|
+
4. creates or verifies the stable GitHub Release and marks it **Latest**.
|
|
44
|
+
|
|
45
|
+
Do not manually publish the gem ahead of this workflow. Do not declare the
|
|
46
|
+
release complete until both the `Push verified gem to RubyGems` and
|
|
47
|
+
`Publish GitHub release` jobs succeed.
|
|
48
|
+
|
|
49
|
+
## Verify completion
|
|
50
|
+
|
|
51
|
+
Replace `VERSION` below, then verify all release surfaces:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
test "$(ruby -r ./lib/typed_eav/version -e 'print TypedEAV::VERSION')" = "VERSION"
|
|
55
|
+
test "$(curl -fsSL https://rubygems.org/api/v1/gems/typed_eav.json | jq -r .version)" = "VERSION"
|
|
56
|
+
gh release view "vVERSION" --json tagName,isDraft,isPrerelease,url
|
|
57
|
+
gh release list --limit 10
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The GitHub release must use tag `vVERSION`, with `isDraft: false` and
|
|
61
|
+
`isPrerelease: false`, and the release list must label it `Latest`.
|
|
62
|
+
|
|
63
|
+
## Recover a missing GitHub Release
|
|
64
|
+
|
|
65
|
+
If RubyGems publishing succeeds but the final GitHub job fails, rerun the
|
|
66
|
+
failed `Publish GitHub release` job. The job is idempotent: it creates a
|
|
67
|
+
missing release or verifies an existing stable release and restores its
|
|
68
|
+
**Latest** designation.
|
|
69
|
+
|
|
70
|
+
If automation is unavailable, use:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
gh release create "vVERSION" \
|
|
74
|
+
--verify-tag \
|
|
75
|
+
--title "typed_eav VERSION" \
|
|
76
|
+
--generate-notes \
|
|
77
|
+
--latest
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then repeat the completion checks above. Never create, move, or replace a tag
|
|
81
|
+
just to repair missing GitHub release metadata.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Collapse column-mapping stack; break `FieldStorageContract` extension API pre-1.0"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Collapse column-mapping stack; break `FieldStorageContract` extension API pre-1.0
|
|
6
|
+
|
|
7
|
+
**Status:** accepted
|
|
8
|
+
|
|
9
|
+
The Phase-5 storage stack had five participants for one concept ("where does a typed value live in the row?"): `ColumnMapping`, `FieldStorageContract`, `CurrencyStorageContract`, Field instance methods (`read_value` / `write_value` / `apply_default_to`), and the `storage_contract_class` macro. `FieldStorageContract` was a pass-through wrapper for 19 of 20 field types — one real adapter (Currency) on a seam designed for many. Currency carried double-declarations across the Field class and its dedicated contract subclass, where the same column list lived in three places.
|
|
10
|
+
|
|
11
|
+
We collapsed the stack into a single `Field::TypedStorage` concern that lives on `Field::Base`. The contract classes, the `storage_contract_class` macro, and `field.storage_contract` are removed. Multi-cell field types override three instance methods on their Field subclass (`read_value`, `write_value`, `apply_default`); snapshot/change-detection methods become concrete (derived from `value_columns`). Currency becomes a normal Field subclass with overrides — the same shape external custom multi-cell types now use.
|
|
12
|
+
|
|
13
|
+
## Considered alternatives
|
|
14
|
+
|
|
15
|
+
- **α (strict BC):** keep `FieldStorageContract` and `storage_contract_class` as the public extension surface; rewire internal callers to use Field methods directly; delete only `CurrencyStorageContract`. Achieves the internal cleanup without breaking external authors. Rejected because the wrapper class would survive as legacy plumbing with no internal justification, leaving the documented extension API and the actual internal pattern divergent forever.
|
|
16
|
+
- **β (deprecate with warnings):** same as α, plus deprecation warnings on `storage_contract_class` / `field.storage_contract`. Rejected because CONTEXT.md's "BC is binding" rule provides no on-ramp to removal — the deprecation would point at nothing and become permanent noise.
|
|
17
|
+
- **γ (chosen):** break BC. Remove `FieldStorageContract` entirely. Re-ship as 0.3.0.
|
|
18
|
+
|
|
19
|
+
## BC reconciliation
|
|
20
|
+
|
|
21
|
+
CONTEXT.md (`.vbw-planning/CONTEXT.md`) lists "Backwards compatibility is binding" as a key decision. That rule was scoped to the Phase 1–7 enhancement arc — every *phase* preserves current API surface. This refactor is not a phase; it's an out-of-band architectural cleanup happening pre-1.0 where SemVer allows breaking changes at the minor version. Future architectural refactors with the same character should follow the same pattern: an explicit ADR + a minor-version bump, not a silent break.
|
|
22
|
+
|
|
23
|
+
## Consequences
|
|
24
|
+
|
|
25
|
+
- External authors who subclass `FieldStorageContract` must migrate to override-on-Field (subclass `Field::Base`, override `value_columns` / `operator_column` / `read_value` / `write_value` / `apply_default`). The Currency source serves as the canonical example.
|
|
26
|
+
- The README's §"Multi-cell field types" is rewritten around the new pattern.
|
|
27
|
+
- Three spec files (`field_storage_contract_spec`, `column_mapping_spec`, `column_mapping_value_columns_spec`) consolidate into one `field/typed_storage_spec`.
|
|
28
|
+
- Override surface shrinks from 7 methods to 3. Snapshot shape becomes a versioning-coupled invariant locked in by `value_columns` rather than an extension point external authors could silently break.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Split `HasTypedEav` into mixin + `EntityQuery` + query objects"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Split `HasTypedEav` into mixin + `EntityQuery` + query objects
|
|
6
|
+
|
|
7
|
+
**Status:** accepted
|
|
8
|
+
|
|
9
|
+
`HasTypedEav` had grown to 881 lines holding three responsibilities (the macro, class-level queries, instance accessors) with two heavy methods (`where_typed_eav`, `typed_eav_hash_for`) carrying blanket `Metrics/CyclomaticComplexity` rubocop disables. The file size was one friction; the methods' depth was another — and the file split alone wouldn't have addressed the second.
|
|
10
|
+
|
|
11
|
+
We split `HasTypedEav` structurally and extracted the heavy method bodies into focused query classes. The macro stays in `has_typed_eav.rb` (~150 lines). Per-record API lives in `has_typed_eav/instance_methods.rb`. Class-level orchestration lives in a new top-level `TypedEAV::EntityQuery` module. The `where_typed_eav` body extracts into `TypedEAV::FilterQuery`; `typed_eav_hash_for` extracts into `TypedEAV::BulkRead`. `bulk_set_typed_eav_values` already delegated to the existing `TypedEAV::BulkWrite`; no change. Field-collision helpers move from `HasTypedEav` module-statics to `TypedEAV::Partition` (where the partition-tuple precedence rule belongs).
|
|
12
|
+
|
|
13
|
+
## Two altitudes of query module
|
|
14
|
+
|
|
15
|
+
The project now has two layers of query module on purpose:
|
|
16
|
+
|
|
17
|
+
- **`QueryBuilder`** — low-level SQL primitives. Given `(field, operator, value)`, returns a relation or predicate. Knows nothing about scope, collision, or multiple filters.
|
|
18
|
+
- **`FilterQuery` / `BulkRead`** — high-level orchestration. Given filters + a resolved scope tuple + a model, calls down into `QueryBuilder` per filter and composes the result.
|
|
19
|
+
|
|
20
|
+
This split is intentional — keeping `QueryBuilder` narrow lets per-field SQL details (Arel predicates, ILIKE escaping, type casting) stay testable in isolation, while orchestration concerns (input normalization, scope resolution, multi-tuple collision) live one level up. Future query-shape additions should pick the matching altitude: per-field predicate work belongs in `QueryBuilder`; cross-filter or cross-tuple orchestration belongs in a new top-level query class.
|
|
21
|
+
|
|
22
|
+
## Considered alternatives
|
|
23
|
+
|
|
24
|
+
- **(a) Two-way relocation only**, no method extraction. Solves file-size friction; leaves the `Metrics/CyclomaticComplexity` disables in place. Rejected because the depth friction was the larger of the two.
|
|
25
|
+
- **(c) Depth-only**, no structural split. Extracts query classes but leaves the 881-line file behind (would shrink to ~500). Rejected because the shape friction was real on its own.
|
|
26
|
+
- **(d) Three-way split** with the macro in its own tiny module. Rejected as over-decomposition — the macro is naturally co-located with its module entry.
|
|
27
|
+
|
|
28
|
+
## Consequences
|
|
29
|
+
|
|
30
|
+
- Zero BC impact on public surfaces. `has_typed_eav` macro and all class/instance method signatures unchanged.
|
|
31
|
+
- `HasTypedEav.definitions_by_name` was technically reachable externally; treated as internal (not documented in README). Callers should use `Partition.definitions_by_name`.
|
|
32
|
+
- New unit-test surfaces (`filter_query_spec`, `bulk_read_spec`, `entity_query_spec`) can exercise query construction with stub models, decoupling tests from full AR setup where possible.
|
|
33
|
+
- The "extract heavy class methods into focused query classes" pattern is established as the project's stance for similar future refactors.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Keep the EventDispatcher broker; do not inline"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Keep the EventDispatcher broker; do not inline
|
|
6
|
+
|
|
7
|
+
**Status:** accepted
|
|
8
|
+
|
|
9
|
+
An architecture review surfaced `EventDispatcher` as a possible "one adapter = hypothetical seam" — the internal-subscriber list (`value_change_internals`) is intentionally generic, and the broker pattern looked over-built for a 1:1 relationship. On closer inspection the broker is correctly designed for its public/internal callback split, and inlining would cost more than it saved.
|
|
10
|
+
|
|
11
|
+
We're keeping `EventDispatcher` as-is. The internal-vs-user-proc split is load-bearing on three counts that don't survive an inline.
|
|
12
|
+
|
|
13
|
+
## Why the broker stays
|
|
14
|
+
|
|
15
|
+
The broker remains useful for the shipped split between generic internal
|
|
16
|
+
observers and public callbacks. The earlier Phase 7 materialized-view plan is
|
|
17
|
+
retired and is not a current subscriber, ordering promise, or implementation
|
|
18
|
+
milestone; future consumers must register and document their own contract.
|
|
19
|
+
|
|
20
|
+
**The error policy split is two different contracts, not stylistic.**
|
|
21
|
+
|
|
22
|
+
- Internal subscribers fail-closed: exceptions PROPAGATE. Transactional
|
|
23
|
+
versioning is outside this broker; its failures propagate inside and roll
|
|
24
|
+
back the source transaction.
|
|
25
|
+
- User procs fail-soft: `rescue StandardError`, log via `Rails.logger.error`, swallow. The Value/Field row is already committed when `after_commit` fires; re-raising would surface a misleading "save failed" error to the caller, when the save actually succeeded.
|
|
26
|
+
|
|
27
|
+
The broker is what enforces this split. Inlining would either duplicate the rescue logic across every subscriber site (bug surface) or collapse the contracts (silently demotes internal errors to logged-and-swallowed, breaking the fail-closed invariant).
|
|
28
|
+
|
|
29
|
+
**The user-proc seam is public API and stays.** `Config.on_value_change` / `Config.on_field_change` are documented in README. External callers register here. Any refactor would have to preserve them — at which point the question becomes "do you keep the broker for the user procs and inline only the internals?" That partial inline loses the shared registration and error-policy seam while making Value/Field know each subscriber.
|
|
30
|
+
|
|
31
|
+
## Considered alternatives
|
|
32
|
+
|
|
33
|
+
- **(c) Collapse only the value-change internals path.** Direct calls from `Value#after_commit` to subscribers. Rejected because the broker preserves a generic future-consumer seam and the public/internal error-policy split.
|
|
34
|
+
- **(d) Original "inline the broker" recommendation.** Rejected because the broker is the documented callback boundary, not because of an imminent materialized projection.
|
|
35
|
+
- **(b) Defer the question until a future consumer exists.** Rejected because the current seam already has a stable public contract and does not need a speculative redesign.
|
|
36
|
+
|
|
37
|
+
## Where the friction came from
|
|
38
|
+
|
|
39
|
+
The friction my original review identified was cognitive (tracing through Value → EventDispatcher → Subscriber → Registry takes four files) rather than architectural. Each file is doing one job well. The trace looks long because audit-trail plumbing genuinely involves four concerns (emit, route, write, gate per-entity opt-in); collapsing them would lose the seams, not the work.
|
|
40
|
+
|
|
41
|
+
Future contributors who hit the same "shouldn't this be inlined?" reaction should land here first.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Introduce family intermediate bases on `Field` (ValidatedString, RangeBounded, Optionable)"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Introduce family intermediate bases on `Field` (ValidatedString, RangeBounded, Optionable)
|
|
6
|
+
|
|
7
|
+
**Status:** accepted
|
|
8
|
+
|
|
9
|
+
The Field STI hierarchy was nearly flat — 20+ leaf subclasses all directly under `Field::Base` (the only exception being Percentage < Decimal). Three real duplication families had grown across the leaves: Text/Email/Url duplicated min/max_length + pattern validation (~30 lines × 3); Integer/Decimal/Date/DateTime duplicated min/max range patterns with inconsistencies (Integer/Decimal had a macro-level `validates :max, comparison:` check; Date/DateTime didn't); Select/MultiSelect duplicated `optionable?` + `allowed_values` + option-inclusion validators. Each family had three or more leaves — well above the "two adapters = real seam" threshold.
|
|
10
|
+
|
|
11
|
+
We introduced three intermediate types — one inheritance-vs-mixin choice per family — and documented them as public extension API. ValidatedString and RangeBounded are intermediate classes (`< Field::Base`); Optionable is a concern. Leaf subclasses move to the appropriate family. The protected validation helpers move from Field::Base down to the family that owns them (validate_length/validate_pattern → ValidatedString; validate_range/_date_range/_datetime_range → RangeBounded; validate_option_inclusion/validate_multi_option_inclusion → Optionable). `validate_array_size` stays on Field::Base because its callers (MultiSelect via Optionable and IntegerArray directly) don't share a family.
|
|
12
|
+
|
|
13
|
+
True stubs (Color, Boolean, Json) stay as direct children of Field::Base — no family fits them; the stub form is the correct shape when there's nothing to absorb.
|
|
14
|
+
|
|
15
|
+
## The inheritance-vs-concern rule
|
|
16
|
+
|
|
17
|
+
The choice per family was driven by storage shape:
|
|
18
|
+
|
|
19
|
+
- **Inheritance class when children share storage.** ValidatedString's leaves (Text, Email, Url) all use `string_value`. RangeBounded's leaves use different columns, but the family is identified by "has min/max bounds" — no storage declaration on the parent; each leaf still declares its own value_column.
|
|
20
|
+
- **Concern (mixin) when children don't share storage.** Optionable's leaves (Select, MultiSelect) use different columns and one is array-typed. Inheritance can't fix storage for both; a concern adds the shared behavior without claiming a parent slot.
|
|
21
|
+
|
|
22
|
+
This rule should govern future family extractions in the same codebase. Mixing the two patterns is intentional, not accidental.
|
|
23
|
+
|
|
24
|
+
## Grooming fixes folded in
|
|
25
|
+
|
|
26
|
+
Extracting the families surfaced inconsistencies that were latent bugs:
|
|
27
|
+
|
|
28
|
+
- ValidatedString's `max_gte_min_length` validator (previously only on Text) now covers Email and Url. Email configured with `max_length: 5, min_length: 10` now raises at field-save time instead of saving silently.
|
|
29
|
+
- RangeBounded's `validates :max, comparison: { greater_than_or_equal_to: :min }`-style macros now cover Date and DateTime (previously only Integer and Decimal). Inverted date bounds raise at field-save time.
|
|
30
|
+
|
|
31
|
+
CHANGELOG entry notes the behavior change for users who may have invalid configurations they never noticed.
|
|
32
|
+
|
|
33
|
+
## Public extension API
|
|
34
|
+
|
|
35
|
+
ValidatedString, RangeBounded, and Optionable are documented in README §"Custom field types" as recommended extension bases. External authors building (e.g.) a Phone or Slug field type subclass ValidatedString instead of duplicating min/max_length plumbing.
|
|
36
|
+
|
|
37
|
+
## Considered alternatives
|
|
38
|
+
|
|
39
|
+
- **(a) Status quo + ADR.** Rejected because three subclasses per family is real duplication, and the inconsistencies (max_gte_min_length only on Text; comparison macro only on Integer/Decimal) prove the duplication was already drifting.
|
|
40
|
+
- **(d) Registry-driven declarations for true stubs.** Rejected because it breaks STI for affected types — Field would need two parallel type-identification schemes (class names vs registry keys). Cost outweighs the gain of removing three small files.
|
|
41
|
+
- **(a3) All families as concerns** (no intermediate classes). Rejected because it abandons the existing Percentage < Decimal precedent and forces an unnatural "include for these but inherit for those" rule with no clear principle.
|
|
42
|
+
- **(b2) Internal-only family bases.** Rejected because external authors face the same duplication; keeping the bases gem-private solves the gem maintainer's problem and leaves external authors with the original friction.
|
|
43
|
+
|
|
44
|
+
## Consequences
|
|
45
|
+
|
|
46
|
+
- Test surface shrinks: per-leaf specs cover only leaf-specific behavior. Shared family behavior tested once on the family's spec.
|
|
47
|
+
- Future custom-field-type authors get the family's validation surface for free by picking the right parent.
|
|
48
|
+
- The "inheritance when children share storage; concern otherwise" rule is now an established pattern. Future families (e.g., if "array-typed" grows beyond MultiSelect + IntegerArray + others) should apply the same test.
|
|
49
|
+
- One outlier remains: `validate_array_size` lives on Field::Base because its callers span unrelated families. This is acknowledged technical debt; if a third array-family caller emerges, extract to an ArraySupport concern.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Keep Phase-6 modules independent; do not introduce an import-pipeline orchestrator"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Keep Phase-6 modules independent; do not introduce an import-pipeline orchestrator
|
|
6
|
+
|
|
7
|
+
**Status:** accepted
|
|
8
|
+
|
|
9
|
+
An architecture review surfaced `BulkWrite`, `CSVMapper`, and `SchemaPortability` as a possible missing-orchestrator situation: three Phase-6 modules that *could* form a 4-step import pipeline (export schema → import schema → parse CSV → bulk-write) but don't share an interface, return three different result shapes, and force users assembling the full flow to write per-module glue. On closer inspection the three are correctly scoped as independent toolbox utilities, not a fragmented pipeline.
|
|
10
|
+
|
|
11
|
+
We're keeping them as-is. Each module owns a focused responsibility and is independently useful:
|
|
12
|
+
|
|
13
|
+
- `SchemaPortability` moves Field+Section definitions between environments. Used standalone for environment-to-environment schema sync — no CSV or bulk-write involved.
|
|
14
|
+
- `CSVMapper.row_to_attributes` is a pure stateless transform with no record context. Used standalone for preview UIs, custom validation flows, anything that needs "header → field name" mapping without committing to a write.
|
|
15
|
+
- `BulkWrite.execute` runs batch typed-value writes with savepoint isolation and version-group stamping. Used standalone for bulk imports from non-CSV sources (JSON APIs, admin forms, migrations).
|
|
16
|
+
|
|
17
|
+
The "import pipeline" is a usage pattern when users compose all three — not an architectural concept the gem needs to name.
|
|
18
|
+
|
|
19
|
+
## Why the asymmetric return shapes are correct
|
|
20
|
+
|
|
21
|
+
The three return shapes match each module's natural axis:
|
|
22
|
+
|
|
23
|
+
- `BulkWrite` returns `{ successes: [...], errors_by_record: {...} }` because per-record error attribution is what a bulk-write caller needs.
|
|
24
|
+
- `SchemaPortability.import_schema` returns aggregate counts (`created`, `updated`, `skipped`, `unchanged`, `errors`) because schema import is a category-of-action operation, not per-row.
|
|
25
|
+
- `CSVMapper` returns a `Result` value object (`attributes`, `errors`, `success?`) because it transforms one row at a time.
|
|
26
|
+
|
|
27
|
+
Forcing a unified `Result` shape across all three would either lose the per-record axis (BulkWrite) or shoehorn aggregate counts into a `success?`/`failure?` boolean that doesn't fit (SchemaPortability). The shapes diverge because the use cases diverge.
|
|
28
|
+
|
|
29
|
+
## Why an orchestrator class would hurt
|
|
30
|
+
|
|
31
|
+
An `ImportPipeline` class taking a schema Hash + CSV source + host class would either:
|
|
32
|
+
- Hardcode a single pipeline shape (export-then-import-then-CSV-then-bulk), inflexible for users with custom flows, OR
|
|
33
|
+
- Become a fluent builder that duplicates each module's existing API in a wrapper, growing surface area without addressing the original modules.
|
|
34
|
+
|
|
35
|
+
Users assembling the full flow today write straightforward composition code. That's correct — the composition is theirs, not the gem's.
|
|
36
|
+
|
|
37
|
+
## Considered alternatives
|
|
38
|
+
|
|
39
|
+
- **(b) Unify result shapes only.** Rejected because each return shape's axis is correct for its module's domain.
|
|
40
|
+
- **(c) Add `ImportPipeline` orchestrator.** Rejected because it imposes a pipeline shape on users who compose modules differently.
|
|
41
|
+
- **(d) b + c.** Rejected for the union of both objections.
|
|
42
|
+
- **(e) Minor error-message standardization.** No concrete inconsistencies worth a separate change — each module's error vocabulary fits its domain.
|
|
43
|
+
|
|
44
|
+
## Where the friction came from
|
|
45
|
+
|
|
46
|
+
The original review identified cognitive friction (three modules with similar names; no obvious "pipeline" home) and proposed an orchestrator. The friction doesn't survive contact with the implementations: CSVMapper's docstring explicitly documents its scope as "a pure stateless transform with no record context," BulkWrite's docstring states its purpose as "internal executor for host-class bulk typed-value writes," and SchemaPortability's docstring states its purpose as "export and import field + section definitions for an exact partition tuple." Each module is doing one thing well and saying so out loud.
|
|
47
|
+
|
|
48
|
+
Future contributors who hit the same "shouldn't these compose under one orchestrator?" reaction should land here first.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Compose `include_missing:` via set-complement at the FilterQuery altitude"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Compose `include_missing:` via set-complement at the FilterQuery altitude
|
|
6
|
+
|
|
7
|
+
**Status:** accepted
|
|
8
|
+
|
|
9
|
+
`Entity.with_field("status", :is_null)` today matches only hosts that have a `typed_eav_values` row whose value-column is NULL. Hosts that have no row at all are invisible — a common usability gap when admins build "is empty" filters. The G3 ticket (#19) adds an opt-in `include_missing:` keyword that broadens the `:is_null` semantic to "no non-NULL value, including no-row hosts" (Reading A).
|
|
10
|
+
|
|
11
|
+
The PRD's first sketch proposed a LEFT JOIN inside `QueryBuilder` for the `:is_null` branch. We're rejecting that framing and composing the wider predicate at the `FilterQuery` altitude as a **set complement** instead. `QueryBuilder` is not modified.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
In `FilterQuery`:
|
|
16
|
+
|
|
17
|
+
- **Single-scope branch** when `include_missing: true && operator == :is_null`:
|
|
18
|
+
```ruby
|
|
19
|
+
non_missing_ids = QueryBuilder.entity_ids(field, :is_not_null, nil)
|
|
20
|
+
query.where.not(id: non_missing_ids)
|
|
21
|
+
```
|
|
22
|
+
- **Multimap (`ALL_SCOPES`) branch** when `include_missing: true && operator == :is_null`:
|
|
23
|
+
```ruby
|
|
24
|
+
non_missing_ids = fields.flat_map { |f|
|
|
25
|
+
QueryBuilder.entity_ids(f, :is_not_null, nil).pluck(:entity_id)
|
|
26
|
+
}.uniq
|
|
27
|
+
query.where.not(id: non_missing_ids)
|
|
28
|
+
```
|
|
29
|
+
- `:is_not_null` + `include_missing: true` → no-op. The natural complement already covers the intent.
|
|
30
|
+
- Any other operator + `include_missing: true` → silently ignored.
|
|
31
|
+
|
|
32
|
+
The wrapper methods `Entity.with_field` and `Entity.where_typed_eav` accept `include_missing: false` by default and thread it through to `FilterQuery#initialize`.
|
|
33
|
+
|
|
34
|
+
## Why set-complement, not LEFT JOIN
|
|
35
|
+
|
|
36
|
+
The LEFT JOIN framing would push the "no row" branch into `QueryBuilder`, which today is a per-field SQL primitive that knows nothing about multi-filter composition, partition collision, or the multimap-vs-single-scope split (ADR-0002). Adding a LEFT JOIN there:
|
|
37
|
+
|
|
38
|
+
- Forks the `:is_null` branch's return-type contract (the existing `filter` returns an `ActiveRecord::Relation` of `TypedEAV::Value` records suitable for `select(:entity_id)`; a LEFT JOIN against the host table breaks that shape).
|
|
39
|
+
- Introduces a host-table dependency at the per-field altitude, which is exactly the multi-filter composition concern `FilterQuery` was extracted to own.
|
|
40
|
+
- Doesn't generalise cleanly to the multimap branch — "no non-NULL value across any matching field def" is set-complement at the host level, not a per-field LEFT JOIN.
|
|
41
|
+
|
|
42
|
+
Set-complement at `FilterQuery` reuses the existing `:is_not_null` primitive verbatim. `QueryBuilder.entity_ids(field, :is_not_null, nil)` returns the hosts that DO have a non-NULL value; `where.not(id: ...)` is the host-level complement. The math is "all hosts minus hosts with a value," which is precisely Reading A.
|
|
43
|
+
|
|
44
|
+
## Reading A vs Reading B on the multimap branch
|
|
45
|
+
|
|
46
|
+
The multimap branch unions field definitions across tenants (e.g. `name` defined separately for `ws-1`, `ws-2`, `ws-3`). When a user asks for "is empty," two readings are possible:
|
|
47
|
+
|
|
48
|
+
- **Reading A — "no non-NULL value across ANY matching field def."** A host matches iff none of the per-tenant field defs have a non-NULL value for it. A host with a NULL row in ws-1 and a populated row in ws-2 does NOT match (it has a non-NULL value in ws-2).
|
|
49
|
+
- **Reading B — "no row for any field def."** A host matches iff it has zero rows across all the matching field defs. A host with a NULL row anywhere does not match.
|
|
50
|
+
|
|
51
|
+
We're pinning **Reading A**. Rationale:
|
|
52
|
+
|
|
53
|
+
1. Reading A is the single-scope semantic, generalised. Users who escalate from a single-scope query to an `unscoped { }` block don't expect the meaning of `:is_null` to flip on them.
|
|
54
|
+
2. Reading B is operationally indistinguishable from "do any rows exist," which is a different question with its own clear phrasing.
|
|
55
|
+
3. The set-complement implementation falls out naturally for Reading A — union the non-missing entity_ids across all matching field defs, then complement. Reading B would need a separate row-existence query that doesn't reuse `:is_not_null`.
|
|
56
|
+
|
|
57
|
+
The `FilterQuery` RDoc pins Reading A explicitly so future contributors don't second-guess it.
|
|
58
|
+
|
|
59
|
+
## Why `:is_not_null` is a no-op
|
|
60
|
+
|
|
61
|
+
`:is_not_null` already returns the natural complement of `:is_null`'s NULL-row-only semantic. Layering `include_missing: true` on top would either (a) flip the meaning of `:is_not_null` to "has a non-NULL value OR has no row" (incoherent — a no-row host has neither a value nor a NULL), or (b) silently leave it alone. We pick (b) so filter UIs can pass `include_missing: true` uniformly without branching per operator.
|
|
62
|
+
|
|
63
|
+
## Why other operators silently ignore
|
|
64
|
+
|
|
65
|
+
Same UI ergonomics. A filter UI that exposes "Include records with no value" as a checkbox should be able to pass `include_missing: true` regardless of the current operator selection. `:eq`, `:gt`, `:contains`, `:references`, `:between`, `:starts_with`, etc. all have their own well-defined semantics that don't compose with "or has no row" in a useful way. Silent-ignore is the least-surprise behavior for a filter UI.
|
|
66
|
+
|
|
67
|
+
## Considered alternatives
|
|
68
|
+
|
|
69
|
+
- **LEFT JOIN inside `QueryBuilder`.** Rejected — forks the return-type contract and pushes multi-filter composition concerns into the per-field primitive (see "Why set-complement, not LEFT JOIN").
|
|
70
|
+
- **A new operator symbol (`:is_empty`).** Rejected — proliferates the operator vocabulary and forces filter UIs to branch on operator selection. The opt-in kwarg keeps the operator surface stable.
|
|
71
|
+
- **Reading B on the multimap branch.** Rejected — see "Reading A vs Reading B."
|
|
72
|
+
- **Make `include_missing:` default `true`.** Rejected — would silently change the meaning of `:is_null` for existing callers. The kwarg is opt-in by design.
|
|
73
|
+
|
|
74
|
+
## References
|
|
75
|
+
|
|
76
|
+
- Issue #19 — G3 PRD.
|
|
77
|
+
- ADR-0002 — `EntityQuery` / `FilterQuery` / `QueryBuilder` altitude split.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "ADR 0007: Visibility versus mutation relations"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# ADR 0007: Visibility versus mutation relations
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
Accepted
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
Typed EAV keeps two relations for partitioned definitions:
|
|
14
|
+
|
|
15
|
+
- `for_entity` is a visibility relation. It includes the requested
|
|
16
|
+
`(entity_type, scope, parent_scope)` row plus the global and partial-scope
|
|
17
|
+
fallback rows used for collision precedence.
|
|
18
|
+
- `for_partition` is a mutation relation. It matches `entity_type`, `scope`,
|
|
19
|
+
and `parent_scope` exactly, including SQL `NULL` axes, and is used by
|
|
20
|
+
ordering mutations and their `FOR UPDATE` locks.
|
|
21
|
+
|
|
22
|
+
Field ordering queries `TypedEAV::Field::Base.for_partition` so STI field
|
|
23
|
+
subclasses remain one ordering partition. Section ordering queries the Section
|
|
24
|
+
base relation directly for the same reason of keeping the mutation boundary
|
|
25
|
+
independent from the receiver's relation scope.
|
|
26
|
+
|
|
27
|
+
## Consequences
|
|
28
|
+
|
|
29
|
+
Moving a definition cannot renumber a global fallback, a scope-only fallback,
|
|
30
|
+
or a different full `(scope, parent_scope)` tuple. Visibility lookup and its
|
|
31
|
+
collision precedence remain unchanged. Mutation locks still run in one
|
|
32
|
+
transaction, acquire every exact-partition row in deterministic `id` order,
|
|
33
|
+
and normalize only those rows.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "ADR 0008: Partial-covering scalar indexes"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# ADR 0008: Partial-covering scalar indexes
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
Accepted
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
|
|
13
|
+
Each `typed_eav_values` row has one applicable typed cell and normally leaves
|
|
14
|
+
the other typed cells NULL. The shipped scalar indexes are full B-trees on
|
|
15
|
+
`(field_id, typed_value)` with `INCLUDE (entity_id, entity_type)`, so every
|
|
16
|
+
scalar index maintains many rows that cannot satisfy a non-NULL typed query.
|
|
17
|
+
|
|
18
|
+
The representative scalar comparison selected a partial-covering layout as
|
|
19
|
+
the balanced default. The follow-up NULL comparison tested whether dropping
|
|
20
|
+
NULL entries required an automatic replacement index. It used 100,000 hosts,
|
|
21
|
+
six typed field families, 5,000 missing integer rows, and both 1% and 50%
|
|
22
|
+
explicit NULL among present integer rows. Three same-seed trials ran under
|
|
23
|
+
resource-capped co-tenancy; decisions use relative plans, buffers, storage,
|
|
24
|
+
and WAL rather than absolute latency.
|
|
25
|
+
|
|
26
|
+
## Decision
|
|
27
|
+
|
|
28
|
+
Replace the six scalar indexes with:
|
|
29
|
+
|
|
30
|
+
```sql
|
|
31
|
+
(field_id, typed_value) INCLUDE (entity_id)
|
|
32
|
+
WHERE typed_value IS NOT NULL
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The string index retains `text_pattern_ops`. The migration uses these stable
|
|
36
|
+
names:
|
|
37
|
+
|
|
38
|
+
- `idx_te_values_field_int_present`
|
|
39
|
+
- `idx_te_values_field_dec_present`
|
|
40
|
+
- `idx_te_values_field_date_present`
|
|
41
|
+
- `idx_te_values_field_dt_present`
|
|
42
|
+
- `idx_te_values_field_bool_present`
|
|
43
|
+
- `idx_te_values_field_str_present`
|
|
44
|
+
|
|
45
|
+
Do not ship automatic typed NULL indexes. Applications with a measured,
|
|
46
|
+
low-NULL workload dominated by explicit-NULL or `eq nil` probes may evaluate a
|
|
47
|
+
targeted concurrent index such as:
|
|
48
|
+
|
|
49
|
+
```sql
|
|
50
|
+
CREATE INDEX CONCURRENTLY app_te_values_integer_null
|
|
51
|
+
ON typed_eav_values (field_id, entity_id)
|
|
52
|
+
WHERE integer_value IS NULL;
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
That guidance is opt-in and requires application-specific `EXPLAIN (ANALYZE,
|
|
56
|
+
BUFFERS)` and write/storage measurements. Under current query semantics, the
|
|
57
|
+
predicate also indexes every logical value stored in another typed column.
|
|
58
|
+
|
|
59
|
+
## Evidence
|
|
60
|
+
|
|
61
|
+
The partial-covering scalar layout reduced total relation bytes 54.6%, all
|
|
62
|
+
index bytes 67.2%, insert WAL 61.3%, and improved insert/update throughput
|
|
63
|
+
76.9%/131.1% relative to the shipped covering layout while preserving the
|
|
64
|
+
core non-NULL equality/range plan shape.
|
|
65
|
+
|
|
66
|
+
In the NULL follow-up, an additional integer NULL index contained 500,947 of
|
|
67
|
+
595,000 rows at low logical NULL and 547,737 at high logical NULL. At low NULL,
|
|
68
|
+
it reduced explicit-NULL and `eq nil` median root-plan buffers 83.4%, but added
|
|
69
|
+
37.5% index bytes and 28.7% insert WAL. At high NULL, it increased those query
|
|
70
|
+
buffers 84.4%, index bytes 43.4%, and insert WAL 32.4%. It increased
|
|
71
|
+
NULL-inclusive `not_eq` buffers in both distributions and did not change
|
|
72
|
+
`is_not_null` or `include_missing` plans. The raw evidence is in
|
|
73
|
+
`bench/results/phase-2-scalar-representative.json` and
|
|
74
|
+
`bench/results/phase-2-null-distributions.json`.
|
|
75
|
+
|
|
76
|
+
## Migration consequences
|
|
77
|
+
|
|
78
|
+
The upgrade migration must be nontransactional and use concurrent index DDL.
|
|
79
|
+
It creates all six new indexes before dropping any legacy scalar index, so an
|
|
80
|
+
upgrade does not open a core-query coverage gap. The down path recreates every
|
|
81
|
+
legacy `(field_id, typed_value) INCLUDE (entity_id, entity_type)` index before
|
|
82
|
+
removing its partial-covering replacement. Shipped migrations remain
|
|
83
|
+
unchanged, rollback is explicit, and no NULL index is created or dropped.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "ADR 0009: Application-owned trigram indexes for measured string workloads"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# ADR 0009: Application-owned trigram indexes for measured string workloads
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
Accepted
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
|
|
13
|
+
TypedEAV's public string operators use three distinct SQL contracts: equality
|
|
14
|
+
uses `=`, positive pattern operators use `ILIKE`, and `not_contains` uses
|
|
15
|
+
`NOT ILIKE`. The shipped partial-covering `text_pattern_ops` B-tree preserves
|
|
16
|
+
efficient equality. It does not make every `ILIKE` shape efficient.
|
|
17
|
+
|
|
18
|
+
The Phase 3 comparison kept those predicates unchanged while adding either a
|
|
19
|
+
`lower(string_value) text_pattern_ops` B-tree or a partial `pg_trgm` GIN index.
|
|
20
|
+
The deterministic dataset contained 250,000 target-field rows and 250,000
|
|
21
|
+
noise-field rows. Three candidate-order rotations ran on PostgreSQL 17.11
|
|
22
|
+
under resource-capped co-tenancy. Disposable PostgreSQL 15.19, 16.15, and 18.6
|
|
23
|
+
lanes tested extension lifecycle only; they are not planner evidence.
|
|
24
|
+
|
|
25
|
+
## Decision
|
|
26
|
+
|
|
27
|
+
Keep the shipped partial-covering B-tree and public SQL unchanged. TypedEAV
|
|
28
|
+
does not require `pg_trgm`, install it, create a trigram index, or provide an
|
|
29
|
+
installer or generator for one.
|
|
30
|
+
|
|
31
|
+
Applications whose measured workload is dominated by positive `ILIKE`
|
|
32
|
+
patterns with extractable trigrams may evaluate an application-owned partial
|
|
33
|
+
GIN index:
|
|
34
|
+
|
|
35
|
+
```sql
|
|
36
|
+
CREATE INDEX CONCURRENTLY app_te_values_string_trgm
|
|
37
|
+
ON typed_eav_values USING gin (string_value gin_trgm_ops)
|
|
38
|
+
WHERE string_value IS NOT NULL;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The decision is operator- and workload-specific:
|
|
42
|
+
|
|
43
|
+
| Public query | Shipped B-tree | Observed use of additive GIN | Policy |
|
|
44
|
+
| --- | --- | --- | --- |
|
|
45
|
+
| `eq` (`=`) | Index-only in the representative plan | Not needed | Retain the B-tree |
|
|
46
|
+
| `starts_with` (`ILIKE 'value%'`) | Not reliably pattern-indexed by this B-tree for public `ILIKE` | Used for the measured extractable pattern | Evaluate only with application plans/selectivity |
|
|
47
|
+
| `contains` (`ILIKE '%value%'`) | May scan/filter B-tree entries or the relation | Used for the measured extractable pattern | Primary candidate for workload-specific evaluation |
|
|
48
|
+
| `ends_with` (`ILIKE '%value'`) | Not suffix-indexed | Used for the measured extractable pattern | Evaluate only with application plans/selectivity |
|
|
49
|
+
| Escaped `%` and `_` literals | Semantics remain escaped `ILIKE` | Used when the remaining literal supplied trigrams | Do not infer support for every escaped pattern |
|
|
50
|
+
| `not_contains` (`NOT ILIKE`) | No selective negative-search guarantee | Not used | Do not recommend GIN as acceleration |
|
|
51
|
+
| One- or two-character probes | No useful trigram extraction | Not used | Do not recommend GIN as acceleration |
|
|
52
|
+
|
|
53
|
+
The separate `lower(string_value) LIKE ...` prototype used its expression
|
|
54
|
+
B-tree, but that predicate is not the public `ILIKE` contract. It does not
|
|
55
|
+
justify changing public semantics or assuming collation equivalence. GiST was
|
|
56
|
+
smoke-only and has no representative justification, so it is not recommended.
|
|
57
|
+
|
|
58
|
+
## Application ownership and deployment
|
|
59
|
+
|
|
60
|
+
Before deployment, the application owner must confirm `pg_trgm` is available
|
|
61
|
+
and that the deploy role may create it in the target database:
|
|
62
|
+
|
|
63
|
+
```sql
|
|
64
|
+
SELECT name, default_version, installed_version
|
|
65
|
+
FROM pg_available_extensions
|
|
66
|
+
WHERE name = 'pg_trgm';
|
|
67
|
+
|
|
68
|
+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The benchmark's non-superuser database owner could manage the extension on
|
|
72
|
+
stock PostgreSQL 15, 16, and 18. Hosted services may impose different
|
|
73
|
+
privileges, so this is a preproduction check, not a portability promise.
|
|
74
|
+
|
|
75
|
+
The consuming application should own a nontransactional migration with a
|
|
76
|
+
stable application-specific index name. It must create and drop the index with
|
|
77
|
+
`CONCURRENTLY`, monitor invalid indexes after interruption, and verify the
|
|
78
|
+
catalog definition rather than accepting a same-named but different index.
|
|
79
|
+
Rollback drops only the application-owned index:
|
|
80
|
+
|
|
81
|
+
```sql
|
|
82
|
+
DROP INDEX CONCURRENTLY IF EXISTS app_te_values_string_trgm;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Do not drop `pg_trgm` during index rollback. Extensions are database-wide and
|
|
86
|
+
may be shared by unrelated application objects. Extension removal belongs in
|
|
87
|
+
a separate, explicitly owned operation only after catalog checks prove no
|
|
88
|
+
remaining dependants.
|
|
89
|
+
|
|
90
|
+
Before keeping the index, compare representative before/after plans with
|
|
91
|
+
`EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS)`, including real pattern lengths,
|
|
92
|
+
selectivity, field distribution, and concurrency. Also measure index and total
|
|
93
|
+
relation bytes, build and write WAL, insert/update throughput, and ongoing
|
|
94
|
+
maintenance. A per-field partial predicate may be worth testing for a known
|
|
95
|
+
hot field, but it was not the Phase 3 candidate and requires its own evidence.
|
|
96
|
+
|
|
97
|
+
## Consequences and evidence limits
|
|
98
|
+
|
|
99
|
+
The additive GIN candidate increased median candidate index bytes by 61.417%
|
|
100
|
+
and build WAL by 50.293% versus the current B-tree candidate. Median insert and
|
|
101
|
+
update throughput fell 56.995% and 58.060%, while their WAL rose 156.499% and
|
|
102
|
+
195.441%. These costs make automatic installation inappropriate.
|
|
103
|
+
|
|
104
|
+
The retained artifact contains the full plans, checksums, sizes, and build
|
|
105
|
+
measurements for trial 1 plus cross-trial metric arrays and rotation metadata.
|
|
106
|
+
It does not retain the raw trial 2/3 plans, checksums, sizes, or build times, so
|
|
107
|
+
those items are not independently auditable from the artifact. The result is
|
|
108
|
+
relative evidence under active co-tenants; absolute timings are diagnostic,
|
|
109
|
+
and PostgreSQL 17 plan choices must not be generalized to every version or
|
|
110
|
+
workload. Applications should rerun the comparison in their own environment.
|