Skip to content

Commit 50891f7

Browse files
authored
Adds nested field support with query, aggregation tests and serialization fixes (#150)
* Adds nested field support in tests Introduces nested field support and related tests. This commit addresses the need for testing nested fields. It includes a new test file, updates configurations, and adds utilities for generating test data with nested properties. A nested field is also added to the default search fields. * formatting updates * Adds nested aggregations filtering tests Adds tests to verify filtering of nested aggregations using include and exclude. This ensures that aggregations can be filtered based on specific values within nested fields, providing more granular control over the aggregation results. Also improves readability in existing tests. * Fixes nested aggregation serialization Addresses issues with deserializing nested aggregations in Elasticsearch queries. This change ensures that nested aggregations, particularly those involving terms aggregations on nested fields, are correctly serialized and deserialized. It corrects the format of aggregations expressions in tests and handles the deserialization of SingleBucketAggregates correctly. * Adds comprehensive nested field query tests Updates Foundatio.Parsers.ElasticQueries dependency to support enhanced nested field parsing. Introduces new test cases for various nested query scenarios in FindAsync: - Queries on individual nested fields without group syntax. - Nested range queries. - Nested AND conditions without group syntax. - Queries mixing nested and non-nested fields. - Negated nested group queries. Refactors existing test code for clarity and minor performance improvements. * Add comprehensive serialization round-trip tests and upgrade parsers - Add round-trip tests for all aggregate types (ValueAggregate, StatsAggregate, ExtendedStatsAggregate, PercentilesAggregate, ObjectValueAggregate, DateValueAggregate, BucketAggregate, SingleBucketAggregate) - Add round-trip tests for all bucket types (KeyedBucket<string>, KeyedBucket<double>, DateHistogramBucket, RangeBucket) with inner aggregation preservation - Add deep nesting round-trip test (sbucket -> bucket -> keyed bucket -> value) - Upgrade Foundatio.Parsers.ElasticQueries to 7.18.0-beta4 - Simplify SetDefaultFields to use plain strings consistently Made-with: Cursor * Fix serializer converters to properly handle non-public setters Both Newtonsoft and System.Text.Json converters now explicitly extract Items for PercentilesAggregate, matching the existing pattern used by BucketsNewtonsoftJsonConverter for inner aggregations. This fixes a latent bug where serializer.Populate and element.Deserialize could not set properties with internal/protected setters through the custom converters. Reverts PercentilesAggregate.Items back to internal set to preserve the original API contract. Made-with: Cursor * Add isolated serializer tests and deep nesting coverage Re-adds isolated Newtonsoft and System.Text.Json tests for SingleBucketAggregate to catch serializer-specific regressions. Adds 3-level deep sbucket nesting test and mixed sub-aggregation test (stats + percentiles + bucket-with-sub-agg inside a single bucket). Made-with: Cursor * Refactor converters to use pattern matching and readonly types Replaces if/switch chains with switch expressions in both Newtonsoft and STJ aggregate converters. Extracts dedicated methods for percentiles and sbucket deserialization. Deserializes to IReadOnlyList to skip constructor allocations. Removes serializer-specific tests in favor of the existing all-serializers loop test. Made-with: Cursor * Clean up GetProperty and document tophits limitation Makes GetProperty and GetTokenType static directly, removing the unnecessary GetPropertyStatic/GetProperty indirection. Adds comments to both converters explaining why TopHitsAggregate cannot be round-tripped. Made-with: Cursor * Remove JsonConstructor attributes, handle all types explicitly in converters SingleBucketAggregate and MultiBucketAggregate no longer need JsonConstructor attributes. The STJ converter now explicitly extracts Aggregations for sbucket (matching the Newtonsoft converter pattern), so both serializers handle non-public setters consistently through converter-level extraction rather than relying on STJ fallback behavior. Made-with: Cursor * updated deps * Ensures culture-invariant string casing Updates `ToLower()` calls to `ToLowerInvariant()` for JSON property name lookups and Elasticsearch default field configurations. This promotes consistent string comparison and conversion, mitigating potential issues with culture-specific casing rules across different environments. Adds documentation to `AggregationQueryTests` explaining the limitations of JSON round-tripping for `TopHitsAggregate` due to its internal use of `ILazyDocument` references. Refactors test setup in `NestedFieldTests` by renaming `utcToday` to `baseDate` for improved clarity, better reflecting its role as a fixed reference point in the tests. * Docs: Add comprehensive nested query/aggregation guide Adds detailed documentation explaining how the framework automatically handles Elasticsearch nested types. Explains how filter and aggregation expressions on nested fields are automatically wrapped in appropriate nested queries and aggregations. Covers: - Basic and advanced nested query patterns - Nested field aggregations and result access - Sorting, _exists_/_missing_, and default fields with nested paths - The Top Hits aggregation and its serialization limitations
1 parent c63eefa commit 50891f7

19 files changed

Lines changed: 1399 additions & 150 deletions

File tree

docs/guide/elasticsearch-setup.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,8 @@ This creates:
380380
))
381381
```
382382

383+
Fields mapped as `nested` are automatically wrapped in Elasticsearch `nested` queries and `nested` aggregations when queried through filter or aggregation expressions. See [Nested Queries](/guide/querying#nested-queries) and [Nested Field Aggregations](/guide/querying#nested-field-aggregations) for details and examples.
384+
383385
### Index Settings
384386

385387
```csharp

docs/guide/querying.md

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,206 @@ Console.WriteLine($"Average Salary: {avgSalary}");
296296
Console.WriteLine($"Latest Created: {maxDate}");
297297
```
298298

299+
### Nested Field Aggregations
300+
301+
When aggregating on fields that are mapped as `nested` in Elasticsearch, the framework automatically wraps the aggregation in a nested aggregation context. Use the same `parentObject.childField` syntax you use for flat fields:
302+
303+
```csharp
304+
// Terms aggregation on a nested field
305+
var results = await repository.CountAsync(q => q
306+
.AggregationsExpression("terms:peerReviews.rating"));
307+
308+
// Multiple aggregation types on nested fields
309+
var results = await repository.CountAsync(q => q
310+
.AggregationsExpression("terms:peerReviews.reviewerEmployeeId min:peerReviews.rating max:peerReviews.rating"));
311+
```
312+
313+
The framework detects nested fields via the index mapping and groups all nested aggregations under a single `SingleBucketAggregate` keyed by the nested path. Access results through that wrapper:
314+
315+
```csharp
316+
var results = await repository.CountAsync(q => q
317+
.AggregationsExpression("terms:peerReviews.rating min:peerReviews.rating max:peerReviews.rating"));
318+
319+
// All nested aggregations are grouped under a single-bucket aggregate
320+
var nestedAgg = results.Aggregations["nested_peerReviews"] as SingleBucketAggregate;
321+
322+
var ratingTerms = nestedAgg.Aggregations.Terms<int>("terms_peerReviews.rating");
323+
foreach (var bucket in ratingTerms.Buckets)
324+
{
325+
Console.WriteLine($"Rating {bucket.Key}: {bucket.Total}");
326+
}
327+
328+
var minRating = nestedAgg.Aggregations.Min("min_peerReviews.rating")?.Value;
329+
var maxRating = nestedAgg.Aggregations.Max("max_peerReviews.rating")?.Value;
330+
```
331+
332+
Include and exclude filtering works the same way as non-nested aggregations:
333+
334+
```csharp
335+
// Only include specific terms
336+
var results = await repository.CountAsync(q => q
337+
.AggregationsExpression("terms:(peerReviews.reviewerEmployeeId @include:emp1 @include:emp2)"));
338+
339+
// Exclude specific terms
340+
var results = await repository.CountAsync(q => q
341+
.AggregationsExpression("terms:(peerReviews.rating @exclude:1 @exclude:2)"));
342+
```
343+
344+
### Top Hits Aggregation
345+
346+
The `tophits` sub-aggregation returns the top matching documents within each bucket:
347+
348+
```csharp
349+
var results = await repository.CountAsync(q => q
350+
.AggregationsExpression("terms:(age tophits:_)"));
351+
352+
var bucket = results.Aggregations.Terms<int>("terms_age").Buckets.First();
353+
var topHits = bucket.Aggregations.TopHits();
354+
var employees = topHits.Documents<Employee>();
355+
```
356+
357+
::: warning TopHitsAggregate Cannot Be Serialized
358+
`TopHitsAggregate` holds `ILazyDocument` references that contain raw Elasticsearch document bytes and require an active serializer instance to materialize into typed objects. These references are lost during JSON serialization, which means:
359+
360+
- **Caching**: `CountResult` or `FindResults` containing `TopHitsAggregate` cannot be cached and restored via JSON serialization (Newtonsoft or System.Text.Json). The top hits data will be `null` after deserialization.
361+
- **Workaround**: If you need to cache results that include top hits, materialize the documents into concrete types *before* caching, and cache those typed results separately.
362+
:::
363+
364+
## Nested Queries
365+
366+
When querying fields inside [nested objects](https://www.elastic.co/guide/en/elasticsearch/reference/current/nested.html), the framework automatically wraps filter expressions in the required Elasticsearch `nested` query. You do not need to manually construct nested queries -- just use dotted field paths.
367+
368+
### Prerequisites
369+
370+
The field must be mapped as `nested` in your index configuration:
371+
372+
```csharp
373+
public override TypeMappingDescriptor<Employee> ConfigureIndexMapping(
374+
TypeMappingDescriptor<Employee> map)
375+
{
376+
return map
377+
.Dynamic(false)
378+
.Properties(p => p
379+
.SetupDefaults()
380+
.Keyword(f => f.Name(e => e.Id))
381+
.Text(f => f.Name(e => e.Name).AddKeywordAndSortFields())
382+
.Nested<PeerReview>(f => f.Name(e => e.PeerReviews).Properties(p1 => p1
383+
.Keyword(f2 => f2.Name(p2 => p2.ReviewerEmployeeId))
384+
.Scalar(p3 => p3.Rating, f2 => f2.Name(p3 => p3.Rating))))
385+
);
386+
}
387+
```
388+
389+
### Basic Nested Queries
390+
391+
Query nested fields with standard filter expressions using `parentObject.childField` syntax:
392+
393+
```csharp
394+
// Exact match on a nested field
395+
var results = await repository.FindAsync(q => q
396+
.FilterExpression("peerReviews.rating:5"));
397+
398+
// Range query on a nested field
399+
var results = await repository.FindAsync(q => q
400+
.FilterExpression("peerReviews.rating:[4 TO 5]"));
401+
402+
// Match on a nested keyword field
403+
var results = await repository.FindAsync(q => q
404+
.FilterExpression("peerReviews.reviewerEmployeeId:bob_456"));
405+
```
406+
407+
### Combining Nested Conditions
408+
409+
Multiple conditions on the same nested path are combined into a single `nested` query with a `bool` clause:
410+
411+
```csharp
412+
// AND: both conditions must match the SAME nested document
413+
var results = await repository.FindAsync(q => q
414+
.FilterExpression("peerReviews.rating:5 AND peerReviews.reviewerEmployeeId:bob_456"));
415+
416+
// OR: either condition can match across different nested documents
417+
var results = await repository.FindAsync(q => q
418+
.FilterExpression("peerReviews.rating:>=4 OR peerReviews.reviewerEmployeeId:bob_456"));
419+
```
420+
421+
### Mixing Nested and Non-Nested Fields
422+
423+
Nested fields and regular fields can be used together. The framework only wraps the nested portions in a `nested` query:
424+
425+
```csharp
426+
// "name" is a root-level field, "peerReviews.rating" is nested
427+
var results = await repository.FindAsync(q => q
428+
.FilterExpression("name:Alice peerReviews.rating:5"));
429+
```
430+
431+
### Negating Nested Conditions
432+
433+
```csharp
434+
// Exclude employees who have any peer review with rating 5
435+
var results = await repository.FindAsync(q => q
436+
.FilterExpression("NOT peerReviews.rating:5"));
437+
```
438+
439+
### Default Fields with Nested Paths
440+
441+
Nested fields can be included in default search fields via `SetDefaultFields` in your query parser configuration. This allows unqualified search terms to match against nested fields:
442+
443+
```csharp
444+
protected override void ConfigureQueryParser(ElasticQueryParserConfiguration config)
445+
{
446+
base.ConfigureQueryParser(config);
447+
config.SetDefaultFields([
448+
nameof(Employee.Id).ToLowerInvariant(),
449+
nameof(Employee.Name).ToLowerInvariant(),
450+
"peerReviews.reviewerEmployeeId"
451+
]);
452+
}
453+
```
454+
455+
With this configuration, a bare search term like `bob_456` will match against `id`, `name`, and `peerReviews.reviewerEmployeeId`:
456+
457+
```csharp
458+
// Searches id, name, AND the nested peerReviews.reviewerEmployeeId field
459+
var results = await repository.FindAsync(q => q.SearchExpression("bob_456"));
460+
```
461+
462+
### Sorting on Nested Fields
463+
464+
Sort expressions on nested fields automatically include the required `nested` context:
465+
466+
```csharp
467+
// Sort descending by a nested numeric field
468+
var results = await repository.FindAsync(q => q
469+
.SortExpression("-peerReviews.rating"));
470+
```
471+
472+
### Exists / Missing on Nested Fields
473+
474+
`_exists_` and `_missing_` queries on nested fields are automatically wrapped in a `nested` query:
475+
476+
```csharp
477+
// Find employees that have at least one peer review with a reviewerEmployeeId
478+
var results = await repository.FindAsync(q => q
479+
.FilterExpression("_exists_:peerReviews.reviewerEmployeeId"));
480+
```
481+
482+
### Deeply Nested Types
483+
484+
Multi-level nesting (nested objects inside other nested objects) is supported. The framework resolves the correct nested path at each level:
485+
486+
```csharp
487+
// Given a mapping: parent (nested) -> child (nested inside parent)
488+
// Query a deeply nested field
489+
var results = await repository.FindAsync(q => q
490+
.FilterExpression("parent.child.field1:value"));
491+
```
492+
493+
### Known Limitations
494+
495+
| Limitation | Details |
496+
|---|---|
497+
| **TopHits round-tripping** | `TopHitsAggregate` cannot survive JSON serialization. See the [Top Hits Aggregation](#top-hits-aggregation) warning above. |
498+
299499
## Field Selection
300500

301501
Field selection controls which fields are returned from Elasticsearch via `_source` filtering. This reduces network payload and deserialization cost when you only need a subset of fields from a document.

samples/Foundatio.SampleApp/Client/Foundatio.SampleApp.Client.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
</PropertyGroup>
88

99
<ItemGroup>
10-
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.19" />
11-
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.19" PrivateAssets="all" />
10+
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.3" />
11+
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.3" PrivateAssets="all" />
1212
</ItemGroup>
1313

1414
<ItemGroup>

samples/Foundatio.SampleApp/Server/Foundatio.SampleApp.Server.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
<ItemGroup>
1010
<PackageReference Include="Foundatio.Extensions.Hosting" Version="13.0.0-beta3.8" />
11-
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.19" />
11+
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.3" />
1212
</ItemGroup>
1313

1414
<ItemGroup>

src/Foundatio.Repositories.Elasticsearch/Foundatio.Repositories.Elasticsearch.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<Compile Include="..\Foundatio.Repositories\Extensions\TaskExtensions.cs" Link="Extensions\TaskExtensions.cs" />
44
</ItemGroup>
55
<ItemGroup>
6-
<PackageReference Include="Foundatio.Parsers.ElasticQueries" Version="7.18.0-beta3" Condition="'$(ReferenceFoundatioRepositoriesSource)' == '' OR '$(ReferenceFoundatioRepositoriesSource)' == 'false'" />
6+
<PackageReference Include="Foundatio.Parsers.ElasticQueries" Version="7.18.0-beta4" Condition="'$(ReferenceFoundatioRepositoriesSource)' == '' OR '$(ReferenceFoundatioRepositoriesSource)' == 'false'" />
77
<ProjectReference Include="$(FoundatioProjectsDir)Foundatio.Parsers\src\Foundatio.Parsers.ElasticQueries\Foundatio.Parsers.ElasticQueries.csproj" Condition="'$(ReferenceFoundatioRepositoriesSource)' == 'true'" />
88
<ProjectReference Include="..\Foundatio.Repositories\Foundatio.Repositories.csproj" />
99
</ItemGroup>

src/Foundatio.Repositories/Models/Aggregations/MultiBucketAggregate.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
using System.Collections.Generic;
1+
using System.Collections.Generic;
22

33
namespace Foundatio.Repositories.Models;
44

55
public class MultiBucketAggregate<TBucket> : BucketAggregateBase
66
where TBucket : IBucket
77
{
88
public MultiBucketAggregate() { }
9+
910
public MultiBucketAggregate(IReadOnlyDictionary<string, IAggregate> aggregations) : base(aggregations) { }
1011

1112
public IReadOnlyCollection<TBucket> Buckets { get; set; } = EmptyReadOnly<TBucket>.Collection;

src/Foundatio.Repositories/Models/Aggregations/PercentilesAggregate.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Collections.Generic;
1+
using System.Collections.Generic;
22
using System.Diagnostics;
33

44
namespace Foundatio.Repositories.Models;

src/Foundatio.Repositories/Models/Aggregations/SingleBucketAggregate.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Collections.Generic;
1+
using System.Collections.Generic;
22
using System.Diagnostics;
33

44
namespace Foundatio.Repositories.Models;
@@ -7,6 +7,7 @@ namespace Foundatio.Repositories.Models;
77
public class SingleBucketAggregate : BucketAggregateBase
88
{
99
public SingleBucketAggregate() { }
10+
1011
public SingleBucketAggregate(IReadOnlyDictionary<string, IAggregate> aggregations) : base(aggregations) { }
1112

1213
public long Total { get; set; }

src/Foundatio.Repositories/Utility/AggregationsNewtonsoftJsonConverter.cs

Lines changed: 31 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System;
1+
using System;
2+
using System.Collections.Generic;
23
using Foundatio.Repositories.Models;
34
using Newtonsoft.Json;
45
using Newtonsoft.Json.Linq;
@@ -16,52 +17,44 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist
1617
{
1718
var item = JObject.Load(reader);
1819
var typeToken = item.SelectToken("Data.@type") ?? item.SelectToken("data.@type");
20+
string type = typeToken?.Value<string>();
1921

20-
IAggregate value = null;
21-
if (typeToken != null)
22+
IAggregate value = type switch
2223
{
23-
string type = typeToken.Value<string>();
24-
switch (type)
25-
{
26-
case "bucket":
27-
value = new BucketAggregate();
28-
break;
29-
case "exstats":
30-
value = new ExtendedStatsAggregate();
31-
break;
32-
case "ovalue":
33-
value = new ObjectValueAggregate();
34-
break;
35-
case "percentiles":
36-
value = new PercentilesAggregate();
37-
break;
38-
case "sbucket":
39-
value = new SingleBucketAggregate();
40-
break;
41-
case "stats":
42-
value = new StatsAggregate();
43-
break;
44-
case "tophits":
45-
// TODO: Have to get all the docs as JToken and
46-
//value = new TopHitsAggregate();
47-
break;
48-
case "value":
49-
value = new ValueAggregate();
50-
break;
51-
case "dvalue":
52-
value = new ValueAggregate<DateTime>();
53-
break;
54-
}
55-
}
24+
"bucket" => new BucketAggregate(),
25+
"exstats" => new ExtendedStatsAggregate(),
26+
"ovalue" => new ObjectValueAggregate(),
27+
"percentiles" => DeserializePercentiles(item, serializer),
28+
"sbucket" => DeserializeSingleBucket(item, serializer),
29+
"stats" => new StatsAggregate(),
30+
// TopHitsAggregate cannot be round-tripped: it holds ILazyDocument references (raw ES doc bytes) that require a serializer instance to materialize.
31+
"value" => new ValueAggregate(),
32+
"dvalue" => new ValueAggregate<DateTime>(),
33+
_ => null
34+
};
5635

57-
if (value == null)
58-
value = new ValueAggregate();
36+
value ??= new ValueAggregate();
5937

6038
serializer.Populate(item.CreateReader(), value);
6139

6240
return value;
6341
}
6442

43+
private static PercentilesAggregate DeserializePercentiles(JObject item, JsonSerializer serializer)
44+
{
45+
if ((item.SelectToken("Items") ?? item.SelectToken("items")) is { } itemsToken)
46+
return new PercentilesAggregate(itemsToken.ToObject<IReadOnlyList<PercentileItem>>(serializer));
47+
48+
return new PercentilesAggregate();
49+
}
50+
51+
private static SingleBucketAggregate DeserializeSingleBucket(JObject item, JsonSerializer serializer)
52+
{
53+
var aggregationsToken = item.SelectToken("Aggregations") ?? item.SelectToken("aggregations");
54+
var aggregations = aggregationsToken?.ToObject<IReadOnlyDictionary<string, IAggregate>>(serializer);
55+
return new SingleBucketAggregate(aggregations);
56+
}
57+
6558
public override bool CanWrite => false;
6659

6760
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)

0 commit comments

Comments
 (0)