Skip to content

Commit 0f080a5

Browse files
Fix for top
1 parent 8bcb0c4 commit 0f080a5

File tree

4 files changed

+260
-31
lines changed

4 files changed

+260
-31
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
package org.elasticsearch.xpack.esql.expression.function;
8+
9+
import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
10+
import org.elasticsearch.xpack.esql.common.Failures;
11+
import org.elasticsearch.xpack.esql.core.expression.Expression;
12+
import org.elasticsearch.xpack.esql.core.expression.Literal;
13+
14+
import static org.elasticsearch.common.logging.LoggerMessageFormat.format;
15+
import static org.elasticsearch.xpack.esql.common.Failure.fail;
16+
17+
public class FunctionUtils {
18+
public static Integer limitValue(Expression limitField, String sourceText) {
19+
if (limitField instanceof Literal literal) {
20+
Object value = literal.value();
21+
if (value instanceof Integer intValue) {
22+
return intValue;
23+
}
24+
}
25+
throw new EsqlIllegalArgumentException(format(null, "Limit value must be an integer in [{}], found [{}]", sourceText, limitField));
26+
}
27+
28+
/**
29+
* We check that the limit is not null and that if it is a literal, it is a positive integer
30+
* We will do a more thorough check in the postOptimizationVerification once folding is done.
31+
*/
32+
public static Expression.TypeResolution resolveTypeLimit(Expression limitField, String sourceText) {
33+
if (limitField == null) {
34+
return new Expression.TypeResolution(format(null, "Limit must be a constant integer in [{}], found [{}]", sourceText, limitField));
35+
}
36+
if (limitField instanceof Literal literal) {
37+
if (literal.value() == null) {
38+
return new Expression.TypeResolution(
39+
format(null, "Limit must be a constant integer in [{}], found [{}]", sourceText, limitField)
40+
);
41+
}
42+
int value = (Integer) literal.value();
43+
if (value <= 0) {
44+
return new Expression.TypeResolution(format(null, "Limit must be greater than 0 in [{}], found [{}]", sourceText, value));
45+
}
46+
}
47+
return Expression.TypeResolution.TYPE_RESOLVED;
48+
}
49+
public static void postOptimizationVerificationLimit(Failures failures, Expression limitField, String sourceText) {
50+
if (limitField == null) {
51+
failures.add(fail(limitField, "Limit must be a constant integer in [{}], found [{}]", sourceText, limitField));
52+
}
53+
if (limitField instanceof Literal literal) {
54+
int value = (Integer) literal.value();
55+
if (value <= 0) {
56+
failures.add(fail(limitField, "Limit must be greater than 0 in [{}], found [{}]", sourceText, value));
57+
}
58+
} else {
59+
// it is expected that the expression is a literal after folding
60+
// we fail if it is not a literal
61+
failures.add(fail(limitField, "Limit must be a constant integer in [{}], found [{}]", sourceText, limitField));
62+
}
63+
}
64+
65+
}

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Top.java

Lines changed: 96 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020
import org.elasticsearch.compute.aggregation.TopIpAggregatorFunctionSupplier;
2121
import org.elasticsearch.compute.aggregation.TopLongAggregatorFunctionSupplier;
2222
import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
23+
import org.elasticsearch.xpack.esql.capabilities.PostOptimizationVerificationAware;
24+
import org.elasticsearch.xpack.esql.common.Failures;
2325
import org.elasticsearch.xpack.esql.core.expression.Expression;
24-
import org.elasticsearch.xpack.esql.core.expression.FoldContext;
2526
import org.elasticsearch.xpack.esql.core.expression.Literal;
2627
import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
2728
import org.elasticsearch.xpack.esql.core.tree.Source;
@@ -30,6 +31,7 @@
3031
import org.elasticsearch.xpack.esql.expression.function.Example;
3132
import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
3233
import org.elasticsearch.xpack.esql.expression.function.FunctionType;
34+
import org.elasticsearch.xpack.esql.expression.function.FunctionUtils;
3335
import org.elasticsearch.xpack.esql.expression.function.Param;
3436
import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
3537
import org.elasticsearch.xpack.esql.planner.ToAggregator;
@@ -39,14 +41,15 @@
3941

4042
import static java.util.Arrays.asList;
4143
import static org.elasticsearch.common.logging.LoggerMessageFormat.format;
44+
import static org.elasticsearch.xpack.esql.common.Failure.fail;
4245
import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.FIRST;
4346
import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.SECOND;
4447
import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.THIRD;
45-
import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNullAndFoldable;
48+
import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNull;
4649
import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString;
4750
import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType;
4851

49-
public class Top extends AggregateFunction implements ToAggregator, SurrogateExpression {
52+
public class Top extends AggregateFunction implements ToAggregator, SurrogateExpression, PostOptimizationVerificationAware {
5053
public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Top", Top::new);
5154

5255
private static final String ORDER_ASC = "ASC";
@@ -116,16 +119,18 @@ Expression orderField() {
116119
return parameters().get(1);
117120
}
118121

119-
private int limitValue() {
120-
return (int) limitField().fold(FoldContext.small() /* TODO remove me */);
121-
}
122-
123-
private String orderRawValue() {
124-
return BytesRefs.toString(orderField().fold(FoldContext.small() /* TODO remove me */));
122+
private Integer limitValue() {
123+
return FunctionUtils.limitValue(limitField(), sourceText());
125124
}
126125

127126
private boolean orderValue() {
128-
return orderRawValue().equalsIgnoreCase(ORDER_ASC);
127+
if (orderField() instanceof Literal literal) {
128+
String order = BytesRefs.toString(literal.value());
129+
if (ORDER_ASC.equalsIgnoreCase(order) || ORDER_DESC.equalsIgnoreCase(order)) {
130+
return order.equalsIgnoreCase(ORDER_ASC);
131+
}
132+
}
133+
throw new EsqlIllegalArgumentException("Order value must be a literal, found: " + orderField());
129134
}
130135

131136
@Override
@@ -148,31 +153,88 @@ protected TypeResolution resolveType() {
148153
"ip",
149154
"string",
150155
"numeric except unsigned_long or counter types"
151-
).and(isNotNullAndFoldable(limitField(), sourceText(), SECOND))
156+
).and(isNotNull(limitField(), sourceText(), SECOND))
152157
.and(isType(limitField(), dt -> dt == DataType.INTEGER, sourceText(), SECOND, "integer"))
153-
.and(isNotNullAndFoldable(orderField(), sourceText(), THIRD))
158+
.and(isNotNull(orderField(), sourceText(), THIRD))
154159
.and(isString(orderField(), sourceText(), THIRD));
155160

156161
if (typeResolution.unresolved()) {
157162
return typeResolution;
158163
}
159164

160-
var limit = limitValue();
161-
var order = orderRawValue();
162-
163-
if (limit <= 0) {
164-
return new TypeResolution(format(null, "Limit must be greater than 0 in [{}], found [{}]", sourceText(), limit));
165+
TypeResolution result = resolveTypeLimit();
166+
if (result.equals(TypeResolution.TYPE_RESOLVED) == false) {
167+
return result;
165168
}
166-
167-
if (order.equalsIgnoreCase(ORDER_ASC) == false && order.equalsIgnoreCase(ORDER_DESC) == false) {
168-
return new TypeResolution(
169-
format(null, "Invalid order value in [{}], expected [{}, {}] but got [{}]", sourceText(), ORDER_ASC, ORDER_DESC, order)
170-
);
169+
result = resolveTypeOrder();
170+
if (result.equals(TypeResolution.TYPE_RESOLVED) == false) {
171+
return result;
171172
}
173+
return TypeResolution.TYPE_RESOLVED;
174+
}
175+
176+
/**
177+
* We check that the limit is not null and that if it is a literal, it is a positive integer
178+
* We will do a more thorough check in the postOptimizationVerification once folding is done.
179+
*/
180+
private TypeResolution resolveTypeLimit() {
181+
return FunctionUtils.resolveTypeLimit(limitField(), sourceText());
182+
}
172183

184+
/**
185+
* We check that the order is not null and that if it is a literal, it is one of the two valid values: "asc" or "desc".
186+
* We will do a more thorough check in the postOptimizationVerification once folding is done.
187+
*/
188+
private TypeResolution resolveTypeOrder() {
189+
Expression order = orderField();
190+
if (order == null) {
191+
return new TypeResolution(format(null, "Order must be a valid string in [{}], found [{}]", sourceText(), order));
192+
}
193+
if (order instanceof Literal literal) {
194+
if (literal.value() == null) {
195+
return new TypeResolution(
196+
format(null, "Invalid order value in [{}], expected [{}, {}] but got [{}]", sourceText(), ORDER_ASC, ORDER_DESC, order)
197+
);
198+
}
199+
String value = BytesRefs.toString(literal.value());
200+
if (value == null || value.equalsIgnoreCase(ORDER_ASC) == false && value.equalsIgnoreCase(ORDER_DESC) == false) {
201+
return new TypeResolution(
202+
format(null, "Invalid order value in [{}], expected [{}, {}] but got [{}]", sourceText(), ORDER_ASC, ORDER_DESC, order)
203+
);
204+
}
205+
}
173206
return TypeResolution.TYPE_RESOLVED;
174207
}
175208

209+
@Override
210+
public void postOptimizationVerification(Failures failures) {
211+
postOptimizationVerificationLimit(failures);
212+
postOptimizationVerificationOrder(failures);
213+
}
214+
215+
private void postOptimizationVerificationLimit(Failures failures) {
216+
FunctionUtils.postOptimizationVerificationLimit(failures, limitField(), sourceText());
217+
}
218+
219+
private void postOptimizationVerificationOrder(Failures failures) {
220+
Expression order = orderField();
221+
if (order == null) {
222+
failures.add(fail(order, "Order must be a valid string in [{}], found [{}]", sourceText(), order));
223+
}
224+
if (order instanceof Literal literal) {
225+
String value = BytesRefs.toString(literal.value());
226+
if (value == null || value.equalsIgnoreCase(ORDER_ASC) == false && value.equalsIgnoreCase(ORDER_DESC) == false) {
227+
failures.add(
228+
fail(order, "Invalid order value in [{}], expected [{}, {}] but got [{}]", sourceText(), ORDER_ASC, ORDER_DESC, order)
229+
);
230+
}
231+
} else {
232+
// it is expected that the expression is a literal after folding
233+
// we fail if it is not a literal
234+
failures.add(fail(order, "Order must be a valid string in [{}], found [{}]", sourceText(), order));
235+
}
236+
}
237+
176238
@Override
177239
public DataType dataType() {
178240
return field().dataType().noText();
@@ -215,15 +277,20 @@ public AggregatorFunctionSupplier supplier() {
215277
@Override
216278
public Expression surrogate() {
217279
var s = source();
218-
219-
if (limitValue() == 1) {
220-
if (orderValue()) {
221-
return new Min(s, field());
222-
} else {
223-
return new Max(s, field());
280+
try {
281+
if (limitValue() == 1) {
282+
if (orderValue()) {
283+
return new Min(s, field());
284+
} else {
285+
return new Max(s, field());
286+
}
224287
}
288+
} catch (EsqlIllegalArgumentException e) {
289+
// If the limit is not a literal or is not a positive integer, we cannot create a surrogate
290+
// so we return null to indicate that no surrogate can be created.
291+
// This is possible if the limit is an expression, and folding has not been done yet.
292+
return null;
225293
}
226-
227294
return null;
228295
}
229296
}

x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/TopTests.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ public static Iterable<Object[]> parameters() {
262262
new TestCaseSupplier.TypedData(null, DataType.INTEGER, "limit").forceLiteral(),
263263
new TestCaseSupplier.TypedData(new BytesRef("desc"), DataType.KEYWORD, "order").forceLiteral()
264264
),
265-
"second argument of [source] cannot be null, received [limit]"
265+
"Limit must be a constant integer in [source], found [null]"
266266
)
267267
),
268268
new TestCaseSupplier(
@@ -273,7 +273,7 @@ public static Iterable<Object[]> parameters() {
273273
new TestCaseSupplier.TypedData(1, DataType.INTEGER, "limit").forceLiteral(),
274274
new TestCaseSupplier.TypedData(null, DataType.KEYWORD, "order").forceLiteral()
275275
),
276-
"third argument of [source] cannot be null, received [order]"
276+
"Invalid order value in [source], expected [ASC, DESC] but got [null]"
277277
)
278278
)
279279
)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
---
2+
setup:
3+
- requires:
4+
test_runner_features: [ capabilities ]
5+
capabilities:
6+
- method: POST
7+
path: /_query
8+
parameters: [ ]
9+
capabilities: [ agg_top ]
10+
reason: "uses TOP function in STATS aggregation"
11+
- do:
12+
indices.create:
13+
index: employees
14+
body:
15+
mappings:
16+
properties:
17+
hire_date:
18+
type: date
19+
salary_change:
20+
type: double
21+
salary:
22+
type: integer
23+
salary_change_long:
24+
type: long
25+
- do:
26+
bulk:
27+
index: employees
28+
refresh: true
29+
body:
30+
- { "index": { } }
31+
- { "hire_date": "2020-01-01", "salary_change": 100.5, "salary": 50000, "salary_change_long": 100 }
32+
- { "index": { } }
33+
- { "hire_date": "2021-01-01", "salary_change": 200.5, "salary": 60000, "salary_change_long": 200 }
34+
- { "index": { } }
35+
- { "hire_date": "2019-01-01", "salary_change": 50.5, "salary": 40000, "salary_change_long": 50 }
36+
37+
---
38+
Top function with constant folding:
39+
- do:
40+
esql.query:
41+
body:
42+
query: |
43+
FROM employees
44+
| STATS
45+
date = TOP(hire_date, 1+1, "dEsc"),
46+
double = TOP(salary_change, 100-98, REVERSE("csed")),
47+
integer = TOP(salary, 4-(1+1), Substring("Ascending",0,3)),
48+
long = TOP(salary_change_long, 10 - 4*2, Concat("as","c"))
49+
| LIMIT 5
50+
- match: { columns.0.name: "date" }
51+
- match: { columns.1.name: "double" }
52+
- match: { columns.2.name: "integer" }
53+
- match: { columns.3.name: "long" }
54+
- length: { values: 1 }
55+
- length: { values.0: 4 }
56+
# Check that the values are as expected for the folded constants
57+
- match: { values.0.0: [ "2021-01-01T00:00:00.000Z", "2020-01-01T00:00:00.000Z" ] }
58+
- match: { values.0.1: [ 200.5, 100.5 ] }
59+
- match: { values.0.2: [ 40000, 50000 ] }
60+
- match: { values.0.3: [ 50, 100 ] }
61+
62+
63+
---
64+
65+
Top function with negative limit value after folding:
66+
- do:
67+
catch: bad_request
68+
esql.query:
69+
body:
70+
query: |
71+
FROM employees
72+
| STATS
73+
date = TOP(hire_date, 10 - 20, "dEsc"),
74+
double = TOP(salary_change, 100-98, REVERSE("csed")),
75+
integer = TOP(salary, 4-(1+1), Substring("Ascending",0,3)),
76+
long = TOP(salary_change_long, 10 - 4*2, Concat("as","c"))
77+
| LIMIT 5
78+
- match: { error.type: "verification_exception" }
79+
- match: { error.reason: "Found 1 problem\nline 3:27: Limit must be greater than 0 in [TOP(hire_date, 10 - 20, \"dEsc\")], found [-10]" }
80+
81+
---
82+
83+
Top function with invalid sort order:
84+
- do:
85+
catch: bad_request
86+
esql.query:
87+
body:
88+
query: |
89+
FROM employees
90+
| STATS
91+
date = TOP(hire_date, 2, REVERSE("csed123")),
92+
double = TOP(salary_change, 100-98, REVERSE("csed")),
93+
integer = TOP(salary, 4-(1+1), Substring("Ascending",0,3)),
94+
long = TOP(salary_change_long, 10 - 4*2, Concat("as","c"))
95+
| LIMIT 5
96+
- match: { error.type: "verification_exception" }
97+
- match: { error.reason: "Found 1 problem\nline 3:30: Invalid order value in [TOP(hire_date, 2, REVERSE(\"csed123\"))], expected [ASC, DESC] but got [321desc]" }

0 commit comments

Comments
 (0)