fix: bind the caller's filter as a single conjunct - #24
Merged
michaelstonis merged 1 commit intoAug 31, 2026
Merged
Conversation
FilterBuilder appended the caller's terms bare, so the generated clause read "FullTypeName = ? AND Partition = ? AND term1 OR term2". AND binds tighter than OR, so SQL parsed that as "(FullTypeName AND Partition AND term1) OR (term2)": every term after the first Or() was matched against the whole table. A two-term Or() returned rows from other partitions, and rows of other stored types, which the reader deserialized as T with no error. The same clause backs DeleteObjectsAsync, so an ungrouped Or() could delete rows in other partitions and of other types, and CountObjectsAsync over-counted. The caller's filter is now emitted inside its own parentheses. Losing the Partition predicate also cost the partition-prefixed indexes, so this was a large performance regression too; a test asserts via EXPLAIN QUERY PLAN that a two-term OR-chain reaches the index again. TychoQueryable had the same failure one level down: && and || were emitted flat, so Where(x => (x.A || x.B) && x.C) became "A OR B AND C" — read as "A OR (B AND C)" — returning rows that matched only A despite failing C. Each composite boolean node is now emitted in its own group. Every regression test here seeds more than one partition and more than one type: a single-partition, single-type fixture cannot observe the difference, which is presumably why this went unnoticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes a critical SQL precedence bug where caller-supplied filters containing Or() could escape the mandatory FullTypeName + Partition predicates, causing cross-partition/type reads (and potentially deletes/counts) and preventing partition-prefixed indexes from being used. The fix groups the caller filter as a single conjunct in generated SQL, and additionally preserves boolean precedence when translating LINQ && / || expressions.
Changes:
- Wrap the caller’s
FilterBuilderoutput in parentheses when appended to the baseWHEREclause, preventingORfrom splitting the partition/type predicates. - Update LINQ predicate translation (
TychoQueryable) to parenthesize each composite boolean node, preserving the expression tree’s intended precedence in SQL. - Add regression tests (including an
EXPLAIN QUERY PLANassertion) and update README + changelog to document the behavior and breaking-change impact.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| TychoDB/TychoQueryable.cs | Emits &&/` |
| TychoDB/FilterBuilder.cs | Wraps the caller filter in parentheses so it remains a single conjunct after partition/type predicates. |
| TychoDB.UnitTests/FilterCompositionTests.cs | Adds multi-partition/type regression tests and an index-usage plan assertion for OR-chains. |
| README.md | Documents how to explicitly group OR alternatives when mixing with other terms. |
| CHANGELOG.md | Records the critical fix, LINQ precedence fix, and the resulting breaking-change semantics. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
michaelstonis
merged commit Aug 31, 2026
ec55c89
into
pr1/benchmarks-build-and-flaky-key
1 check passed
This was referenced Aug 31, 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.
The headline fix, from an external bug report evaluating TychoDB 5.1.4 against a 257,974-row item master. It reproduced, and turned out to be worse than reported.
An ungrouped
Or()escaped the partition and type predicatesFilterBuilderappended the caller's terms bare, so the generated clause read:ANDbinds tighter thanOR, so SQL parsed that as(FullTypeName = ? AND Partition = ? AND term1) OR (term2). Every term after the firstOr()was matched against the whole table.With two types in one partition it returned a
VendorModeldeserialized as anItemModel, no exception.Worse than reported: the same clause backs
DeleteObjectsAsync, so an ungroupedOr()could delete rows in other partitions and of other types (my repro deletes 3 rows where 2 were asked for), andCountObjectsAsyncover-counted.The caller's filter is now emitted inside its own parentheses — exactly the fix the reporter suggested:
Losing the
Partitionpredicate also cost the partition-prefixed indexes, so this was a large performance regression too. A test asserts viaEXPLAIN QUERY PLANthat a two-term OR-chain reaches the index again.The same failure one level down, in LINQ
Found while investigating the above, and not in the original report.
TychoQueryableemitted&&and||operands flat, soWhere(x => (x.A || x.B) && x.C)becameA OR B AND C— read by SQL asA OR (B AND C)— returning rows that matched onlyAdespite failingC. Wrong precedence within the predicate, independent of the partition leak. Each composite boolean node is now emitted in its own group.Why it went unnoticed
Every regression test here seeds more than one partition and more than one type. A single-partition, single-type fixture passes even with the bug present, which is presumably why the existing suite was green.
Breaking change
An ungrouped
Or()now means what it reads as. Code that unknowingly depended on the leaked rows returns fewer rows. That is the fix, not a regression.Verification
237 tests pass;
dotnet build TychoDB.sln -c Releaseclean.🤖 Generated with Claude Code