This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 5411d063c5 [core] Validate data types for max/min aggregate functions
(#9013)
5411d063c5 is described below
commit 5411d063c5d9b6b22c82eff3bde6e4d8839e507f
Author: jackylee <[email protected]>
AuthorDate: Fri Aug 7 13:17:21 2026 +0800
[core] Validate data types for max/min aggregate functions (#9013)
---
.../primary-key-table/merge-engine/aggregation.mdx | 4 +-
.../org/apache/paimon/utils/InternalRowUtils.java | 3 +
.../apache/paimon/utils/InternalRowUtilsTest.java | 5 +
.../aggregate/factory/FieldMaxAggFactory.java | 8 ++
.../aggregate/factory/FieldMinAggFactory.java | 8 ++
.../compact/aggregate/FieldAggregatorTest.java | 103 +++++++++++++++++++++
6 files changed, 129 insertions(+), 2 deletions(-)
diff --git a/docs/docs/primary-key-table/merge-engine/aggregation.mdx
b/docs/docs/primary-key-table/merge-engine/aggregation.mdx
index 823ae7b016..ca4a8cf8cf 100644
--- a/docs/docs/primary-key-table/merge-engine/aggregation.mdx
+++ b/docs/docs/primary-key-table/merge-engine/aggregation.mdx
@@ -85,11 +85,11 @@ Current supported aggregate functions and data types are:
### max
The max function identifies and retains the maximum value.
- It supports CHAR, VARCHAR, DECIMAL, TINYINT, SMALLINT, INTEGER, BIGINT,
FLOAT, DOUBLE, DATE, TIME, TIMESTAMP, and TIMESTAMP_LTZ data types.
+ It supports BOOLEAN, CHAR, VARCHAR, BINARY, VARBINARY, DECIMAL, TINYINT,
SMALLINT, INTEGER, BIGINT, FLOAT, DOUBLE, DATE, TIME, TIMESTAMP, and
TIMESTAMP_LTZ data types.
### min
The min function identifies and retains the minimum value.
- It supports CHAR, VARCHAR, DECIMAL, TINYINT, SMALLINT, INTEGER, BIGINT,
FLOAT, DOUBLE, DATE, TIME, TIMESTAMP, and TIMESTAMP_LTZ data types.
+ It supports BOOLEAN, CHAR, VARCHAR, BINARY, VARBINARY, DECIMAL, TINYINT,
SMALLINT, INTEGER, BIGINT, FLOAT, DOUBLE, DATE, TIME, TIMESTAMP, and
TIMESTAMP_LTZ data types.
### last_value
The last_value function replaces the previous value with the most recently
imported value.
diff --git
a/paimon-common/src/main/java/org/apache/paimon/utils/InternalRowUtils.java
b/paimon-common/src/main/java/org/apache/paimon/utils/InternalRowUtils.java
index 1ff433f3e2..4c329797f3 100644
--- a/paimon-common/src/main/java/org/apache/paimon/utils/InternalRowUtils.java
+++ b/paimon-common/src/main/java/org/apache/paimon/utils/InternalRowUtils.java
@@ -446,6 +446,9 @@ public class InternalRowUtils {
public static int compare(Object x, Object y, DataTypeRoot type) {
int ret;
switch (type) {
+ case BOOLEAN:
+ ret = Boolean.compare((boolean) x, (boolean) y);
+ break;
case DECIMAL:
Decimal xDD = (Decimal) x;
Decimal yDD = (Decimal) y;
diff --git
a/paimon-common/src/test/java/org/apache/paimon/utils/InternalRowUtilsTest.java
b/paimon-common/src/test/java/org/apache/paimon/utils/InternalRowUtilsTest.java
index c085147c41..dec1ab4ad6 100644
---
a/paimon-common/src/test/java/org/apache/paimon/utils/InternalRowUtilsTest.java
+++
b/paimon-common/src/test/java/org/apache/paimon/utils/InternalRowUtilsTest.java
@@ -120,6 +120,11 @@ public class InternalRowUtilsTest {
@Test
public void testCompare() {
+ // test BOOLEAN data type
+ assertThat(InternalRowUtils.compare(false, true,
DataTypeRoot.BOOLEAN)).isLessThan(0);
+ assertThat(InternalRowUtils.compare(true, false,
DataTypeRoot.BOOLEAN)).isGreaterThan(0);
+ assertThat(InternalRowUtils.compare(true, true,
DataTypeRoot.BOOLEAN)).isEqualTo(0);
+
// test DECIMAL data type
Decimal xDecimalData = Decimal.fromBigDecimal(new BigDecimal("12.34"),
4, 2);
Decimal yDecimalData = Decimal.fromBigDecimal(new BigDecimal("13.14"),
4, 2);
diff --git
a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/factory/FieldMaxAggFactory.java
b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/factory/FieldMaxAggFactory.java
index 4e3c33171a..22f22f371c 100644
---
a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/factory/FieldMaxAggFactory.java
+++
b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/factory/FieldMaxAggFactory.java
@@ -21,6 +21,9 @@ package org.apache.paimon.mergetree.compact.aggregate.factory;
import org.apache.paimon.CoreOptions;
import org.apache.paimon.mergetree.compact.aggregate.FieldMaxAgg;
import org.apache.paimon.types.DataType;
+import org.apache.paimon.utils.TypeCheckUtils;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
/** Factory for #{@link FieldMaxAgg}. */
public class FieldMaxAggFactory implements FieldAggregatorFactory {
@@ -29,6 +32,11 @@ public class FieldMaxAggFactory implements
FieldAggregatorFactory {
@Override
public FieldMaxAgg create(DataType fieldType, CoreOptions options, String
field) {
+ checkArgument(
+ TypeCheckUtils.isComparable(fieldType),
+ "Data type for max column '%s' must be comparable but was
'%s'.",
+ field,
+ fieldType);
return new FieldMaxAgg(identifier(), fieldType);
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/factory/FieldMinAggFactory.java
b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/factory/FieldMinAggFactory.java
index 4ac7c08b19..e5ee8d9d16 100644
---
a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/factory/FieldMinAggFactory.java
+++
b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/factory/FieldMinAggFactory.java
@@ -21,6 +21,9 @@ package org.apache.paimon.mergetree.compact.aggregate.factory;
import org.apache.paimon.CoreOptions;
import org.apache.paimon.mergetree.compact.aggregate.FieldMinAgg;
import org.apache.paimon.types.DataType;
+import org.apache.paimon.utils.TypeCheckUtils;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
/** Factory for #{@link FieldMinAgg}. */
public class FieldMinAggFactory implements FieldAggregatorFactory {
@@ -29,6 +32,11 @@ public class FieldMinAggFactory implements
FieldAggregatorFactory {
@Override
public FieldMinAgg create(DataType fieldType, CoreOptions options, String
field) {
+ checkArgument(
+ TypeCheckUtils.isComparable(fieldType),
+ "Data type for min column '%s' must be comparable but was
'%s'.",
+ field,
+ fieldType);
return new FieldMinAgg(identifier(), fieldType);
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/aggregate/FieldAggregatorTest.java
b/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/aggregate/FieldAggregatorTest.java
index 5f9537e5a7..90ade26f71 100644
---
a/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/aggregate/FieldAggregatorTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/aggregate/FieldAggregatorTest.java
@@ -27,6 +27,7 @@ import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.InternalArray;
import org.apache.paimon.data.InternalMap;
import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.Timestamp;
import
org.apache.paimon.mergetree.compact.aggregate.factory.FieldAggregatorFactory;
import
org.apache.paimon.mergetree.compact.aggregate.factory.FieldBoolAndAggFactory;
import
org.apache.paimon.mergetree.compact.aggregate.factory.FieldBoolOrAggFactory;
@@ -51,6 +52,7 @@ import org.apache.paimon.types.ArrayType;
import org.apache.paimon.types.BigIntType;
import org.apache.paimon.types.BooleanType;
import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypeRoot;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.DecimalType;
import org.apache.paimon.types.DoubleType;
@@ -64,6 +66,7 @@ import org.apache.paimon.types.VarCharType;
import org.apache.paimon.utils.HllSketchUtil;
import org.apache.paimon.utils.RoaringBitmap32;
import org.apache.paimon.utils.RoaringBitmap64;
+import org.apache.paimon.utils.TypeCheckUtils;
import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableMap;
@@ -75,8 +78,11 @@ import java.util.AbstractMap;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
@@ -513,6 +519,103 @@ public class FieldAggregatorTest {
assertThat(fieldMinAgg.agg(accumulator, inputField)).isEqualTo(1);
}
+ @Test
+ public void testFieldMaxMinAggWithBooleanType() {
+ FieldMaxAgg fieldMaxAgg = new FieldMaxAggFactory().create(new
BooleanType(), null, null);
+ assertThat(fieldMaxAgg.agg(null, true)).isEqualTo(true);
+ assertThat(fieldMaxAgg.agg(false, null)).isEqualTo(false);
+ assertThat(fieldMaxAgg.agg(false, false)).isEqualTo(false);
+ assertThat(fieldMaxAgg.agg(false, true)).isEqualTo(true);
+ assertThat(fieldMaxAgg.agg(true, false)).isEqualTo(true);
+ assertThat(fieldMaxAgg.agg(true, true)).isEqualTo(true);
+
+ FieldMinAgg fieldMinAgg = new FieldMinAggFactory().create(new
BooleanType(), null, null);
+ assertThat(fieldMinAgg.agg(null, false)).isEqualTo(false);
+ assertThat(fieldMinAgg.agg(true, null)).isEqualTo(true);
+ assertThat(fieldMinAgg.agg(true, true)).isEqualTo(true);
+ assertThat(fieldMinAgg.agg(true, false)).isEqualTo(false);
+ assertThat(fieldMinAgg.agg(false, true)).isEqualTo(false);
+ assertThat(fieldMinAgg.agg(false, false)).isEqualTo(false);
+ }
+
+ @Test
+ public void testFieldMaxMinAggComparableTypesAreAllSupported() {
+ // The factory admits a field iff TypeCheckUtils.isComparable, so
every admitted type must
+ // be one InternalRowUtils.compare can actually order. Keep the two in
lockstep: a new
+ // comparable type must be added to compare() in the same change.
+ Map<DataType, Object> samples = new LinkedHashMap<>();
+ samples.put(DataTypes.BOOLEAN(), true);
+ samples.put(DataTypes.TINYINT(), (byte) 1);
+ samples.put(DataTypes.SMALLINT(), (short) 1);
+ samples.put(DataTypes.INT(), 1);
+ samples.put(DataTypes.BIGINT(), 1L);
+ samples.put(DataTypes.FLOAT(), 1.0f);
+ samples.put(DataTypes.DOUBLE(), 1.0d);
+ samples.put(DataTypes.DECIMAL(4, 2), Decimal.fromUnscaledLong(1, 4,
2));
+ samples.put(DataTypes.CHAR(1), BinaryString.fromString("a"));
+ samples.put(DataTypes.VARCHAR(1), BinaryString.fromString("a"));
+ samples.put(DataTypes.BINARY(1), new byte[] {1});
+ samples.put(DataTypes.VARBINARY(1), new byte[] {1});
+ samples.put(DataTypes.DATE(), 1);
+ samples.put(DataTypes.TIME(), 1);
+ samples.put(DataTypes.TIMESTAMP(), Timestamp.fromEpochMillis(1));
+ samples.put(DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(),
Timestamp.fromEpochMillis(1));
+
+ for (Map.Entry<DataType, Object> entry : samples.entrySet()) {
+ DataType type = entry.getKey();
+ Object value = entry.getValue();
+
assertThat(TypeCheckUtils.isComparable(type)).as("isComparable(%s)",
type).isTrue();
+ assertThat(new FieldMaxAggFactory().create(type, null,
"f").agg(value, value))
+ .as("max on %s", type)
+ .isEqualTo(value);
+ assertThat(new FieldMinAggFactory().create(type, null,
"f").agg(value, value))
+ .as("min on %s", type)
+ .isEqualTo(value);
+ }
+
+ // Guard against a new comparable type root slipping in without being
covered above: the
+ // sampled roots must be exactly the roots that are not excluded by
isComparable.
+ Set<DataTypeRoot> sampledRoots = new HashSet<>();
+ samples.keySet().forEach(type -> sampledRoots.add(type.getTypeRoot()));
+ Set<DataTypeRoot> expectedRoots = new
HashSet<>(Arrays.asList(DataTypeRoot.values()));
+ expectedRoots.removeAll(
+ Arrays.asList(
+ DataTypeRoot.MAP,
+ DataTypeRoot.MULTISET,
+ DataTypeRoot.ROW,
+ DataTypeRoot.ARRAY,
+ DataTypeRoot.VECTOR,
+ DataTypeRoot.VARIANT,
+ DataTypeRoot.BLOB));
+ assertThat(sampledRoots)
+ .as("a comparable type root must be covered here and in
InternalRowUtils.compare")
+ .isEqualTo(expectedRoots);
+ }
+
+ @Test
+ public void testFieldMaxMinAggWithIncomparableTypeShouldFail() {
+ // These types have no ordering, so max/min must be rejected when the
aggregator is
+ // created rather than failing later during merging.
+ for (DataType incomparable :
+ Arrays.asList(
+ DataTypes.ARRAY(DataTypes.INT()),
+ DataTypes.MAP(DataTypes.INT(), DataTypes.INT()),
+ DataTypes.MULTISET(DataTypes.INT()),
+ DataTypes.ROW(DataTypes.FIELD(0, "f0",
DataTypes.INT())),
+ DataTypes.VARIANT(),
+ DataTypes.BLOB(),
+ DataTypes.VECTOR(3, DataTypes.FLOAT()))) {
+ assertThatThrownBy(() -> new
FieldMaxAggFactory().create(incomparable, null, "label"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining(
+ "Data type for max column 'label' must be
comparable but was");
+ assertThatThrownBy(() -> new
FieldMinAggFactory().create(incomparable, null, "label"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining(
+ "Data type for min column 'label' must be
comparable but was");
+ }
+ }
+
@Test
public void testFieldSumIntAgg() {
FieldSumAgg fieldSumAgg = new FieldSumAggFactory().create(new
IntType(), null, null);