Skip to content

Commit b576e95

Browse files
committed
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.
1 parent e11bcc8 commit b576e95

19 files changed

Lines changed: 875 additions & 19 deletions

Agents.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Agents.md - CSharpDriver
2+
3+
## Overview
4+
The C# driver for MongoDB.
5+
6+
## Tech Stack
7+
- .NET library projects producing NuGet packages
8+
- Multi-targeted from .NET Framework 4.7.2 through .NET 10
9+
- xUnit + FluentAssertions for testing
10+
11+
## Project Structure
12+
- `src/MongoDB.Bson/` - BSON for MongoDB
13+
- `src/MongoDB.Driver/` - C# driver for MongoDB
14+
- `MongoDB.Driver.Encryption` - Client encryption (CSFLE with KMS).
15+
- `MongoDB.Driver.Authentication.AWS` - AWS IAM authentication
16+
- `tests/MongoDB.Driver.Tests/` - Main C# driver tests
17+
- `tests/MongoDB.Bson.Tests/` - BSON handling tests
18+
- `tests/*/TestHelpers` - Common test utilities
19+
- `tests/*` - Specialized tests; less common
20+
- `tests/MongoDB.Driver.Tests/Specifications/` are JSON-driven tests using a common runner.
21+
22+
## Commands
23+
- Build: `dotnet build CSharpDriver.sln`
24+
- Run all tests: `dotnet test tests/MongoDB.Driver.Tests/MongoDB.Driver.Tests.csproj -f net10.0`
25+
- Run a single test class: `dotnet test tests/MongoDB.Driver.Tests/MongoDB.Driver.Tests.csproj -f net10.0 --filter "FullyQualifiedName~ClassName"`
26+
27+
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.
28+
29+
| Feature area | Required environment variables |
30+
|---|---|
31+
| Atlas Search | `ATLAS_SEARCH_TESTS_ENABLED`, `ATLAS_SEARCH_URI` |
32+
| Atlas Search index helpers | `ATLAS_SEARCH_INDEX_HELPERS_TESTS_ENABLED`, `ATLAS_SEARCH_URI` |
33+
| CSFLE / auto-encryption | `CRYPT_SHARED_LIB_PATH` |
34+
| CSFLE with KMS mock servers | `KMS_MOCK_SERVERS_ENABLED` |
35+
| CSFLE with AWS KMS | `CSFLE_AWS_TEMPORARY_CREDS_ENABLED` |
36+
| CSFLE with Azure KMS | `CSFLE_AZURE_KMS_TESTS_ENABLED` |
37+
| CSFLE with GCP KMS | `CSFLE_GCP_KMS_TESTS_ENABLED` |
38+
| AWS authentication | `AWS_TESTS_ENABLED` |
39+
| GSSAPI / Kerberos | `GSSAPI_TESTS_ENABLED`, `AUTH_HOST`, `AUTH_GSSAPI` |
40+
| OIDC authentication | `OIDC_ENV` |
41+
| X.509 authentication | `MONGO_X509_CLIENT_CERTIFICATE_PATH`, `MONGO_X509_CLIENT_CERTIFICATE_PASSWORD` |
42+
| PLAIN authentication | `PLAIN_AUTH_TESTS_ENABLED` |
43+
| SOCKS5 proxy | `SOCKS5_PROXY_SERVERS_ENABLED` |
44+
45+
## Commit and PR Conventions
46+
47+
- Commit and PR messages start with a JIRA number: `CSHARP-1234: Description`

src/MongoDB.Driver/AggregateFluent.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,12 @@ public override IAggregateFluent<TResult> Search(
299299
return WithPipeline(_pipeline.Search(searchDefinition, searchOptions));
300300
}
301301

302+
public override IAggregateFluent<TNewResult> Search<TNewResult>(
303+
SearchDefinition<TResult> searchDefinition,
304+
FieldDefinition<TResult, IEnumerable<TNewResult>> returnScope,
305+
SearchOptions<TResult> searchOptions = null)
306+
=> WithPipeline(_pipeline.Search(searchDefinition, returnScope, searchOptions));
307+
302308
public override IAggregateFluent<SearchMetaResult> SearchMeta(
303309
SearchDefinition<TResult> searchDefinition,
304310
string indexName = null,
@@ -307,6 +313,13 @@ public override IAggregateFluent<SearchMetaResult> SearchMeta(
307313
return WithPipeline(_pipeline.SearchMeta(searchDefinition, indexName, count));
308314
}
309315

316+
public override IAggregateFluent<SearchMetaResult> SearchMeta(
317+
SearchDefinition<TResult> searchDefinition,
318+
FieldDefinition<TResult> returnScope,
319+
string indexName = null,
320+
SearchCountOptions count = null)
321+
=> WithPipeline(_pipeline.SearchMeta(searchDefinition, returnScope, indexName, count));
322+
310323
public override IAggregateFluent<TResult> Set(SetFieldDefinitions<TResult> fields)
311324
{
312325
return WithPipeline(_pipeline.Set(fields));

src/MongoDB.Driver/AggregateFluentBase.cs

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
using System;
1717
using System.Collections.Generic;
18+
using System.Linq.Expressions;
1819
using System.Threading;
1920
using System.Threading.Tasks;
2021
using MongoDB.Bson;
@@ -248,26 +249,57 @@ public virtual IAggregateFluent<TResult> Search(
248249
SearchCountOptions count = null,
249250
bool returnStoredSource = false,
250251
bool scoreDetails = false)
251-
{
252-
throw new NotImplementedException();
253-
}
252+
=> throw new NotImplementedException();
254253

255254
/// <inheritdoc />
256255
public virtual IAggregateFluent<TResult> Search(
257256
SearchDefinition<TResult> searchDefinition,
258257
SearchOptions<TResult> searchOptions)
259-
{
260-
throw new NotImplementedException();
261-
}
258+
=> throw new NotImplementedException();
259+
260+
/// <inheritdoc />
261+
public virtual IAggregateFluent<TNewResult> Search<TNewResult>(
262+
SearchDefinition<TResult> searchDefinition,
263+
FieldDefinition<TResult, IEnumerable<TNewResult>> returnScope,
264+
SearchOptions<TResult> searchOptions = null)
265+
=> throw new NotImplementedException();
266+
267+
/// <inheritdoc />
268+
public virtual IAggregateFluent<TNewResult> Search<TNewResult>(
269+
SearchDefinition<TResult> searchDefinition,
270+
Expression<Func<TResult, IEnumerable<TNewResult>>> returnScope,
271+
SearchOptions<TResult> searchOptions = null)
272+
=> Search(
273+
searchDefinition,
274+
new ExpressionFieldDefinition<TResult, IEnumerable<TNewResult>>(returnScope),
275+
searchOptions);
262276

263277
/// <inheritdoc />
264278
public virtual IAggregateFluent<SearchMetaResult> SearchMeta(
265279
SearchDefinition<TResult> searchDefinition,
266280
string indexName = null,
267281
SearchCountOptions count = null)
268-
{
269-
throw new NotImplementedException();
270-
}
282+
=> throw new NotImplementedException();
283+
284+
/// <inheritdoc />
285+
public virtual IAggregateFluent<SearchMetaResult> SearchMeta(
286+
SearchDefinition<TResult> searchDefinition,
287+
FieldDefinition<TResult> returnScope,
288+
string indexName = null,
289+
SearchCountOptions count = null)
290+
=> throw new NotImplementedException();
291+
292+
/// <inheritdoc />
293+
public virtual IAggregateFluent<SearchMetaResult> SearchMeta(
294+
SearchDefinition<TResult> searchDefinition,
295+
Expression<Func<TResult, object>> returnScope,
296+
string indexName = null,
297+
SearchCountOptions count = null)
298+
=> SearchMeta(
299+
searchDefinition,
300+
new ExpressionFieldDefinition<TResult>(returnScope),
301+
indexName,
302+
count);
271303

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

src/MongoDB.Driver/IAggregateFluent.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
using System;
1717
using System.Collections.Generic;
18+
using System.Linq.Expressions;
1819
using System.Threading;
1920
using System.Threading.Tasks;
2021
using MongoDB.Bson;
@@ -440,15 +441,71 @@ IAggregateFluent<TResult> Search(
440441
SearchDefinition<TResult> searchDefinition,
441442
SearchOptions<TResult> searchOptions);
442443

444+
/// <summary>
445+
/// Appends a $search stage to the pipeline, returning documents from a nested scope.
446+
/// </summary>
447+
/// <param name="searchDefinition">The search definition.</param>
448+
/// <param name="returnScope">The level of nested documents to return.</param>
449+
/// <param name="searchOptions">The search options.</param>
450+
/// <returns>
451+
/// The fluent aggregate interface.
452+
/// </returns>
453+
IAggregateFluent<TNewResult> Search<TNewResult>(
454+
SearchDefinition<TResult> searchDefinition,
455+
FieldDefinition<TResult, IEnumerable<TNewResult>> returnScope,
456+
SearchOptions<TResult> searchOptions = null);
457+
458+
/// <summary>
459+
/// Appends a $search stage to the pipeline, returning documents from a nested scope.
460+
/// </summary>
461+
/// <param name="searchDefinition">The search definition.</param>
462+
/// <param name="returnScope">The level of nested documents to return.</param>
463+
/// <param name="searchOptions">The search options.</param>
464+
/// <returns>
465+
/// The fluent aggregate interface.
466+
/// </returns>
467+
IAggregateFluent<TNewResult> Search<TNewResult>(
468+
SearchDefinition<TResult> searchDefinition,
469+
Expression<Func<TResult, IEnumerable<TNewResult>>> returnScope,
470+
SearchOptions<TResult> searchOptions = null);
471+
472+
/// <summary>
473+
/// Appends a $searchMeta stage to the pipeline.
474+
/// </summary>
475+
/// <param name="searchDefinition">The search definition.</param>
476+
/// <param name="indexName">The index name.</param>
477+
/// <param name="count">The count options.</param>
478+
/// <returns>The fluent aggregate interface.</returns>
479+
IAggregateFluent<SearchMetaResult> SearchMeta(
480+
SearchDefinition<TResult> searchDefinition,
481+
string indexName = null,
482+
SearchCountOptions count = null);
483+
484+
/// <summary>
485+
/// Appends a $searchMeta stage to the pipeline.
486+
/// </summary>
487+
/// <param name="searchDefinition">The search definition.</param>
488+
/// <param name="returnScope">The level of nested documents to return.</param>
489+
/// <param name="indexName">The index name.</param>
490+
/// <param name="count">The count options.</param>
491+
/// <returns>The fluent aggregate interface.</returns>
492+
IAggregateFluent<SearchMetaResult> SearchMeta(
493+
SearchDefinition<TResult> searchDefinition,
494+
FieldDefinition<TResult> returnScope,
495+
string indexName = null,
496+
SearchCountOptions count = null);
497+
443498
/// <summary>
444499
/// Appends a $searchMeta stage to the pipeline.
445500
/// </summary>
446501
/// <param name="searchDefinition">The search definition.</param>
502+
/// <param name="returnScope">The level of nested documents to return.</param>
447503
/// <param name="indexName">The index name.</param>
448504
/// <param name="count">The count options.</param>
449505
/// <returns>The fluent aggregate interface.</returns>
450506
IAggregateFluent<SearchMetaResult> SearchMeta(
451507
SearchDefinition<TResult> searchDefinition,
508+
Expression<Func<TResult, object>> returnScope,
452509
string indexName = null,
453510
SearchCountOptions count = null);
454511

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: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1437,16 +1437,53 @@ 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+
outputSerializer = args.SerializerRegistry.GetSerializer<TOutput>();
1482+
renderedSearchDefinition.Add("returnScope", new BsonDocument { { "path", returnScope.Render(args).FieldName } });
1483+
searchOptions = searchOptions.Clone();
1484+
searchOptions.ReturnStoredSource = true;
1485+
}
1486+
14501487
renderedSearchDefinition.Add("highlight", () => searchOptions.Highlight.Render(args), searchOptions.Highlight != null);
14511488
renderedSearchDefinition.Add("count", () => searchOptions.CountOptions.Render(), searchOptions.CountOptions != null);
14521489
renderedSearchDefinition.Add("sort", () => searchOptions.Sort.Render(args), searchOptions.Sort != null);
@@ -1458,7 +1495,7 @@ public static PipelineStageDefinition<TInput, TInput> Search<TInput>(
14581495
renderedSearchDefinition.Add("searchBefore", () => searchOptions.SearchBefore, searchOptions.SearchBefore != null);
14591496

14601497
var document = new BsonDocument(operatorName, renderedSearchDefinition);
1461-
return new RenderedPipelineStageDefinition<TInput>(operatorName, document, args.DocumentSerializer);
1498+
return new RenderedPipelineStageDefinition<TOutput>(operatorName, document, outputSerializer);
14621499
});
14631500

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

@@ -1488,6 +1541,7 @@ public static PipelineStageDefinition<TInput, SearchMetaResult> SearchMeta<TInpu
14881541
var renderedSearchDefinition = searchDefinition.Render(args);
14891542
renderedSearchDefinition.Add("count", () => count.Render(), count != null);
14901543
renderedSearchDefinition.Add("index", indexName, indexName != null);
1544+
renderedSearchDefinition.Add("returnScope", () => new BsonDocument { { "path", returnScope!.Render(args).FieldName } }, returnScope != null);
14911545

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

0 commit comments

Comments
 (0)