This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new b5e1bddbc UNOMI-1018: Return date and range buckets from aggregate
queries (#926)
b5e1bddbc is described below
commit b5e1bddbc6dc3be9073db9c2e05fa7bf43436cd9
Author: Serge Huber <[email protected]>
AuthorDate: Mon Oct 26 10:16:32 2026 +0100
UNOMI-1018: Return date and range buckets from aggregate queries (#926)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../java/org/apache/unomi/api/query/DateRange.java | 6 +-
.../java/org/apache/unomi/api/query/IpRange.java | 9 +-
.../org/apache/unomi/api/query/NumericRange.java | 6 +-
.../apache/unomi/itests/PersistenceServiceIT.java | 138 ++++++++++++++++-
.../ElasticSearchPersistenceServiceImpl.java | 103 ++++++++-----
.../ElasticSearchAggregateBucketsTest.java | 159 +++++++++++++++++++
.../OpenSearchPersistenceServiceImpl.java | 115 ++++++++++----
.../opensearch/OpenSearchAggregateBucketsTest.java | 152 ++++++++++++++++++
.../spi/aggregate/AggregateBuckets.java | 171 +++++++++++++++++++++
.../spi/aggregate/AggregateBucketsTest.java | 154 +++++++++++++++++++
10 files changed, 930 insertions(+), 83 deletions(-)
diff --git a/api/src/main/java/org/apache/unomi/api/query/DateRange.java
b/api/src/main/java/org/apache/unomi/api/query/DateRange.java
index cfdfb01e5..e8c5066b0 100644
--- a/api/src/main/java/org/apache/unomi/api/query/DateRange.java
+++ b/api/src/main/java/org/apache/unomi/api/query/DateRange.java
@@ -20,7 +20,7 @@ package org.apache.unomi.api.query;
import java.io.Serializable;
/**
- * Named inclusive date bucket for queries and aggregations.
+ * Named date bucket for queries and aggregations.
* Combines a display {@code key} with {@code from} and {@code to} bounds so
* segment conditions and aggregate queries can group events or profiles inside
* a calendar window.
@@ -73,7 +73,7 @@ public class DateRange implements Serializable {
}
/**
- * Inclusive upper bound of the interval.
+ * Upper bound of the interval, exclusive in range aggregations.
*
* @return end date or value
*/
@@ -82,7 +82,7 @@ public class DateRange implements Serializable {
}
/**
- * Sets the inclusive upper bound.
+ * Sets the upper bound, exclusive in range aggregations.
*
* @param to end date or value
*/
diff --git a/api/src/main/java/org/apache/unomi/api/query/IpRange.java
b/api/src/main/java/org/apache/unomi/api/query/IpRange.java
index a2deb51ed..2e9121b58 100644
--- a/api/src/main/java/org/apache/unomi/api/query/IpRange.java
+++ b/api/src/main/java/org/apache/unomi/api/query/IpRange.java
@@ -20,9 +20,8 @@ package org.apache.unomi.api.query;
import java.io.Serializable;
/**
- * Named inclusive IP range used in geo or network segment conditions.
- * The {@code from} and {@code to} strings define lower and upper bounds
checked
- * when evaluating whether a visitor's IP address belongs to the range.
+ * Named IP range used by property types and IP range aggregations.
+ * In range aggregations, {@code from} is inclusive and {@code to} is
exclusive.
*/
public class IpRange implements Serializable {
private String key;
@@ -72,7 +71,7 @@ public class IpRange implements Serializable {
}
/**
- * Inclusive upper IP bound.
+ * Upper IP bound, exclusive in range aggregations.
*
* @return end IP address
*/
@@ -81,7 +80,7 @@ public class IpRange implements Serializable {
}
/**
- * Sets the inclusive upper IP bound.
+ * Sets the upper IP bound, exclusive in range aggregations.
*
* @param to end IP address
*/
diff --git a/api/src/main/java/org/apache/unomi/api/query/NumericRange.java
b/api/src/main/java/org/apache/unomi/api/query/NumericRange.java
index 9a493b79b..3fae05f5c 100644
--- a/api/src/main/java/org/apache/unomi/api/query/NumericRange.java
+++ b/api/src/main/java/org/apache/unomi/api/query/NumericRange.java
@@ -20,7 +20,7 @@ package org.apache.unomi.api.query;
import java.io.Serializable;
/**
- * Named inclusive numeric bucket for query filters and aggregations.
+ * Named numeric bucket for query filters and aggregations.
* Stores a {@code key} plus {@code from}/{@code to} bounds so conditions can
* test profile or event properties against numeric intervals.
*/
@@ -72,7 +72,7 @@ public class NumericRange implements Serializable {
}
/**
- * Inclusive upper bound.
+ * Upper bound, exclusive in range aggregations.
*
* @return upper bound, or {@code null} if unset
*/
@@ -81,7 +81,7 @@ public class NumericRange implements Serializable {
}
/**
- * Sets the inclusive upper bound.
+ * Sets the upper bound, exclusive in range aggregations.
*
* @param to upper bound
*/
diff --git
a/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
b/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
index eb039b203..75ac30fe7 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
@@ -20,7 +20,12 @@ import org.apache.unomi.api.Event;
import org.apache.unomi.api.PartialList;
import org.apache.unomi.api.Profile;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.query.DateRange;
+import org.apache.unomi.api.query.NumericRange;
import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.persistence.spi.aggregate.DateAggregate;
+import org.apache.unomi.persistence.spi.aggregate.DateRangeAggregate;
+import org.apache.unomi.persistence.spi.aggregate.NumericRangeAggregate;
import org.apache.unomi.persistence.spi.aggregate.TermsAggregate;
import org.junit.After;
import org.junit.Assert;
@@ -30,7 +35,9 @@ import org.ops4j.pax.exam.junit.PaxExam;
import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
import org.ops4j.pax.exam.spi.reactors.PerSuite;
+import java.time.Instant;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@@ -312,7 +319,7 @@ public class PersistenceServiceIT extends BaseIT {
Assert.assertEquals(7.0, metrics.get("_sum"), 0.001);
Assert.assertEquals(1.0, metrics.get("_count"), 0.001);
- // Non-optimized path without a condition: this is where the
global aggregation used to count every tenant.
+ // Without a condition the non-optimized aggregate must still only
count the current tenant.
Map<String, Long> termsNonOptimizedNoCondition = keepTrying(
"Non-optimized terms aggregate without a condition should
only count the current tenant",
() -> persistenceService.aggregateQuery(null, byFirstName,
Profile.ITEM_TYPE),
@@ -332,6 +339,16 @@ public class PersistenceServiceIT extends BaseIT {
DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
Assert.assertEquals(7.0, metricsNoCondition.get("_sum"), 0.001);
Assert.assertEquals(1.0, metricsNoCondition.get("_count"), 0.001);
+
+ Map<String, Long> otherTenantTerms = keepTrying(
+ "The other tenant's _all should only count its own
profiles",
+ () ->
executionContextManager.executeAsTenant(otherTenant.getItemId(),
+ () -> persistenceService.aggregateQuery(null,
byFirstName, Profile.ITEM_TYPE)),
+ r -> r != null && Long.valueOf(2L).equals(r.get("_all")),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ Assert.assertEquals(Long.valueOf(2L),
otherTenantTerms.get("_all"));
+ Assert.assertEquals(Long.valueOf(1L),
otherTenantTerms.get("Alice"));
+ Assert.assertEquals(Long.valueOf(1L), otherTenantTerms.get("Bob"));
} finally {
try {
executionContextManager.executeAsTenant(otherTenant.getItemId(), () -> {
@@ -344,10 +361,129 @@ public class PersistenceServiceIT extends BaseIT {
}
}
+ /**
+ * Date histogram, numeric terms, numeric range and date range aggregates
must return their buckets on both engines.
+ */
+ @Test
+ public void testDateAndRangeAggregatesReturnBuckets() throws
InterruptedException {
+ String marker = "agg-buckets-" + UUID.randomUUID();
+ saveProfileWithVisitAndAge(marker, "2020-01-01T10:00:00Z", 10);
+ saveProfileWithVisitAndAge(marker, "2020-01-01T11:00:00Z", 30);
+ saveProfileWithVisitAndAge(marker, "2020-01-01T12:00:00Z", 40);
+ saveProfileWithVisitAndAge(marker, "2020-01-03T10:00:00Z", 70);
+ refreshPersistence(Profile.class);
+ Condition onlyTheseProfiles = firstNameEquals(marker);
+
+ // "1d" is the legacy interval format; both engines must accept it
+ Map<String, Long> byDay = keepTrying("Date histogram should return one
bucket per day",
+ () ->
persistenceService.aggregateWithOptimizedQuery(onlyTheseProfiles,
+ new DateAggregate("properties.lastVisit", "1d",
"yyyy-MM-dd"), Profile.ITEM_TYPE),
+ r -> r != null && Long.valueOf(3L).equals(r.get("2020-01-01")),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ Assert.assertEquals(Long.valueOf(3L), byDay.get("2020-01-01"));
+ Assert.assertEquals(Long.valueOf(0L), byDay.get("2020-01-02"));
+ Assert.assertEquals(Long.valueOf(1L), byDay.get("2020-01-03"));
+
+ Map<String, Long> byAgeValue = keepTrying("Terms aggregate on a
numeric field should key buckets by value",
+ () ->
persistenceService.aggregateWithOptimizedQuery(onlyTheseProfiles, new
TermsAggregate(AGE_PROPERTY),
+ Profile.ITEM_TYPE),
+ r -> r != null && Long.valueOf(1L).equals(r.get("70")),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ for (String age : new String[]{"10", "30", "40", "70"}) {
+ Assert.assertEquals("Bucket for age " + age, Long.valueOf(1L),
byAgeValue.get(age));
+ }
+ Assert.assertFalse("No bucket may have a null key",
byAgeValue.containsKey(null));
+
+ Map<String, Long> byAge = keepTrying("Numeric range aggregate should
return one bucket per range",
+ () -> persistenceService.aggregateQuery(onlyTheseProfiles, new
NumericRangeAggregate(AGE_PROPERTY,
+ Arrays.asList(numericRange("young", null, 18.0),
numericRange("adult", 18.0, 65.0),
+ numericRange("senior", 65.0, null),
numericRange("all", null, null))), Profile.ITEM_TYPE),
+ r -> r != null && Long.valueOf(2L).equals(r.get("adult")),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ Assert.assertEquals(Long.valueOf(1L), byAge.get("young"));
+ Assert.assertEquals(Long.valueOf(2L), byAge.get("adult"));
+ Assert.assertEquals(Long.valueOf(1L), byAge.get("senior"));
+ Assert.assertEquals("A range without bounds covers every profile",
Long.valueOf(4L), byAge.get("all"));
+ Assert.assertEquals(Long.valueOf(4L), byAge.get("_filtered"));
+
+ // One range has no lower bound and the other no upper bound; both
engines must accept them
+ Map<String, Long> byVisit = keepTrying("Date range aggregate should
return one bucket per range",
+ () ->
persistenceService.aggregateWithOptimizedQuery(onlyTheseProfiles, new
DateRangeAggregate(
+ "properties.lastVisit", "yyyy-MM-dd",
Arrays.asList(dateRange("before", null, "2020-01-02"),
+ dateRange("after", "2020-01-02", null))),
Profile.ITEM_TYPE),
+ r -> r != null && Long.valueOf(3L).equals(r.get("before")),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ Assert.assertEquals(Long.valueOf(3L), byVisit.get("before"));
+ Assert.assertEquals(Long.valueOf(1L), byVisit.get("after"));
+ }
+
+ /**
+ * A bucket whose key is the name of a result count must not change that
count.
+ */
+ @Test
+ public void testBucketNamedLikeAResultCountLeavesItUnchanged() throws
InterruptedException {
+ String marker = "agg-result-key-" + UUID.randomUUID();
+ saveProfileWithLastName(marker, "_all");
+ saveProfileWithLastName(marker, "Smith");
+ refreshPersistence(Profile.class);
+
+ Map<String, Long> byLastName = keepTrying("Terms aggregate should
return the regular last-name bucket",
+ () ->
persistenceService.aggregateQuery(firstNameEquals(marker), new
TermsAggregate("properties.lastName"),
+ Profile.ITEM_TYPE),
+ r -> r != null && Long.valueOf(1L).equals(r.get("Smith")),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ Assert.assertEquals("_all must still be the current tenant's profile
count",
+
Long.valueOf(persistenceService.getAllItemsCount(Profile.ITEM_TYPE)),
byLastName.get("_all"));
+ Assert.assertEquals(Long.valueOf(2L), byLastName.get("_filtered"));
+ }
+
private Condition matchAllCondition() {
return new
Condition(definitionsService.getConditionType("matchAllCondition"));
}
+ private Condition firstNameEquals(String firstName) {
+ Condition condition = new
Condition(definitionsService.getConditionType("profilePropertyCondition"));
+ condition.setParameter("propertyName", "properties.firstName");
+ condition.setParameter("comparisonOperator", "equals");
+ condition.setParameter("propertyValue", firstName);
+ return condition;
+ }
+
+ private void saveProfileWithLastName(String firstName, String lastName) {
+ String itemId = "agg-result-key-" + UUID.randomUUID();
+ Profile profile = new Profile(itemId);
+ profile.setProperty("firstName", firstName);
+ profile.setProperty("lastName", lastName);
+ persistenceService.save(profile);
+ profileIds.add(itemId);
+ }
+
+ private void saveProfileWithVisitAndAge(String firstName, String
lastVisit, int age) {
+ String itemId = "agg-buckets-" + UUID.randomUUID();
+ Profile profile = new Profile(itemId);
+ profile.setProperty("firstName", firstName);
+ profile.setProperty("lastVisit", Date.from(Instant.parse(lastVisit)));
+ profile.setProperty("age", age);
+ persistenceService.save(profile);
+ profileIds.add(itemId);
+ }
+
+ private static NumericRange numericRange(String key, Double from, Double
to) {
+ NumericRange range = new NumericRange();
+ range.setKey(key);
+ range.setFrom(from);
+ range.setTo(to);
+ return range;
+ }
+
+ private static DateRange dateRange(String key, String from, String to) {
+ DateRange range = new DateRange();
+ range.setKey(key);
+ range.setFrom(from);
+ range.setTo(to);
+ return range;
+ }
+
private void saveProfileWithFirstName(String itemId, String firstName) {
Profile profile = new Profile(itemId);
profile.setProperty("firstName", firstName);
diff --git
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
index f5980a7ac..b27807fec 100644
---
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
+++
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
@@ -102,6 +102,10 @@ import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.apache.unomi.api.tenants.TenantService.SYSTEM_TENANT;
+import static
org.apache.unomi.persistence.spi.aggregate.AggregateBuckets.addBucket;
+import static
org.apache.unomi.persistence.spi.aggregate.AggregateBuckets.bucketKey;
+import static
org.apache.unomi.persistence.spi.aggregate.AggregateBuckets.ipRangeBucketKey;
+import static
org.apache.unomi.persistence.spi.aggregate.AggregateBuckets.rangeBucketKey;
@SuppressWarnings("rawtypes")
public class ElasticSearchPersistenceServiceImpl implements
PersistenceService, SynchronousBundleListener, ManagedService {
@@ -2468,7 +2472,6 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
List<IpRangeAggregationRange> ranges = new
ArrayList<>();
for (IpRange range : ipRangeAggregate.getRanges()) {
if (range != null) {
- IpRangeAggregationRange.of(builder ->
builder.from(range.getFrom()).to(range.getTo()));
ranges.add(IpRangeAggregationRange.of(builder
-> builder.from(range.getFrom()).to(range.getTo())));
}
}
@@ -2572,47 +2575,19 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
aggregations = filterAggregate.aggregations();
}
}
- if (aggregations.get("buckets") != null) {
- if (aggQueryThrowOnMissingDocs) {
- Aggregate agg = aggregations.get("buckets");
- if (agg.isSterms()) {
- StringTermsAggregate terms =
aggregations.get("buckets").sterms();
- if (terms.docCountErrorUpperBound() > 0 ||
terms.sumOtherDocCount() > 0) {
- throw new
UnsupportedOperationException("Some docs are missing in aggregation query.
docCountError is:"
- + terms.docCountErrorUpperBound()
+ " sumOfOtherDocCounts:" + terms.sumOtherDocCount());
- }
- }
- // TODO check if needed for dTerms and lTerms
- }
-
- long totalDocCount = 0;
- Aggregate bucketsAggregate =
aggregations.get("buckets");
- if (bucketsAggregate.isMultiTerms()) {
- MultiTermsAggregate terms =
aggregations.get("buckets").multiTerms();
- for (MultiTermsBucket bucket :
terms.buckets().array()) {
- results.put(bucket.keyAsString(),
bucket.docCount());
- totalDocCount += bucket.docCount();
- }
- } else if (bucketsAggregate.isSterms()) {
+ Aggregate bucketsAggregate = aggregations.get("buckets");
+ if (bucketsAggregate != null) {
+ // TODO check if needed for dTerms and lTerms
+ if (aggQueryThrowOnMissingDocs &&
bucketsAggregate.isSterms()) {
StringTermsAggregate terms =
bucketsAggregate.sterms();
- for (StringTermsBucket bucket :
terms.buckets().array()) {
- results.put(bucket.key().stringValue(),
bucket.docCount());
- totalDocCount += bucket.docCount();
- }
- } else if (bucketsAggregate.isLterms()) {
- LongTermsAggregate terms =
bucketsAggregate.lterms();
- for (LongTermsBucket bucket :
terms.buckets().array()) {
- results.put(bucket.keyAsString(),
bucket.docCount());
- totalDocCount += bucket.docCount();
- }
- } else if (bucketsAggregate.isDateHistogram()){
- DateHistogramAggregate histogramAggregate =
bucketsAggregate.dateHistogram();
- for (DateHistogramBucket bucket :
histogramAggregate.buckets().array()) {
- results.put(bucket.keyAsString(),
bucket.docCount());
- totalDocCount += bucket.docCount();
+ if (terms.docCountErrorUpperBound() > 0 ||
terms.sumOtherDocCount() > 0) {
+ throw new UnsupportedOperationException("Some
docs are missing in aggregation query. docCountError is:"
+ + terms.docCountErrorUpperBound() + "
sumOfOtherDocCounts:" + terms.sumOtherDocCount());
}
}
+ long totalDocCount = addBucketCounts(bucketsAggregate,
aggregate, results);
+
Aggregate missingAgg = aggregations.get("missing");
if (missingAgg != null && missingAgg.isMissing()) {
MissingAggregate missing = missingAgg.missing();
@@ -2632,6 +2607,58 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
}.catchingExecuteInClassLoader(true);
}
+ /**
+ * Adds the buckets of a terms, date histogram, or numeric, date or IP
range aggregate to {@code results} and
+ * returns their total document count. {@code aggregate} supplies the keys
of IP ranges.
+ */
+ static long addBucketCounts(Aggregate bucketsAggregate, BaseAggregate
aggregate, Map<String, Long> results) {
+ long totalDocCount = 0;
+ if (bucketsAggregate.isMultiTerms()) {
+ for (MultiTermsBucket bucket :
bucketsAggregate.multiTerms().buckets().array()) {
+ totalDocCount += addBucket(results, bucket.keyAsString(),
bucket.docCount());
+ }
+ } else if (bucketsAggregate.isSterms()) {
+ for (StringTermsBucket bucket :
bucketsAggregate.sterms().buckets().array()) {
+ totalDocCount += addBucket(results,
bucket.key().stringValue(), bucket.docCount());
+ }
+ } else if (bucketsAggregate.isLterms()) {
+ for (LongTermsBucket bucket :
bucketsAggregate.lterms().buckets().array()) {
+ totalDocCount += addBucket(results,
bucketKey(bucket.keyAsString(), bucket.key()), bucket.docCount());
+ }
+ } else if (bucketsAggregate.isDterms()) {
+ for (DoubleTermsBucket bucket :
bucketsAggregate.dterms().buckets().array()) {
+ totalDocCount += addBucket(results,
bucketKey(bucket.keyAsString(), bucket.key()), bucket.docCount());
+ }
+ } else if (bucketsAggregate.isDateHistogram()) {
+ for (DateHistogramBucket bucket :
bucketsAggregate.dateHistogram().buckets().array()) {
+ totalDocCount += addBucket(results,
bucketKey(bucket.keyAsString(), bucket.key()), bucket.docCount());
+ }
+ } else if (bucketsAggregate.isRange()) {
+ totalDocCount +=
addRangeBucketCounts(bucketsAggregate.range().buckets().array(), results);
+ } else if (bucketsAggregate.isDateRange()) {
+ totalDocCount +=
addRangeBucketCounts(bucketsAggregate.dateRange().buckets().array(), results);
+ } else if (bucketsAggregate.isIpRange()) {
+ for (IpRangeBucket bucket :
bucketsAggregate.ipRange().buckets().array()) {
+ totalDocCount += addBucket(results,
ipRangeBucketKey(aggregate, bucket.key(), bucket.from(), bucket.to()),
+ bucket.docCount());
+ }
+ } else {
+ LOGGER.warn("Aggregate results of type {} on field {} are not
read, their buckets are left out",
+ bucketsAggregate._kind(), aggregate.getField());
+ }
+ return totalDocCount;
+ }
+
+ private static long addRangeBucketCounts(List<RangeBucket> buckets,
Map<String, Long> results) {
+ long totalDocCount = 0;
+ for (RangeBucket bucket : buckets) {
+ totalDocCount += addBucket(results,
+ rangeBucketKey(bucket.key(), bucket.fromAsString(),
bucket.from(), bucket.toAsString(), bucket.to()),
+ bucket.docCount());
+ }
+ return totalDocCount;
+ }
+
private <T extends Item> String[] getRouting(String fieldName, String[]
fieldValues, Class<T> clazz) {
String itemType = Item.getItemType(clazz);
String[] routing = null;
diff --git
a/persistence-elasticsearch/core/src/test/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchAggregateBucketsTest.java
b/persistence-elasticsearch/core/src/test/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchAggregateBucketsTest.java
new file mode 100644
index 000000000..bbf919169
--- /dev/null
+++
b/persistence-elasticsearch/core/src/test/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchAggregateBucketsTest.java
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.persistence.elasticsearch;
+
+import co.elastic.clients.elasticsearch._types.aggregations.Aggregate;
+import co.elastic.clients.elasticsearch.core.SearchResponse;
+import co.elastic.clients.json.JsonpDeserializer;
+import co.elastic.clients.json.JsonpMapper;
+import co.elastic.clients.json.jackson.JacksonJsonpMapper;
+import org.apache.unomi.api.query.IpRange;
+import org.apache.unomi.persistence.spi.aggregate.BaseAggregate;
+import org.apache.unomi.persistence.spi.aggregate.IpRangeAggregate;
+import org.apache.unomi.persistence.spi.aggregate.TermsAggregate;
+import org.junit.Test;
+
+import java.io.StringReader;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Reads aggregate buckets from Elasticsearch response JSON, parsed by the
client as it would be for a real search.
+ */
+public class ElasticSearchAggregateBucketsTest {
+
+ private static final BaseAggregate TERMS = new
TermsAggregate("properties.field");
+
+ @Test
+ public void readsStringTerms() {
+ Map<String, Long> results = read("sterms",
"{\"doc_count_error_upper_bound\":0,\"sum_other_doc_count\":0,"
+ +
"\"buckets\":[{\"key\":\"Alice\",\"doc_count\":2},{\"key\":\"Bob\",\"doc_count\":1}]}",
TERMS, 3);
+ assertEquals(Long.valueOf(2L), results.get("Alice"));
+ assertEquals(Long.valueOf(1L), results.get("Bob"));
+ }
+
+ @Test
+ public void readsLongTermsWithoutKeyAsString() {
+ Map<String, Long> results = read("lterms",
"{\"doc_count_error_upper_bound\":0,\"sum_other_doc_count\":0,"
+ +
"\"buckets\":[{\"key\":30,\"doc_count\":2},{\"key\":70,\"doc_count\":1}]}",
TERMS, 3);
+ assertEquals(Long.valueOf(2L), results.get("30"));
+ assertEquals(Long.valueOf(1L), results.get("70"));
+ assertEquals(2, results.size());
+ }
+
+ @Test
+ public void readsDoubleTerms() {
+ Map<String, Long> results = read("dterms",
"{\"doc_count_error_upper_bound\":0,\"sum_other_doc_count\":0,"
+ + "\"buckets\":[{\"key\":2.5,\"doc_count\":4}]}", TERMS, 4);
+ assertEquals(Long.valueOf(4L), results.get("2.5"));
+ }
+
+ @Test
+ public void readsDateHistogramWithFormattedKeys() {
+ Map<String, Long> results = read("date_histogram", "{\"buckets\":["
+ +
"{\"key_as_string\":\"2020-01-01\",\"key\":1577836800000,\"doc_count\":3},"
+ +
"{\"key_as_string\":\"2020-01-02\",\"key\":1577923200000,\"doc_count\":0}]}",
TERMS, 3);
+ assertEquals(Long.valueOf(3L), results.get("2020-01-01"));
+ assertEquals(Long.valueOf(0L), results.get("2020-01-02"));
+ }
+
+ @Test
+ public void readsNumericRanges() {
+ Map<String, Long> results = read("range", "{\"buckets\":["
+ + "{\"key\":\"young\",\"to\":18.0,\"doc_count\":1},"
+ +
"{\"key\":\"adult\",\"from\":18.0,\"to\":65.0,\"doc_count\":2},"
+ + "{\"from\":65.0,\"doc_count\":1}]}", TERMS, 4);
+ assertEquals(Long.valueOf(1L), results.get("young"));
+ assertEquals(Long.valueOf(2L), results.get("adult"));
+ assertEquals(Long.valueOf(1L), results.get("65.0-*"));
+ }
+
+ @Test
+ public void readsDateRanges() {
+ Map<String, Long> results = read("date_range", "{\"buckets\":["
+ +
"{\"key\":\"before\",\"to\":1577923200000,\"to_as_string\":\"2020-01-02\",\"doc_count\":3},"
+ +
"{\"from\":1577923200000,\"from_as_string\":\"2020-01-02\",\"doc_count\":1}]}",
TERMS, 4);
+ assertEquals(Long.valueOf(3L), results.get("before"));
+ assertEquals(Long.valueOf(1L), results.get("2020-01-02-*"));
+ }
+
+ @Test
+ public void readsIpRangesUnderTheirRangeKeys() {
+ IpRangeAggregate aggregate = new IpRangeAggregate("properties.ip",
+ Arrays.asList(ipRange("lan", "10.0.0.0", "10.0.1.0"),
ipRange("loopback", "0:0:0:0:0:0:0:1", null)));
+ Map<String, Long> results = read("ip_range", "{\"buckets\":["
+ +
"{\"key\":\"10.0.0.0-10.0.1.0\",\"from\":\"10.0.0.0\",\"to\":\"10.0.1.0\",\"doc_count\":2},"
+ + "{\"key\":\"::1-*\",\"from\":\"::1\",\"doc_count\":1}]}",
aggregate, 3);
+ assertEquals(Long.valueOf(2L), results.get("lan"));
+ assertEquals(Long.valueOf(1L), results.get("loopback"));
+ assertEquals(2, results.size());
+ }
+
+ @Test
+ public void readsMultiTerms() {
+ Map<String, Long> results = read("multi_terms",
"{\"doc_count_error_upper_bound\":0,\"sum_other_doc_count\":0,"
+ +
"\"buckets\":[{\"key\":[\"a\",\"b\"],\"key_as_string\":\"a|b\",\"doc_count\":1}]}",
TERMS, 1);
+ assertEquals(Long.valueOf(1L), results.get("a|b"));
+ }
+
+ @Test
+ public void leavesOutUnreadBucketTypes() {
+ Map<String, Long> results = read("avg", "{\"value\":1.5}", TERMS, 0);
+ assertTrue(results.isEmpty());
+ }
+
+ @Test
+ public void bucketsNamedLikeResultCountsDoNotChangeThem() {
+ Map<String, Long> results = new LinkedHashMap<>();
+ results.put("_all", 10L);
+ long total =
ElasticSearchPersistenceServiceImpl.addBucketCounts(parse("sterms",
+ "{\"doc_count_error_upper_bound\":0,\"sum_other_doc_count\":0,"
+ +
"\"buckets\":[{\"key\":\"_all\",\"doc_count\":3},{\"key\":\"x\",\"doc_count\":1}]}"),
TERMS, results);
+ assertEquals(4L, total);
+ assertEquals(Long.valueOf(10L), results.get("_all"));
+ assertEquals(Long.valueOf(1L), results.get("x"));
+ }
+
+ private static Map<String, Long> read(String type, String body,
BaseAggregate aggregate, long expectedTotal) {
+ Map<String, Long> results = new LinkedHashMap<>();
+ assertEquals(expectedTotal,
ElasticSearchPersistenceServiceImpl.addBucketCounts(parse(type, body),
aggregate, results));
+ return results;
+ }
+
+ private static Aggregate parse(String type, String body) {
+ String json = "{\"took\":1,\"timed_out\":false,"
+ +
"\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},"
+ +
"\"hits\":{\"total\":{\"value\":0,\"relation\":\"eq\"},\"hits\":[]},"
+ + "\"aggregations\":{\"" + type + "#buckets\":" + body + "}}";
+ JsonpMapper mapper = new JacksonJsonpMapper();
+ SearchResponse<Void> response =
SearchResponse.createSearchResponseDeserializer(JsonpDeserializer.voidDeserializer())
+ .deserialize(mapper.jsonProvider().createParser(new
StringReader(json)), mapper);
+ return response.aggregations().get("buckets");
+ }
+
+ private static IpRange ipRange(String key, String from, String to) {
+ IpRange range = new IpRange();
+ range.setKey(key);
+ range.setFrom(from);
+ range.setTo(to);
+ return range;
+ }
+}
diff --git
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
index 0e2be7a7e..235818bd2 100644
---
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
+++
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
@@ -98,6 +98,10 @@ import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
import static org.apache.unomi.api.tenants.TenantService.SYSTEM_TENANT;
+import static
org.apache.unomi.persistence.spi.aggregate.AggregateBuckets.addBucket;
+import static
org.apache.unomi.persistence.spi.aggregate.AggregateBuckets.bucketKey;
+import static
org.apache.unomi.persistence.spi.aggregate.AggregateBuckets.ipRangeBucketKey;
+import static
org.apache.unomi.persistence.spi.aggregate.AggregateBuckets.rangeBucketKey;
@SuppressWarnings("rawtypes")
public class OpenSearchPersistenceServiceImpl implements PersistenceService,
SynchronousBundleListener, ManagedService {
@@ -2416,7 +2420,7 @@ public class OpenSearchPersistenceServiceImpl implements
PersistenceService, Syn
DateAggregate dateAggregate = (DateAggregate)
aggregate;
DateHistogramAggregation.Builder dateHistogramBuilder
= new DateHistogramAggregation.Builder()
.field(fieldName)
-
.calendarInterval(CalendarInterval.valueOf(dateAggregate.getInterval()));
+
.calendarInterval(CalendarInterval.valueOf(dateAggregate.getIntervalByAlias(dateAggregate.getInterval())));
if (dateAggregate.getFormat() != null) {
dateHistogramBuilder.format(dateAggregate.getFormat());
}
@@ -2426,17 +2430,16 @@ public class OpenSearchPersistenceServiceImpl
implements PersistenceService, Syn
.field(fieldName);
for (NumericRange range : ((NumericRangeAggregate)
aggregate).getRanges()) {
if (range != null) {
- if (range.getFrom() != null && range.getTo()
!= null) {
- rangeBuilder.ranges(r ->
r.key(range.getKey())
- .from(JsonData.of(range.getFrom()))
- .to(JsonData.of(range.getTo())));
- } else if (range.getFrom() != null) {
- rangeBuilder.ranges(r ->
r.key(range.getKey())
-
.from(JsonData.of(range.getFrom())));
- } else if (range.getTo() != null) {
- rangeBuilder.ranges(r ->
r.key(range.getKey())
- .to(JsonData.of(range.getTo())));
- }
+ rangeBuilder.ranges(r -> {
+ r.key(range.getKey());
+ if (range.getFrom() != null) {
+ r.from(JsonData.of(range.getFrom()));
+ }
+ if (range.getTo() != null) {
+ r.to(JsonData.of(range.getTo()));
+ }
+ return r;
+ });
}
}
bucketsContainerBuilder =
bucketsAggregationBuilder.range(rangeBuilder.build());
@@ -2449,9 +2452,16 @@ public class OpenSearchPersistenceServiceImpl implements
PersistenceService, Syn
}
for (DateRange range :
dateRangeAggregate.getDateRanges()) {
if (range != null) {
- rangeBuilder.ranges(r -> r.key(range.getKey())
- .from(f -> f.expr(range.getFrom() !=
null ? range.getFrom().toString() : null))
- .to(t -> t.expr(range.getTo() != null
? range.getTo().toString() : null)));
+ rangeBuilder.ranges(r -> {
+ r.key(range.getKey());
+ if (range.getFrom() != null) {
+ r.from(f ->
f.expr(range.getFrom().toString()));
+ }
+ if (range.getTo() != null) {
+ r.to(t ->
t.expr(range.getTo().toString()));
+ }
+ return r;
+ });
}
}
bucketsContainerBuilder =
bucketsAggregationBuilder.dateRange(rangeBuilder.build());
@@ -2461,7 +2471,7 @@ public class OpenSearchPersistenceServiceImpl implements
PersistenceService, Syn
.field(fieldName);
for (IpRange range : ipRangeAggregate.getRanges()) {
if (range != null) {
- rangeBuilder.ranges(r ->
r.mask(range.getKey()).from(range.getFrom()).to(range.getTo()));
+ rangeBuilder.ranges(r ->
r.from(range.getFrom()).to(range.getTo()));
}
}
bucketsContainerBuilder =
bucketsAggregationBuilder.ipRange(rangeBuilder.build());
@@ -2565,26 +2575,17 @@ public class OpenSearchPersistenceServiceImpl
implements PersistenceService, Syn
aggregates = filterAgg.aggregations();
}
}
- if (aggregates.get("buckets") != null) {
-
- if (aggQueryThrowOnMissingDocs) {
- if (aggregates.get("buckets").isSterms()) {
- StringTermsAggregate terms =
aggregates.get("buckets").sterms();
- if (terms.docCountErrorUpperBound() > 0 ||
terms.sumOtherDocCount() > 0) {
- throw new
UnsupportedOperationException("Some docs are missing in aggregation query.
docCountError is:" +
- terms.docCountErrorUpperBound() +
" sumOfOtherDocCounts:" + terms.sumOtherDocCount());
- }
+ Aggregate bucketsAggregate = aggregates.get("buckets");
+ if (bucketsAggregate != null) {
+ if (aggQueryThrowOnMissingDocs &&
bucketsAggregate.isSterms()) {
+ StringTermsAggregate terms =
bucketsAggregate.sterms();
+ if (terms.docCountErrorUpperBound() > 0 ||
terms.sumOtherDocCount() > 0) {
+ throw new UnsupportedOperationException("Some
docs are missing in aggregation query. docCountError is:" +
+ terms.docCountErrorUpperBound() + "
sumOfOtherDocCounts:" + terms.sumOtherDocCount());
}
}
- long totalDocCount = 0;
- Aggregate bucketsAggregate = aggregates.get("buckets");
- if (bucketsAggregate.isSterms()) {
- for (StringTermsBucket bucket :
bucketsAggregate.sterms().buckets().array()) {
- results.put(bucket.key(), bucket.docCount());
- totalDocCount += bucket.docCount();
- }
- }
+ long totalDocCount = addBucketCounts(bucketsAggregate,
aggregate, results);
Aggregate missingAgg = aggregates.get("missing");
if (missingAgg != null && missingAgg.isMissing()) {
MissingAggregate missing = missingAgg.missing();
@@ -2604,6 +2605,54 @@ public class OpenSearchPersistenceServiceImpl implements
PersistenceService, Syn
}.catchingExecuteInClassLoader(true);
}
+ /**
+ * Adds the buckets of a terms, date histogram, or numeric, date or IP
range aggregate to {@code results} and
+ * returns their total document count. {@code aggregate} supplies the keys
of IP ranges.
+ */
+ static long addBucketCounts(Aggregate bucketsAggregate, BaseAggregate
aggregate, Map<String, Long> results) {
+ long totalDocCount = 0;
+ if (bucketsAggregate.isSterms()) {
+ for (StringTermsBucket bucket :
bucketsAggregate.sterms().buckets().array()) {
+ totalDocCount += addBucket(results, bucket.key(),
bucket.docCount());
+ }
+ } else if (bucketsAggregate.isLterms()) {
+ for (LongTermsBucket bucket :
bucketsAggregate.lterms().buckets().array()) {
+ totalDocCount += addBucket(results,
bucketKey(bucket.keyAsString(), bucket.key()._get()), bucket.docCount());
+ }
+ } else if (bucketsAggregate.isDterms()) {
+ for (DoubleTermsBucket bucket :
bucketsAggregate.dterms().buckets().array()) {
+ totalDocCount += addBucket(results,
bucketKey(bucket.keyAsString(), bucket.key()), bucket.docCount());
+ }
+ } else if (bucketsAggregate.isDateHistogram()) {
+ for (DateHistogramBucket bucket :
bucketsAggregate.dateHistogram().buckets().array()) {
+ totalDocCount += addBucket(results,
bucketKey(bucket.keyAsString(), bucket.key()), bucket.docCount());
+ }
+ } else if (bucketsAggregate.isRange()) {
+ totalDocCount +=
addRangeBucketCounts(bucketsAggregate.range().buckets().array(), results);
+ } else if (bucketsAggregate.isDateRange()) {
+ totalDocCount +=
addRangeBucketCounts(bucketsAggregate.dateRange().buckets().array(), results);
+ } else if (bucketsAggregate.isIpRange()) {
+ for (IpRangeBucket bucket :
bucketsAggregate.ipRange().buckets().array()) {
+ totalDocCount += addBucket(results,
ipRangeBucketKey(aggregate, bucket.key(), bucket.from(), bucket.to()),
+ bucket.docCount());
+ }
+ } else {
+ LOGGER.warn("Aggregate results of type {} on field {} are not
read, their buckets are left out",
+ bucketsAggregate._kind(), aggregate.getField());
+ }
+ return totalDocCount;
+ }
+
+ private static long addRangeBucketCounts(List<RangeBucket> buckets,
Map<String, Long> results) {
+ long totalDocCount = 0;
+ for (RangeBucket bucket : buckets) {
+ totalDocCount += addBucket(results,
+ rangeBucketKey(bucket.key(), bucket.fromAsString(),
bucket.from(), bucket.toAsString(), bucket.to()),
+ bucket.docCount());
+ }
+ return totalDocCount;
+ }
+
private <T extends Item> String[] getRouting(String fieldName, String[]
fieldValues, Class<T> clazz) {
String itemType = Item.getItemType(clazz);
String[] routing = null;
diff --git
a/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/OpenSearchAggregateBucketsTest.java
b/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/OpenSearchAggregateBucketsTest.java
new file mode 100644
index 000000000..52c412426
--- /dev/null
+++
b/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/OpenSearchAggregateBucketsTest.java
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.persistence.opensearch;
+
+import org.opensearch.client.opensearch._types.aggregations.Aggregate;
+import org.opensearch.client.opensearch.core.SearchResponse;
+import org.opensearch.client.json.JsonpDeserializer;
+import org.opensearch.client.json.JsonpMapper;
+import org.opensearch.client.json.jackson.JacksonJsonpMapper;
+import org.apache.unomi.api.query.IpRange;
+import org.apache.unomi.persistence.spi.aggregate.BaseAggregate;
+import org.apache.unomi.persistence.spi.aggregate.IpRangeAggregate;
+import org.apache.unomi.persistence.spi.aggregate.TermsAggregate;
+import org.junit.jupiter.api.Test;
+
+import java.io.StringReader;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Reads aggregate buckets from OpenSearch response JSON, parsed by the client
as it would be for a real search.
+ */
+public class OpenSearchAggregateBucketsTest {
+
+ private static final BaseAggregate TERMS = new
TermsAggregate("properties.field");
+
+ @Test
+ public void readsStringTerms() {
+ Map<String, Long> results = read("sterms",
"{\"doc_count_error_upper_bound\":0,\"sum_other_doc_count\":0,"
+ +
"\"buckets\":[{\"key\":\"Alice\",\"doc_count\":2},{\"key\":\"Bob\",\"doc_count\":1}]}",
TERMS, 3);
+ assertEquals(Long.valueOf(2L), results.get("Alice"));
+ assertEquals(Long.valueOf(1L), results.get("Bob"));
+ }
+
+ @Test
+ public void readsLongTermsWithoutKeyAsString() {
+ Map<String, Long> results = read("lterms",
"{\"doc_count_error_upper_bound\":0,\"sum_other_doc_count\":0,"
+ +
"\"buckets\":[{\"key\":30,\"doc_count\":2},{\"key\":70,\"doc_count\":1}]}",
TERMS, 3);
+ assertEquals(Long.valueOf(2L), results.get("30"));
+ assertEquals(Long.valueOf(1L), results.get("70"));
+ assertEquals(2, results.size());
+ }
+
+ @Test
+ public void readsDoubleTerms() {
+ Map<String, Long> results = read("dterms",
"{\"doc_count_error_upper_bound\":0,\"sum_other_doc_count\":0,"
+ + "\"buckets\":[{\"key\":2.5,\"doc_count\":4}]}", TERMS, 4);
+ assertEquals(Long.valueOf(4L), results.get("2.5"));
+ }
+
+ @Test
+ public void readsDateHistogramWithFormattedKeys() {
+ Map<String, Long> results = read("date_histogram", "{\"buckets\":["
+ +
"{\"key_as_string\":\"2020-01-01\",\"key\":1577836800000,\"doc_count\":3},"
+ +
"{\"key_as_string\":\"2020-01-02\",\"key\":1577923200000,\"doc_count\":0}]}",
TERMS, 3);
+ assertEquals(Long.valueOf(3L), results.get("2020-01-01"));
+ assertEquals(Long.valueOf(0L), results.get("2020-01-02"));
+ }
+
+ @Test
+ public void readsNumericRanges() {
+ Map<String, Long> results = read("range", "{\"buckets\":["
+ + "{\"key\":\"young\",\"to\":18.0,\"doc_count\":1},"
+ +
"{\"key\":\"adult\",\"from\":18.0,\"to\":65.0,\"doc_count\":2},"
+ + "{\"from\":65.0,\"doc_count\":1}]}", TERMS, 4);
+ assertEquals(Long.valueOf(1L), results.get("young"));
+ assertEquals(Long.valueOf(2L), results.get("adult"));
+ assertEquals(Long.valueOf(1L), results.get("65.0-*"));
+ }
+
+ @Test
+ public void readsDateRanges() {
+ Map<String, Long> results = read("date_range", "{\"buckets\":["
+ +
"{\"key\":\"before\",\"to\":1577923200000,\"to_as_string\":\"2020-01-02\",\"doc_count\":3},"
+ +
"{\"from\":1577923200000,\"from_as_string\":\"2020-01-02\",\"doc_count\":1}]}",
TERMS, 4);
+ assertEquals(Long.valueOf(3L), results.get("before"));
+ assertEquals(Long.valueOf(1L), results.get("2020-01-02-*"));
+ }
+
+ @Test
+ public void readsIpRangesUnderTheirRangeKeys() {
+ IpRangeAggregate aggregate = new IpRangeAggregate("properties.ip",
+ Arrays.asList(ipRange("lan", "10.0.0.0", "10.0.1.0"),
ipRange("loopback", "0:0:0:0:0:0:0:1", null)));
+ Map<String, Long> results = read("ip_range", "{\"buckets\":["
+ +
"{\"key\":\"10.0.0.0-10.0.1.0\",\"from\":\"10.0.0.0\",\"to\":\"10.0.1.0\",\"doc_count\":2},"
+ + "{\"key\":\"::1-*\",\"from\":\"::1\",\"doc_count\":1}]}",
aggregate, 3);
+ assertEquals(Long.valueOf(2L), results.get("lan"));
+ assertEquals(Long.valueOf(1L), results.get("loopback"));
+ assertEquals(2, results.size());
+ }
+
+ @Test
+ public void leavesOutUnreadBucketTypes() {
+ Map<String, Long> results = read("avg", "{\"value\":1.5}", TERMS, 0);
+ assertTrue(results.isEmpty());
+ }
+
+ @Test
+ public void bucketsNamedLikeResultCountsDoNotChangeThem() {
+ Map<String, Long> results = new LinkedHashMap<>();
+ results.put("_all", 10L);
+ long total =
OpenSearchPersistenceServiceImpl.addBucketCounts(parse("sterms",
+ "{\"doc_count_error_upper_bound\":0,\"sum_other_doc_count\":0,"
+ +
"\"buckets\":[{\"key\":\"_all\",\"doc_count\":3},{\"key\":\"x\",\"doc_count\":1}]}"),
TERMS, results);
+ assertEquals(4L, total);
+ assertEquals(Long.valueOf(10L), results.get("_all"));
+ assertEquals(Long.valueOf(1L), results.get("x"));
+ }
+
+ private static Map<String, Long> read(String type, String body,
BaseAggregate aggregate, long expectedTotal) {
+ Map<String, Long> results = new LinkedHashMap<>();
+ assertEquals(expectedTotal,
OpenSearchPersistenceServiceImpl.addBucketCounts(parse(type, body), aggregate,
results));
+ return results;
+ }
+
+ private static Aggregate parse(String type, String body) {
+ String json = "{\"took\":1,\"timed_out\":false,"
+ +
"\"_shards\":{\"total\":1,\"successful\":1,\"skipped\":0,\"failed\":0},"
+ +
"\"hits\":{\"total\":{\"value\":0,\"relation\":\"eq\"},\"hits\":[]},"
+ + "\"aggregations\":{\"" + type + "#buckets\":" + body + "}}";
+ JsonpMapper mapper = new JacksonJsonpMapper();
+ SearchResponse<Void> response =
SearchResponse.createSearchResponseDeserializer(JsonpDeserializer.voidDeserializer())
+ .deserialize(mapper.jsonProvider().createParser(new
StringReader(json)), mapper);
+ return response.aggregations().get("buckets");
+ }
+
+ private static IpRange ipRange(String key, String from, String to) {
+ IpRange range = new IpRange();
+ range.setKey(key);
+ range.setFrom(from);
+ range.setTo(to);
+ return range;
+ }
+}
diff --git
a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/aggregate/AggregateBuckets.java
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/aggregate/AggregateBuckets.java
new file mode 100644
index 000000000..9f7329ea8
--- /dev/null
+++
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/aggregate/AggregateBuckets.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.persistence.spi.aggregate;
+
+import org.apache.unomi.api.query.IpRange;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Turns the buckets a search engine returns for an aggregate into the keys
and counts of
+ * {@link org.apache.unomi.persistence.spi.PersistenceService} aggregate
results, so that every persistence
+ * implementation reports buckets the same way.
+ * <p>
+ * Support code for persistence implementations, not intended for other
callers.
+ */
+public final class AggregateBuckets {
+
+ private static final Logger LOGGER =
LoggerFactory.getLogger(AggregateBuckets.class);
+
+ /**
+ * Keys that aggregate results use for their own counts rather than for
buckets.
+ */
+ private static final Set<String> RESULT_COUNT_KEYS = Set.of("_all",
"_filtered", "_missing");
+
+ private static final String OCTET =
"(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
+ private static final Pattern IPV4_LITERAL = Pattern.compile(OCTET + "(\\."
+ OCTET + "){3}");
+ // Starts with a hex digit or ':' and contains ':'. The JDK parses such a
string as an IPv6 literal or rejects it,
+ // without a lookup. It only resolves names that start with another
character. Scoped addresses (with '%') do not
+ // match, so they are only compared as plain strings.
+ private static final Pattern IPV6_LITERAL =
Pattern.compile("(?=.*:)[0-9A-Fa-f:][0-9A-Fa-f:.]*");
+
+ private AggregateBuckets() {
+ }
+
+ /**
+ * Key of a terms or date histogram bucket. Engines send {@code
key_as_string} for formatted values such as
+ * dates, and omit it for plain numeric terms, in which case the raw key
is used.
+ *
+ * @param keyAsString the formatted key sent by the engine, or {@code null}
+ * @param key the raw key
+ * @return the bucket key
+ */
+ public static String bucketKey(String keyAsString, Object key) {
+ return keyAsString != null ? keyAsString : String.valueOf(key);
+ }
+
+ /**
+ * Key of a numeric or date range bucket: the engine's key, or else {@code
from-to} with {@code *} for an open
+ * bound, which is the form the engines generate for unnamed ranges.
+ *
+ * @param key the key sent by the engine, or {@code null}
+ * @param fromAsString the formatted lower bound, or {@code null}
+ * @param from the raw lower bound, or {@code null} if open
+ * @param toAsString the formatted upper bound, or {@code null}
+ * @param to the raw upper bound, or {@code null} if open
+ * @return the bucket key
+ */
+ public static String rangeBucketKey(String key, String fromAsString,
Object from, String toAsString, Object to) {
+ if (key != null) {
+ return key;
+ }
+ return rangeKey(fromAsString != null ? fromAsString :
Objects.toString(from, null),
+ toAsString != null ? toAsString : Objects.toString(to, null));
+ }
+
+ /**
+ * Key of an IP range bucket. IP range requests cannot carry a key, so the
bucket is reported under the key of the
+ * {@link IpRange} whose bounds match the bucket's. Any other bucket keeps
the engine's key, or {@code from-to} when
+ * the engine sends none. A bucket whose bounds match no range at all is
logged, since it cannot be labelled.
+ *
+ * @param aggregate the aggregate the request was built from
+ * @param key the key sent by the engine, or {@code null}
+ * @param from the lower bound sent by the engine, or {@code null} if
open
+ * @param to the upper bound sent by the engine, or {@code null} if
open
+ * @return the bucket key
+ */
+ public static String ipRangeBucketKey(BaseAggregate aggregate, String key,
String from, String to) {
+ boolean hasKeyedRange = false;
+ boolean matchesUnkeyedRange = false;
+ if (aggregate instanceof IpRangeAggregate && ((IpRangeAggregate)
aggregate).getRanges() != null) {
+ for (IpRange range : ((IpRangeAggregate) aggregate).getRanges()) {
+ if (range == null) {
+ continue;
+ }
+ boolean sameBounds = sameIp(range.getFrom(), from) &&
sameIp(range.getTo(), to);
+ if (range.getKey() != null) {
+ hasKeyedRange = true;
+ if (sameBounds) {
+ return range.getKey();
+ }
+ } else if (sameBounds) {
+ matchesUnkeyedRange = true;
+ }
+ }
+ }
+ String engineKey = key != null ? key : rangeKey(from, to);
+ if (hasKeyedRange && !matchesUnkeyedRange) {
+ LOGGER.warn("IP range bucket {} on field {} matches no keyed
range, reporting it under the engine key",
+ engineKey, aggregate.getField());
+ }
+ return engineKey;
+ }
+
+ /**
+ * Adds a bucket count to aggregate results. Buckets that share a key,
such as two ranges with the same key, are
+ * summed so that the results stay consistent with the total document
count. A bucket named like one of the
+ * result counts ({@code _all}, {@code _filtered}, {@code _missing}) is
left out and logged, so that it cannot
+ * change those counts.
+ *
+ * @param results the aggregate results
+ * @param key the bucket key
+ * @param docCount the bucket document count
+ * @return {@code docCount}, which still counts towards the total document
count
+ */
+ public static long addBucket(Map<String, Long> results, String key, long
docCount) {
+ if (key != null && RESULT_COUNT_KEYS.contains(key)) {
+ LOGGER.warn("Aggregate bucket {} has the name of a result count,
its {} documents are left out of the results",
+ key, docCount);
+ return docCount;
+ }
+ results.merge(key, docCount, Long::sum);
+ return docCount;
+ }
+
+ private static String rangeKey(String from, String to) {
+ return (from != null ? from : "*") + "-" + (to != null ? to : "*");
+ }
+
+ /**
+ * Engines return IPv6 bounds in canonical form (e.g. {@code ::1}), so
compare addresses rather than strings.
+ * Only strict IP literals are parsed: for those the JDK never falls back
to a DNS lookup.
+ */
+ static boolean sameIp(String a, String b) {
+ if (Objects.equals(a, b)) {
+ return true;
+ }
+ if (a == null || b == null || !isIpLiteral(a) || !isIpLiteral(b)) {
+ return false;
+ }
+ try {
+ return InetAddress.getByName(a).equals(InetAddress.getByName(b));
+ } catch (UnknownHostException e) {
+ return false;
+ }
+ }
+
+ static boolean isIpLiteral(String value) {
+ return IPV4_LITERAL.matcher(value).matches() ||
IPV6_LITERAL.matcher(value).matches();
+ }
+}
diff --git
a/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/aggregate/AggregateBucketsTest.java
b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/aggregate/AggregateBucketsTest.java
new file mode 100644
index 000000000..fffefa6b9
--- /dev/null
+++
b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/aggregate/AggregateBucketsTest.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.persistence.spi.aggregate;
+
+import org.apache.unomi.api.query.IpRange;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class AggregateBucketsTest {
+
+ @Test
+ public void bucketKeyPrefersTheFormattedKey() {
+ assertEquals("2020-01-01", AggregateBuckets.bucketKey("2020-01-01",
1577836800000L));
+ assertEquals("70", AggregateBuckets.bucketKey(null, 70L));
+ assertEquals("2.5", AggregateBuckets.bucketKey(null, 2.5d));
+ }
+
+ @Test
+ public void rangeBucketKeyFallsBackToTheEngineFormat() {
+ assertEquals("adult", AggregateBuckets.rangeBucketKey("adult", null,
18.0, null, 65.0));
+ assertEquals("18.0-65.0", AggregateBuckets.rangeBucketKey(null, null,
18.0, null, 65.0));
+ assertEquals("*-2020-01-02", AggregateBuckets.rangeBucketKey(null,
null, null, "2020-01-02", 1577923200000.0));
+ assertEquals("*-*", AggregateBuckets.rangeBucketKey(null, null, null,
null, null));
+ assertEquals("2020-01-01-*", AggregateBuckets.rangeBucketKey(null,
"2020-01-01", 1577836800000.0, null, null));
+ }
+
+ @Test
+ public void ipRangeBucketKeyUsesTheMatchingRangeKey() {
+ IpRangeAggregate aggregate = new IpRangeAggregate("properties.ip",
+ Arrays.asList(ipRange("lan", "10.0.0.0", "10.0.1.0"),
ipRange("loopback", "::1", null), null));
+
+ assertEquals("lan", AggregateBuckets.ipRangeBucketKey(aggregate,
"10.0.0.0-10.0.1.0", "10.0.0.0", "10.0.1.0"));
+ // engines return IPv6 bounds in canonical form
+ assertEquals("loopback", AggregateBuckets.ipRangeBucketKey(
+ new IpRangeAggregate("properties.ip",
Arrays.asList(ipRange("loopback", "0:0:0:0:0:0:0:1", null))),
+ "::1-*", "::1", null));
+ }
+
+ @Test
+ public void ipRangeBucketKeyKeepsTheEngineKeyWithoutAMatch() {
+ IpRangeAggregate aggregate = new IpRangeAggregate("properties.ip",
+ Arrays.asList(ipRange("lan", "10.0.0.0", "10.0.1.0")));
+
+ assertEquals("192.168.0.0-*",
AggregateBuckets.ipRangeBucketKey(aggregate, "192.168.0.0-*", "192.168.0.0",
null));
+ assertEquals("192.168.0.0-*",
AggregateBuckets.ipRangeBucketKey(aggregate, null, "192.168.0.0", null));
+ }
+
+ @Test
+ public void ipRangeBucketKeyKeepsTheEngineKeyOfAnUnkeyedRange() {
+ IpRangeAggregate aggregate = new IpRangeAggregate("properties.ip",
+ Arrays.asList(ipRange("lan", "10.0.0.0", "10.0.1.0"),
ipRange(null, "192.168.0.0", null)));
+
+ assertEquals("192.168.0.0-*",
AggregateBuckets.ipRangeBucketKey(aggregate, "192.168.0.0-*", "192.168.0.0",
null));
+ }
+
+ @Test
+ public void ipRangeBucketKeyKeepsTheEngineKeyWithoutIpRanges() {
+ assertEquals("10.0.0.0-10.0.1.0", AggregateBuckets.ipRangeBucketKey(
+ new TermsAggregate("properties.ip"), "10.0.0.0-10.0.1.0",
"10.0.0.0", "10.0.1.0"));
+ assertEquals("1.1.1.1-*", AggregateBuckets.ipRangeBucketKey(
+ new IpRangeAggregate("properties.ip", null), null, "1.1.1.1",
null));
+ assertEquals("1.1.1.1-*", AggregateBuckets.ipRangeBucketKey(null,
"1.1.1.1-*", "1.1.1.1", null));
+ }
+
+ @Test
+ public void addBucketSumsBucketsThatShareAKey() {
+ Map<String, Long> results = new LinkedHashMap<>();
+ assertEquals(2L, AggregateBuckets.addBucket(results, "adult", 2L));
+ assertEquals(3L, AggregateBuckets.addBucket(results, "adult", 3L));
+ assertEquals(Long.valueOf(5L), results.get("adult"));
+ }
+
+ @Test
+ public void addBucketLeavesResultCountsUntouched() {
+ Map<String, Long> results = new LinkedHashMap<>();
+ results.put("_all", 10L);
+ results.put("_filtered", 4L);
+
+ assertEquals(3L, AggregateBuckets.addBucket(results, "_all", 3L));
+ assertEquals(1L, AggregateBuckets.addBucket(results, "_filtered", 1L));
+ assertEquals(2L, AggregateBuckets.addBucket(results, "_missing", 2L));
+
+ assertEquals(Long.valueOf(10L), results.get("_all"));
+ assertEquals(Long.valueOf(4L), results.get("_filtered"));
+ assertNull(results.get("_missing"));
+ assertEquals(2, results.size());
+ }
+
+ @Test
+ public void addBucketAcceptsANullKey() {
+ Map<String, Long> results = new LinkedHashMap<>();
+ assertEquals(1L, AggregateBuckets.addBucket(results, null, 1L));
+ assertEquals(Long.valueOf(1L), results.get(null));
+ }
+
+ @Test
+ public void sameIpComparesAddresses() {
+ assertTrue(AggregateBuckets.sameIp("::1", "0:0:0:0:0:0:0:1"));
+ assertTrue(AggregateBuckets.sameIp("1.2.3.4", "::ffff:1.2.3.4"));
+ assertTrue(AggregateBuckets.sameIp(null, null));
+ assertFalse(AggregateBuckets.sameIp("10.0.0.1", "10.0.0.2"));
+ assertFalse(AggregateBuckets.sameIp("10.0.0.1", null));
+ assertFalse(AggregateBuckets.sameIp("localhost", "127.0.0.1"));
+ // scoped addresses are not parsed, only compared as strings
+ assertTrue(AggregateBuckets.sameIp("fe80::1%eth0", "fe80::1%eth0"));
+ assertFalse(AggregateBuckets.sameIp("fe80::1%eth0", "fe80::1"));
+ }
+
+ @Test
+ public void onlyStrictIpLiteralsAreParsed() {
+ assertTrue(AggregateBuckets.isIpLiteral("10.0.0.1"));
+ assertTrue(AggregateBuckets.isIpLiteral("::1"));
+ assertTrue(AggregateBuckets.isIpLiteral("fe80::1"));
+ // these would make InetAddress.getByName resolve a host name
+ assertFalse(AggregateBuckets.isIpLiteral("999.1.1.1"));
+ assertFalse(AggregateBuckets.isIpLiteral("localhost"));
+ assertFalse(AggregateBuckets.isIpLiteral("zz:1"));
+ assertFalse(AggregateBuckets.isIpLiteral(".:1"));
+ assertFalse(AggregateBuckets.isIpLiteral("fe80::1%eth0"));
+ // malformed IPv6 is rejected by the JDK without a lookup
+ assertTrue(AggregateBuckets.isIpLiteral("1::2::3"));
+ assertFalse(AggregateBuckets.sameIp("1::2::3", "::1"));
+ }
+
+ private static IpRange ipRange(String key, String from, String to) {
+ IpRange range = new IpRange();
+ range.setKey(key);
+ range.setFrom(from);
+ range.setTo(to);
+ return range;
+ }
+}