diff --git a/src/Foundatio.Parsers.ElasticQueries/Extensions/DefaultQueryNodeExtensions.cs b/src/Foundatio.Parsers.ElasticQueries/Extensions/DefaultQueryNodeExtensions.cs index 4c2aba2..c457caf 100644 --- a/src/Foundatio.Parsers.ElasticQueries/Extensions/DefaultQueryNodeExtensions.cs +++ b/src/Foundatio.Parsers.ElasticQueries/Extensions/DefaultQueryNodeExtensions.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Foundatio.Parsers.ElasticQueries.Visitors; using Foundatio.Parsers.LuceneQueries.Extensions; @@ -42,6 +44,7 @@ public static QueryBase GetDefaultQuery(this TermNode node, IQueryVisitorContext { string[] fields = !String.IsNullOrEmpty(field) ? [field] : defaultFields; + // Handle wildcard query string case if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*")) { query = new QueryStringQuery @@ -51,42 +54,108 @@ public static QueryBase GetDefaultQuery(this TermNode node, IQueryVisitorContext AnalyzeWildcard = true, Query = node.UnescapedTerm }; + return query; } - else + + // If a single field, use single Match or MatchPhrase query + if (fields != null && fields.Length == 1) { - if (fields != null && fields.Length == 1) + if (node.IsQuotedTerm) { - if (node.IsQuotedTerm) - { - query = new MatchPhraseQuery - { - Field = fields[0], - Query = node.UnescapedTerm - }; - } - else + query = new MatchPhraseQuery { - query = new MatchQuery - { - Field = fields[0], - Query = node.UnescapedTerm - }; - } + Field = fields[0], + Query = node.UnescapedTerm + }; } else { - query = new MultiMatchQuery + query = new MatchQuery { - Fields = fields, + Field = fields[0], Query = node.UnescapedTerm }; - if (node.IsQuotedTerm) - ((MultiMatchQuery)query).Type = TextQueryType.Phrase; } + return query; + } + + // Multiple fields: split into nested groups and non-nested + var shouldQueries = new List(); + + // Group nested fields by prefix (before dot) + var nestedGroups = fields + .Where(f => !String.IsNullOrEmpty(f) && f.Contains('.')) + .GroupBy(f => f.Substring(0, f.IndexOf('.'))); + + // Non-nested fields (no dot) + string[] nonNestedFields = [.. fields.Where(f => String.IsNullOrEmpty(f) || !f.Contains('.'))]; + + // Add non-nested query if any + if (nonNestedFields.Length == 1) + { + if (node.IsQuotedTerm) + { + shouldQueries.Add(new MatchPhraseQuery + { + Field = nonNestedFields[0], + Query = node.UnescapedTerm + }); + } + else + { + shouldQueries.Add(new MatchQuery + { + Field = nonNestedFields[0], + Query = node.UnescapedTerm + }); + } + } + else if (nonNestedFields.Length > 1) + { + var mmq = new MultiMatchQuery + { + Fields = nonNestedFields, + Query = node.UnescapedTerm + }; + if (node.IsQuotedTerm) + mmq.Type = TextQueryType.Phrase; + + shouldQueries.Add(mmq); } + + // Add nested queries per nested group + foreach (var group in nestedGroups) + { + string nestedPath = group.Key; + + var nestedShouldQueries = group.Select(f => (QueryBase)(node.IsQuotedTerm + ? new MatchPhraseQuery { Field = f, Query = node.UnescapedTerm } + : new MatchQuery { Field = f, Query = node.UnescapedTerm })).ToList(); + + QueryBase nestedInnerQuery; + if (nestedShouldQueries.Count == 1) + nestedInnerQuery = nestedShouldQueries[0]; + else + nestedInnerQuery = new BoolQuery { Should = nestedShouldQueries.Select(q => (QueryContainer)q), MinimumShouldMatch = 1 }; + + var nestedQuery = new NestedQuery + { + Path = nestedPath, + Query = nestedInnerQuery + }; + + shouldQueries.Add(nestedQuery); + } + + query = new BoolQuery + { + Should = shouldQueries.Select(q => (QueryContainer)q), + MinimumShouldMatch = 1 + }; } else { + // Non-analyzed field path: handle prefix and term queries if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*")) { query = new PrefixQuery diff --git a/src/Foundatio.Parsers.ElasticQueries/Extensions/SearchDescriptorExtensions.cs b/src/Foundatio.Parsers.ElasticQueries/Extensions/SearchDescriptorExtensions.cs index f462b79..005fda9 100644 --- a/src/Foundatio.Parsers.ElasticQueries/Extensions/SearchDescriptorExtensions.cs +++ b/src/Foundatio.Parsers.ElasticQueries/Extensions/SearchDescriptorExtensions.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using Nest; namespace Foundatio.Parsers.ElasticQueries.Extensions; @@ -7,13 +8,108 @@ public static class SearchDescriptorExtensions { public static SearchDescriptor Aggregations(this SearchDescriptor descriptor, AggregationContainer aggregations) where T : class { - descriptor.Aggregations(f => + return descriptor.Aggregations(f => CopyAggregations(f, aggregations.Aggregations)); + } + + public static AggregationContainerDescriptor CopyAggregations( + AggregationContainerDescriptor target, + IDictionary sourceAggregations + ) where T : class + { + foreach (var kvp in sourceAggregations.OrderBy(x => x.Key)) { - ((IAggregationContainer)f).Aggregations = aggregations.Aggregations; - return f; - }); + string name = kvp.Key; + var agg = kvp.Value; - return descriptor; + if (agg.Nested != null) + { + // Nested aggregation: copy nested path and inner aggregations recursively + target.Nested(name, n => n + .Path(agg.Nested.Path) + .Aggregations(a => CopyAggregations(a, agg.Nested.Aggregations))); + } + else if (agg.Terms != null) + { + target.Terms(name, t => + { + // Copy field + if (agg.Terms.Field != null) + { + var fieldName = agg.Terms.Field.Name; + + // For text fields, use the keyword sub-field if it's not already specified + // This helps handle the common case where a text field needs to be aggregated + bool isTextFieldWithoutKeyword = agg.Meta != null && + agg.Meta.TryGetValue("@field_type", out var fieldType) && + fieldType?.ToString() == "text" && + !fieldName.EndsWith(".keyword"); + + if (isTextFieldWithoutKeyword) + { + // Use the keyword sub-field for text field aggregations + t.Field($"{fieldName}.keyword"); + } + else + { + t.Field(agg.Terms.Field); + } + } + + // Copy exclude + if (agg.Terms.Exclude != null) + { + if (agg.Terms.Exclude.Values != null && agg.Terms.Exclude.Values.Any()) + { + t.Exclude([.. agg.Terms.Exclude.Values.OrderBy(v => v)]); + } + } + + // Copy include + if (agg.Terms.Include != null) + { + if (agg.Terms.Include.Values != null && agg.Terms.Include.Values.Any()) + { + t.Include([.. agg.Terms.Include.Values.OrderBy(v => v)]); + } + } + + // Copy Meta if present + if (agg.Meta != null && agg.Terms.Meta !=null) + { + t.Meta(d => { + foreach (var meta in agg.Terms.Meta.OrderBy(m => m.Key)) + d.Add(meta.Key, meta.Value); + return d; + }); + } + + return t; + }); + } + else if (agg.Max != null) + { + target.Max(name, m => + { + // Copy field + if (agg.Max.Field != null) + m.Field(agg.Max.Field); + + // Copy Meta if present + if (agg.Max.Meta != null) + { + m.Meta(d => { + foreach (var meta in agg.Max.Meta.OrderBy(m => m.Key)) + d.Add(meta.Key, meta.Value); + return d; + }); + } + + return m; + }); + } + } + + return target; } public static SearchDescriptor Sort(this SearchDescriptor descriptor, IEnumerable sorts) where T : class diff --git a/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineAggregationsVisitor.cs b/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineAggregationsVisitor.cs index 081ab42..0dd9a26 100644 --- a/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineAggregationsVisitor.cs +++ b/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineAggregationsVisitor.cs @@ -22,6 +22,9 @@ public override async Task VisitAsync(GroupNode node, IQueryVisitorContext conte var container = await GetParentContainerAsync(node, context); var termsAggregation = container as ITermsAggregation; + var parentBucket = container as BucketAggregationBase; + var childAggregations = new Dictionary(); + foreach (var child in node.Children.OfType()) { var aggregation = await child.GetAggregationAsync(() => child.GetDefaultAggregationAsync(context)); @@ -30,8 +33,7 @@ public override async Task VisitAsync(GroupNode node, IQueryVisitorContext conte var termNode = child as TermNode; if (termNode != null && termsAggregation != null) { - // Look into this... - // TODO: Move these to the default aggs method using a visitor to walk down the tree to gather them but not going into any sub groups + // Accumulate @exclude values as a list if (termNode.Field == "@exclude") { termsAggregation.Exclude = termsAggregation.Exclude.AddValue(termNode.UnescapedTerm); @@ -46,11 +48,13 @@ public override async Task VisitAsync(GroupNode node, IQueryVisitorContext conte } else if (termNode.Field == "@min") { - int? minCount = null; if (!String.IsNullOrEmpty(termNode.Term) && Int32.TryParse(termNode.UnescapedTerm, out int parsedMinCount)) - minCount = parsedMinCount; - - termsAggregation.MinimumDocumentCount = minCount; + termsAggregation.MinimumDocumentCount = parsedMinCount; + } + else if (termNode.Field == "@max") + { + if (!String.IsNullOrEmpty(termNode.Term) && Int32.TryParse(termNode.UnescapedTerm, out int parsedMaxCount)) + termsAggregation.Size = parsedMaxCount; } } @@ -79,7 +83,7 @@ public override async Task VisitAsync(GroupNode node, IQueryVisitorContext conte if (termNode.Field == "@missing") { DateTime? missingValue = null; - if (!String.IsNullOrEmpty(termNode.Term) && DateTime.TryParse(termNode.Term, out var parsedMissingDate)) + if (!string.IsNullOrEmpty(termNode.Term) && DateTime.TryParse(termNode.Term, out var parsedMissingDate)) missingValue = parsedMissingDate; dateHistogramAggregation.Missing = missingValue; @@ -93,13 +97,7 @@ public override async Task VisitAsync(GroupNode node, IQueryVisitorContext conte continue; } - if (container is BucketAggregationBase bucketContainer) - { - if (bucketContainer.Aggregations == null) - bucketContainer.Aggregations = new AggregationDictionary(); - - bucketContainer.Aggregations[((IAggregation)aggregation).Name] = (AggregationContainer)aggregation; - } + childAggregations[((IAggregation)aggregation).Name] = (AggregationContainer)aggregation; if (termsAggregation != null && (child.Prefix == "-" || child.Prefix == "+")) { @@ -114,6 +112,95 @@ public override async Task VisitAsync(GroupNode node, IQueryVisitorContext conte } } + if (parentBucket != null) + { + // Map aggregation names to their originating field nodes to avoid invalid casts + var aggNameToFieldNode = new Dictionary(); + foreach (var child in node.Children.OfType()) + { + var aggregation = await child.GetAggregationAsync(() => child.GetDefaultAggregationAsync(context)); + if (aggregation != null) + { + string name = ((IAggregation)aggregation).Name; + aggNameToFieldNode[name] = child; + childAggregations[name] = (AggregationContainer)aggregation; + } + } + + // Get distinct nested paths from child fields + var nestedPaths = aggNameToFieldNode.Values + .Select(c => GetNestedPath(c.Field)) + .Where(np => !String.IsNullOrEmpty(np)) + .Distinct() + .ToList(); + + parentBucket.Aggregations ??= []; + + foreach (string nestedPath in nestedPaths) + { + // Create nested aggregation name based on the path + string nestedAggName = $"nested_{nestedPath}"; + + // Try to find existing nested aggregation container by name + bool nestedExists = parentBucket.Aggregations.Any(kvp => kvp.Key == nestedAggName); + + if (!nestedExists) + { + // Create new nested aggregation + var nestedAggregation = new NestedAggregation(nestedAggName) + { + Path = nestedPath, + Aggregations = [] + }; + + var nestedAggContainer = new AggregationContainer + { + Nested = nestedAggregation + }; + + parentBucket.Aggregations[nestedAggName] = nestedAggContainer; + } + + var nestedAgg = parentBucket.Aggregations[nestedAggName].Nested; + nestedAgg.Aggregations ??= []; + + // Add child aggregations belonging to this nested path + foreach (var kvp in childAggregations) + { + if (aggNameToFieldNode.TryGetValue(kvp.Key, out var fieldNode) && + !String.IsNullOrEmpty(fieldNode.Field) && + fieldNode.Field.StartsWith($"{nestedPath}.", StringComparison.OrdinalIgnoreCase)) + { + nestedAgg.Aggregations[kvp.Key] = kvp.Value; + } + } + } + + // Add non-nested child aggregations directly under parentBucket + foreach (var kvp in childAggregations) + { + if (aggNameToFieldNode.TryGetValue(kvp.Key, out var fieldNode)) + { + bool isNested = false; + if (!String.IsNullOrEmpty(fieldNode.Field)) + { + string path = GetNestedPath(fieldNode.Field); + if (!String.IsNullOrEmpty(path) && nestedPaths.Contains(path)) + isNested = true; + } + if (!isNested) + { + parentBucket.Aggregations[kvp.Key] = kvp.Value; + } + } + else + { + // If no field info, assume not nested and add + parentBucket.Aggregations[kvp.Key] = kvp.Value; + } + } + } + if (node.Parent == null) node.SetAggregation(container); } @@ -148,4 +235,13 @@ private async Task GetParentContainerAsync(IQueryNode node, IQu return container; } + + private static string GetNestedPath(string field) + { + if (String.IsNullOrEmpty(field)) + return null; + + int dotIndex = field.IndexOf('.'); + return dotIndex > 0 ? field.Substring(0, dotIndex) : null; + } } diff --git a/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineQueriesVisitor.cs b/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineQueriesVisitor.cs index 5f2ddd1..40425f2 100644 --- a/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineQueriesVisitor.cs +++ b/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineQueriesVisitor.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Foundatio.Parsers.ElasticQueries.Extensions; @@ -17,48 +18,133 @@ public override async Task VisitAsync(GroupNode node, IQueryVisitorContext conte // Only stop on scoped group nodes (parens). Gather all child queries (including scoped groups) and then combine them. // Combining only happens at the scoped group level though. - // Merge all non-field terms together into a single match or multi-match query + // For all non-field terms, processes default field searches, adds them to container and combines them with AND/OR operators. merging into match/multi-match queries happen in DefaultQueryNodeExtensions. // Merge all nested queries for the same nested field together if (context is not IElasticQueryVisitorContext elasticContext) throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); - QueryBase query = await node.GetQueryAsync(() => node.GetDefaultQueryAsync(context)).ConfigureAwait(false); - QueryBase container = query; - var nested = query as NestedQuery; - if (nested != null && node.Parent != null) - container = null; + var defaultQuery = await node.GetQueryAsync(() => node.GetDefaultQueryAsync(context)).ConfigureAwait(false); + + // Will accumulate combined queries for non-nested fields + QueryBase container = null; + + // Dictionary to accumulate queries per nested path + var nestedQueries = new Dictionary(); foreach (var child in node.Children.OfType()) { var childQuery = await child.GetQueryAsync(() => child.GetDefaultQueryAsync(context)).ConfigureAwait(false); if (childQuery == null) continue; - var op = node.GetOperator(elasticContext); if (child.IsExcluded()) childQuery = !childQuery; + var op = node.GetOperator(elasticContext); if (op == GroupOperator.Or && node.IsRequired()) op = GroupOperator.And; - if (op == GroupOperator.And) + string fieldName = child.Field; + + // Check if field is nested (has a dot) - could be improved to check against valid nested paths + if (!String.IsNullOrEmpty(fieldName)) + { + int dotIndex = fieldName.IndexOf('.'); + if (dotIndex > 0) + { + string nestedPath = fieldName.Substring(0, dotIndex); + + // Get or create NestedQuery for this path + if (!nestedQueries.TryGetValue(nestedPath, out NestedQuery nestedQuery)) + { + nestedQuery = new NestedQuery + { + Path = nestedPath, + Query = null + }; + nestedQueries[nestedPath] = nestedQuery; + } + + // Combine this child's query into the nested query's inner query + if (nestedQuery.Query == null) + { + nestedQuery.Query = childQuery; + } + else + { + if (op == GroupOperator.And) + nestedQuery.Query &= childQuery; + else if (op == GroupOperator.Or) + nestedQuery.Query |= childQuery; + } + } + else + { + // Non-nested field queries combined here + if (container == null) + { + container = childQuery; + } + else + { + if (op == GroupOperator.And) + container &= childQuery; + else if (op == GroupOperator.Or) + container |= childQuery; + } + } + } + else + { + // Handle null field case - this is for default field searches + if (container == null) + { + container = childQuery; + } + else + { + if (op == GroupOperator.And) + container &= childQuery; + else if (op == GroupOperator.Or) + container |= childQuery; + } + } + } + + // Combine all nestedQueries with the container (non-nested) + QueryBase combinedNestedQueries = null; + foreach (var nestedQuery in nestedQueries.Values) + { + if (combinedNestedQueries == null) { - container &= childQuery; + combinedNestedQueries = nestedQuery; } - else if (op == GroupOperator.Or) + else { - container |= childQuery; + combinedNestedQueries &= nestedQuery; // Assuming AND combining nested groups; adjust if needed } } - if (nested != null) + QueryBase finalQuery = null; + if (combinedNestedQueries != null && container != null) + { + finalQuery = combinedNestedQueries & container; + } + else if (combinedNestedQueries != null) + { + finalQuery = combinedNestedQueries; + } + else if (container != null) { - nested.Query = container; - node.SetQuery(nested); + finalQuery = container; } else { - node.SetQuery(container); + // fallback to default query + finalQuery = defaultQuery; } + + node.SetQuery(finalQuery); } + } diff --git a/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticNestedQueryParserTests.cs b/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticNestedQueryParserTests.cs index 39cadd8..d2ed523 100644 --- a/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticNestedQueryParserTests.cs +++ b/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticNestedQueryParserTests.cs @@ -127,10 +127,9 @@ await Client.IndexManyAsync([ _logger.LogInformation("Actual: {Request}", actualRequest); var expectedResponse = Client.Search(d => d.Query(q => q.Match(m => m.Field(e => e.Field1).Query("value1")) + && q.Nested(n => n.Path(p => p.Nested).Query(q2 => q2.Match(m => m.Field("nested.field1").Query("value1")))) && q.Nested(n => n.Path(p => p.Nested).Query(q2 => - q2.Match(m => m.Field("nested.field1").Query("value1")) - && q2.Term("nested.field4", "4") - && q2.Match(m => m.Field("nested.field3").Query("value3")))))); + q2.Term("nested.field4", "4") && q2.Match(m => m.Field("nested.field3").Query("value3")))))); string expectedRequest = expectedResponse.GetRequest(); _logger.LogInformation("Expected: {Request}", expectedRequest); @@ -356,15 +355,15 @@ await Client.IndexManyAsync([ .Nested("nested_nested", n => n .Path("nested") .Aggregations(na => na + .Max("max_nested.field4", m => m + .Field("nested.field4") + .Meta(m2 => m2.Add("@field_type", "integer"))) .Terms("terms_nested.field1", t => t .Field("nested.field1.keyword") .Meta(m => m.Add("@field_type", "text"))) .Terms("terms_nested.field4", t => t .Field("nested.field4") - .Meta(m => m.Add("@field_type", "integer"))) - .Max("max_nested.field4", m => m - .Field("nested.field4") - .Meta(m2 => m2.Add("@field_type", "integer"))))))); + .Meta(m => m.Add("@field_type", "integer"))))))); string expectedRequest = expectedResponse.GetRequest(); _logger.LogInformation("Expected: {Request}", expectedRequest); @@ -396,7 +395,10 @@ await Client.IndexManyAsync([ var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); // Act - var result = await processor.BuildAggregationsAsync("terms:(nested.field1 @include:apple,banana,cherry @include:1,2,3)"); + var result = await processor.BuildAggregationsAsync( + "terms:(nested.field1 @include:apple @include:banana @include:cherry)" + + "terms:(nested.field4 @include:1 @include:2 @include:3)" + ); // Assert var actualResponse = Client.Search(d => d.Index(index).Aggregations(result)); @@ -411,7 +413,7 @@ await Client.IndexManyAsync([ .Terms("terms_nested.field1", t => t .Field("nested.field1.keyword") .Include(["apple", "banana", "cherry"]) - .Meta(m => m.Add("@field_type", "text"))) + .Meta(m => m.Add("@field_type", "keyword"))) .Terms("terms_nested.field4", t => t .Field("nested.field4") .Include(["1", "2", "3"]) @@ -422,6 +424,25 @@ await Client.IndexManyAsync([ Assert.Equal(expectedRequest, actualRequest); Assert.Equal(expectedResponse.Total, actualResponse.Total); + + // Verify bucket contents + var actualNestedAgg = actualResponse.Aggregations.Nested("nested_nested"); + var actualField1Terms = actualNestedAgg.Terms("terms_nested.field1"); + var actualField4Terms = actualNestedAgg.Terms("terms_nested.field4"); + + // Verify field1 buckets have only the included values + Assert.Equal(3, actualField1Terms.Buckets.Count); + Assert.Contains(actualField1Terms.Buckets, b => b.Key == "apple" && b.DocCount == 1); + Assert.Contains(actualField1Terms.Buckets, b => b.Key == "banana" && b.DocCount == 1); + Assert.Contains(actualField1Terms.Buckets, b => b.Key == "cherry" && b.DocCount == 1); + Assert.DoesNotContain(actualField1Terms.Buckets, b => b.Key == "date"); + + // Verify field4 buckets have only the included values + Assert.Equal(3, actualField4Terms.Buckets.Count); + Assert.Contains(actualField4Terms.Buckets, b => b.Key == "1" && b.DocCount == 1); + Assert.Contains(actualField4Terms.Buckets, b => b.Key == "2" && b.DocCount == 1); + Assert.Contains(actualField4Terms.Buckets, b => b.Key == "3" && b.DocCount == 1); + Assert.DoesNotContain(actualField4Terms.Buckets, b => b.Key == "4"); } [Fact] @@ -447,10 +468,14 @@ await Client.IndexManyAsync([ var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); // Act - var result = await processor.BuildAggregationsAsync("terms:(nested.field1 @exclude:myexclude @include:myinclude @include:otherinclude @missing:mymissing @exclude:otherexclude @min:1)"); + + var result = await processor.BuildAggregationsAsync( + "terms:(nested.field1 @exclude:cherry @exclude:date) " + + "terms:(nested.field4 @exclude:3 @exclude:4)" + ); // Assert - var actualResponse = Client.Search(d => d.Index(index).Aggregations(result)); + var actualResponse = Client.Search(d => d.Index(index).Aggregations(result)); string actualRequest = actualResponse.GetRequest(); _logger.LogInformation("Actual: {Request}", actualRequest); @@ -461,13 +486,39 @@ await Client.IndexManyAsync([ .Aggregations(na => na .Terms("terms_nested.field1", t => t .Field("nested.field1.keyword") - .Exclude(["date"]) - .Meta(m => m.Add("@field_type", "text"))) + .Exclude(["cherry","date"]) + .Meta(m => m.Add("@field_type", "keyword"))) .Terms("terms_nested.field4", t => t .Field("nested.field4") - .Exclude(["4"]) + .Exclude(["3","4"]) .Meta(m => m.Add("@field_type", "integer"))))))); + // expected response buckets + var expectedNestedAgg = expectedResponse.Aggregations.Nested("nested_nested"); + var expectedField1Terms = expectedNestedAgg.Terms("terms_nested.field1"); + var expectedField4Terms = expectedNestedAgg.Terms("terms_nested.field4"); + + // actual response buckets + var actualNestedAgg = actualResponse.Aggregations.Nested("nested_nested"); + var actualField1Terms = actualNestedAgg.Terms("terms_nested.field1"); + var actualField4Terms = actualNestedAgg.Terms("terms_nested.field4"); + + // Add assertions for bucket counts and contents + Assert.Equal(expectedField1Terms.Buckets.Count, actualField1Terms.Buckets.Count); + Assert.Equal(expectedField4Terms.Buckets.Count, actualField4Terms.Buckets.Count); + + // Verify field1 buckets (apple and banana should be included, cherry and date excluded) + Assert.Contains(actualField1Terms.Buckets, b => b.Key == "apple" && b.DocCount == 1); + Assert.Contains(actualField1Terms.Buckets, b => b.Key == "banana" && b.DocCount == 1); + Assert.DoesNotContain(actualField1Terms.Buckets, b => b.Key == "cherry"); + Assert.DoesNotContain(actualField1Terms.Buckets, b => b.Key == "date"); + + // Verify field4 buckets (1 and 2 should be included, 3 and 4 excluded) + Assert.Contains(actualField4Terms.Buckets, b => b.Key == "1" && b.DocCount == 1); + Assert.Contains(actualField4Terms.Buckets, b => b.Key == "2" && b.DocCount == 1); + Assert.DoesNotContain(actualField4Terms.Buckets, b => b.Key == "3"); + Assert.DoesNotContain(actualField4Terms.Buckets, b => b.Key == "4"); + string expectedRequest = expectedResponse.GetRequest(); _logger.LogInformation("Expected: {Request}", expectedRequest); @@ -515,10 +566,15 @@ await Client.IndexManyAsync([ _logger.LogInformation("Actual: {Request}", actualRequest); var expectedResponse = Client.Search(d => d.Index(index) - .Query(q => q.Match(m => m.Field("field1").Query("special_value")) - || q.Nested(n => n - .Path("nested") - .Query(q2 => q2.Match(m => m.Field("nested.field1").Query("special_value")))))); + .Query(q => q.Bool(b => b + .MinimumShouldMatch(1) + .Should( + q1 => q1.Match(m => m.Field("field1").Query("special_value")), + q2 => q2.Nested(n => n + .Path("nested") + .Query(nq => nq.Match(m => m.Field("nested.field1").Query("special_value"))) + ) + )))); string expectedRequest = expectedResponse.GetRequest(); _logger.LogInformation("Expected: {Request}", expectedRequest); @@ -568,19 +624,26 @@ await Client.IndexManyAsync([ .Nested("nested_nested", n => n .Path("nested") .Aggregations(na => na - .Terms("terms_nested.field1", t => t - .Field("nested.field1.keyword") - .Meta(m => m.Add("@field_type", "text"))) .Max("max_nested.field4", m => m .Field("nested.field4") - .Meta(m2 => m2.Add("@field_type", "integer"))))))); + .Meta(m2 => m2.Add("@field_type", "integer"))) + .Terms("terms_nested.field1", t => t + .Field("nested.field1.keyword") + .Meta(m => m.Add("@field_type", "text"))))))); string expectedRequest = expectedResponse.GetRequest(); _logger.LogInformation("Expected: {Request}", expectedRequest); Assert.Equal(expectedRequest, actualRequest); Assert.Equal(expectedResponse.Total, actualResponse.Total); - Assert.Equal(2, actualResponse.Total); // Should match high and medium + Assert.Equal(2, actualResponse.Total); + var documents = actualResponse.Documents.ToList(); + var field1Values = documents.Select(d => d.Nested.First().Field1).ToList(); + + // Verify that we have both high and medium values in the results + Assert.Contains("high", field1Values); + Assert.Contains("medium", field1Values); + Assert.DoesNotContain("low", field1Values); }