Skip to content

Commit e242566

Browse files
authored
CSHARP-5769: Implement hasAncestor, hasRoot, and returnScope for Atlas Search (#1933)
* 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. * Move new IAggregateFluent members to extension methods * Review updates * Remove SearchOptions.Clone
1 parent 91d86ae commit e242566

13 files changed

+851
-20
lines changed

src/MongoDB.Driver/AggregateFluentBase.cs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -248,26 +248,20 @@ public virtual IAggregateFluent<TResult> Search(
248248
SearchCountOptions count = null,
249249
bool returnStoredSource = false,
250250
bool scoreDetails = false)
251-
{
252-
throw new NotImplementedException();
253-
}
251+
=> throw new NotImplementedException();
254252

255253
/// <inheritdoc />
256254
public virtual IAggregateFluent<TResult> Search(
257255
SearchDefinition<TResult> searchDefinition,
258256
SearchOptions<TResult> searchOptions)
259-
{
260-
throw new NotImplementedException();
261-
}
257+
=> throw new NotImplementedException();
262258

263259
/// <inheritdoc />
264260
public virtual IAggregateFluent<SearchMetaResult> SearchMeta(
265261
SearchDefinition<TResult> searchDefinition,
266262
string indexName = null,
267263
SearchCountOptions count = null)
268-
{
269-
throw new NotImplementedException();
270-
}
264+
=> throw new NotImplementedException();
271265

272266
/// <inheritdoc />
273267
public virtual IAggregateFluent<TResult> Set(SetFieldDefinitions<TResult> fields) => throw new NotImplementedException();

src/MongoDB.Driver/IAggregateFluentExtensions.cs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
using MongoDB.Bson.Serialization;
2424
using MongoDB.Driver.Core.Misc;
2525
using MongoDB.Driver.GeoJsonObjectModel;
26+
using MongoDB.Driver.Search;
2627

2728
namespace MongoDB.Driver
2829
{
@@ -694,6 +695,99 @@ public static IAggregateFluent<TNewResult> ReplaceWith<TResult, TNewResult>(
694695
return aggregate.AppendStage(PipelineStageDefinitionBuilder.ReplaceWith(newRoot));
695696
}
696697

698+
/// <summary>
699+
/// Appends a $search stage to the pipeline, returning documents from a nested scope.
700+
/// </summary>
701+
/// <typeparam name="TResult">The type of the result.</typeparam>
702+
/// <typeparam name="TNewResult">The type of the new result.</typeparam>
703+
/// <param name="aggregate">The aggregate.</param>
704+
/// <param name="searchDefinition">The search definition.</param>
705+
/// <param name="returnScope">The level of nested documents to return.</param>
706+
/// <param name="searchOptions">The search options.</param>
707+
/// <returns>The fluent aggregate interface.</returns>
708+
public static IAggregateFluent<TNewResult> Search<TResult, TNewResult>(
709+
this IAggregateFluent<TResult> aggregate,
710+
SearchDefinition<TResult> searchDefinition,
711+
FieldDefinition<TResult, IEnumerable<TNewResult>> returnScope,
712+
SearchOptions<TResult> searchOptions = null)
713+
{
714+
Ensure.IsNotNull(aggregate, nameof(aggregate));
715+
Ensure.IsNotNull(searchDefinition, nameof(searchDefinition));
716+
Ensure.IsNotNull(returnScope, nameof(returnScope));
717+
718+
return aggregate.AppendStage(
719+
PipelineStageDefinitionBuilder.Search(searchDefinition, returnScope, searchOptions));
720+
}
721+
722+
/// <summary>
723+
/// Appends a $search stage to the pipeline, returning documents from a nested scope.
724+
/// </summary>
725+
/// <typeparam name="TResult">The type of the result.</typeparam>
726+
/// <typeparam name="TNewResult">The type of the new result.</typeparam>
727+
/// <param name="aggregate">The aggregate.</param>
728+
/// <param name="searchDefinition">The search definition.</param>
729+
/// <param name="returnScope">The level of nested documents to return.</param>
730+
/// <param name="searchOptions">The search options.</param>
731+
/// <returns>The fluent aggregate interface.</returns>
732+
public static IAggregateFluent<TNewResult> Search<TResult, TNewResult>(
733+
this IAggregateFluent<TResult> aggregate,
734+
SearchDefinition<TResult> searchDefinition,
735+
Expression<Func<TResult, IEnumerable<TNewResult>>> returnScope,
736+
SearchOptions<TResult> searchOptions = null)
737+
=> Search(
738+
aggregate,
739+
searchDefinition,
740+
new ExpressionFieldDefinition<TResult, IEnumerable<TNewResult>>(returnScope),
741+
searchOptions);
742+
743+
/// <summary>
744+
/// Appends a $searchMeta stage to the pipeline.
745+
/// </summary>
746+
/// <typeparam name="TResult">The type of the result.</typeparam>
747+
/// <param name="aggregate">The aggregate.</param>
748+
/// <param name="searchDefinition">The search definition.</param>
749+
/// <param name="returnScope">The level of nested documents to return.</param>
750+
/// <param name="indexName">The index name.</param>
751+
/// <param name="count">The count options.</param>
752+
/// <returns>The fluent aggregate interface.</returns>
753+
public static IAggregateFluent<SearchMetaResult> SearchMeta<TResult>(
754+
this IAggregateFluent<TResult> aggregate,
755+
SearchDefinition<TResult> searchDefinition,
756+
FieldDefinition<TResult> returnScope,
757+
string indexName = null,
758+
SearchCountOptions count = null)
759+
{
760+
Ensure.IsNotNull(aggregate, nameof(aggregate));
761+
Ensure.IsNotNull(searchDefinition, nameof(searchDefinition));
762+
Ensure.IsNotNull(returnScope, nameof(returnScope));
763+
764+
return aggregate.AppendStage(
765+
PipelineStageDefinitionBuilder.SearchMeta(searchDefinition, returnScope, indexName, count));
766+
}
767+
768+
/// <summary>
769+
/// Appends a $searchMeta stage to the pipeline.
770+
/// </summary>
771+
/// <typeparam name="TResult">The type of the result.</typeparam>
772+
/// <param name="aggregate">The aggregate.</param>
773+
/// <param name="searchDefinition">The search definition.</param>
774+
/// <param name="returnScope">The level of nested documents to return.</param>
775+
/// <param name="indexName">The index name.</param>
776+
/// <param name="count">The count options.</param>
777+
/// <returns>The fluent aggregate interface.</returns>
778+
public static IAggregateFluent<SearchMetaResult> SearchMeta<TResult>(
779+
this IAggregateFluent<TResult> aggregate,
780+
SearchDefinition<TResult> searchDefinition,
781+
Expression<Func<TResult, object>> returnScope,
782+
string indexName = null,
783+
SearchCountOptions count = null)
784+
=> SearchMeta(
785+
aggregate,
786+
searchDefinition,
787+
new ExpressionFieldDefinition<TResult>(returnScope),
788+
indexName,
789+
count);
790+
697791
/// <summary>
698792
/// Appends a $set stage to the pipeline.
699793
/// </summary>

src/MongoDB.Driver/PipelineDefinitionBuilder.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,6 +1217,27 @@ public static PipelineDefinition<TInput, TOutput> Search<TInput, TOutput>(
12171217
return pipeline.AppendStage(PipelineStageDefinitionBuilder.Search(searchDefinition, searchOptions));
12181218
}
12191219

1220+
/// <summary>
1221+
/// Appends a $search stage to the pipeline.
1222+
/// </summary>
1223+
/// <typeparam name="TInput">The type of the input documents.</typeparam>
1224+
/// <typeparam name="TIntermediate">The type of the intermediate documents.</typeparam>
1225+
/// <typeparam name="TOutput">The type of the output documents.</typeparam>
1226+
/// <param name="pipeline">The pipeline.</param>
1227+
/// <param name="searchDefinition">The search definition.</param>
1228+
/// <param name="returnScope">The level of nested documents to return.</param>
1229+
/// <param name="searchOptions">The search options.</param>
1230+
/// <returns>A new pipeline with an additional stage.</returns>
1231+
public static PipelineDefinition<TInput, TOutput> Search<TInput, TIntermediate, TOutput>(
1232+
this PipelineDefinition<TInput, TIntermediate> pipeline,
1233+
SearchDefinition<TIntermediate> searchDefinition,
1234+
FieldDefinition<TIntermediate, IEnumerable<TOutput>> returnScope,
1235+
SearchOptions<TIntermediate> searchOptions)
1236+
{
1237+
Ensure.IsNotNull(pipeline, nameof(pipeline));
1238+
return pipeline.AppendStage(PipelineStageDefinitionBuilder.Search(searchDefinition, returnScope, searchOptions));
1239+
}
1240+
12201241
/// <summary>
12211242
/// Appends a $searchMeta stage to the pipeline.
12221243
/// </summary>
@@ -1237,6 +1258,28 @@ public static PipelineDefinition<TInput, SearchMetaResult> SearchMeta<TInput, TO
12371258
return pipeline.AppendStage(PipelineStageDefinitionBuilder.SearchMeta(query, indexName, count));
12381259
}
12391260

1261+
/// <summary>
1262+
/// Appends a $searchMeta stage to the pipeline.
1263+
/// </summary>
1264+
/// <typeparam name="TInput">The type of the input documents.</typeparam>
1265+
/// <typeparam name="TOutput">The type of the output documents.</typeparam>
1266+
/// <param name="pipeline">The pipeline.</param>
1267+
/// <param name="query">The search definition.</param>
1268+
/// <param name="returnScope">The level of nested documents to return.</param>
1269+
/// <param name="indexName">The index name.</param>
1270+
/// <param name="count">The count options.</param>
1271+
/// <returns>A new pipeline with an additional stage.</returns>
1272+
public static PipelineDefinition<TInput, SearchMetaResult> SearchMeta<TInput, TOutput>(
1273+
this PipelineDefinition<TInput, TOutput> pipeline,
1274+
SearchDefinition<TOutput> query,
1275+
FieldDefinition<TOutput> returnScope,
1276+
string indexName = null,
1277+
SearchCountOptions count = null)
1278+
{
1279+
Ensure.IsNotNull(pipeline, nameof(pipeline));
1280+
return pipeline.AppendStage(PipelineStageDefinitionBuilder.SearchMeta(query, returnScope, indexName, count));
1281+
}
1282+
12401283
/// <summary>
12411284
/// Appends a $set stage to the pipeline.
12421285
/// </summary>

src/MongoDB.Driver/PipelineStageDefinitionBuilder.cs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,16 +1437,52 @@ public static PipelineStageDefinition<TInput, TInput> Search<TInput>(
14371437
public static PipelineStageDefinition<TInput, TInput> Search<TInput>(
14381438
SearchDefinition<TInput> searchDefinition,
14391439
SearchOptions<TInput> searchOptions)
1440+
=> Search<TInput, TInput>(searchDefinition, returnScope: null, searchOptions);
1441+
1442+
/// <summary>
1443+
/// Creates a $search stage.
1444+
/// </summary>
1445+
/// <typeparam name="TInput">The type of the input documents.</typeparam>
1446+
/// <typeparam name="TOutput">The type of the output documents.</typeparam>
1447+
/// <param name="searchDefinition">The search definition.</param>
1448+
/// <param name="returnScope">The level of nested documents to return.</param>
1449+
/// <param name="searchOptions">The search options.</param>
1450+
/// <returns>The stage.</returns>
1451+
public static PipelineStageDefinition<TInput, TOutput> Search<TInput, TOutput>(
1452+
SearchDefinition<TInput> searchDefinition,
1453+
FieldDefinition<TInput, IEnumerable<TOutput>> returnScope,
1454+
SearchOptions<TInput> searchOptions)
14401455
{
14411456
Ensure.IsNotNull(searchDefinition, nameof(searchDefinition));
14421457

1458+
searchOptions ??= new SearchOptions<TInput>();
1459+
14431460
const string operatorName = "$search";
1444-
var stage = new DelegatedPipelineStageDefinition<TInput, TInput>(
1461+
var stage = new DelegatedPipelineStageDefinition<TInput, TOutput>(
14451462
operatorName,
14461463
args =>
14471464
{
14481465
ClientSideProjectionHelper.ThrowIfClientSideProjection(args.DocumentSerializer, operatorName);
14491466
var renderedSearchDefinition = searchDefinition.Render(args);
1467+
1468+
IBsonSerializer<TOutput> outputSerializer;
1469+
if (returnScope == null)
1470+
{
1471+
if (typeof(TOutput) != typeof(TInput))
1472+
{
1473+
throw new InvalidOperationException(
1474+
$"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.");
1475+
}
1476+
1477+
outputSerializer = (IBsonSerializer<TOutput>)args.DocumentSerializer;
1478+
}
1479+
else
1480+
{
1481+
var renderedField = returnScope.Render(args);
1482+
outputSerializer = (IBsonSerializer<TOutput>)renderedField.ValueSerializer.GetItemSerializer();
1483+
renderedSearchDefinition.Add("returnScope", new BsonDocument { { "path", renderedField.FieldName } });
1484+
}
1485+
14501486
renderedSearchDefinition.Add("highlight", () => searchOptions.Highlight.Render(args), searchOptions.Highlight != null);
14511487
renderedSearchDefinition.Add("count", () => searchOptions.CountOptions.Render(), searchOptions.CountOptions != null);
14521488
renderedSearchDefinition.Add("sort", () => searchOptions.Sort.Render(args), searchOptions.Sort != null);
@@ -1458,7 +1494,7 @@ public static PipelineStageDefinition<TInput, TInput> Search<TInput>(
14581494
renderedSearchDefinition.Add("searchBefore", () => searchOptions.SearchBefore, searchOptions.SearchBefore != null);
14591495

14601496
var document = new BsonDocument(operatorName, renderedSearchDefinition);
1461-
return new RenderedPipelineStageDefinition<TInput>(operatorName, document, args.DocumentSerializer);
1497+
return new RenderedPipelineStageDefinition<TOutput>(operatorName, document, outputSerializer);
14621498
});
14631499

14641500
return stage;
@@ -1476,6 +1512,22 @@ public static PipelineStageDefinition<TInput, SearchMetaResult> SearchMeta<TInpu
14761512
SearchDefinition<TInput> searchDefinition,
14771513
string indexName = null,
14781514
SearchCountOptions count = null)
1515+
=> SearchMeta(searchDefinition, returnScope: null, indexName, count);
1516+
1517+
/// <summary>
1518+
/// Creates a $searchMeta stage.
1519+
/// </summary>
1520+
/// <typeparam name="TInput">The type of the input documents.</typeparam>
1521+
/// <param name="searchDefinition">The search definition.</param>
1522+
/// <param name="returnScope">The level of nested documents to return.</param>
1523+
/// <param name="indexName">The index name.</param>
1524+
/// <param name="count">The count options.</param>
1525+
/// <returns>The stage.</returns>
1526+
public static PipelineStageDefinition<TInput, SearchMetaResult> SearchMeta<TInput>(
1527+
SearchDefinition<TInput> searchDefinition,
1528+
FieldDefinition<TInput> returnScope,
1529+
string indexName = null,
1530+
SearchCountOptions count = null)
14791531
{
14801532
Ensure.IsNotNull(searchDefinition, nameof(searchDefinition));
14811533

@@ -1488,6 +1540,7 @@ public static PipelineStageDefinition<TInput, SearchMetaResult> SearchMeta<TInpu
14881540
var renderedSearchDefinition = searchDefinition.Render(args);
14891541
renderedSearchDefinition.Add("count", () => count.Render(), count != null);
14901542
renderedSearchDefinition.Add("index", indexName, indexName != null);
1543+
renderedSearchDefinition.Add("returnScope", () => new BsonDocument { { "path", returnScope!.Render(args).FieldName } }, returnScope != null);
14911544

14921545
var document = new BsonDocument(operatorName, renderedSearchDefinition);
14931546
return new RenderedPipelineStageDefinition<SearchMetaResult>(

src/MongoDB.Driver/Search/OperatorSearchDefinitions.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,52 @@ private protected override BsonDocument RenderArguments(
235235
new(_area.Render());
236236
}
237237

238+
internal sealed class HasAncestorSearchDefinition<TDocument> : OperatorSearchDefinition<TDocument>
239+
{
240+
private readonly FieldDefinition<TDocument> _ancestorPath;
241+
private readonly SearchDefinition<TDocument> _operator;
242+
243+
public HasAncestorSearchDefinition(
244+
FieldDefinition<TDocument> ancestorPath,
245+
SearchDefinition<TDocument> @operator,
246+
SearchScoreDefinition<TDocument> score)
247+
: base(OperatorType.HasAncestor, score)
248+
{
249+
_ancestorPath = Ensure.IsNotNull(ancestorPath, nameof(ancestorPath));
250+
_operator = Ensure.IsNotNull(@operator, nameof(@operator));
251+
}
252+
253+
private protected override BsonDocument RenderArguments(
254+
RenderArgs<TDocument> args,
255+
IBsonSerializer fieldSerializer)
256+
=> new()
257+
{
258+
{ "ancestorPath", _ancestorPath.Render(args).FieldName },
259+
{ "operator", _operator.Render(args with { PathRenderArgs = args.PathRenderArgs with { PathPrefix = null } }) }
260+
};
261+
}
262+
263+
internal sealed class HasRootSearchDefinition<TDocument> : OperatorSearchDefinition<TDocument>
264+
{
265+
private readonly SearchDefinition<TDocument> _operator;
266+
267+
public HasRootSearchDefinition(
268+
SearchDefinition<TDocument> @operator,
269+
SearchScoreDefinition<TDocument> score)
270+
: base(OperatorType.HasRoot, score)
271+
{
272+
_operator = Ensure.IsNotNull(@operator, nameof(@operator));
273+
}
274+
275+
private protected override BsonDocument RenderArguments(
276+
RenderArgs<TDocument> args,
277+
IBsonSerializer fieldSerializer)
278+
=> new()
279+
{
280+
{ "operator", _operator.Render(args with { PathRenderArgs = args.PathRenderArgs with { PathPrefix = null } }) }
281+
};
282+
}
283+
238284
internal sealed class InSearchDefinition<TDocument, TField> : OperatorSearchDefinition<TDocument>
239285
{
240286
private readonly TField[] _values;

src/MongoDB.Driver/Search/SearchDefinition.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ private protected enum OperatorType
144144
Facet,
145145
GeoShape,
146146
GeoWithin,
147+
HasAncestor,
148+
HasRoot,
147149
In,
148150
MoreLikeThis,
149151
Near,

0 commit comments

Comments
 (0)