Feature: add numeric range aggregation support to Scripting API (#25103)

* feat: add numeric range aggregation support to Scripting API

Add support for numeric range aggregations in the Scripting API,
allowing users to group search results into custom numeric buckets
(e.g., response times 0-100ms, 100-500ms, 500ms+).

New classes:
- NumberRange: value class holding optional from/to Double bounds
- RangeBucket: BucketSpec implementation for numeric range buckets
- ESRangeHandler, OSRangeHandler (OS2/OS3): storage backend handlers

Modified:
- Grouping: new "ranges" field, mutually exclusive with limit/timeunit/scaling
- GroupingToBucketSpecMapper: produces RangeBucket when ranges are present
- AggregationSpecToPivotMapper: respects ranges in auto-interval logic
- All three ViewsBackendModule classes: register RangeBucket handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add integration tests for range aggregation in ScriptingApiResourceIT

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* adding changelog

* using more idiomatic code regarding the Optionals

* improving conditional

* records instead of Autovalue

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jan Heise
2026-02-25 14:40:47 +01:00
committed by GitHub
parent 091bf3c4b4
commit f114cf4ce5
15 changed files with 508 additions and 10 deletions

View File

@@ -0,0 +1,4 @@
type = "a"
message = "Add range aggregation to ScriptingApi."
pulls = ["25103"]

View File

@@ -1117,6 +1117,111 @@ public class ScriptingApiResourceIT {
.isTrue();
}
@FullBackendTest
void testRangeAggregation() {
final ValidatableResponse validatableResponse = given()
.spec(api.requestSpecification())
.when()
.body("""
{
"group_by": [
{
"field": "level",
"ranges": [
{"to": 2.0},
{"from": 2.0, "to": 3.0},
{"from": 3.0}
]
}
],
"metrics": [
{
"function": "count"
}
]
}
""")
.post("/search/aggregate")
.then()
.log().ifStatusCodeMatches(not(200))
.statusCode(200);
validatableResponse.assertThat().body("datarows", Matchers.hasSize(3));
validateRow(validatableResponse, "*-2.0", 1);
validateRow(validatableResponse, "2.0-3.0", 1);
validateRow(validatableResponse, "3.0-*", 1);
}
@FullBackendTest
void testRangeAggregationWithMultipleMetrics() {
final ValidatableResponse validatableResponse = given()
.spec(api.requestSpecification())
.when()
.body("""
{
"group_by": [
{
"field": "level",
"ranges": [
{"to": 2.5},
{"from": 2.5}
]
}
],
"metrics": [
{
"function": "count"
},
{
"function": "max",
"field": "level"
}
]
}
""")
.post("/search/aggregate")
.then()
.log().ifStatusCodeMatches(not(200))
.statusCode(200);
validatableResponse.assertThat().body("datarows", Matchers.hasSize(2));
validateRow(validatableResponse, "*-2.5", 2, 2.0f);
validateRow(validatableResponse, "2.5-*", 1, 3.0f);
}
@FullBackendTest
void testRangeAggregationSchema() {
final ValidatableResponse validatableResponse = given()
.spec(api.requestSpecification())
.when()
.body("""
{
"group_by": [
{
"field": "level",
"ranges": [
{"to": 2.0},
{"from": 2.0}
]
}
],
"metrics": [
{
"function": "count",
"field": "level"
}
]
}
""")
.post("/search/aggregate")
.then()
.log().ifStatusCodeMatches(not(200))
.statusCode(200);
validateSchema(validatableResponse, "grouping: level", "string", "level");
validateSchema(validatableResponse, "metric: count(level)", "numeric", "level");
}
private void validateSchema(ValidatableResponse response, String name, String type, String field) {
response.assertThat().body("schema", Matchers.hasItem(
Matchers.allOf(

View File

@@ -31,6 +31,7 @@ import org.graylog.plugins.views.search.searchtypes.pivot.BucketSpec;
import org.graylog.plugins.views.search.searchtypes.pivot.Pivot;
import org.graylog.plugins.views.search.searchtypes.pivot.SeriesSpec;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.DateRangeBucket;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.RangeBucket;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Time;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Values;
import org.graylog.plugins.views.search.searchtypes.pivot.series.Average;
@@ -57,6 +58,7 @@ import org.graylog.storage.elasticsearch7.views.searchtypes.pivot.ESPivot;
import org.graylog.storage.elasticsearch7.views.searchtypes.pivot.ESPivotBucketSpecHandler;
import org.graylog.storage.elasticsearch7.views.searchtypes.pivot.ESPivotSeriesSpecHandler;
import org.graylog.storage.elasticsearch7.views.searchtypes.pivot.buckets.ESDateRangeHandler;
import org.graylog.storage.elasticsearch7.views.searchtypes.pivot.buckets.ESRangeHandler;
import org.graylog.storage.elasticsearch7.views.searchtypes.pivot.buckets.ESTimeHandler;
import org.graylog.storage.elasticsearch7.views.searchtypes.pivot.buckets.ESValuesHandler;
import org.graylog.storage.elasticsearch7.views.searchtypes.pivot.series.ESAverageHandler;
@@ -106,6 +108,7 @@ public class ViewsESBackendModule extends ViewsModule {
registerPivotBucketHandler(Values.NAME, ESValuesHandler.class);
registerPivotBucketHandler(Time.NAME, ESTimeHandler.class);
registerPivotBucketHandler(DateRangeBucket.NAME, ESDateRangeHandler.class);
registerPivotBucketHandler(RangeBucket.NAME, ESRangeHandler.class);
bindExportBackend().to(ElasticsearchExportBackend.class);
bindRequestStrategy().to(org.graylog.storage.elasticsearch7.views.export.SearchAfter.class);

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
package org.graylog.storage.elasticsearch7.views.searchtypes.pivot.buckets;
import com.google.common.collect.ImmutableList;
import org.graylog.plugins.views.search.Query;
import org.graylog.plugins.views.search.searchtypes.pivot.BucketSpec;
import org.graylog.plugins.views.search.searchtypes.pivot.Pivot;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.RangeBucket;
import org.graylog.shaded.elasticsearch7.org.elasticsearch.search.aggregations.AggregationBuilder;
import org.graylog.shaded.elasticsearch7.org.elasticsearch.search.aggregations.AggregationBuilders;
import org.graylog.shaded.elasticsearch7.org.elasticsearch.search.aggregations.bucket.MultiBucketsAggregation;
import org.graylog.shaded.elasticsearch7.org.elasticsearch.search.aggregations.bucket.range.ParsedRange;
import org.graylog.shaded.elasticsearch7.org.elasticsearch.search.aggregations.bucket.range.RangeAggregationBuilder;
import org.graylog.storage.elasticsearch7.views.ESGeneratedQueryContext;
import org.graylog.storage.elasticsearch7.views.searchtypes.pivot.ESPivotBucketSpecHandler;
import org.graylog.storage.elasticsearch7.views.searchtypes.pivot.PivotBucket;
import javax.annotation.Nonnull;
import java.util.stream.Stream;
public class ESRangeHandler extends ESPivotBucketSpecHandler<RangeBucket> {
private static final String AGG_NAME = "agg";
@Nonnull
@Override
public CreatedAggregations<AggregationBuilder> doCreateAggregation(Direction direction, String name, Pivot pivot, RangeBucket rangeBucket, ESGeneratedQueryContext queryContext, Query query) {
AggregationBuilder root = null;
AggregationBuilder leaf = null;
for (final String field : rangeBucket.fields()) {
final RangeAggregationBuilder builder = AggregationBuilders.range(name).field(field);
rangeBucket.ranges().forEach(r -> {
final Double from = r.from();
final Double to = r.to();
if (from != null && to != null) {
builder.addRange(from, to);
} else if (to != null) {
builder.addUnboundedTo(to);
} else if (from != null) {
builder.addUnboundedFrom(from);
}
});
builder.keyed(false);
queryContext.recordNameForPivotSpec(pivot, rangeBucket, name);
if (root == null) {
root = builder;
} else {
leaf.subAggregation(builder);
}
leaf = builder;
}
return CreatedAggregations.create(root, leaf);
}
@Override
public Stream<PivotBucket> extractBuckets(Pivot pivot, BucketSpec bucketSpec, PivotBucket initialBucket) {
final ImmutableList<String> previousKeys = initialBucket.keys();
final MultiBucketsAggregation.Bucket previousBucket = initialBucket.bucket();
final ParsedRange aggregation = previousBucket.getAggregations().get(AGG_NAME);
return aggregation.getBuckets().stream()
.flatMap(bucket -> {
final String bucketKey = bucket.getKeyAsString();
final ImmutableList<String> keys = ImmutableList.<String>builder()
.addAll(previousKeys)
.add(bucketKey)
.build();
return Stream.of(PivotBucket.create(keys, bucket, false));
});
}
}

View File

@@ -32,6 +32,7 @@ import org.graylog.plugins.views.search.searchtypes.pivot.BucketSpec;
import org.graylog.plugins.views.search.searchtypes.pivot.Pivot;
import org.graylog.plugins.views.search.searchtypes.pivot.SeriesSpec;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.DateRangeBucket;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.RangeBucket;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Time;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Values;
import org.graylog.plugins.views.search.searchtypes.pivot.series.Average;
@@ -60,6 +61,7 @@ import org.graylog.storage.opensearch2.views.searchtypes.pivot.OSPivot;
import org.graylog.storage.opensearch2.views.searchtypes.pivot.OSPivotBucketSpecHandler;
import org.graylog.storage.opensearch2.views.searchtypes.pivot.OSPivotSeriesSpecHandler;
import org.graylog.storage.opensearch2.views.searchtypes.pivot.buckets.OSDateRangeHandler;
import org.graylog.storage.opensearch2.views.searchtypes.pivot.buckets.OSRangeHandler;
import org.graylog.storage.opensearch2.views.searchtypes.pivot.buckets.OSTimeHandler;
import org.graylog.storage.opensearch2.views.searchtypes.pivot.buckets.OSValuesHandler;
import org.graylog.storage.opensearch2.views.searchtypes.pivot.series.OSAverageHandler;
@@ -110,6 +112,7 @@ public class ViewsOSBackendModule extends ViewsModule {
registerPivotBucketHandler(Values.NAME, OSValuesHandler.class);
registerPivotBucketHandler(Time.NAME, OSTimeHandler.class);
registerPivotBucketHandler(DateRangeBucket.NAME, OSDateRangeHandler.class);
registerPivotBucketHandler(RangeBucket.NAME, OSRangeHandler.class);
bindExportBackend().to(OpenSearchExportBackend.class);
bindRequestStrategy().to(SearchAfter.class);

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
package org.graylog.storage.opensearch2.views.searchtypes.pivot.buckets;
import com.google.common.collect.ImmutableList;
import org.graylog.plugins.views.search.Query;
import org.graylog.plugins.views.search.searchtypes.pivot.BucketSpec;
import org.graylog.plugins.views.search.searchtypes.pivot.Pivot;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.RangeBucket;
import org.graylog.shaded.opensearch2.org.opensearch.search.aggregations.AggregationBuilder;
import org.graylog.shaded.opensearch2.org.opensearch.search.aggregations.AggregationBuilders;
import org.graylog.shaded.opensearch2.org.opensearch.search.aggregations.bucket.MultiBucketsAggregation;
import org.graylog.shaded.opensearch2.org.opensearch.search.aggregations.bucket.range.ParsedRange;
import org.graylog.shaded.opensearch2.org.opensearch.search.aggregations.bucket.range.RangeAggregationBuilder;
import org.graylog.storage.opensearch2.views.OSGeneratedQueryContext;
import org.graylog.storage.opensearch2.views.searchtypes.pivot.OSPivotBucketSpecHandler;
import org.graylog.storage.opensearch2.views.searchtypes.pivot.PivotBucket;
import javax.annotation.Nonnull;
import java.util.stream.Stream;
public class OSRangeHandler extends OSPivotBucketSpecHandler<RangeBucket> {
private static final String AGG_NAME = "agg";
@Nonnull
@Override
public CreatedAggregations<AggregationBuilder> doCreateAggregation(Direction direction, String name, Pivot pivot, RangeBucket rangeBucket, OSGeneratedQueryContext queryContext, Query query) {
AggregationBuilder root = null;
AggregationBuilder leaf = null;
for (final String field : rangeBucket.fields()) {
final RangeAggregationBuilder builder = AggregationBuilders.range(name).field(field);
rangeBucket.ranges().forEach(r -> {
final Double from = r.from();
final Double to = r.to();
if (from != null && to != null) {
builder.addRange(from, to);
} else if (to != null) {
builder.addUnboundedTo(to);
} else if (from != null) {
builder.addUnboundedFrom(from);
}
});
builder.keyed(false);
queryContext.recordNameForPivotSpec(pivot, rangeBucket, name);
if (root == null) {
root = builder;
} else {
leaf.subAggregation(builder);
}
leaf = builder;
}
return CreatedAggregations.create(root, leaf);
}
@Override
public Stream<PivotBucket> extractBuckets(Pivot pivot, BucketSpec bucketSpec, PivotBucket initialBucket) {
final ImmutableList<String> previousKeys = initialBucket.keys();
final MultiBucketsAggregation.Bucket previousBucket = initialBucket.bucket();
final ParsedRange aggregation = previousBucket.getAggregations().get(AGG_NAME);
return aggregation.getBuckets().stream()
.flatMap(bucket -> {
final String bucketKey = bucket.getKeyAsString();
final ImmutableList<String> keys = ImmutableList.<String>builder()
.addAll(previousKeys)
.add(bucketKey)
.build();
return Stream.of(PivotBucket.create(keys, bucket));
});
}
}

View File

@@ -32,6 +32,7 @@ import org.graylog.plugins.views.search.searchtypes.pivot.BucketSpec;
import org.graylog.plugins.views.search.searchtypes.pivot.Pivot;
import org.graylog.plugins.views.search.searchtypes.pivot.SeriesSpec;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.DateRangeBucket;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.RangeBucket;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Time;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Values;
import org.graylog.plugins.views.search.searchtypes.pivot.series.Average;
@@ -57,6 +58,7 @@ import org.graylog.storage.opensearch3.views.searchtypes.pivot.OSPivot;
import org.graylog.storage.opensearch3.views.searchtypes.pivot.OSPivotBucketSpecHandler;
import org.graylog.storage.opensearch3.views.searchtypes.pivot.OSPivotSeriesSpecHandler;
import org.graylog.storage.opensearch3.views.searchtypes.pivot.buckets.OSDateRangeHandler;
import org.graylog.storage.opensearch3.views.searchtypes.pivot.buckets.OSRangeHandler;
import org.graylog.storage.opensearch3.views.searchtypes.pivot.buckets.OSTimeHandler;
import org.graylog.storage.opensearch3.views.searchtypes.pivot.buckets.OSValuesHandler;
import org.graylog.storage.opensearch3.views.searchtypes.pivot.series.OSAverageHandler;
@@ -107,6 +109,7 @@ public class ViewsOSBackendModule extends ViewsModule {
registerPivotBucketHandler(Values.NAME, OSValuesHandler.class);
registerPivotBucketHandler(Time.NAME, OSTimeHandler.class);
registerPivotBucketHandler(DateRangeBucket.NAME, OSDateRangeHandler.class);
registerPivotBucketHandler(RangeBucket.NAME, OSRangeHandler.class);
bindExportBackend().to(OpenSearchExportBackend.class);
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
package org.graylog.storage.opensearch3.views.searchtypes.pivot.buckets;
import com.google.common.collect.ImmutableList;
import org.graylog.plugins.views.search.Query;
import org.graylog.plugins.views.search.searchtypes.pivot.BucketSpec;
import org.graylog.plugins.views.search.searchtypes.pivot.Pivot;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.RangeBucket;
import org.graylog.storage.opensearch3.views.OSGeneratedQueryContext;
import org.graylog.storage.opensearch3.views.searchtypes.pivot.MutableNamedAggregationBuilder;
import org.graylog.storage.opensearch3.views.searchtypes.pivot.OSPivotBucketSpecHandler;
import org.graylog.storage.opensearch3.views.searchtypes.pivot.PivotBucket;
import org.opensearch.client.json.JsonData;
import org.opensearch.client.opensearch._types.aggregations.Aggregation;
import org.opensearch.client.opensearch._types.aggregations.AggregationRange;
import org.opensearch.client.opensearch._types.aggregations.MultiBucketBase;
import org.opensearch.client.opensearch._types.aggregations.RangeAggregate;
import org.opensearch.client.opensearch._types.aggregations.RangeAggregation;
import javax.annotation.Nonnull;
import java.util.Optional;
import java.util.stream.Stream;
public class OSRangeHandler extends OSPivotBucketSpecHandler<RangeBucket> {
private static final String AGG_NAME = "agg";
@Nonnull
@Override
public CreatedAggregations<MutableNamedAggregationBuilder> doCreateAggregation(Direction direction, String name, Pivot pivot, RangeBucket rangeBucket, OSGeneratedQueryContext queryContext, Query query) {
MutableNamedAggregationBuilder root = null;
MutableNamedAggregationBuilder leaf = null;
for (final String field : rangeBucket.fields()) {
final RangeAggregation.Builder rangeBuilder = new RangeAggregation.Builder()
.field(field);
rangeBucket.ranges().forEach(r -> {
final AggregationRange.Builder range = new AggregationRange.Builder();
Optional.ofNullable(r.from()).ifPresent(from -> range.from(JsonData.of(from)));
Optional.ofNullable(r.to()).ifPresent(to -> range.to(JsonData.of(to)));
rangeBuilder.ranges(range.build());
});
rangeBuilder.keyed(false);
queryContext.recordNameForPivotSpec(pivot, rangeBucket, name);
final MutableNamedAggregationBuilder builder = new MutableNamedAggregationBuilder(
name,
Aggregation.builder().range(rangeBuilder.build())
);
if (root == null) {
root = builder;
} else {
leaf.subAggregation(builder);
}
leaf = builder;
}
return CreatedAggregations.create(root, leaf);
}
@Override
public Stream<PivotBucket> extractBuckets(Pivot pivot, BucketSpec bucketSpecs, PivotBucket initialBucket) {
final ImmutableList<String> previousKeys = initialBucket.keys();
final MultiBucketBase previousBucket = initialBucket.bucket();
final RangeAggregate aggregation = previousBucket.aggregations().get(AGG_NAME).range();
return aggregation.buckets().array().stream()
.flatMap(bucket -> {
final String bucketKey = bucket.key();
final ImmutableList<String> keys = ImmutableList.<String>builder()
.addAll(previousKeys)
.add(bucketKey)
.build();
return Stream.of(PivotBucket.create(keys, bucket));
});
}
}

View File

@@ -94,6 +94,7 @@ import org.graylog.plugins.views.search.searchtypes.pivot.PivotResult;
import org.graylog.plugins.views.search.searchtypes.pivot.PivotSort;
import org.graylog.plugins.views.search.searchtypes.pivot.SeriesSort;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.AutoInterval;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.RangeBucket;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Time;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.TimeUnitInterval;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Values;
@@ -206,6 +207,7 @@ public class ViewsBindings extends ViewsModule {
// pivot specs
registerJacksonSubtype(Values.class);
registerJacksonSubtype(Time.class);
registerJacksonSubtype(RangeBucket.class);
registerPivotAggregationFunction(Average.NAME, "Average", Average.class);
registerPivotAggregationFunction(Cardinality.NAME, "Cardinality", Cardinality.class);
registerPivotAggregationFunction(Count.NAME, "Count", Count.class);

View File

@@ -80,7 +80,7 @@ public class AggregationSpecToPivotMapper implements BiFunction<AggregationReque
}
private Grouping addAutoInterval(Grouping grouping) {
return new Grouping(grouping.requestedField().name(), Optional.empty(), Optional.empty(), Optional.of(DEFAULT_SCALINGFACTOR));
return new Grouping(grouping.requestedField().name(), Optional.empty(), Optional.empty(), Optional.of(DEFAULT_SCALINGFACTOR), Optional.empty());
}
private boolean isDateType(final String fieldName, final Map<String, FieldTypes.Type> fields) {
@@ -88,7 +88,7 @@ public class AggregationSpecToPivotMapper implements BiFunction<AggregationReque
}
private boolean noBucketingParameterSetOn(Grouping grouping) {
return grouping.timeunit().isEmpty() && grouping.scaling().isEmpty();
return grouping.timeunit().isEmpty() && grouping.scaling().isEmpty() && grouping.ranges().isEmpty();
}
@Override

View File

@@ -19,19 +19,23 @@ package org.graylog.plugins.views.search.rest.scriptingapi.mapping;
import org.graylog.plugins.views.search.rest.scriptingapi.request.Grouping;
import org.graylog.plugins.views.search.searchtypes.pivot.BucketSpec;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.AutoInterval;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.RangeBucket;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Time;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.TimeUnitInterval;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Values;
import java.util.List;
import java.util.function.Function;
public class GroupingToBucketSpecMapper implements Function<Grouping, BucketSpec> {
/**
* Only 'scaling' or 'timeunit' or none of both should be present, this is validated in Grouping.java on deserializing the JSON
* Only one of 'scaling', 'timeunit', or 'ranges' should be present, this is validated in Grouping.java on deserializing the JSON
*/
@Override
public BucketSpec apply(final Grouping grouping) {
if(grouping.scaling().isPresent()) {
if(grouping.ranges().isPresent()) {
return new RangeBucket(List.of(grouping.requestedField().name()), grouping.ranges().get());
} else if(grouping.scaling().isPresent()) {
return Time.builder()
.field(grouping.requestedField().name())
.type(Time.NAME)

View File

@@ -18,11 +18,13 @@ package org.graylog.plugins.views.search.rest.scriptingapi.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.ValidationException;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.NumberRange;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Values;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
@@ -30,34 +32,38 @@ import java.util.concurrent.atomic.AtomicInteger;
public record Grouping(@JsonProperty("field") @Valid @NotBlank String fieldName,
@JsonProperty("limit") Optional<Integer> limit,
@JsonProperty("timeunit") Optional<String> timeunit,
@JsonProperty("scaling") Optional<Double> scaling) {
@JsonProperty("scaling") Optional<Double> scaling,
@JsonProperty("ranges") Optional<List<NumberRange>> ranges) {
public Grouping(String fieldName) {
this(fieldName, Optional.of(Values.DEFAULT_LIMIT), Optional.empty(), Optional.empty());
this(fieldName, Optional.of(Values.DEFAULT_LIMIT), Optional.empty(), Optional.empty(), Optional.empty());
}
public Grouping(@JsonProperty("field") @Valid @NotBlank String fieldName,
@JsonProperty("limit") Optional<Integer> limit,
@JsonProperty("timeunit") Optional<String> timeunit,
@JsonProperty("scaling") Optional<Double> scaling) {
@JsonProperty("scaling") Optional<Double> scaling,
@JsonProperty("ranges") Optional<List<NumberRange>> ranges) {
this.fieldName = fieldName;
this.limit = limit.map(lim -> lim <= 0 ? Values.DEFAULT_LIMIT : lim);
this.timeunit = timeunit;
this.scaling = scaling;
this.ranges = ranges;
// only one of the three following parameters are allowed to be present
// only one of the following parameters are allowed to be present
final AtomicInteger attrCounter = new AtomicInteger();
limit.ifPresent(l -> attrCounter.getAndIncrement());
timeunit.ifPresent(t -> attrCounter.getAndIncrement());
scaling.ifPresent(s -> attrCounter.getAndIncrement());
ranges.ifPresent(r -> attrCounter.getAndIncrement());
if(attrCounter.get() > 1) {
throw new ValidationException("Only one attribute out of 'limit', 'timeunit' or 'scaling' can be specified");
throw new ValidationException("Only one attribute out of 'limit', 'timeunit', 'scaling' or 'ranges' can be specified");
}
}
public Grouping(@JsonProperty("field") @Valid @NotBlank String fieldName,
@JsonProperty("limit") int limit) {
this(fieldName, Optional.of(limit), Optional.empty(), Optional.empty());
this(fieldName, Optional.of(limit), Optional.empty(), Optional.empty(), Optional.empty());
}
@Deprecated

View File

@@ -0,0 +1,21 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
package org.graylog.plugins.views.search.searchtypes.pivot.buckets;
import javax.annotation.Nullable;
public record NumberRange(@Nullable Double from, @Nullable Double to) {}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
package org.graylog.plugins.views.search.searchtypes.pivot.buckets;
import com.fasterxml.jackson.annotation.JsonTypeName;
import org.graylog.plugins.views.search.searchtypes.pivot.BucketSpec;
import java.util.List;
@JsonTypeName(RangeBucket.NAME)
public record RangeBucket(List<String> fields, List<NumberRange> ranges) implements BucketSpec {
public static final String NAME = "range";
@Override
public String type() {
return NAME;
}
}

View File

@@ -16,13 +16,18 @@
*/
package org.graylog.plugins.views.search.rest.scriptingapi.mapping;
import jakarta.validation.ValidationException;
import org.graylog.plugins.views.search.rest.scriptingapi.request.Grouping;
import org.graylog.plugins.views.search.searchtypes.pivot.BucketSpec;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.NumberRange;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.RangeBucket;
import org.graylog.plugins.views.search.searchtypes.pivot.buckets.Values;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -68,4 +73,43 @@ class GroupingToBucketSpecMapperTest {
.satisfies(b -> assertEquals(Values.DEFAULT_LIMIT, ((Values) b).limit()));
}
@Test
void buildsRangeBucketSpecCorrectly() {
final List<NumberRange> ranges = List.of(
new NumberRange(null, 100.0),
new NumberRange(100.0, 500.0),
new NumberRange(500.0, null)
);
final Grouping grouping = new Grouping("took_ms", Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(ranges));
final BucketSpec bucketSpec = toTest.apply(grouping);
assertThat(bucketSpec)
.isNotNull()
.isInstanceOf(RangeBucket.class)
.satisfies(b -> assertEquals(Collections.singletonList("took_ms"), b.fields()))
.satisfies(b -> assertEquals(RangeBucket.NAME, b.type()))
.satisfies(b -> assertEquals(3, ((RangeBucket) b).ranges().size()));
}
@Test
void throwsValidationExceptionWhenRangesAndLimitBothProvided() {
final List<NumberRange> ranges = List.of(new NumberRange(null, 100.0));
assertThrows(ValidationException.class,
() -> new Grouping("field", Optional.of(10), Optional.empty(), Optional.empty(), Optional.of(ranges)));
}
@Test
void throwsValidationExceptionWhenRangesAndTimeunitBothProvided() {
final List<NumberRange> ranges = List.of(new NumberRange(null, 100.0));
assertThrows(ValidationException.class,
() -> new Grouping("field", Optional.empty(), Optional.of("1h"), Optional.empty(), Optional.of(ranges)));
}
@Test
void throwsValidationExceptionWhenRangesAndScalingBothProvided() {
final List<NumberRange> ranges = List.of(new NumberRange(null, 100.0));
assertThrows(ValidationException.class,
() -> new Grouping("field", Optional.empty(), Optional.empty(), Optional.of(1.0), Optional.of(ranges)));
}
}