This is an automated email from the ASF dual-hosted git repository.

cloud-fan pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 79e801558a2c [SPARK-57924][SQL] Add bitmap_xor_agg aggregation function
79e801558a2c is described below

commit 79e801558a2c76f97594e2d12b676f5df3cb0893
Author: StormSpirit <[email protected]>
AuthorDate: Thu Aug 20 21:59:22 2026 +0800

    [SPARK-57924][SQL] Add bitmap_xor_agg aggregation function
    
    ### What changes were proposed in this pull request?
    
    Currently, Spark has three bitmap aggregation functions: 
`bitmap_construct_agg`, `bitmap_or_agg` (since 3.5.0), and `bitmap_and_agg` 
(since 4.1.0) for constructing, union, and intersection of sets represented by 
bitmaps. However, the symmetric difference operation (bitwise XOR) is missing, 
which is needed for use cases such as finding elements belonging to exactly one 
of two groups and detecting changes between snapshots.
    
    This PR adds a new aggregation function `bitmap_xor_agg` that performs 
bitwise XOR on binary bitmap inputs, following the same `ImperativeAggregate` 
pattern as `BitmapAndAgg`.
    
    #### Design Decisions
    
    - **Result on empty input is the XOR identity element (all zeros)**: An 
ungrouped empty input, or an existing group with no matching rows after FILTER, 
returns the all-zero bitmap as the XOR identity element (X ^ 0 = X).
    - **Missing bytes handling**: For XOR operations, bytes beyond the shorter 
input's length are left unchanged, which is equivalent to XOR with 0 (absent 
bits). This is consistent with the OR operation and requires no explicit 
trailing-byte zeroing unlike AND.
    - **Input validation**: Only BINARY input is accepted, with the same 
`UNEXPECTED_INPUT_TYPE` error class as the other bitmap aggregates.
    
    ### Why are the changes needed?
    
    Symmetric difference is a fundamental set operation alongside union and 
intersection. Other analytical engines provide this functionality. Adding 
`bitmap_xor_agg` completes the bitmap aggregation function family.
    
    Example:
    ```sql
    -- Symmetric difference: 0x10 ^ 0x30 ^ 0x40 = 0x60
    SELECT substring(hex(bitmap_xor_agg(col)), 0, 6)
    FROM VALUES (X'10'), (X'30'), (X'40') AS tab(col);
    -- 600000
    
    -- Same values cancel out: 0x10 ^ 0x10 = 0x00
    SELECT substring(hex(bitmap_xor_agg(col)), 0, 6)
    FROM VALUES (X'10'), (X'10') AS tab(col);
    -- 000000
    
    -- Count set bits in the XOR result: 0x10 ^ 0x30 ^ 0x40 = 0x60
    SELECT bitmap_count(bitmap_xor_agg(col)) AS symmetric_diff_count
    FROM VALUES (X'10'), (X'30'), (X'40') AS tab(col);
    -- 2
    ```
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. A new `bitmap_xor_agg` function is available in SQL, the Scala 
DataFrame API, and PySpark (classic and Connect).
    
    ### How was this patch tested?
    
    - **`BitmapExpressionUtilsSuite`**: 5 new unit tests covering equal-length 
XOR, different-length XOR, all-zeros identity, self-XOR equals zero, and 
sign-bit bytes.
    - **`BitmapExpressionsQuerySuite`**: 3 new tests covering basic XOR, 
same-value cancellation, zero identity, different-length bitmaps, empty input, 
FILTER clause, GROUP BY, complex `bitmap_construct_agg` composition, and type 
mismatch error handling.
    - **`PlanGenerationTestSuite`**: Spark Connect plan generation test with 
regenerated golden files.
    - **`ProtoToParsedPlanTestSuite`**: Parsed-plan golden test validating the 
Connect explain artifact.
    - **`ExpressionsSchemaSuite`**: Regenerated the SQL expression schema 
golden file.
    - **`ExpressionInfoSuite`**: Verified the new expression examples.
    - **PySpark doctest smoke**: Targeted doctests for the `bitmap_xor_agg` 
wrapper in classic PySpark.
    - **`spark-pr-precheck.py`**: Pre-push static checks (format, license, 
golden, version consistency) all passed.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Codex and Claude AI
    
    Closes #58025 from jiangxt2/feat/bitmap-xor-agg.
    
    Authored-by: StormSpirit <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
    (cherry picked from commit 7e9c387cf9ad5ac46801cea45d4ce21f5d109488)
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../source/reference/pyspark.sql/functions.rst     |   1 +
 python/pyspark/sql/connect/functions/builtin.py    |   7 ++
 python/pyspark/sql/functions/__init__.py           |   1 +
 python/pyspark/sql/functions/builtin.py            |  36 +++++++
 .../scala/org/apache/spark/sql/functions.scala     |  14 +++
 .../expressions/BitmapExpressionUtils.java         |   9 ++
 .../sql/catalyst/analysis/FunctionRegistry.scala   |   1 +
 .../catalyst/expressions/bitmapExpressions.scala   |  98 ++++++++++++++++++
 .../expressions/BitmapExpressionUtilsSuite.scala   |  53 ++++++++++
 .../apache/spark/sql/PlanGenerationTestSuite.scala |   4 +
 .../function_bitmap_xor_agg.explain                |   2 +
 .../queries/function_bitmap_xor_agg.json           |  60 +++++++++++
 .../queries/function_bitmap_xor_agg.proto.bin      | Bin 0 -> 448 bytes
 .../sql-functions/sql-expression-schema.md         |   1 +
 .../spark/sql/BitmapExpressionsQuerySuite.scala    | 111 ++++++++++++++++++++-
 15 files changed, 397 insertions(+), 1 deletion(-)

diff --git a/python/docs/source/reference/pyspark.sql/functions.rst 
b/python/docs/source/reference/pyspark.sql/functions.rst
index 229fcaf6c381..9ac05bd3b0ed 100644
--- a/python/docs/source/reference/pyspark.sql/functions.rst
+++ b/python/docs/source/reference/pyspark.sql/functions.rst
@@ -455,6 +455,7 @@ Aggregate Functions
     bit_xor
     bitmap_construct_agg
     bitmap_or_agg
+    bitmap_xor_agg
     bool_and
     bool_or
     collect_list
diff --git a/python/pyspark/sql/connect/functions/builtin.py 
b/python/pyspark/sql/connect/functions/builtin.py
index 9ad1bf43cb63..91fadcf056ab 100644
--- a/python/pyspark/sql/connect/functions/builtin.py
+++ b/python/pyspark/sql/connect/functions/builtin.py
@@ -5655,6 +5655,13 @@ def bitmap_and_agg(col: "ColumnOrName") -> Column:
 bitmap_and_agg.__doc__ = pysparkfuncs.bitmap_and_agg.__doc__
 
 
+def bitmap_xor_agg(col: "ColumnOrName") -> Column:
+    return _invoke_function_over_columns("bitmap_xor_agg", col)
+
+
+bitmap_xor_agg.__doc__ = pysparkfuncs.bitmap_xor_agg.__doc__
+
+
 # Geospatial ST Functions
 
 
diff --git a/python/pyspark/sql/functions/__init__.py 
b/python/pyspark/sql/functions/__init__.py
index 001a7d4784ca..a5a95196c1ad 100644
--- a/python/pyspark/sql/functions/__init__.py
+++ b/python/pyspark/sql/functions/__init__.py
@@ -369,6 +369,7 @@ __all__ = [  # noqa: F405
     "bitmap_and_agg",
     "bitmap_construct_agg",
     "bitmap_or_agg",
+    "bitmap_xor_agg",
     "bool_and",
     "bool_or",
     "collect_list",
diff --git a/python/pyspark/sql/functions/builtin.py 
b/python/pyspark/sql/functions/builtin.py
index 5662ec0487bc..44491a2f6faf 100644
--- a/python/pyspark/sql/functions/builtin.py
+++ b/python/pyspark/sql/functions/builtin.py
@@ -33057,6 +33057,42 @@ def bitmap_and_agg(col: "ColumnOrName") -> Column:
     return _invoke_function_over_columns("bitmap_and_agg", col)
 
 
+@_try_remote_functions
+def bitmap_xor_agg(col: "ColumnOrName") -> Column:
+    """
+    Returns a bitmap that is the bitwise XOR of all of the bitmaps from the 
input column.
+    The input column should be bitmaps created from bitmap_construct_agg().
+
+    .. versionadded:: 4.4.0
+
+    See Also
+    --------
+    :meth:`pyspark.sql.functions.bitmap_bit_position`
+    :meth:`pyspark.sql.functions.bitmap_bucket_number`
+    :meth:`pyspark.sql.functions.bitmap_construct_agg`
+    :meth:`pyspark.sql.functions.bitmap_count`
+    :meth:`pyspark.sql.functions.bitmap_or_agg`
+    :meth:`pyspark.sql.functions.bitmap_and_agg`
+
+    Parameters
+    ----------
+    col : :class:`~pyspark.sql.Column` or column name
+        The input column should be bitmaps created from bitmap_construct_agg().
+
+    Examples
+    --------
+    >>> from pyspark.sql import functions as sf
+    >>> df = spark.createDataFrame([("10",), ("30",), ("40",)], ["a"])
+    >>> df.select(sf.bitmap_xor_agg(sf.to_binary(df.a, sf.lit("hex")))).show()
+    +---------------------------------+
+    |bitmap_xor_agg(to_binary(a, hex))|
+    +---------------------------------+
+    |             [60 00 00 00 00 0...|
+    +---------------------------------+
+    """
+    return _invoke_function_over_columns("bitmap_xor_agg", col)
+
+
 # ---------------------------- User Defined Function 
----------------------------------
 
 
diff --git a/sql/api/src/main/scala/org/apache/spark/sql/functions.scala 
b/sql/api/src/main/scala/org/apache/spark/sql/functions.scala
index 053e0281fa5b..a66216bac9a3 100644
--- a/sql/api/src/main/scala/org/apache/spark/sql/functions.scala
+++ b/sql/api/src/main/scala/org/apache/spark/sql/functions.scala
@@ -7366,6 +7366,20 @@ object functions {
    */
   def bitmap_and_agg(col: Column): Column = Column.fn("bitmap_and_agg", col)
 
+  /**
+   * Returns a bitmap that is the bitwise XOR of all of the bitmaps from the 
input column. The
+   * input column should be bitmaps created from bitmap_construct_agg().
+   *
+   * @param col
+   *   A column containing bitmaps created by bitmap_construct_agg() and 
evaluating to binary
+   *   data.
+   * @group agg_funcs
+   * @since 4.4.0
+   * @return
+   *   Returns a column that evaluates to a binary.
+   */
+  def bitmap_xor_agg(col: Column): Column = Column.fn("bitmap_xor_agg", col)
+
   
//////////////////////////////////////////////////////////////////////////////////////////////
   // String functions
   
//////////////////////////////////////////////////////////////////////////////////////////////
diff --git 
a/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/BitmapExpressionUtils.java
 
b/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/BitmapExpressionUtils.java
index a505b8d7d756..d03db7d952a5 100644
--- 
a/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/BitmapExpressionUtils.java
+++ 
b/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/BitmapExpressionUtils.java
@@ -57,6 +57,15 @@ public class BitmapExpressionUtils {
     }
   }
 
+  /** Performs bitwise XOR on both bitmaps and writes the result into bitmap1. 
*/
+  public static void bitmapXorMerge(byte[] bitmap1, byte[] bitmap2) {
+    // For XOR operation, bytes beyond the shorter bitmap's length are 
unchanged
+    // since XOR with 0 (absent bits) is identity: X ^ 0 = X.
+    for (int i = 0; i < java.lang.Math.min(bitmap1.length, bitmap2.length); 
++i) {
+      bitmap1[i] = (byte) ((bitmap1[i] & 0x0FF) ^ (bitmap2[i] & 0x0FF));
+    }
+  }
+
   /** Performs bitwise AND on both bitmaps and writes the result into bitmap1. 
*/
   public static void bitmapAndMerge(byte[] bitmap1, byte[] bitmap2) {
     int minLen = java.lang.Math.min(bitmap1.length, bitmap2.length);
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionRegistry.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionRegistry.scala
index 0f091af61671..30b65c7ee608 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionRegistry.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionRegistry.scala
@@ -975,6 +975,7 @@ object FunctionRegistry {
     expression[BitmapCount]("bitmap_count"),
     expression[BitmapOrAgg]("bitmap_or_agg"),
     expression[BitmapAndAgg]("bitmap_and_agg"),
+    expression[BitmapXorAgg]("bitmap_xor_agg"),
 
     // json
     expression[StructsToJson]("to_json"),
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/bitmapExpressions.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/bitmapExpressions.scala
index 87c04d229e91..02576f051979 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/bitmapExpressions.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/bitmapExpressions.scala
@@ -445,3 +445,101 @@ case class BitmapAndAgg(
     buffer.getBinary(mutableAggBufferOffset)
   }
 }
+
+@ExpressionDescription(
+  usage = """
+    _FUNC_(child) - Returns a bitmap that is the bitwise XOR of all of the 
bitmaps from the child
+    expression. The input should be bitmaps created from 
bitmap_construct_agg().
+  """,
+  arguments = """
+    Arguments:
+      * child - The expression whose bitmap values are combined with a bitwise 
XOR.
+          An expression that evaluates to a binary bitmap created from 
bitmap_construct_agg().
+  """,
+  // scalastyle:off line.size.limit
+  examples = """
+    Examples:
+      > SELECT substring(hex(_FUNC_(col)), 0, 6) FROM VALUES (X'10'), (X'30'), 
(X'40') AS tab(col);
+       600000
+      > SELECT substring(hex(_FUNC_(col)), 0, 6) FROM VALUES (X'10'), (X'10') 
AS tab(col);
+       000000
+  """,
+  // scalastyle:on line.size.limit
+  since = "4.4.0",
+  group = "agg_funcs")
+case class BitmapXorAgg(
+    child: Expression,
+    mutableAggBufferOffset: Int = 0,
+    inputAggBufferOffset: Int = 0)
+    extends ImperativeAggregate
+    with UnaryLike[Expression] {
+
+  def this(child: Expression) = {
+    this(child = child, mutableAggBufferOffset = 0, inputAggBufferOffset = 0)
+  }
+
+  override def checkInputDataTypes(): TypeCheckResult = {
+    if (child.dataType != BinaryType) {
+      DataTypeMismatch(
+        errorSubClass = "UNEXPECTED_INPUT_TYPE",
+        messageParameters = Map(
+          "paramIndex" -> ordinalNumber(0),
+          "requiredType" -> toSQLType(BinaryType),
+          "inputSql" -> toSQLExpr(child),
+          "inputType" -> toSQLType(child.dataType)))
+    } else {
+      TypeCheckSuccess
+    }
+  }
+
+  override def dataType: DataType = BinaryType
+
+  override def prettyName: String = "bitmap_xor_agg"
+
+  override protected def withNewChildInternal(newChild: Expression): 
BitmapXorAgg =
+    copy(child = newChild)
+
+  override def withNewMutableAggBufferOffset(
+      newMutableAggBufferOffset: Int): ImperativeAggregate =
+    copy(mutableAggBufferOffset = newMutableAggBufferOffset)
+
+  override def withNewInputAggBufferOffset(newInputAggBufferOffset: Int): 
ImperativeAggregate =
+    copy(inputAggBufferOffset = newInputAggBufferOffset)
+
+  override def nullable: Boolean = false
+
+  override def aggBufferSchema: StructType = 
DataTypeUtils.fromAttributes(aggBufferAttributes)
+
+  // The aggregation buffer is a fixed size binary.
+  private val bitmapAttr = AttributeReference("bitmap", BinaryType, false)()
+
+  override def aggBufferAttributes: Seq[AttributeReference] = bitmapAttr :: Nil
+
+  override def defaultResult: Option[Literal] =
+    Option(Literal(Array.fill[Byte](BitmapExpressionUtils.NUM_BYTES)(0)))
+
+  override val inputAggBufferAttributes: Seq[AttributeReference] =
+    aggBufferAttributes.map(_.newInstance())
+
+  override def initialize(buffer: InternalRow): Unit = {
+    buffer.update(mutableAggBufferOffset, 
Array.fill[Byte](BitmapExpressionUtils.NUM_BYTES)(0))
+  }
+
+  override def update(buffer: InternalRow, input: InternalRow): Unit = {
+    val input_bitmap = child.eval(input).asInstanceOf[Array[Byte]]
+    if (input_bitmap != null) {
+      val bitmap = buffer.getBinary(mutableAggBufferOffset)
+      BitmapExpressionUtils.bitmapXorMerge(bitmap, input_bitmap)
+    }
+  }
+
+  override def merge(buffer1: InternalRow, buffer2: InternalRow): Unit = {
+    val bitmap1 = buffer1.getBinary(mutableAggBufferOffset)
+    val bitmap2 = buffer2.getBinary(inputAggBufferOffset)
+    BitmapExpressionUtils.bitmapXorMerge(bitmap1, bitmap2)
+  }
+
+  override def eval(buffer: InternalRow): Any = {
+    buffer.getBinary(mutableAggBufferOffset)
+  }
+}
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/BitmapExpressionUtilsSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/BitmapExpressionUtilsSuite.scala
index 53935c66c613..c33a14aa7b42 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/BitmapExpressionUtilsSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/BitmapExpressionUtilsSuite.scala
@@ -92,4 +92,57 @@ class BitmapExpressionUtilsSuite extends SparkFunSuite {
     setBitmapBits(bitmap, bitmap.length - 1, 0x67)
     assert(BitmapExpressionUtils.bitmapCount(bitmap) == 15L)
   }
+
+  test("bitmap_xor_merge equal length") {
+    val bitmap1 = Array[Byte](0x10, 0x30, 0x40)
+    val bitmap2 = Array[Byte](0x10, 0x20, 0x40)
+    // 0x10 ^ 0x10 = 0x00, 0x30 ^ 0x20 = 0x10, 0x40 ^ 0x40 = 0x00
+    val expected = Array[Byte](0x00, 0x10, 0x00)
+    BitmapExpressionUtils.bitmapXorMerge(bitmap1, bitmap2)
+    for (i <- expected.indices) {
+      assert(bitmap1(i) == expected(i), s"bitmap1($i) should be 
${expected(i)}")
+    }
+  }
+
+  test("bitmap_xor_merge different lengths") {
+    val bitmap1 = Array[Byte](0x0A, 0x0B, 0x0C)
+    val bitmap2 = Array[Byte](0x0A)
+    // 0x0A ^ 0x0A = 0x00, remaining bytes unchanged because XOR 0 = X
+    val expected = Array[Byte](0x00, 0x0B, 0x0C)
+    BitmapExpressionUtils.bitmapXorMerge(bitmap1, bitmap2)
+    for (i <- expected.indices) {
+      assert(bitmap1(i) == expected(i), s"bitmap1($i) should be 
${expected(i)}")
+    }
+  }
+
+  test("bitmap_xor_merge all zeros") {
+    val bitmap1 = Array[Byte](0x10, 0x20)
+    val bitmap2 = Array[Byte](0x00, 0x00)
+    val expected = Array[Byte](0x10, 0x20)
+    BitmapExpressionUtils.bitmapXorMerge(bitmap1, bitmap2)
+    for (i <- expected.indices) {
+      assert(bitmap1(i) == expected(i), s"bitmap1($i) should be 
${expected(i)}")
+    }
+  }
+
+  test("bitmap_xor_merge self xor equals zero") {
+    val bitmap1 = Array[Byte](0x10, 0x30, 0x40)
+    val bitmap2 = Array[Byte](0x10, 0x30, 0x40)
+    val expected = Array[Byte](0x00, 0x00, 0x00)
+    BitmapExpressionUtils.bitmapXorMerge(bitmap1, bitmap2)
+    for (i <- expected.indices) {
+      assert(bitmap1(i) == expected(i), s"bitmap1($i) should be 
${expected(i)}")
+    }
+  }
+
+  test("bitmap_xor_merge with bytes containing sign bits") {
+    val bitmap1 = Array[Byte](0xFF.toByte, 0x80.toByte)
+    val bitmap2 = Array[Byte](0xF0.toByte, 0x0F.toByte)
+    // 0xFF ^ 0xF0 = 0x0F, 0x80 ^ 0x0F = 0x8F
+    val expected = Array[Byte](0x0F.toByte, 0x8F.toByte)
+    BitmapExpressionUtils.bitmapXorMerge(bitmap1, bitmap2)
+    for (i <- expected.indices) {
+      assert(bitmap1(i) == expected(i), s"bitmap1($i) should be 
${expected(i)}")
+    }
+  }
 }
diff --git 
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/PlanGenerationTestSuite.scala
 
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/PlanGenerationTestSuite.scala
index de37f913016d..88f887d302a5 100644
--- 
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/PlanGenerationTestSuite.scala
+++ 
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/PlanGenerationTestSuite.scala
@@ -2266,6 +2266,10 @@ class PlanGenerationTestSuite extends ConnectFunSuite 
with Logging {
     binary.select(fn.bitmap_or_agg(fn.col("bytes")))
   }
 
+  test("function bitmap_xor_agg") {
+    binary.select(fn.bitmap_xor_agg(fn.col("bytes")))
+  }
+
   private def temporalFunctionTest(name: String)(f: => Column): Unit = {
     test("function " + name) {
       temporals.select(f)
diff --git 
a/sql/connect/common/src/test/resources/query-tests/explain-results/function_bitmap_xor_agg.explain
 
b/sql/connect/common/src/test/resources/query-tests/explain-results/function_bitmap_xor_agg.explain
new file mode 100644
index 000000000000..ee4bb977934c
--- /dev/null
+++ 
b/sql/connect/common/src/test/resources/query-tests/explain-results/function_bitmap_xor_agg.explain
@@ -0,0 +1,2 @@
+Aggregate [bitmap_xor_agg(bytes#0, 0, 0) AS bitmap_xor_agg(bytes)#0]
++- LocalRelation <empty>, [id#0L, bytes#0]
diff --git 
a/sql/connect/common/src/test/resources/query-tests/queries/function_bitmap_xor_agg.json
 
b/sql/connect/common/src/test/resources/query-tests/queries/function_bitmap_xor_agg.json
new file mode 100644
index 000000000000..12f2878b6b97
--- /dev/null
+++ 
b/sql/connect/common/src/test/resources/query-tests/queries/function_bitmap_xor_agg.json
@@ -0,0 +1,60 @@
+{
+  "common": {
+    "planId": "1"
+  },
+  "project": {
+    "input": {
+      "common": {
+        "planId": "0"
+      },
+      "localRelation": {
+        "schema": "struct\u003cid:bigint,bytes:binary\u003e"
+      }
+    },
+    "expressions": [{
+      "unresolvedFunction": {
+        "functionName": "bitmap_xor_agg",
+        "arguments": [{
+          "unresolvedAttribute": {
+            "unparsedIdentifier": "bytes"
+          },
+          "common": {
+            "origin": {
+              "jvmOrigin": {
+                "stackTrace": [{
+                  "classLoaderName": "app",
+                  "declaringClass": "org.apache.spark.sql.functions$",
+                  "methodName": "col",
+                  "fileName": "functions.scala"
+                }, {
+                  "classLoaderName": "app",
+                  "declaringClass": 
"org.apache.spark.sql.PlanGenerationTestSuite",
+                  "methodName": "~~trimmed~anonfun~~",
+                  "fileName": "PlanGenerationTestSuite.scala"
+                }]
+              }
+            }
+          }
+        }],
+        "isInternal": false
+      },
+      "common": {
+        "origin": {
+          "jvmOrigin": {
+            "stackTrace": [{
+              "classLoaderName": "app",
+              "declaringClass": "org.apache.spark.sql.functions$",
+              "methodName": "bitmap_xor_agg",
+              "fileName": "functions.scala"
+            }, {
+              "classLoaderName": "app",
+              "declaringClass": "org.apache.spark.sql.PlanGenerationTestSuite",
+              "methodName": "~~trimmed~anonfun~~",
+              "fileName": "PlanGenerationTestSuite.scala"
+            }]
+          }
+        }
+      }
+    }]
+  }
+}
\ No newline at end of file
diff --git 
a/sql/connect/common/src/test/resources/query-tests/queries/function_bitmap_xor_agg.proto.bin
 
b/sql/connect/common/src/test/resources/query-tests/queries/function_bitmap_xor_agg.proto.bin
new file mode 100644
index 000000000000..27ca4a6c3880
Binary files /dev/null and 
b/sql/connect/common/src/test/resources/query-tests/queries/function_bitmap_xor_agg.proto.bin
 differ
diff --git a/sql/core/src/test/resources/sql-functions/sql-expression-schema.md 
b/sql/core/src/test/resources/sql-functions/sql-expression-schema.md
index 8a4d226fd94a..2db516884258 100644
--- a/sql/core/src/test/resources/sql-functions/sql-expression-schema.md
+++ b/sql/core/src/test/resources/sql-functions/sql-expression-schema.md
@@ -55,6 +55,7 @@
 | org.apache.spark.sql.catalyst.expressions.BitmapConstructAgg | 
bitmap_construct_agg | SELECT 
substring(hex(bitmap_construct_agg(bitmap_bit_position(col))), 0, 6) FROM 
VALUES (1), (2), (3) AS tab(col) | 
struct<substring(hex(bitmap_construct_agg(bitmap_bit_position(col))), 0, 
6):string> |
 | org.apache.spark.sql.catalyst.expressions.BitmapCount | bitmap_count | 
SELECT bitmap_count(X '1010') | struct<bitmap_count(X'1010'):bigint> |
 | org.apache.spark.sql.catalyst.expressions.BitmapOrAgg | bitmap_or_agg | 
SELECT substring(hex(bitmap_or_agg(col)), 0, 6) FROM VALUES (X '10'), (X '20'), 
(X '40') AS tab(col) | struct<substring(hex(bitmap_or_agg(col)), 0, 6):string> |
+| org.apache.spark.sql.catalyst.expressions.BitmapXorAgg | bitmap_xor_agg | 
SELECT substring(hex(bitmap_xor_agg(col)), 0, 6) FROM VALUES (X'10'), (X'30'), 
(X'40') AS tab(col) | struct<substring(hex(bitmap_xor_agg(col)), 0, 6):string> |
 | org.apache.spark.sql.catalyst.expressions.BitwiseAnd | & | SELECT 3 & 5 | 
struct<(3 & 5):int> |
 | org.apache.spark.sql.catalyst.expressions.BitwiseCount | bit_count | SELECT 
bit_count(0) | struct<bit_count(0):int> |
 | org.apache.spark.sql.catalyst.expressions.BitwiseGet | bit_get | SELECT 
bit_get(11, 0) | struct<bit_get(11, 0):tinyint> |
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/BitmapExpressionsQuerySuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/BitmapExpressionsQuerySuite.scala
index 458688de3fbd..8201e2c2f872 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/BitmapExpressionsQuerySuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/BitmapExpressionsQuerySuite.scala
@@ -17,7 +17,20 @@
 
 package org.apache.spark.sql
 
-import org.apache.spark.sql.functions.{bitmap_and_agg, bitmap_bit_position, 
bitmap_bucket_number, bitmap_construct_agg, bitmap_count, bitmap_or_agg, col, 
expr, hex, lit, substring, to_binary}
+import org.apache.spark.sql.functions.{
+  bitmap_and_agg,
+  bitmap_bit_position,
+  bitmap_bucket_number,
+  bitmap_construct_agg,
+  bitmap_count,
+  bitmap_or_agg,
+  bitmap_xor_agg,
+  col,
+  expr,
+  hex,
+  lit,
+  substring,
+  to_binary}
 import org.apache.spark.sql.test.SharedSparkSession
 
 class BitmapExpressionsQuerySuite extends SharedSparkSession {
@@ -356,4 +369,100 @@ class BitmapExpressionsQuerySuite extends 
SharedSparkSession {
         "inputType" -> "\"INT\""),
       context = ExpectedContext(fragment = "bitmap_and_agg(a)", start = 0, 
stop = 16))
   }
+
+  test("bitmap_xor_agg") {
+    // Test basic XOR functionality: 10 ^ 30 ^ 40 = 60
+    val df = Seq("10", "30", "40").toDF("a")
+    checkAnswer(
+      df.selectExpr("substring(hex(bitmap_xor_agg(to_binary(a, 'hex'))), 0, 
6)"),
+      Seq(Row("600000")))
+    checkAnswer(
+      df.select(substring(hex(bitmap_xor_agg(to_binary(col("a"), 
lit("hex")))), 0, 6)),
+      Seq(Row("600000")))
+
+    // Test with same values - XOR should result in zero: 10 ^ 10 = 00
+    val df2 = Seq("10", "10").toDF("a")
+    checkAnswer(
+      df2.selectExpr("substring(hex(bitmap_xor_agg(to_binary(a, 'hex'))), 0, 
6)"),
+      Seq(Row("000000")))
+
+    // Test with zero - X XOR 0 = X: A0 ^ 00 = A0
+    val df3 = Seq("A0", "00").toDF("a")
+    checkAnswer(
+      df3.selectExpr("substring(hex(bitmap_xor_agg(to_binary(a, 'hex'))), 0, 
6)"),
+      Seq(Row("A00000")))
+
+    // Test with binary values of different lengths: 0A0B ^ 0A = 000B
+    val df4 = Seq("0A0B", "0A").toDF("a")
+    checkAnswer(
+      df4.selectExpr("substring(hex(bitmap_xor_agg(to_binary(a, 'hex'))), 0, 
6)"),
+      Seq(Row("000B00")))
+
+    // Test empty result (no rows) - should return all zeros as XOR identity
+    val emptyDf = Seq.empty[String].toDF("a")
+    checkAnswer(
+      emptyDf.selectExpr("substring(hex(bitmap_xor_agg(to_binary(a, 'hex'))), 
0, 6)"),
+      Seq(Row("000000")))
+
+    val emptyDf2 = Seq.empty[(String, Int)].toDF("a", "b")
+    checkAnswer(
+      emptyDf2
+        .selectExpr("bitmap_xor_agg(to_binary(a, 'hex')) " +
+          "filter (where b = 1) as xor_agg")
+        .select(substring(hex(col("xor_agg")), 0, 6)),
+      Seq(Row("000000")))
+
+    // Test empty result (no rows) with GROUP BY - should return empty 
DataFrame
+    val emptyDf3 = Seq.empty[(String, Int, Int)].toDF("a", "b", "c")
+    checkAnswer(
+      emptyDf3
+        .groupBy("c")
+        .agg(expr("bitmap_xor_agg(to_binary(a, 'hex')) " +
+          "filter (where b = 1) as xor_agg").alias("xor_agg"))
+        .select(substring(hex(col("xor_agg")), 0, 6)),
+      Seq())
+  }
+
+  test("bitmap_xor_agg with complex bitmaps from bitmap_construct_agg") {
+    val table = "bitmap_xor_test_table"
+    withTable(table) {
+      // Create test data: Group 1 has bits 1,2,4; Group 2 has bits 1,2,3.
+      spark.sql(s"""
+           | CREATE TABLE $table (group_id INT, bit_pos LONG)
+           | """.stripMargin)
+      spark.sql(s"""
+           | INSERT INTO $table VALUES
+           | (1, 1), (1, 2), (1, 4),
+           | (2, 1), (2, 2), (2, 3)
+           | """.stripMargin)
+      // XOR of the group bitmaps cancels bits 1 and 2, leaving bits 3 and 4.
+      val xorResult = spark.sql(s"""
+           | SELECT bitmap_count(
+           |   bitmap_xor_agg(group_bitmap)
+           | ) as xor_count
+           | FROM (
+           |   SELECT bitmap_construct_agg(bitmap_bit_position(bit_pos)) as 
group_bitmap
+           |   FROM $table
+           |   GROUP BY group_id
+           | )
+           | """.stripMargin)
+      checkAnswer(xorResult, Seq(Row(2)))
+    }
+  }
+
+  test("bitmap_xor_agg called with non-binary type") {
+    val df = Seq(12).toDF("a")
+    checkError(
+      exception = intercept[AnalysisException] {
+        df.selectExpr("bitmap_xor_agg(a)")
+      },
+      condition = "DATATYPE_MISMATCH.UNEXPECTED_INPUT_TYPE",
+      parameters = Map(
+        "sqlExpr" -> "\"bitmap_xor_agg(a)\"",
+        "paramIndex" -> "first",
+        "requiredType" -> "\"BINARY\"",
+        "inputSql" -> "\"a\"",
+        "inputType" -> "\"INT\""),
+      context = ExpectedContext(fragment = "bitmap_xor_agg(a)", start = 0, 
stop = 16))
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to