Conversation
The PostgreSQL writer drops the `_id` and `_lastUpdated` values of a
contained resource: `build_contained` answers `_id` from the
`contained_local_id` column every contained row carries, so the `_id` row
is a restatement with no reader (1.5% of the crud suite's index write).
But `build_contained` finds contained resources by grouping their rows, so
a contained resource that yields no other indexed value - a bare
`{"resourceType": "Location", "id": "x"}` - had no row at all and was
invisible to every `_contained` search: the no-criteria form, `_id` and
`:not` included. SQLite, MongoDB and Elasticsearch keep the `_id` value
and were not affected.
Keep the `_id` row exactly when the contained resource would otherwise
have no row (a presence row). Resources with any other indexed value
still save it, and the write path cannot fail on it.
Existing data: an id-only contained resource written before this change
has no row and stays invisible until its container is re-indexed.
Refs #1407
On SQLite, PostgreSQL and MongoDB a `_contained=true|both` search listed its contained matches by container type, id and local id whatever `_sort` asked for (and `both` appended them to the sorted top-level page) - a 200 in the wrong order. The contained path only groups the contained index rows; it never read `query.sort`. Refuse it with a 400 naming `_sort`, like every other criterion the contained path cannot apply (#1363). Elasticsearch already applies it, to the contained resource's own values across both halves of `both`, with the container's `lastUpdated` standing in for the contained resource's; that is now documented and pinned. New shared scenario `contained_suite::sort_and_id_only_contained` runs on all four backends: `_sort` is applied in the right order or refused by name, and an id-only contained resource is found (previous commit). Refs #1407
…IN wrapper `build_string_condition`, `build_token_condition`, `build_of_type_condition`, `build_reference_condition`, `build_reference_identifier_condition` and `build_uri_condition` each inlined, per value, both the bare `search_index` row predicate and the `id IN (SELECT resource_id FROM search_index WHERE ... AND <predicate>)` membership test around it. `build_contained` groups the contained rows itself and needs only the row predicate, so it could reuse none of them and refuses every modifier (#1407 items 1-2). Split each into a `*_value_predicate` function (one value, carrying the placeholder counter) plus the shared `index_membership` wrapper; the `build_*_condition` functions are now loops over the two. `:identifier` keeps its own aliased wrapper and shares the filter and the `value_reference IN (...)` predicate. No behaviour change: the emitted SQL and bind parameters are byte-identical. Checked with the existing unit tests (which pin the SQL, the placeholder numbering and `single_index_predicate` extraction) and with a throwaway differential dump of 4 types x 14 modifiers x 10 value sets x 2 offsets, solo and in a conjunction, before and after. Refs #1407
… on PostgreSQL PostgreSQL refused every modifier but `:not` under `_contained=true|both`, and matched contained reference and uri values by bare equality only, so `Observation?_contained=true&subject=pt1` (the bare-id form, the primary one in FHIR) returned nothing while `subject=Patient/pt1` matched. Both came from `build_contained` borrowing the modifier-less composite component predicates because the modifier-aware builders could not be used without their `id IN (...)` wrapper. With the value predicates split out (previous commit), `build_contained` now uses them bare: - string: `:exact`, `:contains`, `:text`, and the default starts-with now in its accent-folded, wildcard-escaped range form (it was a raw `value_string ILIKE 'v%'`, so `%` in a value was a wildcard); - token: `:text`, `:code-text`, `:of-type` (a malformed `:of-type` value fails closed instead of being dropped); - reference: bare id, `:Type`, absolute URL, and `:identifier`, whose target lookup excludes contained identifier rows (they are stored under the container and would make it a target); - uri: `:contains`, `:below`, `:above`. `contained_unsupported_reason` shrinks to the list SQLite uses. `:missing` stays a 400 (pinned by the REST and PostgreSQL suites), as do `:in`, `:not-in` and token `:above`/`:below`, which are not predicates on one index row. Non-contained SQL is unchanged (same differential dump as the refactor commit). SQLite: `reference:identifier` let a contained resource's identifier make its *container* a target, for top-level and contained searches alike; one conjunct (`si2.is_contained = 0`) in the reference handler. Found by the new shared scenario `contained_suite::reference_identifier_resolves_the_target`. Refs #1407
SQLite refused composites under `_contained=true|both`. The contained composite rows are there, each carrying the `composite_group` of its contained resource's extraction; only the read-side pairing was missing. `build_contained` now narrows the contained entities, like `_id` does, with `(resource_type, resource_id, contained_local_id) IN (SELECT ... GROUP BY resource_type, resource_id, contained_local_id, composite_group HAVING <every component>)`, reusing `CompositeHandler::build_component_fragments` exactly as the top-level `build_composite_parameter_condition` does. A composite with a modifier or without resolved components is still refused by name. The shared suite gains the discriminating case: a contained Observation with components A = 1 mg and B = 9 mg must match `component-code-value-quantity=A$lt5` and not `A$gt5`. PostgreSQL and MongoDB still refuse composites under `_contained`: PostgreSQL stores contained composites unfolded with no group key (needs a writer change and a reindex), MongoDB needs a grouped pair check over `search_index_contained`. Refs #1407
#1398's design note recorded this as blocked on the writer ("stores contained composites unfolded, one row per component, with no group key"). Half of that is wrong: `build_contained_rows` does bypass `fold_composites`, but `IndexRow::from_extracted` keeps the extractor's `composite_group` on every row, so contained composites are stored in exactly the one-row-per-component form `build_composite_condition_legacy` reads - on every database, whatever its top-level layout. No writer change, no schema change and no reindex are needed. `build_contained` now narrows the contained entities with the legacy pairing keyed on the contained entity: `(resource_type, resource_id, contained_local_id) IN (SELECT ... AND (<prefilter>) GROUP BY resource_type, resource_id, contained_local_id, composite_group HAVING bool_or(<component>) AND ...)`, every component on the base (slot 1) columns. A value with the wrong number of components or an unparseable one fails closed. A composite with a modifier or without resolved components is still refused by name. Verified on a real PostgreSQL by the shared suite, including the pairing case (`component-code-value-quantity=A$gt5` must not match components A = 1 mg, B = 9 mg). MongoDB still refuses composites under `_contained`. Refs #1407
`handlers/compartment.rs` built the query and handed it straight to the backend's `search()`. The type search handler resolves `_list`, `_has` and chained parameters into an `_id` filter first, because the backends do not read `query.list` / `query.reverse_chains`; the compartment handler never did, so `Patient/p1/Observation?_list=l1` returned the whole compartment (reproduced with the new REST test: both Observations instead of the listed one). It also never checked `supports_contained_search`, so `_contained` on a backend without contained indexing was not a 501. One helper, called by the single-type and the all-types handler before `search()`: the `_contained` capability gate (501), the `_contained` + `_list`/`_has`/chain refusal (400, same text as the type search, before any resolution so the ids are not misread as local ids), the functional `$current-*` list refusal (501), then `resolve_list` and `resolve_chains`. No terminology expander is passed, unlike the type search: a terminology-backed modifier on a chain terminal is refused by the resolver instead of expanded. Refs #1407
…s truncated The `_contained` result list on Elasticsearch is de-duplicated from one request's hits, so it - and `_total`, `search_count` and any page beyond it - is bounded by `max_result_window`. `hits.total` counts documents, not containers, so the total cannot be repaired from it. Log a warning when the window is reached instead of reporting a short total silently, and record why in the code. The bound itself is not lifted here. Refs #1407
clippy 1.98 (useless_format): the message has no arguments. Refs #1407
smunini
added a commit
that referenced
this pull request
Sep 21, 2026
…omic-versioned-writes #1419 and this PR each add a backend-agnostic suite (modifier_parity_suite there, versioned_write_race_suite here) and wire it into the same spots of tests/mongodb_tests.rs and tests/postgres_tests.rs, so whichever merges second conflicts. Resolved here, making the order #1419 then this PR; every test kept unchanged. The resulting tree is byte-identical to the corresponding step of a scratch merge of main with all four open PRs (#1419, #1421, #1422, #1423), which compiled under `cargo test --workspace --all-features --no-run` and passed the full SQLite, PostgreSQL, MongoDB, Elasticsearch and REST suites.
smunini
added a commit
that referenced
this pull request
Sep 21, 2026
…nto feat/1406-shared-conditional-patch #1419, #1421 and this PR each add a backend-agnostic suite and wire it into the same spots of tests/mongodb_tests.rs, tests/postgres_tests.rs and tests/sqlite_tests.rs, so whichever merges later conflicts. Resolved here, making the order #1419, #1421, then this PR; every test kept unchanged. The resulting tree is byte-identical to the corresponding step of a scratch merge of main with all four open PRs (#1419, #1421, #1422, #1423), which compiled under `cargo test --workspace --all-features --no-run` and passed the full SQLite, PostgreSQL, MongoDB, Elasticsearch and REST suites.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR does the remaining
_containedsearch work from #1407. That work is what #1398 left after fixing the correctness items of #1383. There are nine commits, one per item, in priority order.What changed
1. An ID-only contained resource is now indexed on PostgreSQL.
_idrows._containedsearch, including the no-criteria form.2.
_sortunder_containedis refused where it used to be ignored._sortasked for._contained=bothappended them to the sorted top-level page.200with results in the wrong order.400naming_sort, like every other criterion the contained path cannot apply._sort.lastUpdatedstands in for the contained resource's.3. The PostgreSQL value predicates are split from the
id IN (…)wrapper. This is a pure refactor.build_string_condition,build_token_condition,build_of_type_condition,build_reference_condition,build_reference_identifier_conditionandbuild_uri_condition.4. Modifiers, and the reference and uri forms, work under
_containedon PostgreSQL.:exact,:contains,:text,:of-type,:identifierand the structural:above/:below.:missingstays a400, because existing tests pin that.5. Composite parameters work under
_containedon SQLite.6. Composite parameters work under
_containedon PostgreSQL.IndexRow::from_extractedkeeps the extractor'scomposite_groupon every contained row.component-code-value-quantity=A$gt5.7. Compartment searches resolve
_list,_hasand chains.handlers/compartment.rsnever resolved these, with or without_contained.8. The Elasticsearch truncation of
_containedresults is documented.max_result_window._totalis a lower bound only.9. A style fix.
format!in the_sortrefusal is removed.Not done
_contained. The refusal is by name, with a400._contained=bothde-duplication is per page._totalcan over-count when a container of the searched type is itself a top-level match.I left
Fixesoff so that #1407 stays open for those two.Behaviour changes clients can observe
_containedwith_sorton SQLite, PostgreSQL and MongoDB was a200in the wrong order. It is now a400naming_sort._contained, the listed modifiers and the bare-ID and URL reference forms now work. They were a400._contained, composite parameters now work. They were a400._list,_hasand chained parameters.Verification
Run after merging current
main. The backend suites used real containers. Every new case is in the sharedcontained_suite.rs, so it runs on all four backends.cargo test -p helios-persistence --lib, all five backend featuressqlite_tests,search_suiteand three composite suitespostgres_tests(full)mongodb_tests(full)elasticsearch_tests(full)cargo test -p helios-restcargo +1.98.1 clippywith the CI flags:helios-persistencewith all four FHIR versions and all five backends,--all-targets;helios-restandhelios-hfs,--all-targetscargo fmt --checkNot run on this branch alone: the full
--workspace --all-featurescompile. That runs on the combined tree of the open PRs.Refs #1407