diff --git a/changelog/unreleased/pr-25103.toml b/changelog/unreleased/pr-25103.toml new file mode 100644 index 0000000000..d74e5190e2 --- /dev/null +++ b/changelog/unreleased/pr-25103.toml @@ -0,0 +1,4 @@ +type = "a" +message = "Add range aggregation to ScriptingApi." + +pulls = ["25103"] diff --git a/full-backend-tests/src/test/java/org/graylog/plugins/views/ScriptingApiResourceIT.java b/full-backend-tests/src/test/java/org/graylog/plugins/views/ScriptingApiResourceIT.java index cb05ab1d51..b5740e4c55 100644 --- a/full-backend-tests/src/test/java/org/graylog/plugins/views/ScriptingApiResourceIT.java +++ b/full-backend-tests/src/test/java/org/graylog/plugins/views/ScriptingApiResourceIT.java @@ -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( diff --git a/graylog-storage-elasticsearch7/src/main/java/org/graylog/storage/elasticsearch7/ViewsESBackendModule.java b/graylog-storage-elasticsearch7/src/main/java/org/graylog/storage/elasticsearch7/ViewsESBackendModule.java index 16ff9937e0..0c0bd18b55 100644 --- a/graylog-storage-elasticsearch7/src/main/java/org/graylog/storage/elasticsearch7/ViewsESBackendModule.java +++ b/graylog-storage-elasticsearch7/src/main/java/org/graylog/storage/elasticsearch7/ViewsESBackendModule.java @@ -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); diff --git a/graylog-storage-elasticsearch7/src/main/java/org/graylog/storage/elasticsearch7/views/searchtypes/pivot/buckets/ESRangeHandler.java b/graylog-storage-elasticsearch7/src/main/java/org/graylog/storage/elasticsearch7/views/searchtypes/pivot/buckets/ESRangeHandler.java new file mode 100644 index 0000000000..621373a6db --- /dev/null +++ b/graylog-storage-elasticsearch7/src/main/java/org/graylog/storage/elasticsearch7/views/searchtypes/pivot/buckets/ESRangeHandler.java @@ -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 + * . + */ +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 { + private static final String AGG_NAME = "agg"; + + @Nonnull + @Override + public CreatedAggregations 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 extractBuckets(Pivot pivot, BucketSpec bucketSpec, PivotBucket initialBucket) { + final ImmutableList 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 keys = ImmutableList.builder() + .addAll(previousKeys) + .add(bucketKey) + .build(); + + return Stream.of(PivotBucket.create(keys, bucket, false)); + }); + } +} diff --git a/graylog-storage-opensearch2/src/main/java/org/graylog/storage/opensearch2/ViewsOSBackendModule.java b/graylog-storage-opensearch2/src/main/java/org/graylog/storage/opensearch2/ViewsOSBackendModule.java index 6a2c63761b..e61d4cced0 100644 --- a/graylog-storage-opensearch2/src/main/java/org/graylog/storage/opensearch2/ViewsOSBackendModule.java +++ b/graylog-storage-opensearch2/src/main/java/org/graylog/storage/opensearch2/ViewsOSBackendModule.java @@ -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); diff --git a/graylog-storage-opensearch2/src/main/java/org/graylog/storage/opensearch2/views/searchtypes/pivot/buckets/OSRangeHandler.java b/graylog-storage-opensearch2/src/main/java/org/graylog/storage/opensearch2/views/searchtypes/pivot/buckets/OSRangeHandler.java new file mode 100644 index 0000000000..b81e3d030d --- /dev/null +++ b/graylog-storage-opensearch2/src/main/java/org/graylog/storage/opensearch2/views/searchtypes/pivot/buckets/OSRangeHandler.java @@ -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 + * . + */ +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 { + private static final String AGG_NAME = "agg"; + + @Nonnull + @Override + public CreatedAggregations 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 extractBuckets(Pivot pivot, BucketSpec bucketSpec, PivotBucket initialBucket) { + final ImmutableList 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 keys = ImmutableList.builder() + .addAll(previousKeys) + .add(bucketKey) + .build(); + + return Stream.of(PivotBucket.create(keys, bucket)); + }); + } +} diff --git a/graylog-storage-opensearch3/src/main/java/org/graylog/storage/opensearch3/ViewsOSBackendModule.java b/graylog-storage-opensearch3/src/main/java/org/graylog/storage/opensearch3/ViewsOSBackendModule.java index d3867de1d2..30bc4b0371 100644 --- a/graylog-storage-opensearch3/src/main/java/org/graylog/storage/opensearch3/ViewsOSBackendModule.java +++ b/graylog-storage-opensearch3/src/main/java/org/graylog/storage/opensearch3/ViewsOSBackendModule.java @@ -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); } diff --git a/graylog-storage-opensearch3/src/main/java/org/graylog/storage/opensearch3/views/searchtypes/pivot/buckets/OSRangeHandler.java b/graylog-storage-opensearch3/src/main/java/org/graylog/storage/opensearch3/views/searchtypes/pivot/buckets/OSRangeHandler.java new file mode 100644 index 0000000000..0a38d1359b --- /dev/null +++ b/graylog-storage-opensearch3/src/main/java/org/graylog/storage/opensearch3/views/searchtypes/pivot/buckets/OSRangeHandler.java @@ -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 + * . + */ +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 { + private static final String AGG_NAME = "agg"; + + @Nonnull + @Override + public CreatedAggregations 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 extractBuckets(Pivot pivot, BucketSpec bucketSpecs, PivotBucket initialBucket) { + final ImmutableList 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 keys = ImmutableList.builder() + .addAll(previousKeys) + .add(bucketKey) + .build(); + + return Stream.of(PivotBucket.create(keys, bucket)); + }); + } +} diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/ViewsBindings.java b/graylog2-server/src/main/java/org/graylog/plugins/views/ViewsBindings.java index 01eeb0adad..efd3a532ff 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/views/ViewsBindings.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/ViewsBindings.java @@ -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); diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/mapping/AggregationSpecToPivotMapper.java b/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/mapping/AggregationSpecToPivotMapper.java index 6f26cd3d05..b36c007dbc 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/mapping/AggregationSpecToPivotMapper.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/mapping/AggregationSpecToPivotMapper.java @@ -80,7 +80,7 @@ public class AggregationSpecToPivotMapper implements BiFunction fields) { @@ -88,7 +88,7 @@ public class AggregationSpecToPivotMapper implements BiFunction { /** - * 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) diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/request/Grouping.java b/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/request/Grouping.java index a705844e69..c8ad29c085 100644 --- a/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/request/Grouping.java +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/search/rest/scriptingapi/request/Grouping.java @@ -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 limit, @JsonProperty("timeunit") Optional timeunit, - @JsonProperty("scaling") Optional scaling) { + @JsonProperty("scaling") Optional scaling, + @JsonProperty("ranges") Optional> 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 limit, @JsonProperty("timeunit") Optional timeunit, - @JsonProperty("scaling") Optional scaling) { + @JsonProperty("scaling") Optional scaling, + @JsonProperty("ranges") Optional> 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 diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/search/searchtypes/pivot/buckets/NumberRange.java b/graylog2-server/src/main/java/org/graylog/plugins/views/search/searchtypes/pivot/buckets/NumberRange.java new file mode 100644 index 0000000000..1abe7b7f6d --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/search/searchtypes/pivot/buckets/NumberRange.java @@ -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 + * . + */ +package org.graylog.plugins.views.search.searchtypes.pivot.buckets; + +import javax.annotation.Nullable; + +public record NumberRange(@Nullable Double from, @Nullable Double to) {} diff --git a/graylog2-server/src/main/java/org/graylog/plugins/views/search/searchtypes/pivot/buckets/RangeBucket.java b/graylog2-server/src/main/java/org/graylog/plugins/views/search/searchtypes/pivot/buckets/RangeBucket.java new file mode 100644 index 0000000000..fd43661ec5 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/plugins/views/search/searchtypes/pivot/buckets/RangeBucket.java @@ -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 + * . + */ +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 fields, List ranges) implements BucketSpec { + public static final String NAME = "range"; + + @Override + public String type() { + return NAME; + } +} diff --git a/graylog2-server/src/test/java/org/graylog/plugins/views/search/rest/scriptingapi/mapping/GroupingToBucketSpecMapperTest.java b/graylog2-server/src/test/java/org/graylog/plugins/views/search/rest/scriptingapi/mapping/GroupingToBucketSpecMapperTest.java index fd68aaace4..e1de4e61cb 100644 --- a/graylog2-server/src/test/java/org/graylog/plugins/views/search/rest/scriptingapi/mapping/GroupingToBucketSpecMapperTest.java +++ b/graylog2-server/src/test/java/org/graylog/plugins/views/search/rest/scriptingapi/mapping/GroupingToBucketSpecMapperTest.java @@ -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 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 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 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 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))); + } + }