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


##########
fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMappingUtils.java:
##########
@@ -153,9 +162,101 @@ public static EsFieldContext 
resolveFieldContext(List<String> columnNames,
             resolveKeywordFields(fieldContext, fieldObject, colName, 
fieldType);
             resolveDocValuesFields(fieldContext, fieldObject, colName, 
fieldType);
         }
+        resolveSourceValueSafety(fieldContext, indexMapping, mappingType);
         return fieldContext;
     }
 
+    private static void resolveSourceValueSafety(EsFieldContext context, 
String indexMapping, String mappingType) {
+        try {
+            // An alias can span mappings. Every backing index must preserve 
source semantics before
+            // a predicate may remove rows or doc_values may replace the value 
returned to Doris.
+            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);
+            }
+            // Only the first mapping's schema is exposed by Doris, including 
unprojected filter columns.
+            
roots.get(0).path("properties").fieldNames().forEachRemaining(columns::add);
+            Set<String> inexactKeywords = new HashSet<>();
+            for (ObjectNode root : roots) {
+                JsonNode properties = root.path("properties");
+                JsonNode source = root.path("_source");
+                boolean filteredSource = 
!source.path("enabled").asBoolean(true)
+                        || source.has("includes") || source.has("excludes");

Review Comment:
   [P2] Restrict source-filter taint to fields the filter affects
   
   The presence of any source include/exclude makes every column inexact. For 
`_source: {"excludes":["a"]}` with ordinary keyword fields `a` and `b`, the 
source and indexed values of `b` are still identical. This code nevertheless 
marks `b` unsafe and removes its bounded-keyword exemption, so a previously 
supported query such as `b = 'ok' OR esquery(b, '{"term":{"b":"other"}}')` now 
fails FE planning with `Cannot evaluate esquery locally`. Both branches can 
still be evaluated exactly by ES; only the unrelated field `a` was excluded. 
This differs from the earlier mixed-native thread, which involved a genuinely 
affected field. Determine source filtering per field, including include/exclude 
patterns, and cover native Boolean queries on a field preserved by the source 
filter.



##########
fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsQueryDslBuilder.java:
##########
@@ -143,18 +168,85 @@ public static EsQueryDslResult 
buildQueryDslWithResult(ConnectorExpression filte
             return new EsQueryDslResult(queryDsl, notPushedIndices);
         }
 
+        // Dropping a source-value predicate after translating a 
transformed/missing index value
+        // cannot be repaired by a residual: matching source documents may 
already be absent.
+        if (referencesUnsafeSourceField(filter, sourceValueUnsafeFields, 
keywordIgnoreAboveLimits)) {
+            requireLocalEvaluation(filter);
+            return new EsQueryDslResult(matchAllQuery(), 
Collections.singletonList(0));
+        }
         // Single expression (not AND)
         List<ConnectorExpression> notPushDownList = new ArrayList<>();
         String dsl = toEsDsl(filter, notPushDownList, fieldsContext,
                 likePushDown, needCompatDateFields, column2typeMap);
         // A nested AND can return a partial DSL while recording an 
unsupported child. OR/NOT must
         // remain local in that case: e.g. NOT(a = 1 AND local_predicate) 
cannot become NOT(a = 1).
         if (dsl == null || !notPushDownList.isEmpty()) {
+            requireLocalEvaluation(filter);
             return new EsQueryDslResult(matchAllQuery(), 
Collections.singletonList(0));
         }
         return new EsQueryDslResult(dsl, Collections.emptyList());
     }
 
+    private static boolean referencesUnsafeSourceField(ConnectorExpression 
expression, Set<String> unsafeFields,
+            Map<String, Integer> keywordIgnoreAboveLimits) {
+        if (isExactKeywordPredicate(expression, keywordIgnoreAboveLimits)) {
+            return false;
+        }
+        if (expression instanceof ConnectorFunctionCall
+                && "esquery".equals(((ConnectorFunctionCall) 
expression).getFunctionName())) {
+            // esquery explicitly requests ES index semantics rather than SQL 
source-value semantics.
+            return false;
+        }
+        if (expression instanceof ConnectorColumnRef) {
+            return unsafeFields.contains(((ConnectorColumnRef) 
expression).getColumnName());
+        }
+        for (ConnectorExpression child : expression.getChildren()) {
+            if (referencesUnsafeSourceField(child, unsafeFields, 
keywordIgnoreAboveLimits)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean isExactKeywordPredicate(ConnectorExpression 
expression, Map<String, Integer> limits) {
+        if (expression instanceof ConnectorComparison) {
+            ConnectorComparison comparison = (ConnectorComparison) expression;
+            if (comparison.getOperator() != ConnectorComparison.Operator.EQ
+                    && comparison.getOperator() != 
ConnectorComparison.Operator.EQ_FOR_NULL) {
+                return false;
+            }
+            return isBoundedKeywordLiteral(comparison.getLeft(), 
comparison.getRight(), limits)
+                    || isBoundedKeywordLiteral(comparison.getRight(), 
comparison.getLeft(), limits);
+        }
+        if (expression instanceof ConnectorIn) {
+            ConnectorIn in = (ConnectorIn) expression;
+            return !in.isNegated() && in.getInList().stream()
+                    .allMatch(value -> isBoundedKeywordLiteral(in.getValue(), 
value, limits));
+        }
+        return false;
+    }
+
+    private static boolean isBoundedKeywordLiteral(ConnectorExpression column, 
ConnectorExpression value,
+            Map<String, Integer> limits) {
+        if (!(column instanceof ConnectorColumnRef) || !(value instanceof 
ConnectorLiteral)) {
+            return false;
+        }
+        Integer limit = limits.get(((ConnectorColumnRef) 
column).getColumnName());
+        Object literal = ((ConnectorLiteral) value).getValue();
+        return limit != null && literal instanceof String && ((String) 
literal).length() <= limit;

Review Comment:
   [P2] Preserve source-array semantics in bounded keyword predicates
   
   A length bound does not establish scalar equality. With `a` mapped as 
`{"type":"keyword","ignore_above":4,"doc_values":true}` and source 
`a=["cat","elephant"]` (without `array_fields` metadata), Doris infers STRING. 
The new safety pass removes its docvalue entry, so both readers now return the 
JSON string `["cat","elephant"]`; nevertheless this check exempts `a = 'cat'` 
and positive IN from the residual. ES matches the `cat` array member and the 
engine prunes the predicate, so `SELECT a FROM idx WHERE a = 'cat'` returns a 
value that fails that equality. Previously the explicit docvalue path returned 
the scalar `cat`, since the longer member was ignored. ES applies [ignore_above 
per array 
element](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/ignore-above.html),
 and `ScrollParser::fill_columns` explicitly serializes source arrays as JSON. 
Keep these source predicates local unless scalar equivalence is established, 
and add array-valued STRING cases across both readers
 /docvalue modes.



##########
fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMappingUtils.java:
##########
@@ -153,9 +162,101 @@ public static EsFieldContext 
resolveFieldContext(List<String> columnNames,
             resolveKeywordFields(fieldContext, fieldObject, colName, 
fieldType);
             resolveDocValuesFields(fieldContext, fieldObject, colName, 
fieldType);
         }
+        resolveSourceValueSafety(fieldContext, indexMapping, mappingType);
         return fieldContext;
     }
 
+    private static void resolveSourceValueSafety(EsFieldContext context, 
String indexMapping, String mappingType) {
+        try {
+            // An alias can span mappings. Every backing index must preserve 
source semantics before
+            // a predicate may remove rows or doc_values may replace the value 
returned to Doris.
+            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);
+            }
+            // Only the first mapping's schema is exposed by Doris, including 
unprojected filter columns.
+            
roots.get(0).path("properties").fieldNames().forEachRemaining(columns::add);
+            Set<String> inexactKeywords = new HashSet<>();
+            for (ObjectNode root : roots) {
+                JsonNode properties = root.path("properties");
+                JsonNode source = root.path("_source");
+                boolean filteredSource = 
!source.path("enabled").asBoolean(true)
+                        || source.has("includes") || source.has("excludes");
+                Set<String> copyTargets = new HashSet<>();
+                collectCopyTargets(properties, copyTargets);
+                for (String column : columns) {
+                    JsonNode field = properties.path(column);
+                    String indexedPath = 
context.getFetchFieldsContext().get(column);
+                    JsonNode indexedField = field;
+                    if (indexedPath != null) {
+                        indexedField = 
field.path("fields").path(indexedPath.substring(column.length() + 1));
+                    }
+                    boolean inexact = filteredSource || 
root.path("runtime").has(column)
+                            || copyTargets.contains(column) || 
changesSourceValueExceptLength(field)
+                            || changesSourceValueExceptLength(indexedField);
+                    for (JsonNode subfield : field.path("fields")) {
+                        if ("keyword".equals(subfield.path("type").asText())
+                                && changesSourceValueExceptLength(subfield)) {
+                            inexact = true;
+                        }
+                    }
+                    if (inexact || field.has("ignore_above") || 
indexedField.has("ignore_above")
+                            || hasUnsafeKeywordSubfield(field)) {
+                        context.getSourceValueUnsafeFields().add(column);
+                    }
+                    if (!inexact && 
"keyword".equals(indexedField.path("type").asText())) {
+                        // Equality/IN with bounded literals stay exact: 
ignored longer values cannot
+                        // equal those literals. Intersect the bound over 
every backing index.
+                        context.getKeywordIgnoreAboveLimits().merge(column,
+                                
indexedField.path("ignore_above").asInt(Integer.MAX_VALUE), Math::min);
+                    } else {
+                        inexactKeywords.add(column);
+                    }
+                }
+            }
+            
inexactKeywords.forEach(context.getKeywordIgnoreAboveLimits()::remove);
+        } catch (IOException e) {
+            throw new DorisConnectorException("Failed to parse ES mapping: " + 
e.getMessage());
+        }
+        // Local residuals must read the original value, including with 
explicit doc_values=true.
+        
context.getSourceValueUnsafeFields().forEach(context.getDocValueFieldsContext()::remove);
+    }
+
+    private static boolean hasUnsafeKeywordSubfield(JsonNode field) {
+        for (JsonNode subfield : field.path("fields")) {
+            if ("keyword".equals(subfield.path("type").asText()) && 
changesSourceValue(subfield)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean changesSourceValue(JsonNode field) {
+        return field.has("ignore_above") || 
changesSourceValueExceptLength(field);
+    }
+
+    private static boolean changesSourceValueExceptLength(JsonNode field) {
+        return field.isMissingNode() || field.has("normalizer")
+                || field.has("null_value") || 
field.path("ignore_malformed").asBoolean(false)
+                || !field.path("index").asBoolean(true);

Review Comment:
   [P2] Preserve queries on doc-value-only keyword fields
   
   `index=false` does not necessarily make a field unsearchable or change its 
source value. ES8 supports [queries on doc-value-only keyword 
fields](https://www.elastic.co/guide/en/elasticsearch/reference/8.19/doc-values.html).
 For `a: {"type":"keyword","index":false,"doc_values":true}` with scalar source 
`a="cat"`, no normalizer or source filtering, equality remains exact and worked 
before this change. This condition nevertheless marks `a` inexact/unsafe and 
removes its keyword exemption, so `a = 'cat' OR esquery(b, 
'{"term":{"b":"other"}}')` now fails FE planning with `Cannot evaluate esquery 
locally` despite both branches being supported remotely. Distinguish genuinely 
unsearchable fields from doc-value-only searchable fields when deriving 
exactness, and cover this supported native-OR configuration. The existing 
index-disabled test only checks local fallback and misses the native Boolean 
regression.



##########
fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMappingUtils.java:
##########
@@ -153,9 +162,101 @@ public static EsFieldContext 
resolveFieldContext(List<String> columnNames,
             resolveKeywordFields(fieldContext, fieldObject, colName, 
fieldType);
             resolveDocValuesFields(fieldContext, fieldObject, colName, 
fieldType);
         }
+        resolveSourceValueSafety(fieldContext, indexMapping, mappingType);
         return fieldContext;
     }
 
+    private static void resolveSourceValueSafety(EsFieldContext context, 
String indexMapping, String mappingType) {
+        try {
+            // An alias can span mappings. Every backing index must preserve 
source semantics before
+            // a predicate may remove rows or doc_values may replace the value 
returned to Doris.
+            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);
+            }
+            // Only the first mapping's schema is exposed by Doris, including 
unprojected filter columns.
+            
roots.get(0).path("properties").fieldNames().forEachRemaining(columns::add);
+            Set<String> inexactKeywords = new HashSet<>();
+            for (ObjectNode root : roots) {
+                JsonNode properties = root.path("properties");
+                JsonNode source = root.path("_source");
+                boolean filteredSource = 
!source.path("enabled").asBoolean(true)
+                        || source.has("includes") || source.has("excludes");
+                Set<String> copyTargets = new HashSet<>();
+                collectCopyTargets(properties, copyTargets);
+                for (String column : columns) {
+                    JsonNode field = properties.path(column);
+                    String indexedPath = 
context.getFetchFieldsContext().get(column);
+                    JsonNode indexedField = field;
+                    if (indexedPath != null) {
+                        indexedField = 
field.path("fields").path(indexedPath.substring(column.length() + 1));
+                    }
+                    boolean inexact = filteredSource || 
root.path("runtime").has(column)
+                            || copyTargets.contains(column) || 
changesSourceValueExceptLength(field)
+                            || changesSourceValueExceptLength(indexedField);
+                    for (JsonNode subfield : field.path("fields")) {
+                        if ("keyword".equals(subfield.path("type").asText())
+                                && changesSourceValueExceptLength(subfield)) {
+                            inexact = true;

Review Comment:
   [P2] Check the selected keyword path before rejecting native queries
   
   This loop treats a transformation on an unused keyword subfield as a 
transformation of the path Doris actually queries. With keyword sniff enabled, 
let text field `a` have `aa_folded: 
{"type":"keyword","normalizer":"lowercase_value"}` followed by `zz_raw: 
{"type":"keyword"}` (with the normalizer defined in the index settings). Both 
`resolveKeywordFields` and `resolveDocValuesFields` select `a.zz_raw`, which 
preserves scalar source values. Nevertheless the unused `aa_folded` sets 
`inexact=true`, removes `a`'s exact-keyword bound and makes `a = 'cat' OR 
esquery(b, '{"term":{"b":"other"}}')` fail FE planning. Before this change both 
branches were pushed through the unchanged selected paths. Determine predicate 
safety from the selected keyword path and read safety from the selected 
docvalue path; an unrelated alternative subfield should not invalidate them. 
Add a native-OR test with a safe selected subfield and an unused normalized 
sibling.



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