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 ff2eee7fafb Support PostgreSQL hex bytea literals (#19263)
ff2eee7fafb is described below

commit ff2eee7fafb83d9a0886fc40bd620e010196e30a
Author: Xiang Fu <[email protected]>
AuthorDate: Mon Sep 21 17:57:51 2026 -0700

    Support PostgreSQL hex bytea literals (#19263)
    
    * Support PostgreSQL hex bytea literals
    
    Accept PostgreSQL hex-format BYTEA constants in both the infix 
`'\x0102'::bytea`
    form and the standard `CAST('\x0102' AS BYTEA)` spelling, normalizing them 
to
    Pinot's existing binary literal representation before either engine plans 
the
    query. The SQL-standard `X'0102'` form is unchanged.
    
    Only quoted constants are normalized. A dynamic STRING-to-BYTES conversion 
would
    have to be implemented per row in each engine, and the two would be easy to
    drift apart. PostgreSQL's legacy octal escape format stays unsupported.
    
    Parsing:
    
    - `InfixCast` appends the operator and target type to the enclosing 
expression
      list as a `SqlParserUtil.ToTreeListItem`, so precedence is resolved by
      `Expression2` the same way as the adjacent `SqlAtTimeZone` and Calcite's 
own
      Babel production. Collapsing the list eagerly would make `::` bind 
everything
      to its left, which breaks the most common shape:
      `WHERE bytesCol = '\x01'::bytea` would parse as
      `CAST(bytesCol = '\x01' AS BYTEA)`.
    - The `[]` and `.ident` postfix forms need no handling here; `Expression2`
      already owns those branches.
    
    Rewriting:
    
    - Only ASCII hex digits and ASCII whitespace are accepted. 
`Character.digit` and
      `Character.isWhitespace` also accept full-width and non-Latin forms, which
      PostgreSQL rejects. Whitespace is allowed between byte pairs but not 
inside
      one, matching PostgreSQL.
    - A `::` whose target type does not survive as a type spec (`col::bytea[1]`,
      where the item accessor binds tighter) is rejected rather than passed on 
as a
      malformed call.
    - Non-BYTEA `::` casts are rejected. This is a deliberate scope limit, not a
      technical one: `INFIX_CAST` is an ordinary `SqlCastOperator` of kind 
`CAST`,
      so `intCol::double` would plan like `CAST(intCol AS DOUBLE)`. Supporting 
the
      full operator means committing to `::` type-name semantics across both
      engines and is left to a follow-up. The grammar accepts `::` so that this
      rewriter can report a clear error instead of a parse failure.
    - Error messages name the offending type or expression and distinguish a
      malformed hex constant from a non-constant operand from an unsupported 
target.
    
    Tests cover scalar literals, BYTES array construction, bytea constants as 
the
    right operand of `=`, `<>`, `>`, `<=` and inside compound predicates, 
agreement
    between the `::`, `CAST` and `X'...'` spellings in both parse paths and both
    cases, ingested multi-value BYTES predicates, planner type inference, and
    runtime execution against H2.
    
    * Rewrite bytea constants in place to preserve Pinot statement nodes
    
    The rewriter extended SqlShuttle, which is copy-on-write: replacing a 
constant
    rebuilt every ancestor through SqlOperator.createCall, and that does not
    preserve Pinot's own statement classes. EXPLAIN IMPLEMENTATION PLAN FOR 
with a
    bytea constant came back as a plain SqlExplain, so the broker silently 
returned
    the logical plan, and CREATE MATERIALIZED VIEW came back as a SqlBasicCall
    classified as DQL, so the DDL compiler rejected it.
    
    Replace only the constant's direct parent, through SqlCall.setOperand or
    SqlNodeList.set, so every other node keeps its identity and class. Every 
node
    under which Pinot's grammar accepts an arbitrary expression supports this; 
the
    exceptions are positions the grammar restricts, such as SqlOrderBy's numeric
    offset and fetch, and Pinot's own statement nodes, which hold queries,
    identifiers and literals. A parent that cannot be updated in place is 
rejected
    with a clear error instead of being rebuilt.
    
    Record the position of the `::` token itself in InfixCast, as
    BinaryRowOperator does. s.pos() started at the token before the enclosing
    expression, so the cast and its rewritten literal claimed spans such as
    `WHERE b = '\x01'::bytea`, which misplaced validator error highlights.
    
    Also from review:
    
    - Name hexToBytes(<expr>) as the per-row alternative when a BYTEA cast has a
      non-constant operand.
    - Say that only the hex format is supported when a constant lacks the \x
      prefix. PostgreSQL reads '0102'::bytea as escape format (four bytes).
    - Run the rewrite outside compileToExpression's catch-all so its messages 
reach
      the caller instead of only its cause.
    - Link the BYTEA-only scope of `::` to the follow-up issue, #19628.
    - Test X'...'::bytea and CAST(X'...' AS BYTEA), the case-sensitive \x 
prefix,
      an IN list, the literal's source span, node class and statement type for
      EXPLAIN and CREATE MATERIALIZED VIEW (including a nested subquery), the
      physical plan returned by EXPLAIN IMPLEMENTATION PLAN, and the stored
      definition of a materialized view whose body contains a bytea constant.
---
 .../LiteralOnlyBrokerRequestTest.java              |  50 ++++-
 pinot-common/src/main/codegen/config.fmpp          |   2 +
 .../src/main/codegen/includes/parserImpls.ftl      |  25 +++
 .../apache/pinot/sql/parsers/CalciteSqlParser.java |   3 +
 .../pinot/sql/parsers/PostgreSqlCastRewriter.java  | 201 ++++++++++++++++++
 .../pinot/common/request/LiteralSerDeTest.java     |   2 +
 .../common/utils/request/RequestUtilsTest.java     |   6 +
 .../pinot/sql/parsers/CalciteSqlParserTest.java    | 233 +++++++++++++++++++++
 .../integration/tests/custom/BytesMvTypeTest.java  |  56 ++---
 .../integration/tests/custom/BytesTypeTest.java    |  21 ++
 .../apache/pinot/query/QueryCompilationTest.java   |  26 +++
 .../src/test/resources/queries/BinaryTypes.json    |  27 ++-
 .../compile/DdlCompilerMaterializedViewTest.java   |  22 ++
 13 files changed, 634 insertions(+), 40 deletions(-)

diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/LiteralOnlyBrokerRequestTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/LiteralOnlyBrokerRequestTest.java
index 5db658610dc..fa3e4eca876 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/LiteralOnlyBrokerRequestTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/LiteralOnlyBrokerRequestTest.java
@@ -97,12 +97,16 @@ public class LiteralOnlyBrokerRequestTest {
     assertEquals((int[]) resultTable.getRows().get(0)[0], new int[]{1, 2});
     assertEquals((String[]) resultTable.getRows().get(0)[1], new 
String[]{"one", "two"});
 
-    brokerResponse = requestHandler.handleRequest("SELECT ARRAY[X'00', 
X'0102'] AS bytes");
-    resultTable = brokerResponse.getResultTable();
-    assertEquals(resultTable.getDataSchema().getColumnName(0), "bytes");
-    assertEquals(resultTable.getDataSchema().getColumnDataType(0), 
DataSchema.ColumnDataType.BYTES_ARRAY);
-    assertEquals(resultTable.getRows().size(), 1);
-    assertEquals(resultTable.getRows().get(0), new Object[]{new String[]{"00", 
"0102"}});
+    // The SQL-standard and PostgreSQL spellings must produce the same 
BYTES_ARRAY response.
+    for (String arrayLiteral : List.of("ARRAY[X'00', X'0102']", 
"ARRAY['\\x00'::bytea, '\\x0102'::bytea]",
+        "ARRAY[CAST('\\x00' AS BYTEA), CAST('\\x0102' AS BYTEA)]")) {
+      brokerResponse = requestHandler.handleRequest("SELECT " + arrayLiteral + 
" AS bytes");
+      resultTable = brokerResponse.getResultTable();
+      assertEquals(resultTable.getDataSchema().getColumnName(0), "bytes");
+      assertEquals(resultTable.getDataSchema().getColumnDataType(0), 
DataSchema.ColumnDataType.BYTES_ARRAY);
+      assertEquals(resultTable.getRows().size(), 1);
+      assertEquals(resultTable.getRows().get(0), new Object[]{new 
String[]{"00", "0102"}});
+    }
 
     brokerResponse = requestHandler.handleRequest(
         "SELECT ARRAYS_OVERLAP(ARRAY[X'00', X'0102'], ARRAY[X'03', X'0102']) 
AS overlaps");
@@ -111,6 +115,40 @@ public class LiteralOnlyBrokerRequestTest {
     assertEquals(resultTable.getRows().get(0)[0], true);
   }
 
+  /// A scalar bytea constant must be answered by the literal-only path 
exactly like `X'...'`, in both spellings.
+  @Test
+  public void testScalarBytesLiteralBrokerRequestFromSQL()
+      throws Exception {
+    SingleConnectionBrokerRequestHandler requestHandler =
+        new SingleConnectionBrokerRequestHandler(new PinotConfiguration(), 
"testBrokerId",
+            new BrokerRequestIdGenerator(), null, ACCESS_CONTROL_FACTORY, 
null, null, null, null,
+            mock(ServerRoutingStatsManager.class), mock(FailureDetector.class),
+            ThreadAccountantUtils.getNoOpAccountant(), null, null);
+
+    for (String literal : List.of("X'0102'", "'\\x0102'::bytea", 
"CAST('\\x0102' AS BYTEA)")) {
+      
assertTrue(isLiteralOnlyQuery(CalciteSqlParser.compileToPinotQuery("SELECT " + 
literal)));
+      BrokerResponse brokerResponse = requestHandler.handleRequest("SELECT " + 
literal + " AS b");
+      ResultTable resultTable = brokerResponse.getResultTable();
+      assertTrue(brokerResponse.getExceptions().isEmpty(), literal);
+      assertEquals(resultTable.getDataSchema().getColumnDataType(0), 
DataSchema.ColumnDataType.BYTES, literal);
+      assertEquals(resultTable.getRows().get(0)[0], "0102", literal);
+    }
+
+    // An empty bytea constant is legal in PostgreSQL and yields zero-length 
BYTES.
+    BrokerResponse brokerResponse = requestHandler.handleRequest("SELECT 
'\\x'::bytea AS b");
+    
assertEquals(brokerResponse.getResultTable().getDataSchema().getColumnDataType(0),
+        DataSchema.ColumnDataType.BYTES);
+    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0], "");
+
+    // Constant folding over bytea elements still happens at parse time, so 
the query stays literal-only.
+    String folded = "SELECT ARRAY_LENGTH(ARRAY['\\x00'::bytea, 
'\\x0102'::bytea]) AS n";
+    
assertTrue(isLiteralOnlyQuery(CalciteSqlParser.compileToPinotQuery(folded)));
+    brokerResponse = requestHandler.handleRequest(folded);
+    
assertEquals(brokerResponse.getResultTable().getDataSchema().getColumnDataType(0),
+        DataSchema.ColumnDataType.INT);
+    assertEquals(brokerResponse.getResultTable().getRows().get(0)[0], 2);
+  }
+
   @Test
   public void testLiteralOnlyTransformBrokerRequestFromSQL() {
     assertTrue(isLiteralOnlyQuery(CalciteSqlParser.compileToPinotQuery("SELECT 
now()")));
diff --git a/pinot-common/src/main/codegen/config.fmpp 
b/pinot-common/src/main/codegen/config.fmpp
index 32b71647386..7e3493d183b 100644
--- a/pinot-common/src/main/codegen/config.fmpp
+++ b/pinot-common/src/main/codegen/config.fmpp
@@ -654,11 +654,13 @@ data: {
     # Binary operators tokens.
     # Example: "< INFIX_CAST: \"::\" >".
     binaryOperatorsTokens: [
+      "< INFIX_CAST: \"::\" >"
     ]
 
     # Binary operators initialization.
     # Example: "InfixCast".
     extraBinaryExpressions: [
+      "InfixCast"
       "SqlAtTimeZone"
     ]
 
diff --git a/pinot-common/src/main/codegen/includes/parserImpls.ftl 
b/pinot-common/src/main/codegen/includes/parserImpls.ftl
index 06070cff935..161fad10ca3 100644
--- a/pinot-common/src/main/codegen/includes/parserImpls.ftl
+++ b/pinot-common/src/main/codegen/includes/parserImpls.ftl
@@ -88,6 +88,31 @@ void SqlAtTimeZone(List<Object> list, ExprContext 
exprContext, Span s) :
     }
 }
 
+/// Parses the PostgreSQL infix `::` cast operator. The operator and its 
target type are appended to the enclosing
+/// expression list so `SqlParserUtil.toTree` applies the standard precedence 
rules, exactly like `SqlAtTimeZone`
+/// above; collapsing the list here instead would make `::` bind the whole 
expression to its left. PostgreSQL BYTEA
+/// literals are normalized after parsing so both query engines receive the 
same binary literal representation as
+/// SQL `X'...'`.
+///
+/// The operator records the position of the `::` token itself, as 
`BinaryRowOperator` does; `toTree` then widens it
+/// to cover the operands. `s.pos()` would instead start at the token before 
the enclosing expression, so the cast
+/// and the literal it is rewritten to would claim a span such as `WHERE b = 
'\x01'::bytea`.
+void InfixCast(List<Object> list, ExprContext exprContext, Span s) :
+{
+    final SqlParserPos pos;
+    final SqlDataTypeSpec dataType;
+}
+{
+    <INFIX_CAST> {
+        checkNonQueryExpression(exprContext);
+        pos = getPos();
+    }
+    dataType = DataType() {
+        list.add(new 
SqlParserUtil.ToTreeListItem(SqlLibraryOperators.INFIX_CAST, pos));
+        list.add(dataType);
+    }
+}
+
 SqlNode SqlPhysicalExplain() :
 {
     SqlNode stmt;
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
index d50f0f1e8dd..0d85fb9b03d 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
@@ -140,6 +140,7 @@ public class CalciteSqlParser {
     try (StringReader inStream = new StringReader(sql)) {
       SqlParserImpl sqlParser = newSqlParser(inStream);
       SqlNodeList sqlNodeList = sqlParser.parseSqlStmtList();
+      sqlNodeList = (SqlNodeList) PostgreSqlCastRewriter.rewrite(sqlNodeList);
       // Extract OPTION statements from sql.
       SqlNodeAndOptions sqlNodeAndOptions = 
extractSqlNodeAndOptions(sqlNodeList);
       // add legacy OPTIONS keyword-based options
@@ -672,6 +673,8 @@ public class CalciteSqlParser {
     } catch (Throwable e) {
       throw new SqlCompilationException("Caught exception while parsing 
expression: " + expression, e);
     }
+    // Outside the try: the rewriter already throws SqlCompilationException, 
and wrapping it would drop its message.
+    sqlNode = PostgreSqlCastRewriter.rewrite(sqlNode);
     return toExpression(sqlNode);
   }
 
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/PostgreSqlCastRewriter.java
 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/PostgreSqlCastRewriter.java
new file mode 100644
index 00000000000..ed6974e0e78
--- /dev/null
+++ 
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/PostgreSqlCastRewriter.java
@@ -0,0 +1,201 @@
+/**
+ * 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.pinot.sql.parsers;
+
+import java.util.List;
+import org.apache.calcite.sql.SqlBinaryStringLiteral;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlCharStringLiteral;
+import org.apache.calcite.sql.SqlDataTypeSpec;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlNodeList;
+import org.apache.calcite.sql.fun.SqlLibraryOperators;
+import org.apache.calcite.sql.util.SqlShuttle;
+
+
+/// Normalizes PostgreSQL hex-format BYTEA constants (`'\x0102'::bytea` and 
`CAST('\x0102' AS BYTEA)`) to Calcite
+/// binary literals, so both query engines see the same representation they 
would get from SQL `X'0102'`. Only quoted
+/// constants are normalized: a dynamic `STRING`-to-`BYTES` conversion would 
have to be implemented per row in each
+/// engine, and the two would be easy to drift apart.
+///
+/// Every other use of the PostgreSQL `::` operator is rejected here. That is 
a deliberate scope limit, not a
+/// technical one — `SqlLibraryOperators.INFIX_CAST` is an ordinary 
`SqlCastOperator` of kind
+/// [org.apache.calcite.sql.SqlKind#CAST], so `intCol::double` would in fact 
plan and run like `CAST(intCol AS
+/// DOUBLE)`. Supporting the full operator means committing to `::` type-name 
semantics across both engines and is
+/// tracked in https://github.com/apache/pinot/issues/19628; until then the 
grammar accepts `::` only so that this
+/// rewriter can give a clear error instead of a parse failure. Rejecting now 
keeps that open: relaxing a rejection
+/// later is backward compatible, while narrowing an accepted type would not 
be.
+///
+/// The tree is rewritten in place. A plain [SqlShuttle] is copy-on-write: it 
rebuilds every ancestor of a replaced
+/// node through `SqlOperator.createCall`, and that does not preserve Pinot's 
own statement classes. A
+/// `SqlPhysicalExplain` comes back as a plain `SqlExplain` and a 
`SqlPinotCreateMaterializedView` as a `SqlBasicCall`,
+/// which silently changes how the statement is planned or classified. Here 
only a constant's direct parent is
+/// modified, through `SqlCall.setOperand` or `SqlNodeList.set`, so every 
other node keeps its identity and class.
+/// Every node under which Pinot's grammar accepts an arbitrary expression 
supports that. The exceptions are positions
+/// the grammar restricts: `SqlOrderBy` holds its offset and fetch directly, 
but they must be numeric, and Pinot's own
+/// statement nodes hold queries, identifiers and literals rather than bare 
expressions. A parent that cannot be
+/// updated in place is rejected with a clear error rather than rebuilt.
+///
+/// Stateless and safe to share across threads. It mutates only the tree 
passed to `rewrite`, which must be owned by
+/// the caller, such as a freshly parsed statement.
+final class PostgreSqlCastRewriter extends SqlShuttle {
+  private static final PostgreSqlCastRewriter INSTANCE = new 
PostgreSqlCastRewriter();
+  private static final String HEX_PREFIX = "\\x";
+  private static final String BYTEA_TYPE_NAME = "BYTEA";
+
+  private PostgreSqlCastRewriter() {
+  }
+
+  /// Rewrites `sqlNode` in place and returns it, or returns the replacement 
literal when `sqlNode` is itself a bytea
+  /// constant, as for an expression parsed on its own.
+  static SqlNode rewrite(SqlNode sqlNode) {
+    return sqlNode.accept(INSTANCE);
+  }
+
+  @Override
+  public SqlNode visit(SqlNodeList nodeList) {
+    for (int i = 0; i < nodeList.size(); i++) {
+      SqlNode node = nodeList.get(i);
+      if (node != null) {
+        SqlNode rewritten = node.accept(this);
+        if (rewritten != node) {
+          try {
+            nodeList.set(i, rewritten);
+          } catch (UnsupportedOperationException e) {
+            throw unsupportedParent("an immutable node list", e);
+          }
+        }
+      }
+    }
+    return nodeList;
+  }
+
+  @Override
+  public SqlNode visit(SqlCall call) {
+    List<SqlNode> operands = call.getOperandList();
+    for (int i = 0; i < operands.size(); i++) {
+      SqlNode operand = operands.get(i);
+      if (operand != null) {
+        SqlNode rewritten = operand.accept(this);
+        if (rewritten != operand) {
+          setOperand(call, i, rewritten);
+        }
+      }
+    }
+    return rewriteCast(call);
+  }
+
+  private static void setOperand(SqlCall parent, int index, SqlNode 
replacement) {
+    try {
+      parent.setOperand(index, replacement);
+    } catch (UnsupportedOperationException e) {
+      throw unsupportedParent(parent.getKind().toString(), e);
+    }
+  }
+
+  /// Not reachable from Pinot's grammar today. Fails with a clear error if a 
future node holds a bytea constant where
+  /// it cannot be replaced in place, rather than falling back to rebuilding 
the node.
+  private static SqlCompilationException unsupportedParent(String parent, 
UnsupportedOperationException cause) {
+    return new SqlCompilationException(
+        "PostgreSQL BYTEA constants are not supported inside " + parent + "; 
use X'...' instead", cause);
+  }
+
+  /// Returns the binary literal that a supported bytea constant normalizes 
to, or `call` itself when it is not a cast
+  /// this rewriter handles. Throws for every other use of the PostgreSQL `::` 
operator.
+  private static SqlNode rewriteCast(SqlCall call) {
+    List<SqlNode> operands = call.getOperandList();
+    boolean infixCast = call.getOperator() == SqlLibraryOperators.INFIX_CAST;
+    if (call.getKind() != SqlKind.CAST || operands.size() != 2 || 
!(operands.get(1) instanceof SqlDataTypeSpec)) {
+      if (infixCast) {
+        // The target type did not survive as a type spec, e.g. 
`col::bytea[1]`, where the item accessor binds
+        // tighter than `::`. Reject it here rather than letting the malformed 
call reach the planner.
+        throw new SqlCompilationException("Unsupported PostgreSQL :: cast 
target in '" + call
+            + "'. Note that [] binds tighter than ::, so write CAST(<expr> AS 
<type>) instead");
+      }
+      return call;
+    }
+
+    SqlDataTypeSpec targetType = (SqlDataTypeSpec) operands.get(1);
+    boolean bytea = targetType.getTypeName().isSimple()
+        && 
targetType.getTypeName().getSimple().equalsIgnoreCase(BYTEA_TYPE_NAME);
+    if (!bytea) {
+      if (infixCast) {
+        throw new SqlCompilationException("PostgreSQL-style :: casts are 
supported only for BYTEA hex constants, "
+            + "not for target type '" + targetType.getTypeName() + "'. Use 
CAST(<expr> AS <type>) instead");
+      }
+      return call;
+    }
+
+    SqlNode source = operands.get(0);
+    if (source instanceof SqlBinaryStringLiteral) {
+      return source;
+    }
+    if (!(source instanceof SqlCharStringLiteral)) {
+      throw new SqlCompilationException("BYTEA casts are supported only for 
quoted hex constants such as "
+          + "'\\x0102', not for the expression '" + source + "'. To convert 
hex strings per row, use "
+          + "hexToBytes(<expr>), which takes plain hexadecimal digits without 
the \\x prefix");
+    }
+    String value = ((SqlCharStringLiteral) source).getValueAs(String.class);
+    if (!value.startsWith(HEX_PREFIX)) {
+      throw invalidByteaLiteral(value);
+    }
+    return SqlBinaryStringLiteral.createBinaryString(normalizeHex(value), 
call.getParserPosition());
+  }
+
+  /// Decodes the digits after the leading `\x`. PostgreSQL allows whitespace 
between byte pairs but not inside one.
+  private static String normalizeHex(String literal) {
+    String value = literal.substring(HEX_PREFIX.length());
+    StringBuilder hex = new StringBuilder(value.length());
+    for (int i = 0; i < value.length(); i++) {
+      char c = value.charAt(i);
+      if (isAsciiWhitespace(c)) {
+        if ((hex.length() & 1) != 0) {
+          throw invalidByteaLiteral(literal);
+        }
+      } else if (isAsciiHexDigit(c)) {
+        hex.append(c);
+      } else {
+        throw invalidByteaLiteral(literal);
+      }
+    }
+    if ((hex.length() & 1) != 0) {
+      throw invalidByteaLiteral(literal);
+    }
+    return hex.toString();
+  }
+
+  /// Deliberately not `Character.digit(c, 16)`: that also accepts full-width 
and non-Latin digits such as `A` and
+  /// `١`, which PostgreSQL rejects.
+  private static boolean isAsciiHexDigit(char c) {
+    return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c 
<= 'F');
+  }
+
+  /// Deliberately not `Character.isWhitespace(c)`, for the same reason as 
[#isAsciiHexDigit]: PostgreSQL only skips
+  /// ASCII whitespace between byte pairs, so a non-ASCII space such as U+205F 
between them is an error.
+  private static boolean isAsciiWhitespace(char c) {
+    return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c 
== 0x0B;
+  }
+
+  private static SqlCompilationException invalidByteaLiteral(String value) {
+    return new SqlCompilationException("Invalid PostgreSQL BYTEA constant '" + 
value + "': only the hex format is "
+        + "supported, so it must begin with \\x and contain complete 
hexadecimal byte pairs, optionally separated by "
+        + "ASCII whitespace");
+  }
+}
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/request/LiteralSerDeTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/request/LiteralSerDeTest.java
index 1056f9a11ba..5727cb145ed 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/request/LiteralSerDeTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/request/LiteralSerDeTest.java
@@ -93,7 +93,9 @@ public class LiteralSerDeTest {
   public void testSingleStageQueryUsesNativeBytesArrayLiteral()
       throws TException {
     for (String sql : List.of("SELECT ARRAY[X'00', X'0102'] FROM myTable",
+        "SELECT ARRAY['\\x00'::bytea, CAST('\\x0102' AS BYTEA)] FROM myTable",
         "SELECT id FROM myTable WHERE ARRAYS_OVERLAP(bytesMV, ARRAY[X'01'])",
+        "SELECT id FROM myTable WHERE ARRAYS_OVERLAP(bytesMV, 
ARRAY['\\x01'::bytea])",
         "SELECT ARRAY[X'02'], COUNT(*) FROM myTable GROUP BY ARRAY[X'02']",
         "SELECT COUNT(*) FROM myTable HAVING 
ARRAYS_OVERLAP(ARRAYAGG(bytesColumn, 'BYTES'), ARRAY[X'03'])",
         "SELECT id FROM myTable "
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/request/RequestUtilsTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/request/RequestUtilsTest.java
index e9acb5336de..a65148b3790 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/request/RequestUtilsTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/request/RequestUtilsTest.java
@@ -126,6 +126,12 @@ public class RequestUtilsTest {
     assertTrue(expression.getLiteral().isSetBytesArrayValue());
     assertEquals(RequestUtils.getBytesArrayValue(expression.getLiteral()), 
expected);
 
+    expression = CalciteSqlParser.compileToPinotQuery(
+        "SELECT ARRAY['\\x00'::bytea, CAST('\\xDEADBEEF' AS BYTEA)] FROM 
myTable").getSelectList().get(0);
+    assertTrue(expression.isSetLiteral());
+    assertTrue(expression.getLiteral().isSetBytesArrayValue());
+    assertEquals(RequestUtils.getBytesArrayValue(expression.getLiteral()), 
expected);
+
     expression = CalciteSqlParser.compileToPinotQuery(
         "SELECT ARRAYS_OVERLAP(ARRAY[X'00', X'0102'], ARRAY[X'03', 
X'0102'])").getSelectList().get(0);
     assertTrue(expression.isSetLiteral());
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
index dec47000efb..552aeea604c 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
@@ -18,17 +18,29 @@
  */
 package org.apache.pinot.sql.parsers;
 
+import java.io.StringReader;
 import java.util.List;
+import org.apache.calcite.sql.SqlBinaryStringLiteral;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlExplain;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlNodeList;
+import org.apache.calcite.sql.SqlOrderBy;
+import org.apache.calcite.sql.SqlSelect;
+import org.apache.calcite.sql.parser.SqlParserPos;
 import org.apache.pinot.common.request.Expression;
 import org.apache.pinot.common.request.Function;
 import org.apache.pinot.common.request.PinotQuery;
 import org.apache.pinot.common.utils.request.RequestUtils;
+import org.apache.pinot.sql.parsers.parser.SqlPhysicalExplain;
+import org.apache.pinot.sql.parsers.parser.SqlPinotCreateMaterializedView;
 import org.testng.annotations.AfterMethod;
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import static 
org.apache.pinot.sql.parsers.CalciteSqlParser.CALCITE_SQL_PARSER_IDENTIFIER_MAX_LENGTH;
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertThrows;
 import static org.testng.Assert.assertTrue;
 import static org.testng.Assert.expectThrows;
@@ -57,6 +69,226 @@ public class CalciteSqlParserTest {
     RequestUtils.setUseLegacyLiteralUnescaping(true);
   }
 
+  @Test
+  public void testPostgreSqlByteaHexLiterals() {
+    PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery("SELECT 
'\\x0102'::bytea");
+    
assertEquals(pinotQuery.getSelectList().get(0).getLiteral().getBinaryValue(), 
new byte[]{1, 2});
+
+    pinotQuery = CalciteSqlParser.compileToPinotQuery("SELECT CAST('\\xDe Ad 
Be Ef' AS BYTEA)");
+    
assertEquals(pinotQuery.getSelectList().get(0).getLiteral().getBinaryValue(),
+        new byte[]{(byte) 0xde, (byte) 0xad, (byte) 0xbe, (byte) 0xef});
+
+    pinotQuery = CalciteSqlParser.compileToPinotQuery("SELECT '\\x'::ByTeA");
+    
assertEquals(pinotQuery.getSelectList().get(0).getLiteral().getBinaryValue(), 
new byte[0]);
+
+    Expression expression = 
CalciteSqlParser.compileToExpression("'\\x0102'::bytea");
+    assertEquals(expression.getLiteral().getBinaryValue(), new byte[]{1, 2});
+  }
+
+  /// `::` must bind only to the literal on its left, not to the whole 
expression accumulated so far, so that a bytea
+  /// constant can be used as the right operand of a comparison.
+  @Test(dataProvider = "binaryOperatorsTakingByteaLiterals")
+  public void testPostgreSqlByteaLiteralBindsTighterThanBinaryOperators(String 
predicate, String expectedOperator) {
+    PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT id FROM myTable WHERE bytesColumn " + predicate + " 
'\\x0102'::bytea");
+    Function filter = pinotQuery.getFilterExpression().getFunctionCall();
+    assertEquals(filter.getOperator(), expectedOperator);
+    assertEquals(filter.getOperands().get(0).getIdentifier().getName(), 
"bytesColumn");
+    assertEquals(filter.getOperands().get(1).getLiteral().getBinaryValue(), 
new byte[]{1, 2});
+  }
+
+  @DataProvider
+  public static Object[][] binaryOperatorsTakingByteaLiterals() {
+    return new Object[][]{
+        {"=", "EQUALS"},
+        {"<>", "NOT_EQUALS"},
+        {">", "GREATER_THAN"},
+        {"<=", "LESS_THAN_OR_EQUAL"}
+    };
+  }
+
+  @Test
+  public void testPostgreSqlByteaLiteralInCompoundPredicate() {
+    PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT id FROM myTable WHERE id = 1 AND bytesColumn = 
'\\x0102'::bytea");
+    Function and = pinotQuery.getFilterExpression().getFunctionCall();
+    assertEquals(and.getOperator(), "AND");
+    Function byteaEquals = and.getOperands().get(1).getFunctionCall();
+    assertEquals(byteaEquals.getOperands().get(0).getIdentifier().getName(), 
"bytesColumn");
+    
assertEquals(byteaEquals.getOperands().get(1).getLiteral().getBinaryValue(), 
new byte[]{1, 2});
+  }
+
+  /// The infix and standard cast spellings must normalize to the same binary 
literal wherever they appear, including
+  /// when the source is already a binary literal.
+  @Test
+  public void testPostgreSqlByteaLiteralMatchesStandardCastSpelling() {
+    for (String bytea : List.of("'\\x0102'::bytea", "'\\x0102' :: ByTeA", 
"CAST('\\x0102' AS BYTEA)",
+        "cast('\\x0102' as bytea)", "X'0102'", "X'0102'::bytea", "CAST(X'0102' 
AS BYTEA)")) {
+      PinotQuery pinotQuery =
+          CalciteSqlParser.compileToPinotQuery("SELECT id FROM myTable WHERE 
bytesColumn = " + bytea);
+      
assertEquals(pinotQuery.getFilterExpression().getFunctionCall().getOperands().get(1).getLiteral()
+          .getBinaryValue(), new byte[]{1, 2}, bytea);
+      
assertEquals(CalciteSqlParser.compileToExpression(bytea).getLiteral().getBinaryValue(),
 new byte[]{1, 2},
+          bytea);
+    }
+  }
+
+  /// Regression coverage for rebuilding Pinot's own statement nodes. A 
copy-on-write rewrite turned a
+  /// `SqlPhysicalExplain` into a plain `SqlExplain`, which silently returned 
the logical plan, and a
+  /// `SqlPinotCreateMaterializedView` into a `SqlBasicCall` classified as 
DQL. The statement must keep the class and
+  /// type it has with the equivalent `X'...'` literal, and the constant must 
still be normalized inside it.
+  @Test(dataProvider = "statementsContainingByteaLiterals")
+  public void testPostgreSqlByteaLiteralPreservesStatementNode(String sql, 
Class<?> expectedClass,
+      PinotSqlType expectedType) {
+    for (String bytea : List.of("X'01'", "'\\x01'::bytea", "CAST('\\x01' AS 
BYTEA)")) {
+      String statement = sql.replace("?", bytea);
+      SqlNodeAndOptions sqlNodeAndOptions = 
CalciteSqlParser.compileToSqlNodeAndOptions(statement);
+      SqlNode sqlNode = sqlNodeAndOptions.getSqlNode();
+      assertEquals(sqlNode.getClass(), expectedClass, statement);
+      assertEquals(sqlNodeAndOptions.getSqlType(), expectedType, statement);
+      String unparsed = sqlNode.toString();
+      assertTrue(unparsed.contains("X'01'"), statement + " unparsed as " + 
unparsed);
+      assertFalse(unparsed.toUpperCase().contains("BYTEA"), statement + " 
unparsed as " + unparsed);
+    }
+  }
+
+  @DataProvider
+  public static Object[][] statementsContainingByteaLiterals() {
+    return new Object[][]{
+        {"EXPLAIN IMPLEMENTATION PLAN FOR SELECT a FROM t WHERE b = ?", 
SqlPhysicalExplain.class, PinotSqlType.DQL},
+        {"EXPLAIN PLAN FOR SELECT a FROM t WHERE b = ?", SqlExplain.class, 
PinotSqlType.DQL},
+        {"CREATE MATERIALIZED VIEW mv AS SELECT a FROM t WHERE b = ?", 
SqlPinotCreateMaterializedView.class,
+            PinotSqlType.DDL},
+        {"CREATE MATERIALIZED VIEW mv AS SELECT a FROM t WHERE a IN (SELECT a 
FROM u WHERE b = ?)",
+            SqlPinotCreateMaterializedView.class, PinotSqlType.DDL},
+        {"SELECT a FROM t WHERE b = ? ORDER BY a LIMIT 5", SqlOrderBy.class, 
PinotSqlType.DQL}
+    };
+  }
+
+  /// A node that cannot replace an operand in place must fail with a clear 
error rather than be rebuilt with a
+  /// different class. Pinot's grammar never puts a bytea constant directly 
under such a node, so the tree is built
+  /// by hand.
+  @Test
+  public void testPostgreSqlByteaLiteralUnderNodeWithoutSetOperandIsRejected()
+      throws Exception {
+    SqlNode query = CalciteSqlParser.newSqlParser(new StringReader("SELECT a 
FROM t")).parseSqlStmtEof();
+    SqlNode bytea = CalciteSqlParser.newSqlParser(new 
StringReader("'\\x01'::bytea")).parseSqlExpressionEof();
+    SqlOrderBy orderBy = new SqlOrderBy(SqlParserPos.ZERO, query, 
SqlNodeList.EMPTY, bytea, null);
+    SqlCompilationException e =
+        expectThrows(SqlCompilationException.class, () -> 
PostgreSqlCastRewriter.rewrite(orderBy));
+    assertTrue(e.getMessage().contains("not supported inside ORDER_BY"), 
e.getMessage());
+  }
+
+  /// Same guard for a node list that cannot be updated in place, such as one 
wrapping an immutable list.
+  @Test
+  public void testPostgreSqlByteaLiteralInImmutableNodeListIsRejected()
+      throws Exception {
+    SqlNode bytea = CalciteSqlParser.newSqlParser(new 
StringReader("'\\x01'::bytea")).parseSqlExpressionEof();
+    SqlNodeList immutable = SqlNodeList.of(SqlParserPos.ZERO, List.of(bytea));
+    SqlCompilationException e =
+        expectThrows(SqlCompilationException.class, () -> 
PostgreSqlCastRewriter.rewrite(immutable));
+    assertTrue(e.getMessage().contains("not supported inside an immutable node 
list"), e.getMessage());
+  }
+
+  private static final String BYTEA_CONSTANT = "'\\x01'::bytea";
+
+  /// The literal a bytea constant is rewritten to must span exactly that 
constant, so validation errors point at it.
+  /// Recording the enclosing expression's start instead made it claim, e.g., 
`WHERE b = '\x01'::bytea`.
+  @Test
+  public void testPostgreSqlByteaLiteralKeepsItsSourcePosition()
+      throws Exception {
+    String sql = "SELECT a FROM t WHERE b = " + BYTEA_CONSTANT;
+    SqlSelect select = (SqlSelect) PostgreSqlCastRewriter.rewrite(
+        CalciteSqlParser.newSqlParser(new 
StringReader(sql)).parseSqlStmtEof());
+    assertSpansByteaConstant(((SqlCall) select.getWhere()).operand(1), sql);
+
+    sql = "SELECT a FROM t WHERE c LIKE 'x' AND b = " + BYTEA_CONSTANT;
+    select = (SqlSelect) PostgreSqlCastRewriter.rewrite(
+        CalciteSqlParser.newSqlParser(new 
StringReader(sql)).parseSqlStmtEof());
+    assertSpansByteaConstant(((SqlCall) ((SqlCall) 
select.getWhere()).operand(1)).operand(1), sql);
+
+    sql = "a || " + BYTEA_CONSTANT;
+    SqlCall concat = (SqlCall) PostgreSqlCastRewriter.rewrite(
+        CalciteSqlParser.newSqlParser(new 
StringReader(sql)).parseSqlExpressionEof());
+    assertSpansByteaConstant(concat.operand(1), sql);
+  }
+
+  private static void assertSpansByteaConstant(SqlNode literal, String sql) {
+    assertTrue(literal instanceof SqlBinaryStringLiteral, sql + " -> " + 
literal);
+    int start = sql.indexOf(BYTEA_CONSTANT) + 1;
+    SqlParserPos pos = literal.getParserPosition();
+    assertEquals(pos.getLineNum(), 1, sql);
+    assertEquals(pos.getColumnNum(), start, sql);
+    assertEquals(pos.getEndColumnNum(), start + BYTEA_CONSTANT.length() - 1, 
sql);
+  }
+
+  /// An IN list is a node list reached through the filter rather than the 
select list.
+  @Test
+  public void testPostgreSqlByteaLiteralInInList() {
+    PinotQuery pinotQuery = CalciteSqlParser.compileToPinotQuery(
+        "SELECT id FROM myTable WHERE bytesColumn IN ('\\x01'::bytea, X'02', 
CAST('\\x03' AS BYTEA))");
+    Function in = pinotQuery.getFilterExpression().getFunctionCall();
+    assertEquals(in.getOperator(), "IN");
+    assertEquals(in.getOperands().get(0).getIdentifier().getName(), 
"bytesColumn");
+    assertEquals(in.getOperands().get(1).getLiteral().getBinaryValue(), new 
byte[]{1});
+    assertEquals(in.getOperands().get(2).getLiteral().getBinaryValue(), new 
byte[]{2});
+    assertEquals(in.getOperands().get(3).getLiteral().getBinaryValue(), new 
byte[]{3});
+  }
+
+  /// The expression parse path must surface the rewriter's own message rather 
than only a generic wrapper.
+  @Test
+  public void testPostgreSqlByteaErrorsSurfaceFromCompileToExpression() {
+    SqlCompilationException e =
+        expectThrows(SqlCompilationException.class, () -> 
CalciteSqlParser.compileToExpression("bytesColumn::bytea"));
+    assertTrue(e.getMessage().contains(NOT_A_CONSTANT), e.getMessage());
+    assertTrue(e.getMessage().contains("hexToBytes(<expr>)"), e.getMessage());
+
+    e = expectThrows(SqlCompilationException.class, () -> 
CalciteSqlParser.compileToExpression("'\\x0'::bytea"));
+    assertTrue(e.getMessage().contains(INVALID_CONSTANT), e.getMessage());
+  }
+
+  @Test(dataProvider = "invalidPostgreSqlByteaLiterals")
+  public void testInvalidPostgreSqlByteaHexLiterals(String sql, String 
expectedMessageFragment) {
+    SqlCompilationException e =
+        expectThrows(SqlCompilationException.class, () -> 
CalciteSqlParser.compileToPinotQuery(sql));
+    assertTrue(e.getMessage().contains(expectedMessageFragment),
+        "Expected <" + expectedMessageFragment + "> for " + sql + " but got: " 
+ e.getMessage());
+  }
+
+  private static final String INVALID_CONSTANT = "Invalid PostgreSQL BYTEA 
constant";
+  private static final String NOT_A_CONSTANT = "BYTEA casts are supported only 
for quoted hex constants";
+
+  @DataProvider
+  public static Object[][] invalidPostgreSqlByteaLiterals() {
+    return new Object[][]{
+        // Malformed hex constants.
+        {"SELECT '\\x0'::bytea", INVALID_CONSTANT},
+        {"SELECT '\\x0g'::bytea", INVALID_CONSTANT},
+        {"SELECT '\\x0 1'::bytea", INVALID_CONSTANT},
+        // PostgreSQL reads this as escape format (four bytes), which is not 
supported.
+        {"SELECT '0102'::bytea", "only the hex format is supported"},
+        // The \x prefix is case-sensitive, as in PostgreSQL.
+        {"SELECT '\\X0102'::bytea", INVALID_CONSTANT},
+        // Full-width and non-Latin digits are hex digits to Character.digit 
but not to PostgreSQL.
+        {"SELECT '\\x\uFF21\uFF22'::bytea", INVALID_CONSTANT},
+        {"SELECT '\\x\u0660\u0661'::bytea", INVALID_CONSTANT},
+        // Non-ASCII whitespace is whitespace to Character.isWhitespace but 
not to PostgreSQL.
+        {"SELECT '\\x01\u205F02'::bytea", INVALID_CONSTANT},
+
+        // BYTEA casts of something that is not a constant.
+        {"SELECT bytesColumn::bytea FROM myTable", NOT_A_CONSTANT},
+        {"SELECT CAST(bytesColumn AS BYTEA) FROM myTable", "use 
hexToBytes(<expr>)"},
+        {"SELECT id FROM myTable WHERE id = 1 AND bytesColumn::bytea = X'01'", 
NOT_A_CONSTANT},
+
+        // `::` to a target type other than BYTEA.
+        {"SELECT 1::int", "not for target type 'INTEGER'"},
+        {"SELECT bytesColumn::varchar FROM myTable", "not for target type 
'VARCHAR'"},
+
+        // The item accessor binds tighter than `::`, so the target type does 
not survive as a type spec.
+        {"SELECT bytesColumn::bytea[1] FROM myTable", "Unsupported PostgreSQL 
:: cast target"}
+    };
+  }
+
   @Test
   public void testIdentifierLength() {
     String tableName = extendIdentifierToMaxLength("exampleTable");
@@ -102,6 +334,7 @@ public class CalciteSqlParserTest {
         new Object[]{"string"},
         new Object[]{"varchar"},
         new Object[]{"bytes"},
+        new Object[]{"bytea"},
         new Object[]{"binary"},
         new Object[]{"varbinary"},
         new Object[]{"variant"},
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/BytesMvTypeTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/BytesMvTypeTest.java
index 25b440b931f..c0e95d86c0d 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/BytesMvTypeTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/BytesMvTypeTest.java
@@ -188,17 +188,19 @@ public class BytesMvTypeTest extends 
CustomDataQueryClusterIntegrationTest {
   public void testBytesArrayLiteral(boolean useMultiStageQueryEngine)
       throws Exception {
     setUseMultiStageQueryEngine(useMultiStageQueryEngine);
-    String arrayLiteral = "ARRAY[X'07', X'0708', X'07090A']";
-    for (boolean withFrom : new boolean[]{true, false}) {
-      String query = withFrom ? String.format("SELECT %s FROM %s WHERE %s = 7 
LIMIT 1", arrayLiteral,
-          getTableName(), ID_COLUMN) : "SELECT " + arrayLiteral;
-      JsonNode result = postQuery(query).get("resultTable");
-      
assertEquals(result.get("dataSchema").get("columnDataTypes").get(0).asText(), 
"BYTES_ARRAY");
-      JsonNode values = result.get("rows").get(0).get(0);
-      assertEquals(values.size(), MV_LENGTH);
-      assertEquals(values.get(0).asText(), "07");
-      assertEquals(values.get(1).asText(), "0708");
-      assertEquals(values.get(2).asText(), "07090a");
+    for (String arrayLiteral : List.of("ARRAY[X'07', X'0708', X'07090A']",
+        "ARRAY['\\x07'::bytea, '\\x0708'::bytea, '\\x07090A'::bytea]")) {
+      for (boolean withFrom : new boolean[]{true, false}) {
+        String query = withFrom ? String.format("SELECT %s FROM %s WHERE %s = 
7 LIMIT 1", arrayLiteral,
+            getTableName(), ID_COLUMN) : "SELECT " + arrayLiteral;
+        JsonNode result = postQuery(query).get("resultTable");
+        
assertEquals(result.get("dataSchema").get("columnDataTypes").get(0).asText(), 
"BYTES_ARRAY");
+        JsonNode values = result.get("rows").get(0).get(0);
+        assertEquals(values.size(), MV_LENGTH);
+        assertEquals(values.get(0).asText(), "07");
+        assertEquals(values.get(1).asText(), "0708");
+        assertEquals(values.get(2).asText(), "07090a");
+      }
     }
   }
 
@@ -219,15 +221,18 @@ public class BytesMvTypeTest extends 
CustomDataQueryClusterIntegrationTest {
       throws Exception {
     setUseMultiStageQueryEngine(useMultiStageQueryEngine);
     for (String mvCol : MV_COLUMNS) {
-      String positiveQuery = String.format(
-          "SELECT COUNT(*) FROM %s WHERE ARRAYS_OVERLAP(%s, ARRAY[X'07'])", 
getTableName(), mvCol);
-      JsonNode rows = postQuery(positiveQuery).get("resultTable").get("rows");
-      assertEquals(rows.get(0).get(0).asLong(), 1L);
-
-      String negativeQuery = String.format(
-          "SELECT COUNT(*) FROM %s WHERE ARRAYS_OVERLAP(%s, ARRAY[X'FF'])", 
getTableName(), mvCol);
-      rows = postQuery(negativeQuery).get("resultTable").get("rows");
-      assertEquals(rows.get(0).get(0).asLong(), 0L);
+      for (String[] literals : List.of(new String[]{"X'07'", "X'FF'"},
+          new String[]{"'\\x07'::bytea", "'\\xFF'::bytea"})) {
+        String positiveQuery = String.format(
+            "SELECT COUNT(*) FROM %s WHERE ARRAYS_OVERLAP(%s, ARRAY[%s])", 
getTableName(), mvCol, literals[0]);
+        JsonNode rows = 
postQuery(positiveQuery).get("resultTable").get("rows");
+        assertEquals(rows.get(0).get(0).asLong(), 1L);
+
+        String negativeQuery = String.format(
+            "SELECT COUNT(*) FROM %s WHERE ARRAYS_OVERLAP(%s, ARRAY[%s])", 
getTableName(), mvCol, literals[1]);
+        rows = postQuery(negativeQuery).get("resultTable").get("rows");
+        assertEquals(rows.get(0).get(0).asLong(), 0L);
+      }
     }
   }
 
@@ -235,11 +240,14 @@ public class BytesMvTypeTest extends 
CustomDataQueryClusterIntegrationTest {
   public void testArraysOverlapWithLiterals(boolean useMultiStageQueryEngine)
       throws Exception {
     setUseMultiStageQueryEngine(useMultiStageQueryEngine);
-    JsonNode result = postQuery(
-        "SELECT ARRAYS_OVERLAP(ARRAY[X'00', X'0102'], ARRAY[X'03', 
X'0102'])").get("resultTable");
-    assertTrue(result.get("rows").get(0).get(0).asBoolean());
+    for (String overlapping : List.of("ARRAYS_OVERLAP(ARRAY[X'00', X'0102'], 
ARRAY[X'03', X'0102'])",
+        "ARRAYS_OVERLAP(ARRAY[CAST('\\x00' AS BYTEA), CAST('\\x0102' AS 
BYTEA)], "
+            + "ARRAY['\\x03'::bytea, '\\x0102'::bytea])")) {
+      JsonNode result = postQuery("SELECT " + overlapping).get("resultTable");
+      assertTrue(result.get("rows").get(0).get(0).asBoolean());
+    }
 
-    result = postQuery(
+    JsonNode result = postQuery(
         "SELECT ARRAYS_OVERLAP(ARRAY[X'00', X'0102'], ARRAY[X'03', 
X'04'])").get("resultTable");
     assertFalse(result.get("rows").get(0).get(0).asBoolean());
   }
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/BytesTypeTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/BytesTypeTest.java
index ff392d5a691..af9b3ca0fb0 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/BytesTypeTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/BytesTypeTest.java
@@ -307,4 +307,25 @@ public class BytesTypeTest extends 
CustomDataQueryClusterIntegrationTest {
       Assert.assertEquals(rows.get(i).get(0).asLong(), NUM_TOTAL_DOCS);
     }
   }
+
+  /// Regression coverage for the PostgreSQL `::` cast binding to the whole 
expression on its left instead of just
+  /// the literal, which made a bytea constant unusable as the right operand 
of a comparison.
+  @Test(dataProvider = "useBothQueryEngines")
+  public void testPostgreSqlByteaLiteralAsPredicateOperand(boolean 
useMultiStageQueryEngine)
+      throws Exception {
+    setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+    for (String byteaLiteral : List.of("'\\x" + FIXED_HEX_STRING_VALUE + 
"'::bytea",
+        "CAST('\\x" + FIXED_HEX_STRING_VALUE + "' AS BYTEA)", "X'" + 
FIXED_HEX_STRING_VALUE + "'")) {
+      String query = String.format("SELECT count(*) FROM %s WHERE %s = %s", 
getTableName(), FIXED_BYTES,
+          byteaLiteral);
+      JsonNode rows = postQuery(query).get("resultTable").get("rows");
+      Assert.assertEquals(rows.get(0).get(0).asLong(), NUM_TOTAL_DOCS, query);
+
+      // Same literal on the right of a compound predicate, where the 
accumulated expression list is non-empty.
+      query = String.format("SELECT count(*) FROM %s WHERE %s IS NOT NULL AND 
%s = %s", getTableName(), FIXED_BYTES,
+          FIXED_BYTES, byteaLiteral);
+      rows = postQuery(query).get("resultTable").get("rows");
+      Assert.assertEquals(rows.get(0).get(0).asLong(), NUM_TOTAL_DOCS, query);
+    }
+  }
 }
diff --git 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
index 30f74e5d60c..714d3983944 100644
--- 
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
+++ 
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
@@ -143,6 +143,32 @@ public class QueryCompilationTest extends 
QueryEnvironmentTestBase {
     }
   }
 
+  @Test
+  public void testPostgreSqlByteaLiteralTypeInference() {
+    RelDataType rowType = _queryEnvironment.compile(
+            "SELECT '\\x0102'::bytea, ARRAY['\\x00'::bytea, CAST('\\x0102' AS 
BYTEA)] FROM a")
+        .getRelRoot().validatedRowType;
+    assertEquals(rowType.getFieldList().get(0).getType().getSqlTypeName(), 
SqlTypeName.BINARY);
+    RelDataType arrayType = rowType.getFieldList().get(1).getType();
+    assertEquals(arrayType.getSqlTypeName(), SqlTypeName.ARRAY);
+    assertEquals(arrayType.getComponentType().getSqlTypeName(), 
SqlTypeName.VARBINARY);
+  }
+
+  /// A bytea constant must not change which plan EXPLAIN returns. Rewriting 
it used to rebuild the statement node, so
+  /// `EXPLAIN IMPLEMENTATION PLAN` lost its physical-plan marker and silently 
returned the logical plan instead.
+  @Test
+  public void testPostgreSqlByteaLiteralKeepsPhysicalExplain() {
+    long requestId = RANDOM_REQUEST_ID_GEN.nextLong();
+    String expected =
+        _queryEnvironment.explainQuery("EXPLAIN IMPLEMENTATION PLAN FOR SELECT 
col1, X'01' FROM a", requestId);
+    assertTrue(expected.contains("MAIL_RECEIVE"), expected);
+    for (String bytea : List.of("'\\x01'::bytea", "CAST('\\x01' AS BYTEA)")) {
+      String explain = _queryEnvironment.explainQuery(
+          "EXPLAIN IMPLEMENTATION PLAN FOR SELECT col1, " + bytea + " FROM a", 
requestId);
+      assertEquals(explain, expected, bytea);
+    }
+  }
+
   /// `jsonPath` must resolve to a literal, but the operand type checker 
deliberately does not demand a literal
   /// `SqlNode` in that position: operand checking runs before 
`PinotEvaluateLiteralRule` folds constant
   /// expressions, so an argument such as `CONCAT('$.', 'foo')` folds to a 
literal and plans and executes
diff --git a/pinot-query-runtime/src/test/resources/queries/BinaryTypes.json 
b/pinot-query-runtime/src/test/resources/queries/BinaryTypes.json
index 6ce66cea165..33bb20b4e71 100644
--- a/pinot-query-runtime/src/test/resources/queries/BinaryTypes.json
+++ b/pinot-query-runtime/src/test/resources/queries/BinaryTypes.json
@@ -18,21 +18,28 @@
       },
       {
         "psql": "8.4.1",
-        "ignored": true,
-        "comments": [
-          "looks like we don't support constants, this is treated as a normal 
string",
-          "we also require using calcite syntax for byte strings to parse, 
which is x'deadbeef' instead",
-          "of postgres syntax which is '\\xdeadbeaf'"
-        ],
-        "description": "bytea hex constants",
-        "sql": "SELECT x'DEADBEEF', data from {bytea}"
+        "description": "PostgreSQL bytea hex constants",
+        "sql": "SELECT '\\xDEADBEEF'::bytea, data FROM {bytea}",
+        "h2Sql": "SELECT X'DEADBEEF', data FROM {bytea}"
+      },
+      {
+        "psql": "8.4.1",
+        "description": "PostgreSQL bytea hex constant as a comparison operand",
+        "sql": "SELECT data FROM {bytea} WHERE data = '\\xDEADBEEF'::bytea",
+        "h2Sql": "SELECT data FROM {bytea} WHERE data = X'DEADBEEF'"
+      },
+      {
+        "psql": "8.4.1",
+        "description": "PostgreSQL bytea hex constant in a compound predicate",
+        "sql": "SELECT data FROM {bytea} WHERE data <> '\\x00'::bytea AND data 
= CAST('\\xDEADBEEF' AS BYTEA)",
+        "h2Sql": "SELECT data FROM {bytea} WHERE data <> X'00' AND data = 
X'DEADBEEF'"
       },
       {
         "psql": "8.4.2",
         "ignored": true,
         "comments": [
-          "in order to specify bytea constants using escape syntax, we need 
type casting",
-          "which we don't support. also, calcite doesn't support the escape 
syntax for bytes"
+          "PostgreSQL's legacy octal bytea escape syntax remains unsupported",
+          "only PostgreSQL hex-format bytea literals are normalized to Pinot 
bytes"
         ],
         "description": "bytea escape constants",
         "sql": "SELECT CAST('\\046' AS BINARY), data from {bytea}"
diff --git 
a/pinot-sql-ddl/src/test/java/org/apache/pinot/sql/ddl/compile/DdlCompilerMaterializedViewTest.java
 
b/pinot-sql-ddl/src/test/java/org/apache/pinot/sql/ddl/compile/DdlCompilerMaterializedViewTest.java
index 801041baa94..10b88f3d94c 100644
--- 
a/pinot-sql-ddl/src/test/java/org/apache/pinot/sql/ddl/compile/DdlCompilerMaterializedViewTest.java
+++ 
b/pinot-sql-ddl/src/test/java/org/apache/pinot/sql/ddl/compile/DdlCompilerMaterializedViewTest.java
@@ -18,6 +18,7 @@
  */
 package org.apache.pinot.sql.ddl.compile;
 
+import java.util.List;
 import java.util.Map;
 import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.TableType;
@@ -423,6 +424,27 @@ public class DdlCompilerMaterializedViewTest {
         "Select Carrier /* hot column */, ts From src");
   }
 
+  /// A PostgreSQL bytea constant in the view body must leave the statement a 
materialized view (rewriting it once
+  /// rebuilt the statement as a plain call, which was then classified as a 
query), and the stored text must be
+  /// exactly what the user typed, since it is sliced from the union of every 
node's parser position.
+  @Test
+  public void definedSqlWithPostgreSqlByteaConstant() {
+    for (String bytea : List.of("'\\x01'::bytea", "CAST('\\x01' AS BYTEA)")) {
+      String query = "SELECT ts, carrier FROM src WHERE payload = " + bytea + 
" AND carrier <> 'x'";
+      CompiledCreateMaterializedView c = compileMaterializedView(
+          "CREATE MATERIALIZED VIEW mv ("
+              + "  ts TIMESTAMP DATETIME FORMAT '1:MILLISECONDS:TIMESTAMP' 
GRANULARITY '1:DAYS',"
+              + "  carrier STRING"
+              + ")"
+              + " REFRESH EVERY 1 DAY"
+              + " PROPERTIES ('timeColumnName' = 'ts', 'bucketTimePeriod' = 
'1d')"
+              + " AS " + query);
+      assertEquals(c.getOperation(), DdlOperation.CREATE_MATERIALIZED_VIEW, 
bytea);
+      
assertEquals(materializedViewTaskConfig(c.getTableConfig()).get(MaterializedViewTask.DEFINED_SQL_KEY),
 query,
+          bytea);
+    }
+  }
+
   @Test
   public void definedSqlAcrossMultipleLinesPreserved() {
     String sql =


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

Reply via email to