Skip to content

fix(query-compiler): return None instead of panicking when a batched QuerySingle has zero keys (prisma/prisma#29642)#5818

Open
tsushanth wants to merge 2 commits into
prisma:mainfrom
tsushanth:fix/query-single-empty-filter-no-panic
Open

fix(query-compiler): return None instead of panicking when a batched QuerySingle has zero keys (prisma/prisma#29642)#5818
tsushanth wants to merge 2 commits into
prisma:mainfrom
tsushanth:fix/query-single-empty-filter-no-panic

Conversation

@tsushanth

Copy link
Copy Markdown

Why

Closes the engine-side half of prisma/prisma#29642.

`QuerySingle::new` validates against `has_many_keys()` but never against "zero keys." When the TS client batches concurrent `findUnique` calls and at least one has `where: { id: undefined }`, the stripped filter arrives here as an empty `QueryFilters`. Falling through to:

```rust
let (key, value) = first.get_single_key().unwrap();
```

panics with `called Option::unwrap() on a None value at selection.rs:218`.

The reporter (prisma/prisma#29642, with Invictnox's root-cause comment) saw 28 `PrismaClientRustPanicError` crashes in a 42-second window in production. The single-call path correctly rejects `undefined` unique values with `PrismaClientValidationError`, but the dataloader/batched path bypasses that validation, hits this unwrap, and panics. Innocent queries sharing the batch window are taken out as collateral when the panic poisons the batch — `Promise.allSettled([bad(), good(), bad(), good()])` rejects every entry.

Fix

Narrow the precondition check on `QuerySingle::new` to reject empty filters alongside many-key ones:

```diff

  • if query_filters.iter().any(|query_filters| query_filters.has_many_keys()) {
  • if query_filters
  • .iter()
    
  • .any(|query_filters| query_filters.has_many_keys() || query_filters.has_no_keys())
    
  • {
    return None;
    }
    ```

Plus a small `QueryFilters::has_no_keys()` helper to make the intent obvious at the call site.

`SelectionSet::new` already handles the `None` result:

```rust
match single {
Some(single) => SelectionSet::Single(single),
None if filters.is_empty() => SelectionSet::Empty,
None => SelectionSet::Many(filters),
}
```

So upstream behaviour is preserved — the panic becomes a graceful fallthrough that downstream layers can already deal with.

What this is NOT

The higher-level concern from prisma/prisma#29642 — that the batched path in the TS client should reject `undefined` unique values with `PrismaClientValidationError` exactly like the single-call path — lives outside this repo. Once that lands, the path that reaches `QuerySingle::new` with an empty filter goes away entirely. The guard here is the safety net for any other code path that might construct an empty filter in the future, and it makes the immediate production panic stop now.

Tests

```
$ cargo test -p query-core --lib selection
running 4 tests
test query_document::selection::tests::query_single_new_returns_none_for_empty_filter_instead_of_panicking ... ok
test query_document::selection::tests::query_filters_has_no_keys_distinguishes_empty_from_populated ... ok
test query_document::selection::tests::query_single_new_returns_none_when_any_filter_in_batch_is_empty ... ok
test query_document::selection::tests::query_single_new_still_works_for_well_formed_single_key_batch ... ok

test result: ok. 4 passed; 0 failed
```

The four cases cover:

  1. Empty filter → returns None (direct regression of the unwrap panic)
  2. Mixed batch with one empty filter — the actual production shape from Invictnox's repro
  3. The new `has_no_keys()` helper distinguishes empty from populated
  4. Behaviour-preservation: well-formed single-key batches still coalesce

`cargo build -p query-core` clean. I did not run `make pedantic` (clippy + fmt) — happy to do that on request, but the touched lines are minimal and the existing style is preserved.

…QuerySingle has zero keys (prisma/prisma#29642)

`QuerySingle::new` validated against `has_many_keys()` but never against
`has_no_keys()`, so when the TS client batched concurrent `findUnique`
calls and at least one had `where: { id: undefined }`, the stripped
filter arrived here as an empty `QueryFilters`. Falling through to

    let (key, value) = first.get_single_key().unwrap();

panicked (`called Option::unwrap() on a None value at selection.rs:218`).
The single-call path correctly rejects this with
`PrismaClientValidationError`, but the dataloader/batched path bypasses
that validation; in production we saw 28 PrismaClientRustPanicError
crashes in a 42-second window, and innocent queries sharing the batch
window were taken out as collateral when the panic poisoned the batch.

This commit narrows the precondition check on `QuerySingle::new` to
reject empty filters alongside many-key ones, and adds a `has_no_keys()`
helper to make the intent obvious at the call site. `SelectionSet::new`
already handles the None-result case — it maps to `SelectionSet::Empty`
when every filter is empty, or `SelectionSet::Many(filters)` otherwise —
so the upstream behaviour is preserved: the panic becomes a graceful
fallthrough that downstream layers can already deal with.

This is the lower half of the fix. The higher-level concern from the
issue — that the batched path should reject `undefined` unique values
with `PrismaClientValidationError` exactly like the single-call path —
lives in the TS client and is left for a follow-up; once that lands,
the path that reaches `QuerySingle::new` with an empty filter goes
away entirely. The guard here is the safety net for any other code
path that might construct an empty filter in the future.

- query-compiler/core/src/query_document/selection.rs: new
  `QueryFilters::has_no_keys()` helper; extend the precondition in
  `QuerySingle::new` to short-circuit on empty filters; rewrite the
  docstring to call out the bug shape and link the issue
- Added a `#[cfg(test)] mod tests` covering: (1) empty filter returns
  None (the direct regression), (2) mixed-batch with one empty filter
  returns None (the production shape), (3) the new helper
  distinguishes empty from populated, (4) well-formed single-key
  batches still coalesce (behaviour preservation)

Verified locally: `cargo test -p query-core --lib selection` → 4
passed. `cargo build -p query-core` clean.
@CLAassistant

CLAassistant commented Jun 16, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6bec5437-f2ad-4df9-994b-e80c7cc0beb6

📥 Commits

Reviewing files that changed from the base of the PR and between b51bd1a and 73f1408.

📒 Files selected for processing (1)
  • query-compiler/core/src/query_document/selection.rs

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a potential crash during query compilation when filter batches contain empty filter sets or invalid multi-key filters.
    • Strengthened single-key batch validation to safely reject unsupported configurations.
  • Tests
    • Added unit tests covering empty vs populated filter detection and regression coverage to confirm invalid batches are rejected instead of failing at runtime.

Walkthrough

QueryFilters gains a has_no_keys() method that returns true when its internal filter list is empty. QuerySingle::new is updated to treat any batch containing filters with zero keys as invalid, returning None rather than falling through to the single-key construction path that previously panicked. Unit tests are added to cover the zero-key regression case, the mixed-batch case, has_no_keys behavior for both empty and populated filters, and a sanity check that well-formed single-key batches continue to coalesce correctly.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: fixing a panic by returning None instead when batched QuerySingle has zero keys, with issue reference.
Description check ✅ Passed The description is well-related to the changeset, providing detailed context about the bug, root cause, fix implementation, and test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@query-compiler/core/src/query_document/selection.rs`:
- Around line 443-444: Update the regression test comment at lines 443-444 to
correctly describe the behavior of SelectionSet::new. The comment currently
states that the all-empty case maps to SelectionSet::Empty, but this is only
true when the input vector itself is empty (Line 277). When you have a non-empty
Vec<QueryFilters> containing empty filters, SelectionSet::new returns
SelectionSet::Many(filters) (Line 278). Revise the comment to accurately reflect
that the all-empty case with non-empty filter vectors maps to
SelectionSet::Many(filters), not SelectionSet::Empty.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 494aa4a5-8f49-4fb2-88e6-610c629a56f3

📥 Commits

Reviewing files that changed from the base of the PR and between 3c6e192 and b51bd1a.

📒 Files selected for processing (1)
  • query-compiler/core/src/query_document/selection.rs

Comment thread query-compiler/core/src/query_document/selection.rs Outdated
The comment claimed the all-empty case maps to `SelectionSet::Empty`, but
that branch is only taken when the input `Vec<QueryFilters>` itself is
empty (selection.rs:277). A non-empty Vec full of empty filters falls
into `SelectionSet::Many(filters)` instead (selection.rs:278). No
behavior change — just a more accurate comment.
@tsushanth

Copy link
Copy Markdown
Author

Thanks for the catch @coderabbitai — pushed 73f1408 reworking the regression-test comment so it reflects the actual mapping: a non-empty Vec<QueryFilters> always lands in SelectionSet::Many(filters) (line 278), and SelectionSet::Empty is only reached when the input Vec itself is empty (line 277).

CLA sign-off coming next.

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

CodeRabbit chat interactions are restricted to organization members for this repository. Ask an organization member to interact with CodeRabbit, or set chat.allow_non_org_members: true in your configuration.

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.

2 participants