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

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 6c27d4d8b0b [fix](nereids) Do not turn invalid UTF-8 from BE constant 
folding into U+FFFD (#69143)
6c27d4d8b0b is described below

commit 6c27d4d8b0b4649f6517e15792fba672a54e2935
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Sat Oct 17 07:27:20 2026 +0100

    [fix](nereids) Do not turn invalid UTF-8 from BE constant folding into 
U+FFFD (#69143)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #69025
    
    Problem Summary:
    
    **In short.** With `enable_fold_constant_by_be=true`, FE turned the
    bytes of a string that BE computed for a constant into a literal by
    decoding them as UTF-8 with replacement, so bytes that are not valid
    UTF-8 became U+FFFD (`EF BF BD`). `WHERE s = unhex('FF')` then matched
    the rows holding U+FFFD instead of the rows holding the byte `FF`, and
    `SELECT unhex('FF')` returned `EF BF BD`. This PR leaves such a constant
    unfolded, so BE evaluates it as it does with the variable off. Strings
    that are valid UTF-8 fold as before. This was found while testing
    #69025, which keeps its VARBINARY casts away from BE folding partly for
    this reason.
    
    **Background.** With `enable_fold_constant_by_be=true`,
    `FoldConstantRuleOnBE` sends the largest constant subtrees of an
    expression to a BE and replaces them with literals built from the values
    BE returns (`getResultExpression`). BE writes the bytes of a string
    value as they are into `PValues.string_value`, a proto2 `string` field
    that does not require UTF-8, and FE read them with `getStringValue(i)`,
    which decodes with replacement. A FE string literal is a Java string, so
    it holds only text that is valid UTF-8. FE has no rule of its own for
    `unhex`, so with the variable off the call stays in the plan and BE
    evaluates it on the real bytes. The variable is off by default;
    regression pipelines turn it on at random.
    
    **The problem, and what it cost.** A table `t(k, s)` holds `'a'` (1),
    `FF` (2), U+FFFD, `EFBFBD` (3), `61FF` (4), NULL (5) and `'é'`, `C3A9`
    (6):
    
    | Query | BE folding off, or `debug_skip_fold_constant=true` | master,
    `enable_fold_constant_by_be=true` | with this PR |
    |---|---|---|---|
    | `SELECT k FROM t WHERE s = unhex('FF')` | 2 | **3** | 2 |
    | `... WHERE s = concat('a', unhex('FF'))` | 4 | **no row** | 4 |
    | `... WHERE array_contains(array(unhex('FF')), s)`, and the same with a
    MAP key or a STRUCT field | 2 | **3** | 2 |
    | `SELECT hex(concat(s, unhex('FF'))) FROM t WHERE k = 1` | `61FF` |
    **`61EFBFBD`** | `61FF` |
    | `SELECT unhex('FF')`, bytes sent to the client | `FF` | **`EF BF BD`**
    | `FF` |
    | `EXPLAIN ... WHERE s = unhex('FF')` | `s = unhex('FF')` | `s = '�'` |
    `s = unhex('FF')` |
    | `EXPLAIN ... WHERE s = unhex('C3A9')` | `s = unhex('C3A9')` | `s =
    'é'` | `s = 'é'` |
    
    The query succeeds either way, so the wrong rows and bytes go unnoticed:
    a session variable changes which rows a filter on binary data matches.
    
    **How this PR fixes it.** For a string type, `getResultExpression` reads
    the bytes (`getStringValueBytes(i)`) and builds the literal only when
    `ByteString.isValidUtf8()` holds; otherwise it returns no literal, which
    `evalOnBE` already treats as "keep the expression" (as for a NaN float).
    That check rejects exactly the bytes a Java string cannot carry: stray
    bytes, truncated sequences, overlong forms, surrogates and code points
    beyond U+10FFFF. An array, map or struct holding such a string is not
    folded either, since those branches already stop when an element is not
    folded. A valid string decodes to the same text as before, NUL bytes and
    supplementary characters included.
    
    The JSON branch is unchanged: BE never folds a JSON expression
    (`shouldSkipFold`), and a JSON value inside a folded array, map or
    struct can only come from a CAST that parses text, and BE's parser
    rejects text that is not valid UTF-8 (`CAST(array(unhex('22FF22')) AS
    ARRAY<JSON>)` is `[null]` with and without BE folding).
    
    **Results.**
    
    - **FE UT.**
    `LiteralTest.testGetResultExpressionLeavesInvalidUtf8Unfolded`: `FF`,
    `61FF`, `C3`, `C080` and `EDA080` are not folded as STRING, VARCHAR or
    CHAR, nor as an ARRAY element, a MAP key or value, or a STRUCT field;
    the same results with `C3A9` fold.
    `testGetResultExpressionStringKeepsItsBytes`: valid UTF-8 with a NUL
    byte, U+1F600 or U+FFFD itself folds to the same bytes. Without the fix
    the first test fails. All tests in `LiteralTest` pass.
    - **Regression.** The new `fold_constant_by_be_invalid_utf8`
    (query_p0/expression/fold_constant) runs the filters and projections
    above with FE folding, with BE folding and with folding skipped; the
    results of each block must match in the three modes, and two `EXPLAIN`
    checks show that `unhex('C3A9')` is still folded on BE and `unhex('FF')`
    is not. Its `.out` was generated on a local cluster with this change;
    with the FE before this change the suite fails at `filter_fold_by_be`
    (row 3 instead of 2).
    - **End to end.** The table above, on the same local cluster before and
    after this change.
    
    ### Release note
    
    With `enable_fold_constant_by_be=true`, a constant string whose bytes
    are not valid UTF-8, such as `unhex('FF')`, keeps its bytes instead of
    turning into U+FFFD, so filters and results match those with the
    variable off.
    
    ### Check List (For Author)
    
    - Test <!-- At least one of them must be included. -->
        - [x] Regression test
    - `fold_constant_by_be_invalid_utf8`
    (query_p0/expression/fold_constant): filters and projections on
    `unhex('FF')` and the strings, arrays, maps and structs built from it,
    in three folding modes.
        - [x] Unit Test
    - `LiteralTest.testGetResultExpressionLeavesInvalidUtf8Unfolded` and
    `LiteralTest.testGetResultExpressionStringKeepsItsBytes`.
        - [x] Manual test (add detailed scripts or steps below)
    - The queries above on a local cluster, before and after this change.
        - [ ] No need to test or manual test. Explain why:
    - [ ] This is a refactor/code format and no logic has been changed.
            - [ ] Previous test can cover this change.
            - [ ] No code files have been changed.
            - [ ] Other reason <!-- Add your reason?  -->
    
    - Behavior changed:
        - [ ] No.
        - [x] Yes. <!-- Explain the behavior change -->
    - With `enable_fold_constant_by_be=true`, a constant whose string result
    is not valid UTF-8 is no longer folded; BE evaluates it, and FE logs the
    existing `Be constant folding convert ... failed` warning.
    
    - Does this need documentation?
        - [x] No.
    - [ ] Yes. <!-- Add document PR link here. eg:
    https://github.com/apache/doris-website/pull/1214 -->
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../expression/rules/FoldConstantRuleOnBE.java     |   9 +-
 .../nereids/trees/expressions/LiteralTest.java     | 109 +++++++++++++++++++++
 .../fold_constant_by_be_invalid_utf8.out           |  57 +++++++++++
 .../fold_constant_by_be_invalid_utf8.groovy        |  77 +++++++++++++++
 4 files changed, 251 insertions(+), 1 deletion(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/FoldConstantRuleOnBE.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/FoldConstantRuleOnBE.java
index d8fd83457c8..57a5a5fe1d9 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/FoldConstantRuleOnBE.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/FoldConstantRuleOnBE.java
@@ -562,7 +562,14 @@ public class FoldConstantRuleOnBE implements 
ExpressionPatternRuleFactory {
         } else if (type.isStringLikeType()) {
             int num = resultContent.getStringValueCount();
             for (int i = 0; i < num; ++i) {
-                Literal literal = new 
StringLiteral(resultContent.getStringValue(i));
+                // BE sends the bytes of a string as they are, but a FE string 
literal holds only valid UTF-8:
+                // getStringValue(i) would turn other bytes, such as the 0xFF 
of unhex('FF'), into U+FFFD.
+                // Leave such a result unfolded, so that BE evaluates the 
expression.
+                ByteString bytesValue = resultContent.getStringValueBytes(i);
+                if (!bytesValue.isValidUtf8()) {
+                    return Collections.emptyList();
+                }
+                Literal literal = new StringLiteral(bytesValue.toStringUtf8());
                 res.add(literal);
             }
         } else if (type.isArrayType()) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/LiteralTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/LiteralTest.java
index aaa63ac3562..681c6d2daff 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/LiteralTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/expressions/LiteralTest.java
@@ -17,30 +17,40 @@
 
 package org.apache.doris.nereids.trees.expressions;
 
+import org.apache.doris.common.Pair;
 import org.apache.doris.nereids.trees.expressions.literal.ArrayLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.Literal;
+import org.apache.doris.nereids.trees.expressions.literal.MapLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.StringLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.StructLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.TimeStampNsLiteral;
 import org.apache.doris.nereids.types.ArrayType;
+import org.apache.doris.nereids.types.CharType;
 import org.apache.doris.nereids.types.DataType;
 import org.apache.doris.nereids.types.DateTimeV2Type;
 import org.apache.doris.nereids.types.IntegerType;
+import org.apache.doris.nereids.types.MapType;
 import org.apache.doris.nereids.types.StringType;
 import org.apache.doris.nereids.types.StructField;
 import org.apache.doris.nereids.types.StructType;
 import org.apache.doris.nereids.types.TimeStampNsType;
+import org.apache.doris.nereids.types.VarcharType;
 import org.apache.doris.proto.Types.PGenericType;
 import org.apache.doris.proto.Types.PGenericType.TypeId;
 import org.apache.doris.proto.Types.PValues;
 
+import com.google.common.collect.ImmutableList;
+import com.google.protobuf.ByteString;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HexFormat;
 import java.util.List;
 
 class LiteralTest {
@@ -396,4 +406,103 @@ class LiteralTest {
         // STRUCT('col1':[0, 2, 4, 6, 8],'col2':['str1', 'str3', 'str5', 
'str7', 'str9'])
         System.out.println(resultExpression.get(0).toString());
     }
+
+    @Test
+    public void testGetResultExpressionStringKeepsItsBytes() {
+        // BE sends the bytes of a string result as they are. Valid UTF-8, 
including a NUL byte, a supplementary
+        // character (U+1F600) and U+FFFD itself, folds to a literal with the 
same bytes.
+        List<String> hexes = ImmutableList.of("", "61", "C3A9", "610062", 
"F09F9880", "EFBFBD");
+        List<Literal> result = 
org.apache.doris.nereids.rules.expression.rules.FoldConstantRuleOnBE
+                .getResultExpression(StringType.INSTANCE, stringValues(hexes));
+        Assertions.assertEquals(hexes, stringLeaves(result));
+    }
+
+    @Test
+    public void testGetResultExpressionLeavesInvalidUtf8Unfolded() {
+        // A FE string literal cannot hold these bytes: 0xFF (unhex('FF')), a 
truncated sequence, an overlong NUL
+        // and a UTF-16 surrogate. Decoding them would give U+FFFD, so neither 
the string nor an array, a map or
+        // a struct holding it is folded, and BE evaluates the expression.
+        for (String invalid : ImmutableList.of("FF", "61FF", "C3", "C080", 
"EDA080")) {
+            for (Pair<DataType, PValues> result : stringResults(invalid)) {
+                Assertions.assertEquals(ImmutableList.of(), 
org.apache.doris.nereids.rules.expression.rules
+                        
.FoldConstantRuleOnBE.getResultExpression(result.first, result.second),
+                        invalid + " in " + result.first);
+            }
+        }
+        // The same results fold when the bytes are valid UTF-8, here 'é' 
(C3A9).
+        for (Pair<DataType, PValues> result : stringResults("C3A9")) {
+            Assertions.assertEquals(ImmutableList.of("61", "C3A9"), 
stringLeaves(org.apache.doris.nereids.rules
+                    
.expression.rules.FoldConstantRuleOnBE.getResultExpression(result.first, 
result.second)),
+                    result.first.toString());
+        }
+    }
+
+    // Results of each shape that hold the strings 'a' and the bytes of 
secondHex, laid out as BE writes them.
+    private static List<Pair<DataType, PValues>> stringResults(String 
secondHex) {
+        PValues strings = stringValues(ImmutableList.of("61", secondHex));
+        PValues ints = PValues.newBuilder()
+                .setType(PGenericType.newBuilder().setId(TypeId.INT32).build())
+                .addInt32Value(1)
+                .addInt32Value(2)
+                .build();
+        PValues array = PValues.newBuilder()
+                .setType(PGenericType.newBuilder().setId(TypeId.LIST).build())
+                .addChildElement(strings)
+                .addChildOffset(2)
+                .build();
+        PValues stringKeyMap = PValues.newBuilder()
+                .setType(PGenericType.newBuilder().setId(TypeId.MAP).build())
+                .addChildElement(strings)
+                .addChildElement(ints)
+                .addChildOffset(2)
+                .build();
+        PValues stringValueMap = PValues.newBuilder()
+                .setType(PGenericType.newBuilder().setId(TypeId.MAP).build())
+                .addChildElement(ints)
+                .addChildElement(strings)
+                .addChildOffset(2)
+                .build();
+        PValues struct = PValues.newBuilder()
+                
.setType(PGenericType.newBuilder().setId(TypeId.STRUCT).build())
+                .addChildElement(ints)
+                .addChildElement(strings)
+                .build();
+        StructType structType = new StructType(ImmutableList.of(
+                new StructField("i", IntegerType.INSTANCE, true, ""),
+                new StructField("s", StringType.INSTANCE, true, "")));
+        return ImmutableList.of(
+                Pair.of(StringType.INSTANCE, strings),
+                Pair.of(VarcharType.createVarcharType(10), strings),
+                Pair.of(CharType.createCharType(10), strings),
+                Pair.of(ArrayType.of(StringType.INSTANCE), array),
+                Pair.of(MapType.of(StringType.INSTANCE, IntegerType.INSTANCE), 
stringKeyMap),
+                Pair.of(MapType.of(IntegerType.INSTANCE, StringType.INSTANCE), 
stringValueMap),
+                Pair.of(structType, struct));
+    }
+
+    private static PValues stringValues(List<String> hexes) {
+        PValues.Builder builder = PValues.newBuilder()
+                
.setType(PGenericType.newBuilder().setId(TypeId.STRING).build());
+        hexes.forEach(hex -> 
builder.addStringValueBytes(ByteString.copyFrom(HexFormat.of().parseHex(hex))));
+        return builder.build();
+    }
+
+    // The UTF-8 bytes, in hex, of the string literals in the results, in the 
order the results hold them.
+    private static List<String> stringLeaves(List<? extends Literal> literals) 
{
+        List<String> leaves = new ArrayList<>();
+        for (Literal literal : literals) {
+            if (literal instanceof StringLiteral) {
+                leaves.add(HexFormat.of().withUpperCase()
+                        .formatHex(((StringLiteral) 
literal).getValue().getBytes(StandardCharsets.UTF_8)));
+            } else if (literal instanceof ArrayLiteral) {
+                leaves.addAll(stringLeaves(((ArrayLiteral) 
literal).getValue()));
+            } else if (literal instanceof StructLiteral) {
+                leaves.addAll(stringLeaves(((StructLiteral) 
literal).getValue()));
+            } else if (literal instanceof MapLiteral) {
+                ((MapLiteral) literal).getValue().forEach((key, value) ->
+                        leaves.addAll(stringLeaves(ImmutableList.of(key, 
value))));
+            }
+        }
+        return leaves;
+    }
 }
diff --git 
a/regression-test/data/query_p0/expression/fold_constant/fold_constant_by_be_invalid_utf8.out
 
b/regression-test/data/query_p0/expression/fold_constant/fold_constant_by_be_invalid_utf8.out
new file mode 100644
index 00000000000..ab13d2543a5
--- /dev/null
+++ 
b/regression-test/data/query_p0/expression/fold_constant/fold_constant_by_be_invalid_utf8.out
@@ -0,0 +1,57 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !data --
+1      61
+2      FF
+3      EFBFBD
+4      61FF
+5      \N
+6      C3A9
+
+-- !filter_fold --
+array  2
+concat 4
+ff     2
+map    2
+struct 2
+utf8   6
+
+-- !project_fold --
+1      61FF    61C3A9
+2      FFFF    FFC3A9
+3      EFBFBDFF        EFBFBDC3A9
+4      61FFFF  61FFC3A9
+5      \N      \N
+6      C3A9FF  C3A9C3A9
+
+-- !filter_fold_by_be --
+array  2
+concat 4
+ff     2
+map    2
+struct 2
+utf8   6
+
+-- !project_fold_by_be --
+1      61FF    61C3A9
+2      FFFF    FFC3A9
+3      EFBFBDFF        EFBFBDC3A9
+4      61FFFF  61FFC3A9
+5      \N      \N
+6      C3A9FF  C3A9C3A9
+
+-- !filter_no_fold --
+array  2
+concat 4
+ff     2
+map    2
+struct 2
+utf8   6
+
+-- !project_no_fold --
+1      61FF    61C3A9
+2      FFFF    FFC3A9
+3      EFBFBDFF        EFBFBDC3A9
+4      61FFFF  61FFC3A9
+5      \N      \N
+6      C3A9FF  C3A9C3A9
+
diff --git 
a/regression-test/suites/query_p0/expression/fold_constant/fold_constant_by_be_invalid_utf8.groovy
 
b/regression-test/suites/query_p0/expression/fold_constant/fold_constant_by_be_invalid_utf8.groovy
new file mode 100644
index 00000000000..7e0ff898a45
--- /dev/null
+++ 
b/regression-test/suites/query_p0/expression/fold_constant/fold_constant_by_be_invalid_utf8.groovy
@@ -0,0 +1,77 @@
+// 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.
+
+// BE folds a constant string to its bytes, and FE can build a string literal 
from them only when they are
+// valid UTF-8. unhex('FF'), and the strings, arrays, maps and structs built 
from it, hold the byte 0xFF, which a
+// FE literal would turn into U+FFFD (EFBFBD). So they must give the same 
results whether FE folds the
+// constants (fold), FE and BE fold them (fold_by_be) or BE evaluates them 
(no_fold); the results of a block
+// must match in the three modes. BE still folds a string that is valid UTF-8, 
such as unhex('C3A9') ('é').
+// Each mode puts its name in a column alias, so that it is not served the 
cached result of another mode.
+
+suite("fold_constant_by_be_invalid_utf8") {
+    sql "drop table if exists fold_constant_by_be_invalid_utf8_t"
+    sql """
+        CREATE TABLE fold_constant_by_be_invalid_utf8_t (k int, s string NULL)
+        DUPLICATE KEY(k) DISTRIBUTED BY HASH(k) BUCKETS 1 PROPERTIES 
('replication_num' = '1')
+    """
+    // BE evaluates unhex, so the rows hold the bytes: 'a', 0xFF, U+FFFD, 'a' 
0xFF, NULL and 'é'.
+    sql "set enable_fold_constant_by_be = false"
+    sql "set debug_skip_fold_constant = true"
+    sql """
+        INSERT INTO fold_constant_by_be_invalid_utf8_t VALUES
+            (1, 'a'), (2, unhex('FF')), (3, unhex('EFBFBD')), (4, 
unhex('61FF')), (5, NULL), (6, unhex('C3A9'))
+    """
+    order_qt_data "SELECT k, hex(s) FROM fold_constant_by_be_invalid_utf8_t"
+
+    def modes = [
+            fold      : [false, false],
+            fold_by_be: [true, false],
+            no_fold   : [false, true]]
+    modes.each { mode, settings ->
+        sql "set enable_fold_constant_by_be = ${settings[0]}"
+        sql "set debug_skip_fold_constant = ${settings[1]}"
+        "order_qt_filter_${mode}" """
+            SELECT 'ff' AS filter_${mode}, k FROM 
fold_constant_by_be_invalid_utf8_t WHERE s = unhex('FF')
+            UNION ALL
+            SELECT 'concat', k FROM fold_constant_by_be_invalid_utf8_t WHERE s 
= concat('a', unhex('FF'))
+            UNION ALL
+            SELECT 'array', k FROM fold_constant_by_be_invalid_utf8_t WHERE 
array_contains(array(unhex('FF')), s)
+            UNION ALL
+            SELECT 'map', k FROM fold_constant_by_be_invalid_utf8_t WHERE 
map_contains_key(map(unhex('FF'), 1), s)
+            UNION ALL
+            SELECT 'struct', k FROM fold_constant_by_be_invalid_utf8_t
+            WHERE struct_element(if(k > 0, named_struct('f', unhex('FF')), 
named_struct('f', 'x')), 'f') = s
+            UNION ALL
+            SELECT 'utf8', k FROM fold_constant_by_be_invalid_utf8_t WHERE s = 
unhex('C3A9')
+        """
+        "order_qt_project_${mode}" """
+            SELECT k AS project_${mode}, hex(concat(s, unhex('FF'))), 
hex(concat(s, unhex('C3A9')))
+            FROM fold_constant_by_be_invalid_utf8_t
+        """
+    }
+
+    sql "set enable_fold_constant_by_be = true"
+    sql "set debug_skip_fold_constant = false"
+    explain {
+        sql "SELECT k FROM fold_constant_by_be_invalid_utf8_t WHERE s = 
unhex('C3A9')"
+        contains "= 'é')"
+    }
+    explain {
+        sql "SELECT k FROM fold_constant_by_be_invalid_utf8_t WHERE s = 
unhex('FF')"
+        contains "= unhex('FF'))"
+    }
+}


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

Reply via email to