github-actions[bot] commented on code in PR #67918:
URL: https://github.com/apache/doris/pull/67918#discussion_r4070880347
##########
fe/fe-core/src/main/java/org/apache/doris/analysis/InvertedIndexUtil.java:
##########
@@ -337,23 +339,48 @@ private static void
checkInvertedIndexProperties(Map<String, String> properties,
// dict_compression now silently ignores by V2/V3 inverted index
}
- // Normalize analyzer and normalizer names to lowercase for
case-insensitive matching
+ // Canonicalize built-ins while retaining the exact spelling of a
resolved legacy policy.
normalizeInvertedIndexProperties(properties);
}
/**
- * Normalize analyzer and normalizer names in index properties to
lowercase.
- * This ensures case-insensitive matching between table creation and query
time.
+ * Canonicalize analyzer and normalizer names in index properties. Legacy
metadata may contain
+ * case-distinct policy names, so a resolved custom policy must keep its
exact stored name.
*/
private static void normalizeInvertedIndexProperties(Map<String, String>
properties) {
+ resolvePolicyNames(properties);
AnalyzerKeyNormalizer.normalizeInvertedIndexProperties(
properties,
- INVERTED_INDEX_ANALYZER_NAME_KEY,
- INVERTED_INDEX_NORMALIZER_NAME_KEY,
INVERTED_INDEX_PARSER_KEY,
INVERTED_INDEX_PARSER_KEY_ALIAS);
}
+ /** Store analyzer and normalizer names in the spelling BE dispatches on.
*/
+ public static void resolvePolicyNames(Map<String, String> properties) {
+ normalizeResolvedPolicyName(properties,
INVERTED_INDEX_ANALYZER_NAME_KEY);
+ normalizeResolvedPolicyName(properties,
INVERTED_INDEX_NORMALIZER_NAME_KEY);
+ }
+
+ private static void normalizeResolvedPolicyName(Map<String, String>
properties, String key) {
+ String name = properties.get(key);
+ if (name == null || name.isEmpty()) {
+ return;
+ }
+ properties.put(key, resolveAnalyzerName(name));
+ }
+
+ /** Resolve built-in names and retain the stored spelling of custom
policies. */
+ public static String resolveAnalyzerName(String name) {
+ String trimmedName = name.trim();
+ // Match the BE writer's case-sensitive built-in dispatch before
policy lookup.
+ if (IndexPolicy.BUILTIN_ANALYZERS.contains(trimmedName)
+ || IndexPolicy.BUILTIN_NORMALIZERS.contains(trimmedName)) {
+ return trimmedName;
Review Comment:
[P1] Keep mixed-case builtin resolution consistent with validation and BE
dispatch. With only a replayed `LOWERCASE` policy, `normalizer=LowerCase`
passes `validateNormalizerExists()` as the builtin (no exact `LowerCase`, then
normalized builtin), but this lookup falls through to the normalized legacy
policy and rewrites the stored value to exact `LOWERCASE`; BE then binds that
policy instead. Analyzer builtins have the same split with a normalized
wrong-family legacy policy. Please share an exact-policy -> builtin ->
normalized-policy resolver between validation and this property/MATCH path, and
cover mixed-case builtin spellings with replayed collisions.
##########
fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java:
##########
@@ -72,7 +177,7 @@ private static String resolveAnalyzerIdentity(String
analyzerName, String defaul
// Check if it's a built-in normalizer
if (IndexPolicy.BUILTIN_NORMALIZERS.contains(analyzerName)) {
Review Comment:
[P1] Resolve an exact legacy normalizer before assigning builtin identity.
BE's top-level order lets an exact replayed policy named `lowercase` shadow the
builtin, but this branch (and `customAnalyzerFoldContext`) always models
keyword-plus-lowercase first. A legacy `lowercase` normalizer backed by
`asciifolding`, for example, can therefore be rejected as a duplicate of a
distinct pipeline or admit an equivalent alias. Consult the exact binding
before the builtin special case and cover CREATE/ALTER identity with an exact
replayed `lowercase` policy.
##########
fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java:
##########
@@ -226,26 +858,381 @@ private static String resolveTokenFilterIdentity(String
filterList) {
* IMPORTANT: Order is preserved because filter order is semantically
significant.
*/
private static String resolveCharFilterIdentity(String filterList) {
+ return resolveCharFilterIdentity(filterList, null);
+ }
+
+ private static String resolveCharFilterIdentity(String filterList,
FoldContext downstreamFold) {
+ ArrayDeque<String> identities = new ArrayDeque<>();
+ walkCharFilters(filterList, downstreamFold, identities);
+ return String.join(",", identities);
+ }
+
+ /**
+ * Resolve the chain from its last filter to its first, collecting
identities, and return the
+ * case-folding context that a filter placed in front of the chain would
run in.
+ */
+ private static FoldContext walkCharFilters(
+ String filterList, FoldContext downstreamFold, Deque<String>
identities) {
+ FoldContext fold = downstreamFold;
if (Strings.isNullOrEmpty(filterList)) {
- return "";
+ return fold;
}
- StringBuilder sb = new StringBuilder();
String[] filters = filterList.split(",\\s*");
// DO NOT sort - filter order is semantically significant
- for (int i = 0; i < filters.length; i++) {
- String filter = filters[i].trim();
- if (i > 0) {
- sb.append(",");
+ for (int i = filters.length - 1; i >= 0; --i) {
+ String filterName = filters[i].trim();
+ String filter = resolveComponentIdentity(filterName,
IndexPolicyTypeEnum.CHAR_FILTER, fold);
+ if (Strings.isNullOrEmpty(filter)) {
+ continue;
}
+ identities.addFirst(filter);
+ fold = foldContextBefore(filterName, fold);
+ }
+ return fold;
+ }
- if (IndexPolicy.BUILTIN_CHAR_FILTERS.contains(filter)) {
- sb.append(filter);
- } else {
- sb.append(resolveComponentIdentity(filter,
IndexPolicyTypeEnum.CHAR_FILTER));
+ /**
+ * Context for the filter that runs before this one: a case fold starts a
fresh context, a
+ * char_replace filter adds the bytes it rewrites, and any other filter
ends the context.
+ */
+ private static FoldContext foldContextBefore(String filterName,
FoldContext fold) {
+ FoldContext caseFold = caseFoldingCharFilterContext(filterName);
+ if (caseFold != null) {
+ return caseFold;
+ }
+ if (fold == null) {
+ return null;
+ }
+ boolean[] sourceBytes = charReplaceSourceBytes(filterName);
+ if (sourceBytes == null) {
+ return null;
+ }
+ fold.block(sourceBytes);
+ return fold;
+ }
+
+ /** Bytes a named char_replace filter rewrites, or null for any other
filter. */
+ private static boolean[] charReplaceSourceBytes(String filterName) {
+ IndexPolicy policy = findPolicy(filterName,
IndexPolicyTypeEnum.CHAR_FILTER);
+ if (policy == null || policy.isInvalid() || policy.getProperties() ==
null) {
+ return null;
+ }
+ Map<String, String> properties = policy.getProperties();
+ String type = normalizeBuiltinComponentName(
+ properties.get(IndexPolicy.PROP_TYPE),
IndexPolicyTypeEnum.CHAR_FILTER);
+ String pattern = properties.get("pattern");
+ if (!"char_replace".equals(type) || pattern == null) {
+ return null;
+ }
+ // Replacing the single replacement byte with itself leaves the stream
unchanged.
+ String replacement = properties.getOrDefault("replacement", " ");
+ int replacementByte = replacement.length() == 1 &&
replacement.charAt(0) < 128 ? replacement.charAt(0) : -1;
+ boolean[] sourceBytes = new boolean[256];
+ for (int i = 0; i < pattern.length(); ++i) {
+ char patternByte = pattern.charAt(i);
+ if (patternByte < sourceBytes.length && patternByte !=
replacementByte) {
+ sourceBytes[patternByte] = true;
}
}
- return sb.toString();
+ return sourceBytes;
+ }
+
+ /** The named policy when one exists with the expected type, or null. */
+ private static IndexPolicy findPolicy(String name, IndexPolicyTypeEnum
expectedType) {
+ if (Strings.isNullOrEmpty(name)) {
+ return null;
+ }
+ try {
+ Env env = Env.getCurrentEnv();
+ if (env != null && env.getIndexPolicyMgr() != null) {
+ IndexPolicy policy =
env.getIndexPolicyMgr().getPolicyByName(name);
+ if (policy != null && policy.getType() == expectedType) {
+ return policy;
+ }
+ }
+ } catch (RuntimeException e) {
+ // Treat lookup failures as an unknown policy.
+ }
+ return null;
+ }
+
+ /** Fold context started by a named or built-in case-folding char filter,
or null for any other filter. */
+ private static FoldContext caseFoldingCharFilterContext(String name) {
+ if (Strings.isNullOrEmpty(name)) {
+ return null;
+ }
+
+ try {
+ Env env = Env.getCurrentEnv();
+ if (env != null && env.getIndexPolicyMgr() != null) {
+ IndexPolicy policy =
env.getIndexPolicyMgr().getPolicyByName(name);
+ if (policy != null && policy.getType() ==
IndexPolicyTypeEnum.CHAR_FILTER) {
+ if (policy.isInvalid()) {
+ return null;
+ }
+ Map<String, String> properties = policy.getProperties();
+ if (properties != null && !properties.isEmpty()) {
+ String type = normalizeBuiltinComponentName(
+ properties.get(IndexPolicy.PROP_TYPE),
IndexPolicyTypeEnum.CHAR_FILTER);
+ return "icu_normalizer".equals(type) ?
icuNormalizerFoldContext(properties) : null;
+ }
+ }
+ }
+ } catch (RuntimeException e) {
+ // Fall through to built-in resolution.
+ }
+
+ return "icu_normalizer".equals(normalizeBuiltinComponentName(name,
IndexPolicyTypeEnum.CHAR_FILTER))
+ ? FoldContext.unfiltered() : null;
+ }
+
+ /**
+ * Fold context of an icu_normalizer component: the default nfkc_cf form
folds case over every
+ * code point, or only inside a parsable non-empty unicode_set_filter.
Null for other forms.
+ */
+ private static FoldContext icuNormalizerFoldContext(Map<String, String>
properties) {
+ if (!"nfkc_cf".equals(icuNormalizerName(properties))) {
+ return null;
+ }
+ String filter = properties.get("unicode_set_filter");
+ if (filter == null || filter.isEmpty()) {
+ return FoldContext.unfiltered();
+ }
+ try {
+ UnicodeSet unicodeSet = new UnicodeSet(filter);
+ return unicodeSet.isEmpty() ? FoldContext.unfiltered() : new
FoldContext(unicodeSet.freeze());
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ /** Whether an icu_normalizer component leaves ASCII letters as they are.
*/
+ private static boolean isAsciiCaseTransparentIcuNormalizer(Map<String,
String> properties) {
+ String name = icuNormalizerName(properties);
+ return "nfc".equals(name) || "nfd".equals(name) || "nfkc".equals(name)
|| "nfkd".equals(name);
+ }
+
+ private static String icuNormalizerName(Map<String, String> properties) {
+ return properties.getOrDefault("name",
"nfkc_cf").trim().toLowerCase(Locale.ROOT);
+ }
+
+ /** The outer char filter runs before everything else, so it takes the
analyzer's fold context. */
+ private static String appendOuterCharFilterIdentity(
+ String analyzerIdentity, Map<String, String> properties,
FoldContext fold) {
+ String type =
properties.get(InvertedIndexProperties.INVERTED_INDEX_PARSER_CHAR_FILTER_TYPE);
+ String pattern =
properties.get(InvertedIndexProperties.INVERTED_INDEX_PARSER_CHAR_FILTER_PATTERN);
+ if (!"char_replace".equals(type) || Strings.isNullOrEmpty(pattern)) {
+ return analyzerIdentity;
+ }
+ String replacement = properties.getOrDefault(
+
InvertedIndexProperties.INVERTED_INDEX_PARSER_CHAR_FILTER_REPLACEMENT, " ");
+ String canonicalPattern = canonicalizeCharReplacePattern(pattern,
replacement, fold);
+ if (canonicalPattern.isEmpty()) {
+ return analyzerIdentity;
+ }
+ return analyzerIdentity + "|outer_char_filter=char_replace:"
+ + canonicalPattern.length() + ":" + canonicalPattern + ":"
+ + replacement.length() + ":" + replacement + ";";
+ }
+
+ /**
+ * Canonicalize the ASCII pattern to the BE filter's byte set.
+ * Order, duplicate bytes, and replacements of a byte with itself do not
change the stream.
+ */
+ private static String canonicalizeCharReplacePattern(
+ String pattern, String replacement, FoldContext fold) {
+ if (replacement.length() != 1) {
+ return pattern;
+ }
+ char replacementByte = replacement.charAt(0);
+ boolean[] replacedBytes = new boolean[256];
+ for (int i = 0; i < pattern.length(); ++i) {
+ char patternByte = pattern.charAt(i);
+ if (patternByte < replacedBytes.length && patternByte !=
replacementByte) {
+ replacedBytes[patternByte] = true;
+ }
+ }
+ if (fold != null && replacementByte >= 'a' && replacementByte <= 'z') {
+ // The downstream fold maps the upper-case byte to the replacement
anyway.
+ int upperByte = replacementByte - ('a' - 'A');
+ if (fold.foldsByte(upperByte, replacementByte)) {
+ replacedBytes[upperByte] = false;
+ }
+ }
+
+ StringBuilder canonical = new StringBuilder();
+ for (int i = 0; i < replacedBytes.length; ++i) {
+ if (replacedBytes[i]) {
+ canonical.append((char) i);
+ }
+ }
+ return canonical.toString();
+ }
+
+ private static FoldContext builtinIkFoldContext(String analyzerIdentity) {
+ return isDefaultLowercaseBuiltinIkIdentity(analyzerIdentity) ?
FoldContext.unfiltered() : null;
+ }
+
+ private static boolean isDefaultLowercaseBuiltinIkIdentity(String
analyzerIdentity) {
+ return (IndexPolicyTypeEnum.ANALYZER.name() +
":tokenizer=ik_smart;").equals(analyzerIdentity)
+ || (IndexPolicyTypeEnum.ANALYZER.name() +
":tokenizer=ik_max_word;").equals(analyzerIdentity);
+ }
+
+ /**
+ * Fold context for the outer char filter of a custom analyzer or
normalizer, which BE applies
+ * before the policy's own char filters. Unknown or unresolvable policies
get no context.
+ */
+ private static FoldContext customAnalyzerFoldContext(String analyzerName) {
+ if (IndexPolicy.BUILTIN_ANALYZERS.contains(analyzerName)) {
+ return null;
+ }
+ if (IndexPolicy.BUILTIN_NORMALIZERS.contains(analyzerName)) {
+ // The built-in normalizer lowercases keyword tokens without char
filters of its own.
+ return FoldContext.unfiltered();
+ }
+ IndexPolicy policy = findPolicy(analyzerName,
IndexPolicyTypeEnum.ANALYZER);
+ if (policy == null) {
+ policy = findPolicy(analyzerName, IndexPolicyTypeEnum.NORMALIZER);
+ }
+ if (policy == null || policy.isInvalid() || policy.getProperties() ==
null
+ || policy.getProperties().isEmpty()) {
+ return null;
+ }
+ Map<String, String> properties = policy.getProperties();
+ try {
+ String tokenizerIdentity = resolveComponentIdentity(
+ properties.get(IndexPolicy.PROP_TOKENIZER),
IndexPolicyTypeEnum.TOKENIZER);
+ return
walkCharFilters(properties.get(IndexPolicy.PROP_CHAR_FILTER),
+ foldsAsciiCaseAfterCharFilters(policy.getType(),
properties, tokenizerIdentity),
+ new ArrayDeque<>());
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ /**
+ * The fold the tokenizer and token filters apply to ASCII letters, so a
char filter that only
+ * lowercases such a letter cannot change the output, or null when they
keep case.
+ */
+ private static FoldContext foldsAsciiCaseAfterCharFilters(
+ IndexPolicyTypeEnum type, Map<String, String> properties, String
tokenizerIdentity) {
+ if (type == IndexPolicyTypeEnum.NORMALIZER) {
+ // A normalizer always tokenizes with keyword, which is case
transparent.
+ return
tokenFiltersFoldAsciiCase(properties.get(IndexPolicy.PROP_TOKEN_FILTER));
+ }
+ if ("ik_smart".equals(tokenizerIdentity) ||
"ik_max_word".equals(tokenizerIdentity)) {
+ return FoldContext.unfiltered();
+ }
+ return
isCaseTransparentTokenizer(properties.get(IndexPolicy.PROP_TOKENIZER))
+ ?
tokenFiltersFoldAsciiCase(properties.get(IndexPolicy.PROP_TOKEN_FILTER)) : null;
+ }
+
+ /** Whether the tokenizer splits and emits ASCII letters the same way
regardless of their case. */
+ private static boolean isCaseTransparentTokenizer(String name) {
+ TreeMap<String, String> settings = resolveComponentSettings(name,
IndexPolicyTypeEnum.TOKENIZER);
+ if (settings == null) {
+ return false;
+ }
+ switch (settings.get(IndexPolicy.PROP_TYPE)) {
Review Comment:
[P1] Compute case transparency from the same canonical tokenizer settings
used in the base identity. The base path drops NGram `custom_token_chars=A`
when `token_chars=letter` already covers it (and likewise drops a redundant
CharGroup literal `A`), but this later check reloads raw properties and treats
the redundant alias as case-sensitive. With a downstream lowercase filter,
identical aliases then differ only because `A->a` is retained for one identity,
so CREATE/ALTER can admit duplicate indexes. Canonicalize these settings before
the transparency check and cover both NGram and CharGroup aliases.
##########
be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp:
##########
@@ -74,6 +75,10 @@ Token* NGramTokenizer::next(Token* token) {
to_chars(_buffer, _buffer_start, _gram_size);
set(token, _utf8_buffer);
+ set_source_byte_offsets(_utf8_buffer, _offset);
+ token->setStartOffset(correct_source_offset(_offset));
Review Comment:
[P1] Preserve the full source span when an n-gram cuts through a char-filter
expansion. For `nfkc_cf`, source U+FB01 becomes `fi`; with 1-grams the second
gram uses normalized slice `[1,2)`, whose corrected endpoints are both byte 3.
Because `set_source_byte_offsets()` only sees that local slice, it publishes
`[0,0]`, and these new assignments expose token/provenance range `[3,3)` for
`i`; an offset-aware Pinyin filter cannot recover the missing start. Build
correction intervals for the full normalized input and slice them (or add a
range-aware correction API), and cover the ligature -> 1-gram chain through
reset.
##########
be/src/storage/index/inverted/tokenizer/icu/icu_tokenizer.cpp:
##########
@@ -54,26 +58,78 @@ Token* ICUTokenizer::next(Token* token) {
utf8Str_.clear();
int32_t length = std::min(end - start, LUCENE_MAX_WORD_LEN);
+ if (length < end - start && length > 0 && U16_IS_LEAD(buffer_.charAt(start
+ length - 1)) &&
+ U16_IS_TRAIL(buffer_.charAt(start + length))) {
+ --length;
+ }
auto subString = buffer_.tempSubString(start, length);
+ sourceUtf8Str_.clear();
Review Comment:
[P2] Skip this second UTF-16-to-UTF-8 conversion when source tracking is
disabled. `sourceUtf8Str_` is used only by `set_source_byte_offsets()`, whose
base implementation immediately returns in the ordinary non-opted-in ICU path;
with lowercase disabled it is also byte-for-byte identical to `utf8Str_`.
Normal indexing and query analysis now transcode every token twice and retain a
second scratch buffer for no observable result. Gate the source conversion on
the provenance opt-in (and reuse the output when possible), with
disabled/enabled coverage.
##########
fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicyMgr.java:
##########
@@ -93,14 +149,13 @@ public List<IndexPolicy> getCopiedIndexPolicies() {
public void validateAnalyzerExists(String analyzerName) throws
DdlException {
String normalizedName = normalizeKey(analyzerName);
- // Built-in analyzers are stored in lowercase, so use normalized name
for comparison
- if (IndexPolicy.BUILTIN_ANALYZERS.contains(normalizedName)) {
- return;
- }
-
readLock();
Review Comment:
[P2] Let a canonical builtin analyzer win validation before an exact
replayed policy. With an exact legacy TOKENIZER named `ik`, `analyzer=ik` now
binds that policy here and fails as the wrong family (an invalid analyzer
policy also fails), even though `resolveAnalyzerName("ik")` and BE's analyzer
factory both intercept canonical `ik` as the builtin before policy lookup. The
pre-change validator accepted this case. Check canonical analyzer builtins
first, while retaining exact-first behavior for noncanonical names and
normalizers, and cover CREATE/ALTER replay collisions.
##########
be/src/storage/index/inverted/tokenizer/pinyin/pinyin_tokenizer.cpp:
##########
@@ -48,6 +48,9 @@
PinyinTokenizer::PinyinTokenizer(std::shared_ptr<doris::segment_v2::PinyinConfig
void PinyinTokenizer::reset() {
DorisTokenizer::reset();
+ has_current_span_ = false;
+ ascii_buff_rune_starts_.clear();
Review Comment:
[P1] Avoid allocating and retaining these new provenance vectors when
offsets are ignored. With default Pinyin settings
(`ignore_pinyin_offset=true`), every byte of a large ASCII-alphanumeric value
still appends an `int32_t` to both vectors, even though `next()` discards the
candidate ranges and uses the whole-input span. A 100 MiB value thus adds
roughly 800 MiB of unnecessary peak and retained capacity to the cached
analyzer; reset only `clear()`s it. Gate collection on the effective offset
need, release oversized enabled-mode scratch on reset, and cover large ->
empty/small reuse.
##########
fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicyMgr.java:
##########
@@ -508,11 +602,10 @@ public void dropIndexPolicy(boolean isIfExists, String
indexPolicyName,
* tables, and indexes. In large-scale clusters with many tables, this can
be slow.
* Consider maintaining a reverse index (analyzer -> tables) if this
becomes a bottleneck.
*
- * @param analyzerName the analyzer name to check
+ * @param analyzer the analyzer policy to check
Review Comment:
[P2] Apply top-level builtin precedence when checking DROP dependencies. A
replayed policy `IK` plus an index stored with canonical `analyzer=ik` is
treated here as a reference to `IK` through normalized fallback, so `DROP
ANALYZER IK` is rejected even though runtime dispatch uses builtin `ik`.
Replayed `LOWERCASE` versus builtin normalizer `lowercase` has the same false
dependency. Use the runtime-equivalent top-level resolver for index references
(while keeping nested component lookup semantics) and add both collision cases.
##########
be/src/runtime/index_policy/index_policy_mgr.cpp:
##########
@@ -142,50 +205,61 @@ AnalyzerPtr IndexPolicyMgr::get_policy_by_name(const
std::string& name) {
AnalyzerPtr IndexPolicyMgr::get_analyzer_by_name(const std::string& name) {
std::shared_lock lock(_mutex);
const std::string normalized_name = normalize_name(name);
- auto name_it = _name_to_id.find(normalized_name);
- if (name_it == _name_to_id.end()) {
- if (is_builtin_normalizer(normalized_name)) {
- return build_builtin_normalizer(name);
+ bool builtin_normalizer = false;
+ const auto* index_policy = find_top_level_policy_locked(name,
&builtin_normalizer);
+ if (index_policy == nullptr) {
+ if (builtin_normalizer) {
+ return build_builtin_normalizer(normalized_name);
}
throw Exception(ErrorCode::INVALID_ARGUMENT, "Policy not found with
name: " + name);
}
- auto policy_it = _policys.find(name_it->second);
- if (policy_it == _policys.end()) {
- throw Exception(ErrorCode::INVALID_ARGUMENT, "Policy not found with
id: " + name);
- }
- if (policy_it->second.type == TIndexPolicyType::ANALYZER) {
- return build_analyzer_provider_from_config(
- build_analyzer_config_from_policy(policy_it->second),
{})
+ if (index_policy->type == TIndexPolicyType::ANALYZER) {
+ return
build_analyzer_provider_from_config(build_analyzer_config_from_policy(*index_policy),
+ {})
->get_analyzer();
}
- if (policy_it->second.type == TIndexPolicyType::NORMALIZER) {
- return build_normalizer_from_policy(policy_it->second);
+ if (index_policy->type == TIndexPolicyType::NORMALIZER) {
+ return build_normalizer_from_policy(*index_policy);
}
throw Exception(ErrorCode::INVALID_ARGUMENT, "Analyzer policy not found: "
+ name);
}
AnalyzerProviderPtr IndexPolicyMgr::get_analyzer_provider_by_name(
- const std::string& name, const std::map<std::string, std::string>&
outer_char_filter_map) {
+ const std::string& name, const std::map<std::string, std::string>&
outer_char_filter_map,
+ std::string* resolved_name, std::string* legacy_name) {
std::shared_lock lock(_mutex);
+ if (resolved_name != nullptr) {
+ *resolved_name = name;
+ }
+ if (legacy_name != nullptr) {
+ legacy_name->clear();
+ }
const std::string normalized_name = normalize_name(name);
- auto name_it = _name_to_id.find(normalized_name);
- if (name_it == _name_to_id.end()) {
- if (is_builtin_normalizer(normalized_name)) {
- return
std::make_shared<SingleAnalyzerProvider>(build_builtin_normalizer(name));
+ bool builtin_normalizer = false;
+ const auto* index_policy = find_top_level_policy_locked(name,
&builtin_normalizer);
+ if (index_policy == nullptr) {
+ if (builtin_normalizer) {
+ return std::make_shared<SingleAnalyzerProvider>(
+ build_builtin_normalizer(normalized_name));
}
throw Exception(ErrorCode::INVALID_ARGUMENT, "Policy not found with
name: " + name);
}
- auto policy_it = _policys.find(name_it->second);
- if (policy_it == _policys.end()) {
- throw Exception(ErrorCode::INVALID_ARGUMENT, "Policy not found with
id: " + name);
+ if (resolved_name != nullptr) {
Review Comment:
[P1] Do not publish a builtin normalizer name as a compatibility alias for
an exact custom policy. For a replayed exact `LOWERCASE` normalizer, normalized
lookup of `lowercase` resolves to that same policy, so this sets the alias even
though `lowercase` is reserved for the builtin normalizer. If no exact
`LOWERCASE` reader exists, selection can then use a builtin-`lowercase` index
while tokenizing the query with the custom pipeline. Exclude builtin
normalizers here too and cover the exact-custom/query versus
canonical-builtin-reader case.
##########
fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java:
##########
@@ -226,26 +858,381 @@ private static String resolveTokenFilterIdentity(String
filterList) {
* IMPORTANT: Order is preserved because filter order is semantically
significant.
*/
private static String resolveCharFilterIdentity(String filterList) {
+ return resolveCharFilterIdentity(filterList, null);
+ }
+
+ private static String resolveCharFilterIdentity(String filterList,
FoldContext downstreamFold) {
+ ArrayDeque<String> identities = new ArrayDeque<>();
+ walkCharFilters(filterList, downstreamFold, identities);
+ return String.join(",", identities);
+ }
+
+ /**
+ * Resolve the chain from its last filter to its first, collecting
identities, and return the
+ * case-folding context that a filter placed in front of the chain would
run in.
+ */
+ private static FoldContext walkCharFilters(
+ String filterList, FoldContext downstreamFold, Deque<String>
identities) {
+ FoldContext fold = downstreamFold;
if (Strings.isNullOrEmpty(filterList)) {
- return "";
+ return fold;
}
- StringBuilder sb = new StringBuilder();
String[] filters = filterList.split(",\\s*");
// DO NOT sort - filter order is semantically significant
- for (int i = 0; i < filters.length; i++) {
- String filter = filters[i].trim();
- if (i > 0) {
- sb.append(",");
+ for (int i = filters.length - 1; i >= 0; --i) {
+ String filterName = filters[i].trim();
+ String filter = resolveComponentIdentity(filterName,
IndexPolicyTypeEnum.CHAR_FILTER, fold);
+ if (Strings.isNullOrEmpty(filter)) {
+ continue;
}
+ identities.addFirst(filter);
+ fold = foldContextBefore(filterName, fold);
+ }
+ return fold;
+ }
- if (IndexPolicy.BUILTIN_CHAR_FILTERS.contains(filter)) {
- sb.append(filter);
- } else {
- sb.append(resolveComponentIdentity(filter,
IndexPolicyTypeEnum.CHAR_FILTER));
+ /**
+ * Context for the filter that runs before this one: a case fold starts a
fresh context, a
+ * char_replace filter adds the bytes it rewrites, and any other filter
ends the context.
+ */
+ private static FoldContext foldContextBefore(String filterName,
FoldContext fold) {
+ FoldContext caseFold = caseFoldingCharFilterContext(filterName);
+ if (caseFold != null) {
+ return caseFold;
+ }
+ if (fold == null) {
+ return null;
+ }
+ boolean[] sourceBytes = charReplaceSourceBytes(filterName);
+ if (sourceBytes == null) {
+ return null;
+ }
+ fold.block(sourceBytes);
+ return fold;
+ }
+
+ /** Bytes a named char_replace filter rewrites, or null for any other
filter. */
+ private static boolean[] charReplaceSourceBytes(String filterName) {
+ IndexPolicy policy = findPolicy(filterName,
IndexPolicyTypeEnum.CHAR_FILTER);
+ if (policy == null || policy.isInvalid() || policy.getProperties() ==
null) {
+ return null;
+ }
+ Map<String, String> properties = policy.getProperties();
+ String type = normalizeBuiltinComponentName(
+ properties.get(IndexPolicy.PROP_TYPE),
IndexPolicyTypeEnum.CHAR_FILTER);
+ String pattern = properties.get("pattern");
+ if (!"char_replace".equals(type) || pattern == null) {
+ return null;
+ }
+ // Replacing the single replacement byte with itself leaves the stream
unchanged.
+ String replacement = properties.getOrDefault("replacement", " ");
+ int replacementByte = replacement.length() == 1 &&
replacement.charAt(0) < 128 ? replacement.charAt(0) : -1;
+ boolean[] sourceBytes = new boolean[256];
+ for (int i = 0; i < pattern.length(); ++i) {
+ char patternByte = pattern.charAt(i);
+ if (patternByte < sourceBytes.length && patternByte !=
replacementByte) {
+ sourceBytes[patternByte] = true;
}
}
- return sb.toString();
+ return sourceBytes;
+ }
+
+ /** The named policy when one exists with the expected type, or null. */
+ private static IndexPolicy findPolicy(String name, IndexPolicyTypeEnum
expectedType) {
+ if (Strings.isNullOrEmpty(name)) {
+ return null;
+ }
+ try {
+ Env env = Env.getCurrentEnv();
+ if (env != null && env.getIndexPolicyMgr() != null) {
+ IndexPolicy policy =
env.getIndexPolicyMgr().getPolicyByName(name);
+ if (policy != null && policy.getType() == expectedType) {
+ return policy;
+ }
+ }
+ } catch (RuntimeException e) {
+ // Treat lookup failures as an unknown policy.
+ }
+ return null;
+ }
+
+ /** Fold context started by a named or built-in case-folding char filter,
or null for any other filter. */
+ private static FoldContext caseFoldingCharFilterContext(String name) {
+ if (Strings.isNullOrEmpty(name)) {
+ return null;
+ }
+
+ try {
+ Env env = Env.getCurrentEnv();
+ if (env != null && env.getIndexPolicyMgr() != null) {
+ IndexPolicy policy =
env.getIndexPolicyMgr().getPolicyByName(name);
+ if (policy != null && policy.getType() ==
IndexPolicyTypeEnum.CHAR_FILTER) {
+ if (policy.isInvalid()) {
+ return null;
+ }
+ Map<String, String> properties = policy.getProperties();
+ if (properties != null && !properties.isEmpty()) {
+ String type = normalizeBuiltinComponentName(
+ properties.get(IndexPolicy.PROP_TYPE),
IndexPolicyTypeEnum.CHAR_FILTER);
+ return "icu_normalizer".equals(type) ?
icuNormalizerFoldContext(properties) : null;
+ }
+ }
+ }
+ } catch (RuntimeException e) {
+ // Fall through to built-in resolution.
+ }
+
+ return "icu_normalizer".equals(normalizeBuiltinComponentName(name,
IndexPolicyTypeEnum.CHAR_FILTER))
+ ? FoldContext.unfiltered() : null;
+ }
+
+ /**
+ * Fold context of an icu_normalizer component: the default nfkc_cf form
folds case over every
+ * code point, or only inside a parsable non-empty unicode_set_filter.
Null for other forms.
+ */
+ private static FoldContext icuNormalizerFoldContext(Map<String, String>
properties) {
+ if (!"nfkc_cf".equals(icuNormalizerName(properties))) {
+ return null;
+ }
+ String filter = properties.get("unicode_set_filter");
+ if (filter == null || filter.isEmpty()) {
+ return FoldContext.unfiltered();
+ }
+ try {
+ UnicodeSet unicodeSet = new UnicodeSet(filter);
+ return unicodeSet.isEmpty() ? FoldContext.unfiltered() : new
FoldContext(unicodeSet.freeze());
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ /** Whether an icu_normalizer component leaves ASCII letters as they are.
*/
+ private static boolean isAsciiCaseTransparentIcuNormalizer(Map<String,
String> properties) {
+ String name = icuNormalizerName(properties);
+ return "nfc".equals(name) || "nfd".equals(name) || "nfkc".equals(name)
|| "nfkd".equals(name);
+ }
+
+ private static String icuNormalizerName(Map<String, String> properties) {
+ return properties.getOrDefault("name",
"nfkc_cf").trim().toLowerCase(Locale.ROOT);
+ }
+
+ /** The outer char filter runs before everything else, so it takes the
analyzer's fold context. */
+ private static String appendOuterCharFilterIdentity(
+ String analyzerIdentity, Map<String, String> properties,
FoldContext fold) {
+ String type =
properties.get(InvertedIndexProperties.INVERTED_INDEX_PARSER_CHAR_FILTER_TYPE);
+ String pattern =
properties.get(InvertedIndexProperties.INVERTED_INDEX_PARSER_CHAR_FILTER_PATTERN);
+ if (!"char_replace".equals(type) || Strings.isNullOrEmpty(pattern)) {
+ return analyzerIdentity;
+ }
+ String replacement = properties.getOrDefault(
+
InvertedIndexProperties.INVERTED_INDEX_PARSER_CHAR_FILTER_REPLACEMENT, " ");
+ String canonicalPattern = canonicalizeCharReplacePattern(pattern,
replacement, fold);
+ if (canonicalPattern.isEmpty()) {
+ return analyzerIdentity;
+ }
+ return analyzerIdentity + "|outer_char_filter=char_replace:"
+ + canonicalPattern.length() + ":" + canonicalPattern + ":"
+ + replacement.length() + ":" + replacement + ";";
+ }
+
+ /**
+ * Canonicalize the ASCII pattern to the BE filter's byte set.
+ * Order, duplicate bytes, and replacements of a byte with itself do not
change the stream.
+ */
+ private static String canonicalizeCharReplacePattern(
+ String pattern, String replacement, FoldContext fold) {
+ if (replacement.length() != 1) {
+ return pattern;
+ }
+ char replacementByte = replacement.charAt(0);
Review Comment:
[P1] Canonicalize lower-to-upper replacements that a proven downstream fold
immediately erases. `a->A` before an unfiltered lowercase token filter emits
exactly the same bytes and offsets as no char filter, but this branch only
removes the opposite `A->a` form because it requires a lowercase replacement.
Two named `keyword -> lowercase` aliases can therefore get different identities
and pass CREATE/ALTER duplicate checks. Compare the downstream folded source
and replacement bytes in both directions, and add the reverse-case DDL coverage.
##########
be/src/storage/index/inverted/token_filter/pinyin_filter.cpp:
##########
@@ -214,29 +245,94 @@ bool PinyinFilter::readTerm(Token* token) {
return false;
}
-bool PinyinFilter::processCurrentToken() {
- processed_candidate_ = true;
+bool PinyinFilter::prepareCurrentSource(std::vector<UChar32>&
source_codepoints) {
+ size_t source_start = 0;
+ size_t source_end = current_token_text_.size();
+ if (config_->trimWhitespace) {
+ source_start = current_token_text_.find_first_not_of(" \t\n\r");
+ if (source_start == std::string::npos) {
+ return false;
+ }
+ source_end = current_token_text_.find_last_not_of(" \t\n\r") + 1;
+ }
+ current_source_ = current_token_text_.substr(source_start, source_end -
source_start);
- if (!has_current_token_) {
+ if (current_source_.empty()) {
return false;
}
- current_source_ = current_token_text_;
+ if (config_->ignorePinyinOffset) {
+ convertToCodepoints(current_source_, source_codepoints);
+ return !source_codepoints.empty();
+ }
- // Apply trimming if configured
- if (config_->trimWhitespace) {
- current_source_ = trim(current_source_);
+ current_runes_ = convertToRunes(current_source_, source_codepoints);
+
+ std::vector<RuneInfo> original_runes;
+ if (!current_source_byte_offsets_.empty()) {
+ std::vector<UChar32> original_codepoints;
+ original_runes = convertToRunes(current_token_text_,
original_codepoints);
+ if (current_source_byte_offsets_.size() != original_runes.size() + 1) {
+ current_source_byte_offsets_.clear();
+ current_source_byte_end_offsets_.clear();
+ }
+ }
+ if (!current_source_byte_offsets_.empty()) {
+ DORIS_CHECK(current_source_byte_end_offsets_.empty() ||
+ current_source_byte_end_offsets_.size() ==
original_runes.size());
+ const auto start_rune = std::ranges::lower_bound(
+ original_runes, static_cast<int32_t>(source_start), {},
&RuneInfo::byte_start);
+ const auto end_rune = std::ranges::lower_bound(
+ original_runes, static_cast<int32_t>(source_end), {},
&RuneInfo::byte_start);
+ const auto start_index = static_cast<size_t>(start_rune -
original_runes.begin());
+ const auto end_index = static_cast<size_t>(end_rune -
original_runes.begin());
+ DORIS_CHECK_EQ(end_index - start_index, current_runes_.size());
+ const int32_t token_start_offset = current_start_offset_;
+ current_start_offset_ += current_source_byte_offsets_[start_index];
+ current_end_offset_ =
+ token_start_offset + (current_source_byte_end_offsets_.empty()
+ ?
current_source_byte_offsets_[end_index]
+ :
current_source_byte_end_offsets_[end_index - 1]);
+ for (size_t i = 0; i < current_runes_.size(); ++i) {
+ current_runes_[i].byte_start =
current_source_byte_offsets_[start_index + i] -
+
current_source_byte_offsets_[start_index];
+ current_runes_[i].byte_end =
+ (current_source_byte_end_offsets_.empty()
+ ? current_source_byte_offsets_[start_index + i +
1]
+ : current_source_byte_end_offsets_[start_index +
i]) -
+ current_source_byte_offsets_[start_index];
+ }
+ } else if (has_current_conservative_source_span_) {
+ DORIS_CHECK_GE(current_conservative_source_start_, 0);
+ DORIS_CHECK_GE(current_conservative_source_end_,
current_conservative_source_start_);
+ const int32_t token_start_offset = current_start_offset_;
+ current_start_offset_ = token_start_offset +
current_conservative_source_start_;
+ current_end_offset_ = token_start_offset +
current_conservative_source_end_;
+ const int32_t source_length =
+ current_conservative_source_end_ -
current_conservative_source_start_;
+ for (auto& rune : current_runes_) {
+ rune.byte_start = 0;
+ rune.byte_end = source_length;
+ }
+ } else {
+ current_start_offset_ += static_cast<int32_t>(source_start);
+ current_end_offset_ =
+ current_start_offset_ + static_cast<int32_t>(source_end -
source_start);
}
- if (current_source_.empty()) {
+ return !source_codepoints.empty();
+}
+
+bool PinyinFilter::processCurrentToken() {
+ processed_candidate_ = true;
+
+ if (!has_current_token_) {
return false;
}
- // Convert to Unicode codepoints for processing
+ // Convert to Unicode codepoints for processing.
std::vector<UChar32> source_codepoints;
- convertToRunes(current_source_, source_codepoints);
-
- if (source_codepoints.empty()) {
+ if (!prepareCurrentSource(source_codepoints)) {
return false;
}
Review Comment:
[P1] Avoid the new per-rune index vector when Pinyin offsets are ignored.
The default configuration still pushes one `int` per ASCII alphanumeric rune
here, but `setTokenAttributes()` uses candidate subranges only under
`!ignorePinyinOffset`; otherwise every candidate gets the whole input span.
With the supported `empty -> pinyin` chain, a 100 MiB ASCII token therefore
adds about 400 MiB of pointless peak index storage. Keep the exact index vector
only for offset-aware mode and cover a large default-mode token while
processing is live.
##########
be/src/storage/index/inverted/token_filter/lower_case_filter.h:
##########
@@ -131,16 +166,40 @@ class LowerCaseFilter : public DorisTokenFilter {
static_cast<int32_t>(status), u_errorName(status));
}
Review Comment:
[P2] Gate these provenance-only rune counts on
`_source_byte_offsets_enabled`. Ordinary analyzer pipelines leave that flag
false, so upstream maps are empty and `_rune_count_changed` cannot affect any
consumer, yet each transformed non-ASCII token now gets two additional full
UTF-8 scans. `ASCIIFoldingFilter` has the same unconditional input/output
counts. Skip both paths when provenance is disabled and add a seam showing
disabled mode does no counting.
--
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]