diff --git a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/BinaryMapKeys.java b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/BinaryMapKeys.java index 5fc1eb68e4fe..4a2e1df3880e 100644 --- a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/BinaryMapKeys.java +++ b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/BinaryMapKeys.java @@ -20,14 +20,15 @@ import org.apache.paimon.data.GenericMap; import org.apache.paimon.types.DataType; -import org.apache.paimon.types.DataTypeFamily; +import org.apache.paimon.types.DataTypeRoot; import org.apache.paimon.utils.ByteArrayKey; import java.util.HashMap; import java.util.Map; /** - * Value semantics for map keys of type {@code BINARY} or {@code VARBINARY}. + * Value semantics for map keys of type {@code BINARY}, {@code VARBINARY}, {@code GEOMETRY} or + * {@code GEOGRAPHY}. * *

Such a key arrives as a {@code byte[]}, which inherits identity equality from {@link Object}. * Used directly as a hash key, two keys with the same content occupy two entries, a lookup never @@ -39,8 +40,18 @@ final class BinaryMapKeys { private BinaryMapKeys() {} - static boolean isBinary(DataType keyType) { - return keyType.getTypeRoot().getFamilies().contains(DataTypeFamily.BINARY_STRING); + /** + * Whether values of this type are held as a {@code byte[]}. This is the set of roots that + * {@link org.apache.paimon.data.InternalArray#createElementGetter} reads with {@code + * getBinary}: {@code BINARY} and {@code VARBINARY}, plus {@code GEOMETRY} and {@code GEOGRAPHY} + * whose in-memory value is WKB. + */ + static boolean isBinary(DataType type) { + return type.isAnyOf( + DataTypeRoot.BINARY, + DataTypeRoot.VARBINARY, + DataTypeRoot.GEOMETRY, + DataTypeRoot.GEOGRAPHY); } /** Wrap a key for storage in a hash collection; a no-op for every non-binary key type. */ diff --git a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/FieldCollectAgg.java b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/FieldCollectAgg.java index 368ae685a960..60f60e82ce93 100644 --- a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/FieldCollectAgg.java +++ b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/aggregate/FieldCollectAgg.java @@ -36,7 +36,6 @@ import java.util.HashSet; import java.util.Iterator; import java.util.List; -import java.util.Set; import java.util.function.BiFunction; import static org.apache.paimon.codegen.CodeGenUtils.newRecordEqualiser; @@ -55,7 +54,9 @@ public FieldCollectAgg(String name, ArrayType dataType, boolean distinct) { this.distinct = distinct; this.elementGetter = InternalArray.createElementGetter(dataType.getElementType()); - if (distinct && needsEqualiser(dataType.getElementType())) { + // The equaliser is built from the element type alone: retract() needs it whether or not + // the array is distinct, and agg() gates de-duplication on 'distinct' separately. + if (needsEqualiser(dataType.getElementType())) { DataType elementType = dataType.getElementType(); List fieldTypes = elementType instanceof RowType @@ -83,14 +84,13 @@ public FieldCollectAgg(String name, ArrayType dataType, boolean distinct) { * Whether elements of this type need the generated equaliser rather than {@link Object#equals}. * *

Constructed types need it because two rows holding the same values are not necessarily - * equal objects. Binary types need it for a blunter reason: an element of {@code BINARY} or - * {@code VARBINARY} is a {@code byte[]}, which inherits identity equality from {@link Object}, - * so two arrays with the same content never compare equal and never share a hash bucket. + * equal objects. Binary types need it for a blunter reason: an element of {@code BINARY}, + * {@code VARBINARY}, {@code GEOMETRY} or {@code GEOGRAPHY} is a {@code byte[]}, which inherits + * identity equality from {@link Object}, so two arrays with the same content never compare + * equal and never share a hash bucket. */ private static boolean needsEqualiser(DataType elementType) { - Set families = elementType.getTypeRoot().getFamilies(); - return families.contains(DataTypeFamily.CONSTRUCTED) - || families.contains(DataTypeFamily.BINARY_STRING); + return elementType.is(DataTypeFamily.CONSTRUCTED) || BinaryMapKeys.isBinary(elementType); } @Override @@ -111,7 +111,7 @@ public Object agg(Object accumulator, Object inputField) { return accumulator == null ? inputField : accumulator; } - if (equaliser != null) { + if (distinct && equaliser != null) { List collection = new ArrayList<>(); // do not need to distinct accumulator, because the accumulator is always distinct, no // need to distinct it every time 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 6cc3c73a01c6..2dc2eb789065 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 @@ -2158,6 +2158,90 @@ public void testFieldCollectAggRetractWithDistinctBinary() { .containsExactly(new byte[] {3, 4}); } + /** + * A {@code GEOMETRY} or {@code GEOGRAPHY} element is a WKB {@code byte[]} just like a binary + * one, so distinct collection has to compare it by content as well. + */ + @Test + public void testFieldCollectAggWithDistinctGeospatial() { + for (DataType elementType : Arrays.asList(DataTypes.GEOMETRY(), DataTypes.GEOGRAPHY())) { + FieldCollectAgg agg = + new FieldCollectAggFactory() + .create( + DataTypes.ARRAY(elementType), + CoreOptions.fromMap( + ImmutableMap.of("fields.fieldName.distinct", "true")), + "fieldName"); + InternalArray.ElementGetter elementGetter = + InternalArray.createElementGetter(elementType); + + InternalArray result = + (InternalArray) + agg.agg( + new GenericArray(new Object[] {new byte[] {1, 2}}), + new GenericArray( + new Object[] {new byte[] {1, 2}, new byte[] {3, 4}})); + + assertThat(unnest(result, elementGetter)) + .as(elementType.toString()) + .usingRecursiveFieldByFieldElementComparator() + .containsExactlyInAnyOrder(new byte[] {1, 2}, new byte[] {3, 4}); + } + } + + /** + * Retraction compares by content even when the array is not distinct: a retracted binary + * element must still be removed from the accumulator. + */ + @Test + public void testFieldCollectAggRetractWithBinary() { + FieldCollectAgg agg = + new FieldCollectAggFactory() + .create( + DataTypes.ARRAY(DataTypes.VARBINARY(10)), + CoreOptions.fromMap( + ImmutableMap.of("fields.fieldName.distinct", "false")), + "fieldName"); + InternalArray.ElementGetter elementGetter = + InternalArray.createElementGetter(DataTypes.VARBINARY(10)); + + InternalArray result = + (InternalArray) + agg.retract( + new GenericArray( + new Object[] {new byte[] {1, 2}, new byte[] {3, 4}}), + new GenericArray(new Object[] {new byte[] {1, 2}})); + + assertThat(unnest(result, elementGetter)) + .usingRecursiveFieldByFieldElementComparator() + .containsExactly(new byte[] {3, 4}); + } + + /** Without distinct, a binary array keeps duplicates: the equaliser must not de-duplicate. */ + @Test + public void testFieldCollectAggKeepsDuplicatesWithBinary() { + FieldCollectAgg agg = + new FieldCollectAggFactory() + .create( + DataTypes.ARRAY(DataTypes.VARBINARY(10)), + CoreOptions.fromMap( + ImmutableMap.of("fields.fieldName.distinct", "false")), + "fieldName"); + InternalArray.ElementGetter elementGetter = + InternalArray.createElementGetter(DataTypes.VARBINARY(10)); + + InternalArray result = + (InternalArray) + agg.agg( + new GenericArray(new Object[] {new byte[] {1, 2}}), + new GenericArray( + new Object[] {new byte[] {1, 2}, new byte[] {3, 4}})); + + assertThat(unnest(result, elementGetter)) + .usingRecursiveFieldByFieldElementComparator() + .containsExactly(new byte[] {1, 2}, new byte[] {1, 2}, new byte[] {3, 4}); + } + @Test public void testFiledCollectAggWithRowType() { RowType rowType = RowType.of(DataTypes.INT(), DataTypes.STRING()); @@ -2594,6 +2678,25 @@ public void testFieldMergeMapAggRetractWithBinaryKey() { assertThat(binaryKeyed(result)).containsOnlyKeys("0304"); } + /** A {@code GEOMETRY} key is a WKB {@code byte[]} and must be merged by content as well. */ + @Test + public void testFieldMergeMapAggWithGeospatialKey() { + FieldMergeMapAgg agg = + new FieldMergeMapAggFactory() + .create(DataTypes.MAP(DataTypes.GEOMETRY(), DataTypes.INT()), null, null); + + Map first = new HashMap<>(); + first.put(new byte[] {1, 2}, 1); + Map second = new HashMap<>(); + second.put(new byte[] {1, 2}, 2); + second.put(new byte[] {3, 4}, 3); + + InternalMap merged = (InternalMap) agg.agg(new GenericMap(first), new GenericMap(second)); + + assertThat(merged.size()).isEqualTo(2); + assertThat(binaryKeyed(merged)).containsOnlyKeys("0102", "0304").containsValues(2, 3); + } + /** Render an {@code InternalMap} with binary keys as hex so it can be asserted by value. */ private Map binaryKeyed(InternalMap map) { InternalArray.ElementGetter keyGetter = @@ -2943,6 +3046,35 @@ public void testFieldMergeMapWithKeyTimeAggWithBinaryKey() { assertThat(((InternalMap) acc).size()).isEqualTo(0); } + /** The same walk with a {@code GEOMETRY} key, which is a WKB {@code byte[]} as well. */ + @Test + public void testFieldMergeMapWithKeyTimeAggWithGeospatialKey() { + MapType mapType = + DataTypes.MAP( + DataTypes.GEOMETRY(), + DataTypes.ROW( + DataTypes.FIELD(0, "actual_value", DataTypes.STRING()), + DataTypes.FIELD(1, "dbsync_ts", DataTypes.STRING()))); + FieldMergeMapWithKeyTimeAgg agg = new FieldMergeMapWithKeyTimeAgg("test", mapType, 1); + + Object acc = agg.agg(null, binaryKeyedMap(new byte[] {1, 2}, "A", "100")); + + acc = agg.agg(acc, binaryKeyedMap(new byte[] {1, 2}, "A1", "200")); + InternalMap merged = (InternalMap) acc; + assertThat(merged.size()).isEqualTo(1); + assertThat(firstRowValue(merged)).isEqualTo("A1"); + + acc = agg.agg(acc, binaryKeyedMap(new byte[] {1, 2}, "A0", "050")); + merged = (InternalMap) acc; + assertThat(merged.size()).isEqualTo(1); + assertThat(firstRowValue(merged)).isEqualTo("A1"); + + Map tombstone = new HashMap<>(); + tombstone.put(new byte[] {1, 2}, null); + acc = agg.agg(acc, new GenericMap(tombstone)); + assertThat(((InternalMap) acc).size()).isEqualTo(0); + } + private GenericMap binaryKeyedMap(byte[] key, String value, String ts) { Map map = new HashMap<>(); map.put(key, GenericRow.of(BinaryString.fromString(value), BinaryString.fromString(ts)));