Skip to content

Commit a3f7297

Browse files
committed
Parse start/end/time/step parameters
1 parent 5aca3d7 commit a3f7297

File tree

7 files changed

+332
-65
lines changed

7 files changed

+332
-65
lines changed

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/LogicalPlanBuilder.java

Lines changed: 6 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,7 @@
4242
import org.elasticsearch.xpack.esql.core.expression.UnresolvedStar;
4343
import org.elasticsearch.xpack.esql.core.tree.Source;
4444
import org.elasticsearch.xpack.esql.core.type.DataType;
45-
import org.elasticsearch.xpack.esql.core.util.CollectionUtils;
4645
import org.elasticsearch.xpack.esql.core.util.Holder;
47-
import org.elasticsearch.xpack.esql.core.util.StringUtils;
4846
import org.elasticsearch.xpack.esql.expression.Order;
4947
import org.elasticsearch.xpack.esql.expression.UnresolvedNamePattern;
5048
import org.elasticsearch.xpack.esql.expression.function.UnresolvedFunction;
@@ -53,6 +51,7 @@
5351
import org.elasticsearch.xpack.esql.expression.predicate.logical.Not;
5452
import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.Equals;
5553
import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.EsqlBinaryComparison;
54+
import org.elasticsearch.xpack.esql.parser.promql.PromqlParams;
5655
import org.elasticsearch.xpack.esql.parser.promql.PromqlParserUtils;
5756
import org.elasticsearch.xpack.esql.plan.EsqlStatement;
5857
import org.elasticsearch.xpack.esql.plan.IndexPattern;
@@ -1219,52 +1218,7 @@ public PlanFactory visitSampleCommand(EsqlBaseParser.SampleCommandContext ctx) {
12191218
@Override
12201219
public PlanFactory visitPromqlCommand(EsqlBaseParser.PromqlCommandContext ctx) {
12211220
Source source = source(ctx);
1222-
Map<String, Expression> params = new HashMap<>();
1223-
String TIME = "time", START = "start", END = "end", STEP = "step";
1224-
Set<String> allowed = Set.of(TIME, START, END, STEP);
1225-
1226-
if (ctx.promqlParam().isEmpty()) {
1227-
throw new ParsingException(source(ctx), "Parameter [{}] or [{}] is required", STEP, TIME);
1228-
}
1229-
1230-
for (EsqlBaseParser.PromqlParamContext paramCtx : ctx.promqlParam()) {
1231-
var paramNameCtx = paramCtx.name;
1232-
String name = paramNameCtx.getText();
1233-
if (params.containsKey(name)) {
1234-
throw new ParsingException(source(paramNameCtx), "[{}] already specified", name);
1235-
}
1236-
if (allowed.contains(name) == false) {
1237-
String message = "Unknown parameter [{}]";
1238-
List<String> similar = StringUtils.findSimilar(name, allowed);
1239-
if (CollectionUtils.isEmpty(similar) == false) {
1240-
message += ", did you mean " + (similar.size() == 1 ? "[" + similar.get(0) + "]" : "any of " + similar) + "?";
1241-
}
1242-
throw new ParsingException(source(paramNameCtx), message, name);
1243-
}
1244-
String value = paramCtx.value.getText();
1245-
// TODO: validate and convert the value
1246-
1247-
}
1248-
1249-
// Validation logic for time parameters
1250-
Expression time = params.get(TIME);
1251-
Expression start = params.get(START);
1252-
Expression end = params.get(END);
1253-
Expression step = params.get(STEP);
1254-
1255-
if (time != null && (start != null || end != null || step != null)) {
1256-
throw new ParsingException(
1257-
source,
1258-
"Specify either [{}] for instant query or [{}}], [{}] or [{}}] for a range query",
1259-
TIME,
1260-
STEP,
1261-
START,
1262-
END
1263-
);
1264-
}
1265-
if ((start != null || end != null) && step == null) {
1266-
throw new ParsingException(source, "[{}}] is required alongside [{}}] or [{}}]", STEP, START, END);
1267-
}
1221+
PromqlParams params = PromqlParams.parse(ctx, source);
12681222

12691223
// TODO: Perform type and value validation
12701224
var queryCtx = ctx.promqlQueryPart();
@@ -1292,9 +1246,10 @@ public PlanFactory visitPromqlCommand(EsqlBaseParser.PromqlCommandContext ctx) {
12921246
throw PromqlParserUtils.adjustParsingException(pe, promqlStartLine, promqlStartColumn);
12931247
}
12941248

1295-
return plan -> time != null
1296-
? new PromqlCommand(source, plan, promqlPlan, time)
1297-
: new PromqlCommand(source, plan, promqlPlan, start, end, step);
1249+
return plan -> params.time() != null
1250+
? new PromqlCommand(source, plan, promqlPlan, params.time())
1251+
: new PromqlCommand(source, plan, promqlPlan, params.start(), params.end(), params.step());
1252+
12981253
}
12991254

13001255
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
8+
package org.elasticsearch.xpack.esql.parser.promql;
9+
10+
import org.elasticsearch.xpack.esql.core.tree.Source;
11+
import org.elasticsearch.xpack.esql.core.util.CollectionUtils;
12+
import org.elasticsearch.xpack.esql.core.util.StringUtils;
13+
import org.elasticsearch.xpack.esql.parser.EsqlBaseParser;
14+
import org.elasticsearch.xpack.esql.parser.ParsingException;
15+
16+
import java.time.Duration;
17+
import java.time.Instant;
18+
import java.util.HashSet;
19+
import java.util.List;
20+
import java.util.Set;
21+
22+
import static org.elasticsearch.xpack.esql.parser.ParserUtils.source;
23+
24+
/**
25+
* Container for PromQL command parameters:
26+
* <ul>
27+
* <li>time for instant queries</li>
28+
* <li>start, end, step for range queries</li>
29+
* </ul>
30+
* These can be specified in the {@linkplain org.elasticsearch.xpack.esql.plan.logical.promql.PromqlCommand PROMQL command} like so:
31+
* <pre>
32+
* # instant query
33+
* PROMQL time `2025-10-31T00:00:00Z` (avg(foo))
34+
* # range query with explicit start and end
35+
* PROMQL start `2025-10-31T00:00:00Z` end `2025-10-31T01:00:00Z` step 1m (avg(foo))
36+
* # range query with implicit time bounds, doesn't support calling {@code start()} or {@code end()} functions
37+
* PROMQL step 5m (avg(foo))
38+
* </pre>
39+
*
40+
* @see <a href="https://prometheus.io/docs/prometheus/latest/querying/api/#expression-queries">PromQL API documentation</a>
41+
*/
42+
public record PromqlParams(Instant time, Instant start, Instant end, Duration step) {
43+
44+
private static final String TIME = "time", START = "start", END = "end", STEP = "step";
45+
private static final Set<String> ALLOWED = Set.of(TIME, START, END, STEP);
46+
47+
public static PromqlParams parse(EsqlBaseParser.PromqlCommandContext ctx, Source source) {
48+
Instant time = null;
49+
Instant start = null;
50+
Instant end = null;
51+
Duration step = null;
52+
53+
Set<String> paramsSeen = new HashSet<>();
54+
for (EsqlBaseParser.PromqlParamContext paramCtx : ctx.promqlParam()) {
55+
var paramNameCtx = paramCtx.name;
56+
String name = paramNameCtx.getText();
57+
if (paramsSeen.add(name) == false) {
58+
throw new ParsingException(source(paramNameCtx), "[{}] already specified", name);
59+
}
60+
String value = paramCtx.value.getText();
61+
if (value.startsWith("`") && value.endsWith("`")) {
62+
value = value.substring(1, value.length() - 1);
63+
}
64+
Source valueSource = source(paramCtx.value);
65+
switch (name) {
66+
case TIME -> time = PromqlParserUtils.parseDate(valueSource, value);
67+
case START -> start = PromqlParserUtils.parseDate(valueSource, value);
68+
case END -> end = PromqlParserUtils.parseDate(valueSource, value);
69+
case STEP -> {
70+
try {
71+
step = Duration.ofSeconds(Integer.parseInt(value));
72+
} catch (NumberFormatException ignore) {
73+
step = PromqlParserUtils.parseDuration(valueSource, value);
74+
}
75+
}
76+
default -> {
77+
String message = "Unknown parameter [{}]";
78+
List<String> similar = StringUtils.findSimilar(name, ALLOWED);
79+
if (CollectionUtils.isEmpty(similar) == false) {
80+
message += ", did you mean " + (similar.size() == 1 ? "[" + similar.get(0) + "]" : "any of " + similar) + "?";
81+
}
82+
throw new ParsingException(source(paramNameCtx), message, name);
83+
}
84+
}
85+
}
86+
87+
// Validation logic for time parameters
88+
if (time != null) {
89+
if (start != null || end != null || step != null) {
90+
throw new ParsingException(
91+
source,
92+
"Specify either [{}] for instant query or [{}}], [{}] or [{}}] for a range query",
93+
TIME,
94+
STEP,
95+
START,
96+
END
97+
);
98+
}
99+
} else if (step != null) {
100+
if (start != null || end != null) {
101+
if (start == null || end == null) {
102+
throw new ParsingException(
103+
source,
104+
"Parameters [{}] and [{}] must either both be specified or both be omitted for a range query",
105+
START,
106+
END
107+
);
108+
}
109+
if (end.isBefore(start)) {
110+
throw new ParsingException(
111+
source,
112+
"invalid parameter \"end\": end timestamp must not be before start time",
113+
end,
114+
start
115+
);
116+
}
117+
}
118+
if (step.isPositive() == false) {
119+
throw new ParsingException(
120+
source,
121+
"invalid parameter \"step\": zero or negative query resolution step widths are not accepted. "
122+
+ "Try a positive integer",
123+
step
124+
);
125+
}
126+
} else {
127+
throw new ParsingException(source, "Parameter [{}] or [{}] is required", STEP, TIME);
128+
}
129+
return new PromqlParams(time, start, end, step);
130+
}
131+
}

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/promql/PromqlParserUtils.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import org.elasticsearch.xpack.esql.parser.ParsingException;
1414

1515
import java.time.Duration;
16+
import java.time.Instant;
17+
import java.time.format.DateTimeParseException;
1618
import java.util.LinkedHashMap;
1719
import java.util.Map;
1820

@@ -272,4 +274,23 @@ private static int adjustColumn(int lineNumber, int columnNumber, int startColum
272274
// the column offset only applies to the first line of the PROMQL command
273275
return lineNumber == 1 ? columnNumber + startColumn - 1 : columnNumber;
274276
}
277+
278+
/*
279+
* Parses a Prometheus date which can be either a float representing epoch seconds or an RFC3339 date string.
280+
*/
281+
public static Instant parseDate(Source source, String value) {
282+
try {
283+
return Instant.ofEpochMilli((long) (Double.parseDouble(value) * 1000));
284+
} catch (NumberFormatException ignore) {
285+
// Not a float, try parsing as date string
286+
}
287+
if (value.startsWith("\"") && value.endsWith("\"")) {
288+
value = value.substring(1, value.length() - 1);
289+
}
290+
try {
291+
return Instant.parse(value);
292+
} catch (DateTimeParseException e) {
293+
throw new ParsingException(source, "Invalid date format [{}]", value);
294+
}
295+
}
275296
}

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/plan/logical/promql/PromqlCommand.java

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,9 @@
88
package org.elasticsearch.xpack.esql.plan.logical.promql;
99

1010
import org.elasticsearch.common.io.stream.StreamOutput;
11-
import org.elasticsearch.core.TimeValue;
1211
import org.elasticsearch.xpack.esql.capabilities.PostAnalysisVerificationAware;
1312
import org.elasticsearch.xpack.esql.capabilities.TelemetryAware;
1413
import org.elasticsearch.xpack.esql.common.Failures;
15-
import org.elasticsearch.xpack.esql.core.expression.Expression;
16-
import org.elasticsearch.xpack.esql.core.expression.Literal;
1714
import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
1815
import org.elasticsearch.xpack.esql.core.tree.Source;
1916
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;
@@ -22,6 +19,7 @@
2219

2320
import java.io.IOException;
2421
import java.time.Duration;
22+
import java.time.Instant;
2523
import java.util.Objects;
2624

2725
import static org.elasticsearch.xpack.esql.common.Failure.fail;
@@ -33,15 +31,16 @@
3331
public class PromqlCommand extends UnaryPlan implements TelemetryAware, PostAnalysisVerificationAware {
3432

3533
private final LogicalPlan promqlPlan;
36-
private final Expression start, end, step;
34+
private final Instant start, end;
35+
private final Duration step;
3736

3837
// Instant query constructor - shortcut for a range constructor
39-
public PromqlCommand(Source source, LogicalPlan child, LogicalPlan promqlPlan, Expression time) {
40-
this(source, child, promqlPlan, time, time, Literal.timeDuration(source, Duration.ZERO));
38+
public PromqlCommand(Source source, LogicalPlan child, LogicalPlan promqlPlan, Instant time) {
39+
this(source, child, promqlPlan, time, time, null);
4140
}
4241

4342
// Range query constructor
44-
public PromqlCommand(Source source, LogicalPlan child, LogicalPlan promqlPlan, Expression start, Expression end, Expression step) {
43+
public PromqlCommand(Source source, LogicalPlan child, LogicalPlan promqlPlan, Instant start, Instant end, Duration step) {
4544
super(source, child);
4645
this.promqlPlan = promqlPlan;
4746
this.start = start;
@@ -87,15 +86,15 @@ public LogicalPlan promqlPlan() {
8786
return promqlPlan;
8887
}
8988

90-
public Expression start() {
89+
public Instant start() {
9190
return start;
9291
}
9392

94-
public Expression end() {
93+
public Instant end() {
9594
return end;
9695
}
9796

98-
public Expression step() {
97+
public Duration step() {
9998
return step;
10099
}
101100

x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/promql/PromqlVerifierTests.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,7 @@ public void testPromqlMissingAcrossSeriesAggregation() {
2828
TS test | PROMQL step 5m (
2929
rate(network.bytes_in[5m])
3030
)""", tsdb),
31-
equalTo(
32-
"2:3: only aggregations across timeseries are supported at this time (found [rate(network.bytes_in[5m])])"
33-
)
31+
equalTo("2:3: only aggregations across timeseries are supported at this time (found [rate(network.bytes_in[5m])])")
3432
);
3533
}
3634

0 commit comments

Comments
 (0)