diff --git a/src/MongoDB.Driver/AggregateFluentBase.cs b/src/MongoDB.Driver/AggregateFluentBase.cs index 51381f930bd..4aad9f8a97e 100644 --- a/src/MongoDB.Driver/AggregateFluentBase.cs +++ b/src/MongoDB.Driver/AggregateFluentBase.cs @@ -248,26 +248,20 @@ 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 SearchMeta( SearchDefinition searchDefinition, string indexName = null, SearchCountOptions count = null) - { - throw new NotImplementedException(); - } + => throw new NotImplementedException(); /// public virtual IAggregateFluent Set(SetFieldDefinitions fields) => throw new NotImplementedException(); 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/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..e6338366e66 100644 --- a/src/MongoDB.Driver/PipelineStageDefinitionBuilder.cs +++ b/src/MongoDB.Driver/PipelineStageDefinitionBuilder.cs @@ -1437,16 +1437,52 @@ 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 + { + 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); renderedSearchDefinition.Add("count", () => searchOptions.CountOptions.Render(), searchOptions.CountOptions != null); renderedSearchDefinition.Add("sort", () => searchOptions.Sort.Render(args), searchOptions.Sort != null); @@ -1458,7 +1494,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 +1512,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 +1540,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/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..66d7f3c896b 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 in the ancestor context specified by . + /// 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 in the ancestor context specified by . + /// 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/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..cbd0d70c09c 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; @@ -39,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[] { @@ -280,13 +286,195 @@ public class AtlasSearchTests : LoggableTestClass #endregion private readonly IMongoClient _mongoClient; + private readonly EventCapturer _eventCapturer; 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(); + protected override void DisposeInternal() + { + if (__returnScopeTestCount == 0) + { + _mongoClient.DropDatabase("return_scope_" + __databaseUniquifier); + __returnScopeTestCount = -1; + } + _mongoClient.Dispose(); + } [Fact] public void Autocomplete() @@ -337,7 +525,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 +539,56 @@ public void EmbeddedDocument() } } + [Fact] + public void EmbeddedDocument_with_HasAncestor() + { + 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() + { + 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); + + 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] public void EqualsGuid() { @@ -734,6 +972,131 @@ 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) + { + 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 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 } }"); + } + finally + { + // Temporary shared resource cleanup 3 & 4. + __returnScopeTestCount--; + } + } + + [Fact] + public void ReturnScope_nested_path() + { + 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", ReturnStoredSource = true }).ToList(); + + results.Count.Should().Be(0); + + 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] + [InlineData(false)] + [InlineData(true)] + public void ReturnScope_HasRoot(bool useExpression) + { + 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 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); + + 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] + [InlineData(false)] + [InlineData(true)] + public void ReturnScope_HasAncestor(bool useExpression) + { + 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 aggregate = GetReturnScopeCollection().Aggregate(); + + var results = aggregate.Search( + searchDefinition, + "movies.reviews", + new() { IndexName = "returnScopeIndex2", ReturnStoredSource = true }).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 } }"); + } + finally + { + // Temporary shared resource cleanup 8 & 9. + __returnScopeTestCount--; + } + } + [Fact] public void SearchSequenceToken() { @@ -864,6 +1227,55 @@ public void SearchMeta_facet() bucket.Count.Should().Be(108); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SearchMeta_with_ReturnScope(bool useExpression) + { + 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 results = query.ToList(); + results.Count.Should().Be(1); + + 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() + { + 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(); + + results.Count.Should().Be(1); + + ValidateSearchStage( + "{ $searchMeta: { range: { gte: 8.0, path: 'movies.reviews.rating' }, index: 'returnScopeIndex2', returnScope: { path: 'movies.reviews' } } }"); + } + finally + { + // Temporary shared resource cleanup 12. + __returnScopeTestCount--; + } + } + [Fact] public void Should() { @@ -1214,6 +1626,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 +1763,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();