From b10aa3c2adfb6265b0c4aa448035ae4e274dd3d3 Mon Sep 17 00:00:00 2001 From: Prajwal Banakar Date: Sun, 26 Jul 2026 09:42:41 +0000 Subject: [PATCH 1/2] [FIP-37] Add bitmap Phase 2 functions and register via FlussCatalog --- .../fluss/flink/catalog/FlinkCatalog.java | 6 + .../functions/bitmap/RbAndNotFunction.java | 52 +++++++++ .../functions/bitmap/RbXorAggFunction.java | 59 ++++++++++ .../flink/functions/bitmap/RbXorFunction.java | 51 +++++++++ .../fluss/flink/catalog/FlinkCatalogTest.java | 14 ++- .../functions/bitmap/RbAggFunctionsTest.java | 72 +++++++++++- .../bitmap/RbFunctionsCatalogITCase.java | 64 +++++++++++ .../bitmap/RbScalarFunctionsTest.java | 104 +++++++++++++++++- 8 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbAndNotFunction.java create mode 100644 fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorAggFunction.java create mode 100644 fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorFunction.java diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/catalog/FlinkCatalog.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/catalog/FlinkCatalog.java index 17194c05b85..1beb69288b8 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/catalog/FlinkCatalog.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/catalog/FlinkCatalog.java @@ -27,6 +27,7 @@ import org.apache.fluss.flink.adapter.CatalogTableAdapter; import org.apache.fluss.flink.functions.bitmap.RbAndAggFunction; import org.apache.fluss.flink.functions.bitmap.RbAndFunction; +import org.apache.fluss.flink.functions.bitmap.RbAndNotFunction; import org.apache.fluss.flink.functions.bitmap.RbBuildAggFunction; import org.apache.fluss.flink.functions.bitmap.RbBuildFunction; import org.apache.fluss.flink.functions.bitmap.RbCardinalityFunction; @@ -34,6 +35,8 @@ import org.apache.fluss.flink.functions.bitmap.RbOrAggFunction; import org.apache.fluss.flink.functions.bitmap.RbOrFunction; import org.apache.fluss.flink.functions.bitmap.RbToArrayFunction; +import org.apache.fluss.flink.functions.bitmap.RbXorAggFunction; +import org.apache.fluss.flink.functions.bitmap.RbXorFunction; import org.apache.fluss.flink.lake.LakeFlinkCatalog; import org.apache.fluss.flink.procedure.ProcedureManager; import org.apache.fluss.flink.utils.CatalogExceptionUtils; @@ -154,6 +157,7 @@ public class FlinkCatalog extends AbstractCatalog { map.put("rb_build_agg", RbBuildAggFunction.class.getName()); map.put("rb_or_agg", RbOrAggFunction.class.getName()); map.put("rb_and_agg", RbAndAggFunction.class.getName()); + map.put("rb_xor_agg", RbXorAggFunction.class.getName()); // scalar functions map.put("rb_cardinality", RbCardinalityFunction.class.getName()); map.put("rb_build", RbBuildFunction.class.getName()); @@ -161,6 +165,8 @@ public class FlinkCatalog extends AbstractCatalog { map.put("rb_to_array", RbToArrayFunction.class.getName()); map.put("rb_or", RbOrFunction.class.getName()); map.put("rb_and", RbAndFunction.class.getName()); + map.put("rb_xor", RbXorFunction.class.getName()); + map.put("rb_andnot", RbAndNotFunction.class.getName()); BUILTIN_BITMAP_FUNCTIONS = Collections.unmodifiableMap(map); } diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbAndNotFunction.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbAndNotFunction.java new file mode 100644 index 00000000000..0be55229303 --- /dev/null +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbAndNotFunction.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.fluss.flink.functions.bitmap; + +import org.apache.flink.table.functions.ScalarFunction; +import org.roaringbitmap.RoaringBitmap; + +import javax.annotation.Nullable; + +import java.io.IOException; + +/** + * {@code rb_andnot(left BYTES, right BYTES) -> BYTES} + * + *

Returns the difference (AND NOT) of two serialized {@link RoaringBitmap} values. The result + * contains elements present in the left bitmap but not in the right bitmap. Useful for exclusion + * analysis such as "users who visited page A but not page B." Returns {@code null} if either + * argument is null. + */ +public class RbAndNotFunction extends ScalarFunction { + + /** + * @param leftBytes serialized left bitmap + * @param rightBytes serialized right bitmap + * @return elements in left but not in right, or null if either argument is null + */ + @Nullable + public byte[] eval(@Nullable byte[] leftBytes, @Nullable byte[] rightBytes) throws IOException { + if (leftBytes == null || rightBytes == null) { + return null; + } + RoaringBitmap left = BitmapUtils.fromBytes(leftBytes); + RoaringBitmap right = BitmapUtils.fromBytes(rightBytes); + left.andNot(right); + return BitmapUtils.toBytes(left); + } +} diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorAggFunction.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorAggFunction.java new file mode 100644 index 00000000000..99238be3c93 --- /dev/null +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorAggFunction.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.fluss.flink.functions.bitmap; + +import org.roaringbitmap.RoaringBitmap; + +import javax.annotation.Nullable; + +import java.io.IOException; + +/** + * {@code rb_xor_agg(bitmap BYTES) -> BYTES} + * + *

Aggregates multiple serialized {@link RoaringBitmap} values using bitwise XOR across rows. + * Returns elements that appear in an odd number of input bitmaps — useful for change detection and + * symmetric difference analysis. + * + *

Null and empty inputs are ignored. Returns {@code null} when all inputs are null. + */ +public class RbXorAggFunction extends AbstractRbAggFunction { + + /** + * XORs the input bitmap into the accumulator. + * + * @param acc the running bitmap accumulator + * @param bitmapBytes serialized RoaringBitmap bytes; null and empty arrays are ignored + */ + public void accumulate(RoaringBitmap acc, @Nullable byte[] bitmapBytes) throws IOException { + if (bitmapBytes == null || bitmapBytes.length == 0) { + return; + } + acc.xor(BitmapUtils.fromBytes(bitmapBytes)); + } + + /** + * Retraction is not supported for bitmap XOR aggregation. + * + * @throws UnsupportedOperationException always + */ + public void retract(RoaringBitmap acc, @Nullable byte[] bitmapBytes) { + throw new UnsupportedOperationException( + "rb_xor_agg does not support retraction. " + "Use it only on append-only streams."); + } +} diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorFunction.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorFunction.java new file mode 100644 index 00000000000..3b1c01cb908 --- /dev/null +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorFunction.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.fluss.flink.functions.bitmap; + +import org.apache.flink.table.functions.ScalarFunction; +import org.roaringbitmap.RoaringBitmap; + +import javax.annotation.Nullable; + +import java.io.IOException; + +/** + * {@code rb_xor(left BYTES, right BYTES) -> BYTES} + * + *

Returns the bitwise XOR (symmetric difference) of two serialized {@link RoaringBitmap} values. + * The result contains elements that appear in exactly one of the two bitmaps. Returns {@code null} + * if either argument is null. + */ +public class RbXorFunction extends ScalarFunction { + + /** + * @param leftBytes serialized left bitmap + * @param rightBytes serialized right bitmap + * @return symmetric difference of left and right, or null if either argument is null + */ + @Nullable + public byte[] eval(@Nullable byte[] leftBytes, @Nullable byte[] rightBytes) throws IOException { + if (leftBytes == null || rightBytes == null) { + return null; + } + RoaringBitmap left = BitmapUtils.fromBytes(leftBytes); + RoaringBitmap right = BitmapUtils.fromBytes(rightBytes); + left.xor(right); + return BitmapUtils.toBytes(left); + } +} diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogTest.java index 6b087803903..3c390a1c857 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogTest.java @@ -1012,12 +1012,15 @@ void testViewsAndFunctions() throws Exception { "rb_build_agg", "rb_or_agg", "rb_and_agg", + "rb_xor_agg", "rb_cardinality", "rb_build", "rb_contains", "rb_to_array", "rb_or", - "rb_and"); + "rb_and", + "rb_xor", + "rb_andnot"); ObjectPath functionPath = new ObjectPath(DEFAULT_DB, "testFunction"); assertThat(catalog.functionExists(functionPath)).isFalse(); @@ -1118,7 +1121,7 @@ void testBitmapFunctionsRegistered() throws Exception { List functions = catalog.listFunctions(DEFAULT_DB); // aggregate functions - assertThat(functions).contains("rb_build_agg", "rb_or_agg", "rb_and_agg"); + assertThat(functions).contains("rb_build_agg", "rb_or_agg", "rb_and_agg", "rb_xor_agg"); // scalar functions assertThat(functions) .contains( @@ -1127,7 +1130,9 @@ void testBitmapFunctionsRegistered() throws Exception { "rb_contains", "rb_to_array", "rb_or", - "rb_and"); + "rb_and", + "rb_xor", + "rb_andnot"); // verify each function exists and resolves to the correct class assertThat(catalog.functionExists(new ObjectPath(DEFAULT_DB, "rb_cardinality"))).isTrue(); @@ -1136,6 +1141,9 @@ void testBitmapFunctionsRegistered() throws Exception { assertThat(catalog.functionExists(new ObjectPath(DEFAULT_DB, "rb_to_array"))).isTrue(); assertThat(catalog.functionExists(new ObjectPath(DEFAULT_DB, "rb_or"))).isTrue(); assertThat(catalog.functionExists(new ObjectPath(DEFAULT_DB, "rb_and"))).isTrue(); + assertThat(catalog.functionExists(new ObjectPath(DEFAULT_DB, "rb_xor_agg"))).isTrue(); + assertThat(catalog.functionExists(new ObjectPath(DEFAULT_DB, "rb_xor"))).isTrue(); + assertThat(catalog.functionExists(new ObjectPath(DEFAULT_DB, "rb_andnot"))).isTrue(); // verify unknown still returns false assertThat(catalog.functionExists(new ObjectPath(DEFAULT_DB, "unknown_fn"))).isFalse(); diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbAggFunctionsTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbAggFunctionsTest.java index 1f9e9149635..be6aaacec9d 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbAggFunctionsTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbAggFunctionsTest.java @@ -30,7 +30,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -/** Unit tests for {@link RbBuildAggFunction}, {@link RbOrAggFunction}, {@link RbAndAggFunction}. */ +/** + * Unit tests for {@link RbBuildAggFunction}, {@link RbOrAggFunction}, {@link RbAndAggFunction}, + * {@link RbXorAggFunction}. + */ class RbAggFunctionsTest { // ------------------------------------------------------------------------- @@ -340,4 +343,71 @@ void testAndAggAccumulatorSerializerSnapshotNotNull() { assertThat(RbAndAggFunction.AccumulatorSerializer.INSTANCE.snapshotConfiguration()) .isNotNull(); } + + // ------------------------------------------------------------------------- + // RbXorAggFunction + // ------------------------------------------------------------------------- + + @Test + void testXorAggBasic() throws IOException { + RbXorAggFunction fn = new RbXorAggFunction(); + RoaringBitmap acc = fn.createAccumulator(); + fn.accumulate(acc, BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2, 3))); + fn.accumulate(acc, BitmapUtils.toBytes(RoaringBitmap.bitmapOf(2, 3, 4))); + // XOR: elements in exactly one bitmap = {1, 4} + byte[] result = fn.getValue(acc); + assertThat(result).isNotNull(); + RoaringBitmap restored = BitmapUtils.fromBytes(result); + assertThat(restored.getLongCardinality()).isEqualTo(2L); + assertThat(restored.contains(1)).isTrue(); + assertThat(restored.contains(4)).isTrue(); + assertThat(restored.contains(2)).isFalse(); + assertThat(restored.contains(3)).isFalse(); + } + + @Test + void testXorAggNullInputIgnored() throws IOException { + RbXorAggFunction fn = new RbXorAggFunction(); + RoaringBitmap acc = fn.createAccumulator(); + fn.accumulate(acc, null); + fn.accumulate(acc, new byte[0]); + fn.accumulate(acc, BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2))); + byte[] result = fn.getValue(acc); + assertThat(result).isNotNull(); + RoaringBitmap restored = BitmapUtils.fromBytes(result); + assertThat(restored.getLongCardinality()).isEqualTo(2L); + } + + @Test + void testXorAggEmptyReturnsNull() { + RbXorAggFunction fn = new RbXorAggFunction(); + assertThat(fn.getValue(fn.createAccumulator())).isNull(); + } + + @Test + void testXorAggMerge() throws IOException { + RbXorAggFunction fn = new RbXorAggFunction(); + RoaringBitmap acc1 = fn.createAccumulator(); + fn.accumulate(acc1, BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2, 3))); + RoaringBitmap acc2 = fn.createAccumulator(); + fn.accumulate(acc2, BitmapUtils.toBytes(RoaringBitmap.bitmapOf(3, 4, 5))); + fn.merge(acc1, Collections.singletonList(acc2)); + // merge() unions partial accumulators via OR: {1,2,3} OR {3,4,5} = {1,2,3,4,5} + byte[] result = fn.getValue(acc1); + assertThat(result).isNotNull(); + RoaringBitmap restored = BitmapUtils.fromBytes(result); + assertThat(restored.getLongCardinality()).isEqualTo(5L); + assertThat(restored.contains(1)).isTrue(); + assertThat(restored.contains(2)).isTrue(); + assertThat(restored.contains(3)).isTrue(); + assertThat(restored.contains(4)).isTrue(); + assertThat(restored.contains(5)).isTrue(); + } + + @Test + void testXorAggRetractThrows() { + RbXorAggFunction fn = new RbXorAggFunction(); + assertThatThrownBy(() -> fn.retract(fn.createAccumulator(), new byte[0])) + .isInstanceOf(UnsupportedOperationException.class); + } } diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbFunctionsCatalogITCase.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbFunctionsCatalogITCase.java index f76647dc19c..b4cb568d1fd 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbFunctionsCatalogITCase.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbFunctionsCatalogITCase.java @@ -318,4 +318,68 @@ void testRbBuildFromArrayResolvedFromCatalog() throws Exception { assertThat(rows).hasSize(1); assertThat(rows.get(0).getField(0)).isEqualTo(3L); // duplicate 2 ignored } + + @Test + void testRbXorAggResolvedFromCatalog() throws Exception { + byte[] bitmap1 = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2, 3)); + byte[] bitmap2 = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(2, 3, 4)); + + Table source = + tEnv.fromValues( + DataTypes.ROW( + DataTypes.FIELD("k", DataTypes.INT()), + DataTypes.FIELD("bmap", DataTypes.BYTES())), + Row.of(1, bitmap1), + Row.of(1, bitmap2)); + tEnv.createTemporaryView("bitmaps", source); + + TableResult result = tEnv.executeSql("SELECT k, rb_xor_agg(bmap) FROM bitmaps GROUP BY k"); + List rows = CollectionUtil.iteratorToList(result.collect()); + assertThat(rows).hasSize(1); + + byte[] resultBytes = (byte[]) rows.get(0).getField(1); + RoaringBitmap xor = BitmapUtils.fromBytes(resultBytes); + // XOR of {1,2,3} and {2,3,4} = {1,4} + assertThat(xor.getLongCardinality()).isEqualTo(2L); + assertThat(xor.contains(1)).isTrue(); + assertThat(xor.contains(4)).isTrue(); + } + + @Test + void testRbXorScalarResolvedFromCatalog() throws Exception { + byte[] left = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2, 3)); + byte[] right = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(2, 3, 4)); + + Table source = + tEnv.fromValues( + DataTypes.ROW( + DataTypes.FIELD("l", DataTypes.BYTES()), + DataTypes.FIELD("r", DataTypes.BYTES())), + Row.of(left, right)); + tEnv.createTemporaryView("bitmaps", source); + + TableResult result = tEnv.executeSql("SELECT rb_cardinality(rb_xor(l, r)) FROM bitmaps"); + List rows = CollectionUtil.iteratorToList(result.collect()); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getField(0)).isEqualTo(2L); // {1, 4} + } + + @Test + void testRbAndNotResolvedFromCatalog() throws Exception { + byte[] left = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2, 3, 4)); + byte[] right = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(3, 4, 5)); + + Table source = + tEnv.fromValues( + DataTypes.ROW( + DataTypes.FIELD("l", DataTypes.BYTES()), + DataTypes.FIELD("r", DataTypes.BYTES())), + Row.of(left, right)); + tEnv.createTemporaryView("bitmaps", source); + + TableResult result = tEnv.executeSql("SELECT rb_cardinality(rb_andnot(l, r)) FROM bitmaps"); + List rows = CollectionUtil.iteratorToList(result.collect()); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getField(0)).isEqualTo(2L); // {1, 2} + } } diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbScalarFunctionsTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbScalarFunctionsTest.java index 0d7a4b3d05e..0c27f02c547 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbScalarFunctionsTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbScalarFunctionsTest.java @@ -25,7 +25,7 @@ import static org.assertj.core.api.Assertions.assertThat; -/** Unit tests for the six Phase 1 scalar bitmap functions. */ +/** Unit tests for the Phase 1 and Phase 2 scalar bitmap functions. */ class RbScalarFunctionsTest { // ------------------------------------------------------------------------- @@ -252,4 +252,106 @@ void testAndEmptyIntersectionReturnsBytes() throws IOException { RoaringBitmap intersection = BitmapUtils.fromBytes(result); assertThat(intersection.isEmpty()).isTrue(); } + + // ------------------------------------------------------------------------- + // rb_xor (scalar) + // ------------------------------------------------------------------------- + + @Test + void testXorBasic() throws IOException { + RbXorFunction fn = new RbXorFunction(); + byte[] left = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2, 3)); + byte[] right = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(2, 3, 4)); + byte[] result = fn.eval(left, right); + assertThat(result).isNotNull(); + RoaringBitmap restored = BitmapUtils.fromBytes(result); + assertThat(restored.getLongCardinality()).isEqualTo(2L); + assertThat(restored.contains(1)).isTrue(); + assertThat(restored.contains(4)).isTrue(); + assertThat(restored.contains(2)).isFalse(); + assertThat(restored.contains(3)).isFalse(); + } + + @Test + void testXorLeftNull() throws IOException { + byte[] right = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2)); + assertThat(new RbXorFunction().eval(null, right)).isNull(); + } + + @Test + void testXorRightNull() throws IOException { + byte[] left = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2)); + assertThat(new RbXorFunction().eval(left, null)).isNull(); + } + + @Test + void testXorBothNull() throws IOException { + assertThat(new RbXorFunction().eval(null, null)).isNull(); + } + + @Test + void testXorDisjointSets() throws IOException { + RbXorFunction fn = new RbXorFunction(); + byte[] left = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2)); + byte[] right = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(3, 4)); + // XOR of disjoint sets = union + RoaringBitmap result = BitmapUtils.fromBytes(fn.eval(left, right)); + assertThat(result.getLongCardinality()).isEqualTo(4L); + } + + // ------------------------------------------------------------------------- + // rb_andnot (scalar) + // ------------------------------------------------------------------------- + + @Test + void testAndNotBasic() throws IOException { + RbAndNotFunction fn = new RbAndNotFunction(); + byte[] left = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2, 3, 4)); + byte[] right = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(3, 4, 5)); + byte[] result = fn.eval(left, right); + assertThat(result).isNotNull(); + RoaringBitmap restored = BitmapUtils.fromBytes(result); + assertThat(restored.getLongCardinality()).isEqualTo(2L); + assertThat(restored.contains(1)).isTrue(); + assertThat(restored.contains(2)).isTrue(); + assertThat(restored.contains(3)).isFalse(); + assertThat(restored.contains(4)).isFalse(); + } + + @Test + void testAndNotLeftNull() throws IOException { + byte[] right = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2)); + assertThat(new RbAndNotFunction().eval(null, right)).isNull(); + } + + @Test + void testAndNotRightNull() throws IOException { + byte[] left = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2)); + assertThat(new RbAndNotFunction().eval(left, null)).isNull(); + } + + @Test + void testAndNotBothNull() throws IOException { + assertThat(new RbAndNotFunction().eval(null, null)).isNull(); + } + + @Test + void testAndNotRightIsSuperset() throws IOException { + RbAndNotFunction fn = new RbAndNotFunction(); + byte[] left = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2)); + byte[] right = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2, 3)); + RoaringBitmap result = BitmapUtils.fromBytes(fn.eval(left, right)); + assertThat(result.isEmpty()).isTrue(); + } + + @Test + void testAndNotDisjointSets() throws IOException { + RbAndNotFunction fn = new RbAndNotFunction(); + byte[] left = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2)); + byte[] right = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(3, 4)); + RoaringBitmap result = BitmapUtils.fromBytes(fn.eval(left, right)); + assertThat(result.getLongCardinality()).isEqualTo(2L); + assertThat(result.contains(1)).isTrue(); + assertThat(result.contains(2)).isTrue(); + } } From 8cf61906281bd7a78c34227a1ad5f83b5fb83422 Mon Sep 17 00:00:00 2001 From: Prajwal Banakar Date: Mon, 27 Jul 2026 08:19:47 +0000 Subject: [PATCH 2/2] adress review --- .../functions/bitmap/RbXorAggFunction.java | 16 +++++++- .../functions/bitmap/RbAggFunctionsTest.java | 38 +++++++++++++++++-- .../bitmap/RbScalarFunctionsTest.java | 12 ++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorAggFunction.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorAggFunction.java index 99238be3c93..e08c4a7d12b 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorAggFunction.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/functions/bitmap/RbXorAggFunction.java @@ -30,7 +30,12 @@ * Returns elements that appear in an odd number of input bitmaps — useful for change detection and * symmetric difference analysis. * - *

Null and empty inputs are ignored. Returns {@code null} when all inputs are null. + *

Note: there is no server-side {@code FieldRoaringBitmapXorAgg} counterpart. This function + * executes entirely in Flink. Users should be aware that combining it with {@code + * table.merge-engine=aggregation} may produce unexpected results during server-side compaction. + * + *

Null and empty inputs are ignored. Returns {@code null} when all inputs are null or the result + * fully cancels to an empty bitmap. */ public class RbXorAggFunction extends AbstractRbAggFunction { @@ -56,4 +61,13 @@ public void retract(RoaringBitmap acc, @Nullable byte[] bitmapBytes) { throw new UnsupportedOperationException( "rb_xor_agg does not support retraction. " + "Use it only on append-only streams."); } + + @Override + public void merge(RoaringBitmap acc, Iterable it) { + for (RoaringBitmap other : it) { + if (other != null) { + acc.xor(other); + } + } + } } diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbAggFunctionsTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbAggFunctionsTest.java index be6aaacec9d..05cab1d8e27 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbAggFunctionsTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbAggFunctionsTest.java @@ -392,16 +392,16 @@ void testXorAggMerge() throws IOException { RoaringBitmap acc2 = fn.createAccumulator(); fn.accumulate(acc2, BitmapUtils.toBytes(RoaringBitmap.bitmapOf(3, 4, 5))); fn.merge(acc1, Collections.singletonList(acc2)); - // merge() unions partial accumulators via OR: {1,2,3} OR {3,4,5} = {1,2,3,4,5} + // XOR merge: {1,2,3} XOR {3,4,5} = {1,2,4,5} byte[] result = fn.getValue(acc1); assertThat(result).isNotNull(); RoaringBitmap restored = BitmapUtils.fromBytes(result); - assertThat(restored.getLongCardinality()).isEqualTo(5L); + assertThat(restored.getLongCardinality()).isEqualTo(4L); assertThat(restored.contains(1)).isTrue(); assertThat(restored.contains(2)).isTrue(); - assertThat(restored.contains(3)).isTrue(); assertThat(restored.contains(4)).isTrue(); assertThat(restored.contains(5)).isTrue(); + assertThat(restored.contains(3)).isFalse(); } @Test @@ -410,4 +410,36 @@ void testXorAggRetractThrows() { assertThatThrownBy(() -> fn.retract(fn.createAccumulator(), new byte[0])) .isInstanceOf(UnsupportedOperationException.class); } + + @Test + void testXorAggMergeCancelsToEmpty() throws IOException { + // Two partials each holding {1} — XOR merge should cancel to empty, getValue returns null + RbXorAggFunction fn = new RbXorAggFunction(); + RoaringBitmap acc1 = fn.createAccumulator(); + fn.accumulate(acc1, BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1))); + RoaringBitmap acc2 = fn.createAccumulator(); + fn.accumulate(acc2, BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1))); + fn.merge(acc1, Collections.singletonList(acc2)); + // {1} XOR {1} = empty + assertThat(fn.getValue(acc1)).isNull(); + } + + @Test + void testXorAggThreeInputs() throws IOException { + // Exercises the "odd number of inputs" contract + // {1,2} XOR {2,3} XOR {3,4} = {1,4} + RbXorAggFunction fn = new RbXorAggFunction(); + RoaringBitmap acc = fn.createAccumulator(); + fn.accumulate(acc, BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2))); + fn.accumulate(acc, BitmapUtils.toBytes(RoaringBitmap.bitmapOf(2, 3))); + fn.accumulate(acc, BitmapUtils.toBytes(RoaringBitmap.bitmapOf(3, 4))); + byte[] result = fn.getValue(acc); + assertThat(result).isNotNull(); + RoaringBitmap restored = BitmapUtils.fromBytes(result); + assertThat(restored.getLongCardinality()).isEqualTo(2L); + assertThat(restored.contains(1)).isTrue(); + assertThat(restored.contains(4)).isTrue(); + assertThat(restored.contains(2)).isFalse(); + assertThat(restored.contains(3)).isFalse(); + } } diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbScalarFunctionsTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbScalarFunctionsTest.java index 0c27f02c547..5918ee9cc1d 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbScalarFunctionsTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/functions/bitmap/RbScalarFunctionsTest.java @@ -299,6 +299,18 @@ void testXorDisjointSets() throws IOException { assertThat(result.getLongCardinality()).isEqualTo(4L); } + @Test + void testXorIdenticalSetsProducesEmpty() throws IOException { + RbXorFunction fn = new RbXorFunction(); + byte[] left = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2, 3)); + byte[] right = BitmapUtils.toBytes(RoaringBitmap.bitmapOf(1, 2, 3)); + // XOR of identical sets = empty bitmap (not null, since both inputs were provided) + byte[] result = fn.eval(left, right); + assertThat(result).isNotNull(); + RoaringBitmap restored = BitmapUtils.fromBytes(result); + assertThat(restored.isEmpty()).isTrue(); + } + // ------------------------------------------------------------------------- // rb_andnot (scalar) // -------------------------------------------------------------------------