Skip to content

fix(search): match bare-id reference searches on Postgres - #495

Merged
smunini merged 1 commit into
mainfrom
fix/490-postgres-bare-id-reference-search
Aug 7, 2026
Merged

fix(search): match bare-id reference searches on Postgres#495
smunini merged 1 commit into
mainfrom
fix/490-postgres-bare-id-reference-search

Conversation

@aacruzgon

Copy link
Copy Markdown
Contributor

Summary

On the Postgres backend, reference search parameters matched only when the query value carried a Type/ prefix — a bare logical id matched nothing and returned an empty Bundle. Observation?patient=<id> is the primary form in the spec and the shape Inferno uses throughout, so essentially every clinical search on Postgres returned zero.

References are indexed as written (search/writer.rs stores Reference.reference verbatim), so the stored value is the literal Patient/<id>. The query compared the raw search value against it with equality only. SQLite, MongoDB and Elasticsearch all branch on whether the value carries a / and fall back to a suffix match; only Postgres did not.

Fixes #490.

Changes

postgres/search/query_builder.rs

  • build_reference_condition now branches on whether the version-stripped value contains /, mirroring the SQLite handler. A bare id matches exactly, as a /<id> suffix, or with a trailing /_history/<vid>; a Type/id or absolute URL keeps its previous exact-plus-versioned semantics.
  • Added the :Type modifier, which was not handled on Postgres at all — subject:Patient=<id> also returned zero. A bare id is now resolved to Type/id and matched as a type-prefixed reference.
  • The plain path binds fully-formed patterns built with the file's existing like_escape plus ESCAPE '\', rather than concatenating the raw value onto a pattern in SQL. This is load-bearing for the new suffix match: unescaped, patient=% would become LIKE '%/%' and match every stored reference. The :contains, :below and :above paths keep their existing behavior.
  • That varies the parameter count per value (two for a type-prefixed reference, three for a bare id), so the loop runs a counter instead of offset + i. build_search_query already advances the next parameter's offset by params.len(), and both production callers append fragment.params wholesale, so placeholder numbering for following parameters is unaffected.
  • Matching stays version-agnostic and the OR-list still compiles to a single sublink (the semi-join shape that reference_or_list_is_a_single_sublink guards).

Testing

  • cargo test -p helios-persistence --features postgres,sqlite,R4 --lib backends::postgres::search::query_builder — 14 passed, including 4 new: bare-id parameter binding, :Type normalization, version stripping, and LIKE-metacharacter escaping.
  • cargo test -p helios-persistence --features postgres,sqlite,R4 — full suite, 0 failures.
  • New postgres_integration_search_reference_bare_id against a real Postgres via testcontainers, covering bare id, :Type + bare id, a decoy patient, and the wildcard case. The existing postgres_integration_search_reference only ever asserted the Type/id form, which is how the gap survived.
  • Regression guard confirmed: with the fix reverted, the new integration test fails with left: [] right: ["obs-1", "obs-2"].

End to end

Postgres 16 in Docker, hfs built as CI builds it (--features R4,sqlite,elasticsearch,postgres,mongodb,s3), Inferno fixtures loaded via crates/hfs/tests/inferno/install.sh — the reproduction from #490:

query before after
Observation?_summary=count (control) 246 246
Observation?patient=us-core-client-tests-patient 0 27
Observation?patient=Patient/us-core-client-tests-patient 27 27
Observation?subject=us-core-client-tests-patient 0 27
Observation?subject:Patient=us-core-client-tests-patient 27
Condition?patient=us-core-client-tests-patient 0 2
Encounter?patient=us-core-client-tests-patient 0 1
Observation?patient=% 0
Observation?patient=nonexistent-id 0

Every value now matches the SQLite column.

Inferno

Ran us_core_v800 locally against both backends on the same fixtures:

backend pass rate
postgres (before, 2026-08-02 nightly) 4.6% (25/544)
postgres (after) 60.2% (328/544)
sqlite (same local run) 59.9% (326/544)

Postgres is now at parity with SQLite. Diffing the two failure sets: 10 shared (all already in the workflow's OMITTED_TESTS for v8.0.0), 0 SQLite-only, 3 Postgres-only.

Notes

@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.58120% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ence/src/backends/postgres/search/query_builder.rs 96.58% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@aacruzgon
aacruzgon force-pushed the fix/490-postgres-bare-id-reference-search branch 3 times, most recently from e9bbefb to c980e60 Compare August 6, 2026 17:56
`Observation?patient=<id>` returned an empty Bundle on the Postgres
backend. References are indexed as written, so the stored value is the
literal `Patient/<id>`, and the query compared the raw search value
against it — a bare logical id matched nothing while
`patient=Patient/<id>` worked. Per FHIR R4 search on references,
`[parameter]=[id]` is the primary form with `[type]/[id]` an additional
one, so this broke the most common search shape in FHIR. SQLite, MongoDB
and Elasticsearch all normalize; only Postgres did not.

`build_reference_condition` now branches on whether the version-stripped
value carries a `/`, mirroring the SQLite handler: a bare id also matches
any reference ending in `/<id>`, with or without a trailing `_history`
version. A `:Type` modifier — previously unhandled on Postgres entirely,
so `subject:Patient=<id>` returned zero as well — resolves the bare id to
`Type/id` first.

The suffix match makes LIKE escaping load-bearing: unescaped, `patient=%`
would become `LIKE '%/%'` and match every stored reference. The plain
path now binds fully-formed patterns built with the file's existing
`like_escape` plus `ESCAPE '\'` rather than concatenating the raw value
into a pattern in SQL. That varies the parameter count per value (two for
a type-prefixed reference, three for a bare id), so the loop runs a
counter; `build_search_query` already advances by `params.len()`.

Tests: four unit tests covering bare-id parameters, `:Type`
normalization, version stripping and LIKE escaping, plus a
`postgres_integration_search_reference_bare_id` integration test against
a real Postgres. The existing reference test only ever asserted the
`Type/id` form, which is how the gap survived. Verified end to end on the
Inferno fixtures: `us_core_v800` on Postgres goes from 4.6% (25/544) to
60.2% (328/544), against SQLite's 59.9% (326/544) on the same data.

Fixes #490
@aacruzgon
aacruzgon force-pushed the fix/490-postgres-bare-id-reference-search branch from c980e60 to 736a407 Compare August 7, 2026 14:48
@smunini
smunini merged commit 161d1bf into main Aug 7, 2026
19 checks passed
@smunini
smunini deleted the fix/490-postgres-bare-id-reference-search branch August 7, 2026 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

postgres search: reference parameters match only Type/id — a bare id matches nothing, so patient=85 returns zero (Inferno pass rate 4.6%)

2 participants