Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using Nest;

namespace Foundatio.Parsers.ElasticQueries.Extensions;
Expand All @@ -7,13 +8,59 @@ public static class SearchDescriptorExtensions
{
public static SearchDescriptor<T> Aggregations<T>(this SearchDescriptor<T> descriptor, AggregationContainer aggregations) where T : class
{
descriptor.Aggregations(f =>
return descriptor.Aggregations(f => CopyAggregations(f, aggregations.Aggregations));
}

public static AggregationContainerDescriptor<T> CopyAggregations<T>(
AggregationContainerDescriptor<T> target,
IDictionary<string, IAggregationContainer> sourceAggregations
) where T : class
{
foreach (var kvp in sourceAggregations)
{
((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)
t.Field(agg.Terms.Field);

// Copy exclude
if (agg.Terms.Exclude != null)
{
if (agg.Terms.Exclude.Values != null && agg.Terms.Exclude.Values.Count() > 0)
{
t.Exclude([.. agg.Terms.Exclude.Values]);
}
}

// Copy Meta if present
if (agg.Meta != null)
{
t.Meta(d => {
foreach (var meta in agg.Terms.Meta)
d.Add(meta.Key, meta.Value);
return d;
});
}
Comment on lines +76 to +84
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition checks both agg.Meta != null and agg.Terms.Meta != null before copying metadata. However, it only iterates over agg.Terms.Meta, which means the check for agg.Meta != null is redundant and potentially confusing. The condition should only check agg.Terms.Meta != null since that's what's being used.

Copilot uses AI. Check for mistakes.

return t;
});
}
}
Comment on lines +14 to +110
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CopyAggregations method only handles three aggregation types (Nested, Terms, and Max), but silently ignores all other aggregation types. This could lead to data loss if other aggregation types are used (Min, Avg, Sum, DateHistogram, etc.). Consider either adding support for more aggregation types or logging a warning/throwing an exception when an unsupported aggregation type is encountered.

Copilot uses AI. Check for mistakes.

return target;
}

public static SearchDescriptor<T> Sort<T>(this SearchDescriptor<T> descriptor, IEnumerable<ISort> sorts) where T : class
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AggregationContainer>();

foreach (var child in node.Children.OfType<IFieldQueryNode>())
{
var aggregation = await child.GetAggregationAsync(() => child.GetDefaultAggregationAsync(context));
Expand All @@ -30,7 +33,7 @@ public override async Task VisitAsync(GroupNode node, IQueryVisitorContext conte
var termNode = child as TermNode;
if (termNode != null && termsAggregation != null)
{
// 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);
Expand All @@ -45,11 +48,8 @@ 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;
if (!string.IsNullOrEmpty(termNode.Term) && Int32.TryParse(termNode.UnescapedTerm, out int parsedMinCount))
termsAggregation.MinimumDocumentCount = parsedMinCount;
}
}

Expand Down Expand Up @@ -78,7 +78,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))
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line uses string.IsNullOrEmpty (lowercase) instead of String.IsNullOrEmpty (PascalCase). The codebase consistently uses the PascalCase version throughout all other source files. This should be changed to String.IsNullOrEmpty for consistency.

Suggested change
if (!string.IsNullOrEmpty(termNode.Term) && DateTime.TryParse(termNode.Term, out var parsedMissingDate))
if (!String.IsNullOrEmpty(termNode.Term) && DateTime.TryParse(termNode.Term, out var parsedMissingDate))

Copilot uses AI. Check for mistakes.
missingValue = parsedMissingDate;

dateHistogramAggregation.Missing = missingValue;
Expand All @@ -92,13 +92,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 == "+"))
{
Expand All @@ -113,6 +107,56 @@ public override async Task VisitAsync(GroupNode node, IQueryVisitorContext conte
}
}

if (parentBucket != null)
{
bool containsNestedField = node.Children
.OfType<IFieldQueryNode>()
.Any(c => !String.IsNullOrEmpty(c.Field) && c.Field.StartsWith("nested.", StringComparison.OrdinalIgnoreCase));

if (containsNestedField)
{
parentBucket.Aggregations ??= [];

bool nestedExists = parentBucket.Aggregations.Any(kvp => kvp.Key == "nested_nested");

if (!nestedExists)
{
var nestedAggregation = new NestedAggregation("nested_nested")
{
Path = "nested",
Aggregations = []
};

var nestedAggregationContainer = new AggregationContainer
{
Nested = nestedAggregation
};

parentBucket.Aggregations["nested_nested"] = nestedAggregationContainer;
}

var nestedAggregationContainerFromDict = parentBucket.Aggregations["nested_nested"];
var nestedAgg = nestedAggregationContainerFromDict.Nested;

nestedAgg.Aggregations ??= [];

foreach (var kvp in childAggregations)
{
nestedAgg.Aggregations[kvp.Key] = kvp.Value;
}
}
else
{
if (parentBucket.Aggregations == null)
parentBucket.Aggregations = [];

foreach (var kvp in childAggregations)
{
parentBucket.Aggregations[kvp.Key] = kvp.Value;
}
}
}

if (node.Parent == null)
node.SetAggregation(container);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ public ElasticMappingResolverTests(ITestOutputHelper output, ElasticsearchFixtur
Log.DefaultLogLevel = Microsoft.Extensions.Logging.LogLevel.Trace;
}

private ITypeMapping MapMyNestedType(TypeMappingDescriptor<MyNestedType> m)
private ITypeMapping MapMyNestedType(TypeMappingDescriptor<ElasticNestedQueryParserTests.MyNestedType> m)
{
return m
.AutoMap<MyNestedType>()
.AutoMap<ElasticNestedQueryParserTests.MyNestedType>()
.Dynamic()
.DynamicTemplates(t => t.DynamicTemplate("idx_text", t => t.Match("text*").Mapping(m => m.Text(mp => mp.AddKeywordAndSortFields()))))
.Properties(p => p
Expand All @@ -30,10 +30,10 @@ private ITypeMapping MapMyNestedType(TypeMappingDescriptor<MyNestedType> m)
[Fact]
public void CanResolveCodedProperty()
{
string index = CreateRandomIndex<MyNestedType>(MapMyNestedType);
string index = CreateRandomIndex<ElasticNestedQueryParserTests.MyNestedType>(MapMyNestedType);

Client.IndexMany([
new MyNestedType
new ElasticNestedQueryParserTests.MyNestedType
{
Field1 = "value1",
Field2 = "value2",
Expand All @@ -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<MyNestedType>(MapMyNestedType, Client, index, _logger);
var resolver = ElasticMappingResolver.Create<ElasticNestedQueryParserTests.MyNestedType>(MapMyNestedType, Client, index, _logger);

var payloadProperty = resolver.GetMappingProperty("payload");
Assert.IsType<TextProperty>(payloadProperty);
Expand All @@ -65,10 +65,10 @@ public void CanResolveCodedProperty()
[Fact]
public void CanResolveProperties()
{
string index = CreateRandomIndex<MyNestedType>(MapMyNestedType);
string index = CreateRandomIndex<ElasticNestedQueryParserTests.MyNestedType>(MapMyNestedType);

Client.IndexMany([
new MyNestedType
new ElasticNestedQueryParserTests.MyNestedType
{
Field1 = "value1",
Field2 = "value2",
Expand All @@ -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<MyNestedType>(MapMyNestedType, Client, index, _logger);
var resolver = ElasticMappingResolver.Create<ElasticNestedQueryParserTests.MyNestedType>(MapMyNestedType, Client, index, _logger);

string dynamicTextAggregation = resolver.GetAggregationsFieldName("nested.data.text-0001");
Assert.Equal("nested.data.text-0001.keyword", dynamicTextAggregation);
Expand Down Expand Up @@ -131,7 +131,7 @@ public void CanResolveProperties()
var field4Property = resolver.GetMappingProperty("Field4");
Assert.IsType<TextProperty>(field4Property);

var field4ReflectionProperty = resolver.GetMappingProperty(new Field(typeof(MyNestedType).GetProperty("Field4")));
var field4ReflectionProperty = resolver.GetMappingProperty(new Field(typeof(ElasticNestedQueryParserTests.MyNestedType).GetProperty("Field4")));
Assert.IsType<TextProperty>(field4ReflectionProperty);

var field4ExpressionProperty = resolver.GetMappingProperty(new Field(GetObjectPath(p => p.Field4)));
Expand Down Expand Up @@ -172,7 +172,7 @@ public void CanResolveProperties()
Assert.IsType<ObjectProperty>(nestedDataProperty);
}

private static Expression GetObjectPath(Expression<Func<MyNestedType, object>> objectPath)
private static Expression GetObjectPath(Expression<Func<ElasticNestedQueryParserTests.MyNestedType, object>> objectPath)
{
return objectPath;
}
Expand Down
Loading
Loading