Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
44eabdc
initial outline of rule
not-napoleon Aug 18, 2025
cb9ebe7
add ability to know if an attribute is a metric
not-napoleon Aug 18, 2025
201014a
actually collect the metrics
not-napoleon Aug 18, 2025
989d6ce
simplify the lambdas. I think this is more correct
not-napoleon Aug 18, 2025
cbfbbbb
add the rule to the analisys chain
not-napoleon Aug 18, 2025
7b620c4
[CI] Auto commit changes from spotless
Aug 18, 2025
abf4d35
start of tests
not-napoleon Aug 19, 2025
9c9047f
Merge remote-tracking branch 'refs/remotes/not-napoleon/esql-skip-nul…
not-napoleon Aug 19, 2025
641fec9
[CI] Auto commit changes from spotless
Aug 19, 2025
7edbf66
remove redundant check
not-napoleon Aug 19, 2025
a9fa160
Merge remote-tracking branch 'refs/remotes/not-napoleon/esql-skip-nul…
not-napoleon Aug 19, 2025
0932461
test passes!
not-napoleon Aug 19, 2025
0451b10
more tests
not-napoleon Aug 20, 2025
540bbdc
more tests
not-napoleon Aug 20, 2025
a222e27
test non-ts query
not-napoleon Aug 20, 2025
96fef3f
move the rule to the logical plan optimizer
not-napoleon Aug 20, 2025
7d773cb
move the rule to the correct package
not-napoleon Aug 20, 2025
3cb8bf8
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Aug 20, 2025
2bf80cd
[CI] Auto commit changes from spotless
Aug 20, 2025
f255066
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Aug 21, 2025
9eb5544
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Aug 22, 2025
190a420
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Aug 22, 2025
8b77bd8
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Aug 25, 2025
4e0a8a0
Update docs/changelog/133087.yaml
not-napoleon Aug 26, 2025
4ea5f18
Fix changelog area
not-napoleon Aug 26, 2025
96161bd
Update docs/changelog/133087.yaml
not-napoleon Aug 26, 2025
a1b0298
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Aug 27, 2025
e57204c
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Aug 28, 2025
5e64278
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Aug 29, 2025
62bbf94
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Sep 2, 2025
2f9093c
response to PR feedback
not-napoleon Sep 3, 2025
484b171
Merge remote-tracking branch 'refs/remotes/not-napoleon/esql-skip-nul…
not-napoleon Sep 3, 2025
7f3f934
response to PR feedback
not-napoleon Sep 3, 2025
a7e9dd8
[CI] Auto commit changes from spotless
Sep 3, 2025
fc00a2e
do everything in one transformation pass
not-napoleon Sep 3, 2025
dffc121
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Sep 3, 2025
db91bc8
Put the filter immediately below (before) the aggregation
not-napoleon Sep 4, 2025
db14fe4
use the correct source in the conditional
not-napoleon Sep 4, 2025
9db9af5
make IgnoreNullMetrics a local rule
not-napoleon Sep 4, 2025
79f3202
move the rule to the correct package
not-napoleon Sep 4, 2025
7462fd8
refactor to use OptimizerRules.OptimizerRule
not-napoleon Sep 4, 2025
4eb33cb
[CI] Auto commit changes from spotless
Sep 4, 2025
a1b5454
skip InfernonNullAggConstraint for TS aggs
not-napoleon Sep 5, 2025
48e7dbe
Merge remote-tracking branch 'refs/remotes/not-napoleon/esql-skip-nul…
not-napoleon Sep 5, 2025
108d7d8
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Sep 5, 2025
3b08b8d
Merge branch 'main' into esql-skip-null-metrics
not-napoleon Sep 8, 2025
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
6 changes: 6 additions & 0 deletions docs/changelog/133087.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pr: 133087
summary: Esql skip null metrics
area: ES|QL
type: enhancement
issues:
- 129524
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ public static boolean dataTypeEquals(List<Attribute> left, List<Attribute> right
*/
public abstract boolean isDimension();

/**
* @return true if the attribute represents a TSDB metric type
*/
public abstract boolean isMetric();

protected void checkAndSerializeQualifier(PlanStreamOutput out, TransportVersion version) throws IOException {
if (version.supports(ESQL_QUALIFIERS_IN_ATTRIBUTES)) {
out.writeOptionalCachedString(qualifier());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ public boolean isDimension() {
return false;
}

@Override
public boolean isMetric() {
return false;
}

@Override
public boolean resolved() {
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ public boolean isDimension() {
return field.getTimeSeriesFieldType() == EsField.TimeSeriesFieldType.DIMENSION;
}

@Override
public boolean isMetric() {
return field.getTimeSeriesFieldType() == EsField.TimeSeriesFieldType.METRIC;
}

public EsField field() {
return field;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ protected String label() {

@Override
public boolean isDimension() {
// Metadata attributes cannot be dimensions. I think?
return false;
}

@Override
public boolean isMetric() {
// Metadata attributes definitely cannot be metrics.
return false;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,9 @@ protected String label() {
public boolean isDimension() {
return false;
}

@Override
public boolean isMetric() {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,14 @@ protected String label() {

@Override
public boolean isDimension() {
return false;
// We don't want to use this during the analysis phase, and this class does not exist after analysis
throw new UnsupportedOperationException("This should never be called before the attribute is resolved");
}

@Override
public boolean isMetric() {
// We don't want to use this during the analysis phase, and this class does not exist after analysis
throw new UnsupportedOperationException("This should never be called before the attribute is resolved");
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

public record EsIndex(
String name,
/** Map of field names to {@link EsField} instances representing that field */
Map<String, EsField> mapping,
Map<String, IndexMode> indexNameWithModes,
/** Fields mapped only in some (but *not* all) indices. Since this is only used by the analyzer, it is not serialized. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ConstantFolding;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ExtractAggregateCommonFilter;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.FoldNull;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.IgnoreNullMetrics;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.LiteralsOnTheRight;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PartiallyFoldCase;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PropagateEmptyRelation;
Expand Down Expand Up @@ -131,6 +132,7 @@ protected static Batch<LogicalPlan> substitutions() {
return new Batch<>(
"Substitutions",
Limiter.ONCE,
new IgnoreNullMetrics(),
new SubstituteSurrogatePlans(),
// Translate filtered expressions into aggregate with filters - can't use surrogate expressions because it was
// retrofitted for constant folding - this needs to be fixed.
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

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

Hey, I noticed that the local logical optimizer rules have InferNonNullAggConstraint, which prepends a WHERE field IS NOT NULL OR other_field IS NOT NULL to a STATS min(field), max(other_field).

This is overlapping a bit with what we're implementing here. The limitation is that InferNonNullAggConstraint is not used with a BY clause. It also places the filter directly ahead of the STATS, relying on filter pushdown to make the filter Lucene-pushable.

I'm not trying to imply that we should make InferNonNullAggConstraint handle TS cases, too. I'm fine with having a more specific rule in place. I'm saying this because the two rules may interfere with one another, and you may want to add local logical plan optimizer tests and/or local physical plan optimizer tests to ensure that you get the filter properly pushed down to Lucene. That's not really visible from the tests added in IgnoreNullMetricsTests. In fact, it may be that the added test cases are already covered by InferNonNullAggConstraint but we don't see it because this requires local tests to run.

Copy link
Contributor

Choose a reason for hiding this comment

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

Another thing: InferNonNullAggConstraint doesn't apply when there is a BY clause. That's because it would filter out some groups that have only null values.

I think the same can happen here. If we filter out documents where all the metrics are missing but group by a non-metric field, we might remove a whole group from the output. This would be inconsistent with how STATS normally works. I don't know if semantically correct for TS.

Copy link
Member Author

Choose a reason for hiding this comment

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

I believe this is intended, but @kkrik-es can confirm.

Copy link
Contributor

@kkrik-es kkrik-es Sep 4, 2025

Choose a reason for hiding this comment

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

Removing groups containing only null values makes sense for time-series, indeed, as grouping attributes (dimensions) are included in documents along with metric values.

This is very interesting, @alex-spies. I wonder if we should be piggy-backing on InferNonNullAggConstraint and apply it under TS even in the presence of grouping attributes, instead of introducing a new rule. You def know better which one is cleaner.

Copy link
Member Author

Choose a reason for hiding this comment

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

If we're going to have two rules, I think it makes sense to modify th eInferNonNullAggConstraint rule to not apply to TimeSeriesAggregation nodes. We probably still want it to apply to any later aggregations.

Copy link
Member

Choose a reason for hiding this comment

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

This is a known issue with this option. Groups without values will be omitted, and having two stats may return different groups than having one stat, even a single stat can return different groups than another. However, unlike FROM, which is document-centric, TS is metric-centric, and we are okay with this semantic. We should document this behavior in TS.

Copy link
Contributor

Choose a reason for hiding this comment

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

This is very interesting, @alex-spies. I wonder if we should be piggy-backing on InferNonNullAggConstraint and apply it under TS even in the presence of grouping attributes, instead of introducing a new rule. You def know better which one is cleaner.

I think piggy-backing and having an own rule both works. Even when piggy-backing, the separate logic between TS and non-TS queries can be made very clear in the code, so I have no issues with either approach.

If we go with two rules, I agree with @not-napoleon that we better adjust InferNonNullAggConstraint to not apply to TS queries, otherwise we'll have 2 rules doing similar work at the same time - which cannot be good when evolving and/or debugging TS.

Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.esql.optimizer.rules.logical;

import org.elasticsearch.index.IndexMode;
import org.elasticsearch.xpack.esql.core.expression.Attribute;
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.tree.Source;
import org.elasticsearch.xpack.esql.expression.predicate.logical.Or;
import org.elasticsearch.xpack.esql.expression.predicate.nulls.IsNotNull;
import org.elasticsearch.xpack.esql.plan.logical.EsRelation;
import org.elasticsearch.xpack.esql.plan.logical.Filter;
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;
import org.elasticsearch.xpack.esql.plan.logical.TimeSeriesAggregate;
import org.elasticsearch.xpack.esql.plan.logical.UnresolvedRelation;
import org.elasticsearch.xpack.esql.rule.Rule;

import java.util.HashSet;
import java.util.Set;

/**
* TSDB often ends up storing many null values for metrics columns (since not every time series contains every metric). However, loading
* many null values can negatively impact query performance. To reduce that, this rule applies filters to remove null values on all
* metrics involved in the query. In the case that there are multiple metrics, the not null checks are OR'd together, so we accept rows
* where any of the metrics have values.
*/
public final class IgnoreNullMetrics extends Rule<LogicalPlan, LogicalPlan> {
Copy link
Member

Choose a reason for hiding this comment

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

Can we move this to a logical local rule? I'm asking because we might switch to using HashJoin instead of this for semantic and performance reasons. With local logical rules, we don't need to worry much about BWC when deciding to change the execution method.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think placing this in the local logical optimizer's Local rewrite batch would be consistent. We'd also have search stats available if that helps.

Copy link
Member Author

Choose a reason for hiding this comment

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

Can we move this to a logical local rule?

Yes, I will work on this today.

With local logical rules, we don't need to worry much about BWC when deciding to change the execution method.

Can you elaborate on this? I don't see why one or the other place would impact backwards compatibility.

Copy link
Contributor

Choose a reason for hiding this comment

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

With global rules, a problem is that if data nodes later rely on a specific optimization to already have taken place, removing or changing this optimization in the coordinator is hard, because the coordinator would have to send a different plan to old nodes. We haven't solved this problem, yet, even though we'll have to sometime, soon.

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, there may be a small bwc issue here: if we move this to a local rule, then old nodes will not know about it and will continue sending unfiltered data; which is generally fine except for the edge case where there are groups with no metrics - those will be completely removed from new nodes, but will still be sent from old nodes.

I guess it's fine because we don't really care about these groups, but wanted to highlight it in case this has some consequences for anything you folks are building.

Copy link
Member Author

Choose a reason for hiding this comment

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

There are no "old nodes" yet. TS is unreleased, so the first released version will include this rule. Any nodes that do not support this rule, will also not support the TS command, and the query will fail anyway.

@Override
public LogicalPlan apply(LogicalPlan logicalPlan) {
return logicalPlan.transformUp(TimeSeriesAggregate.class, agg -> {
Copy link
Member

Choose a reason for hiding this comment

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

We can type check here instead of in transformUp, which executes another loop.

        if (logicalPlan instanceof TimeSeriesAggregate) {
            //...
        } else {
            return  logicalPlan;
        }

Set<Attribute> metrics = new HashSet<>();
agg.forEachExpression(Attribute.class, attr -> {
if (attr.isMetric()) {
metrics.add(attr);
}
});
if (metrics.isEmpty()) {
return agg;
}
Expression conditional = null;
for (Attribute metric : metrics) {
// Create an is not null check for each metric
if (conditional == null) {
conditional = new IsNotNull(logicalPlan.source(), metric);
} else {
// Join the is not null checks with OR nodes
conditional = new Or(logicalPlan.source(), conditional, new IsNotNull(Source.EMPTY, metric));
Copy link
Member

Choose a reason for hiding this comment

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

I think we should either use Source.EMPTY and logicalPlan.source() for all clauses.

}
}
Expression finalConditional = conditional;
return agg.transformUp(p -> isMetricsQuery((LogicalPlan) p), p -> new Filter(p.source(), p, finalConditional));
});
}

/**
* Scans the given {@link LogicalPlan} to see if it is a "metrics mode" query
*/
private static boolean isMetricsQuery(LogicalPlan logicalPlan) {
if (logicalPlan instanceof EsRelation r) {
return r.indexMode() == IndexMode.TIME_SERIES;
}
if (logicalPlan instanceof UnresolvedRelation r) {
return r.indexMode() == IndexMode.TIME_SERIES;
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.esql.optimizer.rules.logical;

import org.elasticsearch.index.IndexMode;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.xpack.esql.EsqlTestUtils;
import org.elasticsearch.xpack.esql.analysis.Analyzer;
import org.elasticsearch.xpack.esql.analysis.AnalyzerContext;
import org.elasticsearch.xpack.esql.analysis.AnalyzerTestUtils;
import org.elasticsearch.xpack.esql.analysis.EnrichResolution;
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
import org.elasticsearch.xpack.esql.core.type.DataType;
import org.elasticsearch.xpack.esql.core.type.EsField;
import org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry;
import org.elasticsearch.xpack.esql.expression.predicate.logical.Or;
import org.elasticsearch.xpack.esql.expression.predicate.nulls.IsNotNull;
import org.elasticsearch.xpack.esql.index.EsIndex;
import org.elasticsearch.xpack.esql.index.IndexResolution;
import org.elasticsearch.xpack.esql.optimizer.LogicalOptimizerContext;
import org.elasticsearch.xpack.esql.optimizer.LogicalPlanOptimizer;
import org.elasticsearch.xpack.esql.parser.EsqlParser;
import org.elasticsearch.xpack.esql.plan.logical.Aggregate;
import org.elasticsearch.xpack.esql.plan.logical.EsRelation;
import org.elasticsearch.xpack.esql.plan.logical.Eval;
import org.elasticsearch.xpack.esql.plan.logical.Filter;
import org.elasticsearch.xpack.esql.plan.logical.Limit;
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;

import java.util.Map;

import static org.elasticsearch.xpack.esql.EsqlTestUtils.TEST_VERIFIER;
import static org.elasticsearch.xpack.esql.EsqlTestUtils.as;
import static org.elasticsearch.xpack.esql.EsqlTestUtils.emptyInferenceResolution;
import static org.elasticsearch.xpack.esql.EsqlTestUtils.unboundLogicalOptimizerContext;
import static org.elasticsearch.xpack.esql.analysis.AnalyzerTestUtils.defaultLookupResolution;

/**
* Tests for the {@link IgnoreNullMetrics} transformation rule. Like most rule tests, this runs the entire analysis chain.
*/
public class IgnoreNullMetricsTests extends ESTestCase {

private Analyzer analyzer;

private LogicalPlan analyze(String query) {
EsqlParser parser = new EsqlParser();
Copy link
Contributor

Choose a reason for hiding this comment

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

Note: in the future, if you want to save some time when adding new tests, you can inherit from AbstractLogicalPlanOptimizerTests. You'll have all the test helpers that the logical optimizer tests already have.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I'm using that in another PR. I still need to load a schema that fits the tests. I can rework this test to inherit from that as well though.

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually, looking at it, LocalLogicalPlanOptimizerTests extends directly from ESTestCase. If I'm switching this to be a local optimization (as per https://github.com/elastic/elasticsearch/pull/133087/files#r2320394569), should I follow that pattern instead?

Copy link
Contributor

Choose a reason for hiding this comment

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

Gah, of course the local optimizer tests are structured inconsistently. Sorry about that! Use whatever base class works best for you.

EnrichResolution enrichResolution = new EnrichResolution();
AnalyzerTestUtils.loadEnrichPolicyResolution(enrichResolution, "languages_idx", "id", "languages_idx", "mapping-languages.json");
LogicalOptimizerContext logicalOptimizerCtx = unboundLogicalOptimizerContext();
LogicalPlanOptimizer logicalOptimizer = new LogicalPlanOptimizer(logicalOptimizerCtx);

Map<String, EsField> mapping = Map.of(
"dimension_1",
new EsField("dimension_1", DataType.KEYWORD, Map.of(), true, EsField.TimeSeriesFieldType.DIMENSION),
"dimension_2",
new EsField("dimension_2", DataType.KEYWORD, Map.of(), true, EsField.TimeSeriesFieldType.DIMENSION),
"metric_1",
new EsField("metric_1", DataType.LONG, Map.of(), true, EsField.TimeSeriesFieldType.METRIC),
"metric_2",
new EsField("metric_2", DataType.LONG, Map.of(), true, EsField.TimeSeriesFieldType.METRIC),
"@timestamp",
new EsField("@timestamp", DataType.DATETIME, Map.of(), true, EsField.TimeSeriesFieldType.NONE),
"_tsid",
new EsField("_tsid", DataType.TSID_DATA_TYPE, Map.of(), true, EsField.TimeSeriesFieldType.NONE)
);
EsIndex test = new EsIndex("test", mapping, Map.of("test", IndexMode.TIME_SERIES));
IndexResolution getIndexResult = IndexResolution.valid(test);
analyzer = new Analyzer(
new AnalyzerContext(
EsqlTestUtils.TEST_CFG,
new EsqlFunctionRegistry(),
getIndexResult,
defaultLookupResolution(),
enrichResolution,
emptyInferenceResolution()
),
TEST_VERIFIER
);

return logicalOptimizer.optimize(analyzer.analyze(parser.createStatement(query, EsqlTestUtils.TEST_CFG)));
}

public void testSimple() {
LogicalPlan actual = analyze("""
TS test
| STATS max(max_over_time(metric_1))
| LIMIT 10
""");
Limit limit = as(actual, Limit.class);
Aggregate agg = as(limit.child(), Aggregate.class);
// The optimizer expands the STATS out into two STATS steps
Aggregate tsAgg = as(agg.child(), Aggregate.class);
Filter filter = as(tsAgg.child(), Filter.class);
IsNotNull condition = as(filter.condition(), IsNotNull.class);
FieldAttribute attribute = as(condition.field(), FieldAttribute.class);
assertEquals("metric_1", attribute.fieldName().string());
}

public void testRuleDoesNotApplyInNonTSMode() {
LogicalPlan actual = analyze("""
FROM test
| STATS max(metric_1)
| LIMIT 10
""");
Limit limit = as(actual, Limit.class);
Aggregate agg = as(limit.child(), Aggregate.class);
EsRelation relation = as(agg.child(), EsRelation.class);
}

public void testDimensionsAreNotFiltered() {

LogicalPlan actual = analyze("""
TS test
| STATS max(max_over_time(metric_1)) BY dimension_1
| LIMIT 10
""");
Limit limit = as(actual, Limit.class);
Aggregate agg = as(limit.child(), Aggregate.class);
// The optimizer expands the STATS out into two STATS steps
Aggregate tsAgg = as(agg.child(), Aggregate.class);
Filter filter = as(tsAgg.child(), Filter.class);
IsNotNull condition = as(filter.condition(), IsNotNull.class);
FieldAttribute attribute = as(condition.field(), FieldAttribute.class);
assertEquals("metric_1", attribute.fieldName().string());
}

public void testFiltersAreJoinedWithOr() {

LogicalPlan actual = analyze("""
TS test
| STATS max(max_over_time(metric_1)), min(min_over_time(metric_2))
| LIMIT 10
""");
Limit limit = as(actual, Limit.class);
Aggregate agg = as(limit.child(), Aggregate.class);
// The optimizer expands the STATS out into two STATS steps
Aggregate tsAgg = as(agg.child(), Aggregate.class);
Filter filter = as(tsAgg.child(), Filter.class);
Or or = as(filter.expressions().getFirst(), Or.class);

// For reasons beyond my comprehension, the ordering of the conditionals inside the OR is nondeterministic.
IsNotNull condition;
FieldAttribute attribute;

condition = as(or.left(), IsNotNull.class);
attribute = as(condition.field(), FieldAttribute.class);
if (attribute.fieldName().string().equals("metric_1")) {
condition = as(or.right(), IsNotNull.class);
attribute = as(condition.field(), FieldAttribute.class);
assertEquals("metric_2", attribute.fieldName().string());
} else if (attribute.fieldName().string().equals("metric_2")) {
condition = as(or.right(), IsNotNull.class);
attribute = as(condition.field(), FieldAttribute.class);
assertEquals("metric_1", attribute.fieldName().string());
} else {
// something weird happened
assert false;
}
}

public void testSkipCoalescedMetrics() {
// Note: this test is passing because the reference attribute metric_2 in the stats block does not inherit the
// metric property from the original field.
LogicalPlan actual = analyze("""
TS test
| EVAL metric_2 = coalesce(metric_2, 0)
| STATS max(max_over_time(metric_1)), min(min_over_time(metric_2))
| LIMIT 10
""");
Limit limit = as(actual, Limit.class);
Aggregate agg = as(limit.child(), Aggregate.class);
// The optimizer expands the STATS out into two STATS steps
Aggregate tsAgg = as(agg.child(), Aggregate.class);
Eval eval = as(tsAgg.child(), Eval.class);
Filter filter = as(eval.child(), Filter.class);
IsNotNull condition = as(filter.condition(), IsNotNull.class);
FieldAttribute attribute = as(condition.field(), FieldAttribute.class);
assertEquals("metric_1", attribute.fieldName().string());
}

/**
* check that stats blocks after the first are not sourced for adding metrics to the filter
*/
public void testMultipleStats() {
LogicalPlan actual = analyze("""
TS test
| STATS m = max(max_over_time(metric_1))
| STATS sum(m)
| LIMIT 10
""");
Limit limit = as(actual, Limit.class);
Aggregate sumAgg = as(limit.child(), Aggregate.class);
Aggregate outerAgg = as(sumAgg.child(), Aggregate.class);
Aggregate tsAgg = as(outerAgg.child(), Aggregate.class);
Filter filter = as(tsAgg.child(), Filter.class);
IsNotNull condition = as(filter.condition(), IsNotNull.class);
FieldAttribute attribute = as(condition.field(), FieldAttribute.class);
assertEquals("metric_1", attribute.fieldName().string());

}
}