github-actions[bot] commented on code in PR #63528:
URL: https://github.com/apache/doris/pull/63528#discussion_r4324150491


##########
fe/fe-core/src/main/java/org/apache/doris/nereids/load/NereidsLoadScanProvider.java:
##########
@@ -581,4 +600,321 @@ private Expression transformHadoopFunctionExpr(Table tbl, 
String columnName, Exp
         }
         return originExpr;
     }
+
+    /**
+     * The explicit value of a default_value or replace_value mapping on an 
ARRAY/MAP/STRUCT column was
+     * validated by {@link NereidsDataDescription#validateMappingFunction} as 
a SQL literal, but BE casts
+     * the mapping expression as a string. Replace it with the canonical text 
so that both agree, e.g.
+     * {@code '[1e3]'} for an array of INT is written as {@code [1000]} 
instead of a NULL element.
+     */
+    private static Expression canonicalizeComplexValue(Column column, 
Expression value) {
+        if (!column.getOriginType().isComplexType() || !(value instanceof 
StringLikeLiteral)) {
+            return value;
+        }
+        return new StringLiteral(ComplexTypeDefaultValue.canonicalize(
+                DataType.fromCatalogType(column.getOriginType()), 
((StringLikeLiteral) value).getStringValue()));
+    }
+
+    /**
+     * Arrow reads a column with its declared type, so an ARRAY/MAP/STRUCT 
column keeps the complex type
+     * in its scan slot instead of being read as VARCHAR like the text 
formats. The mapping expressions
+     * have to work on that complex type, otherwise the IF branches mix 
complex and string values.
+     */
+    private boolean readsNativeComplexType(Column column) {
+        return column.getOriginType().isComplexType()
+                && 
fileGroupInfo.getFileGroup().getFileFormatProperties().getFileFormatType()
+                        == TFileFormatType.FORMAT_ARROW;
+    }
+
+    /**
+     * The mapping value of a complex column is written as canonical text. 
When the scan slot keeps the
+     * native complex type, parse that text into the column type: a string 
branch and a complex branch
+     * have no common type under the legacy type coercion behavior ({@code
+     * enable_new_type_coercion_behavior=false}).
+     */
+    private static Expression mappingValue(Column column, Expression value, 
boolean nativeComplex) {
+        Expression canonical = canonicalizeComplexValue(column, value);
+        if (!nativeComplex) {
+            return canonical;
+        }
+        return new Cast(canonical, 
DataType.fromCatalogType(column.getOriginType()));
+    }
+
+    /**
+     * The NULL branch of a mapping IF. The typed VARCHAR NULL used for text 
sources has no common type
+     * with a native complex branch under the legacy type coercion behavior, 
so leave it untyped.
+     */
+    private static Expression nullMappingValue(boolean nativeComplex) {
+        return nativeComplex ? new NullLiteral() : new 
NullLiteral(VarcharType.SYSTEM_DEFAULT);
+    }
+
+    /**
+     * A text source compares its raw value with the search value of {@code 
replace_value}. A native
+     * complex slot needs a type-aware comparison instead of the display text, 
whose nested strings are
+     * not escaped: a single element {@code a", "b} renders exactly like the 
two element array
+     * {@code ["a", "b"]}. The search is a transient comparison value, so it 
must not be canonicalized
+     * as a column default, whose format rejects quotes and backslashes that 
the complex cast accepts.
+     */
+    private static Expression searchValueComparison(Expression slot, Column 
column, Expression search,
+            boolean nativeComplex) throws AnalysisException {
+        if (!nativeComplex) {
+            return new EqualTo(slot, search);
+        }
+        DataType complexType = 
DataType.fromCatalogType(column.getOriginType());
+        // Cast the search expression, literal or slot, to the column type so 
that the comparison sees
+        // exactly the bytes the sink writes. The cast is strict: Stream Load 
disables
+        // enable_strict_cast, so a malformed non-NULL search would otherwise 
become SQL NULL, which
+        // the mapping IF treats as "not equal" and replaces every row, 
including rows that do not
+        // match the search. A strict cast fails the load instead.
+        Expression typedSearch = new Cast(search, complexType, false, true);
+        return buildEquality(slot, typedSearch, complexType, true, 
column.getOriginType().toSql());
+    }
+
+    /**
+     * Equality of two expressions of {@code type}, built from comparisons the 
engine can evaluate.
+     * Primitive values and arrays of them are compared structurally ({@code 
compare_at}), which keeps
+     * nested NULLs apart from values and treats two NULLs as equal like the 
engine does inside a
+     * complex value; {@code <=>} expresses that for a nested value. MAP and 
STRUCT are rejected by
+     * {@code ComparisonPredicate}, so their sub-values are compared 
recursively: a MAP is identified
+     * by its deduplicated keys and values in order, and a STRUCT that cannot 
be serialized losslessly
+     * enough for a whole JSON comparison both by its own NULL flag and by its 
fields. A STRUCT whose
+     * JSON text is a lossless identity is compared as one value, and a STRUCT 
whose leaves have no
+     * lossless JSON identity but are comparable recursively is compared by 
one engine call, so the
+     * search is parsed a bounded number of times instead of once per field.
+     *
+     * <p>JSON is used only for a value the engine cannot compare 
structurally. A MAP is never
+     * rendered as a JSON object: JSONB object keys are limited to 255 bytes 
and lose the key NULL
+     * flag, so the empty string key and a NULL key would render the same. 
Nested MAPs are rejected
+     * for the same reason.
+     *
+     * @param topLevel whether this is the whole search comparison. A NULL 
search then keeps the SQL
+     *                 three-valued result of the text formats instead of 
matching a NULL value.
+     */
+    private static Expression buildEquality(Expression left, Expression right, 
DataType type,
+            boolean topLevel, String columnTypeSql) throws AnalysisException {
+        if (supportsStructuralComparison(type)) {
+            // A top level ARRAY is compared by EqualTo, which is NULL when 
the search is NULL; a
+            // nested value uses <=> so that two NULLs match, like compare_at 
inside a complex value.
+            return topLevel ? new EqualTo(left, right) : new 
NullSafeEqual(left, right);
+        }
+        if (type.isMapType()) {
+            MapType mapType = (MapType) type;
+            DataType keyType = mapType.getKeyType();
+            DataType valueType = mapType.getValueType();
+            if (!supportsStructuralComparison(keyType) || 
!supportsMapValueComparison(valueType)) {
+                throw unsupportedComplexSearch(columnTypeSql, type);
+            }
+            // An Arrow MAP keeps every entry, so a repeated key makes its key 
and value arrays
+            // longer than the ones of the search text, which the string cast 
deduplicates with the
+            // last value winning. The sink applies the same last-wins rule 
before it writes the
+            // column, so normalize the native source the same way; otherwise 
a row that is written
+            // as the search value would not match it and would not be 
replaced.
+            Expression leftMap = deduplicateMap(left);
+            // Only the top level search is the string cast, which already 
deduplicated its keys. A
+            // MAP field of a STRUCT is parsed by the struct SerDe, which 
leaves repeated keys in
+            // place, so it needs the same normalization as the source.
+            Expression rightMap = topLevel ? right : deduplicateMap(right);

Review Comment:
   [P2] Normalize native MAP search slots before comparing
   
   A `replace_value` search can be another native MAP slot, including the 
source column itself. For Arrow `v MAP<INT,INT>` containing entries 
`[(1,1),(1,2)]`, `v=replace_value(v,NULL)` deduplicates the left side to 
`{1:2}` but leaves the right side with both entries here. The search's 
same-type cast is a no-op, so its keys remain `[1,1]` instead of `[1]`; the 
comparison is false and the load keeps `{1:2}` rather than replacing the column 
with NULL. Normalize native search maps too instead of assuming every top-level 
search passed through the string parser.



##########
be/src/exprs/function/comparison_equals.cpp:
##########
@@ -15,18 +15,86 @@
 // specific language governing permissions and limitations
 // under the License.
 
+#include "common/check.h"
 #include "core/accurate_comparison.h"
 #include "core/column/column.h"
+#include "core/column/column_nullable.h"
+#include "core/column/column_vector.h"
 #include "core/data_type/data_type.h"
+#include "core/data_type/data_type_number.h"
 #include "exprs/function/functions_comparison.h"
 #include "exprs/function/simple_function_factory.h"
 
 namespace doris {
 using FunctionEquals = FunctionComparison<EqualsOp, NameEquals>;
 using FunctionNotEquals = FunctionComparison<NotEqualsOp, NameNotEquals>;
 
+/// Recursive equality for a value the planner cannot compare as one 
expression, for example a
+/// STRUCT with a float leaf whose JSON text is not a lossless identity. The 
comparison is NULL
+/// safe: two NULL values (including a NULL nested field) are equal, and a 
NULL and a non-NULL
+/// value are not.
+///
+/// The planner emits this function to keep the typed search in one place. 
Expanding the fields in
+/// the expression tree instead embeds the search cast in every field 
comparison, and each
+/// occurrence parses the whole search string again; an N field STRUCT then 
converts N fields N+1
+/// times per row. Column::compare_at already applies the nested NULL rule and 
compares every
+/// column type structurally, so the function only has to unwrap the two top 
level null maps.
+class FunctionComplexEquals : public IFunction {
+public:
+    static constexpr auto name = "complex_equals";
+    static FunctionPtr create() { return 
std::make_shared<FunctionComplexEquals>(); }
+
+    String get_name() const override { return name; }
+
+    size_t get_number_of_arguments() const override { return 2; }
+
+    bool use_default_implementation_for_nulls() const override { return false; 
}
+
+    DataTypePtr get_return_type_impl(const DataTypes& /*arguments*/) const 
override {
+        return std::make_shared<DataTypeBool>();
+    }
+
+    Status execute_impl(FunctionContext* /*context*/, Block& block, const 
ColumnNumbers& arguments,
+                        uint32_t result, size_t input_rows_count) const 
override {
+        ColumnPtr left =
+                
block.get_by_position(arguments[0]).column->convert_to_full_column_if_const();
+        ColumnPtr right =
+                
block.get_by_position(arguments[1]).column->convert_to_full_column_if_const();

Review Comment:
   [P2] Compare constant searches without expanding their payload
   
   For an Arrow `replace_value` mapping on `STRUCT<a:ARRAY<INT>,d:DOUBLE>`, a 
literal search reaches this function as a one-row `ColumnConst`, while the 
source column is nonconstant. Expanding that search here copies its entire 
nested array once per input row (`ColumnConst::convert_to_full_column` -> 
`ColumnStruct::insert_many_from` -> `ColumnArray::insert_many_from`). A 
100000-element search therefore adds about 2 GB of nullable INT payload for a 
4096-row batch, even when every source array is empty and cannot match. Retain 
the constant's data column and compare against row 0, as 
`GenericComparisonImpl::vector_constant` already does.



##########
be/src/exprs/function/cast/cast_base.cpp:
##########
@@ -264,12 +265,20 @@ Status cast_from_string_to_complex_type(FunctionContext* 
context, Block& block,
     options.escape_char = '\\';
     options.timezone = &context->state()->timezone_obj();
 
+    // The complex SerDes unescape nested strings in place, and get_data_at() 
returns a view into
+    // the source column. That column can also be another load target, for 
example
+    // `columns: k, s, s = replace_value(w, NULL), w`, so parsing the borrowed 
text would rewrite
+    // the bytes the same row imports into w. Parse an owned copy instead; 
reusing the buffer keeps
+    // the copy at one allocation per batch.
+    std::string owned_text;
     for (size_t i = 0; i < input_rows_count; ++i) {
         if (null_map && null_map[i]) {
             nullable_col_to.insert_default();
         } else {
             auto str = col_from->get_data_at(i);
-            Status st = to_serde->from_string(str, nested_column, options);
+            owned_text.assign(str.data, str.size);

Review Comment:
   [P2] Charge the owned cast scratch buffer to the query tracker
   
   Every string-to-complex cast now copies a whole input cell into 
`std::string`, which bypasses Doris Allocator/MemTracker. For a 9 MiB STRING 
containing non-array text, `CAST(s AS ARRAY<INT>)` allocates another roughly 9 
MiB before rejecting the first character and returning only NULL; the tracked 
source and output therefore omit the new live buffer. Concurrent evaluators 
multiply this uncharged memory, and the string retains its largest capacity 
until the batch ends. Keep the owned copy, but use reusable allocator-aware 
storage in both wrappers (the strict twin is at line 323). This is separate 
from the existing thread about persisted-default `Field` vectors.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/load/NereidsLoadScanProvider.java:
##########
@@ -581,4 +600,321 @@ private Expression transformHadoopFunctionExpr(Table tbl, 
String columnName, Exp
         }
         return originExpr;
     }
+
+    /**
+     * The explicit value of a default_value or replace_value mapping on an 
ARRAY/MAP/STRUCT column was
+     * validated by {@link NereidsDataDescription#validateMappingFunction} as 
a SQL literal, but BE casts
+     * the mapping expression as a string. Replace it with the canonical text 
so that both agree, e.g.
+     * {@code '[1e3]'} for an array of INT is written as {@code [1000]} 
instead of a NULL element.
+     */
+    private static Expression canonicalizeComplexValue(Column column, 
Expression value) {
+        if (!column.getOriginType().isComplexType() || !(value instanceof 
StringLikeLiteral)) {
+            return value;
+        }
+        return new StringLiteral(ComplexTypeDefaultValue.canonicalize(
+                DataType.fromCatalogType(column.getOriginType()), 
((StringLikeLiteral) value).getStringValue()));
+    }
+
+    /**
+     * Arrow reads a column with its declared type, so an ARRAY/MAP/STRUCT 
column keeps the complex type
+     * in its scan slot instead of being read as VARCHAR like the text 
formats. The mapping expressions
+     * have to work on that complex type, otherwise the IF branches mix 
complex and string values.
+     */
+    private boolean readsNativeComplexType(Column column) {
+        return column.getOriginType().isComplexType()
+                && 
fileGroupInfo.getFileGroup().getFileFormatProperties().getFileFormatType()
+                        == TFileFormatType.FORMAT_ARROW;
+    }
+
+    /**
+     * The mapping value of a complex column is written as canonical text. 
When the scan slot keeps the
+     * native complex type, parse that text into the column type: a string 
branch and a complex branch
+     * have no common type under the legacy type coercion behavior ({@code
+     * enable_new_type_coercion_behavior=false}).
+     */
+    private static Expression mappingValue(Column column, Expression value, 
boolean nativeComplex) {
+        Expression canonical = canonicalizeComplexValue(column, value);
+        if (!nativeComplex) {
+            return canonical;
+        }
+        return new Cast(canonical, 
DataType.fromCatalogType(column.getOriginType()));
+    }
+
+    /**
+     * The NULL branch of a mapping IF. The typed VARCHAR NULL used for text 
sources has no common type
+     * with a native complex branch under the legacy type coercion behavior, 
so leave it untyped.
+     */
+    private static Expression nullMappingValue(boolean nativeComplex) {
+        return nativeComplex ? new NullLiteral() : new 
NullLiteral(VarcharType.SYSTEM_DEFAULT);
+    }
+
+    /**
+     * A text source compares its raw value with the search value of {@code 
replace_value}. A native
+     * complex slot needs a type-aware comparison instead of the display text, 
whose nested strings are
+     * not escaped: a single element {@code a", "b} renders exactly like the 
two element array
+     * {@code ["a", "b"]}. The search is a transient comparison value, so it 
must not be canonicalized
+     * as a column default, whose format rejects quotes and backslashes that 
the complex cast accepts.
+     */
+    private static Expression searchValueComparison(Expression slot, Column 
column, Expression search,
+            boolean nativeComplex) throws AnalysisException {
+        if (!nativeComplex) {
+            return new EqualTo(slot, search);
+        }
+        DataType complexType = 
DataType.fromCatalogType(column.getOriginType());
+        // Cast the search expression, literal or slot, to the column type so 
that the comparison sees
+        // exactly the bytes the sink writes. The cast is strict: Stream Load 
disables
+        // enable_strict_cast, so a malformed non-NULL search would otherwise 
become SQL NULL, which
+        // the mapping IF treats as "not equal" and replaces every row, 
including rows that do not
+        // match the search. A strict cast fails the load instead.
+        Expression typedSearch = new Cast(search, complexType, false, true);
+        return buildEquality(slot, typedSearch, complexType, true, 
column.getOriginType().toSql());
+    }
+
+    /**
+     * Equality of two expressions of {@code type}, built from comparisons the 
engine can evaluate.
+     * Primitive values and arrays of them are compared structurally ({@code 
compare_at}), which keeps
+     * nested NULLs apart from values and treats two NULLs as equal like the 
engine does inside a
+     * complex value; {@code <=>} expresses that for a nested value. MAP and 
STRUCT are rejected by
+     * {@code ComparisonPredicate}, so their sub-values are compared 
recursively: a MAP is identified
+     * by its deduplicated keys and values in order, and a STRUCT that cannot 
be serialized losslessly
+     * enough for a whole JSON comparison both by its own NULL flag and by its 
fields. A STRUCT whose
+     * JSON text is a lossless identity is compared as one value, and a STRUCT 
whose leaves have no
+     * lossless JSON identity but are comparable recursively is compared by 
one engine call, so the
+     * search is parsed a bounded number of times instead of once per field.
+     *
+     * <p>JSON is used only for a value the engine cannot compare 
structurally. A MAP is never
+     * rendered as a JSON object: JSONB object keys are limited to 255 bytes 
and lose the key NULL
+     * flag, so the empty string key and a NULL key would render the same. 
Nested MAPs are rejected
+     * for the same reason.
+     *
+     * @param topLevel whether this is the whole search comparison. A NULL 
search then keeps the SQL
+     *                 three-valued result of the text formats instead of 
matching a NULL value.
+     */
+    private static Expression buildEquality(Expression left, Expression right, 
DataType type,
+            boolean topLevel, String columnTypeSql) throws AnalysisException {
+        if (supportsStructuralComparison(type)) {
+            // A top level ARRAY is compared by EqualTo, which is NULL when 
the search is NULL; a
+            // nested value uses <=> so that two NULLs match, like compare_at 
inside a complex value.
+            return topLevel ? new EqualTo(left, right) : new 
NullSafeEqual(left, right);
+        }
+        if (type.isMapType()) {
+            MapType mapType = (MapType) type;
+            DataType keyType = mapType.getKeyType();
+            DataType valueType = mapType.getValueType();
+            if (!supportsStructuralComparison(keyType) || 
!supportsMapValueComparison(valueType)) {
+                throw unsupportedComplexSearch(columnTypeSql, type);
+            }
+            // An Arrow MAP keeps every entry, so a repeated key makes its key 
and value arrays
+            // longer than the ones of the search text, which the string cast 
deduplicates with the
+            // last value winning. The sink applies the same last-wins rule 
before it writes the
+            // column, so normalize the native source the same way; otherwise 
a row that is written
+            // as the search value would not match it and would not be 
replaced.
+            Expression leftMap = deduplicateMap(left);
+            // Only the top level search is the string cast, which already 
deduplicated its keys. A
+            // MAP field of a STRUCT is parsed by the struct SerDe, which 
leaves repeated keys in
+            // place, so it needs the same normalization as the source.
+            Expression rightMap = topLevel ? right : deduplicateMap(right);
+            List<Expression> equalities = Lists.newArrayList(
+                    new NullSafeEqual(mapFunction("map_keys", leftMap), 
mapFunction("map_keys", rightMap)));
+            if (supportsStructuralComparison(valueType)) {
+                equalities.add(new NullSafeEqual(mapFunction("map_values", 
leftMap),
+                        mapFunction("map_values", rightMap)));
+            } else {
+                // The value contains a STRUCT or an ARRAY of one, which the 
engine cannot compare as
+                // an array element. Its JSON rendering is escaped, keeps NULL 
apart from the "null"
+                // text and never uses the value as a JSON object key.
+                equalities.add(new 
NullSafeEqual(jsonText(mapFunction("map_values", leftMap)),
+                        jsonText(mapFunction("map_values", rightMap))));
+            }
+            Expression equality = new And(equalities);
+            return topLevel ? guardNullSearch(right, equality) : equality;
+        }
+        if (type.isStructType()) {
+            if (supportsJsonEncoding(type)) {
+                // The engine rejects a direct STRUCT comparison, so its JSON 
text is the comparison
+                // key. Compare the whole value at once: expanding the fields 
would embed the typed
+                // search in every field comparison and each occurrence parses 
the whole search
+                // string, which makes a dynamic search over an N field STRUCT 
convert N times the
+                // N fields for every row.
+                return topLevel
+                        ? new EqualTo(jsonText(left), jsonText(right))
+                        : new NullSafeEqual(jsonText(left), jsonText(right));
+            }
+            if (supportsDeepStructuralComparison(type)) {
+                // The JSON text is not a lossless identity for this STRUCT (a 
float signed zero, a
+                // version one DATE/DATETIME, DECIMALV2, ...), so the fields 
have to be compared
+                // one by one. Doing that in the expression tree embeds the 
typed search in every
+                // field comparison, and each occurrence parses the whole 
search string: an N field
+                // STRUCT converts N fields N+1 times per row. One engine call 
compares both values
+                // recursively instead, so the search is parsed once.
+                Expression equality = complexEquals(left, right);
+                return topLevel ? guardNullSearch(right, equality) : equality;
+            }
+            List<StructField> fields = ((StructType) type).getFields();
+            List<Expression> equalities = 
Lists.newArrayListWithCapacity(fields.size());
+            for (int i = 0; i < fields.size(); i++) {

Review Comment:
   [P2] Reuse the typed search when a STRUCT contains MAP fields
   
   For `v STRUCT<m1:MAP<INT,INT>,...,mN:MAP<INT,INT>>` and a dynamic STRING 
source `w`, `v=replace_value(w,NULL)` falls through to this expansion because 
both faster branches reject MAPs. Each field's key and value comparison embeds 
another `CAST(w AS full_v_type STRICT)`, so a matching row parses all N fields 
at least 2N times. Load plans are translated directly from the logical project 
and do not run physical-project common-subexpression extraction, leaving 
quadratic conversion work per row. Materialize the typed search once before 
expanding the field comparisons, or compare a once-normalized value in one 
engine call.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to