Summary
The HTTP conditional handlers hand ConditionalStorage a k=v&k=v string rebuilt from Query<HashMap<String, String>>. Repeated keys collapse to one (last wins), a decoded value containing & mis-splits in the backend, parameter order is HashMap iteration order, and five copies of the string parser have drifted. Discussion #28 designs every conditional operation around typed &[SearchParameter] criteria; the string API is where that contract was never implemented.
Evidence
crates/rest/src/handlers/update.rs:338 (conditional_update_handler), delete.rs:243, patch.rs:182 extract axum::extract::Query<std::collections::HashMap<String, String>>. The rejoin is byte-identical in all three (update.rs:366-370, delete.rs:256-260, patch.rs:189-193):
let search_params: String = query.iter().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join("&");
- axum 0.8.4
Query::try_from_uri deserializes via serde_urlencoded into the HashMap; repeated keys are inserted in turn, so ?identifier=a&identifier=b becomes {"identifier": "b"} with no error. FHIR gives repeated parameters AND semantics.
- None of the three handlers reads
Uri/RawQuery, parse_query_pairs (crates/rest/src/extractors/query_pairs.rs:12, which preserves repeated keys), or SearchParams::from_map (extractors/search_params.rs:79, whose doc names conditional queries as its intended caller and has no such caller).
- Backends split with
split('&') then splitn(2, '=') and never percent-decode, so a decoded & in a value mis-splits while = survives. Re-encoding on the way out is therefore not a fix.
- Five copies of the parser:
backends/sqlite/storage.rs:2706, backends/postgres/storage.rs:2646, composite/storage.rs:196, backends/mongodb/storage.rs:367 (parse_simple_bundle_search_params), backends/mongodb/search_impl.rs:179. The mongodb/storage.rs:367 copy trims and drops pairs with an empty key or value, so Patient?identifier= is one criterion on SQLite/PostgreSQL and zero on MongoDB; zero criteria makes the in-transaction matcher return no matches and a duplicate is created. parse_url is likewise copied three times (sqlite:3439, postgres:3377, mongodb:3470).
The batch path (#860) works around this by decoding once with parse_query_pairs and re-joining, which keeps repeated keys but shares the & limit.
Design reference
#28 Transaction::create_if_none_exist / update_conditional / delete_conditional, the REST handler trait's update_conditional / delete_conditional, and TransactionEntryRequest.if_none_exist: Option<Vec<SearchParameter>> all take typed parameters.
Proposed fix
Change ConditionalStorage (and the in-transaction matchers) to take &[(String, String)] or &[SearchParameter]; have the HTTP handlers use parse_query_pairs; delete the five string parsers and converge parse_url into helios_persistence::core next to bundle_if_match_gate. Tests: repeated-key criteria on each backend; Patient?identifier= answering the same on all backends.
Found while validating #511 (see PR #860).
Summary
The HTTP conditional handlers hand
ConditionalStorageak=v&k=vstring rebuilt fromQuery<HashMap<String, String>>. Repeated keys collapse to one (last wins), a decoded value containing&mis-splits in the backend, parameter order isHashMapiteration order, and five copies of the string parser have drifted. Discussion #28 designs every conditional operation around typed&[SearchParameter]criteria; the string API is where that contract was never implemented.Evidence
crates/rest/src/handlers/update.rs:338(conditional_update_handler),delete.rs:243,patch.rs:182extractaxum::extract::Query<std::collections::HashMap<String, String>>. The rejoin is byte-identical in all three (update.rs:366-370,delete.rs:256-260,patch.rs:189-193):Query::try_from_urideserializes viaserde_urlencodedinto theHashMap; repeated keys are inserted in turn, so?identifier=a&identifier=bbecomes{"identifier": "b"}with no error. FHIR gives repeated parameters AND semantics.Uri/RawQuery,parse_query_pairs(crates/rest/src/extractors/query_pairs.rs:12, which preserves repeated keys), orSearchParams::from_map(extractors/search_params.rs:79, whose doc names conditional queries as its intended caller and has no such caller).split('&')thensplitn(2, '=')and never percent-decode, so a decoded&in a value mis-splits while=survives. Re-encoding on the way out is therefore not a fix.backends/sqlite/storage.rs:2706,backends/postgres/storage.rs:2646,composite/storage.rs:196,backends/mongodb/storage.rs:367(parse_simple_bundle_search_params),backends/mongodb/search_impl.rs:179. Themongodb/storage.rs:367copy trims and drops pairs with an empty key or value, soPatient?identifier=is one criterion on SQLite/PostgreSQL and zero on MongoDB; zero criteria makes the in-transaction matcher return no matches and a duplicate is created.parse_urlis likewise copied three times (sqlite:3439,postgres:3377,mongodb:3470).The batch path (#860) works around this by decoding once with
parse_query_pairsand re-joining, which keeps repeated keys but shares the&limit.Design reference
#28
Transaction::create_if_none_exist / update_conditional / delete_conditional, the REST handler trait'supdate_conditional / delete_conditional, andTransactionEntryRequest.if_none_exist: Option<Vec<SearchParameter>>all take typed parameters.Proposed fix
Change
ConditionalStorage(and the in-transaction matchers) to take&[(String, String)]or&[SearchParameter]; have the HTTP handlers useparse_query_pairs; delete the five string parsers and convergeparse_urlintohelios_persistence::corenext tobundle_if_match_gate. Tests: repeated-key criteria on each backend;Patient?identifier=answering the same on all backends.Found while validating #511 (see PR #860).