Summary
Queryable.Concat() throws a NullReferenceException during expression translation (reproducible by just calling .ToString() on the composed queryable, before any server round-trip) whenever the second argument is a queryable with zero LINQ stages applied - e.g. otherCollection.AsQueryable() passed directly, with no .Where()/.Select()/etc. applied. This is arguably the most common way to call Concat between two collections. Queryable.Union() is not affected and correctly produces a single pipeline (with the extra $group/$replaceRoot dedup stages Union requires).
MongoDB.Driver 3.8.1 and 3.9.0, .NET 10. No MongoDB server connection is required to reproduce - the crash happens entirely client-side during LINQ expression translation.
How to Reproduce
Minimal repro (no live MongoDB server connection is required - the crash happens during client-side expression translation):
using MongoDB.Driver; var client = new MongoClient("mongodb://localhost:27017"); var db = client.GetDatabase("test"); IQueryable<BsonDocument> a = db.GetCollection<BsonDocument>("a").AsQueryable(); IQueryable<BsonDocument> b = db.GetCollection<BsonDocument>("b").AsQueryable(); var union = a.Concat(b); Console.WriteLine(union.ToString()); // throws NullReferenceException
Expected result: a single aggregation pipeline with a { $unionWith: "b" } stage (the short form, since the second collection has no additional pipeline stages) - analogous to what Queryable.Union() already produces correctly today.
Actual result:
System.NullReferenceException: Object reference not set to an instance of an object. at MongoDB.Driver.Linq.Linq3Implementation.Translators.ExpressionToPipelineTranslators.ConcatMethodToPipelineTranslator.Translate(TranslationContext context, MethodCallExpression expression) at MongoDB.Driver.Linq.Linq3Implementation.Translators.ExpressionToPipelineTranslators.ExpressionToPipelineTranslator.Translate(TranslationContext context, Expression expression) at MongoDB.Driver.Linq.Linq3Implementation.Translators.ExpressionToExecutableQueryTranslators.ExpressionToExecutableQueryTranslator.Translate[TDocument,TOutput](MongoQueryProvider`1 provider, Expression expression, ExpressionTranslationOptions translationOptions) at MongoDB.Driver.Linq.Linq3Implementation.MongoQuery`2.ToString()
Additional Background (root cause analysis):
In ConcatMethodToPipelineTranslator.cs:
var secondPipeline = ExpressionToPipelineTranslator.Translate(secondContext, secondQueryable.Expression); if (secondPipeline.Ast.Stages.Count == 0) { secondPipeline = null; } pipeline = pipeline.AddStage( AstStage.UnionWith(secondCollectionName, secondPipeline.Ast), // NRE: secondPipeline is null here pipeline.OutputSerializer); AstUnionWithStage is explicitly designed to accept a null pipeline (see AstUnionWithStage.RenderWith(), which branches on _pipeline == null to render the short { $unionWith: "collName" } form instead of { $unionWith: { coll, pipeline } }). So nulling out secondPipeline when it has zero stages is intentional - the bug is that the call site then dereferences it unconditionally instead of using secondPipeline?.Ast.
This was introduced by commit a3d3d4f5933acb52b84a37b391f7edcff87fd8a ("CSHARP-5448: Refactor AstFilterField and AstPipeline to remove reference to serializers", 2024-12-19). Before that commit, Translate returned the raw AstPipeline and the (possibly-null) secondPipeline was passed directly to UnionWith(...) - safe. The refactor changed the return type to a TranslatedPipeline wrapper and mechanically updated both secondPipeline.Stages.Count -> secondPipeline.Ast.Stages.Count (correct) and AstStage.UnionWith(secondCollectionName, secondPipeline) -> AstStage.UnionWith(secondCollectionName, secondPipeline.Ast) (should have been secondPipeline?.Ast).
Suggested fix:
- AstStage.UnionWith(secondCollectionName, secondPipeline.Ast),
- + AstStage.UnionWith(secondCollectionName, secondPipeline?.Ast),
Workaround: Queryable.Union() is not affected and produces a correct (if slightly heavier, due to the $group/$replaceRoot dedup stages) single pipeline.