fix(search): match bare-id reference searches on Postgres - #495
Merged
Conversation
This was referenced Aug 4, 2026
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
aacruzgon
force-pushed
the
fix/490-postgres-bare-id-reference-search
branch
3 times, most recently
from
August 6, 2026 17:56
e9bbefb to
c980e60
Compare
`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
force-pushed
the
fix/490-postgres-bare-id-reference-search
branch
from
August 7, 2026 14:48
c980e60 to
736a407
Compare
smunini
approved these changes
Aug 7, 2026
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
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.rsstoresReference.referenceverbatim), so the stored value is the literalPatient/<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.rsbuild_reference_conditionnow 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>; aType/idor absolute URL keeps its previous exact-plus-versioned semantics.:Typemodifier, which was not handled on Postgres at all —subject:Patient=<id>also returned zero. A bare id is now resolved toType/idand matched as a type-prefixed reference.like_escapeplusESCAPE '\', rather than concatenating the raw value onto a pattern in SQL. This is load-bearing for the new suffix match: unescaped,patient=%would becomeLIKE '%/%'and match every stored reference. The:contains,:belowand:abovepaths keep their existing behavior.offset + i.build_search_queryalready advances the next parameter's offset byparams.len(), and both production callers appendfragment.paramswholesale, so placeholder numbering for following parameters is unaffected.reference_or_list_is_a_single_sublinkguards).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,:Typenormalization, version stripping, and LIKE-metacharacter escaping.cargo test -p helios-persistence --features postgres,sqlite,R4— full suite, 0 failures.postgres_integration_search_reference_bare_idagainst a real Postgres via testcontainers, covering bare id,:Type+ bare id, a decoy patient, and the wildcard case. The existingpostgres_integration_search_referenceonly ever asserted theType/idform, which is how the gap survived.left: [] right: ["obs-1", "obs-2"].End to end
Postgres 16 in Docker,
hfsbuilt as CI builds it (--features R4,sqlite,elasticsearch,postgres,mongodb,s3), Inferno fixtures loaded viacrates/hfs/tests/inferno/install.sh— the reproduction from #490:Observation?_summary=count(control)Observation?patient=us-core-client-tests-patientObservation?patient=Patient/us-core-client-tests-patientObservation?subject=us-core-client-tests-patientObservation?subject:Patient=us-core-client-tests-patientCondition?patient=us-core-client-tests-patientEncounter?patient=us-core-client-tests-patientObservation?patient=%Observation?patient=nonexistent-idEvery value now matches the SQLite column.
Inferno
Ran
us_core_v800locally against both backends on the same fixtures:Postgres is now at parity with SQLite. Diffing the two failure sets: 10 shared (all already in the workflow's
OMITTED_TESTSfor v8.0.0), 0 SQLite-only, 3 Postgres-only.Notes
skip. With those groups now proceeding, the 3 Postgres-only failures become real. That is the gate working, but it will look like a regression in the run history.patient_category_date_search_teston vital-signs profiles, and are a separate pre-existing defect — filed as postgres search: dates with a negative UTC offset fail to parse and are silently indexed asUtc::now()— every date search over them is wrong #494. Dates carrying a negative UTC offset fail to parse in the Postgres writer and are silently indexed asUtc::now(). Confirmed independent of this change: it reproduces with no reference parameter at all (Observation?_id=<obs>&date=gt2023-08-02returns 1 on Postgres, 0 on SQLite), and bare-id and type-prefixed reference forms return identical results.build_contained(_contained=true) still compares references with equality only — same gap on a narrow path. Chained search is identical on both backends (LIKE '%value%') and unaffected.Type/id— a bare id matches nothing, sopatient=85returns zero (Inferno pass rate 4.6%) #490 — an unsatisfiable reference shape still returns aBundlewithtotal: 0rather than anOperationOutcome, andPrefer: handling=strictstill returns a Bundle. That is the search: _tag, _profile and _security filters are silently dropped — even under Prefer: handling=strict — and the self link claims they were applied #474-class concern and is out of scope here.doc_lazy_continuationinhelios-serde-support,collapsible_ifin thehelios-fhirbuild script,large_enum_variantin generatedcrates/fhir/src/r6.rs). Leaving that to CI.