Skip to content

feat(rest,persistence): resolve conditional URL entries inside transaction bundles - #919

Open
aacruzgon wants to merge 7 commits into
mainfrom
feat/859-transaction-conditional-url
Open

feat(rest,persistence): resolve conditional URL entries inside transaction bundles#919
aacruzgon wants to merge 7 commits into
mainfrom
feat/859-transaction-conditional-url

Conversation

@aacruzgon

@aacruzgon aacruzgon commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Transaction Bundles now resolve PUT [type]?[criteria] and DELETE [type]?[criteria] inside the open transaction. The transaction arm used to decline any non-GET entry with a query string whole (400 not-supported, #503) while the batch arm resolved the same entries (#860). The spec's own transaction example (bundle-transaction.json entries 3 and 6) uses both forms.

The shape is the one design discussion #28 sketched: a separate ConditionalTransaction: Transaction trait with typed &[SearchParameter] criteria, and a supports_conditional_in_transaction() capability predicate on BundleProvider. Criteria are parsed once in the REST layer with the search parser, so the transaction path never hits #865 (modifiers dropped) or #866 (_-names matching everything). The HTTP handlers' and batch arm's string API are unchanged (#861 owns that migration).

Closes #859.

Changes

Persistence

  • ConditionalTransaction (core/transaction.rs): one required method, find_matching, the transaction-scoped search; create_if_none_exist / update_conditional / delete_conditional defaulted on top of it with the existing outcome enums. BundleEntry.criteria: Option<Vec<SearchParameter>>. BundleProvider::supports_conditional_in_transaction (required, not defaulted, like supports_atomic_transactions).
  • core::bundle_conditionals: every conditional entry is resolved against the transaction's starting view before any write (R4 transaction processing rules) and the outcome pinned; several matches → TransactionError::MultipleMatches (412 multiple-matches for the whole bundle); R4 §3.1.0.11.2 overlap check — a resolved identity another entry addresses (instance PUT/DELETE, or another conditional entry) fails the bundle with 400 naming both entries; a matched entry's fullUrl is seeded into the reference map so urn:uuid references resolve from any position; a delete's 204 carries location naming the deleted version.
  • SQLite and PostgreSQL implement the trait on their transaction types (PostgreSQL's transaction gains a cloneable backend handle) and pin PUT/DELETE targets; the string ifNoneExist matcher now runs through the same typed search. supports_conditional_in_transaction is !is_search_offloaded().
  • MongoDB runs the same pre-pass on its session. Its in-memory matcher understands plain name=value only, so a modifier, chain, prefix, or OR-list refuses the entry with 501 rather than silently matching nothing (Optimize MongoDB If-None-Exist logic #709 owns an index-backed matcher).
  • Composite delegates the predicate and syncs a 204 carrying location as a delete to secondaries. S3 answers false.

REST (handlers/batch.rs)

  • Guard replaced by admission: criteria percent-decoded (repeated keys kept), parsed with build_search_query_from_pairs against the tenant registry; unknown parameter, invalid modifier, or result-shaping _ parameter (_count, _sort, _include, _has, …) → 400 whole bundle. Criteria on POST, empty query, and ifMatch on a conditional entry → 400, as in batch. A control parameter on an instance URL (Patient/123?_format=json) is dropped.
  • !supports_conditional_in_transaction() with URL criteria or ifNoneExist → 501 intact before anything executes (was a 400 surfacing from the backend's mid-bundle refusal).
  • Conditional entries' AuditEvents name the resolved resource via the result's location.

Docs: crates/rest/README.md transaction bullet rewritten; docs/draft-issues-from-859.md indexes the side findings, all validated and filed: #921 (transactional instance deletes never reach composite secondaries — this PR fixes only the conditional form), #922 (BundleError index is the processing-order index), #868 (the fixture's $lookup entry, already tracked), and a comment on #390 (MongoDB transaction tests skip silently on the standalone testcontainer).

Testing

  • cargo fmt --all -- --check clean; CI's clippy line (--all-targets --all-features -D warnings + the 8 allows) clean.
  • cargo test -p helios-persistence: 27 binaries pass; transactions_suite 53 (9 shared scenarios + offloaded 501 + ConditionalTransaction defaults); bundle_conditionals unit tests 8.
  • cargo test -p helios-rest: lib 580; batch_conformance 89, with 17 new transaction cases (update/create/412 rollback, delete/no-match/412, overlap ×2, urn:uuid to a matched PUT, percent-encoding, family:exact honoured and case-sensitive, _count/_sort/_include refused, unknown param, ifMatch, POST-with-criteria, instance control parameter, 501 gate for URL criteria and ifNoneExist).
  • Testcontainers: postgres_integration 18 matching pass (9 shared scenarios + existing conditional/transaction/ifMatch). MongoDB: the 11 transaction cases pass against a replica-set container via HFS_TEST_MONGODB_URL; on the standalone testcontainer they take the existing skip path.

Notes

Side findings for review before filing: transactional instance deletes never
reach composite secondaries, the spec fixture's `$lookup` entry, the test-hfs
skill's missing backend commands, and `BundleError` indexes referring to the
sorted entry order.
…s via ConditionalTransaction

A transaction Bundle could not carry `PUT [type]?[criteria]` or
`DELETE [type]?[criteria]`: the backends' `parse_url` is query-blind, and
`ConditionalStorage` writes through the backend's own connection, outside the
open transaction, so nothing could resolve criteria within the atomic scope.

Add the shape design discussion #28 proposed. `ConditionalTransaction:
Transaction` is a separate trait whose one required method, `find_matching`,
is the transaction-scoped search; `create_if_none_exist`,
`update_conditional` and `delete_conditional` are defaulted on top of it and
reuse the existing outcome enums. `BundleEntry` gains typed `criteria`, so
modifiers, chains and prefixes reach the backend as `SearchParameter`s rather
than a `k=v&k=v` string. `BundleProvider` gains the required predicate
`supports_conditional_in_transaction`, false when search is offloaded to a
secondary (empty local index) and for S3.

`core::bundle_conditionals` holds what every executor shares: resolution of
every conditional entry against the transaction's starting view before any
write, `412 multiple-matches` for the whole bundle on several matches, the
R4 §3.1.0.11.2 overlapping-identity check (a resolved identity another entry
addresses fails the bundle naming both entries), and the delete result that
names what it deleted through `location`. A matched entry's `fullUrl` is
seeded into the reference map before execution, so `urn:uuid` references to
it resolve from any position.

- SQLite and PostgreSQL implement the trait on their transaction types
  (PostgreSQL's transaction now carries a cloneable backend handle) and pin
  the pre-pass target in their PUT/DELETE arms; the string `ifNoneExist`
  matcher now runs through the same typed search.
- MongoDB has no `Transaction` impl, so it runs the same pre-pass on its
  session; criteria its in-memory matcher cannot evaluate (modifier, chain,
  prefix, OR-list) refuse the entry with 501 rather than matching nothing.
- CompositeStorage delegates the predicate and syncs a 204 carrying a
  `location` as a delete to secondaries.

Tests: unit tests for the overlap check, URL parsing and entry results in
`bundle_conditionals`; the backend suites land in the next commit.
…e, PostgreSQL and MongoDB

Add `tests/transactions/conditional_url_suite.rs`, generic over
`BundleProvider` and `#[path]`-included by all three backend binaries the way
`if_match_suite.rs` is, so the same nine scenarios run everywhere: update the
single match (200 naming the new version), create on no match, 412 with the
sibling create rolled back, delete the match (204 naming the deleted version),
no-match 204, several-match delete rolled back, overlap with an
instance-addressed entry, two conditional entries resolving to one resource,
and a matched PUT's `fullUrl` resolving a `urn:uuid` reference from an earlier
entry.

SQLite additionally covers the offloaded-search 501 refusal and the
`ConditionalTransaction` defaults (upsert, single-match delete, empty criteria
match nothing). MongoDB additionally asserts the 501 for a criterion with a
modifier. Postgres and Mongo wrappers use per-scenario tenants on the shared
container; Mongo probes the topology first and skips on standalone.

Tests: transactions_suite 53 pass; postgres_integration 18 matching pass on
testcontainers; the 11 MongoDB transaction cases pass against a replica-set
container (they skip on the standalone testcontainer).
…bundles

The transaction arm declined any non-GET entry whose URL carried a query
string with `400 not-supported` before anything executed (#503), while the
batch arm resolved the same entries. With the backends now resolving typed
criteria inside the open transaction, the guard becomes admission:

- `PUT/DELETE [type]?[criteria]` criteria are percent-decoded with repeated
  keys kept and parsed with the search parser against the tenant's registry,
  so an unknown parameter, a modifier the type does not define, or a
  result-shaping `_` parameter (`_count`, `_sort`, `_include`, `_has`, …) is a
  400 for the whole bundle rather than a silent "matches nothing" — which on
  a conditional write is a duplicate (#865) and on a `_`-name matched
  everything (#866). The typed criteria travel on `BundleEntry.criteria`.
- The batch arm's refusals apply: criteria on a POST, an empty query, and
  `ifMatch` on a conditional entry are 400. A control parameter on an instance
  URL (`PUT Patient/123?_format=json`) is dropped; the entry addresses the
  instance either way.
- A backend answering `supports_conditional_in_transaction() == false`
  declines a bundle carrying URL criteria or `ifNoneExist` intact with 501,
  instead of the 400 the backend's mid-bundle refusal used to surface as.
- A conditional entry's audit event names the resource the backend resolved,
  read from the result's `location`, since its URL has no id and a delete no
  body.

README: the transaction bullet describes the new behavior, the
snapshot-resolution rule, the overlap and several-matches outcomes, the 501
gate and MongoDB's criteria-shape limit; the limitation line is removed.

Tests: `AuditTarget::from_location` unit test; helios-rest lib 580 pass; the
conformance cases land in the next commit.

Closes #859.
Replace the two #503 guard tests (declined intact; GET exempt) with
seventeen transaction cases on the in-memory SQLite harness: PUT updates the
match, creates on none, 412 with the sibling rolled back; DELETE removes the
match naming it, no-match 204, 412 deleting nothing; overlap with an
instance entry and two conditional entries resolving to one resource are
400 with nothing written; a `urn:uuid` reference to a matched PUT resolves;
percent-encoded criteria; `family:exact` honoured and case-sensitive;
`_count`/`_sort`/`_include` refused and `_id=nonexistent` deleting nothing;
unknown parameter, `ifMatch` and POST-with-criteria 400; an instance URL's
control parameter dropped; and the 501 gate on an offloaded-search backend
for URL criteria and `ifNoneExist` alike.

`create_test_server` is split so a test can hand in a configured backend.

Tests: batch_conformance 89 pass.
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

…n reach

`codecov/patch` failed the PR at 70.44% of the diff hit against an 82.08%
target: 183 of the 619 measured new lines were uncovered, and two files
carried 159 of them with no coverage at all. The MongoDB backend's share is
structural — the coverage job's Mongo testcontainer is standalone, so every
transaction test skips there (#390) — but its criteria flattener is pure, and
nothing outside a live session was covered either.

- `mongodb`: unit tests for `bundle_criteria_pairs`, the flattener that
  decides which criteria the session-scoped matcher can evaluate — plain
  pairs, and the refusal naming the first modifier, chain, prefix, OR-list,
  composite or valueless criterion. The refusal spelled out the implicit `eq`
  prefix (`family:exact=eqNguyen`), echoing back a criterion in a form the
  client never sent; it now prints only a prefix that was written.
- `composite`: a recording secondary pins the delete fan-out a conditional
  delete's `204`+`location` triggers, that a locationless `204` syncs nothing,
  that a create beside it still fans out, and that a fan-out that cannot be
  queued is logged rather than failing the committed bundle.
- `bundle_conditionals`: a fake `ConditionalTransaction` covers the pre-pass
  without a backend — targets pinned per entry index, a URL naming no resource
  type, a failing search, the 501 for a backend that cannot resolve
  conditionals, the `412`'s per-method operation naming, and the three
  `ConditionalTransaction` defaults reading their outcome from `find_matching`.
- `transactions_suite`: `create_if_none_exist`'s three outcomes on a real
  SQLite transaction, beside the update and delete defaults already there.
- `batch_conformance`: a modifier the parameter's type does not define is a
  400 for the whole bundle.

The ~109 lines still uncovered are the MongoDB session code that only a
replica-set container reaches; making the test harness start one is its own
change.

Tests: persistence lib 1015, transactions_suite 53, batch_conformance 90.
fmt clean; CI's clippy line clean on both crates.
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.

transaction: resolve URL-borne conditional criteria (PUT/DELETE [type]?[criteria]) inside the atomic scope

1 participant