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


##########
be/src/format/table/es/es_scroll_parser.cpp:
##########
@@ -723,15 +801,20 @@ Status ScrollParser::fill_columns(const 
std::vector<SlotDescriptor*>& slots,
             }
         }
 
-        const rapidjson::Value& col = (*line)[col_name];
-
         auto type = slot_desc->type()->get_primitive_type();
-
-        // when the column value is null, the subsequent type casting will 
report an error
-        if (col.IsNull() && slot_desc->is_nullable()) {
+        const rapidjson::Value& source_value = (*line)[col_name];
+        // Source fallback changes every sibling's decoder. A scalar 
numeric/Boolean/date field
+        // may hold [value], [] or [null]; preserve its single value or 
missing-value semantics.
+        // STRING keeps its JSON representation, and declared ARRAY keeps its 
container.
+        bool scalar_source_array = !pure_doc_value && source_value.IsArray() &&
+                                   type != TYPE_ARRAY && !is_string_type(type);

Review Comment:
   [P2] Preserve JSONB containers during scalar array normalization
   
   `TYPE_JSONB` also satisfies this condition: `is_string_type` only includes 
CHAR/VARCHAR/STRING. With docvalue scanning disabled, map `obj` as an 
object/nested field and store `{"obj":[{"x":1}]}`. Both readers now pass only 
`{"x":1}` to the JSONB branch; `obj:[]` and `obj:[null]` instead enter the SQL 
NULL branch. The merge-base parser serialized each original JSON array intact. 
This changes existing source reads without any predicate or newly forced 
fallback. Restrict normalization to the intended scalar numeric/Boolean/date 
families, or exclude JSONB, and add JSONB singleton/empty/null-element array 
controls. This is distinct from the existing scalar numeric-array and declared 
ARRAY predicate threads.



##########
be/src/format/table/es/es_scroll_parser.cpp:
##########
@@ -156,36 +146,153 @@ static const std::string INVALID_NULL_VALUE =
         return Status::RuntimeError(ss.str());                           \
     } while (false)
 
+// ES integer coercion truncates decimal/scientific notation, e.g. "1.2e3" -> 
1200.
+// Shift the decimal point using digits so values near INT64_MAX never pass 
through double.
 template <typename T>
-Status get_int_value(const rapidjson::Value& col, PrimitiveType type, void* 
slot,
-                     bool pure_doc_value) {
-    if (col.IsNumber()) {
-        *reinterpret_cast<T*>(slot) = (T)(sizeof(T) < 8 ? col.GetInt() : 
col.GetInt64());
-        return Status::OK();
+static T parse_es_integer(const char* text, size_t length, 
StringParser::ParseResult* result) {
+    text = skip_ascii_whitespaces(text, length);
+    size_t i = 0;
+    bool negative = length > 0 && text[0] == '-';
+    if (length > 0 && (text[0] == '-' || text[0] == '+')) {
+        ++i;
+    }
+    size_t start = i;
+    int64_t digits = 0;
+    int64_t integer_digits = 0;
+    int64_t first_nonzero = -1;
+    bool dot = false;
+    for (; i < length; ++i) {
+        char c = text[i];
+        if (c >= '0' && c <= '9') {
+            if (c != '0' && first_nonzero < 0) {
+                first_nonzero = digits;
+            }
+            ++digits;
+            integer_digits += !dot;
+        } else if (c == '.' && !dot) {
+            dot = true;
+        } else {
+            break;
+        }
     }
-
-    if (pure_doc_value && col.IsArray() && !col.Empty()) {
-        RETURN_ERROR_IF_COL_IS_NOT_NUMBER(col[0], type);
-        *reinterpret_cast<T*>(slot) = (T)(sizeof(T) < 8 ? col[0].GetInt() : 
col[0].GetInt64());
-        return Status::OK();
+    size_t end = i;
+    int64_t exponent = 0;
+    bool negative_exponent = false;
+    if (i < length && (text[i] == 'e' || text[i] == 'E')) {
+        ++i;
+        if (i < length && (text[i] == '+' || text[i] == '-')) {
+            negative_exponent = text[i++] == '-';
+        }
+        size_t exponent_start = i;
+        for (; i < length && text[i] >= '0' && text[i] <= '9'; ++i) {
+            // Saturation avoids overflow and work proportional to an 
untrusted exponent.
+            exponent = std::min(exponent * 10 + text[i] - '0', 
static_cast<int64_t>(length) + 64);
+        }
+        if (i == exponent_start) {
+            *result = StringParser::PARSE_FAILURE;
+            return 0;
+        }
     }
+    if (digits == 0 || i != length) {
+        *result = StringParser::PARSE_FAILURE;
+        return 0;
+    }
+    int64_t count = integer_digits + (negative_exponent ? -exponent : 
exponent) - first_nonzero;
+    if (first_nonzero < 0 || count <= 0) {
+        *result = StringParser::PARSE_SUCCESS;
+        return 0;
+    }
+    // 39 digits cover every signed 128-bit value; the final parser checks the 
exact type bounds.
+    if (count > 39) {
+        *result = StringParser::PARSE_OVERFLOW;
+        return 0;
+    }
+    char integral[40];
+    size_t size = 0;
+    if (negative) {
+        integral[size++] = '-';
+    }
+    int64_t digit = 0;
+    for (i = start; i < end && count > 0; ++i) {
+        if (text[i] != '.' && digit++ >= first_nonzero) {
+            integral[size++] = text[i];
+            --count;
+        }
+    }
+    while (count-- > 0) {
+        integral[size++] = '0';
+    }
+    return StringParser::string_to_int<T, true>(integral, size, result);
+}
 
-    RETURN_ERROR_IF_COL_IS_ARRAY(col, type, true);
-    RETURN_ERROR_IF_COL_IS_NOT_STRING(col, type);
-
-    StringParser::ParseResult result;
-    const std::string& val = col.GetString();
-    size_t len = col.GetStringLength();
-    T v = StringParser::string_to_int<T>(val.c_str(), len, &result);
-    RETURN_ERROR_IF_PARSING_FAILED(result, col, type);
+template <typename T>
+static T parse_es_narrow_integer(const std::string& text, 
StringParser::ParseResult* result) {
+    // Unlike long's BigDecimal conversion, ES parses byte/short/integer 
strings through double:
+    // "0.99999999999999999" rounds to 1 before truncation. Keep long's exact 
path separate.
+    auto number = StringParser::string_to_float<double>(text.data(), 
text.size(), result);
+    if (*result != StringParser::PARSE_SUCCESS) {
+        return 0;
+    }
+    // Java converts NaN to zero; casting it directly in C++ would be 
undefined.
+    if (std::isnan(number)) {
+        return 0;
+    }
+    using ParsedType = std::conditional_t<sizeof(T) == 1, int32_t, T>;
+    if (number < StringParser::numeric_limits<ParsedType>(true) ||
+        number > StringParser::numeric_limits<ParsedType>(false)) {
+        *result = StringParser::PARSE_OVERFLOW;
+        return 0;
+    }
+    auto parsed = static_cast<ParsedType>(number);
+    // ES byte uses intValue() then checks the byte range, so "127.9" is 
valid. Short and
+    // integer instead check their own double bounds before casting (e.g. 
short "32767.9" fails).
+    if constexpr (sizeof(T) == 1) {
+        if (parsed < StringParser::numeric_limits<T>(true) ||
+            parsed > StringParser::numeric_limits<T>(false)) {
+            *result = StringParser::PARSE_OVERFLOW;
+            return 0;
+        }
+    }
+    return static_cast<T>(parsed);
+}
 
-    if (sizeof(T) < 16) {
-        *reinterpret_cast<T*>(slot) = v;
+template <typename T>
+Status get_int_value(const rapidjson::Value& col, PrimitiveType type, void* 
slot,
+                     bool pure_doc_value) {
+    const rapidjson::Value* value = &col;
+    // A singleton source array has the same scalar value as its one docvalue. 
Keep rejecting
+    // multi-value source arrays: selecting an arbitrary element would discard 
source semantics.
+    if (col.IsArray() && !col.Empty() && (pure_doc_value || col.Size() == 1)) {
+        value = &col[0];
+    }
+    RETURN_ERROR_IF_COL_IS_ARRAY((*value), type, true);
+    T parsed;
+    if (value->IsInt64() || value->IsUint64()) {
+        __int128 number = value->IsInt64() ? 
static_cast<__int128>(value->GetInt64())
+                                           : 
static_cast<__int128>(value->GetUint64());
+        if (number < StringParser::numeric_limits<T>(true) ||
+            number > StringParser::numeric_limits<T>(false)) {
+            return Status::RuntimeError("ES integer value is out of range for 
{}",
+                                        type_to_string(type));
+        }
+        parsed = static_cast<T>(number);
     } else {
-        DCHECK(sizeof(T) == 16);
-        memcpy(slot, &v, sizeof(v));
+        if (!value->IsString() && !value->IsNumber()) {
+            RETURN_ERROR_IF_COL_IS_NOT_STRING((*value), type);
+        }
+        std::string text = value->IsString()
+                                   ? std::string(value->GetString(), 
value->GetStringLength())
+                                   : json_value_to_string(*value);

Review Comment:
   [P2] Preserve numeric INT64_MIN when converting double tokens
   
   Map `n` as `long`, store the unquoted JSON number `-9223372036854775808.0`, 
and disable docvalue scanning. [ES/Jackson numeric 
conversion](https://github.com/FasterXML/jackson-core/blob/jackson-core-2.15.3/src/main/java/com/fasterxml/jackson/core/base/ParserBase.java#L996-L1013)
 accepts this exactly representable double as INT64_MIN, and the merge-base 
`insert_int_value` directly cast it successfully. This branch now reserializes 
it: [the pinned RapidJSON 
writer](https://github.com/Tencent/rapidjson/blob/232389d4f1012dddec4ef84861face2d2ba85709/include/rapidjson/internal/dtoa.h#L139-L150)
 produces `-9223372036854776000.0`, which `parse_es_integer<int64_t>` rejects 
as below INT64_MIN. Consequently a previously supported `SELECT n FROM idx` 
fails on both readers. The same binary boundary survives ES source projection's 
scientific serialization, so this does not depend on preserving the original 
token text. Keep a checked numeric-double conversion path and reserve exact 
decimal parsi
 ng for strings; add an unquoted numeric INT64_MIN control. This numeric-token 
defect differs from the earlier quoted-string exponent and narrow-string 
rounding threads.



##########
be/src/format/table/es/es_scroll_parser.cpp:
##########
@@ -156,36 +146,153 @@ static const std::string INVALID_NULL_VALUE =
         return Status::RuntimeError(ss.str());                           \
     } while (false)
 
+// ES integer coercion truncates decimal/scientific notation, e.g. "1.2e3" -> 
1200.
+// Shift the decimal point using digits so values near INT64_MAX never pass 
through double.
 template <typename T>
-Status get_int_value(const rapidjson::Value& col, PrimitiveType type, void* 
slot,
-                     bool pure_doc_value) {
-    if (col.IsNumber()) {
-        *reinterpret_cast<T*>(slot) = (T)(sizeof(T) < 8 ? col.GetInt() : 
col.GetInt64());
-        return Status::OK();
+static T parse_es_integer(const char* text, size_t length, 
StringParser::ParseResult* result) {
+    text = skip_ascii_whitespaces(text, length);
+    size_t i = 0;
+    bool negative = length > 0 && text[0] == '-';
+    if (length > 0 && (text[0] == '-' || text[0] == '+')) {
+        ++i;
+    }
+    size_t start = i;
+    int64_t digits = 0;
+    int64_t integer_digits = 0;
+    int64_t first_nonzero = -1;
+    bool dot = false;
+    for (; i < length; ++i) {
+        char c = text[i];
+        if (c >= '0' && c <= '9') {
+            if (c != '0' && first_nonzero < 0) {
+                first_nonzero = digits;
+            }
+            ++digits;
+            integer_digits += !dot;
+        } else if (c == '.' && !dot) {
+            dot = true;
+        } else {
+            break;
+        }
     }
-
-    if (pure_doc_value && col.IsArray() && !col.Empty()) {
-        RETURN_ERROR_IF_COL_IS_NOT_NUMBER(col[0], type);
-        *reinterpret_cast<T*>(slot) = (T)(sizeof(T) < 8 ? col[0].GetInt() : 
col[0].GetInt64());
-        return Status::OK();
+    size_t end = i;
+    int64_t exponent = 0;
+    bool negative_exponent = false;
+    if (i < length && (text[i] == 'e' || text[i] == 'E')) {
+        ++i;
+        if (i < length && (text[i] == '+' || text[i] == '-')) {
+            negative_exponent = text[i++] == '-';
+        }
+        size_t exponent_start = i;
+        for (; i < length && text[i] >= '0' && text[i] <= '9'; ++i) {
+            // Saturation avoids overflow and work proportional to an 
untrusted exponent.
+            exponent = std::min(exponent * 10 + text[i] - '0', 
static_cast<int64_t>(length) + 64);
+        }
+        if (i == exponent_start) {
+            *result = StringParser::PARSE_FAILURE;
+            return 0;
+        }
     }
+    if (digits == 0 || i != length) {
+        *result = StringParser::PARSE_FAILURE;
+        return 0;
+    }
+    int64_t count = integer_digits + (negative_exponent ? -exponent : 
exponent) - first_nonzero;
+    if (first_nonzero < 0 || count <= 0) {
+        *result = StringParser::PARSE_SUCCESS;
+        return 0;
+    }
+    // 39 digits cover every signed 128-bit value; the final parser checks the 
exact type bounds.
+    if (count > 39) {
+        *result = StringParser::PARSE_OVERFLOW;
+        return 0;
+    }
+    char integral[40];
+    size_t size = 0;
+    if (negative) {
+        integral[size++] = '-';
+    }
+    int64_t digit = 0;
+    for (i = start; i < end && count > 0; ++i) {
+        if (text[i] != '.' && digit++ >= first_nonzero) {
+            integral[size++] = text[i];
+            --count;
+        }
+    }
+    while (count-- > 0) {
+        integral[size++] = '0';
+    }
+    return StringParser::string_to_int<T, true>(integral, size, result);
+}
 
-    RETURN_ERROR_IF_COL_IS_ARRAY(col, type, true);
-    RETURN_ERROR_IF_COL_IS_NOT_STRING(col, type);
-
-    StringParser::ParseResult result;
-    const std::string& val = col.GetString();
-    size_t len = col.GetStringLength();
-    T v = StringParser::string_to_int<T>(val.c_str(), len, &result);
-    RETURN_ERROR_IF_PARSING_FAILED(result, col, type);
+template <typename T>
+static T parse_es_narrow_integer(const std::string& text, 
StringParser::ParseResult* result) {
+    // Unlike long's BigDecimal conversion, ES parses byte/short/integer 
strings through double:
+    // "0.99999999999999999" rounds to 1 before truncation. Keep long's exact 
path separate.
+    auto number = StringParser::string_to_float<double>(text.data(), 
text.size(), result);

Review Comment:
   [P2] Preserve the ES narrow-integer string grammar
   
   This new helper uses the default `fast_float` grammar through 
`StringParser`, requiring full input consumption. ES instead calls 
[Double.parseDouble](https://github.com/elastic/elasticsearch/blob/v8.11.3/libs/x-content/src/main/java/org/elasticsearch/xcontent/support/AbstractXContentParser.java#L98-L131),
 whose [accepted 
grammar](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Double.html#valueOf(java.lang.String))
 includes hex floats and floating suffixes. With normalized keyword `a`, 
integer `b`, source `{"a":"ok","b":"0x1.8p1"}` and docvalues enabled, ES 
indexes 3 and the merge-base projection/current `SELECT b` read 3. The new 
`SELECT a,b` source fallback fails because this parser stops after the leading 
0; `"3d"` fails similarly. Match the accepted ES grammar while retaining narrow 
rounding/bounds, and add controls. This is the new helper's grammar 
restriction, distinct from the earlier decimal-rounding defect.



##########
fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMappingUtils.java:
##########
@@ -150,12 +190,155 @@ public static EsFieldContext 
resolveFieldContext(List<String> columnNames,
             String fieldType = fieldObject.get("type").asText();
             fieldContext.getColumn2typeMap().put(colName, fieldType);
             resolveDateFields(fieldContext, fieldObject, colName, fieldType);
-            resolveKeywordFields(fieldContext, fieldObject, colName, 
fieldType);
             resolveDocValuesFields(fieldContext, fieldObject, colName, 
fieldType);
         }
+        resolveSourceValueSafety(fieldContext, indexMapping, mappingType, 
properties);
         return fieldContext;
     }
 
+    private static void resolveSourceValueSafety(EsFieldContext context, 
String indexMapping, String mappingType,
+            ObjectNode schemaProperties) {
+        try {
+            // Mapping-induced source/index differences on any alias backing 
index must prevent
+            // filtering or replacing source values through that path.
+            List<ObjectNode> roots = new ArrayList<>();
+            Set<String> columns = new 
HashSet<>(context.getColumn2typeMap().keySet());
+            for (JsonNode index : MAPPER.readTree(indexMapping)) {
+                ObjectNode root = getRootSchema((ObjectNode) 
index.get("mappings"), mappingType,
+                        new ArrayList<>(), true);
+                roots.add(root);
+            }
+            // Use the same populated schema as inference, including 
unprojected filter columns.
+            schemaProperties.fieldNames().forEachRemaining(columns::add);
+            // Filters can reference columns absent from the projection. 
Resolve their query paths
+            // from the exposed schema too; the projection still controls 
docvalue read eligibility.
+            for (String column : columns) {
+                ObjectNode field = (ObjectNode) schemaProperties.get(column);
+                resolveKeywordFields(context, field, column, 
field.path("type").asText());
+            }
+            Set<String> unsafeDocValues = new HashSet<>();
+            for (ObjectNode root : roots) {
+                JsonNode properties = root.path("properties");
+                if (isSourceDisabled(root.path("_source"))) {
+                    // A source projection fails on disabled backings; full 
source preserves the
+                    // enabled backings of a mixed alias while disabled hits 
remain NULL.
+                    context.requireFullSourceRead();
+                }
+                Set<String> copyTargets = new HashSet<>();
+                collectCopyTargets(properties, copyTargets);
+                for (String column : columns) {
+                    JsonNode field = properties.path(column);
+                    JsonNode queryField = fieldAtPath(field, column, 
context.getFetchFieldsContext().get(column));
+                    JsonNode readField = fieldAtPath(field, column, 
context.getDocValueFieldsContext().get(column));
+                    // Disabled root parsing leaves source fields but produces 
no index/docvalues,
+                    // even if an explicit properties section still describes 
those fields.
+                    boolean sourceChanged = 
!root.path("enabled").asBoolean(true)
+                            || filtersSourceColumn(root.path("_source"), 
column)
+                            || root.path("runtime").has(column) || 
copyTargets.contains(column);
+                    // ES mappings do not distinguish arrays from scalars. 
Even a short equality can
+                    // match one indexed array element while Doris reads the 
entire source JSON string.
+                    if (sourceChanged || changesSourceValue(field) || 
changesSourceValue(queryField)
+                            || "text".equals(queryField.path("type").asText()) 
|| !isSearchable(queryField)) {
+                        context.getSourceValueUnsafeFields().add(column);
+                    }
+                    // Source can omit generated/aliased values, retain 
unrounded numbers or decode
+                    // default dates in local time. Docvalues remain eligible 
for these mappings.
+                    if (requiresSourcePredicateEvaluation(field) || 
requiresSourcePredicateEvaluation(queryField)) {
+                        context.getSourceModeUnsafeFields().add(column);
+                    }
+                    // Query and docvalue paths may select different 
multi-fields. An unused sibling's
+                    // normalizer must not invalidate either path; only the 
chosen read value matters.
+                    if (sourceChanged || changesSourceValue(field) || 
changesSourceValue(readField)
+                            || !readField.path("doc_values").asBoolean(true)
+                            || 
DEFAULT_DOCVALUE_DISABLED_FIELDS.contains(readField.path("type").asText())) {
+                        // One backing without this docvalue path would return 
missing values for its hits.
+                        unsafeDocValues.add(column);
+                    }
+                }
+            }
+            
unsafeDocValues.forEach(context.getDocValueFieldsContext()::remove);
+        } catch (IOException e) {
+            throw new DorisConnectorException("Failed to parse ES mapping: " + 
e.getMessage());
+        }
+    }
+
+    private static JsonNode fieldAtPath(JsonNode field, String column, String 
path) {
+        return path == null || column.equals(path) ? field
+                : field.path("fields").path(path.substring(column.length() + 
1));
+    }
+
+    private static boolean changesSourceValue(JsonNode field) {
+        return field.isMissingNode() || field.has("ignore_above") || 
field.hasNonNull("normalizer")
+                || field.hasNonNull("null_value") || 
field.path("ignore_malformed").asBoolean(false);

Review Comment:
   [P2] Include inherited malformed/coercion settings in predicate safety
   
   `ignore_malformed` can be inherited from [index 
settings](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/ignore-malformed.html),
 while `_mapping` omits the implicit field default. Set 
`index.mapping.ignore_malformed=true` and `index.mapping.coerce=false`; map 
normalized keyword `a` and plain `b:long`, then store `{"a":"ok","b":"5"}`. ES 
accepts the document but omits `b` from indexing/docvalues because [coercion is 
disabled](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/coerce.html).
 The new `SELECT a,b FROM idx WHERE b IS NULL` forces source, yet this 
field-only check considers `b` safe and drops its residual after pushing 
`must_not exists(b)`. The hit now materializes as `(ok,5)`, violating IS NULL; 
the merge base read NULL from docvalues. Evaluate effective settings for every 
backing, including field overrides, before authorizing source predicates. Add 
this inherited-settings control; it is distinct from the earlier explicit 
mapping-property and num
 eric-decoder cases.



##########
fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanPlanProvider.java:
##########
@@ -206,15 +212,17 @@ private ScanNodePropertiesResult buildScanNodeProperties(
         // accepts any casing. Passing the raw string through let "TRUE" mean 
https to FE and http to BE.
         nodeProps.put(PROP_HTTP_SSL_ENABLED, 
String.valueOf(props.isHttpSslEnabled()));
 
-        // Query DSL (with not-pushed conjunct tracking)
-        EsQueryDslResult dslResult = buildQueryDsl(filter, state);
-        nodeProps.put(PROP_QUERY_DSL, dslResult.getQueryDsl());
+        // The read mode is scan-wide: losing a's docvalue path also changes a 
keyword sibling b
+        // from scalar docvalues to source JSON (e.g. ["cat"]). Decide it 
before pruning b's predicate.
+        int docValuesMode = useDocValueScan(columns, state);
+        nodeProps.put(PROP_DOC_VALUES_MODE, String.valueOf(docValuesMode));

Review Comment:
   [P2] Gate source plans that depend on the updated BE semantics
   
   The new FE source fallback assumes this PR's BE decoder, but the provider 
never sets `ScanNodePropertyKeys.REQUIRED_CURRENT_BACKEND_SEMANTICS`. An 
available smooth-upgrade source BE remains eligible, and 
`PluginDrivenScanNode.createScanRangeLocations` rejects it only when that 
marker is present. For normalized keyword `a`, long `b`, source 
`{"a":"ok","b":"1.2e3"}` and docvalues enabled, `Scan[a,b](b=1200)` now reads 
source while pruning the numeric residual. A merge-base BE strips the decimal 
suffix and returns `b=1`, violating WHERE; the new BE returns 1200. Old BEs 
also ignore `read_full_source`. Declare the existing backend-generation 
requirement for dependent source plans, or provide equivalent capability 
handling. This is a mixed-generation scheduling defect beyond the already-fixed 
homogeneous decoding threads; add smooth-upgrade coverage.



##########
be/src/format/table/es/es_scroll_parser.cpp:
##########
@@ -723,15 +801,20 @@ Status ScrollParser::fill_columns(const 
std::vector<SlotDescriptor*>& slots,
             }
         }
 
-        const rapidjson::Value& col = (*line)[col_name];
-
         auto type = slot_desc->type()->get_primitive_type();
-
-        // when the column value is null, the subsequent type casting will 
report an error
-        if (col.IsNull() && slot_desc->is_nullable()) {
+        const rapidjson::Value& source_value = (*line)[col_name];
+        // Source fallback changes every sibling's decoder. A scalar 
numeric/Boolean/date field
+        // may hold [value], [] or [null]; preserve its single value or 
missing-value semantics.
+        // STRING keeps its JSON representation, and declared ARRAY keeps its 
container.
+        bool scalar_source_array = !pure_doc_value && source_value.IsArray() &&
+                                   type != TYPE_ARRAY && !is_string_type(type);
+        const rapidjson::Value& col =
+                scalar_source_array && source_value.Size() == 1 ? 
source_value[0] : source_value;
+        bool is_null = col.IsNull() || (scalar_source_array && 
source_value.Empty());

Review Comment:
   [P2] Preserve numeric empty-string NULL coercion in source fallback
   
   An empty numeric string is supported by ES: with default coercion, 
[NumberFieldMapper.value](https://github.com/elastic/elasticsearch/blob/v8.11.3/server/src/main/java/org/elasticsearch/index/mapper/NumberFieldMapper.java#L1636-L1647)
 treats it as the default null value. Map normalized keyword `a` and ordinary 
`b:long`, store `{"a":"ok","b":""}`, and enable docvalues. `SELECT a,b FROM 
idx` previously read `(ok,NULL)`; the new normalizer safety check forces 
source, but this null prepass misses `""`, so `parse_es_integer` fails with 
zero digits and aborts. `SELECT b` still reads NULL from docvalues. 
FLOAT/DOUBLE siblings also reject the empty string. Preserve numeric 
empty-string-to-NULL materialization and add sibling/control cases across both 
readers. This differs from Boolean empty-string=false and the prior non-null 
numeric conversion threads.



##########
fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMappingUtils.java:
##########
@@ -150,12 +190,155 @@ public static EsFieldContext 
resolveFieldContext(List<String> columnNames,
             String fieldType = fieldObject.get("type").asText();
             fieldContext.getColumn2typeMap().put(colName, fieldType);
             resolveDateFields(fieldContext, fieldObject, colName, fieldType);
-            resolveKeywordFields(fieldContext, fieldObject, colName, 
fieldType);
             resolveDocValuesFields(fieldContext, fieldObject, colName, 
fieldType);
         }
+        resolveSourceValueSafety(fieldContext, indexMapping, mappingType, 
properties);
         return fieldContext;
     }
 
+    private static void resolveSourceValueSafety(EsFieldContext context, 
String indexMapping, String mappingType,
+            ObjectNode schemaProperties) {
+        try {
+            // Mapping-induced source/index differences on any alias backing 
index must prevent
+            // filtering or replacing source values through that path.
+            List<ObjectNode> roots = new ArrayList<>();
+            Set<String> columns = new 
HashSet<>(context.getColumn2typeMap().keySet());
+            for (JsonNode index : MAPPER.readTree(indexMapping)) {
+                ObjectNode root = getRootSchema((ObjectNode) 
index.get("mappings"), mappingType,
+                        new ArrayList<>(), true);
+                roots.add(root);
+            }
+            // Use the same populated schema as inference, including 
unprojected filter columns.
+            schemaProperties.fieldNames().forEachRemaining(columns::add);
+            // Filters can reference columns absent from the projection. 
Resolve their query paths
+            // from the exposed schema too; the projection still controls 
docvalue read eligibility.
+            for (String column : columns) {
+                ObjectNode field = (ObjectNode) schemaProperties.get(column);
+                resolveKeywordFields(context, field, column, 
field.path("type").asText());
+            }
+            Set<String> unsafeDocValues = new HashSet<>();
+            for (ObjectNode root : roots) {
+                JsonNode properties = root.path("properties");
+                if (isSourceDisabled(root.path("_source"))) {
+                    // A source projection fails on disabled backings; full 
source preserves the
+                    // enabled backings of a mixed alias while disabled hits 
remain NULL.
+                    context.requireFullSourceRead();
+                }
+                Set<String> copyTargets = new HashSet<>();
+                collectCopyTargets(properties, copyTargets);
+                for (String column : columns) {
+                    JsonNode field = properties.path(column);
+                    JsonNode queryField = fieldAtPath(field, column, 
context.getFetchFieldsContext().get(column));
+                    JsonNode readField = fieldAtPath(field, column, 
context.getDocValueFieldsContext().get(column));
+                    // Disabled root parsing leaves source fields but produces 
no index/docvalues,
+                    // even if an explicit properties section still describes 
those fields.
+                    boolean sourceChanged = 
!root.path("enabled").asBoolean(true)
+                            || filtersSourceColumn(root.path("_source"), 
column)
+                            || root.path("runtime").has(column) || 
copyTargets.contains(column);
+                    // ES mappings do not distinguish arrays from scalars. 
Even a short equality can
+                    // match one indexed array element while Doris reads the 
entire source JSON string.
+                    if (sourceChanged || changesSourceValue(field) || 
changesSourceValue(queryField)
+                            || "text".equals(queryField.path("type").asText()) 
|| !isSearchable(queryField)) {
+                        context.getSourceValueUnsafeFields().add(column);
+                    }
+                    // Source can omit generated/aliased values, retain 
unrounded numbers or decode
+                    // default dates in local time. Docvalues remain eligible 
for these mappings.
+                    if (requiresSourcePredicateEvaluation(field) || 
requiresSourcePredicateEvaluation(queryField)) {
+                        context.getSourceModeUnsafeFields().add(column);
+                    }
+                    // Query and docvalue paths may select different 
multi-fields. An unused sibling's
+                    // normalizer must not invalidate either path; only the 
chosen read value matters.
+                    if (sourceChanged || changesSourceValue(field) || 
changesSourceValue(readField)
+                            || !readField.path("doc_values").asBoolean(true)
+                            || 
DEFAULT_DOCVALUE_DISABLED_FIELDS.contains(readField.path("type").asText())) {
+                        // One backing without this docvalue path would return 
missing values for its hits.
+                        unsafeDocValues.add(column);
+                    }
+                }
+            }
+            
unsafeDocValues.forEach(context.getDocValueFieldsContext()::remove);

Review Comment:
   [P2] Handle nullable ARRAY elements before forcing source reads
   
   With docvalues enabled, map normalized keyword `a`, `b:long`, and 
`_meta.doris.array_fields:["b"]`; store `{"a":"ok","b":[null,1]}`. Removing 
`a`'s path switches `SELECT a,b FROM idx` to source. The declared ARRAY 
bypasses scalar normalization, and `parse_column -> process_column_array -> 
get_int_value(null)` returns a type error. Previously ES omitted the null from 
docvalues and the query read `[1]`; selecting `b` alone still works. ES 
[supports null array 
elements](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/array.html).
 The Boolean ARRAY variant reaches `GetString()` on a null node. Materialize 
nullable members before element conversion and cover both readers. This 
concerns element decoding, distinct from the existing ARRAY container-nullness 
and inferred scalar-array threads.



-- 
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