Typed collections: == null and OrderBy(nullable).Take(n) served from an index return no null-keyed documents (indexes skip null keys, planner reads the null bucket)
Maintainers usually reply within 2 days
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 25/100
Research direction
Start with CollectionSecondaryIndex.Insert and IndexOptimizer.OptimizeExpression, then compare their null handling with DynamicCollection and the BTreeQueryProvider ordering fast paths. Read the linked pull request #253 for the current direction before changing behavior. Done means indexed null comparisons, aggregates, and nullable ordering windows agree with scans and LINQ-to-Objects, while preserving the documented unique-index behavior.
Written by the indexing model from the issue text.
Description
Found by a differential test that runs ~330 LINQ shapes over the same documents on a typed collection with an index on every queried property, on the same collection without indexes, and with LINQ-to-Objects as the reference (PR to follow). Ten rows disagree, all on the indexed path only: the plain collection and LINQ-to-Objects agree with each other.
Repro
// DiffDoc has string? Nick and int? MaybeCount, both with .HasIndex(...) in OnModelCreating.
await ctx.Docs.InsertBulkAsync([
new DiffDoc { Name = "d1", Nick = null, MaybeCount = null },
new DiffDoc { Name = "d2", Nick = "a", MaybeCount = 5 },
new DiffDoc { Name = "d3", Nick = "b", MaybeCount = null }]);
ctx.Docs.AsQueryable().Where(x => x.Nick == null).ToList(); // expected [d1], actual []
ctx.Docs.AsQueryable().Where(x => x.MaybeCount == null).ToList(); // expected [d1, d3], actual []
ctx.Docs.AsQueryable().Count(x => x.MaybeCount == null); // expected 2, actual 0
ctx.Docs.AsQueryable().Where(x => x.MaybeCount == null).First(); // throws "Sequence contains no elements"
// Same root cause through the ordering fast path:
ctx.Docs.AsQueryable().OrderBy(x => x.MaybeCount).Take(3).Select(x => x.MaybeCount).ToList();
// expected [null, null, 5] (LINQ orders null first), actual [5]: the null-keyed documents are absent, not misplaced
Every shape that routes the null comparison through the index is affected: AND/OR combinations, Contains on a list holding a null, the aggregate push-downs, and OrderBy(nullable).Skip/Take served from the index without a Where.
Expected: the documents whose property is null, as LINQ-to-Objects and the same query on a collection without the index return them.
Actual: an empty result; 0 for Count; InvalidOperationException for First; a window that silently lacks the null-keyed documents.
Mechanism (verified on 0450b97)
The planner and the indexer disagree about whether typed indexes have a null bucket.
CollectionSecondaryIndex.Insertskips a document whose key is null (src/BLite.Core/Indexing/CollectionSecondaryIndex.cs:118, "Skip null keys or failed traversal");UpdateandDeletedo the same. No typed index ever containsIndexKey.NullSentinel. README documents this ("typed collections do not indexnullkeys", unique-index section, added with #176) andUniqueIndexTests.Typed_Unique_Index_Ignores_Null_Valuespins it.IndexOptimizer.OptimizeExpressionplansx.Prop == nullon an indexed property as an exact point lookup withMinValue = MaxValue = DBNull.ValueandFilterCompleteness.Exact(src/BLite.Core/Query/IndexOptimizer.cs:293-304);ConvertToIndexKeymapsDBNulltoIndexKey.NullSentinel(CollectionSecondaryIndex.cs:776); a null element of aContainslist is normalised toDBNullthe same way (IndexOptimizer.cs:894). The lookup scans an empty bucket and, because the plan is exact, no post-filter runs.BTreeQueryProviderservesOrderBy(indexedField).Skip(s).Take(n)by readingskip + takeentries straight from the index (src/BLite.Core/Query/BTreeQueryProvider.cs:290-311), so null-keyed documents can never appear in the window; the replay variant (:315-325) has the same gap.DynamicCollectiondoes write the bucket (DynamicCollection.cs:2720,BsonType.Null => IndexKey.NullSentinel), so the same query on a schema-less collection is correct.
Affected versions: the null-skip at insert dates from fd5f9e8 (2026-03-06); the planner has answered == null from the null bucket since c2ba69a (#34, first in v4.1.3); the ordering fast path since f83e0d5 (v3.7.0). Reproduced on v5.2.3 / 0450b97.
Two ways out (your call)
- Typed indexes write
NullSentinel, asDynamicCollectiondoes. Side effects to decide: a typed unique index would then reject a second null (the opposite of what #176 documented); existing indexes need a rebuild to pick up null-keyed documents already stored;CollectionSecondaryIndex.Range(null, max)usesIndexKey.MinKeyas the open lower bound (CollectionSecondaryIndex.cs:448) and would include the null bucket inx.Prop < v/<= v, so the typed path needsDynamicCollection'sNullSentinelNext(DynamicCollection.cs:1065). Ordering then comes out right in both directions, sinceNullSentinelsorts before every encoded value. - The optimizer declines. No index plan for null equality (or a
Containslist holding null) on typed collections, falling back to the BSON predicate, which handlesBsonType.Nullalready (CreateNullCheckPredicate); andTryOptimizeOrderBydeclines when the key property is a reference type orNullable<T>, or the window is merged with the null-keyed documents found by a scan.
Either way the index-served answer and the scan answer must agree; today they do not.
- Dominant language
- C#
- Stars
- 66
- Forks
- 6
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 58
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from EntglDb/BLite
-
GdprMode.Strict warns that deleted documents are not zeroed, but every delete zero-fills themPossibly taken @CaffeinatedCoder claimed this today. Openbug gdpr package/core severity/low
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
EntglDb/BLite#299 · 1 comment ·
Maintainers usually reply within 2 days
-
bug package/core severity/medium triage
Difficulty 4/5 3-5 days Newbie friendliness 54/100
Maintainers usually reply within 2 days
-
bug package/core severity/medium
Difficulty 4/5 3-5 days Newbie friendliness 48/100
EntglDb/BLite#322 · 1 comment ·
Maintainers usually reply within 2 days
-
enhancement package/core
Difficulty 4/5 3-5 days Newbie friendliness 48/100
EntglDb/BLite#320 · 1 comment ·
Maintainers usually reply within 2 days
-
BLiteSession.Rollback() does not await the rollback: it returns while the transaction is still active, and failures go unobservedPossibly taken @CaffeinatedCoder claimed this today. Openbug package/core severity/medium
Difficulty 3/5 1-2 days Newbie friendliness 78/100
EntglDb/BLite#307 · 1 comment ·
Maintainers usually reply within 2 days
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
qew21/Genshin-Subtitles#45 ·
-
bug priority: P2
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
space-wizards/RobustToolbox#7158 ·
Maintainers usually reply within 1 day
-
:watch: Not Triaged dotnet-csharp/svc fundamentals/subsvc
Difficulty 1/5 Under an hour Newbie friendliness 88/100
Maintainers usually reply within 1 day
-
Wrong values of Kernel32.FileShare.Delete and Kernel32.FileAttributes.PosixSemanticsPossibly taken @ElectLive claimed this today. Openbug good first issue help wanted
Difficulty 1/5 Under an hour Newbie friendliness 83/100
ForNeVeR/TruePath#241 · 1 comment ·
Maintainers usually reply within 1 day