fix: bind the caller's filter as a single conjunct, and derive keys honestly - #22
fix: bind the caller's filter as a single conjunct, and derive keys honestly#22michaelstonis wants to merge 2 commits into
Conversation
Two pre-existing problems that made verification unreliable, both independent of the query work that follows. TychoDB.Benchmarks has not compiled since 123c79f, which added an IJsonSerializer parameter to SortBuilder.Build so property expressions could render against the serializer's member names. One call site in Diagnostics.cs was never updated, so `dotnet build TychoDB.sln` failed outright and nobody could run the benchmarks backing our perf claims. TychoDb_QueryUsingContains_ShouldBeSuccessful keyed 1000 objects by GetHashCode() on a type that does not override it — the runtime identity hash, not a value hash. Instances collide, INSERT OR REPLACE overwrites, and the count assertion fails at random. Measured over 2000 trials of building 1000 TestClassA instances: 16 trials collided (0.80%), with as few as 998 distinct keys. Observed twice as a real suite failure across roughly 20 full runs. Keyed by IntProperty instead, which is already distinct over Range(100, 1000). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…onestly An ungrouped Or() escaped the partition and type predicates -------------------------------------------------------- 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 then 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 grouped OR-chain now uses the index again. TychoQueryable lost precedence inside a predicate ------------------------------------------------- && and || were emitted flat, so Where(x => (x.A || x.B) && x.C) became "A OR B AND C" — read by SQL 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. FilterType.In / NotIn --------------------- Set membership as one atomic term, so it cannot be mis-grouped the way an Or() chain can, rendered through the same numeric CAST the scalar comparisons use so an expression index still serves it. Duplicates are removed; an empty set matches nothing (In) or everything (NotIn), never "IN ()", which is a syntax error, and never a silently dropped term; a null in the set is tested with IS NULL, which SQL's own IN would not do; long lists split across several IN terms rather than exceeding SQLITE_MAX_VARIABLE_NUMBER, which is 999 on older SQLite builds. ReadObjectsByKeysAsync ---------------------- Reads a batch of keys in one round trip, binding the key set as a single JSON array expanded by JSON_EACH rather than one parameter per key, so there is no parameter ceiling and no chunking for callers to think about. Measured against a loop of ReadObjectAsync on a 250,000-row store, best of five after warm-up: 200 keys 1.9ms -> 0.9ms; 999 keys 10.6 -> 4.5; 4,949 keys 36.8 -> 16.9; 23,784 keys 183.2 -> 67.3. Both include deserialization, which dominates the remainder. The shape was chosen by measurement: a single IN collapses at scale (1,297.7ms at 23,784 keys), a chunked IN takes 91.8ms, a temp-table join carries ~40ms of fixed setup. CountObjectsAsync stopped counting rows on the client ----------------------------------------------------- It issued "SELECT 1 FROM JsonValue WHERE ..." and incremented a counter once per matching row, costing a reader round trip per row. It now issues SELECT COUNT(*): 16.0ms -> 6.5ms counting a 250,000-row partition. The same query backs the pre-count a progress-reporting read performs. Key derivation -------------- AddTypeRegistration<T>() documented convention-based ID detection and did none: it recorded no selector, so every by-object call threw "An id mapping has not been provided" on a type whose property was named Id. It now finds Id, then <TypeName>Id, case-insensitively. A type with no such property still registers without a mapping, so registering a key-less type and supplying keys at the call site keeps working. WriteObjectsAsync(objs, keySelector, ...) overrides the registration, and a row written under a key the registration would not produce is unreachable by every by-object overload — DeleteObjectAsync(obj) returns false while the row survives. Under requireTypeRegistration that now throws, naming both keys. The check wraps the selector rather than pre-scanning, so a lazy sequence is enumerated exactly once. That guarantee makes a rewrite sound: under strict registration, Equals and In filters on the id property are answered from the indexed Key column instead of a JSON_EXTRACT scan — 79.3ms -> 0.0ms for Equals, 101.2ms -> 0.2ms for In over 100 keys. Rows already in the database are checked once per type by a divergence probe (~92ms, cached for the connection); a single divergent row disables the rewrite for that type and the ordinary predicate is emitted, so the worst case is the behaviour that was there before. Negations are left alone — they cannot use an index either way — as is a null comparison value, since Key is NOT NULL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes several correctness and safety issues in TychoDB query/filter generation (notably OR-precedence escaping partition/type predicates) and adds performance-oriented query features (set-membership filters, batch key reads, server-side counts) plus stricter, more “honest” key/ID derivation under type registration.
Changes:
- Correctly preserves boolean precedence by grouping emitted filter/LINQ boolean nodes, preventing OR terms from escaping partition/type constraints.
- Adds
FilterType.In/NotIn,ReadObjectsByKeysAsync<T>(), and rewrites eligible ID-property filters to the indexedKeycolumn when sound under strict registration. - Improves counting performance by switching to
COUNT(*), and expands tests/docs/changelog to cover the new behavior and APIs.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| TychoDB/TychoQueryable.cs | Groups composite && / ` |
| TychoDB/Tycho.cs | Adds batch key reads, key-column rewrite plumbing, strict key divergence guard, and uses COUNT(*) for counts. |
| TychoDB/RegisteredTypeInformation.cs | Implements convention-based ID property detection and captures ID path segments for later query-time rendering. |
| TychoDB/Queries.cs | Adds SQL for batch key reads, count-by-keys, and key/id divergence probing; switches count query to COUNT(*). |
| TychoDB/KeyColumnRewrite.cs | Introduces cached, per-type rewrite eligibility probing to safely use Key column for eligible ID filters. |
| TychoDB/FilterBuilder.cs | Binds caller filters as a single conjunct, adds In/NotIn, chunks large IN lists, and supports key-column rewrite emission. |
| TychoDB/Filter.cs | Extends FilterType with In / NotIn. |
| TychoDB.UnitTests/ZzBatchKeyBench.cs | Adds an ignored scratch benchmark harness for key batch read/query-shape comparisons. |
| TychoDB.UnitTests/TychoDbTests.cs | Fixes flaky test keying caused by GetHashCode() collisions. |
| TychoDB.UnitTests/KeyRegistrationTests.cs | Adds coverage for convention registration, strict-mode divergence guarding, and key-column rewrite behavior. |
| TychoDB.UnitTests/FilterInTests.cs | Adds behavioral and query-shape tests for In/NotIn semantics, chunking, null handling, and index usability. |
| TychoDB.UnitTests/FilterCompositionTests.cs | Adds regression tests ensuring OR composition can’t escape partition/type predicates; verifies LINQ precedence. |
| TychoDB.UnitTests/BatchKeyReadTests.cs | Adds correctness tests for ReadObjectsByKeysAsync (partition/type safety, JSON metacharacters, large batches, progress). |
| TychoDB.Benchmarks/Diagnostics.cs | Updates benchmark diagnostics to pass serializer into sort building after signature change. |
| README.md | Documents ReadObjectsByKeysAsync, ID-property filter rewrite behavior under strict mode, and performance guidance. |
| CHANGELOG.md | Records the precedence/security fixes, new APIs, performance improvements, and breaking-change notes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// <summary> | ||
| /// Resolves a set-membership term's values element by element, so an enum or DateOnly in an | ||
| /// IN list is compared in the same JSON form the serializer wrote — exactly as the scalar | ||
| /// comparisons already are. Any other term resolves its single value. | ||
| /// </summary> |
| // An id has to be readable and addressable as a plain member: an indexer or a | ||
| // write-only property cannot be turned into a property path. | ||
| if (property.GetIndexParameters().Length > 0 || !property.CanRead) | ||
| { | ||
| continue; | ||
| } |
| /// <param name="isPropertyPathDateTime">Whether the property is a date/time.</param> | ||
| /// <param name="values">The set to test membership against.</param> | ||
| /// <returns>The current builder for chaining.</returns> | ||
| public FilterBuilder<TObj> Filter(FilterType filterType, string propertyPath, bool isPropertyPathNumeric, bool isPropertyPathBool, bool isPropertyPathDateTime, IEnumerable<object>? values) |
|
Superseded by a stacked split, as requested. Same final tree — I verified the tip of the last branch is byte-identical to this one. Review in order; each is based on the one before:
Each branch builds Copilot's three review comments here have been addressed in the split — two applied, one declined with evidence:
|
Prompted by an external bug report evaluating TychoDB 5.1.4 as a query engine for a 257,974-row item master. The reported bug reproduced, turned out to be worse than reported, and pulled in a family of related problems.
1. Critical: 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, with no exception.Beyond the report: the same clause backs
DeleteObjectsAsync, so an ungroupedOr()could delete rows in other partitions and of other types, andCountObjectsAsyncover-counted. The caller's filter is now emitted inside its own parentheses. Losing thePartitionpredicate also cost the partition-prefixed indexes, so this was a large performance regression as well — a grouped OR-chain uses the index again (EXPLAIN QUERY PLANasserted in tests).2.
TychoQueryablelost precedence inside a predicateFound while investigating the above.
&&and||were emitted flat, soWhere(x => (x.A || x.B) && x.C)becameA OR B AND C— read by SQL asA OR (B AND C)— returning rows matching onlyAdespite failingC. Each composite boolean node is now emitted in its own group.3.
FilterType.In/NotInSet membership as one atomic term, so it cannot be mis-grouped the way an
Or()chain can, rendered through the same numericCASTthe scalar comparisons use so an expression index still serves it.In) / everything (NotIn) — neverIN (), a syntax error, and never a silently dropped term, which would widen results.nullin the set is tested withIS NULL, which SQL's ownINwould never match.NotInkeeps SQL's NULL semantics, matching whatNotEqualsalready does (pinned by a test asserting the two agree).INterms rather than exceedingSQLITE_MAX_VARIABLE_NUMBER, which is only 999 on older SQLite builds.4.
ReadObjectsByKeysAsync<T>Batch key reads in one round trip. The key set binds as a single JSON array expanded by
JSON_EACH, not one parameter per key — no ceiling, no chunking, one prepared statement regardless of batch size.The shape was chosen by measurement, not assumption (250,000-row store, best of five after warm-up):
json_eachININEnd to end against a loop of
ReadObjectAsync(both include deserialization, which dominates the remainder):ReadObjectsByKeysAsync5.
CountObjectsAsyncstopped counting rows on the clientIt issued
SELECT 1 FROM JsonValue WHERE …and incremented a counter once per matching row — a reader round trip per row. NowSELECT COUNT(*): 16.0 ms → 6.5 ms counting a 250,000-row partition. The same query backs the pre-count a progress-reporting read performs.6. Key derivation
Three findings that compose:
AddTypeRegistration<T>()documented convention-based ID detection and did none. It recorded no selector, soWriteObjectAsync(obj),ReadObjectAsync(obj),ObjectExistsAsync(obj),DeleteObjectAsync(obj)andGetIdFor(obj)all threwAn id mapping has not been provided— on a type whose property was literally namedId. It now findsId, then<TypeName>Id, case-insensitively. A type with no such property still registers without a mapping, so registering a key-less type and supplying keys at the call site keeps working.Strict registration now rejects a divergent key.
WriteObjectsAsync(objs, keySelector, …)overrides the registration, and a row written under a key the registration would not produce is unreachable by every by-object overload —DeleteObjectAsync(obj)returnsfalsewhile the row survives. UnderrequireTypeRegistrationthat now throws, naming both keys. The check wraps the selector rather than pre-scanning, so a lazy sequence is enumerated exactly once.That guarantee makes a rewrite sound.
Equals/Infilters on the id property are answered from the indexedKeycolumn instead of aJSON_EXTRACTscan:EqualsIn, 100 keysRows already in the database are checked once per type by a divergence probe (~92 ms, lazy, cached for the connection). A single divergent row disables the rewrite for that type and the ordinary predicate is emitted, so the worst case is the behaviour that was there before. Negations are left alone (they cannot use an index either way), as is a null comparison value (
KeyisNOT NULL).Breaking changes
Or()now means what it reads as. Code that depended on the leaked rows returns fewer rows. This is the fix, not a regression.FilterTypethrows. It previously bound toobjectand rendered as"System.Int32[]", matching nothing silently. UseFilterType.In. A literalnullalso binds to the new overload but keeps its old meaning —Filter(Equals, x => x.Value, null)is still the null comparison (regression test included; two existing tests caught this when I first got it wrong).AddTypeRegistration<T>()on a type with a conventional id now supplies a key. Previously every by-object call threw; they now work.Verification
dotnet build TychoDB.sln -c Releaseclean.main.ZzBatchKeyBench.cs,[Ignore]d so they never run in CI — remove the attribute to reproduce any number above. Happy to drop that file if you'd rather it stayed local.Notes
TychoDB.Benchmarkshad not compiled since 123c79f (sodotnet build TychoDB.slnfailed outright), and a test keyed 1000 objects byGetHashCode(), which collides 0.80% of the time (measured over 2000 trials) and failed the suite at random.🤖 Generated with Claude Code