This is an automated email from the ASF dual-hosted git repository.
xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 7835c7dc378 Support MODE on STRING, BOOLEAN and TIMESTAMP inputs
(#19991)
7835c7dc378 is described below
commit 7835c7dc378c21738bb94ecedf89161add936417
Author: Xiang Fu <[email protected]>
AuthorDate: Mon Oct 26 19:18:49 2026 -0700
Support MODE on STRING, BOOLEAN and TIMESTAMP inputs (#19991)
* Support MODE on STRING, BOOLEAN and TIMESTAMP inputs
MODE on a STRING, BOOLEAN or TIMESTAMP input returns the input's type, with
MIN (default) and MAX tie reducers. Both engines rewrite the call to
MODE(expr, 'MIN'|'MAX', 'dataType'): the single-stage optimizer for
STRING/JSON, BOOLEAN and TIMESTAMP columns, and the multi-stage rule for
VARCHAR arguments, since BOOLEAN and TIMESTAMP already return their own type
there. String counts are serialized with a new String2LongMap object type,
and the explicit form also sets the type of a leaf-returned final result.
The
explicit form requires the expression's stored type to match the data type.
The aggregation-only reducer now skips formatting NULL values, like the
group-by reducer.
Single-stage MODE on BOOLEAN and TIMESTAMP columns used to return DOUBLE and
accept the AVG reducer; it now returns BOOLEAN/TIMESTAMP and rejects AVG.
Servers older than the broker reject the rewritten call.
* Use the null-safe ORDER BY comparator for aggregation values
Aggregation results can be null with null handling disabled too, e.g.
MINSTRING or a typed MODE over an empty group, so ORDER BY on them failed
with an NPE in TableResizer. Also use the is_skip_leaf_stage_group_by hint
in
the MODE runtime case.
* Note where MODE's query result type comes from
* Preserve legacy BOOLEAN and TIMESTAMP MODE calls by default
---
.../apache/pinot/core/common/ObjectSerDeUtils.java | 53 +++++-
.../apache/pinot/core/data/table/TableResizer.java | 16 +-
.../function/ModeAggregationFunction.java | 201 +++++++++++++++++---
.../AggregateFunctionRewriteOptimizer.java | 28 +++
.../query/reduce/AggregationDataTableReducer.java | 5 +-
.../pinot/core/common/ObjectSerDeUtilsTest.java | 17 ++
.../function/ModeAggregationFunctionTest.java | 181 ++++++++++++++++++
.../AggregateFunctionRewriteOptimizerTest.java | 25 +++
.../org/apache/pinot/queries/ModeQueriesTest.java | 210 ++++++++++++++++++++-
.../PinotAggregateTypeArgumentRewriteRule.java | 55 +++---
.../org/apache/pinot/query/QueryEnvironment.java | 3 +-
.../src/test/resources/queries/AggregatePlans.json | 26 +++
.../src/test/resources/queries/UDFAggregates.json | 41 ++++
.../pinot/segment/spi/AggregationFunctionType.java | 12 +-
14 files changed, 818 insertions(+), 55 deletions(-)
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/common/ObjectSerDeUtils.java
b/pinot-core/src/main/java/org/apache/pinot/core/common/ObjectSerDeUtils.java
index 4259bd3873b..b72af2c0717 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/common/ObjectSerDeUtils.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/common/ObjectSerDeUtils.java
@@ -49,6 +49,8 @@ import it.unimi.dsi.fastutil.longs.LongArrayList;
import it.unimi.dsi.fastutil.longs.LongIterator;
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
import it.unimi.dsi.fastutil.longs.LongSet;
+import it.unimi.dsi.fastutil.objects.Object2LongMap;
+import it.unimi.dsi.fastutil.objects.Object2LongOpenHashMap;
import it.unimi.dsi.fastutil.objects.ObjectArrayList;
import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet;
import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet;
@@ -177,7 +179,8 @@ public class ObjectSerDeUtils {
BigDecimalSet(53),
BytesArrayList(54),
BigDecimalLongPair(55),
- BytesLongPair(56);
+ BytesLongPair(56),
+ String2LongMap(57);
private final int _value;
@@ -1680,6 +1683,53 @@ public class ObjectSerDeUtils {
}
};
+ public static final ObjectSerDe<Object2LongMap<String>>
STRING_2_LONG_MAP_SER_DE =
+ new ObjectSerDe<Object2LongMap<String>>() {
+
+ @Override
+ public byte[] serialize(Object2LongMap<String> map) {
+ int size = map.size();
+ // Besides the key bytes, we store: size, length for each key, value
for each key
+ long bufferSize = Integer.BYTES + (long) size * (Integer.BYTES +
Long.BYTES);
+ byte[][] keyBytesArray = new byte[size][];
+ long[] values = new long[size];
+ int index = 0;
+ for (Object2LongMap.Entry<String> entry : map.object2LongEntrySet())
{
+ byte[] keyBytes = entry.getKey().getBytes(UTF_8);
+ bufferSize += keyBytes.length;
+ keyBytesArray[index] = keyBytes;
+ values[index++] = entry.getLongValue();
+ }
+ Preconditions.checkState(bufferSize <= Integer.MAX_VALUE, "Buffer
size exceeds 2GB");
+ byte[] bytes = new byte[(int) bufferSize];
+ ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
+ byteBuffer.putInt(size);
+ for (int i = 0; i < size; i++) {
+ byteBuffer.putInt(keyBytesArray[i].length);
+ byteBuffer.put(keyBytesArray[i]);
+ byteBuffer.putLong(values[i]);
+ }
+ return bytes;
+ }
+
+ @Override
+ public Object2LongOpenHashMap<String> deserialize(byte[] bytes) {
+ return deserialize(ByteBuffer.wrap(bytes));
+ }
+
+ @Override
+ public Object2LongOpenHashMap<String> deserialize(ByteBuffer
byteBuffer) {
+ int size = byteBuffer.getInt();
+ Object2LongOpenHashMap<String> map = new
Object2LongOpenHashMap<>(size);
+ for (int i = 0; i < size; i++) {
+ byte[] keyBytes = new byte[byteBuffer.getInt()];
+ byteBuffer.get(keyBytes);
+ map.put(new String(keyBytes, UTF_8), byteBuffer.getLong());
+ }
+ return map;
+ }
+ };
+
public static final ObjectSerDe<Int2LongMap> INT_2_LONG_MAP_SER_DE = new
ObjectSerDe<Int2LongMap>() {
@Override
@@ -2134,6 +2184,7 @@ public class ObjectSerDeUtils {
BYTES_ARRAY_LIST_SER_DE,
BIG_DECIMAL_LONG_PAIR_SER_DE,
BYTES_LONG_PAIR_SER_DE,
+ STRING_2_LONG_MAP_SER_DE,
};
//@formatter:on
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/TableResizer.java
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/TableResizer.java
index fdfd1b1ecd4..15d4660ef5f 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/data/table/TableResizer.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/data/table/TableResizer.java
@@ -104,16 +104,22 @@ public class TableResizer {
_orderByValueExtractors = new
OrderByValueExtractor[_numOrderByExpressions];
Comparator[] comparators = new Comparator[_numOrderByExpressions];
int[] nullComparisonResults = new int[_numOrderByExpressions];
+ /// Grouping-set queries produce genuine null group-key values for
rolled-up columns regardless of the
+ /// user's null-handling option, so ORDER BY over a grouping column must
use the null-safe comparator.
+ boolean nullAwareComparison =
queryContext.requiresNullAwareKeyEvaluation();
for (int i = 0; i < _numOrderByExpressions; i++) {
OrderByExpressionContext orderByExpression = orderByExpressions.get(i);
- _orderByValueExtractors[i] =
getOrderByValueExtractor(orderByExpression.getExpression());
+ ExpressionContext expression = orderByExpression.getExpression();
+ _orderByValueExtractors[i] = getOrderByValueExtractor(expression);
comparators[i] = orderByExpression.isAsc() ? Comparator.naturalOrder() :
Comparator.reverseOrder();
nullComparisonResults[i] = orderByExpression.isNullsLast() ? -1 : 1;
+ // Aggregation results can be null with null handling disabled too, e.g.
MINSTRING over an empty group.
+ if (expression.getType() != ExpressionContext.Type.LITERAL &&
!_groupByExpressionIndexMap.containsKey(
+ expression)) {
+ nullAwareComparison = true;
+ }
}
- /// Grouping-set queries produce genuine null group-key values for
rolled-up columns regardless of the
- /// user's null-handling option, so ORDER BY over a grouping column must
use the null-safe comparator.
- boolean nullHandlingEnabled =
queryContext.requiresNullAwareKeyEvaluation();
- if (nullHandlingEnabled) {
+ if (nullAwareComparison) {
_intermediateRecordComparator = (o1, o2) -> {
for (int i = 0; i < _numOrderByExpressions; i++) {
Object v1 = o1._values[i];
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunction.java
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunction.java
index 95ccf22bbe4..56b1ec7acaa 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunction.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunction.java
@@ -30,8 +30,11 @@ import it.unimi.dsi.fastutil.ints.Int2LongMap;
import it.unimi.dsi.fastutil.ints.Int2LongOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2LongMap;
import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap;
+import it.unimi.dsi.fastutil.objects.Object2LongMap;
+import it.unimi.dsi.fastutil.objects.Object2LongOpenHashMap;
import it.unimi.dsi.fastutil.objects.ObjectIterator;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import javax.annotation.Nullable;
import org.apache.pinot.common.CustomObject;
@@ -51,33 +54,71 @@ import org.apache.pinot.spi.data.FieldSpec.DataType;
/// This function is used for Mode calculations.
///
-/// The function can be used as MODE(expression, multiModeReducerType)
+/// The function can be used as MODE(expression, multiModeReducerType,
dataType)
///
/// Following arguments are supported:
///
-/// - Expression: expression that contains the column to be calculated mode
on, can be any Numeric column
+/// - Expression: expression that contains the column to be calculated mode on
/// - MultiModeReducerType (optional): the reducer to use in case of multiple
modes present in data
+/// - DataType (optional): STRING, BOOLEAN or TIMESTAMP, which is also the
result type. The expression must have the
+/// same stored type, and only the MIN and MAX reducers are supported.
Without it, the expression must be numeric and
+/// the result is DOUBLE.
@SuppressWarnings({"rawtypes", "unchecked"})
-public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<Map<? extends Number, Long>, Double> {
+public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<Map<?, Long>, Comparable<?>> {
private static final double DEFAULT_FINAL_RESULT = Double.NEGATIVE_INFINITY;
private final MultiModeReducerType _multiModeReducerType;
+ private final ColumnDataType _resultType;
public ModeAggregationFunction(List<ExpressionContext> arguments, boolean
nullHandlingEnabled) {
super(arguments.get(0), nullHandlingEnabled);
int numArguments = arguments.size();
- Preconditions.checkArgument(numArguments <= 2, "Mode expects at most 2
arguments, got: %s", numArguments);
+ Preconditions.checkArgument(numArguments <= 3, "Mode expects at most 3
arguments, got: %s", numArguments);
if (numArguments > 1) {
_multiModeReducerType =
MultiModeReducerType.valueOf(arguments.get(1).getLiteral().getStringValue());
} else {
_multiModeReducerType = MultiModeReducerType.MIN;
}
+ if (numArguments > 2) {
+ _resultType =
getResultType(arguments.get(2).getLiteral().getStringValue());
+ Preconditions.checkArgument(_multiModeReducerType !=
MultiModeReducerType.AVG,
+ "Unsupported MODE reducer for %s: AVG", _resultType);
+ } else {
+ _resultType = ColumnDataType.DOUBLE;
+ }
+ }
+
+ private static ColumnDataType getResultType(String dataType) {
+ switch (DataType.valueOf(dataType.toUpperCase(Locale.ROOT))) {
+ case STRING:
+ return ColumnDataType.STRING;
+ case BOOLEAN:
+ return ColumnDataType.BOOLEAN;
+ case TIMESTAMP:
+ return ColumnDataType.TIMESTAMP;
+ default:
+ throw new IllegalArgumentException("Unsupported data type for MODE: "
+ dataType);
+ }
+ }
+
+ /// Returns the stored type of the values, which must match the result type
when it is given. BOOLEAN and TIMESTAMP
+ /// values are stored as INT and LONG.
+ private DataType getStoredType(DataType valueType) {
+ DataType storedType = valueType.getStoredType();
+ if (_resultType != ColumnDataType.DOUBLE) {
+ Preconditions.checkState(storedType ==
_resultType.getStoredType().toDataType(),
+ "Illegal data type for MODE aggregation function with %s result:
%s", _resultType, valueType);
+ } else {
+ Preconditions.checkState(storedType.isNumeric(), "Illegal data type for
MODE aggregation function: %s",
+ storedType);
+ }
+ return storedType;
}
/// Helper method to create a value map for the given value type.
- private static Map<? extends Number, Long> getValueMap(DataType valueType) {
+ private static Map<?, Long> getValueMap(DataType valueType) {
switch (valueType) {
case INT:
return new Int2LongOpenHashMap();
@@ -87,15 +128,16 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
return new Float2LongOpenHashMap();
case DOUBLE:
return new Double2LongOpenHashMap();
+ case STRING:
+ return new Object2LongOpenHashMap<String>();
default:
throw new IllegalStateException("Illegal data type for MODE
aggregation function: " + valueType);
}
}
/// Returns the value map from the result holder or creates a new one if it
does not exist.
- private static Map<? extends Number, Long>
getValueMap(AggregationResultHolder aggregationResultHolder,
- DataType valueType) {
- Map<? extends Number, Long> valueMap = aggregationResultHolder.getResult();
+ private static Map<?, Long> getValueMap(AggregationResultHolder
aggregationResultHolder, DataType valueType) {
+ Map<?, Long> valueMap = aggregationResultHolder.getResult();
if (valueMap == null) {
valueMap = getValueMap(valueType);
aggregationResultHolder.setValue(valueMap);
@@ -143,6 +185,16 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
valueMap.merge(value, 1, Long::sum);
}
+ /// Helper method to set STRING value for the given group keys into the
result holder.
+ private static void setValueForGroupKeys(GroupByResultHolder
groupByResultHolder, int groupKey, String value) {
+ Object2LongOpenHashMap<String> valueMap =
groupByResultHolder.getResult(groupKey);
+ if (valueMap == null) {
+ valueMap = new Object2LongOpenHashMap<>();
+ groupByResultHolder.setValueForKey(groupKey, valueMap);
+ }
+ valueMap.addTo(value, 1);
+ }
+
/// Returns the dictionary id count map from the result holder or creates a
new one if it does not exist.
protected static Int2IntOpenHashMap
getDictIdCountMap(AggregationResultHolder aggregationResultHolder,
Dictionary dictionary) {
@@ -166,12 +218,12 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
}
/// Helper method to read dictionary and convert dictionary ids to values
for dictionary-encoded expression.
- private static Map<? extends Number, Long> convertToValueMap(DictIdsWrapper
dictIdsWrapper) {
+ private Map<?, Long> convertToValueMap(DictIdsWrapper dictIdsWrapper) {
Dictionary dictionary = dictIdsWrapper._dictionary;
Int2IntOpenHashMap dictIdCountMap = dictIdsWrapper._dictIdCountMap;
int numValues = dictIdCountMap.size();
ObjectIterator<Int2IntMap.Entry> iterator =
Int2IntMaps.fastIterator(dictIdCountMap);
- DataType storedType = dictionary.getValueType();
+ DataType storedType = getStoredType(dictionary.getValueType());
switch (storedType) {
case INT:
Int2LongOpenHashMap intValueMap = new Int2LongOpenHashMap(numValues);
@@ -201,13 +253,20 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
doubleValueMap.put(dictionary.getDoubleValue(next.getIntKey()),
next.getIntValue());
}
return doubleValueMap;
+ case STRING:
+ Object2LongOpenHashMap<String> stringValueMap = new
Object2LongOpenHashMap<>(numValues);
+ while (iterator.hasNext()) {
+ Int2IntMap.Entry next = iterator.next();
+ stringValueMap.put(dictionary.getStringValue(next.getIntKey()),
next.getIntValue());
+ }
+ return stringValueMap;
default:
throw new IllegalStateException("Illegal data type for MODE
aggregation function: " + storedType);
}
}
/// Helper method to extract segment level intermediate result from the
inner segment result.
- private static Map<? extends Number, Long>
extractIntermediateResult(@Nullable Object result) {
+ private Map<?, Long> extractIntermediateResult(@Nullable Object result) {
if (result == null) {
// NOTE: Return an empty Int2LongOpenHashMap for empty result.
return new Int2LongOpenHashMap();
@@ -257,8 +316,8 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
}
// For non-dictionary-encoded expression, store values into the value map
- DataType storedType = blockValSet.getValueType().getStoredType();
- Map<? extends Number, Long> valueMap =
getValueMap(aggregationResultHolder, storedType);
+ DataType storedType = getStoredType(blockValSet.getValueType());
+ Map<?, Long> valueMap = getValueMap(aggregationResultHolder, storedType);
switch (storedType) {
case INT:
Int2LongOpenHashMap intMap = (Int2LongOpenHashMap) valueMap;
@@ -296,6 +355,15 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
}
});
break;
+ case STRING:
+ Object2LongOpenHashMap<String> stringMap =
(Object2LongOpenHashMap<String>) valueMap;
+ String[] stringValues = blockValSet.getStringValuesSV();
+ forEachNotNull(length, blockValSet, (from, to) -> {
+ for (int i = from; i < to; i++) {
+ stringMap.addTo(stringValues[i], 1);
+ }
+ });
+ break;
default:
throw new IllegalStateException("Illegal data type for MODE
aggregation function: " + storedType);
}
@@ -320,7 +388,7 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
}
// For non-dictionary-encoded expression, store values into the value map
- DataType storedType = blockValSet.getValueType().getStoredType();
+ DataType storedType = getStoredType(blockValSet.getValueType());
switch (storedType) {
case INT:
int[] intValues = blockValSet.getIntValuesSV();
@@ -354,6 +422,14 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
}
});
break;
+ case STRING:
+ String[] stringValues = blockValSet.getStringValuesSV();
+ forEachNotNull(length, blockValSet, (from, to) -> {
+ for (int i = from; i < to; i++) {
+ setValueForGroupKeys(groupByResultHolder, groupKeyArray[i],
stringValues[i]);
+ }
+ });
+ break;
default:
throw new IllegalStateException("Illegal data type for MODE
aggregation function: " + storedType);
}
@@ -379,7 +455,7 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
}
// For non-dictionary-encoded expression, store values into the value map
- DataType storedType = blockValSet.getValueType().getStoredType();
+ DataType storedType = getStoredType(blockValSet.getValueType());
switch (storedType) {
case INT:
int[] intValues = blockValSet.getIntValuesSV();
@@ -421,24 +497,33 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
}
});
break;
+ case STRING:
+ String[] stringValues = blockValSet.getStringValuesSV();
+ forEachNotNull(length, blockValSet, (from, to) -> {
+ for (int i = from; i < to; i++) {
+ for (int groupKey : groupKeysArray[i]) {
+ setValueForGroupKeys(groupByResultHolder, groupKey,
stringValues[i]);
+ }
+ }
+ });
+ break;
default:
throw new IllegalStateException("Illegal data type for MODE
aggregation function: " + storedType);
}
}
@Override
- public Map<? extends Number, Long>
extractAggregationResult(AggregationResultHolder aggregationResultHolder) {
+ public Map<?, Long> extractAggregationResult(AggregationResultHolder
aggregationResultHolder) {
return extractIntermediateResult(aggregationResultHolder.getResult());
}
@Override
- public Map<? extends Number, Long> extractGroupByResult(GroupByResultHolder
groupByResultHolder, int groupKey) {
+ public Map<?, Long> extractGroupByResult(GroupByResultHolder
groupByResultHolder, int groupKey) {
return extractIntermediateResult(groupByResultHolder.getResult(groupKey));
}
@Override
- public Map<? extends Number, Long> merge(Map<? extends Number, Long>
intermediateResult1,
- Map<? extends Number, Long> intermediateResult2) {
+ public Map<?, Long> merge(Map<?, Long> intermediateResult1, Map<?, Long>
intermediateResult2) {
if (intermediateResult1.isEmpty()) {
return intermediateResult2;
}
@@ -460,6 +545,10 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
&& intermediateResult2 instanceof Double2LongOpenHashMap) {
((Double2LongOpenHashMap)
intermediateResult2).double2LongEntrySet().fastForEach(
e -> ((Double2LongOpenHashMap)
intermediateResult1).merge(e.getDoubleKey(), e.getLongValue(), Long::sum));
+ } else if (intermediateResult1 instanceof Object2LongOpenHashMap
+ && intermediateResult2 instanceof Object2LongOpenHashMap) {
+ ((Object2LongOpenHashMap<String>)
intermediateResult2).object2LongEntrySet().fastForEach(
+ e -> ((Object2LongOpenHashMap<String>)
intermediateResult1).addTo(e.getKey(), e.getLongValue()));
} else {
throw new IllegalStateException(
"Illegal data type for Intermediate Result of MODE aggregation
function: " + intermediateResult1.getClass()
@@ -474,7 +563,7 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
}
@Override
- public SerializedIntermediateResult serializeIntermediateResult(Map<?
extends Number, Long> longMap) {
+ public SerializedIntermediateResult serializeIntermediateResult(Map<?, Long>
longMap) {
if (longMap instanceof Int2LongMap) {
return new
SerializedIntermediateResult(ObjectSerDeUtils.ObjectType.Int2LongMap.getValue(),
ObjectSerDeUtils.INT_2_LONG_MAP_SER_DE.serialize((Int2LongMap)
longMap));
@@ -487,6 +576,9 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
} else if (longMap instanceof Double2LongMap) {
return new
SerializedIntermediateResult(ObjectSerDeUtils.ObjectType.Double2LongMap.getValue(),
ObjectSerDeUtils.DOUBLE_2_LONG_MAP_SER_DE.serialize((Double2LongMap)
longMap));
+ } else if (longMap instanceof Object2LongMap) {
+ return new
SerializedIntermediateResult(ObjectSerDeUtils.ObjectType.String2LongMap.getValue(),
+
ObjectSerDeUtils.STRING_2_LONG_MAP_SER_DE.serialize((Object2LongMap<String>)
longMap));
} else {
throw new IllegalStateException(
"Illegal data type for Intermediate Result of MODE aggregation
function: " + longMap.getClass()
@@ -495,18 +587,22 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
}
@Override
- public Map<? extends Number, Long>
deserializeIntermediateResult(CustomObject customObject) {
+ public Map<?, Long> deserializeIntermediateResult(CustomObject customObject)
{
return ObjectSerDeUtils.deserialize(customObject);
}
@Override
public ColumnDataType getFinalResultColumnType() {
- return ColumnDataType.DOUBLE;
+ return _resultType;
}
@Nullable
@Override
- public Double extractFinalResult(@Nullable Map<? extends Number, Long>
intermediateResult) {
+ public Comparable<?> extractFinalResult(@Nullable Map<?, Long>
intermediateResult) {
+ if (_resultType != ColumnDataType.DOUBLE) {
+ return intermediateResult == null || intermediateResult.isEmpty() ? null
+ : extractTypedFinalResult(intermediateResult);
+ }
// A null intermediate result means nothing was aggregated, and the mode
of nothing is NULL. An empty map is a
// different thing: it is what an untouched single-stage result holder
produces, and it keeps its historical
// sentinel below so that path is not silently changed.
@@ -720,6 +816,65 @@ public class ModeAggregationFunction extends
BaseSingleInputAggregationFunction<
}
}
+ /// Returns the most frequent value read as the result type, breaking ties
with the MIN or MAX reducer.
+ private Comparable<?> extractTypedFinalResult(Map<?, Long>
intermediateResult) {
+ boolean max = _multiModeReducerType == MultiModeReducerType.MAX;
+ if (intermediateResult instanceof Int2LongOpenHashMap) {
+ ObjectIterator<Int2LongMap.Entry> iterator =
+ ((Int2LongOpenHashMap)
intermediateResult).int2LongEntrySet().fastIterator();
+ Int2LongMap.Entry first = iterator.next();
+ long maxFrequency = first.getLongValue();
+ int mode = first.getIntKey();
+ while (iterator.hasNext()) {
+ Int2LongMap.Entry next = iterator.next();
+ long frequency = next.getLongValue();
+ int value = next.getIntKey();
+ if (frequency > maxFrequency || (frequency == maxFrequency && (max ?
value > mode : value < mode))) {
+ maxFrequency = frequency;
+ mode = value;
+ }
+ }
+ return mode;
+ } else if (intermediateResult instanceof Long2LongOpenHashMap) {
+ ObjectIterator<Long2LongMap.Entry> iterator =
+ ((Long2LongOpenHashMap)
intermediateResult).long2LongEntrySet().fastIterator();
+ Long2LongMap.Entry first = iterator.next();
+ long maxFrequency = first.getLongValue();
+ long mode = first.getLongKey();
+ while (iterator.hasNext()) {
+ Long2LongMap.Entry next = iterator.next();
+ long frequency = next.getLongValue();
+ long value = next.getLongKey();
+ if (frequency > maxFrequency || (frequency == maxFrequency && (max ?
value > mode : value < mode))) {
+ maxFrequency = frequency;
+ mode = value;
+ }
+ }
+ return mode;
+ } else if (intermediateResult instanceof Object2LongOpenHashMap) {
+ ObjectIterator<Object2LongMap.Entry<String>> iterator =
+ ((Object2LongOpenHashMap<String>)
intermediateResult).object2LongEntrySet().fastIterator();
+ Object2LongMap.Entry<String> first = iterator.next();
+ long maxFrequency = first.getLongValue();
+ String mode = first.getKey();
+ while (iterator.hasNext()) {
+ Object2LongMap.Entry<String> next = iterator.next();
+ long frequency = next.getLongValue();
+ String value = next.getKey();
+ if (frequency > maxFrequency || (frequency == maxFrequency && (max ?
value.compareTo(mode) > 0
+ : value.compareTo(mode) < 0))) {
+ maxFrequency = frequency;
+ mode = value;
+ }
+ }
+ return mode;
+ } else {
+ throw new IllegalStateException(
+ "Illegal data type for Intermediate Result of MODE aggregation
function: " + intermediateResult.getClass()
+ .getSimpleName());
+ }
+ }
+
private enum MultiModeReducerType {
MIN, MAX, AVG
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizer.java
b/pinot-core/src/main/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizer.java
index e7ed844ff79..518fefc9881 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizer.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizer.java
@@ -36,6 +36,7 @@ import org.apache.pinot.spi.utils.CommonConstants;
/// Always applied:
/// - FIRST_WITH_TIME(dataColumn, timeColumn) -> FIRST_WITH_TIME(dataColumn,
timeColumn, 'dataType')
/// - LAST_WITH_TIME(dataColumn, timeColumn) -> LAST_WITH_TIME(dataColumn,
timeColumn, 'dataType')
+/// - MODE(stringColumn[, 'MIN'|'MAX']) -> MODE(stringColumn, 'MIN'|'MAX',
'STRING')
///
/// Applied only when enabled through the `autoRewriteAggregationType` query
option:
/// - MIN(stringType) -> MINSTRING
@@ -98,6 +99,10 @@ public class AggregateFunctionRewriteOptimizer implements
StatementOptimizer {
addDataTypeArgument(function, normalizedName, fieldSpec);
return;
}
+ if (normalizedName.equals(AggregationFunctionType.MODE.name()) &&
function.getOperandsSize() <= 2) {
+ addModeDataTypeArgument(function, fieldSpec);
+ return;
+ }
if (rewriteToTypedVariants) {
// The typed variants follow the stored type (e.g. TIMESTAMP as LONG),
while the data type argument above keeps
// the logical type.
@@ -134,6 +139,29 @@ public class AggregateFunctionRewriteOptimizer implements
StatementOptimizer {
functionName, fieldSpec.isSingleValueField() ? "" : "multi-value ",
fieldSpec.getDataType()));
}
+ /// Appends the default tie reducer when missing, then STRING for a STRING
or JSON column.
+ private static void addModeDataTypeArgument(Function function, FieldSpec
fieldSpec) {
+ if (!fieldSpec.isSingleValueField()) {
+ return;
+ }
+ String dataType;
+ switch (fieldSpec.getDataType()) {
+ case STRING:
+ dataType = fieldSpec.getDataType().name();
+ break;
+ case JSON:
+ // Same as the multi-stage engine, which types a JSON column as
VARCHAR.
+ dataType = FieldSpec.DataType.STRING.name();
+ break;
+ default:
+ return;
+ }
+ if (function.getOperandsSize() == 1) {
+ function.addToOperands(RequestUtils.getLiteralExpression("MIN"));
+ }
+ function.addToOperands(RequestUtils.getLiteralExpression(dataType));
+ }
+
private static void rewriteToTypedVariant(Function function, String
functionName, FieldSpec.DataType operandType) {
// Rewrite MIN(stringCol) and MAX(stringCol) to MINSTRING / MAXSTRING
// Rewrite MIN(longCol) and MAX(longCol) to MINLONG / MAXLONG
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
index 5c8143975f9..546d3ca7a8e 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/AggregationDataTableReducer.java
@@ -242,7 +242,10 @@ public class AggregationDataTableReducer implements
DataTableReducer {
int numColumns = columnDataTypes.length;
for (Object[] rewrittenRow : rows) {
for (int j = 0; j < numColumns; j++) {
- rewrittenRow[j] = columnDataTypes[j].format(rewrittenRow[j]);
+ Object value = rewrittenRow[j];
+ if (value != null) {
+ rewrittenRow[j] = columnDataTypes[j].format(value);
+ }
}
}
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/common/ObjectSerDeUtilsTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/common/ObjectSerDeUtilsTest.java
index d02250c778c..27b71ab841a 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/common/ObjectSerDeUtilsTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/common/ObjectSerDeUtilsTest.java
@@ -30,6 +30,7 @@ import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import it.unimi.dsi.fastutil.ints.IntSet;
import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap;
import it.unimi.dsi.fastutil.longs.LongArrayList;
+import it.unimi.dsi.fastutil.objects.Object2LongOpenHashMap;
import it.unimi.dsi.fastutil.objects.ObjectArrayList;
import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet;
import java.math.BigDecimal;
@@ -684,6 +685,22 @@ public class ObjectSerDeUtilsTest {
}
}
+ @Test
+ public void testString2LongMap() {
+ for (int i = 0; i < NUM_ITERATIONS; i++) {
+ int size = RANDOM.nextInt(100);
+ Object2LongOpenHashMap<String> expected = new
Object2LongOpenHashMap<>(size);
+ for (int j = 0; j < size; j++) {
+ expected.put(RandomStringUtils.secure().next(RANDOM.nextInt(5)),
RANDOM.nextLong());
+ }
+
+ byte[] bytes =
ObjectSerDeUtils.STRING_2_LONG_MAP_SER_DE.serialize(expected);
+ Object2LongOpenHashMap<String> actual =
ObjectSerDeUtils.deserialize(bytes, ObjectType.String2LongMap);
+
+ assertEquals(actual, expected, ERROR_MESSAGE);
+ }
+ }
+
@Test
public void testCpcSketch() {
for (int i = 0; i < NUM_ITERATIONS; i++) {
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunctionTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunctionTest.java
index 78a7cdcada9..9aa4d02d572 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunctionTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/ModeAggregationFunctionTest.java
@@ -19,6 +19,17 @@
package org.apache.pinot.core.query.aggregation.function;
+import it.unimi.dsi.fastutil.ints.Int2LongOpenHashMap;
+import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap;
+import it.unimi.dsi.fastutil.objects.Object2LongOpenHashMap;
+import java.nio.ByteBuffer;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import org.apache.pinot.common.CustomObject;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import
org.apache.pinot.core.query.aggregation.function.AggregationFunction.SerializedIntermediateResult;
import org.apache.pinot.queries.FluentQueryTest;
import org.apache.pinot.spi.config.table.FieldConfig;
import org.apache.pinot.spi.data.FieldSpec.DataType;
@@ -26,6 +37,10 @@ import org.apache.pinot.spi.utils.PinotDataType;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertThrows;
+
public class ModeAggregationFunctionTest extends
AbstractAggregationFunctionTest {
@@ -232,4 +247,170 @@ public class ModeAggregationFunctionTest extends
AbstractAggregationFunctionTest
.whenQuery("select 'cte1' as cte1, 'cte2' as cte2, mode(myField) as
mode from testTable group by cte1, cte2")
.thenResultIs("STRING | STRING | DOUBLE", "cte1 | cte2 | 1");
}
+
+ @DataProvider(name = "nonNumericScenarios")
+ Object[] nonNumericScenarios() {
+ long earlier = 1_600_000_000_000L;
+ long later = 1_700_000_000_000L;
+ return new Object[]{
+ new NonNumericScenario(DataType.STRING, true, "a", "b", "a", "b",
"null"),
+ new NonNumericScenario(DataType.STRING, false, "a", "b", "a", "b",
"null"),
+ new NonNumericScenario(DataType.BOOLEAN, true, "false", "true", false,
true, false),
+ new NonNumericScenario(DataType.BOOLEAN, false, "false", "true",
false, true, false),
+ new NonNumericScenario(DataType.TIMESTAMP, true,
String.valueOf(earlier), String.valueOf(later),
+ new Timestamp(earlier).toString(), new
Timestamp(later).toString(), new Timestamp(0).toString()),
+ new NonNumericScenario(DataType.TIMESTAMP, false,
String.valueOf(earlier), String.valueOf(later),
+ new Timestamp(earlier).toString(), new
Timestamp(later).toString(), new Timestamp(0).toString())
+ };
+ }
+
+ /// Inputs are the lower and the higher value; results are as the broker
returns them.
+ public class NonNumericScenario {
+ private final DataType _dataType;
+ private final boolean _dictionary;
+ private final String _low;
+ private final String _high;
+ private final Object _lowResult;
+ private final Object _highResult;
+ private final Object _defaultNullValueResult;
+
+ public NonNumericScenario(DataType dataType, boolean dictionary, String
low, String high, Object lowResult,
+ Object highResult, Object defaultNullValueResult) {
+ _dataType = dataType;
+ _dictionary = dictionary;
+ _low = low;
+ _high = high;
+ _lowResult = lowResult;
+ _highResult = highResult;
+ _defaultNullValueResult = defaultNullValueResult;
+ }
+
+ public FluentQueryTest.DeclaringTable getDeclaringTable(boolean
nullHandlingEnabled) {
+ FieldConfig.EncodingType encodingType =
+ _dictionary ? FieldConfig.EncodingType.DICTIONARY :
FieldConfig.EncodingType.RAW;
+ return givenSingleNullableFieldTable(_dataType, nullHandlingEnabled,
builder -> {
+ builder.withEncodingType(encodingType);
+
builder.withCompressionCodec(FieldConfig.CompressionCodec.PASS_THROUGH);
+ });
+ }
+
+ public String mode(String reducer) {
+ return "mode(myField, '" + reducer + "', '" + _dataType + "')";
+ }
+
+ @Override
+ public String toString() {
+ return "NonNumericScenario{" + "dt=" + _dataType + ", dict=" +
_dictionary + '}';
+ }
+ }
+
+ @Test(dataProvider = "nonNumericScenarios")
+ void nonNumericAggrWithNull(NonNumericScenario scenario) {
+ scenario.getDeclaringTable(true)
+ .onFirstInstance("myField", "null", scenario._low, scenario._high)
+ .andOnSecondInstance("myField", "null", scenario._high, "null")
+ .whenQuery("select " + scenario.mode("MIN") + " as mode from
testTable")
+ .thenResultIs(new Object[]{scenario._highResult})
+ .whenQuery("select 'cte', " + scenario.mode("MIN") + " as mode from
testTable group by 'cte'")
+ .thenResultIs(new Object[]{"cte", scenario._highResult});
+ }
+
+ @Test(dataProvider = "nonNumericScenarios")
+ void nonNumericAggrWithoutNull(NonNumericScenario scenario) {
+ // Nulls are read as the default null value, which becomes the most
frequent value
+ scenario.getDeclaringTable(false)
+ .onFirstInstance("myField", "null", scenario._low, scenario._high)
+ .andOnSecondInstance("myField", "null", scenario._high, "null")
+ .whenQuery("select " + scenario.mode("MIN") + " as mode from
testTable")
+ .thenResultIs(new Object[]{scenario._defaultNullValueResult})
+ .whenQuery("select 'cte', " + scenario.mode("MIN") + " as mode from
testTable group by 'cte'")
+ .thenResultIs(new Object[]{"cte", scenario._defaultNullValueResult});
+ }
+
+ @Test(dataProvider = "nonNumericScenarios")
+ void nonNumericAggrOnlyNulls(NonNumericScenario scenario) {
+ scenario.getDeclaringTable(true)
+ .onFirstInstance("myField", "null", "null")
+ .andOnSecondInstance("myField", "null", "null")
+ .whenQuery("select " + scenario.mode("MIN") + " as mode from
testTable")
+ .thenResultIs(new Object[]{null})
+ .whenQuery("select 'cte', " + scenario.mode("MAX") + " as mode from
testTable group by 'cte'")
+ .thenResultIs(new Object[]{"cte", null});
+ }
+
+ @Test(dataProvider = "nonNumericScenarios")
+ void nonNumericAggrTieReducers(NonNumericScenario scenario) {
+ scenario.getDeclaringTable(false)
+ .onFirstInstance("myField", scenario._high, scenario._low)
+ .andOnSecondInstance("myField", scenario._low, scenario._high)
+ .whenQuery("select " + scenario.mode("MIN") + ", " +
scenario.mode("MAX") + " from testTable")
+ .thenResultIs(new Object[]{scenario._lowResult, scenario._highResult})
+ .whenQuery("select 'cte', " + scenario.mode("MIN") + ", " +
scenario.mode("MAX")
+ + " from testTable group by 'cte'")
+ .thenResultIs(new Object[]{"cte", scenario._lowResult,
scenario._highResult});
+ }
+
+ @Test
+ void nonNumericSerializedMerge() {
+ ModeAggregationFunction stringMode = createFunction("MAX", "STRING");
+ assertEquals(stringMode.getFinalResultColumnType(), ColumnDataType.STRING);
+ Object2LongOpenHashMap<String> strings1 = new
Object2LongOpenHashMap<>(Map.of("a", 2L, "b", 1L));
+ Object2LongOpenHashMap<String> strings2 = new
Object2LongOpenHashMap<>(Map.of("b", 1L, "c", 2L));
+ Map<?, Long> mergedStrings = stringMode.merge(roundTrip(stringMode,
strings1), roundTrip(stringMode, strings2));
+ assertEquals(mergedStrings, Map.of("a", 2L, "b", 2L, "c", 2L));
+ assertEquals(stringMode.extractFinalResult(mergedStrings), "c");
+ assertEquals(createFunction("MIN",
"STRING").extractFinalResult(mergedStrings), "a");
+
+ ModeAggregationFunction booleanMode = createFunction("MIN", "BOOLEAN");
+ assertEquals(booleanMode.getFinalResultColumnType(),
ColumnDataType.BOOLEAN);
+ Int2LongOpenHashMap booleans = new Int2LongOpenHashMap(Map.of(0, 3L, 1,
3L));
+ assertEquals(booleanMode.extractFinalResult(roundTrip(booleanMode,
booleans)), 0);
+ assertEquals(createFunction("MAX",
"BOOLEAN").extractFinalResult(booleans), 1);
+
+ ModeAggregationFunction timestampMode = createFunction("MAX", "TIMESTAMP");
+ assertEquals(timestampMode.getFinalResultColumnType(),
ColumnDataType.TIMESTAMP);
+ Long2LongOpenHashMap timestamps1 = new
Long2LongOpenHashMap(Map.of(Long.MAX_VALUE - 1, 2L, 5L, 1L));
+ Long2LongOpenHashMap timestamps2 = new
Long2LongOpenHashMap(Map.of(Long.MAX_VALUE, 2L));
+ Map<?, Long> mergedTimestamps =
+ timestampMode.merge(roundTrip(timestampMode, timestamps1),
roundTrip(timestampMode, timestamps2));
+ // The two modes are equal as doubles, so they must be compared as longs
+ assertEquals(timestampMode.extractFinalResult(mergedTimestamps),
Long.MAX_VALUE);
+ assertEquals(createFunction("MIN",
"TIMESTAMP").extractFinalResult(mergedTimestamps), Long.MAX_VALUE - 1);
+ }
+
+ @Test
+ void nonNumericEmptyIntermediateResult() {
+ for (String dataType : new String[]{"STRING", "BOOLEAN", "TIMESTAMP"}) {
+ ModeAggregationFunction function = createFunction("MIN", dataType);
+ // A server without matching rows sends an empty map
+ Map<?, Long> empty =
+ roundTrip(function,
function.extractAggregationResult(function.createAggregationResultHolder()));
+ assertEquals(empty, Map.of());
+ assertNull(function.extractFinalResult(empty));
+ assertNull(function.extractFinalResult((Map<?, Long>) null));
+ }
+ ModeAggregationFunction stringMode = createFunction("MIN", "STRING");
+ Object2LongOpenHashMap<String> strings = new
Object2LongOpenHashMap<>(Map.of("a", 1L));
+ assertEquals(stringMode.merge(roundTrip(stringMode, new
Int2LongOpenHashMap()), strings), strings);
+ assertEquals(stringMode.merge(strings, new Int2LongOpenHashMap()),
strings);
+ }
+
+ @Test
+ void nonNumericRejectsUnsupportedArguments() {
+ assertThrows(IllegalArgumentException.class, () -> createFunction("AVG",
"STRING"));
+ assertThrows(IllegalArgumentException.class, () -> createFunction("MIN",
"INT"));
+ assertThrows(IllegalArgumentException.class, () -> createFunction("MIN",
"BYTES"));
+ }
+
+ private static ModeAggregationFunction createFunction(String reducer, String
dataType) {
+ return new
ModeAggregationFunction(List.of(ExpressionContext.forIdentifier("myField"),
+ ExpressionContext.forLiteral(DataType.STRING, reducer),
+ ExpressionContext.forLiteral(DataType.STRING, dataType)), false);
+ }
+
+ private static Map<?, Long> roundTrip(ModeAggregationFunction function,
Map<?, Long> intermediateResult) {
+ SerializedIntermediateResult serialized =
function.serializeIntermediateResult(intermediateResult);
+ return function.deserializeIntermediateResult(
+ new CustomObject(serialized.getType(),
ByteBuffer.wrap(serialized.getBytes())));
+ }
}
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizerTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizerTest.java
index 1051598be29..ffef0397a46 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizerTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/query/optimizer/statement/AggregateFunctionRewriteOptimizerTest.java
@@ -46,6 +46,7 @@ public class AggregateFunctionRewriteOptimizerTest {
.addSingleValueDimension("bytesCol", DataType.BYTES)
.addSingleValueDimension("uuidCol", DataType.UUID)
.addMultiValueDimension("mvCol", DataType.INT)
+ .addMultiValueDimension("mvStringCol", DataType.STRING)
.addSingleValueDimension("timeCol", DataType.LONG)
.build();
private static final String AUTO_REWRITE = "SET
autoRewriteAggregationType=true; ";
@@ -101,6 +102,30 @@ public class AggregateFunctionRewriteOptimizerTest {
+ "ORDER BY LAST_WITH_TIME(timestampCol, timeCol, 'TIMESTAMP')");
}
+ @Test
+ public void testModeInfersNonNumericDataType() {
+ assertRewrite("SELECT MODE(stringCol), MODE(jsonCol), MODE(boolCol,
'MAX'), MODE(timestampCol, 'AVG') "
+ + "FROM testTable",
+ "SELECT MODE(stringCol, 'MIN', 'STRING'), MODE(jsonCol, 'MIN',
'STRING'), MODE(boolCol, 'MAX'), "
+ + "MODE(timestampCol, 'AVG') FROM testTable");
+ assertRewrite("SELECT intCol, MODE(stringCol) FILTER (WHERE boolCol) AS m
FROM testTable GROUP BY intCol "
+ + "HAVING MODE(boolCol) = true ORDER BY MODE(timestampCol, 'MAX')",
+ "SELECT intCol, MODE(stringCol, 'MIN', 'STRING') FILTER (WHERE
boolCol) AS m FROM testTable GROUP BY intCol "
+ + "HAVING MODE(boolCol) = true ORDER BY MODE(timestampCol,
'MAX')");
+ }
+
+ @Test
+ public void testModeKeepsOtherInputs() {
+ // Numeric, BOOLEAN and TIMESTAMP MODE keep their DOUBLE results
+ assertUnchanged("SELECT MODE(intCol), MODE(longCol, 'AVG'), MODE(floatCol,
'MAX'), MODE(doubleCol) FROM testTable");
+ for (String column : new String[]{
+ "boolCol", "timestampCol", "bytesCol", "uuidCol", "mvStringCol",
"missingCol", "trim(stringCol)"
+ }) {
+ assertUnchanged("SELECT MODE(" + column + ") FROM testTable");
+ }
+ assertUnchanged("SELECT MODE(stringCol, 'MAX', 'STRING') FROM testTable");
+ }
+
@Test
public void testTypedVariantsRequireQueryOption() {
String query = "SELECT MIN(stringCol) AS m, MAX(longCol), SUM(intCol),
SUM(longCol), MIN(doubleCol) "
diff --git
a/pinot-core/src/test/java/org/apache/pinot/queries/ModeQueriesTest.java
b/pinot-core/src/test/java/org/apache/pinot/queries/ModeQueriesTest.java
index b79c9c1fbce..d6948dafe7d 100644
--- a/pinot-core/src/test/java/org/apache/pinot/queries/ModeQueriesTest.java
+++ b/pinot-core/src/test/java/org/apache/pinot/queries/ModeQueriesTest.java
@@ -21,8 +21,10 @@ package org.apache.pinot.queries;
import com.google.common.collect.Lists;
import java.io.File;
import java.io.IOException;
+import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
@@ -31,6 +33,9 @@ import java.util.Random;
import java.util.stream.Collectors;
import org.apache.commons.io.FileUtils;
import org.apache.pinot.common.response.broker.BrokerResponseNative;
+import org.apache.pinot.common.response.broker.ResultTable;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
import org.apache.pinot.common.utils.HashUtil;
import org.apache.pinot.core.operator.blocks.results.AggregationResultsBlock;
import org.apache.pinot.core.operator.blocks.results.GroupByResultsBlock;
@@ -49,6 +54,7 @@ import org.apache.pinot.spi.config.table.TableType;
import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.exception.BadQueryRequestException;
import org.apache.pinot.spi.utils.ReadMode;
import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
import org.testng.annotations.AfterClass;
@@ -57,8 +63,10 @@ import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
/// Queries test for MODE queries.
@@ -80,23 +88,44 @@ public class ModeQueriesTest extends BaseQueriesTest {
private static final String LONG_NO_DICT_COLUMN = "longNoDictColumn";
private static final String FLOAT_NO_DICT_COLUMN = "floatNoDictColumn";
private static final String DOUBLE_NO_DICT_COLUMN = "doubleNoDictColumn";
+ private static final String STRING_COLUMN = "stringColumn";
+ private static final String STRING_NO_DICT_COLUMN = "stringNoDictColumn";
+ private static final String JSON_COLUMN = "jsonColumn";
+ private static final String BOOLEAN_COLUMN = "booleanColumn";
+ private static final String BOOLEAN_NO_DICT_COLUMN = "booleanNoDictColumn";
+ private static final String TIMESTAMP_COLUMN = "timestampColumn";
+ private static final String TIMESTAMP_NO_DICT_COLUMN =
"timestampNoDictColumn";
private static final Schema SCHEMA = new
Schema.SchemaBuilder().addSingleValueDimension(INT_COLUMN, DataType.INT)
.addMultiValueDimension(INT_MV_COLUMN,
DataType.INT).addSingleValueDimension(INT_NO_DICT_COLUMN, DataType.INT)
.addSingleValueDimension(LONG_COLUMN,
DataType.LONG).addSingleValueDimension(LONG_NO_DICT_COLUMN, DataType.LONG)
.addSingleValueDimension(FLOAT_COLUMN, DataType.FLOAT)
.addSingleValueDimension(FLOAT_NO_DICT_COLUMN, DataType.FLOAT)
.addSingleValueDimension(DOUBLE_COLUMN, DataType.DOUBLE)
- .addSingleValueDimension(DOUBLE_NO_DICT_COLUMN, DataType.DOUBLE).build();
+ .addSingleValueDimension(DOUBLE_NO_DICT_COLUMN, DataType.DOUBLE)
+ .addSingleValueDimension(STRING_COLUMN, DataType.STRING)
+ .addSingleValueDimension(STRING_NO_DICT_COLUMN, DataType.STRING)
+ .addSingleValueDimension(JSON_COLUMN, DataType.JSON)
+ .addSingleValueDimension(BOOLEAN_COLUMN, DataType.BOOLEAN)
+ .addSingleValueDimension(BOOLEAN_NO_DICT_COLUMN, DataType.BOOLEAN)
+ .addSingleValueDimension(TIMESTAMP_COLUMN, DataType.TIMESTAMP)
+ .addSingleValueDimension(TIMESTAMP_NO_DICT_COLUMN,
DataType.TIMESTAMP).build();
private static final TableConfig TABLE_CONFIG = new
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME)
.setNoDictionaryColumns(
- Lists.newArrayList(INT_NO_DICT_COLUMN, LONG_NO_DICT_COLUMN,
FLOAT_NO_DICT_COLUMN, DOUBLE_NO_DICT_COLUMN))
+ Lists.newArrayList(INT_NO_DICT_COLUMN, LONG_NO_DICT_COLUMN,
FLOAT_NO_DICT_COLUMN, DOUBLE_NO_DICT_COLUMN,
+ STRING_NO_DICT_COLUMN, BOOLEAN_NO_DICT_COLUMN,
TIMESTAMP_NO_DICT_COLUMN))
.build();
private static final double DELTA = 0.00001;
+ private static final long TIMESTAMP_BASE = 1_600_000_000_000L;
private HashMap<Integer, Long> _values;
private Double _expectedResultMin;
private Double _expectedResultMax;
private Double _expectedResultAvg;
+ private String _expectedStringMin;
+ private String _expectedStringMax;
+ private boolean _expectedBoolean;
+ private String _expectedTimestampMin;
+ private String _expectedTimestampMax;
private IndexSegment _indexSegment;
private List<IndexSegment> _indexSegments;
@@ -136,6 +165,13 @@ public class ModeQueriesTest extends BaseQueriesTest {
record.putValue(FLOAT_NO_DICT_COLUMN, (float) value);
record.putValue(DOUBLE_COLUMN, (double) value);
record.putValue(DOUBLE_NO_DICT_COLUMN, (double) value);
+ record.putValue(STRING_COLUMN, String.valueOf(value));
+ record.putValue(STRING_NO_DICT_COLUMN, String.valueOf(value));
+ record.putValue(JSON_COLUMN, String.valueOf(value));
+ record.putValue(BOOLEAN_COLUMN, value % 3 == 0);
+ record.putValue(BOOLEAN_NO_DICT_COLUMN, value % 3 == 0);
+ record.putValue(TIMESTAMP_COLUMN, TIMESTAMP_BASE + value);
+ record.putValue(TIMESTAMP_NO_DICT_COLUMN, TIMESTAMP_BASE + value);
records.add(record);
}
long maxOccurrences = _values.values().stream().max(Long::compareTo).get();
@@ -144,6 +180,15 @@ public class ModeQueriesTest extends BaseQueriesTest {
_expectedResultMin = Arrays.stream(modes).min().getAsDouble();
_expectedResultMax = Arrays.stream(modes).max().getAsDouble();
_expectedResultAvg = Arrays.stream(modes).average().getAsDouble();
+ // Ties between string modes are broken in string order, e.g. "10" < "9"
+ List<String> stringModes = Arrays.stream(modes).mapToObj(mode ->
String.valueOf((int) mode)).toList();
+ _expectedStringMin =
stringModes.stream().min(Comparator.naturalOrder()).get();
+ _expectedStringMax =
stringModes.stream().max(Comparator.naturalOrder()).get();
+ long numTrueValues = _values.entrySet().stream().filter(e -> e.getKey() %
3 == 0).mapToLong(Map.Entry::getValue)
+ .sum();
+ _expectedBoolean = 2 * numTrueValues > NUM_RECORDS;
+ _expectedTimestampMin = new Timestamp(TIMESTAMP_BASE +
_expectedResultMin.longValue()).toString();
+ _expectedTimestampMax = new Timestamp(TIMESTAMP_BASE +
_expectedResultMax.longValue()).toString();
SegmentGeneratorConfig segmentGeneratorConfig = new
SegmentGeneratorConfig(TABLE_CONFIG, SCHEMA);
segmentGeneratorConfig.setTableName(RAW_TABLE_NAME);
@@ -371,6 +416,167 @@ public class ModeQueriesTest extends BaseQueriesTest {
return entries.toArray(new Object[0][]);
}
+ @Test(dataProvider = "testNonNumericAggregationOnlyDataProvider")
+ public void testNonNumericAggregationOnly(String query, Object[]
expectedRow) {
+ // STRING is inferred; BOOLEAN and TIMESTAMP use the explicit typed form
+ ResultTable resultTable = getBrokerResponseForOptimizedQuery(query,
SCHEMA).getResultTable();
+ assertEquals(resultTable.getDataSchema(), new DataSchema(new String[]{
+ "mode(stringColumn)", "mode(stringNoDictColumn)",
"mode(booleanColumn)", "mode(booleanNoDictColumn)",
+ "mode(timestampColumn)", "mode(timestampNoDictColumn)",
"mode(jsonColumn)"
+ }, new ColumnDataType[]{
+ ColumnDataType.STRING, ColumnDataType.STRING, ColumnDataType.BOOLEAN,
ColumnDataType.BOOLEAN,
+ ColumnDataType.TIMESTAMP, ColumnDataType.TIMESTAMP,
ColumnDataType.STRING
+ }));
+ assertEquals(resultTable.getRows().size(), 1);
+ assertEquals(resultTable.getRows().get(0), expectedRow);
+ }
+
+ @DataProvider
+ public Object[][] testNonNumericAggregationOnlyDataProvider() {
+ Object[] expectedMinRow = new Object[]{
+ _expectedStringMin, _expectedStringMin, _expectedBoolean,
_expectedBoolean, _expectedTimestampMin,
+ _expectedTimestampMin, _expectedStringMin
+ };
+ Object[] expectedMaxRow = new Object[]{
+ _expectedStringMax, _expectedStringMax, _expectedBoolean,
_expectedBoolean, _expectedTimestampMax,
+ _expectedTimestampMax, _expectedStringMax
+ };
+ return new Object[][]{
+ {
+ "SELECT MODE(stringColumn), MODE(stringNoDictColumn),
MODE(booleanColumn, 'MIN', 'BOOLEAN'), "
+ + "MODE(booleanNoDictColumn, 'MIN', 'BOOLEAN'),
MODE(timestampColumn, 'MIN', 'TIMESTAMP'), "
+ + "MODE(timestampNoDictColumn, 'MIN', 'TIMESTAMP'),
MODE(jsonColumn) FROM testTable", expectedMinRow
+ }, {
+ "SELECT MODE(stringColumn, 'MIN'), MODE(stringNoDictColumn,
'MIN'), MODE(booleanColumn, 'MIN', 'BOOLEAN'), "
+ + "MODE(booleanNoDictColumn, 'MIN', 'BOOLEAN'),
MODE(timestampColumn, 'MIN', 'TIMESTAMP'), "
+ + "MODE(timestampNoDictColumn, 'MIN', 'TIMESTAMP'), "
+ + "MODE(jsonColumn, 'MIN') FROM testTable", expectedMinRow
+ }, {
+ "SELECT MODE(stringColumn, 'MAX'), MODE(stringNoDictColumn,
'MAX'), MODE(booleanColumn, 'MAX', 'BOOLEAN'), "
+ + "MODE(booleanNoDictColumn, 'MAX', 'BOOLEAN'),
MODE(timestampColumn, 'MAX', 'TIMESTAMP'), "
+ + "MODE(timestampNoDictColumn, 'MAX', 'TIMESTAMP'), "
+ + "MODE(jsonColumn, 'MAX') FROM testTable", expectedMaxRow
+ }
+ };
+ }
+
+ @Test(dataProvider = "testNonNumericAggregationGroupByDataProvider")
+ public void testNonNumericAggregationGroupBy(String query) {
+ ResultTable resultTable = getBrokerResponseForOptimizedQuery(query,
SCHEMA).getResultTable();
+ assertEquals(resultTable.getDataSchema().getColumnDataTypes(), new
ColumnDataType[]{
+ ColumnDataType.INT, ColumnDataType.STRING, ColumnDataType.STRING,
ColumnDataType.BOOLEAN,
+ ColumnDataType.BOOLEAN, ColumnDataType.TIMESTAMP,
ColumnDataType.TIMESTAMP
+ });
+ List<Object[]> rows = resultTable.getRows();
+ assertEquals(rows.size(), _values.size());
+ String previous = "";
+ for (Object[] row : rows) {
+ // Each group has a single value per column
+ int key = (Integer) row[0];
+ String timestamp = new Timestamp(TIMESTAMP_BASE + key).toString();
+ assertEquals(row, new Object[]{
+ key, String.valueOf(key), String.valueOf(key), key % 3 == 0, key % 3
== 0, timestamp, timestamp
+ });
+ // ORDER BY repeats the call, so it must be rewritten the same way as
the SELECT list
+ assertTrue(((String) row[1]).compareTo(previous) > 0);
+ previous = (String) row[1];
+ }
+ }
+
+ @DataProvider
+ public Object[][] testNonNumericAggregationGroupByDataProvider() {
+ return new Object[][]{
+ {
+ "SELECT intColumn, MODE(stringColumn), MODE(stringNoDictColumn),
MODE(booleanColumn, 'MIN', 'BOOLEAN'), "
+ + "MODE(booleanNoDictColumn, 'MAX', 'BOOLEAN'),
MODE(timestampColumn, 'MIN', 'TIMESTAMP'), "
+ + "MODE(timestampNoDictColumn, 'MAX', 'TIMESTAMP') "
+ + "FROM testTable GROUP BY intColumn ORDER BY
MODE(stringColumn) LIMIT 1000"
+ }, {
+ "SELECT intMVColumn, MODE(stringColumn), MODE(stringNoDictColumn),
MODE(booleanColumn, 'MIN', 'BOOLEAN'), "
+ + "MODE(booleanNoDictColumn, 'MAX', 'BOOLEAN'),
MODE(timestampColumn, 'MIN', 'TIMESTAMP'), "
+ + "MODE(timestampNoDictColumn, 'MAX', 'TIMESTAMP') "
+ + "FROM testTable GROUP BY intMVColumn ORDER BY
MODE(stringNoDictColumn) LIMIT 1000"
+ }
+ };
+ }
+
+ @Test
+ public void testBooleanTimestampKeepLegacySemantics() {
+ String query = "SELECT intColumn, MODE(booleanColumn),
MODE(timestampColumn, 'AVG'), "
+ + "MODE(timestampColumn) + 1, CAST(MODE(booleanColumn) AS STRING) FROM
testTable "
+ + "GROUP BY intColumn HAVING MODE(booleanColumn) = 1.0 ORDER BY
intColumn LIMIT 1000";
+ ResultTable table = getBrokerResponseForOptimizedQuery(query,
SCHEMA).getResultTable();
+ assertEquals(table.getDataSchema().getColumnDataTypes(), new
ColumnDataType[]{
+ ColumnDataType.INT, ColumnDataType.DOUBLE, ColumnDataType.DOUBLE,
ColumnDataType.DOUBLE, ColumnDataType.OBJECT
+ });
+ List<Integer> expectedKeys = _values.keySet().stream().filter(key -> key %
3 == 0).sorted().toList();
+ assertEquals(table.getRows().size(), expectedKeys.size());
+ for (int i = 0; i < expectedKeys.size(); i++) {
+ int key = expectedKeys.get(i);
+ assertEquals(table.getRows().get(i), new Object[]{
+ key, 1.0, (double) (TIMESTAMP_BASE + key), (double) (TIMESTAMP_BASE
+ key + 1), "1.0"
+ });
+ }
+ }
+
+ @Test
+ public void testNonNumericEmptyInput() {
+ String query = "SELECT MODE(stringColumn), MODE(stringNoDictColumn),
MODE(booleanColumn, 'MIN', 'BOOLEAN'), "
+ + "MODE(timestampNoDictColumn, 'MIN', 'TIMESTAMP') FROM testTable
WHERE intColumn < 0";
+ List<Object[]> rows = getBrokerResponseForOptimizedQuery(query,
SCHEMA).getResultTable().getRows();
+ assertEquals(rows.size(), 1);
+ assertEquals(rows.get(0), new Object[]{null, null, null, null});
+ }
+
+ @Test
+ public void testNonNumericRejectsAvg() {
+ for (String column : new String[]{STRING_COLUMN, BOOLEAN_COLUMN,
TIMESTAMP_COLUMN}) {
+ String dataType = SCHEMA.getFieldSpecFor(column).getDataType().name();
+ String query = "SELECT MODE(" + column + ", 'AVG', '" + dataType + "')
FROM testTable";
+ BadQueryRequestException exception =
+ expectThrows(BadQueryRequestException.class, () ->
getBrokerResponseForOptimizedQuery(query, SCHEMA));
+ assertTrue(exception.getMessage().contains("Unsupported MODE reducer
for"), exception.getMessage());
+ }
+ }
+
+ @Test
+ public void testNonNumericRejectsMismatchedDataType() {
+ for (String query : new String[]{
+ "SELECT MODE(intColumn, 'MIN', 'STRING') FROM testTable",
+ "SELECT MODE(longNoDictColumn, 'MIN', 'BOOLEAN') FROM testTable",
+ "SELECT MODE(stringNoDictColumn, 'MIN', 'TIMESTAMP') FROM testTable"
+ }) {
+ BrokerResponseNative brokerResponse = getBrokerResponse(query);
+ assertFalse(brokerResponse.getExceptions().isEmpty(), query);
+ String message = brokerResponse.getExceptions().get(0).getMessage();
+ assertTrue(message.contains("Illegal data type for MODE aggregation
function"), message);
+ }
+ }
+
+ @Test
+ public void testNonNumericOrderByEmptyGroup() {
+ // With null handling disabled, MODE of an empty group is null, which
sorts last for ASC and first for DESC
+ for (boolean asc : new boolean[]{true, false}) {
+ String query = "SELECT intColumn, MODE(booleanColumn, 'MIN', 'BOOLEAN')
FILTER (WHERE intColumn < 5) "
+ + "FROM testTable GROUP BY intColumn "
+ + "ORDER BY MODE(booleanColumn, 'MIN', 'BOOLEAN') FILTER (WHERE
intColumn < 5) " + (asc ? "ASC" : "DESC")
+ + ", intColumn LIMIT 3";
+ Comparator<Boolean> modeOrder = asc ?
Comparator.nullsLast(Comparator.<Boolean>naturalOrder())
+ : Comparator.nullsFirst(Comparator.<Boolean>reverseOrder());
+ List<Integer> expectedKeys = _values.keySet().stream()
+ .sorted(Comparator.comparing((Integer key) -> key < 5 ? key % 3 == 0
: null, modeOrder)
+ .thenComparing(Comparator.naturalOrder()))
+ .limit(3)
+ .toList();
+ List<Object[]> rows = getBrokerResponseForOptimizedQuery(query,
SCHEMA).getResultTable().getRows();
+ assertEquals(rows.size(), 3, query);
+ for (int i = 0; i < 3; i++) {
+ int key = expectedKeys.get(i);
+ assertEquals(rows.get(i), new Object[]{key, key < 5 ? (Object) (key %
3 == 0) : null}, query);
+ }
+ }
+ }
+
@AfterClass
public void tearDown()
throws IOException {
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateTypeArgumentRewriteRule.java
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateTypeArgumentRewriteRule.java
index aa739af7230..a2b2c2cccd4 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateTypeArgumentRewriteRule.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/rel/rules/PinotAggregateTypeArgumentRewriteRule.java
@@ -43,9 +43,10 @@ import org.apache.pinot.segment.spi.AggregationFunctionType;
/// Supplies the explicit data type argument from the type of the data
argument:
/// - FIRST_WITH_TIME(dataColumn, timeColumn) -> FIRST_WITH_TIME(dataColumn,
timeColumn, 'dataType')
/// - LAST_WITH_TIME(dataColumn, timeColumn) -> LAST_WITH_TIME(dataColumn,
timeColumn, 'dataType')
+/// - MODE(stringColumn) -> MODE(stringColumn, 'MIN', 'STRING')
///
-/// The result type of the call is unchanged. The literal is projected
directly beneath the aggregate, appended to the
-/// existing project when there is one, because
[PinotAggregateExchangeNodeInsertRule] and `AggregatePushdownRule`
+/// The result type of the call is unchanged. The literals are projected
directly beneath the aggregate, appended to
+/// the existing project when there is one, because
[PinotAggregateExchangeNodeInsertRule] and `AggregatePushdownRule`
/// inline literal arguments only from the immediate project.
///
/// The servers require the data type argument, so the rule is required rather
than an optimization: `QueryEnvironment`
@@ -65,7 +66,7 @@ public class PinotAggregateTypeArgumentRewriteRule extends
RelOptRule {
// Requiring a call without its data type is also what makes the rule
terminate.
Aggregate aggRel = call.rel(0);
RelNode input = aggRel.getInput();
- return aggRel.getAggCallList().stream().anyMatch(aggCall ->
getDataTypeArgument(aggCall, input) != null);
+ return aggRel.getAggCallList().stream().anyMatch(aggCall ->
getLiteralArguments(aggCall, input) != null);
}
@Override
@@ -96,18 +97,20 @@ public class PinotAggregateTypeArgumentRewriteRule extends
RelOptRule {
// Appending keeps the existing field indexes, so the group set and the
other calls are unaffected.
List<AggregateCall> orgAggCalls = aggRel.getAggCallList();
List<AggregateCall> aggCalls = new ArrayList<>(orgAggCalls.size());
- Map<String, Integer> literalIndexByDataType = new HashMap<>();
+ Map<String, Integer> literalIndexes = new HashMap<>();
for (AggregateCall aggCall : orgAggCalls) {
- String dataType = getDataTypeArgument(aggCall, input);
- if (dataType == null) {
+ List<String> literalArguments = getLiteralArguments(aggCall, input);
+ if (literalArguments == null) {
aggCalls.add(aggCall);
continue;
}
List<Integer> argList = new ArrayList<>(aggCall.getArgList());
- argList.add(literalIndexByDataType.computeIfAbsent(dataType, type -> {
- projects.add(rexBuilder.makeLiteral(type));
- return projects.size() - 1;
- }));
+ for (String literalArgument : literalArguments) {
+ argList.add(literalIndexes.computeIfAbsent(literalArgument, literal ->
{
+ projects.add(rexBuilder.makeLiteral(literal));
+ return projects.size() - 1;
+ }));
+ }
// Keeps the original result type, so the rewrite does not change the
result schema.
aggCalls.add(aggCall.withArgList(argList));
}
@@ -116,20 +119,30 @@ public class PinotAggregateTypeArgumentRewriteRule
extends RelOptRule {
aggRel.copy(aggRel.getTraitSet(), newInput, aggRel.getGroupSet(),
aggRel.getGroupSets(), aggCalls));
}
- /// Returns the data type argument to append, or `null` when the call
already has one or is not such a call. Throws
- /// for a data argument without an explicit form, such as an array from a
multi-value column.
+ /// Returns the literal arguments to append, or `null` when the call already
has them or is not such a call.
@Nullable
- private static String getDataTypeArgument(AggregateCall aggCall, RelNode
input) {
- if (aggCall.getArgList().size() != 2) {
- return null;
- }
+ private static List<String> getLiteralArguments(AggregateCall aggCall,
RelNode input) {
+ List<Integer> argList = aggCall.getArgList();
String name =
AggregationFunctionType.getNormalizedAggregationFunctionName(aggCall.getAggregation().getName());
- if (!name.equals(AggregationFunctionType.FIRSTWITHTIME.name())
- && !name.equals(AggregationFunctionType.LASTWITHTIME.name())) {
- return null;
+ if (name.equals(AggregationFunctionType.MODE.name())) {
+ // BOOLEAN and TIMESTAMP already return their own type, cast from the
DOUBLE result.
+ return argList.size() == 1 &&
SqlTypeName.CHAR_TYPES.contains(getSqlTypeName(input, argList.get(0)))
+ ? List.of("MIN", "STRING") : null;
}
- SqlTypeName dataType =
-
input.getRowType().getFieldList().get(aggCall.getArgList().get(0)).getType().getSqlTypeName();
+ if (argList.size() == 2 &&
(name.equals(AggregationFunctionType.FIRSTWITHTIME.name())
+ || name.equals(AggregationFunctionType.LASTWITHTIME.name()))) {
+ return List.of(getDataTypeArgument(name, getSqlTypeName(input,
argList.get(0))));
+ }
+ return null;
+ }
+
+ private static SqlTypeName getSqlTypeName(RelNode input, int fieldIndex) {
+ return
input.getRowType().getFieldList().get(fieldIndex).getType().getSqlTypeName();
+ }
+
+ /// Returns the data type argument of FIRST_WITH_TIME / LAST_WITH_TIME.
Throws for a data argument without an
+ /// explicit form, such as an array from a multi-value column.
+ private static String getDataTypeArgument(String name, SqlTypeName dataType)
{
switch (dataType) {
case TINYINT:
case SMALLINT:
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
index 31531c92139..da842624029 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
@@ -605,7 +605,8 @@ public class QueryEnvironment {
for (RelOptRule relOptRule : basicRules) {
hepProgramBuilder.addRuleInstance(relOptRule);
}
- // Required to plan FIRST_WITH_TIME / LAST_WITH_TIME without a data type,
so the rule options do not filter it.
+ // Required to plan FIRST_WITH_TIME / LAST_WITH_TIME without a data type
and MODE on strings, so the rule options
+ // do not filter it.
hepProgramBuilder.addRuleInstance(PinotAggregateTypeArgumentRewriteRule.INSTANCE);
// ----
diff --git a/pinot-query-planner/src/test/resources/queries/AggregatePlans.json
b/pinot-query-planner/src/test/resources/queries/AggregatePlans.json
index f1dbfd4813e..3be6c9e0d64 100644
--- a/pinot-query-planner/src/test/resources/queries/AggregatePlans.json
+++ b/pinot-query-planner/src/test/resources/queries/AggregatePlans.json
@@ -197,6 +197,32 @@
"\n"
]
},
+ {
+ "description": "MODE on a STRING argument gets the tie reducer and
data type; numeric, BOOLEAN and TIMESTAMP MODE are unchanged",
+ "sql": "EXPLAIN PLAN FOR SELECT MODE(a.col1), MODE(a.col3),
MODE(a.col5), MODE(a.ts_timestamp), MODE(UPPER(a.col2)) FROM a",
+ "output": [
+ "Execution Plan",
+ "\nPinotLogicalAggregate(group=[{}], agg#0=[MODE($0, _UTF-8'MIN',
_UTF-8'STRING')], agg#1=[MODE($1)], agg#2=[MODE($2)], agg#3=[MODE($3)],
agg#4=[MODE($4, _UTF-8'MIN', _UTF-8'STRING')], aggType=[FINAL])",
+ "\n PinotLogicalExchange(distribution=[hash])",
+ "\n PinotLogicalAggregate(group=[{}], agg#0=[MODE($0,
_UTF-8'MIN', _UTF-8'STRING')], agg#1=[MODE($1)], agg#2=[MODE($2)],
agg#3=[MODE($3)], agg#4=[MODE($4, _UTF-8'MIN', _UTF-8'STRING')],
aggType=[LEAF])",
+ "\n LogicalProject($f0=[$0], $f1=[$2], $f2=[$4], $f3=[$8],
$f4=[UPPER($1)], $f5=[_UTF-8'MIN'], $f6=[_UTF-8'STRING'])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n"
+ ]
+ },
+ {
+ "description": "MODE and FIRST_WITH_TIME share the projected data type
literal in a grouped aggregate",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col3, MODE(a.col2),
FIRST_WITH_TIME(a.col1, a.ts) FROM a GROUP BY a.col3",
+ "output": [
+ "Execution Plan",
+ "\nPinotLogicalAggregate(group=[{0}], agg#0=[MODE($1, _UTF-8'MIN',
_UTF-8'STRING')], agg#1=[FIRSTWITHTIME($2, $2, _UTF-8'STRING')],
aggType=[FINAL])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n PinotLogicalAggregate(group=[{0}], agg#0=[MODE($1,
_UTF-8'MIN', _UTF-8'STRING')], agg#1=[FIRSTWITHTIME($2, $3, _UTF-8'STRING')],
aggType=[LEAF])",
+ "\n LogicalProject($f0=[$2], $f1=[$1], $f2=[$0], $f3=[$7],
$f4=[_UTF-8'MIN'], $f5=[_UTF-8'STRING'])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n"
+ ]
+ },
{
"description": "MIN on STRING column rewritten to MINSTRING",
"sql": "SET autoRewriteAggregationType=true; EXPLAIN PLAN FOR SELECT
MIN(a.col1) FROM a",
diff --git a/pinot-query-runtime/src/test/resources/queries/UDFAggregates.json
b/pinot-query-runtime/src/test/resources/queries/UDFAggregates.json
index ca555e963d8..816e9e2b637 100644
--- a/pinot-query-runtime/src/test/resources/queries/UDFAggregates.json
+++ b/pinot-query-runtime/src/test/resources/queries/UDFAggregates.json
@@ -23,6 +23,25 @@
[150, 150, 1.5, 1.5, "c", false, "1E+317", "2020-02-03 03:32:12",
"DEADBE01", "00000000-0000-0000-0000-000000000006"],
[175, 175, 1.75, 1.75, "c", true, "1E+328", "2020-01-02 03:32:12",
"DEADBE02", "00000000-0000-0000-0000-000000000007"]
]
+ },
+ "partitioned_tbl": {
+ "schema": [
+ {"name": "num", "type": "INT"},
+ {"name": "name", "type": "STRING"},
+ {"name": "flag", "type": "BOOLEAN"},
+ {"name": "event_ts", "type": "TIMESTAMP"}
+ ],
+ "inputs": [
+ [1, "a", true, "2020-01-01 00:00:00"],
+ [3, "c", false, "2020-01-02 00:00:00"],
+ [3, "yyy", true, "2020-01-03 00:00:00"],
+ [4, "e", true, "2020-01-04 00:00:00"],
+ [4, "e", false, "2020-01-04 00:00:00"],
+ [4, "f", false, "2020-01-05 00:00:00"],
+ [7, "f", true, "2020-01-07 00:00:00"],
+ [7, "d", true, "2020-01-06 00:00:00"]
+ ],
+ "partitionColumns": ["num"]
}
},
"queries": [
@@ -108,6 +127,28 @@
"sql": "SELECT /*+ aggOptions(is_skip_leaf_stage_aggregate='true') */
string_col, FIRST_WITH_TIME(decimal_col, timestamp_col),
LAST_WITH_TIME(decimal_col, timestamp_col), FIRST_WITH_TIME(bytes_col,
timestamp_col), LAST_WITH_TIME(bytes_col, timestamp_col) FROM {tbl} GROUP BY
string_col",
"outputs": [["a", "1E-307", "1E+307", "deadbeef", "fdeadbee"], ["b",
"1E-308", "1E+308", "efdeadbe", "beefdead"], ["c", "1E+328", "1E-317",
"deadbe02", "adbeefde"]]
},
+ {
+ "comment": "MODE returns the type of a non-numeric argument, with ties
broken by the smallest value",
+ "sql": "SELECT MODE(string_col), MODE(UPPER(string_col)),
MODE(bool_col), MODE(timestamp_col), MODE(int_col) FROM {tbl}",
+ "outputs": [["c", "C", false, "2020-01-01 03:10:12.0", 2]]
+ },
+ {
+ "sql": "SELECT bool_col, MODE(string_col), MODE(timestamp_col) FROM
{tbl} GROUP BY bool_col",
+ "outputs": [[false, "b", "2020-01-01 08:32:12.0"], [true, "a",
"2020-01-01 03:10:12.0"]]
+ },
+ {
+ "sql": "SELECT /*+ aggOptions(is_skip_leaf_stage_group_by='true') */
int_col, MODE(string_col), MODE(bool_col), MODE(timestamp_col) FROM {tbl} GROUP
BY int_col",
+ "outputs": [[2, "a", true, "2020-01-01 03:10:12.0"], [3, "b", false,
"2020-01-01 08:32:12.0"], [100, "b", false, "2020-02-06 03:32:12.0"], [101,
"c", false, "2020-05-01 03:32:12.0"], [150, "c", false, "2020-02-03
03:32:12.0"], [175, "c", true, "2020-01-02 03:32:12.0"]]
+ },
+ {
+ "sql": "SELECT MODE(string_col) FROM {tbl} WHERE int_col < 0",
+ "outputs": [[null]]
+ },
+ {
+ "comment": "The leaf returns the final MODE, which keeps the input
type",
+ "sql": "SELECT /*+ aggOptions(is_leaf_return_final_result='true') */
{partitioned_tbl}.num, MODE({partitioned_tbl}.name),
MODE({partitioned_tbl}.flag), MODE({partitioned_tbl}.event_ts) FROM
{partitioned_tbl} /*+ tableOptions(partition_function='hashcode',
partition_key='num', partition_size='4') */ GROUP BY {partitioned_tbl}.num",
+ "outputs": [[1, "a", true, "2020-01-01 00:00:00.0"], [3, "c", false,
"2020-01-02 00:00:00.0"], [4, "e", false, "2020-01-04 00:00:00.0"], [7, "d",
true, "2020-01-06 00:00:00.0"]]
+ },
{
"sql": "SELECT SUMPRECISION(decimal_col) FROM {tbl}",
"outputs":
[["10000000000100000000110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000.00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
[...]
diff --git
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/AggregationFunctionType.java
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/AggregationFunctionType.java
index 7534ec713f6..06907a7b730 100644
---
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/AggregationFunctionType.java
+++
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/AggregationFunctionType.java
@@ -63,7 +63,7 @@ public enum AggregationFunctionType {
SUMLONG("sumLong", ReturnTypes.AGG_SUM,
OperandTypes.or(OperandTypes.INTEGER, OperandTypes.ARRAY_OF_INTEGER)),
SUMPRECISION("sumPrecision", ReturnTypes.explicit(SqlTypeName.DECIMAL),
OperandTypes.ANY, SqlTypeName.OTHER),
AVG("avg", SqlTypeName.OTHER, SqlTypeName.DOUBLE),
- MODE("mode", SqlTypeName.OTHER, SqlTypeName.DOUBLE),
+ MODE("mode", null, null, ReturnTypes.explicit(SqlTypeName.OTHER), new
ModeFinalReturnTypeInference()),
ANYVALUE("anyValue", ReturnTypes.ARG0, OperandTypes.ANY, SqlTypeName.OTHER),
FIRSTWITHTIME("firstWithTime", ReturnTypes.ARG0, OperandTypes.or(
OperandTypes.family(SqlTypeFamily.ANY, SqlTypeFamily.ANY),
@@ -428,6 +428,16 @@ public enum AggregationFunctionType {
}
}
+ /// Returns DOUBLE, or the type of the data argument for the explicit data
type form MODE(expr, reducer, dataType).
+ /// Only a leaf stage that returns final results uses it; the query's result
type comes from Calcite's MODE (ARG0).
+ private static class ModeFinalReturnTypeInference implements
SqlReturnTypeInference {
+ @Override
+ public RelDataType inferReturnType(SqlOperatorBinding opBinding) {
+ return opBinding.getOperandCount() == 3 ?
ReturnTypes.ARG0.inferReturnType(opBinding)
+ : opBinding.getTypeFactory().createSqlType(SqlTypeName.DOUBLE);
+ }
+ }
+
private static class ArrayReturnTypeInference implements
SqlReturnTypeInference {
final SqlTypeName _sqlTypeName;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]