Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions docs/changelog/144057.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
area: ES|QL
issues: []
pr: 144057
summary: "Allow TBUCKET to skip the from/to parameters when Kibana adds a timestamp range filter. Exmaple: `TBUCKET(100)`"
type: enhancement

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -229,13 +229,13 @@ public class Analyzer extends ParameterizedRuleExecutor<LogicalPlan, AnalyzerCon
"Initialize",
Limiter.ONCE,
new ResolveConfigurationAware(),
new ResolveTimestampBoundsAware(),
new ResolveTable(),
new ResolveExternalRelations(),
new PruneEmptyUnionAllBranch(),
new ResolveEnrich(),
new ResolveLookupTables(),
new ResolveFunctions(),
new ResolveTimestampBoundsAware(),
new ResolveInference(),
new DateMillisToNanosInEsRelation()
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ Collection<Failure> verify(LogicalPlan plan, BitSet partialMetrics) {
}

planCheckers.forEach(c -> c.accept(p, failures));
p.forEachExpression(e -> {
if (e instanceof PostAnalysisVerificationAware va) {
va.postAnalysisVerification(failures);
}
});

checkOperationsOnUnsignedLong(p, failures);
checkBinaryComparison(p, failures);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.compute.expression.ExpressionEvaluator;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.xpack.esql.common.Failure;
import org.elasticsearch.xpack.esql.common.Failures;
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.expression.Literal;
import org.elasticsearch.xpack.esql.core.expression.TypeResolutions;
import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
import org.elasticsearch.xpack.esql.core.tree.Source;
Expand All @@ -24,6 +27,7 @@
import org.elasticsearch.xpack.esql.expression.function.FunctionType;
import org.elasticsearch.xpack.esql.expression.function.Param;
import org.elasticsearch.xpack.esql.expression.function.TimestampAware;
import org.elasticsearch.xpack.esql.expression.function.TimestampBoundsAware;
import org.elasticsearch.xpack.esql.expression.function.TwoOptionalArguments;
import org.elasticsearch.xpack.esql.session.Configuration;

Expand All @@ -44,13 +48,15 @@
* if it's a duration or period, it's the explicit bucket size.
* <p>
* When using a target number of buckets, start/end bounds are needed and can be provided explicitly
* as {@code from}/{@code to} parameters.
* as {@code from}/{@code to} parameters or derived automatically from the query DSL {@code @timestamp} range filter
* via {@link TimestampBoundsAware}.
*/
public class TBucket extends GroupingFunction.EvaluatableGroupingFunction
implements
OnlySurrogateExpression,
TimestampAware,
TwoOptionalArguments,
TimestampBoundsAware.OfExpression,
ConfigurationFunction {
public static final String NAME = "TBucket";

Expand All @@ -65,9 +71,14 @@ public class TBucket extends GroupingFunction.EvaluatableGroupingFunction
@FunctionInfo(
returnType = { "date", "date_nanos" },
description = """
Creates groups of values - buckets - out of a @timestamp attribute.
The size of the buckets can either be provided directly as a duration or period,
or chosen based on a recommended count and a range.""",
Creates groups of values - buckets - out of a `@timestamp` attribute.
The size of the buckets can be provided directly as a duration or period.
Alternatively, the bucket size can be chosen based on a recommended count
and a range {applies_to}`stack: ga 9.4`.

When using ES|QL in Kibana, the range can be derived automatically from the
[`@timestamp` filter](docs-content://explore-analyze/query-filter/languages/esql-kibana.md#_standard_time_filter)
that Kibana adds to the query.""",
examples = {
@Example(
description = """
Expand Down Expand Up @@ -121,20 +132,23 @@ public TBucket(
name = "buckets",
type = { "integer", "date_period", "time_duration" },
description = "Target number of buckets, or desired bucket size. "
+ "When a number is provided, the actual bucket size is derived from `from`/`to` {applies_to}`stack: ga 9.4`. "
+ "When a number is provided, the actual bucket size is derived from `from`/`to` "
+ "or the `@timestamp` range in the query filter {applies_to}`stack: ga 9.4`. "
+ "When a duration or period is provided, it is used as the explicit bucket size."
) Expression buckets,
@Param(
name = "from",
type = { "date", "keyword", "text" },
optional = true,
description = "Start of the range. Required with a numeric `buckets` {applies_to}`stack: ga 9.4`."
description = "Start of the range. Required with a numeric `buckets` when no `@timestamp` range is in the "
+ "query filter {applies_to}`stack: ga 9.4`."
) @Nullable Expression from,
@Param(
name = "to",
type = { "date", "keyword", "text" },
optional = true,
description = "End of the range. Required with a numeric `buckets` {applies_to}`stack: ga 9.4`."
description = "End of the range. Required with a numeric `buckets` when no `@timestamp` range is in the "
+ "query filter {applies_to}`stack: ga 9.4`."
) @Nullable Expression to,
Expression timestamp,
Configuration configuration
Expand Down Expand Up @@ -162,10 +176,34 @@ public ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) {
throw new UnsupportedOperationException("should be rewritten");
}

@Override
public boolean needsTimestampBounds() {
return buckets.resolved() && buckets.dataType().isWholeNumber() && from == null && to == null;
}

@Override
public Expression withTimestampBounds(Literal start, Literal end) {
return new TBucket(source(), buckets, from != null ? from : start, to != null ? to : end, timestamp, configuration);
}

@Override
public void postAnalysisVerification(Failures failures) {
if (buckets.resolved() && buckets.dataType().isWholeNumber() && (from == null || to == null)) {
failures.add(
Failure.fail(
this,
"numeric bucket count in [{}] requires [from] and [to] parameters or a `@timestamp` range in the query filter",
sourceText()
)
);
}
}

@Override
public Expression surrogate() {
if (buckets.resolved() && buckets.dataType().isWholeNumber()) {
assert from != null && to != null : "numeric bucket count requires [from] and [to]; resolveType should have caught this";
assert from != null && to != null
: "numeric bucket count requires [from] and [to]; postAnalysisVerification should have caught this";
return new Bucket(source(), timestamp, buckets, from, to, configuration);
}
return new Bucket(source(), timestamp, buckets, from, to, configuration);
Expand Down Expand Up @@ -196,9 +234,6 @@ protected TypeResolution resolveType() {
if (resolution.unresolved()) {
return resolution;
}
if (buckets.dataType().isWholeNumber() && (from == null || to == null)) {
return new TypeResolution("numeric bucket count in [" + sourceText() + "] requires [from] and [to] parameters");
}
if (DataType.isTemporalAmount(buckets.dataType()) && (from != null || to != null)) {
return new TypeResolution("[from] and [to] in [" + sourceText() + "] cannot be used with a duration or period bucket size");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import org.elasticsearch.xpack.esql.core.expression.UnresolvedAttribute;
import org.elasticsearch.xpack.esql.core.expression.predicate.regex.RLikePatternList;
import org.elasticsearch.xpack.esql.core.expression.predicate.regex.WildcardPatternList;
import org.elasticsearch.xpack.esql.core.querydsl.QueryDslTimestampBoundsExtractor;
import org.elasticsearch.xpack.esql.core.tree.Source;
import org.elasticsearch.xpack.esql.core.type.DataType;
import org.elasticsearch.xpack.esql.core.type.EsField;
Expand All @@ -54,6 +55,7 @@
import org.elasticsearch.xpack.esql.datasources.spi.StoragePath;
import org.elasticsearch.xpack.esql.enrich.ResolvedEnrichPolicy;
import org.elasticsearch.xpack.esql.expression.Order;
import org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry;
import org.elasticsearch.xpack.esql.expression.function.UnsupportedAttribute;
import org.elasticsearch.xpack.esql.expression.function.aggregate.Count;
import org.elasticsearch.xpack.esql.expression.function.aggregate.FilteredExpression;
Expand Down Expand Up @@ -189,6 +191,7 @@
import static org.elasticsearch.xpack.esql.core.type.DataType.KEYWORD;
import static org.elasticsearch.xpack.esql.core.type.DataType.LONG;
import static org.elasticsearch.xpack.esql.core.type.DataType.UNSUPPORTED;
import static org.elasticsearch.xpack.esql.plan.QuerySettings.UNMAPPED_FIELDS;
import static org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter.dateTimeToString;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
Expand Down Expand Up @@ -5130,6 +5133,47 @@ public void testInlineStatsStats() {
""", "Found 1 problem\n" + "line 2:15: Unknown column [stats]", "mapping-default.json");
}

public void testTBucketAutoBucketingWithTimestampBounds() {
Instant start = Instant.parse("2024-01-01T00:00:00Z");
Instant end = Instant.parse("2024-01-02T00:00:00Z");
var bounds = new QueryDslTimestampBoundsExtractor.TimestampBounds(start, end);
var indexResolution = loadMapping("mapping-sample_data.json", "sample_data");
var indexResolutions = Map.of(new IndexPattern(Source.EMPTY, "sample_data"), indexResolution);
var mergedResolutions = AnalyzerTestUtils.mergeIndexResolutions(indexResolutions, AnalyzerTestUtils.defaultSubqueryResolution());
var context = new AnalyzerContext(
EsqlTestUtils.TEST_CFG,
new EsqlFunctionRegistry(),
null,
mergedResolutions,
AnalyzerTestUtils.defaultLookupResolution(),
defaultEnrichResolution(),
defaultInferenceResolution(),
ExternalSourceResolution.EMPTY,
EsqlTestUtils.randomMinimumVersion(),
UNMAPPED_FIELDS.defaultValue(),
bounds
);
var analyzer = new Analyzer(context, TEST_VERIFIER);
LogicalPlan plan = analyze("FROM sample_data | STATS count = COUNT() BY bucket = TBUCKET(100)", analyzer);

Limit limit = as(plan, Limit.class);
Aggregate agg = as(limit.child(), Aggregate.class);

List<Expression> groupings = agg.groupings();
assertEquals(1, groupings.size());
Alias a = as(groupings.get(0), Alias.class);
TBucket tbucket = as(a.child(), TBucket.class);
assertFalse(tbucket.needsTimestampBounds());
assertNotNull(tbucket.from());
assertNotNull(tbucket.to());
FieldAttribute fa = as(tbucket.timestamp(), FieldAttribute.class);
assertEquals("@timestamp", fa.name());
Literal fromLiteral = as(tbucket.from(), Literal.class);
assertEquals(start.toEpochMilli(), fromLiteral.value());
Literal toLiteral = as(tbucket.to(), Literal.class);
assertEquals(end.toEpochMilli(), toLiteral.value());
}

public void testTBucketWithDatePeriodInBothAggregationAndGrouping() {
LogicalPlan plan = analyze("""
FROM sample_data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2976,11 +2976,14 @@ public void testInvalidTBucketCalls() {
);
assertThat(
error("from test | stats max(event_duration) by tbucket(3)", sampleDataAnalyzer),
equalTo("1:42: numeric bucket count in [tbucket(3)] requires [from] and [to] parameters")
equalTo(
"1:42: numeric bucket count in [tbucket(3)] requires [from] and [to] parameters"
+ " or a `@timestamp` range in the query filter"
)
);
assertThat(
error("from test | stats max(event_duration) by tbucket(3, \"2023-01-01T00:00:00Z\")", sampleDataAnalyzer),
equalTo("1:42: numeric bucket count in [tbucket(3, \"2023-01-01T00:00:00Z\")] requires [from] and [to] parameters")
equalTo("1:42: [from] and [to] in [tbucket(3, \"2023-01-01T00:00:00Z\")] must both be provided or both omitted")
);
assertThat(
error(
Expand Down Expand Up @@ -3009,6 +3012,13 @@ public void testInvalidTBucketCalls() {
containsString("1:50: Cannot convert string [" + interval + "] to [DATE_PERIOD or TIME_DURATION]")
);
}
assertThat(
error("from test | stats max(event_duration) by tbucket(100)", sampleDataAnalyzer),
equalTo(
"1:42: numeric bucket count in [tbucket(100)] requires [from] and [to] parameters"
+ " or a `@timestamp` range in the query filter"
)
);
}

public void testFuse() {
Expand Down
Loading