Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9c3ef06
[ESQL] Adds parsing and planner wiring for LIMIT BY
ncordon Mar 6, 2026
869254f
Merge remote-tracking branch 'upstream/main' into esql-limit-by-planner
ncordon Mar 9, 2026
7be9e5d
Small nits
ncordon Mar 9, 2026
08c9d83
Adds more tests
ncordon Mar 9, 2026
e8db931
Merge branch 'main' into esql-limit-by-planner
ncordon Mar 9, 2026
6665e6f
Fixes tests
ncordon Mar 9, 2026
975cb89
Fixes test
ncordon Mar 10, 2026
3bbe10d
Fixes capability description
ncordon Mar 10, 2026
dc80e02
Merge branch 'main' into esql-limit-by-planner
ncordon Mar 10, 2026
650d699
Adds tests for negative and zero limits
ncordon Mar 10, 2026
6cee286
Adds tests for negative and zero limits
ncordon Mar 10, 2026
b96fda3
Merge branch 'main' into esql-limit-by-planner
ncordon Mar 10, 2026
376046f
Merge branch 'main' into esql-limit-by-planner
ncordon Mar 10, 2026
8de248c
Addresses pr review feedback
ncordon Mar 10, 2026
3a3663e
[CI] Auto commit changes from spotless
Mar 10, 2026
9de8437
Fixes missing rule for LIMIT BY
ncordon Mar 10, 2026
118fce4
Merge remote-tracking branch 'upstream/main' into esql-limit-by-planner
ncordon Mar 10, 2026
31821f3
Moves tests to limit.csv-spec file
ncordon Mar 11, 2026
a07bed3
Split planner rule into two
ncordon Mar 11, 2026
7955598
Removes Limit and LimitExec constructors without groupings
ncordon Mar 11, 2026
2252f9d
Adds test for PruneLiteralsInLimitBy and previous Eval
ncordon Mar 11, 2026
614cf8e
Reorganizes tests
ncordon Mar 11, 2026
066ebb7
Adds more tests
ncordon Mar 11, 2026
e47b07d
Tests random serialization version instead
ncordon Mar 11, 2026
a89c8fd
Merge remote-tracking branch 'upstream/main' into esql-limit-by-planner
ncordon Mar 11, 2026
26a0c76
Changes outdated comment
ncordon Mar 11, 2026
6851b6b
Addresses more pr review feedback
ncordon Mar 11, 2026
3375850
Merge remote-tracking branch 'upstream/main' into esql-limit-by-planner
ncordon Mar 11, 2026
3bf8e8a
Fixes flaky test, adds one more assert to eval field checks
ncordon Mar 11, 2026
609c51f
Gates ENRICH and FORK against simplifying LIMIT BY
ncordon Mar 11, 2026
28989be
Merge remote-tracking branch 'upstream/main' into esql-limit-by-planner
ncordon Mar 12, 2026
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
@@ -0,0 +1 @@
9314000
2 changes: 1 addition & 1 deletion server/src/main/resources/transport/upper_bounds/9.4.csv
Original file line number Diff line number Diff line change
@@ -1 +1 @@
sql_optional_allow_partial_search_results,9313000
esql_limit_by,9314000
1 change: 0 additions & 1 deletion x-pack/plugin/esql/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,6 @@ tasks.register("regenParser", JavaExec) {
'-package', 'org.elasticsearch.xpack.esql.parser',
'-listener',
'-visitor',
'-lib', outputPath,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What's this change?

Copy link
Copy Markdown
Member Author

@ncordon ncordon Mar 10, 2026

Choose a reason for hiding this comment

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

Sorry I forgot to explain it.

antlr4 only accepts a lib argument, so here passing the second one overrides the first:

https://github.com/antlr/antlr4/blob/faa457ae5b9c39b572334814f0b36c855bc23010/tool/src/org/antlr/v4/Tool.java#L99

We should not have this in the build if it's doing nothing

'-lib', "${file(grammarPath)}/parser",
'-o', outputPath,
"${file(grammarPath)}/EsqlBaseParser.g4"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
import org.elasticsearch.xpack.esql.plan.QuerySettings;
import org.elasticsearch.xpack.esql.plan.logical.Enrich;
import org.elasticsearch.xpack.esql.plan.logical.EsRelation;
import org.elasticsearch.xpack.esql.plan.logical.Eval;
import org.elasticsearch.xpack.esql.plan.logical.Explain;
import org.elasticsearch.xpack.esql.plan.logical.Limit;
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;
Expand Down Expand Up @@ -227,6 +228,7 @@
import static org.elasticsearch.xpack.esql.parser.ParserUtils.ParamClassification.VALUE;
import static org.elasticsearch.xpack.esql.plan.QuerySettings.UNMAPPED_FIELDS;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.assertNotNull;
Expand Down Expand Up @@ -758,6 +760,20 @@ public static Limit asLimit(Object node, Integer limitLiteral, Boolean duplicate
return limit;
}

/**
* Assert that an {@link Eval}'s fields are literal-valued aliases with the given names and values (in order).
*/
public static Eval assertEvalFields(Eval eval, String[] names, Object[] values) {
var fields = eval.fields();
Assert.assertEquals(names.length, fields.size());
Assert.assertEquals(names.length, values.length);
for (int i = 0; i < names.length; i++) {
assertThat(fields.get(i).name(), equalTo(names[i]));
assertThat(as(fields.get(i).child(), Literal.class).value(), equalTo(values[i]));
}
return eval;
}

public static Map<String, EsField> loadMapping(String name) {
return LoadMapping.loadMapping(name);
}
Expand Down
198 changes: 198 additions & 0 deletions x-pack/plugin/esql/qa/testFixtures/src/main/resources/limit.csv-spec
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,201 @@ emp_no:integer
10004
10005
;

//
// LIMIT BY
//

limitBy
required_capability: limit_by

FROM employees
| WHERE emp_no IN (10001, 10002, 10003, 10005, 10006)
| SORT emp_no
| LIMIT 1000
| LIMIT 5 BY languages
| KEEP emp_no, first_name, languages
;

emp_no:integer | first_name:keyword | languages:integer
10001 | Georgi | 2
10002 | Bezalel | 5
10003 | Parto | 4
10005 | Kyoichi | 1
10006 | Anneke | 3
;

limitByLimit0
required_capability: limit_by

FROM employees
| WHERE emp_no IN (10001, 10002, 10003)
| SORT emp_no
| LIMIT 1000
| LIMIT 0 BY languages
| KEEP emp_no, languages
;

emp_no:integer | languages:integer
;

limitByMultipleColumns
required_capability: limit_by

FROM employees
| WHERE emp_no IN (10001, 10003, 10004, 10007)
| SORT emp_no
| LIMIT 1000
| LIMIT 5 BY languages, gender
| KEEP emp_no, first_name, languages, gender
;

emp_no:integer | first_name:keyword | languages:integer | gender:keyword
10001 | Georgi | 2 | M
10003 | Parto | 4 | M
10004 | Chirstian | 5 | M
10007 | Tzvetan | 4 | F
;

limitByWithExpression
required_capability: limit_by

FROM employees
| WHERE emp_no IN (10001, 10002, 10003, 10004, 10005)
| SORT emp_no
| LIMIT 1000
| LIMIT 5 BY languages * 2
| KEEP emp_no, first_name, languages
;

emp_no:integer | first_name:keyword | languages:integer
10001 | Georgi | 2
10002 | Bezalel | 5
10003 | Parto | 4
10004 | Chirstian | 5
10005 | Kyoichi | 1
;

limitByMultivalueGroupKey
required_capability: limit_by

FROM employees
| WHERE emp_no IN (10001, 10008)
| SORT emp_no
| LIMIT 1000
| LIMIT 5 BY job_positions
| KEEP emp_no, first_name, job_positions
;

emp_no:integer | first_name:keyword | job_positions:keyword
10001 | Georgi | [Accountant, Senior Python Developer]
10008 | Saniya | [Internship, Junior Developer, Purchase Manager, Senior Python Developer]
;

limitByNullGroup
required_capability: limit_by

FROM employees
| WHERE emp_no IN (10001, 10020)
| SORT emp_no
| LIMIT 1000
| LIMIT 5 BY languages
| KEEP emp_no, first_name, languages
;

emp_no:integer | first_name:keyword | languages:integer
10001 | Georgi | 2
10020 | Mayuko | null
;

limitByConstant
required_capability: limit_by

FROM employees
| WHERE emp_no IN (10001, 10002, 10003, 10005, 10006)
| SORT emp_no
| LIMIT 1000
| LIMIT 2 BY 5*42
| KEEP emp_no, first_name, languages
;

emp_no:integer | first_name:keyword | languages:integer
10001 | Georgi | 2
10002 | Bezalel | 5
;

limitByWithExpressionAndConstant
required_capability: limit_by

FROM employees
| WHERE emp_no IN (10001, 10002, 10003, 10004, 10005)
| SORT emp_no
| LIMIT 1000
| LIMIT 5 BY languages * 2, 20 - 5 * 2
| KEEP emp_no, first_name, languages
;

emp_no:integer | first_name:keyword | languages:integer
10001 | Georgi | 2
10002 | Bezalel | 5
10003 | Parto | 4
10004 | Chirstian | 5
10005 | Kyoichi | 1
;

limitByWithAlias
required_capability: limit_by

FROM employees
| WHERE emp_no IN (10001, 10002, 10003, 10004, 10005)
| SORT emp_no
| LIMIT 1000
| EVAL g = languages * 2
| LIMIT 5 BY g
| KEEP emp_no, first_name, languages
;

emp_no:integer | first_name:keyword | languages:integer
10001 | Georgi | 2
10002 | Bezalel | 5
10003 | Parto | 4
10004 | Chirstian | 5
10005 | Kyoichi | 1
;

limitByThenStats
required_capability: limit_by

FROM employees
| LIMIT 2 BY languages
| STATS c = COUNT(*) BY languages
| SORT languages ASC NULLS LAST
;

c:long | languages:integer
2 | 1
2 | 2
2 | 3
2 | 4
2 | 5
2 | null
;

limitByPrecededByStats
required_capability: limit_by

FROM employees
| STATS cnt=COUNT(*) BY job_positions, languages
| SORT job_positions, cnt DESC, languages
| LIMIT 1000
| LIMIT 1 BY job_positions
| LIMIT 5
;

cnt:long | job_positions:keyword | languages:integer
5 | Accountant | 2
5 | Architect | 4
4 | Business Analyst | 2
3 | Data Scientist | 1
5 | Head Human Resources | 5
;
6 changes: 5 additions & 1 deletion x-pack/plugin/esql/src/main/antlr/EsqlBaseParser.g4
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,11 @@ stringOrParameter
;

limitCommand
: LIMIT constant
: LIMIT constant limitByGroupKey?
;

limitByGroupKey:
{this.isDevVersion()}? BY grouping=fields
;

sortCommand
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2190,6 +2190,12 @@ public enum Cap {
*/
EXTERNAL_COMMAND(Build.current().isSnapshot()),

/**

* Enables LIMIT N BY without a preceding SORT.
*/
LIMIT_BY,
Comment on lines +2193 to +2197
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

The Javadoc for LIMIT_BY says it enables LIMIT N BY "both with and without a preceding SORT", but Verifier#checkLimitBy currently rejects SORT before LIMIT BY. Please update this comment to reflect the current restriction (or relax the verifier if the intent is to support SORT-before-LIMIT BY now).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was addressed


/**
* https://github.com/elastic/elasticsearch/issues/142219
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
import org.elasticsearch.xpack.esql.plan.logical.Limit;
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;
import org.elasticsearch.xpack.esql.plan.logical.Lookup;
import org.elasticsearch.xpack.esql.plan.logical.OrderBy;
import org.elasticsearch.xpack.esql.plan.logical.Project;
import org.elasticsearch.xpack.esql.plan.logical.UnaryPlan;
import org.elasticsearch.xpack.esql.plan.logical.promql.PromqlCommand;
import org.elasticsearch.xpack.esql.telemetry.FeatureMetric;
import org.elasticsearch.xpack.esql.telemetry.Metrics;
Expand Down Expand Up @@ -119,6 +121,7 @@ Collection<Failure> verify(LogicalPlan plan, BitSet partialMetrics) {
checkUnsupportedAttributeRenaming(p, failures);
checkInsist(p, failures);
checkLimitBeforeInlineStats(p, failures);
checkLimitBy(p, failures);
});

if (failures.hasFailures() == false) {
Expand Down Expand Up @@ -332,6 +335,23 @@ private static void checkLimitBeforeInlineStats(LogicalPlan plan, Failures failu
}
}

// TODO: remove this check when SORT + LIMIT BY (TopN) support is added
private static void checkLimitBy(LogicalPlan plan, Failures failures) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note for reviewers: This is temporary until the TopN part is merged

if (plan instanceof Limit limit && limit.groupings().isEmpty() == false) {
LogicalPlan child = limit.child();
while (child instanceof UnaryPlan unary) {
if (child instanceof OrderBy) {
failures.add(fail(limit, "SORT cannot be used before LIMIT BY"));
break;
}
if (child instanceof Limit l && l.groupings().isEmpty()) {
break;
}
child = unary.child();
}
}
}

private void licenseCheck(LogicalPlan plan, Failures failures) {
Consumer<Node<?>> licenseCheck = n -> {
if (n instanceof LicenseAware la && la.licenseCheck(licenseState) == false) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,21 @@ public static boolean equalsAsAttribute(Expression left, Expression right) {
return true;
}

public static boolean listSemanticEquals(List<Expression> leftList, List<Expression> rightList) {
if (leftList.size() != rightList.size()) {
return false;
}
for (int i = 0; i < leftList.size(); i++) {
Expression left = leftList.get(i);
Expression right = rightList.get(i);
assert left != null && right != null;
if (left.semanticEquals(right) == false) {
return false;
}
}
return true;
}

public static List<Tuple<Attribute, Expression>> aliases(List<? extends NamedExpression> named) {
// an alias of same name and data type can be reused (by mistake): need to use a list to collect all refs (and later report them)
List<Tuple<Attribute, Expression>> aliases = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneEmptyAggregates;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneEmptyForkBranches;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneFilters;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneLiteralsInLimitBy;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneLiteralsInOrderBy;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneRedundantOrderBy;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.PruneRedundantSortClauses;
Expand All @@ -60,6 +61,7 @@
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceAggregateNestedExpressionWithEval;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceAliasingEvalWithProject;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceLimitAndSortAsTopN;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceLimitByExpressionWithEval;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceOrderByExpressionWithEval;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceRegexMatch;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceRowAsLocalRelation;
Expand Down Expand Up @@ -181,6 +183,7 @@ protected static Batch<LogicalPlan> substitutions() {
// check for a trivial conversion introduced by a surrogate
new ReplaceTrivialTypeConversions(),
new ReplaceOrderByExpressionWithEval(),
new ReplaceLimitByExpressionWithEval(),
Comment on lines 185 to +186
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I see ReplaceLimitByExpressionWithEval also prune literals, which is something ORDER BY does in PruneLiteralsInOrderBy.
Given the similarity, I would mimic the same rules structure, as the prune part is in the operators() batch, which gets executed multiple times

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Adding to this, I think a test like this would fail now (as in. limit won't be pruned), but work if it's separated:

FROM index
| EVAL x = 5
| LIMIT 1 BY x

x should be propagated to LIMIT BY, and then pruned

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've taken the following steps:

  • Split the rules for this into PruneLiteralsInLimitBy and ReplaceLimitByExpressionWithEval. I also had to modify PropagateEvalFoldables to cascade the foldable constants into the groupings of Limit.
  • Added a bunch of tests in ReplaceLimitByExpressionWithEvalTests and PruneLiteralsInLimitByTests.
  • Took the opportunity to also reorganize the logical planner tests a bit better and moved some tests in PushDownAndCombineLimits.

// new NormalizeAggregate(), - waits on https://github.com/elastic/elasticsearch/issues/100634
new SubstituteApproximationPlan()
);
Expand Down Expand Up @@ -224,6 +227,7 @@ protected static Batch<LogicalPlan> operators() {
new PruneFilters(),
new PruneColumns(),
new PruneLiteralsInOrderBy(),
new PruneLiteralsInLimitBy(),
new PushDownAndCombineLimits(),
new PushLimitToKnn(),
new PushDownAndCombineFilters(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ public CombineLimitTopN() {

@Override
public LogicalPlan rule(Limit limit) {
if (limit.child() instanceof TopN topn) {
// TODO: update this check when SORT + LIMIT BY (TopN) support is added
if (limit.groupings().isEmpty() && limit.child() instanceof TopN topn) {
int thisLimitValue = Foldables.limitValue(limit.limit(), limit.sourceText());
int topNValue = Foldables.limitValue(topn.limit(), topn.sourceText());
if (topNValue <= thisLimitValue) {
Expand Down
Loading
Loading