diff --git a/src/MongoDB.Driver/Linq/QueryableToAggregateFluentExtensions.cs b/src/MongoDB.Driver/Linq/QueryableToAggregateFluentExtensions.cs
new file mode 100644
index 00000000000..224f93e75a4
--- /dev/null
+++ b/src/MongoDB.Driver/Linq/QueryableToAggregateFluentExtensions.cs
@@ -0,0 +1,123 @@
+/* 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.Bson.Serialization.Serializers;
+using MongoDB.Driver.Core.Misc;
+using MongoDB.Driver.Linq.Linq3Implementation;
+using MongoDB.Driver.Linq.Linq3Implementation.Misc;
+using MongoDB.Driver.Linq.Linq3Implementation.Translators.ExpressionToExecutableQueryTranslators;
+
+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 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 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 over , or it was
+ /// built against a database rather than a collection.
+ ///
+ public static IAggregateFluent ToAggregateFluent(this IQueryable source)
+ {
+ Ensure.IsNotNull(source, nameof(source));
+
+ 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 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, not a database.",
+ nameof(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(
+ stages,
+ AdaptOutputSerializer(translatedPipeline.OutputSerializer));
+
+ return new CollectionAggregateFluent(
+ provider.Session,
+ provider.Collection,
+ pipeline,
+ provider.Options ?? new AggregateOptions());
+ }
+
+ // 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 outputType = outputSerializer.ValueType;
+
+ if (outputType == typeof(TResult))
+ {
+ return (IBsonSerializer)outputSerializer;
+ }
+
+ 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 (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
new file mode 100644
index 00000000000..9bfebaf2b95
--- /dev/null
+++ b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/QueryableToAggregateFluentTests.cs
@@ -0,0 +1,229 @@
+/* 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.Bson;
+using MongoDB.Bson.Serialization;
+using MongoDB.Driver.Linq;
+using Moq;
+using Xunit;
+
+namespace MongoDB.Driver.Tests.Linq.Linq3Implementation
+{
+ public class QueryableToAggregateFluentTests
+ {
+ [Fact]
+ public void ToAggregateFluent_should_produce_the_expected_stages()
+ {
+ 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 stages = Render(collection, queryable.ToAggregateFluent());
+
+ 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_inside_the_lookup()
+ {
+ var collection = CreateCollection();
+
+ var queryable = collection.AsQueryable()
+ .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 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));
+
+ // 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();
+
+ var stages = Render(collection, collection.AsQueryable().ToAggregateFluent());
+
+ stages.Should().BeEmpty();
+ }
+
+ [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());
+
+ 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 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; }
+ }
+
+ 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; }
+ }
+ }
+}