diff --git a/src/Foundatio.Parsers.ElasticQueries/Extensions/DefaultQueryNodeExtensions.cs b/src/Foundatio.Parsers.ElasticQueries/Extensions/DefaultQueryNodeExtensions.cs index 4c2aba2..b353996 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; @@ -32,82 +34,325 @@ public static QueryBase GetDefaultQuery(this TermNode node, IQueryVisitorContext if (context is not IElasticQueryVisitorContext elasticContext) throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); - QueryBase query; string field = node.UnescapedField; string[] defaultFields = node.GetDefaultFields(elasticContext.DefaultFields); - if (field == null && defaultFields != null && defaultFields.Length == 1) - field = defaultFields[0]; - if (elasticContext.MappingResolver.IsPropertyAnalyzed(field)) + // If a specific field is set, use single-field query + if (!String.IsNullOrEmpty(field)) + return GetSingleFieldQuery(node, field, elasticContext); + + // If only one default field, use single-field query + if (defaultFields != null && defaultFields.Length == 1) + return GetSingleFieldQuery(node, defaultFields[0], elasticContext); + + // Multiple default fields - check if any are nested + if (defaultFields != null && defaultFields.Length > 1) { - string[] fields = !String.IsNullOrEmpty(field) ? [field] : defaultFields; + // Group fields by nested path (empty string for non-nested) + var fieldsByNestedPath = GroupFieldsByNestedPath(defaultFields, elasticContext); + + // If all fields are non-nested (single group with empty key), use multi_match + if (fieldsByNestedPath.Count == 1 && fieldsByNestedPath.ContainsKey(String.Empty)) + { + return GetMultiFieldQuery(node, defaultFields, elasticContext); + } + // Otherwise, split into separate queries for each group + return GetSplitNestedQuery(node, fieldsByNestedPath, elasticContext); + } + + // Fallback for no fields + return GetMultiFieldQuery(node, defaultFields, elasticContext); + } + + private static QueryBase GetSingleFieldQuery(TermNode node, string field, IElasticQueryVisitorContext context) + { + if (context.MappingResolver.IsPropertyAnalyzed(field)) + { if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*")) { - query = new QueryStringQuery + return new QueryStringQuery { - Fields = fields, + Fields = Infer.Fields(field), AllowLeadingWildcard = false, AnalyzeWildcard = true, Query = node.UnescapedTerm }; } - else + + if (node.IsQuotedTerm) { - if (fields != null && fields.Length == 1) + return new MatchPhraseQuery { - if (node.IsQuotedTerm) - { - query = new MatchPhraseQuery - { - Field = fields[0], - Query = node.UnescapedTerm - }; - } - else - { - query = new MatchQuery - { - Field = fields[0], - Query = node.UnescapedTerm - }; - } - } - else - { - query = new MultiMatchQuery - { - Fields = fields, - Query = node.UnescapedTerm - }; - if (node.IsQuotedTerm) - ((MultiMatchQuery)query).Type = TextQueryType.Phrase; - } + Field = field, + Query = node.UnescapedTerm + }; } + + return new MatchQuery + { + Field = field, + Query = node.UnescapedTerm + }; } - else + + if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*")) + { + return new PrefixQuery + { + Field = field, + Value = node.UnescapedTerm.TrimEnd('*') + }; + } + + // For non-analyzed fields, try to convert value to appropriate type + object termValue = GetTypedValue(node.UnescapedTerm, field, context); + + return new TermQuery + { + Field = field, + Value = termValue + }; + } + + private static object GetTypedValue(string value, string field, IElasticQueryVisitorContext context) + { + var fieldType = context.MappingResolver.GetFieldType(field); + + return fieldType switch + { + FieldType.Integer or FieldType.Short or FieldType.Byte when Int32.TryParse(value, out int intValue) => intValue, + FieldType.Long when Int64.TryParse(value, out long longValue) => longValue, + FieldType.Float or FieldType.HalfFloat when Single.TryParse(value, out float floatValue) => floatValue, + FieldType.Double or FieldType.ScaledFloat when Double.TryParse(value, out double doubleValue) => doubleValue, + FieldType.Boolean when Boolean.TryParse(value, out bool boolValue) => boolValue, + _ => value // Return as string for other types (keyword, date, ip, etc.) + }; + } + + private static QueryBase GetMultiFieldQuery(TermNode node, string[] fields, IElasticQueryVisitorContext context) + { + // Handle null or empty fields - use default multi_match behavior + if (fields == null || fields.Length == 0) { + var defaultQuery = new MultiMatchQuery + { + Fields = fields, + Query = node.UnescapedTerm + }; + if (node.IsQuotedTerm) + defaultQuery.Type = TextQueryType.Phrase; + return defaultQuery; + } + + // Split fields by analyzed vs non-analyzed + var analyzedFields = new List(); + var nonAnalyzedFields = new List(); + + foreach (string field in fields) + { + if (context.MappingResolver.IsPropertyAnalyzed(field)) + analyzedFields.Add(field); + else + nonAnalyzedFields.Add(field); + } + + // If all fields are of the same type, use simple query + if (nonAnalyzedFields.Count == 0) + { + return GetAnalyzedFieldsQuery(node, analyzedFields.ToArray()); + } + + if (analyzedFields.Count == 0) + { + return GetNonAnalyzedFieldsQuery(node, nonAnalyzedFields, context); + } + + // Mixed types - combine with bool should + var queries = new List(); + + // Add query for analyzed fields + queries.Add(GetAnalyzedFieldsQuery(node, analyzedFields.ToArray())); + + // Add individual queries for non-analyzed fields + foreach (string field in nonAnalyzedFields) + { + queries.Add(GetSingleFieldQuery(node, field, context)); + } + + return new BoolQuery + { + Should = queries.Select(q => (QueryContainer)q).ToList() + }; + } + + private static QueryBase GetAnalyzedFieldsQuery(TermNode node, string[] fields) + { + // For a single field, use match query instead of multi_match + if (fields.Length == 1) + { + string field = fields[0]; if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*")) { - query = new PrefixQuery + return new QueryStringQuery { - Field = field, - Value = node.UnescapedTerm.TrimEnd('*') + Fields = Infer.Fields(field), + AllowLeadingWildcard = false, + AnalyzeWildcard = true, + Query = node.UnescapedTerm }; } - else + + if (node.IsQuotedTerm) { - query = new TermQuery + return new MatchPhraseQuery { Field = field, - Value = node.UnescapedTerm + Query = node.UnescapedTerm }; } + + return new MatchQuery + { + Field = field, + Query = node.UnescapedTerm + }; + } + + // Multiple fields - use multi_match + if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*")) + { + return new QueryStringQuery + { + Fields = fields, + AllowLeadingWildcard = false, + AnalyzeWildcard = true, + Query = node.UnescapedTerm + }; } + var query = new MultiMatchQuery + { + Fields = fields, + Query = node.UnescapedTerm + }; + if (node.IsQuotedTerm) + query.Type = TextQueryType.Phrase; + return query; } + private static QueryBase GetNonAnalyzedFieldsQuery(TermNode node, List fields, IElasticQueryVisitorContext context) + { + // For a single non-analyzed field, use single query + if (fields.Count == 1) + return GetSingleFieldQuery(node, fields[0], context); + + // Multiple non-analyzed fields - combine with bool should + var queries = fields.Select(f => GetSingleFieldQuery(node, f, context)).ToList(); + return new BoolQuery + { + Should = queries.Select(q => (QueryContainer)q).ToList() + }; + } + + private static Dictionary> GroupFieldsByNestedPath(string[] fields, IElasticQueryVisitorContext context) + { + var result = new Dictionary>(); + + foreach (string field in fields) + { + // Use empty string for non-nested fields, actual path for nested + string nestedPath = GetNestedPath(field, context) ?? String.Empty; + + if (!result.ContainsKey(nestedPath)) + result[nestedPath] = new List(); + + result[nestedPath].Add(field); + } + + return result; + } + + private static string GetNestedPath(string fullName, IElasticQueryVisitorContext context) + { + string[] nameParts = fullName?.Split('.').ToArray(); + + if (nameParts == null || nameParts.Length == 0) + return null; + + string fieldName = String.Empty; + for (int i = 0; i < nameParts.Length; i++) + { + if (i > 0) + fieldName += "."; + + fieldName += nameParts[i]; + + if (context.MappingResolver.IsNestedPropertyType(fieldName)) + return fieldName; + } + + return null; + } + + private static QueryBase GetSplitNestedQuery(TermNode node, Dictionary> fieldsByNestedPath, IElasticQueryVisitorContext context) + { + var queryContainers = new List(); + + foreach (var (nestedPath, fields) in fieldsByNestedPath) + { + QueryBase query; + + if (fields.Count == 1) + { + query = GetSingleFieldQuery(node, fields[0], context); + } + else + { + query = GetMultiFieldQuery(node, fields.ToArray(), context); + } + + // Wrap in NestedQuery if this is a nested path (non-empty string) + if (!String.IsNullOrEmpty(nestedPath)) + { + queryContainers.Add(new NestedQuery + { + Path = nestedPath, + Query = query + }); + } + else + { + // For non-nested fields, flatten BoolQuery should clauses if present + if (query is BoolQuery boolQuery && boolQuery.Should != null) + { + foreach (var shouldClause in boolQuery.Should) + { + queryContainers.Add(shouldClause); + } + } + else + { + queryContainers.Add(query); + } + } + } + + // Combine with OR (should) + if (queryContainers.Count == 1) + { + // Try to unwrap single QueryContainer to QueryBase + // Can't directly cast, so return in a minimal BoolQuery + return new BoolQuery { Should = queryContainers }; + } + + return new BoolQuery + { + Should = queryContainers + }; + } + public static async Task GetDefaultQueryAsync(this TermRangeNode node, IQueryVisitorContext context) { if (context is not IElasticQueryVisitorContext elasticContext) diff --git a/src/Foundatio.Parsers.ElasticQueries/Extensions/QueryNodeExtensions.cs b/src/Foundatio.Parsers.ElasticQueries/Extensions/QueryNodeExtensions.cs index 274108a..e2809f1 100644 --- a/src/Foundatio.Parsers.ElasticQueries/Extensions/QueryNodeExtensions.cs +++ b/src/Foundatio.Parsers.ElasticQueries/Extensions/QueryNodeExtensions.cs @@ -83,4 +83,18 @@ public static void RemoveSort(this IQueryNode node) { node.Data.Remove(SortKey); } + + private const string NestedPathKey = "@NestedPath"; + public static string GetNestedPath(this IQueryNode node) + { + if (!node.Data.TryGetValue(NestedPathKey, out object value)) + return null; + + return value as string; + } + + public static void SetNestedPath(this IQueryNode node, string path) + { + node.Data[NestedPathKey] = path; + } } diff --git a/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineAggregationsVisitor.cs b/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineAggregationsVisitor.cs index 3f37ce8..0b884aa 100644 --- a/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineAggregationsVisitor.cs +++ b/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineAggregationsVisitor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -15,41 +15,160 @@ public override async Task VisitAsync(GroupNode node, IQueryVisitorContext conte { await base.VisitAsync(node, context).ConfigureAwait(false); + // Only process aggregations at the root GroupNode or at explicit nested groups + // Skip intermediate GroupNodes that are part of the parsed tree structure + if (node.Parent != null && String.IsNullOrEmpty(node.Field)) + return; + if (context is not IElasticQueryVisitorContext elasticContext) throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context)); var container = await GetParentContainerAsync(node, context); var termsAggregation = container as ITermsAggregation; - foreach (var child in node.Children.OfType()) + // Group aggregations by nested path + var nestedAggregations = new Dictionary>(); + var regularAggregations = new List<(IFieldQueryNode Node, AggregationBase Agg)>(); + + // Collect all field query nodes from immediate children (including nested GroupNodes) + // GetAllFieldQueryNodes returns nodes in depth-first bottom-up order to match original visitor behavior + foreach (var child in GetAllFieldQueryNodes(node)) { var aggregation = await child.GetAggregationAsync(() => child.GetDefaultAggregationAsync(context)); if (aggregation == null) continue; + string nestedPath = child.GetNestedPath(); + if (nestedPath != null) + { + if (!nestedAggregations.ContainsKey(nestedPath)) + nestedAggregations[nestedPath] = new List<(IFieldQueryNode, AggregationBase)>(); + nestedAggregations[nestedPath].Add((child, aggregation)); + } + else + { + regularAggregations.Add((child, aggregation)); + } + } + + // Add regular aggregations + foreach (var (child, aggregation) in regularAggregations) + { + AddAggregationToContainer(container, termsAggregation, child, aggregation); + } + + // Add nested aggregations wrapped in NestedAggregation + foreach (var (nestedPath, childAggregations) in nestedAggregations) + { + var nestedAgg = new NestedAggregation("nested_" + nestedPath) + { + Path = nestedPath, + Aggregations = new AggregationDictionary() + }; + + foreach (var (child, aggregation) in childAggregations) + { + nestedAgg.Aggregations[((IAggregation)aggregation).Name] = (AggregationContainer)aggregation; + + if (termsAggregation != null && (child.Prefix == "-" || child.Prefix == "+")) + { + if (termsAggregation.Order == null) + termsAggregation.Order = new List(); + + termsAggregation.Order.Add(new TermsOrder + { + Key = ((IAggregation)aggregation).Name, + Order = child.Prefix == "-" ? SortOrder.Descending : SortOrder.Ascending + }); + } + } + if (container is BucketAggregationBase bucketContainer) { if (bucketContainer.Aggregations == null) bucketContainer.Aggregations = new AggregationDictionary(); - bucketContainer.Aggregations[((IAggregation)aggregation).Name] = (AggregationContainer)aggregation; + bucketContainer.Aggregations[((IAggregation)nestedAgg).Name] = (AggregationContainer)nestedAgg; } + } - if (termsAggregation != null && (child.Prefix == "-" || child.Prefix == "+")) + if (node.Parent == null) + node.SetAggregation(container); + } + + private void AddAggregationToContainer(AggregationBase container, ITermsAggregation termsAggregation, IFieldQueryNode child, AggregationBase aggregation) + { + if (container is BucketAggregationBase bucketContainer) + { + if (bucketContainer.Aggregations == null) + bucketContainer.Aggregations = new AggregationDictionary(); + + bucketContainer.Aggregations[((IAggregation)aggregation).Name] = (AggregationContainer)aggregation; + } + + if (termsAggregation != null && (child.Prefix == "-" || child.Prefix == "+")) + { + if (termsAggregation.Order == null) + termsAggregation.Order = new List(); + + termsAggregation.Order.Add(new TermsOrder { - if (termsAggregation.Order == null) - termsAggregation.Order = new List(); + Key = ((IAggregation)aggregation).Name, + Order = child.Prefix == "-" ? SortOrder.Descending : SortOrder.Ascending + }); + } + } - termsAggregation.Order.Add(new TermsOrder - { - Key = ((IAggregation)aggregation).Name, - Order = child.Prefix == "-" ? SortOrder.Descending : SortOrder.Ascending - }); + /// + /// Gets all IFieldQueryNode descendants from a GroupNode, flattening nested GroupNodes. + /// Returns nodes in depth-first bottom-up order to match the original visitor behavior + /// where base.VisitAsync processes children first before the foreach loop adds aggregations. + /// + private static IEnumerable GetAllFieldQueryNodes(GroupNode node) + { + // If Right is a GroupNode (without a field), recurse into it first + // This matches the original behavior where the Right subtree's aggregations + // are added during the recursive VisitAsync call before this node's foreach runs + if (node.Right is GroupNode rightGroup && String.IsNullOrEmpty(rightGroup.Field)) + { + foreach (var descendant in GetAllFieldQueryNodes(rightGroup)) + yield return descendant; + + // After recursing into Right, add Left (if it's an IFieldQueryNode) + if (node.Left is GroupNode leftGroup && String.IsNullOrEmpty(leftGroup.Field)) + { + foreach (var descendant in GetAllFieldQueryNodes(leftGroup)) + yield return descendant; + } + else if (node.Left is IFieldQueryNode leftField) + { + yield return leftField; } } + else + { + // Right is not a GroupNode (or has a Field), so iterate in [Left, Right] order + // This handles the deepest nodes where both children are TermNodes + if (node.Left is GroupNode leftGroup && String.IsNullOrEmpty(leftGroup.Field)) + { + foreach (var descendant in GetAllFieldQueryNodes(leftGroup)) + yield return descendant; + } + else if (node.Left is IFieldQueryNode leftField) + { + yield return leftField; + } - if (node.Parent == null) - node.SetAggregation(container); + if (node.Right is GroupNode rightGroupWithField) + { + // Explicit nested groups (with a Field) should be returned as-is + yield return rightGroupWithField; + } + else if (node.Right is IFieldQueryNode rightField) + { + yield return rightField; + } + } } private async Task GetParentContainerAsync(IQueryNode node, IQueryVisitorContext context) diff --git a/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineQueriesVisitor.cs b/src/Foundatio.Parsers.ElasticQueries/Visitors/CombineQueriesVisitor.cs index 390b560..3ca4bd6 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; @@ -11,7 +12,6 @@ namespace Foundatio.Parsers.ElasticQueries.Visitors; public class CombineQueriesVisitor : ChainableQueryVisitor { - public override async Task VisitAsync(GroupNode node, IQueryVisitorContext context) { await base.VisitAsync(node, context).ConfigureAwait(false); @@ -30,26 +30,84 @@ public override async Task VisitAsync(GroupNode node, IQueryVisitorContext conte if (nested != null && node.Parent != null) container = null; + var op = node.GetOperator(elasticContext); + + // Group nested queries by their path for combining + var nestedQueries = new Dictionary>(); + var regularQueries = new List<(IFieldQueryNode Node, QueryBase Query)>(); + 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; - - if (op == GroupOperator.Or && node.IsRequired()) - op = GroupOperator.And; + // Check if this is a nested query from an individual term node (not an explicit nested group) + // Explicit nested groups (like "nested:(...)") are GroupNodes with a nested Field + // We only want to combine nested queries from individual term nodes + bool isExplicitNestedGroup = child is GroupNode groupChild && !String.IsNullOrEmpty(groupChild.Field); - if (op == GroupOperator.And) + if (childQuery is NestedQuery childNested && childNested.Path != null && !isExplicitNestedGroup) { - container &= childQuery; + string pathKey = childNested.Path.Name; + if (!nestedQueries.ContainsKey(pathKey)) + nestedQueries[pathKey] = new List<(IFieldQueryNode, QueryContainer)>(); + nestedQueries[pathKey].Add((child, childNested.Query)); } - else if (op == GroupOperator.Or) + else + { + regularQueries.Add((child, childQuery)); + } + } + + // Process regular queries + foreach (var (child, childQuery) in regularQueries) + { + var q = childQuery; + if (child.IsExcluded()) + q = !q; + + var effectiveOp = op; + if (effectiveOp == GroupOperator.Or && node.IsRequired()) + effectiveOp = GroupOperator.And; + + if (effectiveOp == GroupOperator.And) + container &= q; + else if (effectiveOp == GroupOperator.Or) + container |= q; + } + + // Process nested queries - combine queries with the same path + foreach (var (path, pathQueries) in nestedQueries) + { + QueryContainer combinedInner = null; + + foreach (var (child, innerQuery) in pathQueries) { - container |= childQuery; + QueryContainer q = innerQuery; + if (child.IsExcluded()) + q = !q; + + var effectiveOp = op; + if (effectiveOp == GroupOperator.Or && node.IsRequired()) + effectiveOp = GroupOperator.And; + + if (effectiveOp == GroupOperator.And) + combinedInner &= q; + else if (effectiveOp == GroupOperator.Or) + combinedInner |= q; } + + var combinedNested = new NestedQuery { Path = path, Query = combinedInner }; + QueryBase nestedToAdd = combinedNested; + + var effectiveContainerOp = op; + if (effectiveContainerOp == GroupOperator.Or && node.IsRequired()) + effectiveContainerOp = GroupOperator.And; + + if (effectiveContainerOp == GroupOperator.And) + container &= nestedToAdd; + else if (effectiveContainerOp == GroupOperator.Or) + container |= nestedToAdd; } if (nested != null) diff --git a/src/Foundatio.Parsers.ElasticQueries/Visitors/NestedVisitor.cs b/src/Foundatio.Parsers.ElasticQueries/Visitors/NestedVisitor.cs index 95feaf0..ebe9ffd 100644 --- a/src/Foundatio.Parsers.ElasticQueries/Visitors/NestedVisitor.cs +++ b/src/Foundatio.Parsers.ElasticQueries/Visitors/NestedVisitor.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Foundatio.Parsers.ElasticQueries.Extensions; +using Foundatio.Parsers.LuceneQueries; using Foundatio.Parsers.LuceneQueries.Nodes; using Foundatio.Parsers.LuceneQueries.Visitors; using Nest; @@ -19,11 +21,82 @@ public override Task VisitAsync(GroupNode node, IQueryVisitorContext context) if (nestedProperty == null) return base.VisitAsync(node, context); - node.SetQuery(new NestedQuery { Path = nestedProperty }); + if (context.QueryType == QueryTypes.Aggregation) + node.SetNestedPath(nestedProperty); + else + node.SetQuery(new NestedQuery { Path = nestedProperty }); return base.VisitAsync(node, context); } + public override Task VisitAsync(TermNode node, IQueryVisitorContext context) + { + return HandleNestedFieldNodeAsync(node, context); + } + + public override Task VisitAsync(TermRangeNode node, IQueryVisitorContext context) + { + return HandleNestedFieldNodeAsync(node, context); + } + + public override Task VisitAsync(ExistsNode node, IQueryVisitorContext context) + { + return HandleNestedFieldNodeAsync(node, context); + } + + public override Task VisitAsync(MissingNode node, IQueryVisitorContext context) + { + return HandleNestedFieldNodeAsync(node, context); + } + + private async Task HandleNestedFieldNodeAsync(IFieldQueryNode node, IQueryVisitorContext context) + { + // Skip if inside a group that references a nested path + if (IsInsideNestedGroup(node, context)) + return; + + string nestedProperty = GetNestedProperty(node.Field, context); + if (nestedProperty == null) + return; + + if (context.QueryType == QueryTypes.Aggregation) + { + // For aggregations, just mark the node with its nested path + node.SetNestedPath(nestedProperty); + } + else if (context.QueryType == QueryTypes.Query) + { + // For queries, wrap the query in a nested query + var innerQuery = await node.GetQueryAsync(() => node.GetDefaultQueryAsync(context)); + if (innerQuery == null) + return; + + var nestedQuery = new NestedQuery + { + Path = nestedProperty, + Query = innerQuery + }; + + node.SetQuery(nestedQuery); + } + } + + private bool IsInsideNestedGroup(IQueryNode node, IQueryVisitorContext context) + { + var parent = node.Parent; + while (parent != null) + { + if (parent is GroupNode groupNode && !String.IsNullOrEmpty(groupNode.Field)) + { + string nestedProperty = GetNestedProperty(groupNode.Field, context); + if (nestedProperty != null) + return true; + } + parent = parent.Parent; + } + return false; + } + private string GetNestedProperty(string fullName, IQueryVisitorContext context) { string[] nameParts = fullName?.Split('.').ToArray(); diff --git a/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticMappingResolverTests.cs b/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticMappingResolverTests.cs index f3b19b2..e63c465 100644 --- a/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticMappingResolverTests.cs +++ b/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticMappingResolverTests.cs @@ -13,10 +13,10 @@ public ElasticMappingResolverTests(ITestOutputHelper output, ElasticsearchFixtur Log.DefaultLogLevel = Microsoft.Extensions.Logging.LogLevel.Trace; } - private ITypeMapping MapMyNestedType(TypeMappingDescriptor m) + private ITypeMapping MapMyNestedType(TypeMappingDescriptor m) { return m - .AutoMap() + .AutoMap() .Dynamic() .DynamicTemplates(t => t.DynamicTemplate("idx_text", t => t.Match("text*").Mapping(m => m.Text(mp => mp.AddKeywordAndSortFields())))) .Properties(p => p @@ -30,10 +30,10 @@ private ITypeMapping MapMyNestedType(TypeMappingDescriptor m) [Fact] public void CanResolveCodedProperty() { - string index = CreateRandomIndex(MapMyNestedType); + string index = CreateRandomIndex(MapMyNestedType); Client.IndexMany([ - new MyNestedType + new ElasticNestedQueryParserTests.MyNestedType { Field1 = "value1", Field2 = "value2", @@ -50,12 +50,12 @@ public void CanResolveCodedProperty() } ] }, - new MyNestedType { Field1 = "value2", Field2 = "value2" }, - new MyNestedType { Field1 = "value1", Field2 = "value4" } + new ElasticNestedQueryParserTests.MyNestedType { Field1 = "value2", Field2 = "value2" }, + new ElasticNestedQueryParserTests.MyNestedType { Field1 = "value1", Field2 = "value4" } ], index); Client.Indices.Refresh(index); - var resolver = ElasticMappingResolver.Create(MapMyNestedType, Client, index, _logger); + var resolver = ElasticMappingResolver.Create(MapMyNestedType, Client, index, _logger); var payloadProperty = resolver.GetMappingProperty("payload"); Assert.IsType(payloadProperty); @@ -65,10 +65,10 @@ public void CanResolveCodedProperty() [Fact] public void CanResolveProperties() { - string index = CreateRandomIndex(MapMyNestedType); + string index = CreateRandomIndex(MapMyNestedType); Client.IndexMany([ - new MyNestedType + new ElasticNestedQueryParserTests.MyNestedType { Field1 = "value1", Field2 = "value2", @@ -85,12 +85,12 @@ public void CanResolveProperties() } ] }, - new MyNestedType { Field1 = "value2", Field2 = "value2" }, - new MyNestedType { Field1 = "value1", Field2 = "value4" } + new ElasticNestedQueryParserTests.MyNestedType { Field1 = "value2", Field2 = "value2" }, + new ElasticNestedQueryParserTests.MyNestedType { Field1 = "value1", Field2 = "value4" } ], index); Client.Indices.Refresh(index); - var resolver = ElasticMappingResolver.Create(MapMyNestedType, Client, index, _logger); + var resolver = ElasticMappingResolver.Create(MapMyNestedType, Client, index, _logger); string dynamicTextAggregation = resolver.GetAggregationsFieldName("nested.data.text-0001"); Assert.Equal("nested.data.text-0001.keyword", dynamicTextAggregation); @@ -131,7 +131,7 @@ public void CanResolveProperties() var field4Property = resolver.GetMappingProperty("Field4"); Assert.IsType(field4Property); - var field4ReflectionProperty = resolver.GetMappingProperty(new Field(typeof(MyNestedType).GetProperty("Field4"))); + var field4ReflectionProperty = resolver.GetMappingProperty(new Field(typeof(ElasticNestedQueryParserTests.MyNestedType).GetProperty("Field4"))); Assert.IsType(field4ReflectionProperty); var field4ExpressionProperty = resolver.GetMappingProperty(new Field(GetObjectPath(p => p.Field4))); @@ -172,7 +172,7 @@ public void CanResolveProperties() Assert.IsType(nestedDataProperty); } - private static Expression GetObjectPath(Expression> objectPath) + private static Expression GetObjectPath(Expression> objectPath) { return objectPath; } diff --git a/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticNestedQueryParserTests.cs b/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticNestedQueryParserTests.cs new file mode 100644 index 0000000..10414fe --- /dev/null +++ b/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticNestedQueryParserTests.cs @@ -0,0 +1,829 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Foundatio.Parsers.ElasticQueries.Extensions; +using Foundatio.Parsers.ElasticQueries.Visitors; +using Foundatio.Parsers.LuceneQueries; +using Foundatio.Parsers.LuceneQueries.Visitors; +using Microsoft.Extensions.Logging; +using Nest; +using Xunit; +using Xunit.Abstractions; + +namespace Foundatio.Parsers.ElasticQueries.Tests; + +public class ElasticNestedQueryParserTests : ElasticsearchTestBase +{ + public ElasticNestedQueryParserTests(ITestOutputHelper output, ElasticsearchFixture fixture) : base(output, fixture) + { + Log.DefaultLogLevel = Microsoft.Extensions.Logging.LogLevel.Trace; + } + + [Fact] + public async Task NestedQuery_WithFieldMapAndSingleNestedField_BuildsCorrectNestedQuery() + { + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1).Index()) + .Text(e => e.Name(n => n.Field2).Index()) + .Text(e => e.Name(n => n.Field3).Index()) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1).Index()) + .Text(e => e.Name(n => n.Field2).Index()) + .Text(e => e.Name(n => n.Field3).Index()) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + await Client.IndexManyAsync([ + new MyNestedType + { + Field1 = "value1", + Field2 = "value2", + Nested = { new MyType { Field1 = "value1", Field4 = 4 } } + }, + new MyNestedType { Field1 = "value2", Field2 = "value2" }, + new MyNestedType { Field1 = "value1", Field2 = "value4" } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseFieldMap(new FieldMap { { "blah", "nested" } }).UseMappings(Client).UseNested()); + var result = await processor.BuildQueryAsync("field1:value1 blah:(blah.field1:value1)", new ElasticQueryVisitorContext().UseScoring()); + + var actualResponse = Client.Search(d => d.Query(_ => result)); + string actualRequest = actualResponse.GetRequest(); + _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")))))); + + string expectedRequest = expectedResponse.GetRequest(); + _logger.LogInformation("Expected: {Request}", expectedRequest); + + Assert.Equal(expectedRequest, actualRequest); + Assert.Equal(expectedResponse.Total, actualResponse.Total); + } + + [Fact] + public async Task NestedQuery_WithFieldMapAndMultipleNestedFields_BuildsCorrectNestedQuery() + { + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1).Index()) + .Text(e => e.Name(n => n.Field2).Index()) + .Text(e => e.Name(n => n.Field3).Index()) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1).Index()) + .Text(e => e.Name(n => n.Field2).Index()) + .Text(e => e.Name(n => n.Field3).Index()) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + await Client.IndexManyAsync([ + new MyNestedType + { + Field1 = "value1", + Field2 = "value2", + Nested = { new MyType { Field1 = "value1", Field4 = 4 } } + }, + new MyNestedType { Field1 = "value2", Field2 = "value2" }, + new MyNestedType { Field1 = "value1", Field2 = "value4" } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseFieldMap(new FieldMap { { "blah", "nested" } }).UseMappings(Client).UseNested()); + var result = await processor.BuildQueryAsync("field1:value1 blah:(blah.field1:value1 blah.field4:4)", new ElasticQueryVisitorContext().UseScoring()); + + var actualResponse = Client.Search(d => d.Query(_ => result)); + string actualRequest = actualResponse.GetRequest(); + _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")) + && q2.Term(t => t.Field("nested.field4").Value(4)))))); + + string expectedRequest = expectedResponse.GetRequest(); + _logger.LogInformation("Expected: {Request}", expectedRequest); + + Assert.Equal(expectedRequest, actualRequest); + Assert.Equal(expectedResponse.Total, actualResponse.Total); + } + + [Fact] + public async Task NestedQuery_WithMultipleNestedFieldsAndConditions_BuildsCorrectNestedQuery() + { + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1).Index()) + .Text(e => e.Name(n => n.Field2).Index()) + .Text(e => e.Name(n => n.Field3).Index()) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1).Index()) + .Text(e => e.Name(n => n.Field2).Index()) + .Text(e => e.Name(n => n.Field3).Index()) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType + { + Field1 = "value1", + Field2 = "value2", + Nested = { new MyType { Field1 = "value1", Field4 = 4 } } + }, + new MyNestedType { Field1 = "value2", Field2 = "value2" }, + new MyNestedType { Field1 = "value1", Field2 = "value4", Field3 = "value3" } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); + var result = await processor.BuildQueryAsync("field1:value1 nested:(nested.field1:value1 nested.field4:4 nested.field3:value3)", + new ElasticQueryVisitorContext { UseScoring = true }); + + var actualResponse = Client.Search(d => d.Query(_ => result)); + string actualRequest = actualResponse.GetRequest(); + _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")) + && q2.Term(t => t.Field("nested.field4").Value(4)) + && q2.Match(m => m.Field("nested.field3").Query("value3")))))); + + string expectedRequest = expectedResponse.GetRequest(); + _logger.LogInformation("Expected: {Request}", expectedRequest); + + Assert.Equal(expectedRequest, actualRequest); + Assert.Equal(expectedResponse.Total, actualResponse.Total); + } + + [Fact] + public async Task NestedIndividualFieldQuery_WithSingleNestedField_WrapsInNestedQuery() + { + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1)) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType + { + Field1 = "parent1", + Nested = { new MyType { Field1 = "child1", Field4 = 5 } } + }, + new MyNestedType + { + Field1 = "parent2", + Nested = { new MyType { Field1 = "child2", Field4 = 3 } } + } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); + + // Act + var result = await processor.BuildQueryAsync("nested.field4:5", new ElasticQueryVisitorContext { UseScoring = true }); + + // Assert + var actualResponse = Client.Search(d => d.Index(index).Query(_ => result)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + var expectedResponse = Client.Search(d => d.Index(index) + .Query(q => q.Nested(n => n + .Path(p => p.Nested) + .Query(q2 => q2.Term(t => t.Field("nested.field4").Value(5)))))); + + string expectedRequest = expectedResponse.GetRequest(); + _logger.LogInformation("Expected: {Request}", expectedRequest); + + Assert.Equal(expectedRequest, actualRequest); + Assert.Equal(expectedResponse.Total, actualResponse.Total); + Assert.Equal(1, actualResponse.Total); + } + + [Fact] + public async Task NestedIndividualFieldQuery_WithMultipleNestedFieldsOrCondition_CombinesIntoSingleNestedQuery() + { + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1)) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType + { + Field1 = "parent1", + Nested = { new MyType { Field1 = "target", Field4 = 5 } } + }, + new MyNestedType + { + Field1 = "parent2", + Nested = { new MyType { Field1 = "other", Field4 = 10 } } + } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); + + // Act + var result = await processor.BuildQueryAsync("nested.field1:target OR nested.field4:10", new ElasticQueryVisitorContext { UseScoring = true }); + + // Assert + var actualResponse = Client.Search(d => d.Index(index).Query(_ => result)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + var expectedResponse = Client.Search(d => d.Index(index) + .Query(q => q.Nested(n => n + .Path(p => p.Nested) + .Query(q2 => q2.Match(m => m.Field("nested.field1").Query("target")) + || q2.Term(t => t.Field("nested.field4").Value(10)))))); + + string expectedRequest = expectedResponse.GetRequest(); + _logger.LogInformation("Expected: {Request}", expectedRequest); + + Assert.Equal(expectedRequest, actualRequest); + Assert.Equal(expectedResponse.Total, actualResponse.Total); + Assert.Equal(2, actualResponse.Total); + } + + [Fact] + public async Task NestedIndividualFieldQuery_WithRangeQuery_WrapsInNestedQuery() + { + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType { Nested = { new MyType { Field4 = 5 } } }, + new MyNestedType { Nested = { new MyType { Field4 = 15 } } }, + new MyNestedType { Nested = { new MyType { Field4 = 25 } } } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); + + // Act + var result = await processor.BuildQueryAsync("nested.field4:[10 TO 20]", new ElasticQueryVisitorContext { UseScoring = true }); + + // Assert + var actualResponse = Client.Search(d => d.Index(index).Query(_ => result)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + var expectedResponse = Client.Search(d => d.Index(index) + .Query(q => q.Nested(n => n + .Path(p => p.Nested) + .Query(q2 => q2.TermRange(r => r.Field("nested.field4").GreaterThanOrEquals("10").LessThanOrEquals("20")))))); + + string expectedRequest = expectedResponse.GetRequest(); + _logger.LogInformation("Expected: {Request}", expectedRequest); + + Assert.Equal(expectedRequest, actualRequest); + Assert.Equal(expectedResponse.Total, actualResponse.Total); + Assert.Equal(1, actualResponse.Total); + } + + [Fact] + public async Task NestedQuery_WithNegation_BuildsCorrectMustNotNestedQuery() + { + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1).Index()) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1).Index()) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType { Field1 = "parent1", Nested = { new MyType { Field1 = "excluded_value", Field4 = 10 } } }, + new MyNestedType { Field1 = "parent2", Nested = { new MyType { Field1 = "included_value", Field4 = 20 } } }, + new MyNestedType { Field1 = "parent3", Nested = { new MyType { Field1 = "another_value", Field4 = 30 } } } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); + + // Act - search for documents where nested field1 is NOT "excluded_value" + var result = await processor.BuildQueryAsync("NOT nested:(nested.field1:excluded_value)", new ElasticQueryVisitorContext().UseScoring()); + + var actualResponse = Client.Search(d => d.Index(index).Query(_ => result)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + // Assert - should exclude documents with nested.field1 = "excluded_value" + var expectedResponse = Client.Search(d => d.Index(index) + .Query(q => q.Bool(b => b + .MustNot(mn => mn + .Nested(n => n + .Path(p => p.Nested) + .Query(nq => nq + .Match(m => m.Field("nested.field1").Query("excluded_value")))))))); + + 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 parent2 and parent3 + } + + [Fact] + public async Task NestedAggregation_WithSingleNestedField_AutomaticallyWrapsInNestedAggregation() + { + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType { Nested = { new MyType { Field4 = 5 } } }, + new MyNestedType { Nested = { new MyType { Field4 = 10 } } }, + new MyNestedType { Nested = { new MyType { Field4 = 5 } } } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); + + // Act + var result = await processor.BuildAggregationsAsync("terms:nested.field4"); + + // Assert + var actualResponse = Client.Search(d => d.Index(index).Aggregations(result)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + var expectedResponse = Client.Search(d => d.Index(index) + .Aggregations(a => a + .Nested("nested_nested", n => n + .Path("nested") + .Aggregations(na => na + .Terms("terms_nested.field4", t => t + .Field("nested.field4") + .Meta(m => m.Add("@field_type", "integer"))))))); + + string expectedRequest = expectedResponse.GetRequest(); + _logger.LogInformation("Expected: {Request}", expectedRequest); + + Assert.Equal(expectedRequest, actualRequest); + Assert.Equal(expectedResponse.Total, actualResponse.Total); + } + + [Fact] + public async Task NestedAggregation_WithMultipleNestedFields_CombinesIntoSingleNestedAggregation() + { + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1).Fields(f => f.Keyword(k => k.Name("keyword")))) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType { Nested = { new MyType { Field1 = "test", Field4 = 5 } } }, + new MyNestedType { Nested = { new MyType { Field1 = "other", Field4 = 10 } } } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); + + // Parse and examine the result after visitors have run + var context = new ElasticQueryVisitorContext { QueryType = QueryTypes.Aggregation }; + var parsedNode = await processor.ParseAsync("terms:nested.field1 terms:nested.field4 max:nested.field4", context); + _logger.LogInformation("Parsed node (after visitors): {Node}", await DebugQueryVisitor.RunAsync(parsedNode)); + + // Check nested paths on term nodes + void LogNestedPaths(Foundatio.Parsers.LuceneQueries.Nodes.IQueryNode node, string indent = "") + { + if (node is Foundatio.Parsers.LuceneQueries.Nodes.IFieldQueryNode fieldNode) + { + _logger.LogInformation("{Indent}FieldNode: Field={Field}, NestedPath={NestedPath}", + indent, fieldNode.Field, fieldNode.GetNestedPath() ?? "null"); + } + if (node is Foundatio.Parsers.LuceneQueries.Nodes.GroupNode groupNode) + { + _logger.LogInformation("{Indent}GroupNode: Field={Field}", indent, groupNode.Field ?? "null"); + foreach (var child in groupNode.Children) + LogNestedPaths(child, indent + " "); + } + } + LogNestedPaths(parsedNode); + + // Act + var result = await processor.BuildAggregationsAsync("terms:nested.field1 terms:nested.field4 max:nested.field4"); + + // Assert + var actualResponse = Client.Search(d => d.Index(index).Aggregations(result)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + // Note: @field_type is "text" because the property lookup uses the original field (nested.field1), + // not the resolved aggregation field (nested.field1.keyword) + // Note: The order matches the depth-first bottom-up visitor order (rightmost first) + var expectedResponse = Client.Search(d => d.Index(index) + .Aggregations(a => a + .Nested("nested_nested", n => n + .Path("nested") + .Aggregations(na => na + .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"))) + .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); + } + + [Fact] + public async Task NestedAggregation_WithIncludeCommaSeparatedValues_FiltersCorrectly() + { + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1).Fields(f => f.Keyword(k => k.Name("keyword")))) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType { Nested = { new MyType { Field1 = "apple", Field4 = 1 } } }, + new MyNestedType { Nested = { new MyType { Field1 = "banana", Field4 = 2 } } }, + new MyNestedType { Nested = { new MyType { Field1 = "cherry", Field4 = 3 } } }, + new MyNestedType { Nested = { new MyType { Field1 = "date", Field4 = 4 } } } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); + + // Act - multiple @include values should be combined into one list + var result = await processor.BuildAggregationsAsync("terms:(nested.field1 @include:apple @include:banana @include:cherry)"); + + // Assert + var actualResponse = Client.Search(d => d.Index(index).Aggregations(result)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + var expectedResponse = Client.Search(d => d.Index(index) + .Aggregations(a => a + .Nested("nested_nested", n => n + .Path("nested") + .Aggregations(na => na + .Terms("terms_nested.field1", t => t + .Field("nested.field1.keyword") + .Include(["apple", "banana", "cherry"]) + .Meta(m => m.Add("@field_type", "keyword"))))))); // "keyword" because GroupNode uses resolved aggregation field + + string expectedRequest = expectedResponse.GetRequest(); + _logger.LogInformation("Expected: {Request}", expectedRequest); + + Assert.Equal(expectedRequest, actualRequest); + Assert.Equal(expectedResponse.Total, actualResponse.Total); + } + + [Fact] + public async Task NestedAggregation_WithIncludeExcludeMissingMin_BuildsCorrectTermsAggregation() + { + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1).Fields(f => f.Keyword(k => k.Name("keyword")))) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType { Nested = { new MyType { Field1 = "apple" } } }, + new MyNestedType { Nested = { new MyType { Field1 = "banana" } } }, + new MyNestedType { Nested = { new MyType { Field1 = "cherry" } } } + ]); + await Client.Indices.RefreshAsync(index); + + 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)"); + + // Assert + var actualResponse = Client.Search(d => d.Index(index).Aggregations(result)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + var expectedResponse = Client.Search(d => d.Index(index) + .Aggregations(a => a + .Nested("nested_nested", n => n + .Path("nested") + .Aggregations(na => na + .Terms("terms_nested.field1", t => t + .Field("nested.field1.keyword") + .MinimumDocumentCount(1) + .Include(["myinclude", "otherinclude"]) + .Exclude(["myexclude", "otherexclude"]) + .Missing("mymissing") + .Meta(m => m.Add("@field_type", "keyword"))))))); // "keyword" because GroupNode uses resolved aggregation field + + string expectedRequest = expectedResponse.GetRequest(); + _logger.LogInformation("Expected: {Request}", expectedRequest); + + Assert.Equal(expectedRequest, actualRequest); + Assert.Equal(expectedResponse.Total, actualResponse.Total); + } + + [Fact] + public async Task NestedDefaultSearch_WithNestedFieldInDefaultFields_SearchesNestedFields() + { + // When default fields include nested fields, we can't use multi_match because: + // 1. Nested fields require a NestedQuery wrapper + // 2. Multi_match across nested and non-nested fields is invalid + // We need to split into separate queries: regular match for non-nested, nested(match) for nested + + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType + { + Field1 = "parent", + Nested = { new MyType { Field1 = "special_value" } } + }, + new MyNestedType + { + Field1 = "other_parent", + Nested = { new MyType { Field1 = "normal_value" } } + } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c + .SetLoggerFactory(Log) + .UseMappings(Client) + .UseNested() + .SetDefaultFields(["field1", "nested.field1"])); + + // Act + var result = await processor.BuildQueryAsync("special_value", new ElasticQueryVisitorContext().UseSearchMode()); + + // Assert + var actualResponse = Client.Search(d => d.Index(index).Query(_ => result)); + string actualRequest = actualResponse.GetRequest(); + _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")))))); + + string expectedRequest = expectedResponse.GetRequest(); + _logger.LogInformation("Expected: {Request}", expectedRequest); + + Assert.Equal(expectedRequest, actualRequest); + Assert.Equal(expectedResponse.Total, actualResponse.Total); + Assert.Equal(1, actualResponse.Total); + } + + [Fact] + public async Task NestedDefaultSearch_WithMultipleNestedFieldsSamePath_CombinesIntoSingleNestedQuery() + { + // When multiple nested fields from the same path are in default fields, + // they should be combined into a single NestedQuery with multi_match inside + + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1)) + .Text(e => e.Name(n => n.Field2)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType + { + Field1 = "parent", + Nested = { new MyType { Field1 = "findme", Field2 = "other" } } + }, + new MyNestedType + { + Field1 = "another", + Nested = { new MyType { Field1 = "other", Field2 = "findme" } } + }, + new MyNestedType + { + Field1 = "nomatch", + Nested = { new MyType { Field1 = "no", Field2 = "match" } } + } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c + .SetLoggerFactory(Log) + .UseMappings(Client) + .UseNested() + .SetDefaultFields(["field1", "nested.field1", "nested.field2"])); + + // Act + var result = await processor.BuildQueryAsync("findme", new ElasticQueryVisitorContext().UseSearchMode()); + + // Assert + var actualResponse = Client.Search(d => d.Index(index).Query(_ => result)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + // Expected: regular match for field1, nested with multi_match for nested.field1 and nested.field2 + var expectedResponse = Client.Search(d => d.Index(index) + .Query(q => q.Match(m => m.Field("field1").Query("findme")) + || q.Nested(n => n + .Path("nested") + .Query(q2 => q2.MultiMatch(mm => mm + .Fields(f => f.Fields("nested.field1", "nested.field2")) + .Query("findme")))))); + + 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 both docs with "findme" in nested fields + } + + [Fact] + public async Task NestedDefaultSearch_WithMixedFieldTypes_SplitsIntoAppropriateQueries() + { + // When default fields include both text and non-text types across nested and non-nested, + // we need to split into appropriate query types for each field + + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1)) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType + { + Field1 = "parent", + Field4 = 42, + Nested = { new MyType { Field1 = "child", Field4 = 99 } } + }, + new MyNestedType + { + Field1 = "42", // Field1 contains "42" as text + Field4 = 0, + Nested = { new MyType { Field1 = "other", Field4 = 42 } } // nested.field4 = 42 + }, + new MyNestedType + { + Field1 = "nomatch", + Field4 = 100, + Nested = { new MyType { Field1 = "no", Field4 = 100 } } + } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c + .SetLoggerFactory(Log) + .UseMappings(Client) + .UseNested() + .SetDefaultFields(["field1", "field4", "nested.field1", "nested.field4"])); + + // Act - search for "42" + var result = await processor.BuildQueryAsync("42", new ElasticQueryVisitorContext().UseSearchMode()); + + // Assert + var actualResponse = Client.Search(d => d.Index(index).Query(_ => result)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + // Expected: match for text fields, term for integer fields, with nested wrappers + var expectedResponse = Client.Search(d => d.Index(index) + .Query(q => q.Match(m => m.Field("field1").Query("42")) + || q.Term(t => t.Field("field4").Value(42)) + || q.Nested(n => n + .Path("nested") + .Query(q2 => q2.Match(m => m.Field("nested.field1").Query("42")) + || q2.Term(t => t.Field("nested.field4").Value(42)))))); + + string expectedRequest = expectedResponse.GetRequest(); + _logger.LogInformation("Expected: {Request}", expectedRequest); + + Assert.Equal(expectedRequest, actualRequest); + Assert.Equal(expectedResponse.Total, actualResponse.Total); + Assert.Equal(2, actualResponse.Total); // Matches doc with Field1="42"/Field4=42 and doc with nested.Field4=42 + } + + [Fact] + public async Task NestedMixedOperations_WithQueryAndAggregation_HandlesNestedContextCorrectly() + { + // Arrange + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 + .Text(e => e.Name(n => n.Field1).Fields(f => f.Keyword(k => k.Name("keyword")))) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )) + )); + + await Client.IndexManyAsync([ + new MyNestedType { Nested = { new MyType { Field1 = "high", Field4 = 10 } } }, + new MyNestedType { Nested = { new MyType { Field1 = "medium", Field4 = 5 } } }, + new MyNestedType { Nested = { new MyType { Field1 = "low", Field4 = 1 } } } + ]); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); + + // Act - Query with nested field filter + var queryResult = await processor.BuildQueryAsync("nested.field4:>=5", new ElasticQueryVisitorContext { UseScoring = true }); + + // Act - Aggregation on nested fields + var aggResult = await processor.BuildAggregationsAsync("terms:nested.field1 max:nested.field4"); + + // Assert + var actualResponse = Client.Search(d => d.Index(index).Query(_ => queryResult).Aggregations(aggResult)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + var expectedResponse = Client.Search(d => d.Index(index) + .Query(q => q.Nested(n => n + .Path("nested") + .Query(q2 => q2.TermRange(r => r.Field("nested.field4").GreaterThanOrEquals("5"))))) + .Aggregations(a => a + .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"))))))); + + 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 + } + + + public class MyNestedType + { + public string Field1 { get; set; } + public string Field2 { get; set; } + public string Field3 { get; set; } + public int Field4 { get; set; } + public string Field5 { get; set; } + public string Payload { get; set; } + public IList Nested { get; set; } = new List(); + } +} diff --git a/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticQueryParserTests.cs b/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticQueryParserTests.cs index a4e08be..b3cc810 100644 --- a/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticQueryParserTests.cs +++ b/tests/Foundatio.Parsers.ElasticQueries.Tests/ElasticQueryParserTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Foundatio.Parsers.ElasticQueries.Extensions; using Foundatio.Parsers.ElasticQueries.Visitors; @@ -204,7 +203,8 @@ public async Task ShouldHandleMultipleTermsForAnalyzedFields() Assert.Equal(expectedRequest, actualRequest); Assert.Equal(expectedResponse.Total, actualResponse.Total); - // multi-match on multiple default fields + // multi-match on multiple default fields with mixed types (text + keyword) + // Now splits into match for text fields and term for keyword fields processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).SetDefaultFields(["field1", "field2"]).UseMappings(Client, index)); result = await processor.BuildQueryAsync("value1 abc def ghi", new ElasticQueryVisitorContext().UseSearchMode()); actualResponse = Client.Search(d => d.Index(index).Query(_ => result)); @@ -212,10 +212,14 @@ public async Task ShouldHandleMultipleTermsForAnalyzedFields() _logger.LogInformation("Actual: {Request}", actualRequest); expectedResponse = Client.Search(d => d.Index(index).Query(f => - f.MultiMatch(m => m.Fields(mf => mf.Fields("field1", "field2")).Query("value1")) - || f.MultiMatch(m => m.Fields(mf => mf.Fields("field1", "field2")).Query("abc")) - || f.MultiMatch(m => m.Fields(mf => mf.Fields("field1", "field2")).Query("def")) - || f.MultiMatch(m => m.Fields(mf => mf.Fields("field1", "field2")).Query("ghi")))); + f.Match(m => m.Field("field1").Query("value1")) + || f.Term(t => t.Field("field2").Value("value1")) + || f.Match(m => m.Field("field1").Query("abc")) + || f.Term(t => t.Field("field2").Value("abc")) + || f.Match(m => m.Field("field1").Query("def")) + || f.Term(t => t.Field("field2").Value("def")) + || f.Match(m => m.Field("field1").Query("ghi")) + || f.Term(t => t.Field("field2").Value("ghi")))); expectedRequest = expectedResponse.GetRequest(); _logger.LogInformation("Expected: {Request}", expectedRequest); @@ -225,7 +229,50 @@ public async Task ShouldHandleMultipleTermsForAnalyzedFields() } [Fact] - public void CanGetMappingsFromCode() + public async Task DefaultSearch_WithMixedFieldTypes_SplitsIntoSeparateQueries() + { + // When default fields include different types (text vs integer), we can't use multi_match + // because Elasticsearch multi_match expects compatible field types. + // We need to split into separate queries per field type. + string index = CreateRandomIndex(d => d.Properties(p => p + .Text(e => e.Name(n => n.Field1)) + .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) + )); + + await Client.IndexManyAsync([ + new MyType { Field1 = "test value", Field4 = 42 }, + new MyType { Field1 = "other value", Field4 = 100 }, + new MyType { Field1 = "42", Field4 = 99 } // Field1 contains "42" as text + ], index); + await Client.Indices.RefreshAsync(index); + + var processor = new ElasticQueryParser(c => c + .SetLoggerFactory(Log) + .SetDefaultFields(["field1", "field4"]) + .UseMappings(Client, index)); + + // Search for "42" - should match field1 containing "42" text AND field4 = 42 + var result = await processor.BuildQueryAsync("42", new ElasticQueryVisitorContext().UseSearchMode()); + + var actualResponse = Client.Search(d => d.Index(index).Query(_ => result)); + string actualRequest = actualResponse.GetRequest(); + _logger.LogInformation("Actual: {Request}", actualRequest); + + // Expected: Split into match query for text field, term query for integer field + var expectedResponse = Client.Search(d => d.Index(index) + .Query(q => q.Match(m => m.Field("field1").Query("42")) + || q.Term(t => t.Field("field4").Value(42)))); + + 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 doc with Field1="42" and doc with Field4=42 + } + + [Fact] + public async Task CanGetMappingsFromCode() { TypeMappingDescriptor GetCodeMappings(TypeMappingDescriptor d) => d.Dynamic() @@ -238,8 +285,8 @@ TypeMappingDescriptor GetCodeMappings(TypeMappingDescriptor d) = .GeoPoint(g => g.Name(f => f.Field3)) .Keyword(e => e.Name(m => m.Field2)))); - var res = Client.Index(new MyType { Field1 = "value1", Field2 = "value2", Field4 = 1, Field5 = DateTime.Now }, i => i.Index(index)); - Client.Indices.Refresh(index); + await Client.IndexAsync(new MyType { Field1 = "value1", Field2 = "value2", Field4 = 1, Field5 = DateTime.Now }, i => i.Index(index)); + await Client.Indices.RefreshAsync(index); var parser = new ElasticQueryParser(c => c.SetDefaultFields(["field1"]).UseMappings(GetCodeMappings, Client, index)); @@ -839,127 +886,6 @@ await Client.IndexManyAsync([ Assert.Equal(expectedResponse.Total, actualResponse.Total); } - [Fact] - public async Task NestedFilterProcessor() - { - string index = CreateRandomIndex(d => d.Properties(p => p - .Text(e => e.Name(n => n.Field1).Index()) - .Text(e => e.Name(n => n.Field2).Index()) - .Text(e => e.Name(n => n.Field3).Index()) - .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) - .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 - .Text(e => e.Name(n => n.Field1).Index()) - .Text(e => e.Name(n => n.Field2).Index()) - .Text(e => e.Name(n => n.Field3).Index()) - .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) - )) - )); - await Client.IndexManyAsync([ - new MyNestedType - { - Field1 = "value1", - Field2 = "value2", - Nested = { new MyType { Field1 = "value1", Field4 = 4 } } - }, - new MyNestedType { Field1 = "value2", Field2 = "value2" }, - new MyNestedType { Field1 = "value1", Field2 = "value4" } - ]); - await Client.Indices.RefreshAsync(index); - - var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseFieldMap(new FieldMap { { "blah", "nested" } }).UseMappings(Client).UseNested()); - var result = await processor.BuildQueryAsync("field1:value1 blah:(blah.field1:value1)", new ElasticQueryVisitorContext().UseScoring()); - - var actualResponse = Client.Search(d => d.Query(_ => result)); - string actualRequest = actualResponse.GetRequest(); - _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")))))); - - string expectedRequest = expectedResponse.GetRequest(); - _logger.LogInformation("Expected: {Request}", expectedRequest); - - Assert.Equal(expectedRequest, actualRequest); - Assert.Equal(expectedResponse.Total, actualResponse.Total); - - result = await processor.BuildQueryAsync("field1:value1 blah:(blah.field1:value1 blah.field4:4)", new ElasticQueryVisitorContext().UseScoring()); - - actualResponse = Client.Search(d => d.Query(_ => result)); - actualRequest = actualResponse.GetRequest(); - _logger.LogInformation("Actual: {Request}", actualRequest); - - 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")) - && q2.Term("nested.field4", "4"))))); - - expectedRequest = expectedResponse.GetRequest(); - _logger.LogInformation("Expected: {Request}", expectedRequest); - - Assert.Equal(expectedRequest, actualRequest); - Assert.Equal(expectedResponse.Total, actualResponse.Total); - } - - [Fact] - public async Task NestedFilterProcessor2() - { - string index = CreateRandomIndex(d => d.Properties(p => p - .Text(e => e.Name(n => n.Field1).Index()) - .Text(e => e.Name(n => n.Field2).Index()) - .Text(e => e.Name(n => n.Field3).Index()) - .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) - .Nested(r => r.Name(n => n.Nested.First()).Properties(p1 => p1 - .Text(e => e.Name(n => n.Field1).Index()) - .Text(e => e.Name(n => n.Field2).Index()) - .Text(e => e.Name(n => n.Field3).Index()) - .Number(e => e.Name(n => n.Field4).Type(NumberType.Integer)) - )) - )); - - await Client.IndexManyAsync([ - new MyNestedType - { - Field1 = "value1", - Field2 = "value2", - Nested = { new MyType { Field1 = "value1", Field4 = 4 } } - }, - new MyNestedType { Field1 = "value2", Field2 = "value2" }, - new MyNestedType { Field1 = "value1", Field2 = "value4", Field3 = "value3" } - ]); - await Client.Indices.RefreshAsync(index); - - var processor = new ElasticQueryParser(c => c.SetLoggerFactory(Log).UseMappings(Client).UseNested()); - var result = await processor.BuildQueryAsync("field1:value1 nested:(nested.field1:value1 nested.field4:4 nested.field3:value3)", - new ElasticQueryVisitorContext { UseScoring = true }); - - var actualResponse = Client.Search(d => d.Query(_ => result)); - string actualRequest = actualResponse.GetRequest(); - _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")) - && q2.Term("nested.field4", "4") - && q2.Match(m => m.Field("nested.field3").Query("value3")))))); - - string expectedRequest = expectedResponse.GetRequest(); - _logger.LogInformation("Expected: {Request}", expectedRequest); - - Assert.Equal(expectedRequest, actualRequest); - Assert.Equal(expectedResponse.Total, actualResponse.Total); - } - [Fact] public async Task CanGenerateMatchQuery() { @@ -1359,10 +1285,10 @@ public async Task CanParseSort() [Fact] public async Task CanHandleSpacedFields() { - string index = CreateRandomIndex(); + string index = CreateRandomIndex(); await Client.IndexManyAsync([ - new MyNestedType + new ElasticNestedQueryParserTests.MyNestedType { Field1 = "value1", Field2 = "value2", @@ -1379,8 +1305,8 @@ await Client.IndexManyAsync([ } ] }, - new MyNestedType { Field1 = "value2", Field2 = "value2" }, - new MyNestedType { Field1 = "value1", Field2 = "value4" } + new ElasticNestedQueryParserTests.MyNestedType { Field1 = "value2", Field2 = "value2" }, + new ElasticNestedQueryParserTests.MyNestedType { Field1 = "value1", Field2 = "value4" } ], index); await Client.Indices.RefreshAsync(index); @@ -1537,20 +1463,8 @@ public class MyType public Dictionary Data { get; set; } = new Dictionary(); } -public class MyNestedType -{ - public string Field1 { get; set; } - public string Field2 { get; set; } - public string Field3 { get; set; } - public int Field4 { get; set; } - public string Field5 { get; set; } - public string Payload { get; set; } - public IList Nested { get; set; } = new List(); -} - public class UpdateFixedTermFieldToDateFixedExistsQueryVisitor : ChainableQueryVisitor { - public override void Visit(TermNode node, IQueryVisitorContext context) { if (!String.Equals(node.Field, "fixed", StringComparison.OrdinalIgnoreCase))