Summary
DocumentCollection<TId, T>.ScanAsync(BsonReaderPredicate, ...) (src/BLite.Core/Collections/DocumentCollection.cs, ~L889-947) is the fallback for every predicate the index optimizer cannot serve. It iterates _storage.GetCollectionPageIds(_collectionName) and applies the BSON predicate to every live slot of each data page, then materialises the matches with this collection's mapper. Data pages are shared between collections, so slots of other collections are evaluated too.
The comment there assumes the mapper throws on a foreign-collection document and the catch skips it ("silently skip it"). It never throws when the entity types share field names: the foreign document deserialises cleanly with the missing fields at their defaults, and the foreign document is yielded as a T.
Result: a query on collection A returns documents of collection B stored in the same file, whenever B has a field with the same name whose value passes the predicate.
Reproduction (BLite 5.1.0)
Same file, four collections whose entities share a base class (_id, LastModifiedAt, IsDeleted, RowVersion) and a Name field. Insert one Category, one Product, one MealService and one Vat all named "Pranzo". Then:
| Query |
Result |
MealServices.Where(e => e.Name == "Pranzo") (Name not indexed) |
3 rows, foreign documents returned |
MealServices.Where(e => e.Name.Contains("Pran")) |
3 rows |
Products.Where(e => e.Type == ProductType.Good) (Type not indexed) |
2 rows |
MealServices.CountAsync(e => e.Name == "Pranzo") |
3 (same defect in CountScanAsync) |
Vats.Where(e => e.Rate == 10) (indexed) |
ok |
Vats.Where(e => e.Rate == 10 && e.Description == "Pranzo") (one indexed conjunct) |
ok |
Products.Where(e => e.Name.ToLower().Contains("pran")) (not BSON-compilable, falls back to FindAllAsync) |
ok |
no Where |
ok |
So any predicate that is BSON-compilable, has no indexed conjunct, and names a field that also exists in another collection of the same file leaks.
Other paths with the same page-level iteration
ScanAsync<TResult>(projector) (~L1101)
CountScanAsync (~L1163)
ParallelScanAsync (~L1254)
TryBsonAggregate / BsonProjectionCompiler (e.g. MaxAsync(p => p.Plu) picks up a Plu field from another collection)
- Not application reads, but they walk the same page ids slot by slot and are worth a look:
EstimateCollectionSizeBytes (~L451), VacuumAsync (~L490), RebuildFreeSpaceIndex (~L692).
Expected
A scan must only consider the slots that belong to the collection. FindAllAsync already has the right source of truth: it walks the collection's primary index and reads the locations it yields. The scan fallbacks should restrict the evaluated slots to those locations (or the page format should carry the collection id per slot and the scan should filter on it).
Impact
Any consumer whose entities share field names across collections in one file (a common pattern with a base entity) can get foreign rows from unindexed queries, and can then write them back into the wrong collection.
Summary
DocumentCollection<TId, T>.ScanAsync(BsonReaderPredicate, ...)(src/BLite.Core/Collections/DocumentCollection.cs, ~L889-947) is the fallback for every predicate the index optimizer cannot serve. It iterates_storage.GetCollectionPageIds(_collectionName)and applies the BSON predicate to every live slot of each data page, then materialises the matches with this collection's mapper. Data pages are shared between collections, so slots of other collections are evaluated too.The comment there assumes the mapper throws on a foreign-collection document and the
catchskips it ("silently skip it"). It never throws when the entity types share field names: the foreign document deserialises cleanly with the missing fields at their defaults, and the foreign document is yielded as aT.Result: a query on collection A returns documents of collection B stored in the same file, whenever B has a field with the same name whose value passes the predicate.
Reproduction (BLite 5.1.0)
Same file, four collections whose entities share a base class (
_id,LastModifiedAt,IsDeleted,RowVersion) and aNamefield. Insert one Category, one Product, one MealService and one Vat all named"Pranzo". Then:MealServices.Where(e => e.Name == "Pranzo")(Name not indexed)MealServices.Where(e => e.Name.Contains("Pran"))Products.Where(e => e.Type == ProductType.Good)(Type not indexed)MealServices.CountAsync(e => e.Name == "Pranzo")CountScanAsync)Vats.Where(e => e.Rate == 10)(indexed)Vats.Where(e => e.Rate == 10 && e.Description == "Pranzo")(one indexed conjunct)Products.Where(e => e.Name.ToLower().Contains("pran"))(not BSON-compilable, falls back toFindAllAsync)WhereSo any predicate that is BSON-compilable, has no indexed conjunct, and names a field that also exists in another collection of the same file leaks.
Other paths with the same page-level iteration
ScanAsync<TResult>(projector)(~L1101)CountScanAsync(~L1163)ParallelScanAsync(~L1254)TryBsonAggregate/BsonProjectionCompiler(e.g.MaxAsync(p => p.Plu)picks up aPlufield from another collection)EstimateCollectionSizeBytes(~L451),VacuumAsync(~L490),RebuildFreeSpaceIndex(~L692).Expected
A scan must only consider the slots that belong to the collection.
FindAllAsyncalready has the right source of truth: it walks the collection's primary index and reads the locations it yields. The scan fallbacks should restrict the evaluated slots to those locations (or the page format should carry the collection id per slot and the scan should filter on it).Impact
Any consumer whose entities share field names across collections in one file (a common pattern with a base entity) can get foreign rows from unindexed queries, and can then write them back into the wrong collection.