From e6d6a586a2aba619fd08a34cff65fb283e67d34e Mon Sep 17 00:00:00 2001 From: ahmet-cetinkaya Date: Mon, 10 Aug 2026 10:18:01 +0300 Subject: [PATCH 1/2] Add ToAggregateFluent to bridge a LINQ query into an aggregate A LINQ query applies query filters and translates joins on its own, but its result is an IQueryable, so it cannot be handed to APIs that accept only an IAggregateFluent. Translate and optimize the query the same way executing it would, then wrap the resulting stages in a fluent over the source collection. --- .../QueryableToAggregateFluentExtensions.cs | 99 +++++++++++++++++++ .../QueryableToAggregateFluentTests.cs | 96 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 src/MongoDB.Driver/Linq/QueryableToAggregateFluentExtensions.cs create mode 100644 tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/QueryableToAggregateFluentTests.cs diff --git a/src/MongoDB.Driver/Linq/QueryableToAggregateFluentExtensions.cs b/src/MongoDB.Driver/Linq/QueryableToAggregateFluentExtensions.cs new file mode 100644 index 00000000000..b33355b097c --- /dev/null +++ b/src/MongoDB.Driver/Linq/QueryableToAggregateFluentExtensions.cs @@ -0,0 +1,99 @@ +/* Copyright 2010-present MongoDB Inc. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Linq; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Driver.Core.Misc; +using MongoDB.Driver.Linq.Linq3Implementation; +using MongoDB.Driver.Linq.Linq3Implementation.Ast.Optimizers; +using MongoDB.Driver.Linq.Linq3Implementation.Misc; +using MongoDB.Driver.Linq.Linq3Implementation.Translators; +using MongoDB.Driver.Linq.Linq3Implementation.Translators.ExpressionToPipelineTranslators; + +namespace MongoDB.Driver.Linq +{ + /// + /// Extension methods for converting a LINQ query into an . + /// + public static class QueryableToAggregateFluentExtensions + { + /// + /// Translates a LINQ query into the equivalent aggregation pipeline and returns it as an + /// over the collection the query was built from. + /// + /// + /// This lets a query be expressed in LINQ — where features such as query filters and joins + /// are applied automatically — and then handed to APIs that accept only an + /// . The pipeline is translated and optimized exactly + /// as it would be when the query is executed, so both forms send the same stages to the server. + /// The query is not executed by this method. + /// + /// The type of the documents in the source collection. + /// The type of the documents produced by the query. + /// The LINQ query. It must be a MongoDB queryable built against a collection. + /// An aggregate fluent whose pipeline is the translation of the query. + /// + /// The source is not a MongoDB queryable, or it was not built against a collection. + /// + public static IAggregateFluent ToAggregateFluent(this IQueryable source) + { + Ensure.IsNotNull(source, nameof(source)); + + if (source.Provider is not MongoQueryProvider provider) + { + throw new ArgumentException( + $"The source argument must be a MongoDB IQueryable against a collection of {typeof(TSource).Name}.", + nameof(source)); + } + + if (provider.Collection == null) + { + throw new ArgumentException( + "The source argument must be a MongoDB IQueryable against a collection.", + nameof(source)); + } + + var (stages, outputSerializer) = TranslateToStages(provider, source); + + PipelineDefinition pipeline = new BsonDocumentStagePipelineDefinition( + stages, + outputSerializer as IBsonSerializer); + + return new CollectionAggregateFluent( + provider.Session, + provider.Collection, + pipeline, + provider.Options ?? new AggregateOptions()); + } + + private static (BsonDocument[] Stages, IBsonSerializer OutputSerializer) TranslateToStages( + MongoQueryProvider provider, + IQueryable source) + { + var translationOptions = provider.GetTranslationOptions(); + var expression = LinqExpressionPreprocessor.Preprocess(source.Expression); + + var context = TranslationContext.Create(translationOptions); + var translatedPipeline = ExpressionToPipelineTranslator.Translate(context, expression); + var optimizedAst = AstPipelineOptimizer.Optimize(translatedPipeline.Ast); + + var stages = optimizedAst.Render().AsBsonArray.Cast().ToArray(); + + return (stages, translatedPipeline.OutputSerializer); + } + } +} diff --git a/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/QueryableToAggregateFluentTests.cs b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/QueryableToAggregateFluentTests.cs new file mode 100644 index 00000000000..d82221776ed --- /dev/null +++ b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/QueryableToAggregateFluentTests.cs @@ -0,0 +1,96 @@ +/* Copyright 2010-present MongoDB Inc. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Linq; +using FluentAssertions; +using MongoDB.Driver.Linq; +using Xunit; + +namespace MongoDB.Driver.Tests.Linq.Linq3Implementation +{ + public class QueryableToAggregateFluentTests : Linq3IntegrationTest + { + [Fact] + public void ToAggregateFluent_should_produce_the_same_stages_as_the_queryable() + { + var collection = GetCollection(); + + var queryable = collection.AsQueryable() + .Where(product => product.Price > 10) + .OrderBy(product => product.Name) + .Select(product => new ProductView { Name = product.Name, Price = product.Price }); + + var expectedStages = Linq3TestHelpers.Translate(collection, queryable); + + var aggregate = queryable.ToAggregateFluent(); + var actualStages = Linq3TestHelpers.Translate(collection, aggregate); + + actualStages.Should().Equal(expectedStages); + } + + [Fact] + public void ToAggregateFluent_should_keep_a_filtered_inner_join_correlated() + { + var collection = GetCollection(); + + var queryable = collection.AsQueryable() + .Where(product => product.Price > 10) + .GroupJoin( + collection.AsQueryable().Where(related => related.Price > 10), + product => product.ParentId, + related => (int?)related.Id, + (product, parents) => new ProductView + { + Name = product.Name, + Price = product.Price + }); + + var expectedStages = Linq3TestHelpers.Translate(collection, queryable); + + var aggregate = queryable.ToAggregateFluent(); + var actualStages = Linq3TestHelpers.Translate(collection, aggregate); + + actualStages.Should().Equal(expectedStages); + + var lookup = actualStages.Single(stage => stage.Contains("$lookup"))["$lookup"].AsBsonDocument; + lookup.Contains("pipeline").Should().BeTrue(); + } + + [Fact] + public void ToAggregateFluent_should_throw_when_the_source_is_not_a_mongodb_queryable() + { + var queryable = new[] { new Product() }.AsQueryable(); + + var exception = Record.Exception(() => queryable.ToAggregateFluent()); + + exception.Should().BeOfType(); + } + + private class Product + { + public int Id { get; set; } + public int? ParentId { get; set; } + public string Name { get; set; } + public decimal Price { get; set; } + } + + private class ProductView + { + public string Name { get; set; } + public decimal Price { get; set; } + } + } +} From ba0cfad669c5f79c0b1052ba5dcca252444e7dd7 Mon Sep 17 00:00:00 2001 From: ahmet-cetinkaya Date: Mon, 10 Aug 2026 12:36:50 +0300 Subject: [PATCH 2/2] Translate through the driver's own path instead of repeating it The first version re-derived the preprocess/translate/optimize sequence by hand. That duplicated ExpressionToExecutableQueryTranslator, so an upstream change to the driver's translation path would leave this producing different stages while still compiling. Call that translator instead, and adapt the pipeline's output serializer the way ExecutableQuery does rather than letting a failed cast fall back to the registry default, which would read results from the wrong elements. The stage assertions compared the method against another call into the same translator, so they held no matter what the pipeline looked like; they now assert concrete stages. A grouping query pins the optimizer, whose absence the old tests could not detect. The tests no longer need a running server. --- .../QueryableToAggregateFluentExtensions.cs | 76 +++++--- .../QueryableToAggregateFluentTests.cs | 173 ++++++++++++++++-- 2 files changed, 203 insertions(+), 46 deletions(-) diff --git a/src/MongoDB.Driver/Linq/QueryableToAggregateFluentExtensions.cs b/src/MongoDB.Driver/Linq/QueryableToAggregateFluentExtensions.cs index b33355b097c..224f93e75a4 100644 --- a/src/MongoDB.Driver/Linq/QueryableToAggregateFluentExtensions.cs +++ b/src/MongoDB.Driver/Linq/QueryableToAggregateFluentExtensions.cs @@ -17,12 +17,11 @@ using System.Linq; using MongoDB.Bson; using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; using MongoDB.Driver.Core.Misc; using MongoDB.Driver.Linq.Linq3Implementation; -using MongoDB.Driver.Linq.Linq3Implementation.Ast.Optimizers; using MongoDB.Driver.Linq.Linq3Implementation.Misc; -using MongoDB.Driver.Linq.Linq3Implementation.Translators; -using MongoDB.Driver.Linq.Linq3Implementation.Translators.ExpressionToPipelineTranslators; +using MongoDB.Driver.Linq.Linq3Implementation.Translators.ExpressionToExecutableQueryTranslators; namespace MongoDB.Driver.Linq { @@ -38,62 +37,87 @@ public static class QueryableToAggregateFluentExtensions /// /// This lets a query be expressed in LINQ — where features such as query filters and joins /// are applied automatically — and then handed to APIs that accept only an - /// . The pipeline is translated and optimized exactly - /// as it would be when the query is executed, so both forms send the same stages to the server. - /// The query is not executed by this method. + /// . The query is translated through the driver's own + /// translation path, the same one executing it would take, so both forms send identical + /// stages to the server. The query is not executed by this method. /// - /// The type of the documents in the source collection. + /// The type of the documents in the source collection. /// The type of the documents produced by the query. /// The LINQ query. It must be a MongoDB queryable built against a collection. /// An aggregate fluent whose pipeline is the translation of the query. /// - /// The source is not a MongoDB queryable, or it was not built against a collection. + /// The source is not a MongoDB queryable over , or it was + /// built against a database rather than a collection. /// - public static IAggregateFluent ToAggregateFluent(this IQueryable source) + public static IAggregateFluent ToAggregateFluent(this IQueryable source) { Ensure.IsNotNull(source, nameof(source)); - if (source.Provider is not MongoQueryProvider provider) + if (source.Provider is not MongoQueryProvider provider) { + var actual = source.Provider is MongoQueryProvider + ? "a MongoDB IQueryable over a different document type" + : "not a MongoDB IQueryable"; + throw new ArgumentException( - $"The source argument must be a MongoDB IQueryable against a collection of {typeof(TSource).Name}.", + $"The source argument must be a MongoDB IQueryable over {typeof(TDocument)}, but it is {actual}. " + + $"The first type argument must be the document type of the collection the query was built from.", nameof(source)); } if (provider.Collection == null) { throw new ArgumentException( - "The source argument must be a MongoDB IQueryable against a collection.", + "The source argument must be a MongoDB IQueryable against a collection, not a database.", nameof(source)); } - var (stages, outputSerializer) = TranslateToStages(provider, source); + // Reuse the driver's own translation entry point rather than re-deriving the + // preprocess/translate/optimize sequence, so these stages cannot drift from the ones + // executing the query would send. + var executableQuery = ExpressionToExecutableQueryTranslator.Translate( + provider, + source.Expression, + provider.GetTranslationOptions()); + + var translatedPipeline = executableQuery.Pipeline; + var stages = translatedPipeline.Ast.Render().AsBsonArray.Cast().ToArray(); - PipelineDefinition pipeline = new BsonDocumentStagePipelineDefinition( + PipelineDefinition pipeline = new BsonDocumentStagePipelineDefinition( stages, - outputSerializer as IBsonSerializer); + AdaptOutputSerializer(translatedPipeline.OutputSerializer)); - return new CollectionAggregateFluent( + return new CollectionAggregateFluent( provider.Session, provider.Collection, pipeline, provider.Options ?? new AggregateOptions()); } - private static (BsonDocument[] Stages, IBsonSerializer OutputSerializer) TranslateToStages( - MongoQueryProvider provider, - IQueryable source) + // Mirrors ExecutableQuery.GetOutputSerializer. The translator's serializer describes the + // shape the rendered stages actually produce, so letting it fall back to the registry + // default would read results back from the wrong elements. + private static IBsonSerializer AdaptOutputSerializer(IBsonSerializer outputSerializer) { - var translationOptions = provider.GetTranslationOptions(); - var expression = LinqExpressionPreprocessor.Preprocess(source.Expression); + var outputType = outputSerializer.ValueType; - var context = TranslationContext.Create(translationOptions); - var translatedPipeline = ExpressionToPipelineTranslator.Translate(context, expression); - var optimizedAst = AstPipelineOptimizer.Optimize(translatedPipeline.Ast); + if (outputType == typeof(TResult)) + { + return (IBsonSerializer)outputSerializer; + } - var stages = optimizedAst.Render().AsBsonArray.Cast().ToArray(); + if (!typeof(TResult).IsAssignableFrom(outputType)) + { + throw new NotSupportedException( + $"The type of the pipeline output is {outputType} which is not assignable to {typeof(TResult)}."); + } + + if (typeof(TResult).IsNullableOf(outputType)) + { + return (IBsonSerializer)NullableSerializer.Create(outputSerializer); + } - return (stages, translatedPipeline.OutputSerializer); + return (IBsonSerializer)DowncastingSerializer.Create(typeof(TResult), outputType, outputSerializer); } } } diff --git a/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/QueryableToAggregateFluentTests.cs b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/QueryableToAggregateFluentTests.cs index d82221776ed..9bfebaf2b95 100644 --- a/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/QueryableToAggregateFluentTests.cs +++ b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/QueryableToAggregateFluentTests.cs @@ -16,38 +16,44 @@ using System; using System.Linq; using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; using MongoDB.Driver.Linq; +using Moq; using Xunit; namespace MongoDB.Driver.Tests.Linq.Linq3Implementation { - public class QueryableToAggregateFluentTests : Linq3IntegrationTest + public class QueryableToAggregateFluentTests { [Fact] - public void ToAggregateFluent_should_produce_the_same_stages_as_the_queryable() + public void ToAggregateFluent_should_produce_the_expected_stages() { - var collection = GetCollection(); + var collection = CreateCollection(); var queryable = collection.AsQueryable() .Where(product => product.Price > 10) .OrderBy(product => product.Name) .Select(product => new ProductView { Name = product.Name, Price = product.Price }); - var expectedStages = Linq3TestHelpers.Translate(collection, queryable); + var stages = Render(collection, queryable.ToAggregateFluent()); - var aggregate = queryable.ToAggregateFluent(); - var actualStages = Linq3TestHelpers.Translate(collection, aggregate); - - actualStages.Should().Equal(expectedStages); + Linq3TestHelpers.AssertStages( + stages, + new[] + { + "{ $match : { Price : { $gt : NumberDecimal('10') } } }", + "{ $sort : { Name : 1 } }", + "{ $project : { Name : '$Name', Price : '$Price', _id : 0 } }" + }); } [Fact] - public void ToAggregateFluent_should_keep_a_filtered_inner_join_correlated() + public void ToAggregateFluent_should_keep_a_filtered_inner_join_inside_the_lookup() { - var collection = GetCollection(); + var collection = CreateCollection(); var queryable = collection.AsQueryable() - .Where(product => product.Price > 10) .GroupJoin( collection.AsQueryable().Where(related => related.Price > 10), product => product.ParentId, @@ -58,15 +64,76 @@ public void ToAggregateFluent_should_keep_a_filtered_inner_join_correlated() Price = product.Price }); - var expectedStages = Linq3TestHelpers.Translate(collection, queryable); + var stages = Render(collection, queryable.ToAggregateFluent()); + + var lookup = stages.Single(stage => stage.Contains("$lookup"))["$lookup"].AsBsonDocument; + var innerPipeline = lookup["pipeline"].AsBsonArray.Select(stage => stage.AsBsonDocument).ToList(); + + innerPipeline.Should().ContainSingle(stage => + stage.Contains("$match") && stage["$match"].ToString().Contains("Price")); + } + + [Fact] + public void ToAggregateFluent_should_apply_the_pipeline_optimizer() + { + var collection = CreateCollection(); + + var queryable = collection.AsQueryable() + .GroupBy(product => product.Category) + .Select(group => new GroupView { Category = group.Key, Total = group.Sum(product => product.Price) }); + + var stages = Render(collection, queryable.ToAggregateFluent()); + + // Unoptimized, a grouping pushes every document into the group and sums client-side + // shapes afterwards. Only the pipeline optimizer rewrites it into a server-side $sum, + // so these stages fail if the optimization step is ever skipped. + Linq3TestHelpers.AssertStages( + stages, + new[] + { + "{ $group : { _id : '$Category', __agg0 : { $sum : '$Price' } } }", + "{ $project : { Category : '$_id', Total : '$__agg0', _id : 0 } }" + }); + } + + [Fact] + public void ToAggregateFluent_should_carry_the_output_serializer_of_the_translated_pipeline() + { + var collection = CreateCollection(); + + var queryable = collection.AsQueryable().Select(product => product.Price); + + var aggregate = queryable.ToAggregateFluent(); + + var renderedPipeline = ((AggregateFluent)aggregate).Pipeline.Render( + new(collection.DocumentSerializer, BsonSerializer.SerializerRegistry)); - var aggregate = queryable.ToAggregateFluent(); - var actualStages = Linq3TestHelpers.Translate(collection, aggregate); + // A scalar projection is wrapped by the translator, so the registry's default decimal + // serializer would read the result back from the wrong element. + renderedPipeline.OutputSerializer.Should().NotBeSameAs( + BsonSerializer.SerializerRegistry.GetSerializer()); + } + + [Fact] + public void ToAggregateFluent_should_carry_the_aggregate_options_of_the_provider() + { + var collection = CreateCollection(); + var options = new AggregateOptions { AllowDiskUse = true, MaxTime = TimeSpan.FromSeconds(42) }; + + var aggregate = collection.AsQueryable(options).ToAggregateFluent(); + + aggregate.Options.AllowDiskUse.Should().BeTrue(); + aggregate.Options.MaxTime.Should().Be(TimeSpan.FromSeconds(42)); + } + + [Fact] + public void ToAggregateFluent_should_produce_an_empty_pipeline_for_an_unmodified_queryable() + { + var collection = CreateCollection(); - actualStages.Should().Equal(expectedStages); + var stages = Render(collection, collection.AsQueryable().ToAggregateFluent()); - var lookup = actualStages.Single(stage => stage.Contains("$lookup"))["$lookup"].AsBsonDocument; - lookup.Contains("pipeline").Should().BeTrue(); + stages.Should().BeEmpty(); } [Fact] @@ -76,21 +143,87 @@ public void ToAggregateFluent_should_throw_when_the_source_is_not_a_mongodb_quer var exception = Record.Exception(() => queryable.ToAggregateFluent()); - exception.Should().BeOfType(); + var argumentException = exception.Should().BeOfType().Subject; + argumentException.ParamName.Should().Be("source"); + argumentException.Message.Should().Contain("MongoDB IQueryable"); + } + + [Fact] + public void ToAggregateFluent_should_throw_when_the_source_is_built_against_a_database() + { + var database = CreateDatabase(); + + var queryable = database.AsQueryable().Documents(new Product()); + + var exception = Record.Exception(() => queryable.ToAggregateFluent()); + + var argumentException = exception.Should().BeOfType().Subject; + argumentException.ParamName.Should().Be("source"); + argumentException.Message.Should().Contain("not a database"); + } + + [Fact] + public void ToAggregateFluent_should_throw_when_the_source_is_null() + { + IQueryable source = null; + + var exception = Record.Exception(() => source.ToAggregateFluent()); + + exception.Should().BeOfType(); + } + + private static IMongoCollection CreateCollection() + { + var database = CreateDatabase(); + var settings = new MongoCollectionSettings(); + var mockCollection = new Mock>(); + mockCollection.SetupGet(collection => collection.CollectionNamespace) + .Returns(new CollectionNamespace(database.DatabaseNamespace, "products")); + mockCollection.SetupGet(collection => collection.Database).Returns(database); + mockCollection.SetupGet(collection => collection.DocumentSerializer) + .Returns(BsonSerializer.SerializerRegistry.GetSerializer()); + mockCollection.SetupGet(collection => collection.Settings).Returns(settings); + return mockCollection.Object; + } + + private static IMongoDatabase CreateDatabase() + { + var client = new Mock(); + client.SetupGet(c => c.Settings).Returns(new MongoClientSettings()); + + var mockDatabase = new Mock(); + mockDatabase.SetupGet(database => database.Client).Returns(client.Object); + mockDatabase.SetupGet(database => database.DatabaseNamespace).Returns(new DatabaseNamespace("test")); + mockDatabase.SetupGet(database => database.Settings).Returns(new MongoDatabaseSettings()); + return mockDatabase.Object; } - private class Product + private static System.Collections.Generic.List Render( + IMongoCollection collection, + IAggregateFluent aggregate) + { + return Linq3TestHelpers.Translate(collection, aggregate); + } + + public class Product { public int Id { get; set; } public int? ParentId { get; set; } public string Name { get; set; } public decimal Price { get; set; } + public string Category { get; set; } } - private class ProductView + public class ProductView { public string Name { get; set; } public decimal Price { get; set; } } + + public class GroupView + { + public string Category { get; set; } + public decimal Total { get; set; } + } } }