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; } + } + } +}