From 5422ab2d8d6c87c2ff38eb29afe6827cf90d96bf Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Tue, 31 Mar 2026 10:01:25 +0100 Subject: [PATCH 1/4] CSHARP-5769: Implement hasAncestor, hasRoot, and returnScope for Atlas Search Add hasAncestor and hasRoot search operators for use within embeddedDocument queries, allowing searches to reference fields at ancestor or root document levels. Add returnScope support to $search and $searchMeta pipeline stages, enabling results to be returned from a nested embedded document scope rather than the root document. Add Clone() to SearchOptions and its nested options classes to avoid mutating caller-supplied options when returnScope is set. --- Agents.md | 47 +++ src/MongoDB.Driver/AggregateFluent.cs | 13 + src/MongoDB.Driver/AggregateFluentBase.cs | 50 ++- src/MongoDB.Driver/IAggregateFluent.cs | 57 +++ .../PipelineDefinitionBuilder.cs | 43 ++ .../PipelineStageDefinitionBuilder.cs | 58 ++- .../Search/OperatorSearchDefinitions.cs | 46 ++ .../Search/SearchCountOptions.cs | 6 + src/MongoDB.Driver/Search/SearchDefinition.cs | 2 + .../Search/SearchDefinitionBuilder.cs | 44 ++ .../Search/SearchHighlightOptions.cs | 7 + src/MongoDB.Driver/Search/SearchOptions.cs | 19 + .../Search/SearchTrackingOptions.cs | 6 + .../PipelineStageDefinitionBuilderTests.cs | 13 + .../Search/AtlasSearchTests.cs | 398 +++++++++++++++++- .../Search/AtlasSearchTestsUtils.cs | 9 +- .../Search/AutoEmbedVectorSearchTests.cs | 2 +- .../Search/SearchDefinitionBuilderTests.cs | 72 ++++ .../Search/VectorSearchTests.cs | 2 +- 19 files changed, 875 insertions(+), 19 deletions(-) create mode 100644 Agents.md diff --git a/Agents.md b/Agents.md new file mode 100644 index 00000000000..607401164cb --- /dev/null +++ b/Agents.md @@ -0,0 +1,47 @@ +# Agents.md - CSharpDriver + +## Overview +The C# driver for MongoDB. + +## Tech Stack +- .NET library projects producing NuGet packages +- Multi-targeted from .NET Framework 4.7.2 through .NET 10 +- xUnit + FluentAssertions for testing + +## Project Structure +- `src/MongoDB.Bson/` - BSON for MongoDB +- `src/MongoDB.Driver/` - C# driver for MongoDB +- `MongoDB.Driver.Encryption` - Client encryption (CSFLE with KMS). +- `MongoDB.Driver.Authentication.AWS` - AWS IAM authentication +- `tests/MongoDB.Driver.Tests/` - Main C# driver tests +- `tests/MongoDB.Bson.Tests/` - BSON handling tests +- `tests/*/TestHelpers` - Common test utilities +- `tests/*` - Specialized tests; less common +- `tests/MongoDB.Driver.Tests/Specifications/` are JSON-driven tests using a common runner. + +## Commands +- Build: `dotnet build CSharpDriver.sln` +- Run all tests: `dotnet test tests/MongoDB.Driver.Tests/MongoDB.Driver.Tests.csproj -f net10.0` +- Run a single test class: `dotnet test tests/MongoDB.Driver.Tests/MongoDB.Driver.Tests.csproj -f net10.0 --filter "FullyQualifiedName~ClassName"` + +A MongoDB connection is always available locally, so "integration" tests can be run as well as unit tests. Some test suites also require additional environment variables — if you need to run those tests and the variables are not set, stop and tell the user which variables are needed rather than working around it. + +| Feature area | Required environment variables | +|---|---| +| Atlas Search | `ATLAS_SEARCH_TESTS_ENABLED`, `ATLAS_SEARCH_URI` | +| Atlas Search index helpers | `ATLAS_SEARCH_INDEX_HELPERS_TESTS_ENABLED`, `ATLAS_SEARCH_URI` | +| CSFLE / auto-encryption | `CRYPT_SHARED_LIB_PATH` | +| CSFLE with KMS mock servers | `KMS_MOCK_SERVERS_ENABLED` | +| CSFLE with AWS KMS | `CSFLE_AWS_TEMPORARY_CREDS_ENABLED` | +| CSFLE with Azure KMS | `CSFLE_AZURE_KMS_TESTS_ENABLED` | +| CSFLE with GCP KMS | `CSFLE_GCP_KMS_TESTS_ENABLED` | +| AWS authentication | `AWS_TESTS_ENABLED` | +| GSSAPI / Kerberos | `GSSAPI_TESTS_ENABLED`, `AUTH_HOST`, `AUTH_GSSAPI` | +| OIDC authentication | `OIDC_ENV` | +| X.509 authentication | `MONGO_X509_CLIENT_CERTIFICATE_PATH`, `MONGO_X509_CLIENT_CERTIFICATE_PASSWORD` | +| PLAIN authentication | `PLAIN_AUTH_TESTS_ENABLED` | +| SOCKS5 proxy | `SOCKS5_PROXY_SERVERS_ENABLED` | + +## Commit and PR Conventions + +- Commit and PR messages start with a JIRA number: `CSHARP-1234: Description` diff --git a/src/MongoDB.Driver/AggregateFluent.cs b/src/MongoDB.Driver/AggregateFluent.cs index 90bd9823ce1..28cc6481432 100644 --- a/src/MongoDB.Driver/AggregateFluent.cs +++ b/src/MongoDB.Driver/AggregateFluent.cs @@ -299,6 +299,12 @@ public override IAggregateFluent Search( return WithPipeline(_pipeline.Search(searchDefinition, searchOptions)); } + public override IAggregateFluent Search( + SearchDefinition searchDefinition, + FieldDefinition> returnScope, + SearchOptions searchOptions = null) + => WithPipeline(_pipeline.Search(searchDefinition, returnScope, searchOptions)); + public override IAggregateFluent SearchMeta( SearchDefinition searchDefinition, string indexName = null, @@ -307,6 +313,13 @@ public override IAggregateFluent SearchMeta( return WithPipeline(_pipeline.SearchMeta(searchDefinition, indexName, count)); } + public override IAggregateFluent SearchMeta( + SearchDefinition searchDefinition, + FieldDefinition returnScope, + string indexName = null, + SearchCountOptions count = null) + => WithPipeline(_pipeline.SearchMeta(searchDefinition, returnScope, indexName, count)); + public override IAggregateFluent Set(SetFieldDefinitions fields) { return WithPipeline(_pipeline.Set(fields)); diff --git a/src/MongoDB.Driver/AggregateFluentBase.cs b/src/MongoDB.Driver/AggregateFluentBase.cs index 51381f930bd..e0f3bc2a173 100644 --- a/src/MongoDB.Driver/AggregateFluentBase.cs +++ b/src/MongoDB.Driver/AggregateFluentBase.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; +using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using MongoDB.Bson; @@ -248,26 +249,57 @@ public virtual IAggregateFluent Search( SearchCountOptions count = null, bool returnStoredSource = false, bool scoreDetails = false) - { - throw new NotImplementedException(); - } + => throw new NotImplementedException(); /// public virtual IAggregateFluent Search( SearchDefinition searchDefinition, SearchOptions searchOptions) - { - throw new NotImplementedException(); - } + => throw new NotImplementedException(); + + /// + public virtual IAggregateFluent Search( + SearchDefinition searchDefinition, + FieldDefinition> returnScope, + SearchOptions searchOptions = null) + => throw new NotImplementedException(); + + /// + public virtual IAggregateFluent Search( + SearchDefinition searchDefinition, + Expression>> returnScope, + SearchOptions searchOptions = null) + => Search( + searchDefinition, + new ExpressionFieldDefinition>(returnScope), + searchOptions); /// public virtual IAggregateFluent SearchMeta( SearchDefinition searchDefinition, string indexName = null, SearchCountOptions count = null) - { - throw new NotImplementedException(); - } + => throw new NotImplementedException(); + + /// + public virtual IAggregateFluent SearchMeta( + SearchDefinition searchDefinition, + FieldDefinition returnScope, + string indexName = null, + SearchCountOptions count = null) + => throw new NotImplementedException(); + + /// + public virtual IAggregateFluent SearchMeta( + SearchDefinition searchDefinition, + Expression> returnScope, + string indexName = null, + SearchCountOptions count = null) + => SearchMeta( + searchDefinition, + new ExpressionFieldDefinition(returnScope), + indexName, + count); /// public virtual IAggregateFluent Set(SetFieldDefinitions fields) => throw new NotImplementedException(); diff --git a/src/MongoDB.Driver/IAggregateFluent.cs b/src/MongoDB.Driver/IAggregateFluent.cs index e8d825c66e6..5301a934c9b 100644 --- a/src/MongoDB.Driver/IAggregateFluent.cs +++ b/src/MongoDB.Driver/IAggregateFluent.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; +using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using MongoDB.Bson; @@ -440,15 +441,71 @@ IAggregateFluent Search( SearchDefinition searchDefinition, SearchOptions searchOptions); + /// + /// Appends a $search stage to the pipeline, returning documents from a nested scope. + /// + /// The search definition. + /// The level of nested documents to return. + /// The search options. + /// + /// The fluent aggregate interface. + /// + IAggregateFluent Search( + SearchDefinition searchDefinition, + FieldDefinition> returnScope, + SearchOptions searchOptions = null); + + /// + /// Appends a $search stage to the pipeline, returning documents from a nested scope. + /// + /// The search definition. + /// The level of nested documents to return. + /// The search options. + /// + /// The fluent aggregate interface. + /// + IAggregateFluent Search( + SearchDefinition searchDefinition, + Expression>> returnScope, + SearchOptions searchOptions = null); + + /// + /// Appends a $searchMeta stage to the pipeline. + /// + /// The search definition. + /// The index name. + /// The count options. + /// The fluent aggregate interface. + IAggregateFluent SearchMeta( + SearchDefinition searchDefinition, + string indexName = null, + SearchCountOptions count = null); + + /// + /// Appends a $searchMeta stage to the pipeline. + /// + /// The search definition. + /// The level of nested documents to return. + /// The index name. + /// The count options. + /// The fluent aggregate interface. + IAggregateFluent SearchMeta( + SearchDefinition searchDefinition, + FieldDefinition returnScope, + string indexName = null, + SearchCountOptions count = null); + /// /// Appends a $searchMeta stage to the pipeline. /// /// The search definition. + /// The level of nested documents to return. /// The index name. /// The count options. /// The fluent aggregate interface. IAggregateFluent SearchMeta( SearchDefinition searchDefinition, + Expression> returnScope, string indexName = null, SearchCountOptions count = null); diff --git a/src/MongoDB.Driver/PipelineDefinitionBuilder.cs b/src/MongoDB.Driver/PipelineDefinitionBuilder.cs index 3ad6eca971a..c086535fb18 100644 --- a/src/MongoDB.Driver/PipelineDefinitionBuilder.cs +++ b/src/MongoDB.Driver/PipelineDefinitionBuilder.cs @@ -1217,6 +1217,27 @@ public static PipelineDefinition Search( return pipeline.AppendStage(PipelineStageDefinitionBuilder.Search(searchDefinition, searchOptions)); } + /// + /// Appends a $search stage to the pipeline. + /// + /// The type of the input documents. + /// The type of the intermediate documents. + /// The type of the output documents. + /// The pipeline. + /// The search definition. + /// The level of nested documents to return. + /// The search options. + /// A new pipeline with an additional stage. + public static PipelineDefinition Search( + this PipelineDefinition pipeline, + SearchDefinition searchDefinition, + FieldDefinition> returnScope, + SearchOptions searchOptions) + { + Ensure.IsNotNull(pipeline, nameof(pipeline)); + return pipeline.AppendStage(PipelineStageDefinitionBuilder.Search(searchDefinition, returnScope, searchOptions)); + } + /// /// Appends a $searchMeta stage to the pipeline. /// @@ -1237,6 +1258,28 @@ public static PipelineDefinition SearchMeta + /// Appends a $searchMeta stage to the pipeline. + /// + /// The type of the input documents. + /// The type of the output documents. + /// The pipeline. + /// The search definition. + /// The level of nested documents to return. + /// The index name. + /// The count options. + /// A new pipeline with an additional stage. + public static PipelineDefinition SearchMeta( + this PipelineDefinition pipeline, + SearchDefinition query, + FieldDefinition returnScope, + string indexName = null, + SearchCountOptions count = null) + { + Ensure.IsNotNull(pipeline, nameof(pipeline)); + return pipeline.AppendStage(PipelineStageDefinitionBuilder.SearchMeta(query, returnScope, indexName, count)); + } + /// /// Appends a $set stage to the pipeline. /// diff --git a/src/MongoDB.Driver/PipelineStageDefinitionBuilder.cs b/src/MongoDB.Driver/PipelineStageDefinitionBuilder.cs index 3e43bb5406e..5830d086a92 100644 --- a/src/MongoDB.Driver/PipelineStageDefinitionBuilder.cs +++ b/src/MongoDB.Driver/PipelineStageDefinitionBuilder.cs @@ -1437,16 +1437,53 @@ public static PipelineStageDefinition Search( public static PipelineStageDefinition Search( SearchDefinition searchDefinition, SearchOptions searchOptions) + => Search(searchDefinition, returnScope: null, searchOptions); + + /// + /// Creates a $search stage. + /// + /// The type of the input documents. + /// The type of the output documents. + /// The search definition. + /// The level of nested documents to return. + /// The search options. + /// The stage. + public static PipelineStageDefinition Search( + SearchDefinition searchDefinition, + FieldDefinition> returnScope, + SearchOptions searchOptions) { Ensure.IsNotNull(searchDefinition, nameof(searchDefinition)); + searchOptions ??= new SearchOptions(); + const string operatorName = "$search"; - var stage = new DelegatedPipelineStageDefinition( + var stage = new DelegatedPipelineStageDefinition( operatorName, args => { ClientSideProjectionHelper.ThrowIfClientSideProjection(args.DocumentSerializer, operatorName); var renderedSearchDefinition = searchDefinition.Render(args); + + IBsonSerializer outputSerializer; + if (returnScope == null) + { + if (typeof(TOutput) != typeof(TInput)) + { + throw new InvalidOperationException( + $"The search output type '{typeof(TOutput).Name}' must be the same as the input type '{typeof(TInput).Name}' when 'returnScope' is not specified. Use the overload that specifies 'returnScope' to return documents of a nested collection type."); + } + + outputSerializer = (IBsonSerializer)args.DocumentSerializer; + } + else + { + outputSerializer = args.SerializerRegistry.GetSerializer(); + renderedSearchDefinition.Add("returnScope", new BsonDocument { { "path", returnScope.Render(args).FieldName } }); + searchOptions = searchOptions.Clone(); + searchOptions.ReturnStoredSource = true; + } + renderedSearchDefinition.Add("highlight", () => searchOptions.Highlight.Render(args), searchOptions.Highlight != null); renderedSearchDefinition.Add("count", () => searchOptions.CountOptions.Render(), searchOptions.CountOptions != null); renderedSearchDefinition.Add("sort", () => searchOptions.Sort.Render(args), searchOptions.Sort != null); @@ -1458,7 +1495,7 @@ public static PipelineStageDefinition Search( renderedSearchDefinition.Add("searchBefore", () => searchOptions.SearchBefore, searchOptions.SearchBefore != null); var document = new BsonDocument(operatorName, renderedSearchDefinition); - return new RenderedPipelineStageDefinition(operatorName, document, args.DocumentSerializer); + return new RenderedPipelineStageDefinition(operatorName, document, outputSerializer); }); return stage; @@ -1476,6 +1513,22 @@ public static PipelineStageDefinition SearchMeta searchDefinition, string indexName = null, SearchCountOptions count = null) + => SearchMeta(searchDefinition, returnScope: null, indexName, count); + + /// + /// Creates a $searchMeta stage. + /// + /// The type of the input documents. + /// The search definition. + /// The level of nested documents to return. + /// The index name. + /// The count options. + /// The stage. + public static PipelineStageDefinition SearchMeta( + SearchDefinition searchDefinition, + FieldDefinition returnScope, + string indexName = null, + SearchCountOptions count = null) { Ensure.IsNotNull(searchDefinition, nameof(searchDefinition)); @@ -1488,6 +1541,7 @@ public static PipelineStageDefinition SearchMeta count.Render(), count != null); renderedSearchDefinition.Add("index", indexName, indexName != null); + renderedSearchDefinition.Add("returnScope", () => new BsonDocument { { "path", returnScope!.Render(args).FieldName } }, returnScope != null); var document = new BsonDocument(operatorName, renderedSearchDefinition); return new RenderedPipelineStageDefinition( diff --git a/src/MongoDB.Driver/Search/OperatorSearchDefinitions.cs b/src/MongoDB.Driver/Search/OperatorSearchDefinitions.cs index 3501c504356..0a9ecfed497 100644 --- a/src/MongoDB.Driver/Search/OperatorSearchDefinitions.cs +++ b/src/MongoDB.Driver/Search/OperatorSearchDefinitions.cs @@ -235,6 +235,52 @@ private protected override BsonDocument RenderArguments( new(_area.Render()); } + internal sealed class HasAncestorSearchDefinition : OperatorSearchDefinition + { + private readonly FieldDefinition _ancestorPath; + private readonly SearchDefinition _operator; + + public HasAncestorSearchDefinition( + FieldDefinition ancestorPath, + SearchDefinition @operator, + SearchScoreDefinition score) + : base(OperatorType.HasAncestor, score) + { + _ancestorPath = Ensure.IsNotNull(ancestorPath, nameof(ancestorPath)); + _operator = Ensure.IsNotNull(@operator, nameof(@operator)); + } + + private protected override BsonDocument RenderArguments( + RenderArgs args, + IBsonSerializer fieldSerializer) + => new() + { + { "ancestorPath", _ancestorPath.Render(args).FieldName }, + { "operator", _operator.Render(args with { PathRenderArgs = args.PathRenderArgs with { PathPrefix = null } }) } + }; + } + + internal sealed class HasRootSearchDefinition : OperatorSearchDefinition + { + private readonly SearchDefinition _operator; + + public HasRootSearchDefinition( + SearchDefinition @operator, + SearchScoreDefinition score) + : base(OperatorType.HasRoot, score) + { + _operator = Ensure.IsNotNull(@operator, nameof(@operator)); + } + + private protected override BsonDocument RenderArguments( + RenderArgs args, + IBsonSerializer fieldSerializer) + => new() + { + { "operator", _operator.Render(args with { PathRenderArgs = args.PathRenderArgs with { PathPrefix = null } }) } + }; + } + internal sealed class InSearchDefinition : OperatorSearchDefinition { private readonly TField[] _values; diff --git a/src/MongoDB.Driver/Search/SearchCountOptions.cs b/src/MongoDB.Driver/Search/SearchCountOptions.cs index c3624c9175b..59de1c2a185 100644 --- a/src/MongoDB.Driver/Search/SearchCountOptions.cs +++ b/src/MongoDB.Driver/Search/SearchCountOptions.cs @@ -45,6 +45,12 @@ public SearchCountType Type set => _type = value; } + /// + /// Creates a clone of the options. + /// + /// A clone of the options. + public SearchCountOptions Clone() => new() { Threshold = Threshold, Type = Type }; + internal BsonDocument Render() => new() { diff --git a/src/MongoDB.Driver/Search/SearchDefinition.cs b/src/MongoDB.Driver/Search/SearchDefinition.cs index 99dda1e0271..6b1d9475c8c 100644 --- a/src/MongoDB.Driver/Search/SearchDefinition.cs +++ b/src/MongoDB.Driver/Search/SearchDefinition.cs @@ -144,6 +144,8 @@ private protected enum OperatorType Facet, GeoShape, GeoWithin, + HasAncestor, + HasRoot, In, MoreLikeThis, Near, diff --git a/src/MongoDB.Driver/Search/SearchDefinitionBuilder.cs b/src/MongoDB.Driver/Search/SearchDefinitionBuilder.cs index 908153c8b18..980e84750a5 100644 --- a/src/MongoDB.Driver/Search/SearchDefinitionBuilder.cs +++ b/src/MongoDB.Driver/Search/SearchDefinitionBuilder.cs @@ -313,6 +313,50 @@ public SearchDefinition GeoWithin( where TCoordinates : GeoJsonCoordinates => new GeoWithinSearchDefinition(path, area, score); + /// + /// Creates a search definition that moves the context back up the tree of embedded documents + /// to one of the ancestors of the current embedded document such that ancestor fields can be used in searches + /// of embedded documents. + /// + /// The type of the ancestor documents. + /// The path from the root to the ancestor. + /// The operator to execute at the root. + /// The score modifier. + /// A search definition for the ancestor. + public SearchDefinition HasAncestor( + FieldDefinition> path, + SearchDefinition @operator, + SearchScoreDefinition score = null) + => new HasAncestorSearchDefinition(path, @operator, score); + + /// + /// Creates a search definition that moves the context back up the tree of embedded documents + /// to one of the ancestors of the current embedded document such that ancestor fields can be used in searches + /// of embedded documents. + /// + /// The type of the ancestor documents. + /// The path from the root to the ancestor. + /// The operator to execute at the root. + /// The score modifier. + /// A search definition for the ancestor. + public SearchDefinition HasAncestor( + Expression>> path, + SearchDefinition @operator, + SearchScoreDefinition score = null) + => HasAncestor(new ExpressionFieldDefinition>(path), @operator, score); + + /// + /// Creates a search definition that moves the context back up the tree of embedded documents + /// to the root such that root fields can be used in searches of embedded documents. + /// + /// The operator to execute at the root. + /// The score modifier. + /// A search definition for the root. + public SearchDefinition HasRoot( + SearchDefinition @operator, + SearchScoreDefinition score = null) + => new HasRootSearchDefinition(@operator, score); + /// /// Creates a search definition that queries for documents where the value of the field equals to any of specified values. /// diff --git a/src/MongoDB.Driver/Search/SearchHighlightOptions.cs b/src/MongoDB.Driver/Search/SearchHighlightOptions.cs index 0e39a9510eb..1b95337a6bd 100644 --- a/src/MongoDB.Driver/Search/SearchHighlightOptions.cs +++ b/src/MongoDB.Driver/Search/SearchHighlightOptions.cs @@ -94,6 +94,13 @@ public SearchPathDefinition Path set => _path = Ensure.IsNotNull(value, nameof(value)); } + /// + /// Creates a clone of the options. + /// + /// A clone of the options. + public SearchHighlightOptions Clone() + => new(Path, MaxCharsToExamine, MaxNumPassages); + /// /// Renders the options to a . /// diff --git a/src/MongoDB.Driver/Search/SearchOptions.cs b/src/MongoDB.Driver/Search/SearchOptions.cs index 2dea219202d..2996e9c50a0 100644 --- a/src/MongoDB.Driver/Search/SearchOptions.cs +++ b/src/MongoDB.Driver/Search/SearchOptions.cs @@ -69,5 +69,24 @@ public sealed class SearchOptions /// When set, the search retrieves documents starting immediately before the specified reference point. /// public string SearchBefore { get; set; } + + /// + /// Creates a clone of the options. + /// + /// A clone of the options. + public SearchOptions Clone() + => new() + { + CountOptions = CountOptions?.Clone(), + Highlight = Highlight?.Clone(), + IndexName = IndexName, + ReturnStoredSource = ReturnStoredSource, + ScoreDetails = ScoreDetails, + Sort = Sort, + Tracking = Tracking?.Clone(), + SearchAfter = SearchAfter, + SearchBefore = SearchBefore, + }; + } } diff --git a/src/MongoDB.Driver/Search/SearchTrackingOptions.cs b/src/MongoDB.Driver/Search/SearchTrackingOptions.cs index 19c60c2afe8..c0c6cb38c14 100644 --- a/src/MongoDB.Driver/Search/SearchTrackingOptions.cs +++ b/src/MongoDB.Driver/Search/SearchTrackingOptions.cs @@ -34,6 +34,12 @@ public string SearchTerms set => _searchTerms = Ensure.IsNotNullOrEmpty(value, nameof(value)); } + /// + /// Creates a clone of the options. + /// + /// A clone of the options. + public SearchTrackingOptions Clone() => new() { SearchTerms = SearchTerms }; + internal BsonDocument Render() => new() { diff --git a/tests/MongoDB.Driver.Tests/PipelineStageDefinitionBuilderTests.cs b/tests/MongoDB.Driver.Tests/PipelineStageDefinitionBuilderTests.cs index 1462d3db2be..fd7aca30204 100644 --- a/tests/MongoDB.Driver.Tests/PipelineStageDefinitionBuilderTests.cs +++ b/tests/MongoDB.Driver.Tests/PipelineStageDefinitionBuilderTests.cs @@ -653,6 +653,19 @@ public void RankFusion_with_incorrect_params_should_throw_expected_exception() }); } + [Fact] + public void Search_without_returnScope_should_throw_when_output_type_differs_from_input_type() + { + var stage = PipelineStageDefinitionBuilder.Search( + Builders.Search.Exists("x"), + returnScope: null, + searchOptions: null); + + var exception = Assert.Throws(() => RenderStage(stage)); + exception.Message.Should().Contain(nameof(BsonDocument)); + exception.Message.Should().Contain(nameof(ManyToOne)); + } + // private methods private IMongoDatabase GetDatabase() { diff --git a/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs b/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs index e4df3e98fe6..8a7bbee47c3 100644 --- a/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs +++ b/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs @@ -19,12 +19,13 @@ using System.Threading; using FluentAssertions; using MongoDB.Bson; +using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization.Attributes; -using MongoDB.Driver.Core.Misc; +using MongoDB.Driver.Core; +using MongoDB.Driver.Core.Events; using MongoDB.Driver.Core.TestHelpers.Logging; using MongoDB.Driver.GeoJsonObjectModel; using MongoDB.Driver.Search; -using MongoDB.TestHelpers.XunitExtensions; using Xunit; using Xunit.Abstractions; using Builders = MongoDB.Driver.Builders; @@ -280,10 +281,186 @@ public class AtlasSearchTests : LoggableTestClass #endregion private readonly IMongoClient _mongoClient; + private readonly EventCapturer _eventCapturer; + private static bool __indexesCreated; + private static readonly string __databaseUniquifier = Guid.NewGuid().ToString(); public AtlasSearchTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { - _mongoClient = AtlasSearchTestsUtils.CreateAtlasSearchMongoClient(); + (_mongoClient, _eventCapturer) = AtlasSearchTestsUtils.CreateAtlasSearchMongoClient(); + + if (__indexesCreated) + { + return; + } + + var directors = + """ + [ + { + _id: 0, + director: "Peyton Reed", + birthDate: "1964-07-03", + age: 35, + movies: [ + { + title: "Ant-Man", + release: 2015, + genre: "Action", + reviews: [ + { rating: 8.5, text: "Funny and thrilling" }, + { rating: 8.0, text: "Great cast performances" } + ] + }, + { + title: "Ant-Man-2", + release: 2017, + genre: "Action", + reviews: [ + { rating: 8.5, text: "Funny and thrilling" }, + { rating: 8.0, text: "Great cast performances" } + ] + }, + { + title: "Yes Man", + release: 2008, + genre: "Comedy", + reviews: [ + { rating: 7.0, text: "Hilarious and uplifting" }, + { rating: 6.5, text: "Feel-good comedy flick" } + ] + } + ] + }, + { + _id: 1, + director: "M. Night Shyamalan", + birthDate: "1970-08-06", + age: 25, + movies: [ + { + title: "The Sixth Sense", + releaseYear: 1999, + genre: "Thriller", + reviews: [ + { rating: 9.0, text: "Mind-blowing and suspenseful" }, + { rating: 8.5, text: "Incredible plot twist" }, + { rating: 5.5, text: "Amazing plot twist" } + ] + }, + { + title: "Split", + releaseYear: 2016, + genre: "Thriller", + reviews: [ + { rating: 8.0, text: "Intense psychological thriller" }, + { rating: 7.5, text: "Amazing lead performance" } + ] + } + ] + } + ] + """; + + var returnScopeIndex1 = new CreateSearchIndexModel( + name: "returnScopeIndex1", + definition: BsonDocument.Parse( + """ + { + "mappings": { + "dynamic": true, + "fields": { + "movies": { + "type": "embeddedDocuments", + "dynamic": true, + "storedSource": { + "exclude": ["title"] + } + } + } + } + } + """)); + + var returnScopeIndex2 = new CreateSearchIndexModel( + name: "returnScopeIndex2", + definition: BsonDocument.Parse( + """ + { + "mappings": { + "dynamic": true, + "fields": { + "movies": { + "type": "embeddedDocuments", + "dynamic": true, + "storedSource": { + "exclude": ["title"] + }, + "fields": { + "reviews": { + "type": "embeddedDocuments", + "storedSource": { + "exclude": ["author", "creation_time"] + } + } + } + } + } + } + } + """)); + + var returnScopeIndex3 = new CreateSearchIndexModel( + name: "returnScopeIndex3", + definition: BsonDocument.Parse( + """ + { + "mappings": { + "fields": { + "movies": { + "type": "embeddedDocuments", + "fields": { + "reviews": { + "type": "embeddedDocuments", + "storedSource": { + "exclude": ["author", "creation_time"] + } + } + } + } + } + } + } + """)); + + var collection = GetReturnScopeCollection(); + if (!collection.AsQueryable().Any()) + { + collection.InsertMany(BsonSerializer.Deserialize(directors).Select(e => e.AsBsonDocument)); + } + + var indexModels = new[] { returnScopeIndex1, returnScopeIndex2, returnScopeIndex3 }; + collection.SearchIndexes.CreateMany(indexModels); + var indexNames = indexModels.Select(i => i.Name).ToList(); + + var timeoutCount = 3; + while (--timeoutCount >= 0) + { + var filtered = collection.SearchIndexes.List().ToList() + .Where(d => indexNames.Contains(d["name"].AsString)) + .ToArray(); + + if (filtered.Length == indexNames.Count && + filtered.All(i => i["status"] == "READY")) + { + break; + } + + Thread.Sleep(10000); + } + + _eventCapturer.Clear(); + __indexesCreated = true; } protected override void DisposeInternal() => _mongoClient.Dispose(); @@ -337,7 +514,7 @@ public void EmbeddedDocument() var builderHistoricalDocument = Builders.Search; var builderComments = Builders.Search; - var result = GetTestCollection< HistoricalDocumentWithCommentsOnly>() + var result = GetTestCollection() .Aggregate() .Search(builderHistoricalDocument.EmbeddedDocument( p => p.Comments, @@ -351,6 +528,40 @@ public void EmbeddedDocument() } } + [Fact] + public void EmbeddedDocument_with_HasAncestor() + { + var query = GetReturnScopeCollection().Aggregate().Search( + Builders.Search.EmbeddedDocument( + "movies.reviews", + Builders.Search.HasAncestor( + b => b.Movies, + Builders.Search.Text("movies.title", "Ant-Man"))), + new() { IndexName = "returnScopeIndex3" }); + + var results = query.ToList(); + results.Count.Should().Be(0); + + ValidateSearchStage( + "{ $search: { embeddedDocument: { operator: { hasAncestor: { ancestorPath: 'movies', operator: { text: { query: 'Ant-Man', path: 'movies.title' } } } }, path: 'movies.reviews' }, index: 'returnScopeIndex3' } }"); + } + + [Fact] + public void EmbeddedDocument_with_HasRoot() + { + var query = GetReturnScopeCollection().Aggregate().Search(Builders.Search.EmbeddedDocument( + "movies.reviews", + Builders.Search.HasRoot( + Builders.Search.Text("movies.title", "Ant-Man"))), + new() { IndexName = "returnScopeIndex3" }); + + var results = query.ToList(); + results.Count.Should().Be(0); + + ValidateSearchStage( + "{ $search: { embeddedDocument: { operator: { hasRoot: { operator: { text: { query: 'Ant-Man', path: 'movies.title' } } } }, path: 'movies.reviews' }, index: 'returnScopeIndex3' } }"); + } + [Fact] public void EqualsGuid() { @@ -734,6 +945,99 @@ public void RankFusion() "Battle for the Planet of the Apes", "King Kong Lives", "Mighty Joe Young"); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReturnScope(bool useExpression) + { + var searchDefinition = Builders.Search.Range("movies.releaseYear", SearchRangeBuilder.Gt(2000)); + var aggregate = GetReturnScopeCollection().Aggregate(); + var searchOptions = new SearchOptions { IndexName = "returnScopeIndex2" }; + + var query = useExpression + ? aggregate.Search(searchDefinition, e => e.Movies, searchOptions) + : aggregate.Search(searchDefinition, "movies", searchOptions); + + var results = query.ToList(); + results.Count.Should().Be(1); + results[0].ReleaseYear.Should().Be(2016); + + ValidateSearchStage( + "{ $search: { range: { gt: 2000, path: 'movies.releaseYear' }, returnScope: { path: 'movies' }, index: 'returnScopeIndex2', returnStoredSource: true } }"); + } + + [Fact] + public void ReturnScope_nested_path() + { + var searchDefinition = Builders.Search.Range("movies.reviews.rating", SearchRangeBuilder.Gte(8.0)); + var aggregate = GetReturnScopeCollection().Aggregate(); + + var results = aggregate.Search( + searchDefinition, + "movies.reviews", + new() { IndexName = "returnScopeIndex2" }).ToList(); + + results.Count.Should().Be(0); + + ValidateSearchStage( + "{ $search: { range: { gte: 8.0, path: 'movies.reviews.rating' }, returnScope: { path: 'movies.reviews' }, index: 'returnScopeIndex2', returnStoredSource: true } }"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReturnScope_HasRoot(bool useExpression) + { + var searchDefinition = Builders.Search.Compound() + .Must(Builders.Search.HasRoot( + Builders.Search.Text(e => e.Name, "M. Night Shyamalan")), + Builders.Search.Text("movies.reviews.text", "Amazing")); + + var aggregate = GetReturnScopeCollection().Aggregate(); + + var searchOptions = new SearchOptions() { IndexName = "returnScopeIndex1" }; + var query = useExpression + ? aggregate.Search(searchDefinition, e => e.Movies, searchOptions) + : aggregate.Search(searchDefinition, "movies", searchOptions); + + var results = query.ToList(); + results.Count.Should().Be(2); + results[0].ReleaseYear.Should().Be(2016); + results[1].ReleaseYear.Should().Be(1999); + + ValidateSearchStage( + "{ $search: { compound: { must: [ { hasRoot: { operator: { text: { query: 'M. Night Shyamalan', path: 'director' } } } }, { text: { query: 'Amazing', path: 'movies.reviews.text' } } ] }, returnScope: { path: 'movies' }, index: 'returnScopeIndex1', returnStoredSource: true } }"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReturnScope_HasAncestor(bool useExpression) + { + var hasAncestor = useExpression + ? Builders.Search.HasAncestor( + b => b.Movies, + Builders.Search.Text("movies.title", "Split")) + : Builders.Search.HasAncestor( + "movies", + Builders.Search.Text("movies.title", "Split")); + + var searchDefinition = Builders.Search.Compound() + .Must(hasAncestor, Builders.Search.Text("movies.reviews.text", "Amazing")); + + var aggregate = GetReturnScopeCollection().Aggregate(); + + var results = aggregate.Search( + searchDefinition, + "movies.reviews", + new() { IndexName = "returnScopeIndex2" }).ToList(); + + results.Count.Should().Be(0); + + ValidateSearchStage( + "{ $search: { compound: { must: [ { hasAncestor: { ancestorPath: 'movies', operator: { text: { query: 'Split', path: 'movies.title' } } } }, { text: { query: 'Amazing', path: 'movies.reviews.text' } } ] }, returnScope: { path: 'movies.reviews' }, index: 'returnScopeIndex2', returnStoredSource: true } }"); + } + [Fact] public void SearchSequenceToken() { @@ -864,6 +1168,39 @@ public void SearchMeta_facet() bucket.Count.Should().Be(108); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SearchMeta_with_ReturnScope(bool useExpression) + { + var searchDefinition = Builders.Search.Range("movies.releaseYear", SearchRangeBuilder.Gt(2000)); + var aggregate = GetReturnScopeCollection().Aggregate(); + + var query = useExpression + ? aggregate.SearchMeta(searchDefinition, e => e.Movies, "returnScopeIndex1") + : aggregate.SearchMeta(searchDefinition, "movies", "returnScopeIndex1"); + + var results = query.ToList(); + results.Count.Should().Be(1); + + ValidateSearchStage( + "{ $searchMeta: { range: { gt: 2000, path: 'movies.releaseYear' }, index: 'returnScopeIndex1', returnScope: { path: 'movies' } } }"); + } + + [Fact] + public void SearchMeta_with_ReturnScope_nested_path() + { + var searchDefinition = Builders.Search.Range("movies.reviews.rating", SearchRangeBuilder.Gte(8.0)); + var aggregate = GetReturnScopeCollection().Aggregate(); + + var results = aggregate.SearchMeta(searchDefinition, "movies.reviews", "returnScopeIndex2").ToList(); + + results.Count.Should().Be(1); + + ValidateSearchStage( + "{ $searchMeta: { range: { gte: 8.0, path: 'movies.reviews.rating' }, index: 'returnScopeIndex2', returnScope: { path: 'movies.reviews' } } }"); + } + [Fact] public void Should() { @@ -1214,6 +1551,14 @@ private IMongoCollection GetExtraTestsCollection() => _mongoClient .GetDatabase("csharpExtraTests") .GetCollection("testClasses"); + private IMongoCollection GetReturnScopeCollection() => _mongoClient + .GetDatabase("return_scope_" + __databaseUniquifier) + .GetCollection("directors"); + + private void ValidateSearchStage(string mql) + => _eventCapturer.Events.OfType().Single().Command["pipeline"].AsBsonArray.Single() + .Should().Be(mql); + [BsonIgnoreExtraElements] public class Comment { @@ -1343,4 +1688,49 @@ private class TestClass public Guid TestGuid { get; set; } } } + + [BsonIgnoreExtraElements] + public class Director + { + [BsonId] + public int Id { get; set; } + + [BsonElement("director")] + public string Name { get; set; } + + [BsonElement("birthDate")] + public string BirthDate { get; set; } + + [BsonElement("age")] + public int Age { get; set; } + + [BsonElement("movies")] + public List Movies { get; set; } = new(); + } + + [BsonIgnoreExtraElements] + public class DirectedMovie + { + [BsonElement("title")] + public string Title { get; set; } + + [BsonElement("releaseYear")] + public int ReleaseYear { get; set; } + + [BsonElement("genre")] + public string Genre { get; set; } + + [BsonElement("reviews")] + public List Reviews { get; set; } = new(); + } + + [BsonIgnoreExtraElements] + public class DirectedMovieReview + { + [BsonElement("rating")] + public double Rating { get; set; } + + [BsonElement("text")] + public string Text { get; set; } + } } diff --git a/tests/MongoDB.Driver.Tests/Search/AtlasSearchTestsUtils.cs b/tests/MongoDB.Driver.Tests/Search/AtlasSearchTestsUtils.cs index b09b0b9762a..49c2669c7c8 100644 --- a/tests/MongoDB.Driver.Tests/Search/AtlasSearchTestsUtils.cs +++ b/tests/MongoDB.Driver.Tests/Search/AtlasSearchTestsUtils.cs @@ -14,6 +14,8 @@ */ using System; +using MongoDB.Driver.Core; +using MongoDB.Driver.Core.Events; using MongoDB.Driver.Core.Misc; using MongoDB.TestHelpers.XunitExtensions; @@ -21,7 +23,7 @@ namespace MongoDB.Driver.Tests.Search; public static class AtlasSearchTestsUtils { - public static IMongoClient CreateAtlasSearchMongoClient() + public static (IMongoClient Client, EventCapturer EventCapturer) CreateAtlasSearchMongoClient() { RequireEnvironment.Check().EnvironmentVariable("ATLAS_SEARCH_TESTS_ENABLED"); @@ -31,6 +33,9 @@ public static IMongoClient CreateAtlasSearchMongoClient() var mongoClientSettings = MongoClientSettings.FromConnectionString(atlasSearchUri); mongoClientSettings.ClusterSource = DisposingClusterSource.Instance; - return new MongoClient(mongoClientSettings); + var eventCapturer = new EventCapturer().Capture(e => e.CommandName == "aggregate"); + mongoClientSettings.ClusterConfigurator = b => b.Subscribe(eventCapturer); + + return (new MongoClient(mongoClientSettings), eventCapturer); } } diff --git a/tests/MongoDB.Driver.Tests/Search/AutoEmbedVectorSearchTests.cs b/tests/MongoDB.Driver.Tests/Search/AutoEmbedVectorSearchTests.cs index 2174571fca3..ef68c86275d 100644 --- a/tests/MongoDB.Driver.Tests/Search/AutoEmbedVectorSearchTests.cs +++ b/tests/MongoDB.Driver.Tests/Search/AutoEmbedVectorSearchTests.cs @@ -42,7 +42,7 @@ public AutoEmbedVectorSearchTests(ITestOutputHelper testOutputHelper) : base(tes { SkipTests(); - _mongoClient = AtlasSearchTestsUtils.CreateAtlasSearchMongoClient(); + (_mongoClient, _) = AtlasSearchTestsUtils.CreateAtlasSearchMongoClient(); _collection = _mongoClient.GetDatabase("dotnet-test").GetCollection(GetRandomName()); _autoEmbedIndexName = GetRandomName(); diff --git a/tests/MongoDB.Driver.Tests/Search/SearchDefinitionBuilderTests.cs b/tests/MongoDB.Driver.Tests/Search/SearchDefinitionBuilderTests.cs index c10f83687a2..37d46473ad9 100644 --- a/tests/MongoDB.Driver.Tests/Search/SearchDefinitionBuilderTests.cs +++ b/tests/MongoDB.Driver.Tests/Search/SearchDefinitionBuilderTests.cs @@ -573,6 +573,78 @@ public void GeoWithin_typed() "{ geoWithin: { circle: { center: { type: 'Point', coordinates: [-161.323242, 22.512557] }, radius: 7.5 }, path: 'location' } }"); } + [Fact] + public void HasAncestor() + { + var subject = CreateSubject(); + + AssertRendered( + subject.HasAncestor("x", subject.Text("x.y", "foo")), + "{ hasAncestor: { ancestorPath: 'x', operator: { text: { query: 'foo', path: 'x.y' } } } }"); + + var scoreBuilder = new SearchScoreDefinitionBuilder(); + AssertRendered( + subject.HasAncestor("x", subject.Text("x.y", "foo"), scoreBuilder.Constant(1)), + "{ hasAncestor: { ancestorPath: 'x', operator: { text: { query: 'foo', path: 'x.y' } }, score: { constant: { value: 1 } } } }"); + } + + [Fact] + public void HasAncestor_typed() + { + var subjectFamily = CreateSubject(); + + AssertRendered( + subjectFamily.HasAncestor(f => f.Children, subjectFamily.Text("fn", "John")), + "{ hasAncestor: { ancestorPath: 'Children', operator: { text: { query: 'John', path: 'fn' } } } }"); + + AssertRendered( + subjectFamily.HasAncestor("Children", subjectFamily.Text("fn", "John")), + "{ hasAncestor: { ancestorPath: 'Children', operator: { text: { query: 'John', path: 'fn' } } } }"); + + // Inside EmbeddedDocument: ancestorPath uses the raw field name and the inner operator's + // path is not prefixed (PathPrefix is reset to null by HasAncestor). + AssertRendered( + subjectFamily.EmbeddedDocument( + "Children", + subjectFamily.HasAncestor( + f => f.Children, + subjectFamily.Text("fn", "John"))), + "{ embeddedDocument: { path: 'Children', operator: { hasAncestor: { ancestorPath: 'Children', operator: { text: { query: 'John', path: 'fn' } } } } } }"); + } + + [Fact] + public void HasRoot() + { + var subject = CreateSubject(); + + AssertRendered( + subject.HasRoot(subject.Text("x.y", "foo")), + "{ hasRoot: { operator: { text: { query: 'foo', path: 'x.y' } } } }"); + + var scoreBuilder = new SearchScoreDefinitionBuilder(); + AssertRendered( + subject.HasRoot(subject.Text("x.y", "foo"), scoreBuilder.Constant(1)), + "{ hasRoot: { operator: { text: { query: 'foo', path: 'x.y' } }, score: { constant: { value: 1 } } } }"); + } + + [Fact] + public void HasRoot_typed() + { + var subjectFamily = CreateSubject(); + + AssertRendered( + subjectFamily.HasRoot(subjectFamily.Text("fn", "John")), + "{ hasRoot: { operator: { text: { query: 'John', path: 'fn' } } } }"); + + // Inside EmbeddedDocument: the inner operator's path is not prefixed + // (PathPrefix is reset to null by HasRoot). + AssertRendered( + subjectFamily.EmbeddedDocument( + "Children", + subjectFamily.HasRoot(subjectFamily.Text("fn", "John"))), + "{ embeddedDocument: { path: 'Children', operator: { hasRoot: { operator: { text: { query: 'John', path: 'fn' } } } } } }"); + } + [Theory] [MemberData(nameof(InTestData))] public void In(T[] fieldValues, string[] fieldsRendered) diff --git a/tests/MongoDB.Driver.Tests/Search/VectorSearchTests.cs b/tests/MongoDB.Driver.Tests/Search/VectorSearchTests.cs index 8e164b36cc3..a38ce9f5d20 100644 --- a/tests/MongoDB.Driver.Tests/Search/VectorSearchTests.cs +++ b/tests/MongoDB.Driver.Tests/Search/VectorSearchTests.cs @@ -35,7 +35,7 @@ public class VectorSearchTests : LoggableTestClass public VectorSearchTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { - _mongoClient = AtlasSearchTestsUtils.CreateAtlasSearchMongoClient(); + (_mongoClient, _) = AtlasSearchTestsUtils.CreateAtlasSearchMongoClient(); } protected override void DisposeInternal() => _mongoClient.Dispose(); From 14517fb6bc2642e12700b2dd7608622d04ef3cff Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Mon, 13 Apr 2026 17:15:28 +0100 Subject: [PATCH 2/4] Move new IAggregateFluent members to extension methods --- Agents.md | 47 ---------- src/MongoDB.Driver/AggregateFluent.cs | 13 --- src/MongoDB.Driver/AggregateFluentBase.cs | 38 -------- src/MongoDB.Driver/IAggregateFluent.cs | 56 ----------- .../IAggregateFluentExtensions.cs | 94 +++++++++++++++++++ .../Search/AtlasSearchTests.cs | 8 +- 6 files changed, 98 insertions(+), 158 deletions(-) delete mode 100644 Agents.md diff --git a/Agents.md b/Agents.md deleted file mode 100644 index 607401164cb..00000000000 --- a/Agents.md +++ /dev/null @@ -1,47 +0,0 @@ -# Agents.md - CSharpDriver - -## Overview -The C# driver for MongoDB. - -## Tech Stack -- .NET library projects producing NuGet packages -- Multi-targeted from .NET Framework 4.7.2 through .NET 10 -- xUnit + FluentAssertions for testing - -## Project Structure -- `src/MongoDB.Bson/` - BSON for MongoDB -- `src/MongoDB.Driver/` - C# driver for MongoDB -- `MongoDB.Driver.Encryption` - Client encryption (CSFLE with KMS). -- `MongoDB.Driver.Authentication.AWS` - AWS IAM authentication -- `tests/MongoDB.Driver.Tests/` - Main C# driver tests -- `tests/MongoDB.Bson.Tests/` - BSON handling tests -- `tests/*/TestHelpers` - Common test utilities -- `tests/*` - Specialized tests; less common -- `tests/MongoDB.Driver.Tests/Specifications/` are JSON-driven tests using a common runner. - -## Commands -- Build: `dotnet build CSharpDriver.sln` -- Run all tests: `dotnet test tests/MongoDB.Driver.Tests/MongoDB.Driver.Tests.csproj -f net10.0` -- Run a single test class: `dotnet test tests/MongoDB.Driver.Tests/MongoDB.Driver.Tests.csproj -f net10.0 --filter "FullyQualifiedName~ClassName"` - -A MongoDB connection is always available locally, so "integration" tests can be run as well as unit tests. Some test suites also require additional environment variables — if you need to run those tests and the variables are not set, stop and tell the user which variables are needed rather than working around it. - -| Feature area | Required environment variables | -|---|---| -| Atlas Search | `ATLAS_SEARCH_TESTS_ENABLED`, `ATLAS_SEARCH_URI` | -| Atlas Search index helpers | `ATLAS_SEARCH_INDEX_HELPERS_TESTS_ENABLED`, `ATLAS_SEARCH_URI` | -| CSFLE / auto-encryption | `CRYPT_SHARED_LIB_PATH` | -| CSFLE with KMS mock servers | `KMS_MOCK_SERVERS_ENABLED` | -| CSFLE with AWS KMS | `CSFLE_AWS_TEMPORARY_CREDS_ENABLED` | -| CSFLE with Azure KMS | `CSFLE_AZURE_KMS_TESTS_ENABLED` | -| CSFLE with GCP KMS | `CSFLE_GCP_KMS_TESTS_ENABLED` | -| AWS authentication | `AWS_TESTS_ENABLED` | -| GSSAPI / Kerberos | `GSSAPI_TESTS_ENABLED`, `AUTH_HOST`, `AUTH_GSSAPI` | -| OIDC authentication | `OIDC_ENV` | -| X.509 authentication | `MONGO_X509_CLIENT_CERTIFICATE_PATH`, `MONGO_X509_CLIENT_CERTIFICATE_PASSWORD` | -| PLAIN authentication | `PLAIN_AUTH_TESTS_ENABLED` | -| SOCKS5 proxy | `SOCKS5_PROXY_SERVERS_ENABLED` | - -## Commit and PR Conventions - -- Commit and PR messages start with a JIRA number: `CSHARP-1234: Description` diff --git a/src/MongoDB.Driver/AggregateFluent.cs b/src/MongoDB.Driver/AggregateFluent.cs index 28cc6481432..90bd9823ce1 100644 --- a/src/MongoDB.Driver/AggregateFluent.cs +++ b/src/MongoDB.Driver/AggregateFluent.cs @@ -299,12 +299,6 @@ public override IAggregateFluent Search( return WithPipeline(_pipeline.Search(searchDefinition, searchOptions)); } - public override IAggregateFluent Search( - SearchDefinition searchDefinition, - FieldDefinition> returnScope, - SearchOptions searchOptions = null) - => WithPipeline(_pipeline.Search(searchDefinition, returnScope, searchOptions)); - public override IAggregateFluent SearchMeta( SearchDefinition searchDefinition, string indexName = null, @@ -313,13 +307,6 @@ public override IAggregateFluent SearchMeta( return WithPipeline(_pipeline.SearchMeta(searchDefinition, indexName, count)); } - public override IAggregateFluent SearchMeta( - SearchDefinition searchDefinition, - FieldDefinition returnScope, - string indexName = null, - SearchCountOptions count = null) - => WithPipeline(_pipeline.SearchMeta(searchDefinition, returnScope, indexName, count)); - public override IAggregateFluent Set(SetFieldDefinitions fields) { return WithPipeline(_pipeline.Set(fields)); diff --git a/src/MongoDB.Driver/AggregateFluentBase.cs b/src/MongoDB.Driver/AggregateFluentBase.cs index e0f3bc2a173..4aad9f8a97e 100644 --- a/src/MongoDB.Driver/AggregateFluentBase.cs +++ b/src/MongoDB.Driver/AggregateFluentBase.cs @@ -15,7 +15,6 @@ using System; using System.Collections.Generic; -using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using MongoDB.Bson; @@ -257,50 +256,13 @@ public virtual IAggregateFluent Search( SearchOptions searchOptions) => throw new NotImplementedException(); - /// - public virtual IAggregateFluent Search( - SearchDefinition searchDefinition, - FieldDefinition> returnScope, - SearchOptions searchOptions = null) - => throw new NotImplementedException(); - - /// - public virtual IAggregateFluent Search( - SearchDefinition searchDefinition, - Expression>> returnScope, - SearchOptions searchOptions = null) - => Search( - searchDefinition, - new ExpressionFieldDefinition>(returnScope), - searchOptions); - - /// - public virtual IAggregateFluent SearchMeta( - SearchDefinition searchDefinition, - string indexName = null, - SearchCountOptions count = null) - => throw new NotImplementedException(); - /// public virtual IAggregateFluent SearchMeta( SearchDefinition searchDefinition, - FieldDefinition returnScope, string indexName = null, SearchCountOptions count = null) => throw new NotImplementedException(); - /// - public virtual IAggregateFluent SearchMeta( - SearchDefinition searchDefinition, - Expression> returnScope, - string indexName = null, - SearchCountOptions count = null) - => SearchMeta( - searchDefinition, - new ExpressionFieldDefinition(returnScope), - indexName, - count); - /// public virtual IAggregateFluent Set(SetFieldDefinitions fields) => throw new NotImplementedException(); diff --git a/src/MongoDB.Driver/IAggregateFluent.cs b/src/MongoDB.Driver/IAggregateFluent.cs index 5301a934c9b..a3eef779211 100644 --- a/src/MongoDB.Driver/IAggregateFluent.cs +++ b/src/MongoDB.Driver/IAggregateFluent.cs @@ -441,71 +441,15 @@ IAggregateFluent Search( SearchDefinition searchDefinition, SearchOptions searchOptions); - /// - /// Appends a $search stage to the pipeline, returning documents from a nested scope. - /// - /// The search definition. - /// The level of nested documents to return. - /// The search options. - /// - /// The fluent aggregate interface. - /// - IAggregateFluent Search( - SearchDefinition searchDefinition, - FieldDefinition> returnScope, - SearchOptions searchOptions = null); - - /// - /// Appends a $search stage to the pipeline, returning documents from a nested scope. - /// - /// The search definition. - /// The level of nested documents to return. - /// The search options. - /// - /// The fluent aggregate interface. - /// - IAggregateFluent Search( - SearchDefinition searchDefinition, - Expression>> returnScope, - SearchOptions searchOptions = null); - - /// - /// Appends a $searchMeta stage to the pipeline. - /// - /// The search definition. - /// The index name. - /// The count options. - /// The fluent aggregate interface. - IAggregateFluent SearchMeta( - SearchDefinition searchDefinition, - string indexName = null, - SearchCountOptions count = null); - - /// - /// Appends a $searchMeta stage to the pipeline. - /// - /// The search definition. - /// The level of nested documents to return. - /// The index name. - /// The count options. - /// The fluent aggregate interface. - IAggregateFluent SearchMeta( - SearchDefinition searchDefinition, - FieldDefinition returnScope, - string indexName = null, - SearchCountOptions count = null); - /// /// Appends a $searchMeta stage to the pipeline. /// /// The search definition. - /// The level of nested documents to return. /// The index name. /// The count options. /// The fluent aggregate interface. IAggregateFluent SearchMeta( SearchDefinition searchDefinition, - Expression> returnScope, string indexName = null, SearchCountOptions count = null); diff --git a/src/MongoDB.Driver/IAggregateFluentExtensions.cs b/src/MongoDB.Driver/IAggregateFluentExtensions.cs index 4e5e5fe44bf..2dfa93993a2 100644 --- a/src/MongoDB.Driver/IAggregateFluentExtensions.cs +++ b/src/MongoDB.Driver/IAggregateFluentExtensions.cs @@ -23,6 +23,7 @@ using MongoDB.Bson.Serialization; using MongoDB.Driver.Core.Misc; using MongoDB.Driver.GeoJsonObjectModel; +using MongoDB.Driver.Search; namespace MongoDB.Driver { @@ -694,6 +695,99 @@ public static IAggregateFluent ReplaceWith( return aggregate.AppendStage(PipelineStageDefinitionBuilder.ReplaceWith(newRoot)); } + /// + /// Appends a $search stage to the pipeline, returning documents from a nested scope. + /// + /// The type of the result. + /// The type of the new result. + /// The aggregate. + /// The search definition. + /// The level of nested documents to return. + /// The search options. + /// The fluent aggregate interface. + public static IAggregateFluent Search( + this IAggregateFluent aggregate, + SearchDefinition searchDefinition, + FieldDefinition> returnScope, + SearchOptions searchOptions = null) + { + Ensure.IsNotNull(aggregate, nameof(aggregate)); + Ensure.IsNotNull(searchDefinition, nameof(searchDefinition)); + Ensure.IsNotNull(returnScope, nameof(returnScope)); + + return aggregate.AppendStage( + PipelineStageDefinitionBuilder.Search(searchDefinition, returnScope, searchOptions)); + } + + /// + /// Appends a $search stage to the pipeline, returning documents from a nested scope. + /// + /// The type of the result. + /// The type of the new result. + /// The aggregate. + /// The search definition. + /// The level of nested documents to return. + /// The search options. + /// The fluent aggregate interface. + public static IAggregateFluent Search( + this IAggregateFluent aggregate, + SearchDefinition searchDefinition, + Expression>> returnScope, + SearchOptions searchOptions = null) + => Search( + aggregate, + searchDefinition, + new ExpressionFieldDefinition>(returnScope), + searchOptions); + + /// + /// Appends a $searchMeta stage to the pipeline. + /// + /// The type of the result. + /// The aggregate. + /// The search definition. + /// The level of nested documents to return. + /// The index name. + /// The count options. + /// The fluent aggregate interface. + public static IAggregateFluent SearchMeta( + this IAggregateFluent aggregate, + SearchDefinition searchDefinition, + FieldDefinition returnScope, + string indexName = null, + SearchCountOptions count = null) + { + Ensure.IsNotNull(aggregate, nameof(aggregate)); + Ensure.IsNotNull(searchDefinition, nameof(searchDefinition)); + Ensure.IsNotNull(returnScope, nameof(returnScope)); + + return aggregate.AppendStage( + PipelineStageDefinitionBuilder.SearchMeta(searchDefinition, returnScope, indexName, count)); + } + + /// + /// Appends a $searchMeta stage to the pipeline. + /// + /// The type of the result. + /// The aggregate. + /// The search definition. + /// The level of nested documents to return. + /// The index name. + /// The count options. + /// The fluent aggregate interface. + public static IAggregateFluent SearchMeta( + this IAggregateFluent aggregate, + SearchDefinition searchDefinition, + Expression> returnScope, + string indexName = null, + SearchCountOptions count = null) + => SearchMeta( + aggregate, + searchDefinition, + new ExpressionFieldDefinition(returnScope), + indexName, + count); + /// /// Appends a $set stage to the pipeline. /// diff --git a/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs b/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs index 8a7bbee47c3..655de1eb039 100644 --- a/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs +++ b/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs @@ -956,7 +956,7 @@ public void ReturnScope(bool useExpression) var query = useExpression ? aggregate.Search(searchDefinition, e => e.Movies, searchOptions) - : aggregate.Search(searchDefinition, "movies", searchOptions); + : aggregate.Search(searchDefinition, "movies", searchOptions); var results = query.ToList(); results.Count.Should().Be(1); @@ -972,7 +972,7 @@ public void ReturnScope_nested_path() var searchDefinition = Builders.Search.Range("movies.reviews.rating", SearchRangeBuilder.Gte(8.0)); var aggregate = GetReturnScopeCollection().Aggregate(); - var results = aggregate.Search( + var results = aggregate.Search( searchDefinition, "movies.reviews", new() { IndexName = "returnScopeIndex2" }).ToList(); @@ -998,7 +998,7 @@ public void ReturnScope_HasRoot(bool useExpression) var searchOptions = new SearchOptions() { IndexName = "returnScopeIndex1" }; var query = useExpression ? aggregate.Search(searchDefinition, e => e.Movies, searchOptions) - : aggregate.Search(searchDefinition, "movies", searchOptions); + : aggregate.Search(searchDefinition, "movies", searchOptions); var results = query.ToList(); results.Count.Should().Be(2); @@ -1027,7 +1027,7 @@ public void ReturnScope_HasAncestor(bool useExpression) var aggregate = GetReturnScopeCollection().Aggregate(); - var results = aggregate.Search( + var results = aggregate.Search( searchDefinition, "movies.reviews", new() { IndexName = "returnScopeIndex2" }).ToList(); From 78794cf4b74fa6b20e48b6a02159e232ce493226 Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Tue, 14 Apr 2026 14:11:04 +0100 Subject: [PATCH 3/4] Review updates --- src/MongoDB.Driver/IAggregateFluent.cs | 1 - .../PipelineStageDefinitionBuilder.cs | 7 +- .../Search/SearchDefinitionBuilder.cs | 4 +- .../Search/SearchTrackingOptions.cs | 2 +- .../Search/AtlasSearchTests.cs | 259 +++++++++++------- 5 files changed, 173 insertions(+), 100 deletions(-) diff --git a/src/MongoDB.Driver/IAggregateFluent.cs b/src/MongoDB.Driver/IAggregateFluent.cs index a3eef779211..e8d825c66e6 100644 --- a/src/MongoDB.Driver/IAggregateFluent.cs +++ b/src/MongoDB.Driver/IAggregateFluent.cs @@ -15,7 +15,6 @@ using System; using System.Collections.Generic; -using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using MongoDB.Bson; diff --git a/src/MongoDB.Driver/PipelineStageDefinitionBuilder.cs b/src/MongoDB.Driver/PipelineStageDefinitionBuilder.cs index 5830d086a92..e6338366e66 100644 --- a/src/MongoDB.Driver/PipelineStageDefinitionBuilder.cs +++ b/src/MongoDB.Driver/PipelineStageDefinitionBuilder.cs @@ -1478,10 +1478,9 @@ public static PipelineStageDefinition Search( } else { - outputSerializer = args.SerializerRegistry.GetSerializer(); - renderedSearchDefinition.Add("returnScope", new BsonDocument { { "path", returnScope.Render(args).FieldName } }); - searchOptions = searchOptions.Clone(); - searchOptions.ReturnStoredSource = true; + var renderedField = returnScope.Render(args); + outputSerializer = (IBsonSerializer)renderedField.ValueSerializer.GetItemSerializer(); + renderedSearchDefinition.Add("returnScope", new BsonDocument { { "path", renderedField.FieldName } }); } renderedSearchDefinition.Add("highlight", () => searchOptions.Highlight.Render(args), searchOptions.Highlight != null); diff --git a/src/MongoDB.Driver/Search/SearchDefinitionBuilder.cs b/src/MongoDB.Driver/Search/SearchDefinitionBuilder.cs index 980e84750a5..66d7f3c896b 100644 --- a/src/MongoDB.Driver/Search/SearchDefinitionBuilder.cs +++ b/src/MongoDB.Driver/Search/SearchDefinitionBuilder.cs @@ -320,7 +320,7 @@ public SearchDefinition GeoWithin( /// /// The type of the ancestor documents. /// The path from the root to the ancestor. - /// The operator to execute at the root. + /// The operator to execute in the ancestor context specified by . /// The score modifier. /// A search definition for the ancestor. public SearchDefinition HasAncestor( @@ -336,7 +336,7 @@ public SearchDefinition HasAncestor( /// /// The type of the ancestor documents. /// The path from the root to the ancestor. - /// The operator to execute at the root. + /// The operator to execute in the ancestor context specified by . /// The score modifier. /// A search definition for the ancestor. public SearchDefinition HasAncestor( diff --git a/src/MongoDB.Driver/Search/SearchTrackingOptions.cs b/src/MongoDB.Driver/Search/SearchTrackingOptions.cs index c0c6cb38c14..3f6ba12a8b7 100644 --- a/src/MongoDB.Driver/Search/SearchTrackingOptions.cs +++ b/src/MongoDB.Driver/Search/SearchTrackingOptions.cs @@ -38,7 +38,7 @@ public string SearchTerms /// Creates a clone of the options. /// /// A clone of the options. - public SearchTrackingOptions Clone() => new() { SearchTerms = SearchTerms }; + public SearchTrackingOptions Clone() => new() { _searchTerms = SearchTerms }; internal BsonDocument Render() => new() diff --git a/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs b/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs index 655de1eb039..cbd0d70c09c 100644 --- a/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs +++ b/tests/MongoDB.Driver.Tests/Search/AtlasSearchTests.cs @@ -40,6 +40,11 @@ public class AtlasSearchTests : LoggableTestClass #region static + // Temporary shared resource management--will be replaced with appropriate fixture as part of test updates. + private static bool __indexesCreated; + private static readonly string __databaseUniquifier = Guid.NewGuid().ToString(); + private static int __returnScopeTestCount = 12; + private static readonly GeoJsonPolygon __testPolygon = new(new(new(new GeoJson2DGeographicCoordinates[] { @@ -282,8 +287,6 @@ public class AtlasSearchTests : LoggableTestClass private readonly IMongoClient _mongoClient; private readonly EventCapturer _eventCapturer; - private static bool __indexesCreated; - private static readonly string __databaseUniquifier = Guid.NewGuid().ToString(); public AtlasSearchTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { @@ -463,7 +466,15 @@ public AtlasSearchTests(ITestOutputHelper testOutputHelper) : base(testOutputHel __indexesCreated = true; } - protected override void DisposeInternal() => _mongoClient.Dispose(); + protected override void DisposeInternal() + { + if (__returnScopeTestCount == 0) + { + _mongoClient.DropDatabase("return_scope_" + __databaseUniquifier); + __returnScopeTestCount = -1; + } + _mongoClient.Dispose(); + } [Fact] public void Autocomplete() @@ -531,35 +542,51 @@ public void EmbeddedDocument() [Fact] public void EmbeddedDocument_with_HasAncestor() { - var query = GetReturnScopeCollection().Aggregate().Search( - Builders.Search.EmbeddedDocument( - "movies.reviews", - Builders.Search.HasAncestor( - b => b.Movies, - Builders.Search.Text("movies.title", "Ant-Man"))), - new() { IndexName = "returnScopeIndex3" }); - - var results = query.ToList(); - results.Count.Should().Be(0); - - ValidateSearchStage( - "{ $search: { embeddedDocument: { operator: { hasAncestor: { ancestorPath: 'movies', operator: { text: { query: 'Ant-Man', path: 'movies.title' } } } }, path: 'movies.reviews' }, index: 'returnScopeIndex3' } }"); + try + { + var query = GetReturnScopeCollection().Aggregate().Search( + Builders.Search.EmbeddedDocument( + "movies.reviews", + Builders.Search.HasAncestor( + b => b.Movies, + Builders.Search.Text("movies.title", "Ant-Man"))), + new() { IndexName = "returnScopeIndex3" }); + + var results = query.ToList(); + results.Count.Should().Be(0); + + ValidateSearchStage( + "{ $search: { embeddedDocument: { operator: { hasAncestor: { ancestorPath: 'movies', operator: { text: { query: 'Ant-Man', path: 'movies.title' } } } }, path: 'movies.reviews' }, index: 'returnScopeIndex3' } }"); + } + finally + { + // Temporary shared resource cleanup 1. + __returnScopeTestCount--; + } } [Fact] public void EmbeddedDocument_with_HasRoot() { - var query = GetReturnScopeCollection().Aggregate().Search(Builders.Search.EmbeddedDocument( - "movies.reviews", - Builders.Search.HasRoot( - Builders.Search.Text("movies.title", "Ant-Man"))), - new() { IndexName = "returnScopeIndex3" }); + try + { + var query = GetReturnScopeCollection().Aggregate().Search(Builders.Search.EmbeddedDocument( + "movies.reviews", + Builders.Search.HasRoot( + Builders.Search.Text("movies.title", "Ant-Man"))), + new() { IndexName = "returnScopeIndex3" }); - var results = query.ToList(); - results.Count.Should().Be(0); + var results = query.ToList(); + results.Count.Should().Be(0); - ValidateSearchStage( - "{ $search: { embeddedDocument: { operator: { hasRoot: { operator: { text: { query: 'Ant-Man', path: 'movies.title' } } } }, path: 'movies.reviews' }, index: 'returnScopeIndex3' } }"); + ValidateSearchStage( + "{ $search: { embeddedDocument: { operator: { hasRoot: { operator: { text: { query: 'Ant-Man', path: 'movies.title' } } } }, path: 'movies.reviews' }, index: 'returnScopeIndex3' } }"); + } + finally + { + // Temporary shared resource cleanup 2. + __returnScopeTestCount--; + } } [Fact] @@ -950,37 +977,53 @@ public void RankFusion() [InlineData(true)] public void ReturnScope(bool useExpression) { - var searchDefinition = Builders.Search.Range("movies.releaseYear", SearchRangeBuilder.Gt(2000)); - var aggregate = GetReturnScopeCollection().Aggregate(); - var searchOptions = new SearchOptions { IndexName = "returnScopeIndex2" }; + try + { + var searchDefinition = Builders.Search.Range("movies.releaseYear", SearchRangeBuilder.Gt(2000)); + var aggregate = GetReturnScopeCollection().Aggregate(); + var searchOptions = new SearchOptions { IndexName = "returnScopeIndex2", ReturnStoredSource = true }; - var query = useExpression - ? aggregate.Search(searchDefinition, e => e.Movies, searchOptions) - : aggregate.Search(searchDefinition, "movies", searchOptions); + var query = useExpression + ? aggregate.Search(searchDefinition, e => e.Movies, searchOptions) + : aggregate.Search(searchDefinition, "movies", searchOptions); - var results = query.ToList(); - results.Count.Should().Be(1); - results[0].ReleaseYear.Should().Be(2016); + var results = query.ToList(); + results.Count.Should().Be(1); + results[0].ReleaseYear.Should().Be(2016); - ValidateSearchStage( - "{ $search: { range: { gt: 2000, path: 'movies.releaseYear' }, returnScope: { path: 'movies' }, index: 'returnScopeIndex2', returnStoredSource: true } }"); + ValidateSearchStage( + "{ $search: { range: { gt: 2000, path: 'movies.releaseYear' }, returnScope: { path: 'movies' }, index: 'returnScopeIndex2', returnStoredSource: true } }"); + } + finally + { + // Temporary shared resource cleanup 3 & 4. + __returnScopeTestCount--; + } } [Fact] public void ReturnScope_nested_path() { - var searchDefinition = Builders.Search.Range("movies.reviews.rating", SearchRangeBuilder.Gte(8.0)); - var aggregate = GetReturnScopeCollection().Aggregate(); + try + { + var searchDefinition = Builders.Search.Range("movies.reviews.rating", SearchRangeBuilder.Gte(8.0)); + var aggregate = GetReturnScopeCollection().Aggregate(); - var results = aggregate.Search( - searchDefinition, - "movies.reviews", - new() { IndexName = "returnScopeIndex2" }).ToList(); + var results = aggregate.Search( + searchDefinition, + "movies.reviews", + new() { IndexName = "returnScopeIndex2", ReturnStoredSource = true }).ToList(); - results.Count.Should().Be(0); + results.Count.Should().Be(0); - ValidateSearchStage( - "{ $search: { range: { gte: 8.0, path: 'movies.reviews.rating' }, returnScope: { path: 'movies.reviews' }, index: 'returnScopeIndex2', returnStoredSource: true } }"); + ValidateSearchStage( + "{ $search: { range: { gte: 8.0, path: 'movies.reviews.rating' }, returnScope: { path: 'movies.reviews' }, index: 'returnScopeIndex2', returnStoredSource: true } }"); + } + finally + { + // Temporary shared resource cleanup 5. + __returnScopeTestCount--; + } } [Theory] @@ -988,25 +1031,33 @@ public void ReturnScope_nested_path() [InlineData(true)] public void ReturnScope_HasRoot(bool useExpression) { - var searchDefinition = Builders.Search.Compound() - .Must(Builders.Search.HasRoot( - Builders.Search.Text(e => e.Name, "M. Night Shyamalan")), - Builders.Search.Text("movies.reviews.text", "Amazing")); + try + { + var searchDefinition = Builders.Search.Compound() + .Must(Builders.Search.HasRoot( + Builders.Search.Text(e => e.Name, "M. Night Shyamalan")), + Builders.Search.Text("movies.reviews.text", "Amazing")); - var aggregate = GetReturnScopeCollection().Aggregate(); + var aggregate = GetReturnScopeCollection().Aggregate(); - var searchOptions = new SearchOptions() { IndexName = "returnScopeIndex1" }; - var query = useExpression - ? aggregate.Search(searchDefinition, e => e.Movies, searchOptions) - : aggregate.Search(searchDefinition, "movies", searchOptions); + var searchOptions = new SearchOptions() { IndexName = "returnScopeIndex1", ReturnStoredSource = true }; + var query = useExpression + ? aggregate.Search(searchDefinition, e => e.Movies, searchOptions) + : aggregate.Search(searchDefinition, "movies", searchOptions); - var results = query.ToList(); - results.Count.Should().Be(2); - results[0].ReleaseYear.Should().Be(2016); - results[1].ReleaseYear.Should().Be(1999); + var results = query.ToList(); + results.Count.Should().Be(2); + results[0].ReleaseYear.Should().Be(2016); + results[1].ReleaseYear.Should().Be(1999); - ValidateSearchStage( - "{ $search: { compound: { must: [ { hasRoot: { operator: { text: { query: 'M. Night Shyamalan', path: 'director' } } } }, { text: { query: 'Amazing', path: 'movies.reviews.text' } } ] }, returnScope: { path: 'movies' }, index: 'returnScopeIndex1', returnStoredSource: true } }"); + ValidateSearchStage( + "{ $search: { compound: { must: [ { hasRoot: { operator: { text: { query: 'M. Night Shyamalan', path: 'director' } } } }, { text: { query: 'Amazing', path: 'movies.reviews.text' } } ] }, returnScope: { path: 'movies' }, index: 'returnScopeIndex1', returnStoredSource: true } }"); + } + finally + { + // Temporary shared resource cleanup 6 & 7. + __returnScopeTestCount--; + } } [Theory] @@ -1014,28 +1065,36 @@ public void ReturnScope_HasRoot(bool useExpression) [InlineData(true)] public void ReturnScope_HasAncestor(bool useExpression) { - var hasAncestor = useExpression - ? Builders.Search.HasAncestor( - b => b.Movies, - Builders.Search.Text("movies.title", "Split")) - : Builders.Search.HasAncestor( - "movies", - Builders.Search.Text("movies.title", "Split")); + try + { + var hasAncestor = useExpression + ? Builders.Search.HasAncestor( + b => b.Movies, + Builders.Search.Text("movies.title", "Split")) + : Builders.Search.HasAncestor( + "movies", + Builders.Search.Text("movies.title", "Split")); - var searchDefinition = Builders.Search.Compound() - .Must(hasAncestor, Builders.Search.Text("movies.reviews.text", "Amazing")); + var searchDefinition = Builders.Search.Compound() + .Must(hasAncestor, Builders.Search.Text("movies.reviews.text", "Amazing")); - var aggregate = GetReturnScopeCollection().Aggregate(); + var aggregate = GetReturnScopeCollection().Aggregate(); - var results = aggregate.Search( - searchDefinition, - "movies.reviews", - new() { IndexName = "returnScopeIndex2" }).ToList(); + var results = aggregate.Search( + searchDefinition, + "movies.reviews", + new() { IndexName = "returnScopeIndex2", ReturnStoredSource = true }).ToList(); - results.Count.Should().Be(0); + results.Count.Should().Be(0); - ValidateSearchStage( - "{ $search: { compound: { must: [ { hasAncestor: { ancestorPath: 'movies', operator: { text: { query: 'Split', path: 'movies.title' } } } }, { text: { query: 'Amazing', path: 'movies.reviews.text' } } ] }, returnScope: { path: 'movies.reviews' }, index: 'returnScopeIndex2', returnStoredSource: true } }"); + ValidateSearchStage( + "{ $search: { compound: { must: [ { hasAncestor: { ancestorPath: 'movies', operator: { text: { query: 'Split', path: 'movies.title' } } } }, { text: { query: 'Amazing', path: 'movies.reviews.text' } } ] }, returnScope: { path: 'movies.reviews' }, index: 'returnScopeIndex2', returnStoredSource: true } }"); + } + finally + { + // Temporary shared resource cleanup 8 & 9. + __returnScopeTestCount--; + } } [Fact] @@ -1173,32 +1232,48 @@ public void SearchMeta_facet() [InlineData(true)] public void SearchMeta_with_ReturnScope(bool useExpression) { - var searchDefinition = Builders.Search.Range("movies.releaseYear", SearchRangeBuilder.Gt(2000)); - var aggregate = GetReturnScopeCollection().Aggregate(); + try + { + var searchDefinition = Builders.Search.Range("movies.releaseYear", SearchRangeBuilder.Gt(2000)); + var aggregate = GetReturnScopeCollection().Aggregate(); - var query = useExpression - ? aggregate.SearchMeta(searchDefinition, e => e.Movies, "returnScopeIndex1") - : aggregate.SearchMeta(searchDefinition, "movies", "returnScopeIndex1"); + var query = useExpression + ? aggregate.SearchMeta(searchDefinition, e => e.Movies, "returnScopeIndex1") + : aggregate.SearchMeta(searchDefinition, "movies", "returnScopeIndex1"); - var results = query.ToList(); - results.Count.Should().Be(1); + var results = query.ToList(); + results.Count.Should().Be(1); - ValidateSearchStage( - "{ $searchMeta: { range: { gt: 2000, path: 'movies.releaseYear' }, index: 'returnScopeIndex1', returnScope: { path: 'movies' } } }"); + ValidateSearchStage( + "{ $searchMeta: { range: { gt: 2000, path: 'movies.releaseYear' }, index: 'returnScopeIndex1', returnScope: { path: 'movies' } } }"); + } + finally + { + // Temporary shared resource cleanup 10 & 11. + __returnScopeTestCount--; + } } [Fact] public void SearchMeta_with_ReturnScope_nested_path() { - var searchDefinition = Builders.Search.Range("movies.reviews.rating", SearchRangeBuilder.Gte(8.0)); - var aggregate = GetReturnScopeCollection().Aggregate(); + try + { + var searchDefinition = Builders.Search.Range("movies.reviews.rating", SearchRangeBuilder.Gte(8.0)); + var aggregate = GetReturnScopeCollection().Aggregate(); - var results = aggregate.SearchMeta(searchDefinition, "movies.reviews", "returnScopeIndex2").ToList(); + var results = aggregate.SearchMeta(searchDefinition, "movies.reviews", "returnScopeIndex2").ToList(); - results.Count.Should().Be(1); + results.Count.Should().Be(1); - ValidateSearchStage( - "{ $searchMeta: { range: { gte: 8.0, path: 'movies.reviews.rating' }, index: 'returnScopeIndex2', returnScope: { path: 'movies.reviews' } } }"); + ValidateSearchStage( + "{ $searchMeta: { range: { gte: 8.0, path: 'movies.reviews.rating' }, index: 'returnScopeIndex2', returnScope: { path: 'movies.reviews' } } }"); + } + finally + { + // Temporary shared resource cleanup 12. + __returnScopeTestCount--; + } } [Fact] From e50cee638ba520291cff4ec527eeda7920dc4b93 Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Thu, 16 Apr 2026 17:37:36 +0100 Subject: [PATCH 4/4] Remove SearchOptions.Clone --- .../Search/SearchCountOptions.cs | 6 ------ .../Search/SearchHighlightOptions.cs | 7 ------- src/MongoDB.Driver/Search/SearchOptions.cs | 19 ------------------- .../Search/SearchTrackingOptions.cs | 6 ------ 4 files changed, 38 deletions(-) diff --git a/src/MongoDB.Driver/Search/SearchCountOptions.cs b/src/MongoDB.Driver/Search/SearchCountOptions.cs index 59de1c2a185..c3624c9175b 100644 --- a/src/MongoDB.Driver/Search/SearchCountOptions.cs +++ b/src/MongoDB.Driver/Search/SearchCountOptions.cs @@ -45,12 +45,6 @@ public SearchCountType Type set => _type = value; } - /// - /// Creates a clone of the options. - /// - /// A clone of the options. - public SearchCountOptions Clone() => new() { Threshold = Threshold, Type = Type }; - internal BsonDocument Render() => new() { diff --git a/src/MongoDB.Driver/Search/SearchHighlightOptions.cs b/src/MongoDB.Driver/Search/SearchHighlightOptions.cs index 1b95337a6bd..0e39a9510eb 100644 --- a/src/MongoDB.Driver/Search/SearchHighlightOptions.cs +++ b/src/MongoDB.Driver/Search/SearchHighlightOptions.cs @@ -94,13 +94,6 @@ public SearchPathDefinition Path set => _path = Ensure.IsNotNull(value, nameof(value)); } - /// - /// Creates a clone of the options. - /// - /// A clone of the options. - public SearchHighlightOptions Clone() - => new(Path, MaxCharsToExamine, MaxNumPassages); - /// /// Renders the options to a . /// diff --git a/src/MongoDB.Driver/Search/SearchOptions.cs b/src/MongoDB.Driver/Search/SearchOptions.cs index 2996e9c50a0..2dea219202d 100644 --- a/src/MongoDB.Driver/Search/SearchOptions.cs +++ b/src/MongoDB.Driver/Search/SearchOptions.cs @@ -69,24 +69,5 @@ public sealed class SearchOptions /// When set, the search retrieves documents starting immediately before the specified reference point. /// public string SearchBefore { get; set; } - - /// - /// Creates a clone of the options. - /// - /// A clone of the options. - public SearchOptions Clone() - => new() - { - CountOptions = CountOptions?.Clone(), - Highlight = Highlight?.Clone(), - IndexName = IndexName, - ReturnStoredSource = ReturnStoredSource, - ScoreDetails = ScoreDetails, - Sort = Sort, - Tracking = Tracking?.Clone(), - SearchAfter = SearchAfter, - SearchBefore = SearchBefore, - }; - } } diff --git a/src/MongoDB.Driver/Search/SearchTrackingOptions.cs b/src/MongoDB.Driver/Search/SearchTrackingOptions.cs index 3f6ba12a8b7..19c60c2afe8 100644 --- a/src/MongoDB.Driver/Search/SearchTrackingOptions.cs +++ b/src/MongoDB.Driver/Search/SearchTrackingOptions.cs @@ -34,12 +34,6 @@ public string SearchTerms set => _searchTerms = Ensure.IsNotNullOrEmpty(value, nameof(value)); } - /// - /// Creates a clone of the options. - /// - /// A clone of the options. - public SearchTrackingOptions Clone() => new() { _searchTerms = SearchTerms }; - internal BsonDocument Render() => new() {