diff --git a/be/src/exprs/function/function_search.cpp b/be/src/exprs/function/function_search.cpp index a439800ca538a0..f278542c7d5d5e 100644 --- a/be/src/exprs/function/function_search.cpp +++ b/be/src/exprs/function/function_search.cpp @@ -307,6 +307,29 @@ Status FunctionSearch::evaluate_inverted_index_with_search_param( const IndexExecContext* index_exec_ctx, const std::unordered_map& field_name_to_column_id, const std::shared_ptr& index_query_context) const { + // VSearchExpr enters here directly, outside IFunction::execute() and its exception boundary, + // so this is where a failure inside the search, such as an analyzer whose first token + // stream throws, has to become a Status. + try { + return evaluate_inverted_index_with_search_param_unguarded( + search_param, data_type_with_names, std::move(iterators), num_rows, bitmap_result, + enable_cache, index_exec_ctx, field_name_to_column_id, index_query_context); + } catch (const CLuceneError& e) { + return Status::Error("search failed: {}", + e.what()); + } catch (const Exception& e) { + return e.to_status(); + } +} + +Status FunctionSearch::evaluate_inverted_index_with_search_param_unguarded( + const TSearchParam& search_param, + const std::unordered_map& data_type_with_names, + std::unordered_map iterators, uint32_t num_rows, + InvertedIndexResultBitmap& bitmap_result, bool enable_cache, + const IndexExecContext* index_exec_ctx, + const std::unordered_map& field_name_to_column_id, + const std::shared_ptr& index_query_context) const { const bool is_nested_query = search_param.root.clause_type == "NESTED"; if (is_nested_query && !is_nested_group_search_supported()) { return Status::NotSupported( diff --git a/be/src/exprs/function/function_search.h b/be/src/exprs/function/function_search.h index 343db747583a70..df417e0bca99eb 100644 --- a/be/src/exprs/function/function_search.h +++ b/be/src/exprs/function/function_search.h @@ -93,6 +93,16 @@ class FunctionSearch : public IFunction { const std::unordered_map& field_name_to_column_id, const std::shared_ptr& index_query_context = nullptr) const; + // The body of the overload above; it may throw, so only that overload calls it. + Status evaluate_inverted_index_with_search_param_unguarded( + const TSearchParam& search_param, + const std::unordered_map& data_type_with_names, + std::unordered_map iterators, uint32_t num_rows, + InvertedIndexResultBitmap& bitmap_result, bool enable_cache, + const IndexExecContext* index_exec_ctx, + const std::unordered_map& field_name_to_column_id, + const std::shared_ptr& index_query_context) const; + // Public methods for testing enum class ClauseTypeCategory { NON_TOKENIZED, // TERM, PREFIX, WILDCARD, REGEXP, RANGE, LIST - no tokenization, use EQUAL_QUERY diff --git a/be/src/exprs/function/match.cpp b/be/src/exprs/function/match.cpp index 7fad312500661f..c34e6a330ed56b 100644 --- a/be/src/exprs/function/match.cpp +++ b/be/src/exprs/function/match.cpp @@ -62,14 +62,7 @@ Status FunctionMatchBase::evaluate_inverted_index( } const std::string& function_name = get_name(); - if (function_name == MATCH_PHRASE_FUNCTION || function_name == MATCH_PHRASE_PREFIX_FUNCTION || - function_name == MATCH_PHRASE_EDGE_FUNCTION) { - auto reader = iter->get_reader(InvertedIndexReaderType::FULLTEXT); - if (reader && !segment_v2::IndexReaderHelper::is_support_phrase(reader)) { - return Status::Error( - "phrase queries require setting support_phrase = true"); - } - } + // support_phrase is checked once the analyzer has selected the reader that runs the query. Field param_value; arguments[0].column->get(0, param_value); if (param_value.is_null()) { diff --git a/be/src/exprs/function/variant_inverted_index_search.cpp b/be/src/exprs/function/variant_inverted_index_search.cpp index 5f44ac124dfbb1..87abd534fcbd86 100644 --- a/be/src/exprs/function/variant_inverted_index_search.cpp +++ b/be/src/exprs/function/variant_inverted_index_search.cpp @@ -65,7 +65,7 @@ void add_search_binding_diagnostic(const std::shared_ptr& con } } -InvertedIndexAnalyzerCtxSPtr build_analyzer_context( +InvertedIndexAnalyzerCtxSPtr build_analyzer_context_unsafe( const std::map& properties, const std::string& analyzer_key) { InvertedIndexAnalyzerConfig config; config.analyzer_name = get_analyzer_name_from_properties(properties); @@ -90,6 +90,20 @@ InvertedIndexAnalyzerCtxSPtr build_analyzer_context( } // namespace +// Replayed components can collide across policy families, so building the provider throws. +Result build_search_analyzer_context( + const std::map& properties, const std::string& analyzer_key) { + try { + return build_analyzer_context_unsafe(properties, analyzer_key); + } catch (const CLuceneError& error) { + return ResultError(Status::Error( + "Build search analyzer failed: {}", error.what())); + } catch (const Exception& error) { + return ResultError(Status::Error( + "Build search analyzer failed: {}", error.what())); + } +} + FieldReaderResolver::FieldReaderResolver( const std::unordered_map& data_type_with_names, const std::unordered_map& iterators, @@ -415,8 +429,12 @@ Status FieldReaderResolver::resolve_with_analyzer_context(const std::string& fie return Status::OK(); } - binding->analyzer_context = - build_analyzer_context(binding->index_properties, binding->analyzer_key); + auto built_context = + build_search_analyzer_context(binding->index_properties, binding->analyzer_key); + if (!built_context.has_value()) { + return built_context.error(); + } + binding->analyzer_context = std::move(built_context.value()); _cache.at(binding->binding_key).analyzer_context = binding->analyzer_context; return Status::OK(); } diff --git a/be/src/exprs/function/variant_inverted_index_search.h b/be/src/exprs/function/variant_inverted_index_search.h index a468a9c0034efe..06be33823e553a 100644 --- a/be/src/exprs/function/variant_inverted_index_search.h +++ b/be/src/exprs/function/variant_inverted_index_search.h @@ -102,6 +102,11 @@ struct FieldReaderBinding { } }; +// Build the analyzer context a SEARCH binding executes with, converting a failure to build the +// analyzer provider into a Status instead of letting the exception escape a Status-returning caller. +Result build_search_analyzer_context( + const std::map& properties, const std::string& analyzer_key); + class FieldReaderResolver { public: FieldReaderResolver( diff --git a/be/src/exprs/vmatch_predicate.cpp b/be/src/exprs/vmatch_predicate.cpp index 58b80c17bb4ed0..ea903af15ee401 100644 --- a/be/src/exprs/vmatch_predicate.cpp +++ b/be/src/exprs/vmatch_predicate.cpp @@ -58,8 +58,10 @@ namespace doris { using namespace doris::segment_v2; VMatchPredicate::VMatchPredicate(const TExprNode& node) : VExpr(node) { - const auto resolved = AnalyzerConfigParser::parse(node.match_predicate.analyzer_name, - node.match_predicate.parser_type); + const auto resolved = AnalyzerConfigParser::parse( + node.match_predicate.analyzer_name, node.match_predicate.parser_type, + node.match_predicate.parser_mode, node.match_predicate.parser_lowercase, + node.match_predicate.char_filter_map); InvertedIndexAnalyzerConfig config; config.analyzer_name = resolved.provider_name; @@ -81,9 +83,29 @@ VMatchPredicate::VMatchPredicate(const TExprNode& node) : VExpr(node) { _analyzer_ctx->parser_type = resolved.parser_type; if (_analyzer_ctx->requires_analysis()) { - _analyzer_provider = - inverted_index::InvertedIndexAnalyzer::create_analyzer_provider(&config); + std::string bound_name; + std::string legacy_name; + _analyzer_provider = inverted_index::InvertedIndexAnalyzer::create_analyzer_provider( + &config, &bound_name, &legacy_name); _analyzer = _analyzer_provider->get_analyzer(); + if (!legacy_name.empty() && legacy_name != bound_name) { + _analyzer_ctx->legacy_analyzer_key = + AnalyzerConfigParser::parse(legacy_name, node.match_predicate.parser_type, + node.match_predicate.parser_mode, + node.match_predicate.parser_lowercase, + node.match_predicate.char_filter_map) + .analyzer_key; + } + if (bound_name != resolved.provider_name) { + // Reader selection and query tokenization must use the same policy binding. + _analyzer_ctx->analyzer_key = + AnalyzerConfigParser::parse(bound_name, node.match_predicate.parser_type, + node.match_predicate.parser_mode, + node.match_predicate.parser_lowercase, + node.match_predicate.char_filter_map) + .analyzer_key; + _analyzer_ctx->analyzer_name = std::move(bound_name); + } } _analyzer_ctx->char_filter_map = std::move(config.char_filter_map); diff --git a/be/src/runtime/index_policy/index_policy_mgr.cpp b/be/src/runtime/index_policy/index_policy_mgr.cpp index 8cc007a84f0f76..4631cf09101e82 100644 --- a/be/src/runtime/index_policy/index_policy_mgr.cpp +++ b/be/src/runtime/index_policy/index_policy_mgr.cpp @@ -24,6 +24,8 @@ #include #include +#include "storage/index/inverted/analyzer/analyzer.h" + namespace doris { namespace { @@ -41,13 +43,72 @@ class SingleAnalyzerProvider final : public segment_v2::inverted_index::Analyzer const std::unordered_set IndexPolicyMgr::BUILTIN_NORMALIZERS = {"lowercase"}; -std::string IndexPolicyMgr::normalize_name(const std::string& name) { +std::string IndexPolicyMgr::trim_name(const std::string& name) { std::string result = name; boost::algorithm::trim(result); + return result; +} + +std::string IndexPolicyMgr::normalize_name(const std::string& name) { + std::string result = trim_name(name); boost::algorithm::to_lower(result); return result; } +const TIndexPolicy* IndexPolicyMgr::find_policy_by_name_locked(const std::string& name) const { + const std::string exact_name = trim_name(name); + if (auto exact_it = _exact_name_to_id.find(exact_name); exact_it != _exact_name_to_id.end()) { + if (auto policy_it = _policys.find(exact_it->second); policy_it != _policys.end()) { + return &policy_it->second; + } + } + + const std::string normalized_name = normalize_name(name); + if (auto normalized_it = _name_to_id.find(normalized_name); + normalized_it != _name_to_id.end()) { + if (auto policy_it = _policys.find(normalized_it->second); policy_it != _policys.end()) { + return &policy_it->second; + } + } + return nullptr; +} + +void IndexPolicyMgr::register_policy_name_locked(const TIndexPolicy& policy) { + const std::string exact_name = trim_name(policy.name); + if (auto exact_it = _exact_name_to_id.find(exact_name); + exact_it == _exact_name_to_id.end() || policy.id > exact_it->second) { + _exact_name_to_id[exact_name] = policy.id; + } + + const std::string normalized_name = normalize_name(policy.name); + if (auto normalized_it = _name_to_id.find(normalized_name); + normalized_it != _name_to_id.end()) { + LOG(WARNING) << "Policies have the same normalized name: " << policy.name + << " | Existing authoritative ID: " << normalized_it->second + << " | New ID: " << policy.id << " | The higher ID is authoritative"; + } + if (!_name_to_id.contains(normalized_name) || policy.id > _name_to_id.at(normalized_name)) { + _name_to_id[normalized_name] = policy.id; + } +} + +void IndexPolicyMgr::unregister_policy_name_locked(const TIndexPolicy& policy) { + const std::string exact_name = trim_name(policy.name); + const std::string normalized_name = normalize_name(policy.name); + if (_exact_name_to_id.contains(exact_name) && _exact_name_to_id.at(exact_name) == policy.id) { + _exact_name_to_id.erase(exact_name); + } + if (_name_to_id.contains(normalized_name) && _name_to_id.at(normalized_name) == policy.id) { + _name_to_id.erase(normalized_name); + } + for (const auto& [remaining_id, remaining] : _policys) { + if (trim_name(remaining.name) == exact_name || + normalize_name(remaining.name) == normalized_name) { + register_policy_name_locked(remaining); + } + } +} + void IndexPolicyMgr::apply_policy_changes(const std::vector& policys_to_update, const std::vector& policys_to_delete) { LOG(INFO) << "Starting policy changes - " @@ -63,8 +124,9 @@ void IndexPolicyMgr::apply_policy_changes(const std::vector& polic LOG(INFO) << "Deleting policy - " << "ID: " << id << ", " << "Name: " << it->second.name; - _name_to_id.erase(normalize_name(it->second.name)); + const TIndexPolicy policy = it->second; _policys.erase(it); + unregister_policy_name_locked(policy); ++success_deletes; } else { LOG(WARNING) << "Delete failed - Policy ID not found: " << id; @@ -78,16 +140,8 @@ void IndexPolicyMgr::apply_policy_changes(const std::vector& polic << " | New name: " << policy.name; continue; } - std::string normalized_name = normalize_name(policy.name); - if (_name_to_id.contains(normalized_name)) { - LOG(ERROR) << "Reject update - Duplicate policy name: " << policy.name - << " | Existing ID: " << _name_to_id[normalized_name] - << " | New ID: " << policy.id; - continue; - } - _policys.emplace(policy.id, policy); - _name_to_id.emplace(normalized_name, policy.id); + register_policy_name_locked(policy); ++success_updates; LOG(INFO) << "Successfully applied policy - " << "ID: " << policy.id << ", " @@ -106,34 +160,43 @@ Policys IndexPolicyMgr::get_index_policys() { return _policys; // Return copy to ensure thread safety after lock release } -// NOTE: This function holds a shared_lock while calling build_analyzer_from_policy/ -// build_normalizer_from_policy, which also access _name_to_id and _policys. -// This is safe because std::shared_mutex allows the same thread to hold multiple -// shared_locks (read locks are reentrant). The lock is held throughout to ensure -// consistency when resolving nested policy references (e.g., tokenizer policies). +// Resolve a top-level analyzer or normalizer name the way FE validates it: an exact policy +// wins, then the canonical built-in normalizer, then the normalized policy fallback. +const TIndexPolicy* IndexPolicyMgr::find_top_level_policy_locked(const std::string& name, + bool* builtin_normalizer) const { + *builtin_normalizer = false; + const std::string exact_name = trim_name(name); + if (auto exact_it = _exact_name_to_id.find(exact_name); exact_it != _exact_name_to_id.end()) { + if (auto policy_it = _policys.find(exact_it->second); policy_it != _policys.end()) { + return &policy_it->second; + } + } + if (BUILTIN_NORMALIZERS.contains(normalize_name(name))) { + *builtin_normalizer = true; + return nullptr; + } + return find_policy_by_name_locked(name); +} + +// Hold the lock throughout nested policy resolution so an analyzer observes a consistent +// policy-name mapping and policy set. AnalyzerPtr IndexPolicyMgr::get_policy_by_name(const std::string& name) { std::shared_lock lock(_mutex); - // Use normalized name for case-insensitive lookup 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); - } - - const auto& index_policy = policy_it->second; - if (index_policy.type == TIndexPolicyType::ANALYZER) { - return build_analyzer_from_policy(index_policy); - } else if (index_policy.type == TIndexPolicyType::NORMALIZER) { - return build_normalizer_from_policy(index_policy); + if (index_policy->type == TIndexPolicyType::ANALYZER) { + return build_analyzer_from_policy(*index_policy); + } else if (index_policy->type == TIndexPolicyType::NORMALIZER) { + return build_normalizer_from_policy(*index_policy); } throw Exception(ErrorCode::INVALID_ARGUMENT, "Policy not found with type: " + name); @@ -142,50 +205,62 @@ 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& outer_char_filter_map) { + const std::string& name, const std::map& 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(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( + 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) { + *resolved_name = index_policy->name; + } + // A metadata alias must not select a builtin analyzer or normalizer, or a different policy. + if (legacy_name != nullptr && + !segment_v2::inverted_index::InvertedIndexAnalyzer::is_builtin_analyzer(normalized_name) && + !BUILTIN_NORMALIZERS.contains(normalized_name) && + find_policy_by_name_locked(normalized_name) == index_policy) { + *legacy_name = normalized_name; } - if (policy_it->second.type == TIndexPolicyType::ANALYZER) { - return build_analyzer_provider_from_config( - build_analyzer_config_from_policy(policy_it->second), outer_char_filter_map); + if (index_policy->type == TIndexPolicyType::ANALYZER) { + return build_analyzer_provider_from_config(build_analyzer_config_from_policy(*index_policy), + outer_char_filter_map); } - if (policy_it->second.type == TIndexPolicyType::NORMALIZER) { + if (index_policy->type == TIndexPolicyType::NORMALIZER) { return std::make_shared( - build_normalizer_from_policy(policy_it->second)); + build_normalizer_from_policy(*index_policy)); } throw Exception(ErrorCode::INVALID_ARGUMENT, "Analyzer policy not found: " + name); } @@ -202,34 +277,43 @@ IndexPolicyMgr::build_analyzer_config_from_policy(const TIndexPolicy& index_poli } const auto& tokenizer_name = tokenizer_it->second; - // Use normalized name for case-insensitive lookup std::string normalized_tokenizer_name = normalize_name(tokenizer_name); - if (_name_to_id.contains(normalized_tokenizer_name)) { - const auto& tokenizer_policy = _policys[_name_to_id[normalized_tokenizer_name]]; - auto type_it = tokenizer_policy.properties.find(PROP_TYPE); - if (type_it == tokenizer_policy.properties.end()) { + if (const auto* tokenizer_policy = find_policy_by_name_locked(tokenizer_name); + tokenizer_policy != nullptr) { + // Replayed exact names may collide across families; never build a tokenizer from a + // filter policy whose factory type happens to be a tokenizer type as well. + if (tokenizer_policy->type != TIndexPolicyType::TOKENIZER) { + throw Exception(ErrorCode::INVALID_ARGUMENT, + "Referenced policy '" + tokenizer_name + "' has type " + + to_string(tokenizer_policy->type) + " but expected " + + to_string(TIndexPolicyType::TOKENIZER)); + } + auto type_it = tokenizer_policy->properties.find(PROP_TYPE); + if (type_it == tokenizer_policy->properties.end()) { throw Exception(ErrorCode::INVALID_ARGUMENT, "Invalid tokenizer configuration in policy: " + tokenizer_name); } segment_v2::inverted_index::Settings settings; - for (const auto& prop : tokenizer_policy.properties) { + for (const auto& prop : tokenizer_policy->properties) { if (prop.first != PROP_TYPE) { settings.set(prop.first, prop.second); } } builder.with_tokenizer_config(type_it->second, settings); } else { - builder.with_tokenizer_config(tokenizer_name, {}); + builder.with_tokenizer_config(normalized_tokenizer_name, {}); } - process_filter_configs(index_policy_analyzer, PROP_CHAR_FILTER, "char filter", + process_filter_configs(index_policy_analyzer, PROP_CHAR_FILTER, TIndexPolicyType::CHAR_FILTER, + "char filter", [&builder](const std::string& name, const segment_v2::inverted_index::Settings& settings) { builder.add_char_filter_config(name, settings); }); - process_filter_configs(index_policy_analyzer, PROP_TOKEN_FILTER, "token filter", + process_filter_configs(index_policy_analyzer, PROP_TOKEN_FILTER, TIndexPolicyType::TOKEN_FILTER, + "token filter", [&builder](const std::string& name, const segment_v2::inverted_index::Settings& settings) { builder.add_token_filter_config(name, settings); @@ -255,13 +339,15 @@ AnalyzerPtr IndexPolicyMgr::build_normalizer_from_policy( const TIndexPolicy& index_policy_normalizer) { segment_v2::inverted_index::CustomNormalizerConfig::Builder builder; - process_filter_configs(index_policy_normalizer, PROP_CHAR_FILTER, "char filter", + process_filter_configs(index_policy_normalizer, PROP_CHAR_FILTER, TIndexPolicyType::CHAR_FILTER, + "char filter", [&builder](const std::string& name, const segment_v2::inverted_index::Settings& settings) { builder.add_char_filter_config(name, settings); }); - process_filter_configs(index_policy_normalizer, PROP_TOKEN_FILTER, "token filter", + process_filter_configs(index_policy_normalizer, PROP_TOKEN_FILTER, + TIndexPolicyType::TOKEN_FILTER, "token filter", [&builder](const std::string& name, const segment_v2::inverted_index::Settings& settings) { builder.add_token_filter_config(name, settings); @@ -274,7 +360,7 @@ AnalyzerPtr IndexPolicyMgr::build_normalizer_from_policy( void IndexPolicyMgr::process_filter_configs( const TIndexPolicy& index_policy_analyzer, const std::string& prop_name, - const std::string& error_prefix, + TIndexPolicyType::type expected_type, const std::string& error_prefix, std::function add_config_func) { auto filter_it = index_policy_analyzer.properties.find(prop_name); @@ -291,21 +377,25 @@ void IndexPolicyMgr::process_filter_configs( continue; } - // Use normalized name for case-insensitive lookup std::string normalized_filter_name = normalize_name(filter_name); - if (_name_to_id.contains(normalized_filter_name)) { + if (const auto* filter_policy = find_policy_by_name_locked(filter_name); + filter_policy != nullptr) { // Nested filter policy - const int64_t filter_policy_id = _name_to_id.at(normalized_filter_name); - const auto& filter_policy = _policys.at(filter_policy_id); - auto type_it = filter_policy.properties.find(PROP_TYPE); - if (type_it == filter_policy.properties.end()) { + if (filter_policy->type != expected_type) { + throw Exception(ErrorCode::INVALID_ARGUMENT, + "Referenced policy '" + filter_name + "' has type " + + to_string(filter_policy->type) + " but expected " + + to_string(expected_type)); + } + auto type_it = filter_policy->properties.find(PROP_TYPE); + if (type_it == filter_policy->properties.end()) { throw Exception( ErrorCode::INVALID_ARGUMENT, "Invalid " + error_prefix + " configuration in policy: " + filter_name); } segment_v2::inverted_index::Settings settings; - for (const auto& prop : filter_policy.properties) { + for (const auto& prop : filter_policy->properties) { if (prop.first != PROP_TYPE) { settings.set(prop.first, prop.second); } @@ -313,7 +403,7 @@ void IndexPolicyMgr::process_filter_configs( add_config_func(type_it->second, settings); } else { // Simple filter - add_config_func(filter_name, {}); + add_config_func(normalized_filter_name, {}); } } } @@ -335,4 +425,4 @@ AnalyzerPtr IndexPolicyMgr::build_builtin_normalizer(const std::string& name) { throw Exception(ErrorCode::INVALID_ARGUMENT, "Unknown builtin normalizer: " + name); } -} // namespace doris \ No newline at end of file +} // namespace doris diff --git a/be/src/runtime/index_policy/index_policy_mgr.h b/be/src/runtime/index_policy/index_policy_mgr.h index 7e444636dae678..ccdfade6eb3233 100644 --- a/be/src/runtime/index_policy/index_policy_mgr.h +++ b/be/src/runtime/index_policy/index_policy_mgr.h @@ -50,7 +50,8 @@ class IndexPolicyMgr { AnalyzerPtr get_analyzer_by_name(const std::string& name); AnalyzerProviderPtr get_analyzer_provider_by_name( const std::string& name, - const std::map& outer_char_filter_map = {}); + const std::map& outer_char_filter_map = {}, + std::string* resolved_name = nullptr, std::string* legacy_name = nullptr); private: segment_v2::inverted_index::CustomAnalyzerConfigPtr build_analyzer_config_from_policy( @@ -63,13 +64,20 @@ class IndexPolicyMgr { void process_filter_configs( const TIndexPolicy& index_policy_analyzer, const std::string& prop_name, - const std::string& error_prefix, + TIndexPolicyType::type expected_type, const std::string& error_prefix, std::function add_config_func); bool is_builtin_normalizer(const std::string& name); AnalyzerPtr build_builtin_normalizer(const std::string& name); + const TIndexPolicy* find_policy_by_name_locked(const std::string& name) const; + const TIndexPolicy* find_top_level_policy_locked(const std::string& name, + bool* builtin_normalizer) const; + void register_policy_name_locked(const TIndexPolicy& policy); + void unregister_policy_name_locked(const TIndexPolicy& policy); + + static std::string trim_name(const std::string& name); // Normalize policy name to lowercase for case-insensitive lookup static std::string normalize_name(const std::string& name); @@ -84,6 +92,7 @@ class IndexPolicyMgr { Policys _policys; std::unordered_map _name_to_id; + std::unordered_map _exact_name_to_id; }; -} // namespace doris \ No newline at end of file +} // namespace doris diff --git a/be/src/storage/index/inverted/analysis_factory_mgr.cpp b/be/src/storage/index/inverted/analysis_factory_mgr.cpp index a208a275bda640..d6ecb3bae690e5 100644 --- a/be/src/storage/index/inverted/analysis_factory_mgr.cpp +++ b/be/src/storage/index/inverted/analysis_factory_mgr.cpp @@ -30,6 +30,7 @@ #include "storage/index/inverted/tokenizer/char/char_group_tokenizer_factory.h" #include "storage/index/inverted/tokenizer/empty/empty_tokenizer_factory.h" #include "storage/index/inverted/tokenizer/icu/icu_tokenizer_factory.h" +#include "storage/index/inverted/tokenizer/ik/ik_tokenizer_factory.h" #include "storage/index/inverted/tokenizer/keyword/keyword_tokenizer_factory.h" #include "storage/index/inverted/tokenizer/ngram/edge_ngram_tokenizer_factory.h" #include "storage/index/inverted/tokenizer/pinyin/pinyin_tokenizer_factory.h" @@ -68,6 +69,10 @@ void AnalysisFactoryMgr::initialise() { []() { return std::make_shared(); }); registerFactory( "pinyin", []() { return std::make_shared(); }); + registerFactory( + "ik_smart", []() { return std::make_shared(true); }); + registerFactory( + "ik_max_word", []() { return std::make_shared(false); }); // token_filter registerFactory( @@ -117,4 +122,4 @@ template std::shared_ptr AnalysisFactoryMgr::create AnalysisFactoryMgr::create( const std::string&, const Settings&); -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/analyzer/analyzer.cpp b/be/src/storage/index/inverted/analyzer/analyzer.cpp index d76c75ec70d137..a8cff773ca2d49 100644 --- a/be/src/storage/index/inverted/analyzer/analyzer.cpp +++ b/be/src/storage/index/inverted/analyzer/analyzer.cpp @@ -185,9 +185,16 @@ AnalyzerPtr InvertedIndexAnalyzer::create_analyzer(const InvertedIndexAnalyzerCo } AnalyzerProviderPtr InvertedIndexAnalyzer::create_analyzer_provider( - const InvertedIndexAnalyzerConfig* config) { + const InvertedIndexAnalyzerConfig* config, std::string* resolved_name, + std::string* legacy_name) { DCHECK(config != nullptr); + if (legacy_name != nullptr) { + legacy_name->clear(); + } if (config->analyzer_name.empty() || is_builtin_analyzer(config->analyzer_name)) { + if (resolved_name != nullptr) { + *resolved_name = config->analyzer_name; + } return std::make_shared(*config); } @@ -196,8 +203,8 @@ AnalyzerProviderPtr InvertedIndexAnalyzer::create_analyzer_provider( throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR, "Index policy manager is not initialized"); } - return index_policy_mgr->get_analyzer_provider_by_name(config->analyzer_name, - config->char_filter_map); + return index_policy_mgr->get_analyzer_provider_by_name( + config->analyzer_name, config->char_filter_map, resolved_name, legacy_name); } std::vector InvertedIndexAnalyzer::get_analyse_result( diff --git a/be/src/storage/index/inverted/analyzer/analyzer.h b/be/src/storage/index/inverted/analyzer/analyzer.h index c9d4671c90c2a5..06e705c6995cb6 100644 --- a/be/src/storage/index/inverted/analyzer/analyzer.h +++ b/be/src/storage/index/inverted/analyzer/analyzer.h @@ -50,7 +50,9 @@ class InvertedIndexAnalyzer { const std::string& lower_case, const std::string& stop_words); static AnalyzerPtr create_analyzer(const InvertedIndexAnalyzerConfig* config); - static AnalyzerProviderPtr create_analyzer_provider(const InvertedIndexAnalyzerConfig* config); + static AnalyzerProviderPtr create_analyzer_provider(const InvertedIndexAnalyzerConfig* config, + std::string* resolved_name = nullptr, + std::string* legacy_name = nullptr); static std::vector get_analyse_result(ReaderPtr reader, lucene::analysis::Analyzer* analyzer); @@ -61,4 +63,4 @@ class InvertedIndexAnalyzer { static bool should_analyzer(const std::map& properties); }; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/analyzer/custom_analyzer.cpp b/be/src/storage/index/inverted/analyzer/custom_analyzer.cpp index 512cfd5e02fd0d..ec1298bf411652 100644 --- a/be/src/storage/index/inverted/analyzer/custom_analyzer.cpp +++ b/be/src/storage/index/inverted/analyzer/custom_analyzer.cpp @@ -70,12 +70,17 @@ ReaderPtr CustomAnalyzer::init_reader(ReaderPtr reader) { } TokenStreamComponentsPtr CustomAnalyzer::create_components() { - auto tk = _tokenizer->create(); - TokenStreamPtr ts = tk; - for (const auto& filter : _token_filters) { - ts = filter->create(ts); + try { + auto tk = _tokenizer->create(); + TokenStreamPtr ts = tk; + for (const auto& filter : _token_filters) { + ts = filter->create(ts); + } + return std::make_shared(tk, ts); + } catch (const CLuceneError& e) { + throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR, + "Failed to create custom analyzer components: {}", e.what()); } - return std::make_shared(tk, ts); } CustomAnalyzerPtr CustomAnalyzer::build_custom_analyzer( @@ -131,4 +136,4 @@ TokenizerPtr TokenStreamComponents::get_source() { return _source; } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/analyzer/ik/IKTokenizer.cpp b/be/src/storage/index/inverted/analyzer/ik/IKTokenizer.cpp index df41a909ffa4ba..88c6b97d6c5470 100644 --- a/be/src/storage/index/inverted/analyzer/ik/IKTokenizer.cpp +++ b/be/src/storage/index/inverted/analyzer/ik/IKTokenizer.cpp @@ -17,8 +17,52 @@ #include "storage/index/inverted/analyzer/ik/IKTokenizer.h" +#include + +#include +#include + +#include "storage/index/inverted/char_filter/char_filter.h" + namespace doris::segment_v2 { +namespace { + +// Normalize the token and collect normalized-rune to source-byte boundaries in the same pass. +// offsets is reusable scratch owned by the caller so ordinary tokens do not reallocate it. +// NOLINTNEXTLINE(readability-function-cognitive-complexity): ICU UTF-8 macros expand to branches. +void regularize_with_source_byte_offsets(std::string& token, bool lowercase, + std::vector& offsets) { + const auto length = static_cast(token.size()); + std::string normalized; + normalized.reserve(token.size()); + offsets.clear(); + offsets.push_back(0); + int32_t offset = 0; + while (offset < length) { + const int32_t source_start = offset; + UChar32 codepoint; + U8_NEXT(token.c_str(), offset, length, codepoint); + if (codepoint < 0) { + normalized.append(token, source_start, offset - source_start); + offsets.push_back(offset); + continue; + } + UChar32 regularized = CharacterUtil::regularize(codepoint, false); + if (lowercase && regularized >= 'A' && regularized <= 'Z') { + regularized += 'a' - 'A'; + } + char encoded[U8_MAX_LENGTH]; + int32_t encoded_length = 0; + U8_APPEND_UNSAFE(encoded, encoded_length, regularized); + normalized.append(encoded, encoded_length); + offsets.push_back(offset); + } + token = std::move(normalized); +} + +} // namespace + IKTokenizer::IKTokenizer(std::shared_ptr config, bool lower_case, bool own_reader) { this->lowercase = lower_case; this->ownReader = own_reader; @@ -31,27 +75,89 @@ Token* IKTokenizer::next(Token* token) { return nullptr; } - std::string& token_text = tokens_text_[buffer_index_++]; + TokenData& token_data = tokens_[buffer_index_++]; // full-width to half-width, and lowercase // TODO(ryan19929): do regularizeString in fillBuffer. - CharacterUtil::regularizeString(token_text, this->lowercase); - size_t size = std::min(token_text.size(), static_cast(LUCENE_MAX_WORD_LEN)); - token->setNoCopy(token_text.data(), 0, static_cast(size)); + if (source_byte_offsets_enabled_) { + regularize_with_source_byte_offsets(token_data.text, this->lowercase, + current_source_byte_offsets_); + } else { + CharacterUtil::regularizeString(token_data.text, this->lowercase); + current_source_byte_offsets_.clear(); + } + current_token_ = &token_data; + const int32_t corrected_start = + source_char_filter_ == nullptr + ? token_data.start_offset + : source_char_filter_->correct_start_offset(token_data.start_offset); + if (source_char_filter_ != nullptr && source_byte_offsets_enabled_) { + // The first boundary is the token start itself; later ones end the preceding rune. + for (size_t i = 1; i < current_source_byte_offsets_.size(); ++i) { + int32_t& offset = current_source_byte_offsets_[i]; + offset = source_char_filter_->correct_offset(token_data.start_offset + offset) - + corrected_start; + } + } + size_t published_size = token_data.text.size(); + size_t published_runes = current_source_byte_offsets_.size(); + if (published_size > static_cast(LUCENE_MAX_WORD_LEN)) { + std::tie(published_size, published_runes) = + utf8_prefix_at_most(token_data.text, static_cast(LUCENE_MAX_WORD_LEN)); + } + set(token, std::string_view(token_data.text.data(), published_size)); + token->setStartOffset(corrected_start); + if (source_byte_offsets_enabled_ && published_size < token_data.text.size()) { + DORIS_CHECK_LT(published_runes, current_source_byte_offsets_.size()); + current_source_byte_offsets_.resize(published_runes + 1); + // A clipped term represents only this source prefix, so its end offset must not claim the + // unpublished suffix. The provenance vector uses the same exclusive source boundary. + token->setEndOffset(corrected_start + current_source_byte_offsets_.back()); + } else { + token->setEndOffset(source_char_filter_ == nullptr + ? token_data.end_offset + : source_char_filter_->correct_offset(token_data.end_offset)); + } + if (source_byte_offsets_enabled_) { + // Char-filter expansions can repeat a corrected boundary; publish through the shared + // path so such runes keep a conservative span instead of an empty one. + publish_source_byte_offsets(static_cast(current_source_byte_offsets_.size()) - 1, + current_source_byte_offsets_); + } return token; } +void IKTokenizer::reset() { + if (_in_pending == nullptr) { + return; + } + inverted_index::DorisTokenizer::reset(); + _in_pending.reset(); + reset(_in.get()); +} + void IKTokenizer::reset(lucene::util::Reader* reader) { + _in_pending.reset(); this->input = reader; + source_char_filter_ = dynamic_cast(reader); this->buffer_index_ = 0; this->data_length_ = 0; - this->tokens_text_.clear(); + this->tokens_.clear(); + this->current_token_ = nullptr; + this->current_source_byte_offsets_.clear(); + inverted_index::release_oversized_scratch(this->current_source_byte_offsets_); + _source_byte_offsets.clear(); + _source_byte_end_offsets.clear(); try { buffer_.reserve(input->size()); ik_segmenter_->reset(reader); Lexeme lexeme; while (ik_segmenter_->next(lexeme)) { - tokens_text_.emplace_back(lexeme.getText()); + TokenData token_data { + .text = lexeme.getText(), + .start_offset = static_cast(lexeme.getByteBeginPosition()), + .end_offset = static_cast(lexeme.getByteEndPosition())}; + tokens_.push_back(std::move(token_data)); } } catch (const CLuceneError&) { throw; @@ -60,7 +166,7 @@ void IKTokenizer::reset(lucene::util::Reader* reader) { _CLTHROWT(CL_ERR_Runtime, ("Uncaught exception in IKTokenizer: " + std::string(e.what())).c_str()); } - data_length_ = static_cast(tokens_text_.size()); + data_length_ = static_cast(tokens_.size()); } } // namespace doris::segment_v2 diff --git a/be/src/storage/index/inverted/analyzer/ik/IKTokenizer.h b/be/src/storage/index/inverted/analyzer/ik/IKTokenizer.h index 7031698d385e5a..8a1598eb727ae1 100644 --- a/be/src/storage/index/inverted/analyzer/ik/IKTokenizer.h +++ b/be/src/storage/index/inverted/analyzer/ik/IKTokenizer.h @@ -24,27 +24,46 @@ #include "CLucene/analysis/AnalysisHeader.h" #include "storage/index/inverted/analyzer/ik/cfg/Configuration.h" #include "storage/index/inverted/analyzer/ik/core/IKSegmenter.h" +#include "storage/index/inverted/tokenizer/tokenizer.h" using namespace lucene::analysis; namespace doris::segment_v2 { +namespace inverted_index { +class DorisCharFilter; +} -class IKTokenizer : public Tokenizer { +class IKTokenizer : public inverted_index::DorisTokenizer { public: IKTokenizer(); IKTokenizer(std::shared_ptr config, bool lowercase, bool ownReader); ~IKTokenizer() override = default; Token* next(Token* token) override; + void reset() override; void reset(lucene::util::Reader* reader) override; + void set_source_byte_offsets_enabled(bool enabled) override { + source_byte_offsets_enabled_ = enabled; + inverted_index::DorisTokenizer::set_source_byte_offsets_enabled(enabled); + } private: + struct TokenData { + std::string text; + int32_t start_offset; + int32_t end_offset; + }; + int32_t buffer_index_ {0}; int32_t data_length_ {0}; std::string buffer_; - std::vector tokens_text_; + std::vector tokens_; std::shared_ptr config_; std::unique_ptr ik_segmenter_; + TokenData* current_token_ {nullptr}; + std::vector current_source_byte_offsets_; + const inverted_index::DorisCharFilter* source_char_filter_ {nullptr}; + bool source_byte_offsets_enabled_ {false}; }; } // namespace doris::segment_v2 diff --git a/be/src/storage/index/inverted/analyzer/ik/core/AnalyzeContext.cpp b/be/src/storage/index/inverted/analyzer/ik/core/AnalyzeContext.cpp index e2d518e598b784..c1246414fc0308 100644 --- a/be/src/storage/index/inverted/analyzer/ik/core/AnalyzeContext.cpp +++ b/be/src/storage/index/inverted/analyzer/ik/core/AnalyzeContext.cpp @@ -184,7 +184,7 @@ bool AnalyzeContext::needRefillBuffer() const { } void AnalyzeContext::markBufferOffset() { - buffer_offset_ += typed_runes_[cursor_].offset; + buffer_offset_ += typed_runes_[cursor_].getNextBytePosition(); } void AnalyzeContext::lockBuffer(SegmenterType type) { @@ -294,4 +294,4 @@ void AnalyzeContext::outputSingleCJK(size_t index) { } } -} // namespace doris::segment_v2 \ No newline at end of file +} // namespace doris::segment_v2 diff --git a/be/src/storage/index/inverted/char_filter/char_filter.h b/be/src/storage/index/inverted/char_filter/char_filter.h index 5d4a2fec4ddf0a..5e61f798bfd21d 100644 --- a/be/src/storage/index/inverted/char_filter/char_filter.h +++ b/be/src/storage/index/inverted/char_filter/char_filter.h @@ -31,6 +31,24 @@ class DorisCharFilter : public lucene::util::Reader { virtual void initialize() = 0; + virtual int32_t correct_offset(int32_t current_offset) const { + if (const auto* nested = dynamic_cast(_reader.get()); + nested != nullptr) { + return nested->correct_offset(current_offset); + } + return current_offset; + } + + // Map an offset that starts a term. A position inside an expanded edit belongs to the source + // start of that edit, while correct_offset() maps it to the edit's source end. + virtual int32_t correct_start_offset(int32_t current_offset) const { + if (const auto* nested = dynamic_cast(_reader.get()); + nested != nullptr) { + return nested->correct_start_offset(current_offset); + } + return current_offset; + } + int64_t position() override { throw Exception(ErrorCode::INVERTED_INDEX_NOT_SUPPORTED, "CharFilter::position"); } @@ -48,4 +66,4 @@ class DorisCharFilter : public lucene::util::Reader { }; using CharFilterPtr = std::shared_ptr; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/char_filter/icu_normalizer_char_filter.cpp b/be/src/storage/index/inverted/char_filter/icu_normalizer_char_filter.cpp index 35d882e72380d5..9d66166e587142 100644 --- a/be/src/storage/index/inverted/char_filter/icu_normalizer_char_filter.cpp +++ b/be/src/storage/index/inverted/char_filter/icu_normalizer_char_filter.cpp @@ -17,8 +17,12 @@ #include "storage/index/inverted/char_filter/icu_normalizer_char_filter.h" +#include #include -#include +#include + +#include +#include #include "common/exception.h" #include "common/logging.h" @@ -59,33 +63,68 @@ void ICUNormalizerCharFilter::fill() { input.resize(_reader->size()); _reader->readCopy(input.data(), 0, static_cast(input.size())); normalize_text(input, _buf); + _source_length = static_cast(input.size()); + _offset_cursor = _edits.getFineIterator(); _transformed_input.init(_buf.data(), static_cast(_buf.size()), false); } void ICUNormalizerCharFilter::normalize_text(const std::string& input, std::string& output) { output.clear(); + _edits.reset(); if (input.empty()) { return; } UErrorCode status = U_ZERO_ERROR; - icu::UnicodeString src16 = icu::UnicodeString::fromUTF8(input); - UNormalizationCheckResult quick_result = _normalizer->quickCheck(src16, status); - if (U_SUCCESS(status) && quick_result == UNORM_YES) { + icu::StringByteSink sink(&output); + _normalizer->normalizeUTF8(0, icu::StringPiece(input), sink, &_edits, status); + if (U_FAILURE(status)) { + LOG(WARNING) << "ICU normalize failed: " << u_errorName(status) << ", using original text"; output = input; + _edits.reset(); + _edits.addUnchanged(static_cast(input.size())); return; } +} - icu::UnicodeString result16; - status = U_ZERO_ERROR; - _normalizer->normalize(src16, result16, status); +int32_t ICUNormalizerCharFilter::correct_offset(int32_t current_offset) const { + if (current_offset < 0) { + return DorisCharFilter::correct_offset(current_offset); + } + const auto destination_length = static_cast(_buf.size()); + if (current_offset >= destination_length) { + return DorisCharFilter::correct_offset(_source_length + + (current_offset - destination_length)); + } + + // Offsets at the start of an edit map to its source start, offsets inside an edit map to + // its source end, and unchanged text keeps its relative position. + UErrorCode status = U_ZERO_ERROR; + const int32_t source_offset = + _offset_cursor.sourceIndexFromDestinationIndex(current_offset, status); if (U_FAILURE(status)) { - LOG(WARNING) << "ICU normalize failed: " << u_errorName(status) << ", using original text"; - output = input; - return; + return DorisCharFilter::correct_offset(current_offset); + } + return DorisCharFilter::correct_offset(source_offset); +} + +int32_t ICUNormalizerCharFilter::correct_start_offset(int32_t current_offset) const { + const auto destination_length = static_cast(_buf.size()); + if (current_offset < 0 || current_offset >= destination_length) { + return DorisCharFilter::correct_start_offset(correct_offset(current_offset)); } - result16.toUTF8String(output); + // Inside a changed edit the term starts with output of that whole edit, so it maps to the + // edit's source start; elsewhere this matches correct_offset(). + UErrorCode status = U_ZERO_ERROR; + if (!_offset_cursor.findDestinationIndex(current_offset, status) || U_FAILURE(status)) { + return correct_offset(current_offset); + } + int32_t source_offset = _offset_cursor.sourceIndex(); + if (!_offset_cursor.hasChange()) { + source_offset += current_offset - _offset_cursor.destinationIndex(); + } + return DorisCharFilter::correct_start_offset(source_offset); } } // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/char_filter/icu_normalizer_char_filter.h b/be/src/storage/index/inverted/char_filter/icu_normalizer_char_filter.h index 2141eb2567eb87..7b55bac982df82 100644 --- a/be/src/storage/index/inverted/char_filter/icu_normalizer_char_filter.h +++ b/be/src/storage/index/inverted/char_filter/icu_normalizer_char_filter.h @@ -17,9 +17,11 @@ #pragma once +#include #include #include +#include #include "storage/index/inverted/char_filter/char_filter.h" @@ -37,12 +39,19 @@ class ICUNormalizerCharFilter : public DorisCharFilter { int32_t readCopy(void* start, int32_t off, int32_t len) override; size_t size() override { return _buf.size(); } + int32_t correct_offset(int32_t current_offset) const override; + int32_t correct_start_offset(int32_t current_offset) const override; private: void fill(); void normalize_text(const std::string& input, std::string& output); std::shared_ptr _normalizer; + // ICU's own edit encoding is the only per-change state; the cursor caches the last + // search position so mostly increasing offset queries stay cheap. + icu::Edits _edits; + mutable icu::Edits::Iterator _offset_cursor; + int32_t _source_length = 0; std::string _buf; lucene::util::SStringReader _transformed_input; }; diff --git a/be/src/storage/index/inverted/inverted_index_iterator.cpp b/be/src/storage/index/inverted/inverted_index_iterator.cpp index 9bd7800e7e0ccc..85b864b4c56e7e 100644 --- a/be/src/storage/index/inverted/inverted_index_iterator.cpp +++ b/be/src/storage/index/inverted/inverted_index_iterator.cpp @@ -21,6 +21,7 @@ #include "common/cast_set.h" #include "common/logging.h" +#include "storage/index/index_reader_helper.h" #include "storage/index/inverted/inverted_index_cache.h" #include "storage/index/inverted/inverted_index_parser.h" #include "storage/index/inverted/inverted_index_reader.h" @@ -69,12 +70,23 @@ Status InvertedIndexIterator::read_from_index(const IndexParam& param) { // The execution context carries reader selection separately from analyzer execution. const std::string& analyzer_key = (i_param->analyzer_ctx != nullptr) ? i_param->analyzer_ctx->analyzer_key : ""; - auto reader = - DORIS_TRY(select_best_reader(i_param->column_type, i_param->query_type, analyzer_key)); + const std::string& legacy_analyzer_key = + (i_param->analyzer_ctx != nullptr) ? i_param->analyzer_ctx->legacy_analyzer_key : ""; + auto reader = DORIS_TRY(select_best_reader(i_param->column_type, i_param->query_type, + analyzer_key, legacy_analyzer_key)); if (UNLIKELY(reader == nullptr)) { return Status::Error( "inverted index reader is null"); } + // Check the reader that runs the query, not the first candidate of its type, because the + // analyzer decides which index is selected and the two can disagree on support_phrase. Only + // a tokenized index stores positions, so an untokenized one runs a phrase as a whole term. + if (is_phrase_query(i_param->query_type) && + reader->type() == InvertedIndexReaderType::FULLTEXT && + !IndexReaderHelper::is_support_phrase(reader)) { + return Status::Error( + "phrase queries require setting support_phrase = true"); + } auto* runtime_state = _context->runtime_state; if (!i_param->skip_try && reader->type() == InvertedIndexReaderType::BKD) { if (runtime_state != nullptr && @@ -150,7 +162,7 @@ Status InvertedIndexIterator::try_read_from_inverted_index(const InvertedIndexRe Result InvertedIndexIterator::select_best_reader( const DataTypePtr& column_type, InvertedIndexQueryType query_type, - const std::string& analyzer_key) { + const std::string& analyzer_key, const std::string& legacy_analyzer_key) { const std::string normalized_key = ensure_normalized_key(analyzer_key); // The column type only disambiguates between several indexes on the same field; with a // single candidate the selection is already determined. Callers that have no runtime type @@ -164,8 +176,9 @@ Result InvertedIndexIterator::select_best_reader( } field_type = get_inverted_index_leaf_field_type(column_type); } - auto selection = select_best_inverted_index_candidate(_selection_candidates, _key_to_entries, - field_type, query_type, normalized_key); + auto selection = + select_best_inverted_index_candidate(_selection_candidates, _key_to_entries, field_type, + query_type, normalized_key, legacy_analyzer_key); if (!selection.has_value()) { return ResultError(std::move(selection.error())); } diff --git a/be/src/storage/index/inverted/inverted_index_iterator.h b/be/src/storage/index/inverted/inverted_index_iterator.h index 5f7c2250cc0836..44ab0f4ad1c579 100644 --- a/be/src/storage/index/inverted/inverted_index_iterator.h +++ b/be/src/storage/index/inverted/inverted_index_iterator.h @@ -60,7 +60,7 @@ class InvertedIndexIterator : public IndexIterator { [[nodiscard]] Result select_best_reader( const DataTypePtr& column_type, InvertedIndexQueryType query_type, - const std::string& analyzer_key); + const std::string& analyzer_key, const std::string& legacy_analyzer_key = ""); [[nodiscard]] Result select_any_reader(); @@ -91,4 +91,4 @@ class InvertedIndexIterator : public IndexIterator { InvertedIndexSelectionKeyIndex _key_to_entries; }; -} // namespace doris::segment_v2 \ No newline at end of file +} // namespace doris::segment_v2 diff --git a/be/src/storage/index/inverted/inverted_index_parser.cpp b/be/src/storage/index/inverted/inverted_index_parser.cpp index f92ae7eefc4a9c..0339ba4a48a236 100644 --- a/be/src/storage/index/inverted/inverted_index_parser.cpp +++ b/be/src/storage/index/inverted/inverted_index_parser.cpp @@ -17,7 +17,12 @@ #include "storage/index/inverted/inverted_index_parser.h" +#include + +#include + #include "common/config.h" +#include "storage/index/inverted/analyzer/analyzer.h" #include "storage/tablet/tablet_schema.h" #include "util/string_util.h" @@ -211,62 +216,90 @@ std::string get_analyzer_name_from_properties( } std::string normalize_analyzer_key(std::string_view analyzer) { - if (analyzer.empty()) { - return ""; - } - return to_lower(std::string(analyzer)); + return std::string(analyzer); } -std::string build_analyzer_key_from_properties( - const std::map& properties) { - const auto analyzer_name = get_analyzer_name_from_properties(properties); - if (!analyzer_name.empty()) { - return normalize_analyzer_key(analyzer_name); +namespace { + +constexpr std::string_view ENCODED_ANALYZER_KEY_PREFIX = "#analysis:"; + +std::string append_char_filter_key(std::string key, const CharFilterMap& char_filter_map, + bool lowercase_ik) { + if (char_filter_map.empty()) { + return key; + } + DORIS_CHECK_EQ(char_filter_map.at(INVERTED_INDEX_PARSER_CHAR_FILTER_TYPE), + INVERTED_INDEX_CHAR_FILTER_CHAR_REPLACE); + std::string pattern = char_filter_map.at(INVERTED_INDEX_PARSER_CHAR_FILTER_PATTERN); + const auto& replacement = char_filter_map.at(INVERTED_INDEX_PARSER_CHAR_FILTER_REPLACEMENT); + DORIS_CHECK_EQ(replacement.size(), 1); + const char replacement_byte = replacement.front(); + std::erase_if(pattern, [replacement_byte, lowercase_ik](char byte) { + return byte == replacement_byte || + (lowercase_ik && replacement_byte >= 'a' && replacement_byte <= 'z' && + byte == replacement_byte - ('a' - 'A')); + }); + std::ranges::sort(pattern); + pattern.erase(std::ranges::unique(pattern).begin(), pattern.end()); + if (pattern.empty()) { + return key; } + return fmt::format("{}char_replace={}:{}:{}:{}:{}:{};", ENCODED_ANALYZER_KEY_PREFIX, key.size(), + key, pattern.size(), pattern, replacement.size(), replacement); +} - std::string parser; - auto parser_it = properties.find(INVERTED_INDEX_PARSER_KEY); - if (parser_it != properties.end()) { - parser = parser_it->second; - } else { - parser_it = properties.find(INVERTED_INDEX_PARSER_KEY_ALIAS); - if (parser_it != properties.end()) { - parser = parser_it->second; - } +std::string build_selection_key(std::string key, const std::string& parser_mode, bool lowercase, + const CharFilterMap& char_filter_map) { + const bool builtin_ik = key == INVERTED_INDEX_PARSER_IK; + if (builtin_ik) { + key = fmt::format("{}ik|mode={}|lower_case={}", ENCODED_ANALYZER_KEY_PREFIX, + parser_mode == INVERTED_INDEX_PARSER_SMART + ? INVERTED_INDEX_PARSER_SMART + : INVERTED_INDEX_PARSER_MAX_WORD, + lowercase); + } else if (key.starts_with(ENCODED_ANALYZER_KEY_PREFIX)) { + // Keep arbitrary policy names separate from encoded index configurations. + key = fmt::format("{}name={}:{}", ENCODED_ANALYZER_KEY_PREFIX, key.size(), key); } + return append_char_filter_key(std::move(key), char_filter_map, builtin_ik && lowercase); +} - if (parser.empty()) { - return INVERTED_INDEX_PARSER_NONE; +} // namespace + +std::string build_analyzer_key_from_properties( + const std::map& properties) { + auto key = get_analyzer_name_from_properties(properties); + if (key.empty()) { + key = to_lower(get_parser_string_from_properties(properties)); + if (key.empty()) { + key = INVERTED_INDEX_PARSER_NONE; + } } - return normalize_analyzer_key(parser); + return build_selection_key( + std::move(key), get_parser_mode_string_from_properties(properties), + get_parser_lowercase_from_properties(properties) != INVERTED_INDEX_PARSER_FALSE, + get_parser_char_filter_map_from_properties(properties)); } // ============================================================================ // AnalyzerConfigParser implementation // ============================================================================ -std::string AnalyzerConfigParser::normalize_to_lower(const std::string& value) { - return to_lower(value); -} - -bool AnalyzerConfigParser::is_builtin_analyzer(const std::string& normalized_name) { - if (normalized_name.empty()) { - return false; - } - auto parser_type = get_inverted_index_parser_type_from_string(normalized_name); - return parser_type != InvertedIndexParserType::PARSER_UNKNOWN; +bool AnalyzerConfigParser::is_builtin_analyzer(const std::string& analyzer_name) { + return segment_v2::inverted_index::InvertedIndexAnalyzer::is_builtin_analyzer(analyzer_name); } AnalyzerConfig AnalyzerConfigParser::parse(const std::string& analyzer_name, - const std::string& parser_type_str) { + const std::string& parser_type_str, + const std::string& parser_mode, bool lowercase, + const CharFilterMap& char_filter_map) { AnalyzerConfig config; - const std::string normalized_analyzer = normalize_to_lower(analyzer_name); - const bool analyzer_is_builtin = is_builtin_analyzer(normalized_analyzer); - if (!normalized_analyzer.empty()) { - config.analyzer_key = normalize_to_lower(analyzer_name); - if (analyzer_is_builtin) { - config.parser_type = get_inverted_index_parser_type_from_string(normalized_analyzer); + if (!analyzer_name.empty()) { + config.analyzer_key = + build_selection_key(analyzer_name, parser_mode, lowercase, char_filter_map); + if (is_builtin_analyzer(analyzer_name)) { + config.parser_type = get_inverted_index_parser_type_from_string(analyzer_name); } else { config.provider_name = analyzer_name; config.parser_type = InvertedIndexParserType::PARSER_NONE; diff --git a/be/src/storage/index/inverted/inverted_index_parser.h b/be/src/storage/index/inverted/inverted_index_parser.h index 329a22570e17df..7a9523b28aeac3 100644 --- a/be/src/storage/index/inverted/inverted_index_parser.h +++ b/be/src/storage/index/inverted/inverted_index_parser.h @@ -112,16 +112,18 @@ const std::string INVERTED_INDEX_ANALYZER_NAME_KEY = "analyzer"; const std::string INVERTED_INDEX_NORMALIZER_NAME_KEY = "normalizer"; const std::string INVERTED_INDEX_PARSER_FIELD_PATTERN_KEY = "field_pattern"; -// Normalize a physical analyzer selection key to lowercase. Empty stays empty. +// Preserve resolved policy names as exact keys. FE canonicalizes built-in names. std::string normalize_analyzer_key(std::string_view analyzer); // Runtime context for analyzer // Contains only the fields needed at runtime struct InvertedIndexAnalyzerCtx { - // Physical reader selection key from Thrift. Empty allows fallback selection; - // non-empty requires an exact match. + // Physical reader selection key. Empty allows fallback selection. std::string analyzer_key; + // Optional lowercase metadata key verified against the same analyzer policy. + std::string legacy_analyzer_key; + // Named custom analyzer or normalizer used to execute the predicate. std::string analyzer_name; @@ -195,8 +197,8 @@ std::string get_parser_dict_compression_from_properties( std::string get_analyzer_name_from_properties(const std::map& properties); -// Build a normalized analyzer key from index properties. -// Precedence is analyzer, normalizer, then parser type. A raw index uses "none". +// Build an exact analyzer key from index properties. +// Include IK mode/lowercase and effective outer character filters to distinguish physical readers. std::string build_analyzer_key_from_properties( const std::map& properties); @@ -204,7 +206,7 @@ std::string build_analyzer_key_from_properties( struct AnalyzerConfig { std::string provider_name; InvertedIndexParserType parser_type = InvertedIndexParserType::PARSER_NONE; - // Physical reader selection key from the Thrift analyzer name. + // Physical reader selection key from the Thrift analyzer configuration. // Empty allows fallback selection; non-empty requires an exact match. std::string analyzer_key; @@ -212,22 +214,20 @@ struct AnalyzerConfig { bool uses_provider() const { return !provider_name.empty(); } }; -// Parser for analyzer configuration from Thrift TMatchPredicate. -// Extracts analyzer_name and parser_type_str, determines if builtin or custom, -// and produces a normalized AnalyzerConfig. +// Parse resolved analyzer names and legacy parser types from Thrift TMatchPredicate. class AnalyzerConfigParser { public: - // Parse from raw analyzer name and parser type string (extracted from Thrift). + // Parse the resolved analyzer name and legacy parser type from Thrift. // @param analyzer_name: Analyzer selection name from Thrift (custom, builtin, or empty). // @param parser_type_str: Parser type string like "chinese", "standard", etc. [[nodiscard]] static AnalyzerConfig parse(const std::string& analyzer_name, - const std::string& parser_type_str); - - // Check if a normalized analyzer name looks like a builtin parser type - [[nodiscard]] static bool is_builtin_analyzer(const std::string& normalized_name); + const std::string& parser_type_str, + const std::string& parser_mode = "", + bool lowercase = true, + const CharFilterMap& char_filter_map = {}); -private: - static std::string normalize_to_lower(const std::string& value); + // Use the writer's case-sensitive built-in dispatch. + [[nodiscard]] static bool is_builtin_analyzer(const std::string& analyzer_name); }; } // namespace doris diff --git a/be/src/storage/index/inverted/inverted_index_query_type.h b/be/src/storage/index/inverted/inverted_index_query_type.h index 8fa1a0f9059656..00330ebab25a82 100644 --- a/be/src/storage/index/inverted/inverted_index_query_type.h +++ b/be/src/storage/index/inverted/inverted_index_query_type.h @@ -105,6 +105,13 @@ inline bool is_match_query(InvertedIndexQueryType query_type) { query_type == InvertedIndexQueryType::MATCH_PHRASE_EDGE_QUERY); } +// Query types that read term positions and so need an index built with support_phrase. +inline bool is_phrase_query(InvertedIndexQueryType query_type) { + return (query_type == InvertedIndexQueryType::MATCH_PHRASE_QUERY || + query_type == InvertedIndexQueryType::MATCH_PHRASE_PREFIX_QUERY || + query_type == InvertedIndexQueryType::MATCH_PHRASE_EDGE_QUERY); +} + inline std::string query_type_to_string(InvertedIndexQueryType query_type) { switch (query_type) { case InvertedIndexQueryType::UNKNOWN_QUERY: { diff --git a/be/src/storage/index/inverted/inverted_index_reader.cpp b/be/src/storage/index/inverted/inverted_index_reader.cpp index a119992538b5e5..8c6e5ea40d91ff 100644 --- a/be/src/storage/index/inverted/inverted_index_reader.cpp +++ b/be/src/storage/index/inverted/inverted_index_reader.cpp @@ -484,6 +484,9 @@ Status FullTextIndexReader::query(const IndexQueryContextPtr& context, } catch (const CLuceneError& e) { return Status::Error( "CLuceneError occurred, error msg: {}", e.what()); + } catch (const Exception& e) { + return Status::Error( + "Analyzer error occurred, error msg: {}", e.what()); } } diff --git a/be/src/storage/index/inverted/inverted_index_selector.cpp b/be/src/storage/index/inverted/inverted_index_selector.cpp index dac0ec9b41b1cb..94e2299238fe4e 100644 --- a/be/src/storage/index/inverted/inverted_index_selector.cpp +++ b/be/src/storage/index/inverted/inverted_index_selector.cpp @@ -49,7 +49,8 @@ Status add_inverted_index_selection_candidate( Result select_best_inverted_index_candidate( const std::vector& candidates, const InvertedIndexSelectionKeyIndex& key_index, FieldType field_type, - InvertedIndexQueryType query_type, std::string_view normalized_analyzer_key) { + InvertedIndexQueryType query_type, std::string_view normalized_analyzer_key, + std::string_view legacy_analyzer_key) { if (candidates.empty()) { return ResultError(Status::Error( "No available inverted index candidates")); @@ -57,7 +58,10 @@ Result select_best_inverted_index_candidate( const std::vector* exact_candidates = nullptr; if (!normalized_analyzer_key.empty()) { - const auto exact = key_index.find(std::string(normalized_analyzer_key)); + auto exact = key_index.find(std::string(normalized_analyzer_key)); + if (exact == key_index.end() && !legacy_analyzer_key.empty()) { + exact = key_index.find(std::string(legacy_analyzer_key)); + } if (exact == key_index.end() || exact->second.empty()) { return ResultError(Status::Error( "No inverted index found for analyzer '{}'", normalized_analyzer_key)); diff --git a/be/src/storage/index/inverted/inverted_index_selector.h b/be/src/storage/index/inverted/inverted_index_selector.h index c295a9db3bc33e..0ab13dd54ca053 100644 --- a/be/src/storage/index/inverted/inverted_index_selector.h +++ b/be/src/storage/index/inverted/inverted_index_selector.h @@ -47,7 +47,8 @@ Status add_inverted_index_selection_candidate( [[nodiscard]] Result select_best_inverted_index_candidate( const std::vector& candidates, const InvertedIndexSelectionKeyIndex& key_index, FieldType field_type, - InvertedIndexQueryType query_type, std::string_view normalized_analyzer_key); + InvertedIndexQueryType query_type, std::string_view normalized_analyzer_key, + std::string_view legacy_analyzer_key = {}); FieldType get_inverted_index_leaf_field_type(const DataTypePtr& column_type); diff --git a/be/src/storage/index/inverted/inverted_index_writer.cpp b/be/src/storage/index/inverted/inverted_index_writer.cpp index ed03225fae6f13..7c67f817aa2763 100644 --- a/be/src/storage/index/inverted/inverted_index_writer.cpp +++ b/be/src/storage/index/inverted/inverted_index_writer.cpp @@ -254,6 +254,10 @@ Status InvertedIndexColumnWriter::add_document() { close_on_error(); return Status::Error( "CLuceneError add_document: {}", e.what()); + } catch (const Exception& e) { + close_on_error(); + return Status::Error( + "Analyzer error while adding document: {}", e.what()); } return Status::OK(); } @@ -329,6 +333,9 @@ Status InvertedIndexColumnWriter::new_inverted_index_field(const cha } catch (const CLuceneError& e) { return Status::Error( "CLuceneError create new index field error: {}", e.what()); + } catch (const Exception& e) { + return Status::Error( + "Analyzer error while creating new index field: {}", e.what()); } return Status::OK(); } @@ -450,16 +457,29 @@ Status InvertedIndexColumnWriter::add_array_values(size_t field_size return st; } if (_should_analyzer) { - // in this case stream need to delete after add_document, because the - // stream can not reuse for different field - bool own_token_stream = true; - ReaderPtr char_string_reader = DORIS_TRY( - create_char_string_reader(_analyzer_config.char_filter_map)); - char_string_reader->init(v->get_data(), cast_set(v->get_size()), - false); - ts = _analyzer->tokenStream(new_field->name(), char_string_reader); - new_field->setValue(ts, own_token_stream); - keep_readers.emplace_back(std::move(char_string_reader)); + try { + // Each array field needs an owned stream because streams cannot be + // reused across fields. + bool own_token_stream = true; + ReaderPtr char_string_reader = DORIS_TRY( + create_char_string_reader(_analyzer_config.char_filter_map)); + char_string_reader->init(v->get_data(), + cast_set(v->get_size()), false); + ts = _analyzer->tokenStream(new_field->name(), char_string_reader); + new_field->setValue(ts, own_token_stream); + keep_readers.emplace_back(std::move(char_string_reader)); + } catch (const CLuceneError& e) { + _doc->clear(); + close_on_error(); + return Status::Error( + "CLuceneError while creating array index field: {}", e.what()); + } catch (const Exception& e) { + _doc->clear(); + close_on_error(); + return Status::Error( + "Analyzer error while creating array index field: {}", + e.what()); + } } else { new_field_char_value(v->get_data(), v->get_size(), new_field.get()); } diff --git a/be/src/storage/index/inverted/inverted_index_writer.h b/be/src/storage/index/inverted/inverted_index_writer.h index c50a42e520b73f..149516808d3e58 100644 --- a/be/src/storage/index/inverted/inverted_index_writer.h +++ b/be/src/storage/index/inverted/inverted_index_writer.h @@ -76,6 +76,15 @@ class InvertedIndexColumnWriter : public IndexColumnWriter { void write_null_bitmap(lucene::store::IndexOutput* null_bitmap_out); Status finish() override; +#ifdef BE_TEST + void set_analysis_for_test(ReaderPtr reader, + std::shared_ptr analyzer) { + _should_analyzer = true; + _char_string_reader = std::move(reader); + _analyzer = std::move(analyzer); + } +#endif + private: rowid_t _rid = 0; uint32_t _row_ids_seen_for_bkd = 0; diff --git a/be/src/storage/index/inverted/similarity/predicate_collector.cpp b/be/src/storage/index/inverted/similarity/predicate_collector.cpp index bb6e3fc4dd6c00..282cdc2c3ceabf 100644 --- a/be/src/storage/index/inverted/similarity/predicate_collector.cpp +++ b/be/src/storage/index/inverted/similarity/predicate_collector.cpp @@ -41,7 +41,7 @@ using namespace segment_v2; namespace { -InvertedIndexAnalyzerCtx analyzer_context_from_properties( +InvertedIndexAnalyzerCtx build_analyzer_context( const std::map& properties) { InvertedIndexAnalyzerConfig config; config.analyzer_name = get_analyzer_name_from_properties(properties); @@ -61,14 +61,40 @@ InvertedIndexAnalyzerCtx analyzer_context_from_properties( return analyzer_ctx; } -std::vector analyze_plain_query(const std::string& value, - const InvertedIndexAnalyzerCtx& analyzer_ctx) { +} // namespace + +Result analyzer_context_from_properties( + const std::map& properties) { + // Replayed components can collide across policy families, so building the provider throws. + try { + return build_analyzer_context(properties); + } catch (const CLuceneError& error) { + return ResultError(Status::Error( + "Build scoring analyzer failed: {}", error.what())); + } catch (const Exception& error) { + return ResultError(Status::Error( + "Build scoring analyzer failed: {}", error.what())); + } +} + +namespace { + +Result> analyze_plain_query(const std::string& value, + const InvertedIndexAnalyzerCtx& analyzer_ctx) { DORIS_CHECK(analyzer_ctx.analyzer_provider != nullptr); - auto analyzer = analyzer_ctx.analyzer_provider->get_analyzer(); - auto reader = - inverted_index::InvertedIndexAnalyzer::create_reader(analyzer_ctx.char_filter_map); - reader->init(value.data(), static_cast(value.size()), true); - return inverted_index::InvertedIndexAnalyzer::get_analyse_result(reader, analyzer.get()); + try { + auto analyzer = analyzer_ctx.analyzer_provider->get_analyzer(); + auto reader = + inverted_index::InvertedIndexAnalyzer::create_reader(analyzer_ctx.char_filter_map); + reader->init(value.data(), static_cast(value.size()), true); + return inverted_index::InvertedIndexAnalyzer::get_analyse_result(reader, analyzer.get()); + } catch (const CLuceneError& error) { + return ResultError(Status::Error( + "Analyze scoring query failed: {}", error.what())); + } catch (const Exception& error) { + return ResultError(Status::Error( + "Analyze scoring query failed: {}", error.what())); + } } Status append_scoring_leaf(CollectInfo* collect_info, const std::vector& term_infos) { @@ -126,7 +152,8 @@ InvertedIndexQueryType search_query_type(std::string_view clause_type) { Result select_index_meta(const std::vector& index_metas, FieldType field_type, InvertedIndexQueryType query_type, - std::string_view analyzer_key) { + std::string_view analyzer_key, + std::string_view legacy_analyzer_key = {}) { std::vector candidates; candidates.reserve(index_metas.size()); InvertedIndexSelectionKeyIndex key_index; @@ -144,7 +171,8 @@ Result select_index_meta(const std::vectorop()); DORIS_CHECK(query_type != InvertedIndexQueryType::UNKNOWN_QUERY); - const auto* index_meta = DORIS_TRY(select_index_meta( - candidates.index_metas, candidates.field_type, query_type, analyzer_ctx->analyzer_key)); + const auto* index_meta = DORIS_TRY( + select_index_meta(candidates.index_metas, candidates.field_type, query_type, + analyzer_ctx->analyzer_key, analyzer_ctx->legacy_analyzer_key)); if (!InvertedIndexAnalyzer::should_analyzer(index_meta->properties()) || !IndexReaderHelper::is_need_similarity_score(expr->op(), index_meta)) { return Status::OK(); @@ -386,7 +415,7 @@ Status MatchPredicateCollector::collect(RuntimeState* state, const TabletSchemaS DORIS_CHECK(analyzer_ctx->analyzer_provider != nullptr); auto options = DataTypeSerDe::get_default_format_options(); options.timezone = &state->timezone_obj(); - auto term_infos = analyze_plain_query(right_literal->value(options), *analyzer_ctx); + auto term_infos = DORIS_TRY(analyze_plain_query(right_literal->value(options), *analyzer_ctx)); if (expr->op() == TExprOpcode::MATCH_PHRASE_PREFIX && !term_infos.empty()) { term_infos.pop_back(); } @@ -516,20 +545,24 @@ Status SearchPredicateCollector::collect_from_leaf(const TSearchClause& clause, std::vector term_infos; std::optional analyzer_ctx; if (InvertedIndexAnalyzer::should_analyzer(analysis_properties)) { - analyzer_ctx.emplace(analyzer_context_from_properties(analysis_properties)); + auto built_ctx = analyzer_context_from_properties(analysis_properties); + if (!built_ctx.has_value()) { + return built_ctx.error(); + } + analyzer_ctx.emplace(std::move(built_ctx.value())); } if (clause_type == "MATCH") { term_infos.emplace_back(value); } else if (category == ClauseTypeCategory::TOKENIZED) { if (analyzer_ctx.has_value()) { - term_infos = analyze_plain_query(value, *analyzer_ctx); + term_infos = DORIS_TRY(analyze_plain_query(value, *analyzer_ctx)); } else { term_infos.emplace_back(value); } } else if (category == ClauseTypeCategory::NON_TOKENIZED) { if (clause_type == "TERM" && analyzer_ctx.has_value()) { - term_infos = analyze_plain_query(value, *analyzer_ctx); + term_infos = DORIS_TRY(analyze_plain_query(value, *analyzer_ctx)); } else { term_infos.emplace_back(value); } diff --git a/be/src/storage/index/inverted/similarity/predicate_collector.h b/be/src/storage/index/inverted/similarity/predicate_collector.h index 7cbbd2d1cd21eb..de3860c40b9ceb 100644 --- a/be/src/storage/index/inverted/similarity/predicate_collector.h +++ b/be/src/storage/index/inverted/similarity/predicate_collector.h @@ -18,6 +18,7 @@ #pragma once #include +#include #include #include #include @@ -31,6 +32,13 @@ namespace doris { +struct InvertedIndexAnalyzerCtx; + +// Build the analyzer context of an index, converting a failure to build the analyzer provider +// into a Status instead of letting the exception escape a Status-returning caller. +Result analyzer_context_from_properties( + const std::map& properties); + class VSlotRef; class TabletIndex; class TabletSchema; diff --git a/be/src/storage/index/inverted/token_filter/ascii_folding_filter.cpp b/be/src/storage/index/inverted/token_filter/ascii_folding_filter.cpp index de8354d3cba187..20f66daa53a7b4 100644 --- a/be/src/storage/index/inverted/token_filter/ascii_folding_filter.cpp +++ b/be/src/storage/index/inverted/token_filter/ascii_folding_filter.cpp @@ -22,10 +22,32 @@ namespace doris::segment_v2::inverted_index { +namespace { + +int32_t count_utf8_runes(std::string_view text) { + const auto length = static_cast(text.size()); + const char* data = text.data(); + int32_t offset = 0; + int32_t count = 0; + while (offset < length) { + UChar32 codepoint = U_UNASSIGNED; + U8_NEXT(data, offset, length, codepoint); + if (codepoint < 0) { + return -1; + } + ++count; + } + return count; +} + +} // namespace + ASCIIFoldingFilter::ASCIIFoldingFilter(const TokenStreamPtr& in, bool preserve_original) : DorisTokenFilter(in), _preserve_original(preserve_original), _output(512, 0) {} Token* ASCIIFoldingFilter::next(Token* t) { + _rune_count_changed = false; + _has_source_span = false; if (_state != std::nullopt) { assert(_preserve_original); set(t, std::string_view(_state->data(), _state->size()), 0); @@ -43,6 +65,18 @@ Token* ASCIIFoldingFilter::next(Token* t) { } if (c >= 0x0080) { fold_to_ascii(buffer, length); + // Rune counts only matter to a downstream provenance consumer. Malformed bytes + // are skipped while folding, so the upstream map no longer describes the output. + if (_source_byte_offsets_enabled) { + const int32_t input_runes = count_utf8_runes(std::string_view(buffer, length)); + _rune_count_changed = + input_runes < 0 || input_runes != count_utf8_runes(std::string_view( + _output.data(), _output_pos)); + } + if (_rune_count_changed) { + _has_source_span = + get_delegated_source_byte_span(*t, _source_start, _source_end); + } set_text(t, std::string_view(_output.data(), _output_pos)); break; } @@ -55,6 +89,35 @@ Token* ASCIIFoldingFilter::next(Token* t) { void ASCIIFoldingFilter::reset() { DorisTokenFilter::reset(); _state = std::nullopt; + _rune_count_changed = false; + _has_source_span = false; +} + +std::span ASCIIFoldingFilter::get_source_byte_offsets() const { + return _rune_count_changed ? std::span {} + : DorisTokenFilter::get_source_byte_offsets(); +} + +std::span ASCIIFoldingFilter::get_source_byte_end_offsets() const { + return _rune_count_changed ? std::span {} + : DorisTokenFilter::get_source_byte_end_offsets(); +} + +bool ASCIIFoldingFilter::get_conservative_source_byte_span(int32_t& start, int32_t& end) const { + if (!_rune_count_changed) { + return DorisTokenFilter::get_conservative_source_byte_span(start, end); + } + if (!_has_source_span) { + return false; + } + start = _source_start; + end = _source_end; + return true; +} + +void ASCIIFoldingFilter::set_source_byte_offsets_enabled(bool enabled) { + _source_byte_offsets_enabled = enabled; + DorisTokenFilter::set_source_byte_offsets_enabled(enabled); } void ASCIIFoldingFilter::fold_to_ascii(const char* in, int32_t length) { @@ -2017,4 +2080,4 @@ int32_t ASCIIFoldingFilter::fold_to_ascii(const char* in, int32_t input_pos, cha return output_pos; } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/token_filter/ascii_folding_filter.h b/be/src/storage/index/inverted/token_filter/ascii_folding_filter.h index 71cc18788b3d50..5a3c3d5967847a 100644 --- a/be/src/storage/index/inverted/token_filter/ascii_folding_filter.h +++ b/be/src/storage/index/inverted/token_filter/ascii_folding_filter.h @@ -32,9 +32,18 @@ class ASCIIFoldingFilter : public DorisTokenFilter { Token* next(Token* t) override; void reset() override; + std::span get_source_byte_offsets() const override; + std::span get_source_byte_end_offsets() const override; + bool get_conservative_source_byte_span(int32_t& start, int32_t& end) const override; + void set_source_byte_offsets_enabled(bool enabled) override; + static int32_t fold_to_ascii(const char* in, int32_t input_pos, char* out, int32_t output_pos, int32_t length); +#ifdef BE_TEST + bool rune_count_changed_for_test() const { return _rune_count_changed; } +#endif + private: void fold_to_ascii(const char* in, int32_t length); bool need_to_preserve(const char* in, int32_t input_length); @@ -44,6 +53,11 @@ class ASCIIFoldingFilter : public DorisTokenFilter { bool _preserve_original = false; int32_t _output_pos; std::string _output; + int32_t _source_start = 0; + int32_t _source_end = 0; + bool _rune_count_changed = false; + bool _has_source_span = false; + bool _source_byte_offsets_enabled = false; }; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/token_filter/icu_normalizer_filter.cpp b/be/src/storage/index/inverted/token_filter/icu_normalizer_filter.cpp index 3c1ab666c01c70..f788a32988dd80 100644 --- a/be/src/storage/index/inverted/token_filter/icu_normalizer_filter.cpp +++ b/be/src/storage/index/inverted/token_filter/icu_normalizer_filter.cpp @@ -19,6 +19,7 @@ #include #include +#include #include "common/exception.h" #include "common/logging.h" @@ -35,6 +36,8 @@ ICUNormalizerFilter::ICUNormalizerFilter(TokenStreamPtr in, } Token* ICUNormalizerFilter::next(Token* t) { + _text_changed = false; + _normalized_source_length = 0; if (!_in->next(t)) { return nullptr; } @@ -60,6 +63,12 @@ Token* ICUNormalizerFilter::next(Token* t) { _output_buffer.clear(); result16.toUTF8String(_output_buffer); + _text_changed = std::string_view(buffer, length) != std::string_view(_output_buffer); + if (_text_changed && _source_byte_offsets_enabled) { + _normalized_source_length = t->endOffset() - t->startOffset(); + DORIS_CHECK_GE(_normalized_source_length, 0); + } + set_text(t, std::string_view(_output_buffer.data(), _output_buffer.size())); return t; @@ -67,6 +76,35 @@ Token* ICUNormalizerFilter::next(Token* t) { void ICUNormalizerFilter::reset() { DorisTokenFilter::reset(); + _text_changed = false; + _normalized_source_length = 0; +} + +std::span ICUNormalizerFilter::get_source_byte_offsets() const { + return _text_changed ? std::span {} + : DorisTokenFilter::get_source_byte_offsets(); +} + +std::span ICUNormalizerFilter::get_source_byte_end_offsets() const { + return _text_changed ? std::span {} + : DorisTokenFilter::get_source_byte_end_offsets(); +} + +bool ICUNormalizerFilter::get_conservative_source_byte_span(int32_t& start, int32_t& end) const { + if (!_text_changed) { + return DorisTokenFilter::get_conservative_source_byte_span(start, end); + } + if (!_source_byte_offsets_enabled) { + return false; + } + start = 0; + end = _normalized_source_length; + return true; +} + +void ICUNormalizerFilter::set_source_byte_offsets_enabled(bool enabled) { + _source_byte_offsets_enabled = enabled; + DorisTokenFilter::set_source_byte_offsets_enabled(enabled); } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/token_filter/icu_normalizer_filter.h b/be/src/storage/index/inverted/token_filter/icu_normalizer_filter.h index b5b924643deb97..b1584233d5128a 100644 --- a/be/src/storage/index/inverted/token_filter/icu_normalizer_filter.h +++ b/be/src/storage/index/inverted/token_filter/icu_normalizer_filter.h @@ -20,6 +20,7 @@ #include #include +#include #include #include "storage/index/inverted/token_filter/token_filter.h" @@ -34,10 +35,18 @@ class ICUNormalizerFilter : public DorisTokenFilter { Token* next(Token* t) override; void reset() override; + std::span get_source_byte_offsets() const override; + std::span get_source_byte_end_offsets() const override; + bool get_conservative_source_byte_span(int32_t& start, int32_t& end) const override; + void set_source_byte_offsets_enabled(bool enabled) override; + private: std::shared_ptr _normalizer; std::string _output_buffer; + int32_t _normalized_source_length = 0; + bool _text_changed = false; + bool _source_byte_offsets_enabled = false; }; using ICUNormalizerFilterPtr = std::shared_ptr; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/token_filter/lower_case_filter.h b/be/src/storage/index/inverted/token_filter/lower_case_filter.h index ff737209a1ea09..20dae4ffabd4d1 100644 --- a/be/src/storage/index/inverted/token_filter/lower_case_filter.h +++ b/be/src/storage/index/inverted/token_filter/lower_case_filter.h @@ -66,6 +66,43 @@ class LowerCaseFilter : public DorisTokenFilter { ~LowerCaseFilter() override = default; + void reset() override { + DorisTokenFilter::reset(); + _rune_count_changed = false; + _has_source_span = false; + } + + std::span get_source_byte_offsets() const override { + return _rune_count_changed ? std::span {} + : DorisTokenFilter::get_source_byte_offsets(); + } + + std::span get_source_byte_end_offsets() const override { + return _rune_count_changed ? std::span {} + : DorisTokenFilter::get_source_byte_end_offsets(); + } + + bool get_conservative_source_byte_span(int32_t& start, int32_t& end) const override { + if (!_rune_count_changed) { + return DorisTokenFilter::get_conservative_source_byte_span(start, end); + } + if (!_has_source_span) { + return false; + } + start = _source_start; + end = _source_end; + return true; + } + +#ifdef BE_TEST + bool rune_count_changed_for_test() const { return _rune_count_changed; } +#endif + + void set_source_byte_offsets_enabled(bool enabled) override { + _source_byte_offsets_enabled = enabled; + DorisTokenFilter::set_source_byte_offsets_enabled(enabled); + } + void initialize() { UErrorCode status = U_ZERO_ERROR; auto* ucsm = ucasemap_open("", 0, &status); @@ -78,6 +115,8 @@ class LowerCaseFilter : public DorisTokenFilter { } Token* next(Token* t) override { + _rune_count_changed = false; + _has_source_span = false; if (_in->next(t) == nullptr) { return nullptr; } @@ -131,16 +170,42 @@ class LowerCaseFilter : public DorisTokenFilter { static_cast(status), u_errorName(status)); } + // Rune counts only matter to a downstream provenance consumer. + _rune_count_changed = _source_byte_offsets_enabled && + count_utf8_runes(term) != count_utf8_runes(std::string_view( + _lower_term.data(), result_len)); + if (_rune_count_changed) { + _has_source_span = get_delegated_source_byte_span(*t, _source_start, _source_end); + } set_text(t, std::string_view(_lower_term.data(), result_len)); return t; } - void reset() override { DorisTokenFilter::reset(); } - private: + static int32_t count_utf8_runes(std::string_view text) { + const auto length = cast_set(text.size()); + const char* data = text.data(); + int32_t offset = 0; + int32_t count = 0; + while (offset < length) { + UChar32 codepoint = U_UNASSIGNED; + U8_NEXT(data, offset, length, codepoint); + if (codepoint < 0) { + return -1; + } + ++count; + } + return count; + } + std::unique_ptr _ucsm; std::string _lower_term; + int32_t _source_start = 0; + int32_t _source_end = 0; + bool _rune_count_changed = false; + bool _has_source_span = false; + bool _source_byte_offsets_enabled = false; }; using LowerCaseFilterPtr = std::shared_ptr; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/token_filter/pinyin_filter.cpp b/be/src/storage/index/inverted/token_filter/pinyin_filter.cpp index 06d61c3a443a45..3f84db956c1f67 100644 --- a/be/src/storage/index/inverted/token_filter/pinyin_filter.cpp +++ b/be/src/storage/index/inverted/token_filter/pinyin_filter.cpp @@ -19,6 +19,7 @@ #include #include +#include #include "common/exception.h" #include "common/logging.h" @@ -79,6 +80,18 @@ Token* PinyinFilter::next(Token* token) { current_token_text_ = std::string(token->termBuffer(), token->termLength()); current_start_offset_ = token->startOffset(); current_end_offset_ = token->endOffset(); + if (!config_->ignorePinyinOffset) { + // Read the upstream token's provenance; the overrides publish this filter's own. + auto source_byte_offsets = DorisTokenFilter::get_source_byte_offsets(); + current_source_byte_offsets_.assign(source_byte_offsets.begin(), + source_byte_offsets.end()); + auto source_byte_end_offsets = DorisTokenFilter::get_source_byte_end_offsets(); + current_source_byte_end_offsets_.assign(source_byte_end_offsets.begin(), + source_byte_end_offsets.end()); + has_current_conservative_source_span_ = + DorisTokenFilter::get_conservative_source_byte_span( + current_conservative_source_start_, current_conservative_source_end_); + } done_ = false; } @@ -107,6 +120,18 @@ void PinyinFilter::reset() { done_ = true; resetVariables(); has_current_token_ = false; + current_token_text_.clear(); + has_published_token_ = false; + published_source_length_ = 0; + published_source_byte_offsets_.clear(); + published_source_byte_end_offsets_.clear(); + release_oversized_scratch(published_source_byte_offsets_); + release_oversized_scratch(published_source_byte_end_offsets_); + release_oversized_scratch(current_runes_); + release_oversized_scratch(current_source_byte_offsets_); + release_oversized_scratch(current_source_byte_end_offsets_); + release_oversized_scratch(current_token_text_); + release_oversized_scratch(current_source_); } void PinyinFilter::resetVariables() { @@ -121,6 +146,12 @@ void PinyinFilter::resetVariables() { first_letters_.clear(); full_pinyin_letters_.clear(); current_source_.clear(); + current_runes_.clear(); + current_source_byte_offsets_.clear(); + current_source_byte_end_offsets_.clear(); + current_conservative_source_start_ = 0; + current_conservative_source_end_ = 0; + has_current_conservative_source_span_ = false; candidate_offset_ = 0; terms_filter_.clear(); last_increment_position_ = 0; @@ -190,7 +221,7 @@ bool PinyinFilter::readTerm(Token* token) { // Add candidate if not a single character when separate first letter is enabled if (!(config_->keepSeparateFirstLetter && fl.length() <= 1)) { - addCandidate(TermItem(fl, 0, static_cast(fl.length()), 1)); + addCandidate(TermItem(fl, 0, static_cast(current_source_.length()), 1)); } } @@ -214,29 +245,94 @@ bool PinyinFilter::readTerm(Token* token) { return false; } -bool PinyinFilter::processCurrentToken() { - processed_candidate_ = true; +bool PinyinFilter::prepareCurrentSource(std::vector& 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 original_runes; + if (!current_source_byte_offsets_.empty()) { + std::vector 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(source_start), {}, &RuneInfo::byte_start); + const auto end_rune = std::ranges::lower_bound( + original_runes, static_cast(source_end), {}, &RuneInfo::byte_start); + const auto start_index = static_cast(start_rune - original_runes.begin()); + const auto end_index = static_cast(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(source_start); + current_end_offset_ = + current_start_offset_ + static_cast(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 source_codepoints; - convertToRunes(current_source_, source_codepoints); - - if (source_codepoints.empty()) { + if (!prepareCurrentSource(source_codepoints)) { return false; } @@ -252,7 +348,7 @@ bool PinyinFilter::processCurrentToken() { // Buffer for accumulating ASCII characters std::string ascii_buffer; - int ascii_buffer_start_pos = -1; + std::vector ascii_source_rune_indices; for (size_t i = 0; i < source_codepoints.size(); ++i) { UChar32 codepoint = source_codepoints[i]; @@ -270,9 +366,9 @@ bool PinyinFilter::processCurrentToken() { if (!config_->keepNoneChineseTogether && config_->keepNoneChinese) { // Process accumulated ASCII buffer before processing individual character if (!ascii_buffer.empty()) { - processAsciiBuffer(ascii_buffer, ascii_buffer_start_pos, static_cast(i)); + processAsciiBuffer(ascii_buffer, ascii_source_rune_indices); ascii_buffer.clear(); - ascii_buffer_start_pos = -1; + ascii_source_rune_indices.clear(); } // Process individual ASCII character immediately position_++; @@ -281,10 +377,11 @@ bool PinyinFilter::processCurrentToken() { position_)); } else { // Accumulate ASCII characters for later processing - if (ascii_buffer.empty()) { - ascii_buffer_start_pos = static_cast(i); - } ascii_buffer += static_cast(codepoint); + // Candidate subranges are only used when offsets are tracked. + if (!config_->ignorePinyinOffset) { + ascii_source_rune_indices.push_back(static_cast(i)); + } } // Handle ASCII alphanumeric characters for first letters @@ -301,9 +398,9 @@ bool PinyinFilter::processCurrentToken() { } else { // Process accumulated ASCII buffer when we hit non-ASCII (Chinese) characters if (!ascii_buffer.empty()) { - processAsciiBuffer(ascii_buffer, ascii_buffer_start_pos, static_cast(i)); + processAsciiBuffer(ascii_buffer, ascii_source_rune_indices); ascii_buffer.clear(); - ascii_buffer_start_pos = -1; + ascii_source_rune_indices.clear(); } if (!pinyin.empty() && !chinese.empty()) { @@ -346,9 +443,11 @@ bool PinyinFilter::processCurrentToken() { // Process any remaining ASCII buffer at the end if (!ascii_buffer.empty()) { - processAsciiBuffer(ascii_buffer, ascii_buffer_start_pos, - static_cast(source_codepoints.size())); + processAsciiBuffer(ascii_buffer, ascii_source_rune_indices); } +#ifdef BE_TEST + last_ascii_rune_index_capacity_ = ascii_source_rune_indices.capacity(); +#endif // Store the collected letters for later processing first_letters_ = first_letters_buffer; @@ -392,28 +491,45 @@ void PinyinFilter::addCandidate(const TermItem& item) { candidate_.push_back(new_item); } -void PinyinFilter::processAsciiBuffer(const std::string& ascii_buffer, int start_pos, int end_pos) { +void PinyinFilter::processAsciiBuffer(const std::string& ascii_buffer, + const std::vector& source_rune_indices) { if (ascii_buffer.empty() || !config_->keepNoneChinese) { return; } + // Without offset tracking every candidate gets the whole token span, so letter positions + // stand in for the source rune indices that were not collected. + const bool tracked = !config_->ignorePinyinOffset; + DORIS_CHECK(!tracked || ascii_buffer.size() == source_rune_indices.size()); + auto rune_index = [&](size_t i) { + return tracked ? source_rune_indices[i] : static_cast(i); + }; if (config_->noneChinesePinyinTokenize) { // Use PinyinAlphabetTokenizer to split ASCII buffer into meaningful tokens std::vector tokens = PinyinAlphabetTokenizer::walk(ascii_buffer); - int current_offset = start_pos; + size_t compact_offset = 0; + int fixed_offset = rune_index(0); for (const auto& token : tokens) { + const size_t compact_end = compact_offset + token.size(); + DORIS_CHECK_LE(compact_end, ascii_buffer.size()); position_++; - int token_end = (config_->fixedPinyinOffset) - ? (current_offset + 1) - : (current_offset + static_cast(token.length())); - addCandidate(TermItem(token, current_offset, token_end, position_)); - current_offset = token_end; + if (config_->fixedPinyinOffset) { + addCandidate(TermItem(token, fixed_offset, fixed_offset + 1, position_)); + ++fixed_offset; + } else { + const int source_start = rune_index(compact_offset); + const int source_end = rune_index(compact_end - 1) + 1; + addCandidate(TermItem(token, source_start, source_end, position_)); + } + compact_offset = compact_end; } + DORIS_CHECK_EQ(compact_offset, ascii_buffer.size()); } else { // Treat the entire ASCII buffer as a single token position_++; - addCandidate(TermItem(ascii_buffer, start_pos, end_pos, position_)); + addCandidate(TermItem(ascii_buffer, rune_index(0), rune_index(ascii_buffer.size() - 1) + 1, + position_)); } } @@ -421,8 +537,19 @@ void PinyinFilter::setTokenAttributes(Token* token, const std::string& term, int int end_offset, int position) { set_text(token, term); - token->setStartOffset(start_offset); - token->setEndOffset(end_offset); + int absolute_start = current_start_offset_; + int absolute_end = current_end_offset_; + const bool is_whole_token = + start_offset == 0 && std::cmp_equal(end_offset, current_source_.length()); + if (!config_->ignorePinyinOffset && !is_whole_token && start_offset >= 0 && end_offset > 0 && + std::cmp_less(start_offset, current_runes_.size()) && + std::cmp_less_equal(end_offset, current_runes_.size())) { + absolute_start += current_runes_[start_offset].byte_start; + absolute_end = current_start_offset_ + current_runes_[end_offset - 1].byte_end; + } + token->setStartOffset(absolute_start); + token->setEndOffset(absolute_end); + publishCandidateProvenance(term, is_whole_token, absolute_end - absolute_start); int offset = position - last_increment_position_; if (offset < 0) { @@ -432,6 +559,36 @@ void PinyinFilter::setTokenAttributes(Token* token, const std::string& term, int last_increment_position_ = position; } +bool PinyinFilter::get_conservative_source_byte_span(int32_t& start, int32_t& end) const { + if (!has_published_token_ || !published_source_byte_offsets_.empty()) { + return false; + } + start = 0; + end = published_source_length_; + return true; +} + +void PinyinFilter::publishCandidateProvenance(const std::string& term, bool is_whole_token, + int32_t source_length) { + has_published_token_ = true; + published_source_length_ = source_length; + published_source_byte_offsets_.clear(); + published_source_byte_end_offsets_.clear(); + // Only an unchanged original token keeps exact rune boundaries; transformed candidates + // publish their whole source span instead of the input token's map. + if (config_->ignorePinyinOffset || !is_whole_token || current_runes_.empty() || + term != current_source_) { + return; + } + published_source_byte_offsets_.reserve(current_runes_.size() + 1); + published_source_byte_end_offsets_.reserve(current_runes_.size()); + for (const auto& rune : current_runes_) { + published_source_byte_offsets_.push_back(rune.byte_start); + published_source_byte_end_offsets_.push_back(rune.byte_end); + } + published_source_byte_offsets_.push_back(current_runes_.back().byte_end); +} + std::string PinyinFilter::trim(const std::string& str) { size_t first = str.find_first_not_of(" \t\n\r"); if (first == std::string::npos) { @@ -468,4 +625,16 @@ std::vector PinyinFilter::convertToRunes(const std::stri return runes; } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +void PinyinFilter::convertToCodepoints(const std::string& text, std::vector& codepoints) { + codepoints.clear(); + const char* data = text.data(); + const auto length = static_cast(text.length()); + int32_t offset = 0; + while (offset < length) { + UChar32 codepoint = U_UNASSIGNED; + U8_NEXT(data, offset, length, codepoint); + codepoints.push_back(codepoint); + } +} + +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/token_filter/pinyin_filter.h b/be/src/storage/index/inverted/token_filter/pinyin_filter.h index e4e7c53e6a0ea7..1dfc1ebc1cc48e 100644 --- a/be/src/storage/index/inverted/token_filter/pinyin_filter.h +++ b/be/src/storage/index/inverted/token_filter/pinyin_filter.h @@ -42,6 +42,27 @@ class PinyinFilter : public DorisTokenFilter { void reset() override; + // Provenance of the most recently emitted candidate, never the input token's map. + std::span get_source_byte_offsets() const override { + return published_source_byte_offsets_; + } + std::span get_source_byte_end_offsets() const override { + return published_source_byte_end_offsets_; + } + bool get_conservative_source_byte_span(int32_t& start, int32_t& end) const override; + +#ifdef BE_TEST + size_t last_ascii_rune_index_capacity_for_test() const { + return last_ascii_rune_index_capacity_; + } + size_t current_runes_capacity_for_test() const { return current_runes_.capacity(); } + size_t current_source_offsets_capacity_for_test() const { + return current_source_byte_offsets_.capacity(); + } + size_t current_token_capacity_for_test() const { return current_token_text_.capacity(); } + size_t current_source_capacity_for_test() const { return current_source_.capacity(); } +#endif + private: struct RuneInfo { UChar32 cp; @@ -51,21 +72,29 @@ class PinyinFilter : public DorisTokenFilter { bool processCurrentToken(); + bool prepareCurrentSource(std::vector& source_codepoints); + bool readTerm(Token* token); void resetVariables(); void addCandidate(const TermItem& item); - void processAsciiBuffer(const std::string& ascii_buffer, int start_pos, int end_pos); + void processAsciiBuffer(const std::string& ascii_buffer, + const std::vector& source_rune_indices); void setTokenAttributes(Token* token, const std::string& term, int startOffset, int endOffset, int position); + void publishCandidateProvenance(const std::string& term, bool is_whole_token, + int32_t source_length); + std::string trim(const std::string& str); std::vector convertToRunes(const std::string& text, std::vector& codepoints); + void convertToCodepoints(const std::string& text, std::vector& codepoints); + private: // Configuration std::shared_ptr config_; @@ -98,6 +127,21 @@ class PinyinFilter : public DorisTokenFilter { std::string current_token_text_; int current_start_offset_; int current_end_offset_; + std::vector current_runes_; + std::vector current_source_byte_offsets_; + std::vector current_source_byte_end_offsets_; + int32_t current_conservative_source_start_ = 0; + int32_t current_conservative_source_end_ = 0; + bool has_current_conservative_source_span_ = false; + + // Provenance published for the most recently emitted candidate + std::vector published_source_byte_offsets_; + std::vector published_source_byte_end_offsets_; + int32_t published_source_length_ = 0; + bool has_published_token_ = false; +#ifdef BE_TEST + size_t last_ascii_rune_index_capacity_ = 0; +#endif }; using PinyinFilterPtr = std::shared_ptr; diff --git a/be/src/storage/index/inverted/token_filter/pinyin_filter_factory.cpp b/be/src/storage/index/inverted/token_filter/pinyin_filter_factory.cpp index ebfea592459306..7d6efb4b129188 100644 --- a/be/src/storage/index/inverted/token_filter/pinyin_filter_factory.cpp +++ b/be/src/storage/index/inverted/token_filter/pinyin_filter_factory.cpp @@ -62,7 +62,10 @@ TokenFilterPtr PinyinFilterFactory::create(const TokenStreamPtr& in) { auto filter = std::make_shared(in, config_); filter->initialize(); + if (!config_->ignorePinyinOffset) { + filter->set_source_byte_offsets_enabled(true); + } return filter; } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/token_filter/token_filter.h b/be/src/storage/index/inverted/token_filter/token_filter.h index c4e9b1b5b59504..39e7ce8d76743a 100644 --- a/be/src/storage/index/inverted/token_filter/token_filter.h +++ b/be/src/storage/index/inverted/token_filter/token_filter.h @@ -28,9 +28,48 @@ class DorisTokenFilter : public TokenFilter, public DorisTokenStream { void reset() override { _in->reset(); } + std::span get_source_byte_offsets() const override { + const auto* source = dynamic_cast(_in.get()); + return source == nullptr ? std::span {} : source->get_source_byte_offsets(); + } + + std::span get_source_byte_end_offsets() const override { + const auto* source = dynamic_cast(_in.get()); + return source == nullptr ? std::span {} + : source->get_source_byte_end_offsets(); + } + + bool get_conservative_source_byte_span(int32_t& start, int32_t& end) const override { + const auto* source = dynamic_cast(_in.get()); + return source != nullptr && source->get_conservative_source_byte_span(start, end); + } + + void set_source_byte_offsets_enabled(bool enabled) override { + auto* source = dynamic_cast(_in.get()); + if (source != nullptr) { + source->set_source_byte_offsets_enabled(enabled); + } + } + protected: + bool get_delegated_source_byte_span(const Token& token, int32_t& start, int32_t& end) const { + if (DorisTokenFilter::get_conservative_source_byte_span(start, end)) { + return true; + } + const auto offsets = DorisTokenFilter::get_source_byte_offsets(); + if (!offsets.empty()) { + const auto end_offsets = DorisTokenFilter::get_source_byte_end_offsets(); + start = offsets.front(); + end = end_offsets.empty() ? offsets.back() : end_offsets.back(); + return true; + } + start = 0; + end = token.endOffset() - token.startOffset(); + return end >= 0; + } + TokenStreamPtr _in; }; using TokenFilterPtr = std::shared_ptr; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/token_filter/word_delimiter_filter.cpp b/be/src/storage/index/inverted/token_filter/word_delimiter_filter.cpp index 240a32209b606b..2bafe66311e496 100644 --- a/be/src/storage/index/inverted/token_filter/word_delimiter_filter.cpp +++ b/be/src/storage/index/inverted/token_filter/word_delimiter_filter.cpp @@ -17,6 +17,9 @@ #include "storage/index/inverted/token_filter/word_delimiter_filter.h" +#include + +#include #include #include #include @@ -32,7 +35,7 @@ WordDelimiterFilter::WordDelimiterFilter(const TokenStreamPtr& in, : DorisTokenFilter(in), _flags(configuration_flags), _prot_words(std::move(prot_words)), - _states(8) { + _states(INITIAL_BUFFERED_STATES) { _iterator = std::make_unique(char_type_table, has(SPLIT_ON_CASE_CHANGE), has(SPLIT_ON_NUMERICS), has(STEM_ENGLISH_POSSESSIVE)); @@ -40,6 +43,7 @@ WordDelimiterFilter::WordDelimiterFilter(const TokenStreamPtr& in, _concat_all = std::make_unique(*this); } +// NOLINTNEXTLINE(readability-function-cognitive-complexity,readability-function-size): keep the token emission state machine together. Token* WordDelimiterFilter::next(Token* t) { while (true) { if (!_has_saved_state) { @@ -50,6 +54,9 @@ Token* WordDelimiterFilter::next(Token* t) { char* term_buffer = t->termBuffer(); auto term_length = static_cast(t->termLength()); std::string_view term(term_buffer, term_length); + _saved_start_offset = t->startOffset(); + _saved_end_offset = t->endOffset(); + save_source_state(term); _accum_pos_inc += get_position_increment(t); _iterator->set_text(term.data(), static_cast(term.size())); @@ -61,6 +68,9 @@ Token* WordDelimiterFilter::next(Token* t) { set_position_increment(t, _accum_pos_inc); _accum_pos_inc = 0; _first = false; + _current_source_byte_offsets = _saved_source_byte_offsets; + _current_source_byte_end_offsets = _saved_source_byte_end_offsets; + _current_generated = false; return t; } @@ -81,6 +91,9 @@ Token* WordDelimiterFilter::next(Token* t) { set_position_increment(t, _accum_pos_inc); _accum_pos_inc = 0; _first = false; + _current_source_byte_offsets = _saved_source_byte_offsets; + _current_source_byte_end_offsets = _saved_source_byte_end_offsets; + _current_generated = false; return t; } } @@ -116,6 +129,12 @@ Token* WordDelimiterFilter::next(Token* t) { int32_t position = _states[_buffered_pos].pos_inc; _buffered_pos++; set(t, term, position); + t->setStartOffset(_states[_buffered_pos - 1].token_start_offset); + t->setEndOffset(_states[_buffered_pos - 1].token_end_offset); + _current_source_byte_offsets = _states[_buffered_pos - 1].source_byte_offsets; + _current_source_byte_end_offsets = + _states[_buffered_pos - 1].source_byte_end_offsets; + _current_generated = true; if (_first && get_position_increment(t) == 0) { set_position_increment(t, 1); } @@ -133,6 +152,11 @@ Token* WordDelimiterFilter::next(Token* t) { _iterator->next(); _first = false; set(t, _attribute.buffered, _attribute.pos_inc); + t->setStartOffset(_attribute.token_start_offset); + t->setEndOffset(_attribute.token_end_offset); + _current_source_byte_offsets = _attribute.source_byte_offsets; + _current_source_byte_end_offsets = _attribute.source_byte_end_offsets; + _current_generated = true; return t; } @@ -169,12 +193,74 @@ Token* WordDelimiterFilter::next(Token* t) { void WordDelimiterFilter::reset() { DorisTokenFilter::reset(); _has_saved_state = false; + _current_generated = false; _concat->clear(); _concat_all->clear(); _accum_pos_inc = 0; _buffered_pos = 0; _buffered_len = 0; _first = true; + _saved_source_byte_offsets.clear(); + _saved_source_byte_end_offsets.clear(); + _saved_token_byte_offsets.clear(); + _current_source_byte_offsets.clear(); + _current_source_byte_end_offsets.clear(); + release_oversized_scratch(_saved_source_byte_offsets); + release_oversized_scratch(_saved_source_byte_end_offsets); + release_oversized_scratch(_saved_token_byte_offsets); + release_oversized_scratch(_current_source_byte_offsets); + release_oversized_scratch(_current_source_byte_end_offsets); + _concat->release_oversized_buffers(); + _concat_all->release_oversized_buffers(); + auto release_attribute = [](Attribute& attribute) { + release_oversized_scratch(attribute.buffered); + release_oversized_scratch(attribute.source_byte_offsets); + release_oversized_scratch(attribute.source_byte_end_offsets); + }; + release_attribute(_attribute); + if (_states.capacity() * sizeof(Attribute) > ANALYZER_SCRATCH_HIGH_WATER_BYTES) { + std::vector(INITIAL_BUFFERED_STATES).swap(_states); + } else { + std::ranges::for_each(_states, release_attribute); + } +} + +bool WordDelimiterFilter::get_conservative_source_byte_span(int32_t& start, int32_t& end) const { + if (!_current_source_byte_offsets.empty()) { + return false; + } + if (!_current_generated) { + return DorisTokenFilter::get_conservative_source_byte_span(start, end); + } + if (DorisTokenFilter::get_conservative_source_byte_span(start, end)) { + return true; + } + // A generated part without an exact slice keeps the whole upstream token as its span, which + // is also the offset range it was published with. + start = 0; + end = _saved_end_offset - _saved_start_offset; + return end >= 0; +} + +size_t WordDelimiterFilter::scratch_capacity_bytes_for_test() const { + auto offsets_bytes = [](const std::vector& offsets) { + return offsets.capacity() * sizeof(int32_t); + }; + auto attribute_bytes = [&](const Attribute& attribute) { + return attribute.buffered.capacity() + offsets_bytes(attribute.source_byte_offsets) + + offsets_bytes(attribute.source_byte_end_offsets); + }; + size_t bytes = offsets_bytes(_saved_source_byte_offsets) + + offsets_bytes(_saved_source_byte_end_offsets) + + offsets_bytes(_saved_token_byte_offsets) + + offsets_bytes(_current_source_byte_offsets) + + offsets_bytes(_current_source_byte_end_offsets) + attribute_bytes(_attribute) + + _concat->scratch_capacity_bytes() + _concat_all->scratch_capacity_bytes() + + _states.capacity() * sizeof(Attribute); + for (const auto& state : _states) { + bytes += attribute_bytes(state); + } + return bytes; } void WordDelimiterFilter::save_state(const std::string_view& term) { @@ -200,6 +286,10 @@ void WordDelimiterFilter::buffer() { _states[_buffered_len].buffered = _attribute.buffered; _states[_buffered_len].start_off = _attribute.start_off; _states[_buffered_len].pos_inc = _attribute.pos_inc; + _states[_buffered_len].source_byte_offsets = _attribute.source_byte_offsets; + _states[_buffered_len].source_byte_end_offsets = _attribute.source_byte_end_offsets; + _states[_buffered_len].token_start_offset = _attribute.token_start_offset; + _states[_buffered_len].token_end_offset = _attribute.token_end_offset; _buffered_len++; } @@ -208,6 +298,85 @@ void WordDelimiterFilter::generate_part(bool is_single_word) { _saved_buffer.substr(_iterator->_current, _iterator->_end - _iterator->_current); _attribute.start_off = _iterator->_current; _attribute.pos_inc = position(false); + auto [source_byte_offsets, source_byte_end_offsets] = + slice_source_byte_offsets(_iterator->_current, _iterator->_end); + set_attribute_source_byte_offsets(std::move(source_byte_offsets), + std::move(source_byte_end_offsets)); +} + +void WordDelimiterFilter::save_source_state(std::string_view term) { + auto source_byte_offsets = DorisTokenFilter::get_source_byte_offsets(); + _saved_source_byte_offsets.assign(source_byte_offsets.begin(), source_byte_offsets.end()); + _saved_source_byte_end_offsets.clear(); + _saved_token_byte_offsets.clear(); + if (_saved_source_byte_offsets.empty()) { + return; + } + + _saved_token_byte_offsets.push_back(0); + int32_t offset = 0; + const auto length = static_cast(term.size()); + while (offset < length) { + UChar32 codepoint; + const char* term_data = term.data(); + U8_NEXT(term_data, offset, length, codepoint); + _saved_token_byte_offsets.push_back(offset); + } + if (_saved_token_byte_offsets.size() != _saved_source_byte_offsets.size()) { + _saved_source_byte_offsets.clear(); + _saved_token_byte_offsets.clear(); + return; + } + auto source_byte_end_offsets = DorisTokenFilter::get_source_byte_end_offsets(); + if (source_byte_end_offsets.empty()) { + _saved_source_byte_end_offsets.assign(_saved_source_byte_offsets.begin() + 1, + _saved_source_byte_offsets.end()); + } else { + DORIS_CHECK_EQ(source_byte_end_offsets.size() + 1, _saved_source_byte_offsets.size()); + _saved_source_byte_end_offsets.assign(source_byte_end_offsets.begin(), + source_byte_end_offsets.end()); + } +} + +std::pair, std::vector> +WordDelimiterFilter::slice_source_byte_offsets(int32_t start, int32_t end) const { + if (_saved_source_byte_offsets.empty()) { + return {}; + } + auto start_it = std::ranges::lower_bound(_saved_token_byte_offsets, start); + auto end_it = std::ranges::lower_bound(_saved_token_byte_offsets, end); + if (start_it == _saved_token_byte_offsets.end() || *start_it != start || + end_it == _saved_token_byte_offsets.end() || *end_it != end || start_it > end_it) { + return {}; + } + const auto start_index = std::distance(_saved_token_byte_offsets.begin(), start_it); + const auto end_index = std::distance(_saved_token_byte_offsets.begin(), end_it); + std::vector offsets {_saved_source_byte_offsets.begin() + start_index, + _saved_source_byte_offsets.begin() + end_index}; + std::vector ends {_saved_source_byte_end_offsets.begin() + start_index, + _saved_source_byte_end_offsets.begin() + end_index}; + DORIS_CHECK(!ends.empty()); + offsets.push_back(ends.back()); + return {std::move(offsets), std::move(ends)}; +} + +void WordDelimiterFilter::set_attribute_source_byte_offsets( + std::vector source_byte_offsets, std::vector source_byte_end_offsets) { + _attribute.token_start_offset = _saved_start_offset; + _attribute.token_end_offset = _saved_end_offset; + if (!source_byte_offsets.empty()) { + const int32_t relative_start = source_byte_offsets.front(); + _attribute.token_start_offset += relative_start; + _attribute.token_end_offset = _saved_start_offset + source_byte_offsets.back(); + for (int32_t& offset : source_byte_offsets) { + offset -= relative_start; + } + for (int32_t& offset : source_byte_end_offsets) { + offset -= relative_start; + } + } + _attribute.source_byte_offsets = std::move(source_byte_offsets); + _attribute.source_byte_end_offsets = std::move(source_byte_end_offsets); } int32_t WordDelimiterFilter::position(bool inject) { @@ -246,4 +415,4 @@ bool WordDelimiterFilter::should_generate_parts(int32_t word_type) { (has(GENERATE_NUMBER_PARTS) && is_digit(word_type)); } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/token_filter/word_delimiter_filter.h b/be/src/storage/index/inverted/token_filter/word_delimiter_filter.h index ab778200725711..f9f03bef073214 100644 --- a/be/src/storage/index/inverted/token_filter/word_delimiter_filter.h +++ b/be/src/storage/index/inverted/token_filter/word_delimiter_filter.h @@ -17,6 +17,7 @@ #pragma once +#include #include #include "storage/index/inverted/token_filter/token_filter.h" @@ -35,6 +36,15 @@ class WordDelimiterFilter : public DorisTokenFilter { Token* next(Token* t) override; void reset() override; + std::span get_source_byte_offsets() const override { + return _current_source_byte_offsets; + } + std::span get_source_byte_end_offsets() const override { + return _current_source_byte_end_offsets; + } + bool get_conservative_source_byte_span(int32_t& start, int32_t& end) const override; + + size_t scratch_capacity_bytes_for_test() const; static bool is_alpha(int32_t type) { return (type & ALPHA) != 0; } static bool is_digit(int32_t type) { return (type & DIGIT) != 0; } @@ -68,11 +78,20 @@ class WordDelimiterFilter : public DorisTokenFilter { bool should_generate_parts(int32_t word_type); int32_t position(bool inject); void concatenate(const WordDelimiterConcatenationPtr& concatenation); + void save_source_state(std::string_view term); + std::pair, std::vector> slice_source_byte_offsets( + int32_t start, int32_t end) const; + void set_attribute_source_byte_offsets(std::vector source_byte_offsets, + std::vector source_byte_end_offsets); struct Attribute { std::string buffered; int32_t start_off = 0; int32_t pos_inc = 0; + std::vector source_byte_offsets; + std::vector source_byte_end_offsets; + int32_t token_start_offset = 0; + int32_t token_end_offset = 0; }; Attribute _attribute; @@ -87,10 +106,20 @@ class WordDelimiterFilter : public DorisTokenFilter { int32_t _accum_pos_inc = 0; std::string_view _saved_buffer; + std::vector _saved_source_byte_offsets; + std::vector _saved_source_byte_end_offsets; + std::vector _saved_token_byte_offsets; + std::vector _current_source_byte_offsets; + std::vector _current_source_byte_end_offsets; + int32_t _saved_start_offset = 0; + int32_t _saved_end_offset = 0; bool _has_saved_state = false; + // Whether the current token is a generated part rather than the upstream token itself. + bool _current_generated = false; bool _has_output_token = false; bool _has_output_following_original = false; + static constexpr size_t INITIAL_BUFFERED_STATES = 8; std::vector _states; int32_t _buffered_len = 0; int32_t _buffered_pos = 0; @@ -106,6 +135,18 @@ class WordDelimiterConcatenation { void append(const char* text, int32_t offset, int32_t length) { _buffer.append(text, offset, length); + auto [source_byte_offsets, source_byte_end_offsets] = + _filter.slice_source_byte_offsets(offset, offset + length); + if (!_source_byte_offsets.empty() && !source_byte_offsets.empty()) { + _source_byte_offsets.pop_back(); + _source_byte_offsets.insert(_source_byte_offsets.end(), source_byte_offsets.begin(), + source_byte_offsets.end()); + } else if (!source_byte_offsets.empty()) { + _source_byte_offsets = std::move(source_byte_offsets); + } + _source_byte_end_offsets.insert(_source_byte_end_offsets.end(), + source_byte_end_offsets.begin(), + source_byte_end_offsets.end()); _subword_count++; } @@ -113,6 +154,7 @@ class WordDelimiterConcatenation { _filter._attribute.buffered = _buffer; _filter._attribute.start_off = _start_offset; _filter._attribute.pos_inc = _filter.position(true); + _filter.set_attribute_source_byte_offsets(_source_byte_offsets, _source_byte_end_offsets); _filter._accum_pos_inc = 0; } @@ -120,6 +162,8 @@ class WordDelimiterConcatenation { void clear() { _buffer.clear(); + _source_byte_offsets.clear(); + _source_byte_end_offsets.clear(); _start_offset = 0; _type = 0; _subword_count = 0; @@ -130,6 +174,18 @@ class WordDelimiterConcatenation { clear(); } + void release_oversized_buffers() { + inverted_index::release_oversized_scratch(_buffer); + inverted_index::release_oversized_scratch(_source_byte_offsets); + inverted_index::release_oversized_scratch(_source_byte_end_offsets); + } + + size_t scratch_capacity_bytes() const { + return _buffer.capacity() + + (_source_byte_offsets.capacity() + _source_byte_end_offsets.capacity()) * + sizeof(int32_t); + } + int32_t _subword_count = 0; int32_t _start_offset = 0; int32_t _type = 0; @@ -138,6 +194,8 @@ class WordDelimiterConcatenation { WordDelimiterFilter& _filter; std::string _buffer; + std::vector _source_byte_offsets; + std::vector _source_byte_end_offsets; }; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/token_stream.h b/be/src/storage/index/inverted/token_stream.h index 71744dbcaf66e7..6b26ddf6ee881f 100644 --- a/be/src/storage/index/inverted/token_stream.h +++ b/be/src/storage/index/inverted/token_stream.h @@ -18,9 +18,14 @@ #pragma once #include +#include +#include +#include #include +#include #include +#include #include "CLucene.h" #include "CLucene/analysis/AnalysisHeader.h" @@ -36,6 +41,36 @@ using TokenizerPtr = std::shared_ptr; using TokenStreamPtr = std::shared_ptr; +// Reused analyzers keep ordinary scratch across values; one oversized value must not pin its +// capacity for the rest of the writer lifetime. +constexpr size_t ANALYZER_SCRATCH_HIGH_WATER_BYTES = 64 * 1024; + +// Longest prefix of text that fits in max_bytes without splitting a rune, and its rune count. +inline std::pair utf8_prefix_at_most(std::string_view text, size_t max_bytes) { + const auto length = static_cast(text.size()); + const auto limit = static_cast(std::min(text.size(), max_bytes)); + int32_t offset = 0; + size_t rune_count = 0; + while (offset < length) { + int32_t next = offset; + U8_FWD_1(text, next, length); + if (next > limit) { + break; + } + offset = next; + ++rune_count; + } + return {static_cast(offset), rune_count}; +} + +template +void release_oversized_scratch(Container& container) { + if (container.capacity() * sizeof(typename Container::value_type) > + ANALYZER_SCRATCH_HIGH_WATER_BYTES) { + Container().swap(container); + } +} + /** * All custom tokenizers and token_filters must use the following functions * to set token information. Using these unified set methods helps avoid @@ -59,6 +94,20 @@ class DorisTokenStream { int32_t get_position_increment(Token* t) { return t->getPositionIncrement(); } void set_position_increment(Token* t, int32_t pos) { t->setPositionIncrement(pos); } + + // Return each rune's original relative byte start followed by the token's final byte end. + virtual std::span get_source_byte_offsets() const { return {}; } + + // Return separate rune ends when removed delimiters leave gaps between adjacent runes. + virtual std::span get_source_byte_end_offsets() const { return {}; } + + // Return a conservative relative source span when exact rune boundaries are unavailable. + virtual bool get_conservative_source_byte_span(int32_t& start, int32_t& end) const { + return false; + } + + // Enable source-boundary tracking only for streams with a downstream consumer. + virtual void set_source_byte_offsets_enabled(bool enabled) {} }; class TokenStreamWrapper : public TokenStream { diff --git a/be/src/storage/index/inverted/tokenizer/basic/basic_tokenizer.cpp b/be/src/storage/index/inverted/tokenizer/basic/basic_tokenizer.cpp index c5d38593a9b962..22bbf673e78266 100644 --- a/be/src/storage/index/inverted/tokenizer/basic/basic_tokenizer.cpp +++ b/be/src/storage/index/inverted/tokenizer/basic/basic_tokenizer.cpp @@ -51,6 +51,11 @@ Token* BasicTokenizer::next(Token* token) { std::string_view& token_text = _tokens_text[_buffer_index++]; size_t size = std::min(token_text.size(), static_cast(LUCENE_MAX_WORD_LEN)); token->setNoCopy(token_text.data(), 0, static_cast(size)); + const auto source_start = static_cast(token_text.data() - _buffer.data()); + std::string_view term(token_text.data(), size); + set_source_byte_offsets(term, source_start); + token->setStartOffset(correct_source_start_offset(source_start)); + token->setEndOffset(correct_source_offset(source_start + static_cast(size))); return token; } @@ -123,4 +128,4 @@ void BasicTokenizer::cut() { } } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/tokenizer/char/char_tokenizer.cpp b/be/src/storage/index/inverted/tokenizer/char/char_tokenizer.cpp index 54ac1d72c9de82..1e018a5293890d 100644 --- a/be/src/storage/index/inverted/tokenizer/char/char_tokenizer.cpp +++ b/be/src/storage/index/inverted/tokenizer/char/char_tokenizer.cpp @@ -126,6 +126,9 @@ Token* CharTokenizer::next(Token* token) { int32_t length = end - start + 1; std::string_view term(_char_buffer + start, length); set(token, term); + set_source_byte_offsets(term, start); + token->setStartOffset(correct_source_start_offset(start)); + token->setEndOffset(correct_source_offset(start + length)); return token; } @@ -136,4 +139,4 @@ void CharTokenizer::reset() { _data_len = _in->read((const void**)&_char_buffer, 0, static_cast(_in->size())); } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/tokenizer/empty/empty_tokenizer_factory.h b/be/src/storage/index/inverted/tokenizer/empty/empty_tokenizer_factory.h index b540dfa45a3f61..b49fd50f24633f 100644 --- a/be/src/storage/index/inverted/tokenizer/empty/empty_tokenizer_factory.h +++ b/be/src/storage/index/inverted/tokenizer/empty/empty_tokenizer_factory.h @@ -37,6 +37,9 @@ class EmptyTokenizer : public DorisTokenizer { } std::string_view term(_char_buffer, _char_length); set(token, term); + set_source_byte_offsets(term, 0); + token->setStartOffset(correct_source_offset(0)); + token->setEndOffset(correct_source_offset(_char_length)); return token; } return nullptr; @@ -73,4 +76,4 @@ class EmptyTokenizerFactory : public TokenizerFactory { } }; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/tokenizer/icu/icu_tokenizer.cpp b/be/src/storage/index/inverted/tokenizer/icu/icu_tokenizer.cpp index 097e9d3ed7a389..a320aeccf4c7c1 100644 --- a/be/src/storage/index/inverted/tokenizer/icu/icu_tokenizer.cpp +++ b/be/src/storage/index/inverted/tokenizer/icu/icu_tokenizer.cpp @@ -18,10 +18,14 @@ #include "storage/index/inverted/tokenizer/icu/icu_tokenizer.h" #include +#include #include #include +#include "common/exception.h" +#include "util/utf8_check.h" + namespace doris::segment_v2::inverted_index { ICUTokenizer::ICUTokenizer() { @@ -54,7 +58,17 @@ 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); + // Provenance needs the text before lowercasing; without lowercasing that is the term itself. + const bool keep_source = _source_byte_offsets_enabled && this->lowercase; + sourceUtf8Str_.clear(); + if (keep_source) { + subString.toUTF8String(sourceUtf8Str_); + } if (this->lowercase) { subString.toLower().toUTF8String(utf8Str_); } else { @@ -62,6 +76,14 @@ Token* ICUTokenizer::next(Token* token) { } token->setNoCopy(utf8Str_.data(), 0, static_cast(utf8Str_.size())); + int32_t source_start = 0; + int32_t source_end = 0; + if (start >= 0 && length >= 0 && advance_source_offset(start, source_start) && + advance_source_offset(start + length, source_end)) { + set_source_byte_offsets(utf8Str_, keep_source ? sourceUtf8Str_ : utf8Str_, source_start); + token->setStartOffset(correct_source_start_offset(source_start)); + token->setEndOffset(correct_source_offset(source_end)); + } return token; } @@ -69,11 +91,51 @@ void ICUTokenizer::reset() { DorisTokenizer::reset(); const char* buf = nullptr; int32_t len = _in->read((const void**)&buf, 0, static_cast(_in->size())); + // Malformed bytes become replacement characters and the rest of the input is still indexed. + // Only the offset-aware path rejects them, because they have no source byte span. + if (_source_byte_offsets_enabled && len > 0 && !validate_utf8(buf, len)) { + throw Exception(ErrorCode::INVALID_ARGUMENT, "ICU tokenizer input is not valid UTF-8"); + } buffer_ = icu::UnicodeString::fromUTF8(icu::StringPiece(buf, len)); if (!buffer_.isEmpty() && buffer_.isBogus()) { _CLTHROWT(CL_ERR_Runtime, "Failed to convert UTF-8 string to UnicodeString."); } + sourceBuffer_ = buf; + sourceLength_ = len; + sourceUtf8Offset_ = 0; + sourceUtf16Offset_ = 0; + sourceOffsetsValid_ = true; breaker_->set_text(buffer_.getBuffer(), 0, buffer_.length()); } +bool ICUTokenizer::advance_source_offset(int32_t utf16_offset, int32_t& utf8_offset) { + if (!sourceOffsetsValid_ || utf16_offset < sourceUtf16Offset_) { + return false; + } + + while (sourceUtf16Offset_ < utf16_offset && sourceUtf8Offset_ < sourceLength_) { + const int32_t code_point_start = sourceUtf8Offset_; + UChar32 code_point; + U8_NEXT(sourceBuffer_, sourceUtf8Offset_, sourceLength_, code_point); + if (code_point < 0) { + sourceOffsetsValid_ = false; + return false; + } + + const int32_t next_utf16_offset = sourceUtf16Offset_ + U16_LENGTH(code_point); + sourceUtf16Offset_ = next_utf16_offset; + if (utf16_offset < next_utf16_offset) { + utf8_offset = code_point_start; + return true; + } + } + + if (sourceUtf16Offset_ != utf16_offset) { + sourceOffsetsValid_ = false; + return false; + } + utf8_offset = sourceUtf8Offset_; + return true; +} + } // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/tokenizer/icu/icu_tokenizer.h b/be/src/storage/index/inverted/tokenizer/icu/icu_tokenizer.h index 4af6ad146252db..a29b77264a608b 100644 --- a/be/src/storage/index/inverted/tokenizer/icu/icu_tokenizer.h +++ b/be/src/storage/index/inverted/tokenizer/icu/icu_tokenizer.h @@ -38,12 +38,24 @@ class ICUTokenizer : public DorisTokenizer { Token* next(Token* token) override; void reset() override; +#ifdef BE_TEST + size_t source_scratch_size_for_test() const { return sourceUtf8Str_.size(); } +#endif + private: + bool advance_source_offset(int32_t utf16_offset, int32_t& utf8_offset); + std::string utf8Str_; + std::string sourceUtf8Str_; icu::UnicodeString buffer_; + const char* sourceBuffer_ = nullptr; + int32_t sourceLength_ = 0; + int32_t sourceUtf8Offset_ = 0; + int32_t sourceUtf16Offset_ = 0; + bool sourceOffsetsValid_ = true; ICUTokenizerConfigPtr config_; CompositeBreakIteratorPtr breaker_; }; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/tokenizer/ik/ik_tokenizer_factory.h b/be/src/storage/index/inverted/tokenizer/ik/ik_tokenizer_factory.h new file mode 100644 index 00000000000000..f4b76c2ab333e8 --- /dev/null +++ b/be/src/storage/index/inverted/tokenizer/ik/ik_tokenizer_factory.h @@ -0,0 +1,49 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +#pragma once + +#include "common/config.h" +#include "storage/index/inverted/analyzer/ik/IKTokenizer.h" +#include "storage/index/inverted/analyzer/ik/dic/Dictionary.h" +#include "storage/index/inverted/tokenizer/tokenizer_factory.h" + +namespace doris::segment_v2::inverted_index { + +class IKTokenizerFactory : public TokenizerFactory { +public: + explicit IKTokenizerFactory(bool use_smart) : _use_smart(use_smart) {} + ~IKTokenizerFactory() override = default; + + void initialize(const Settings& settings) override {} + + TokenizerPtr create() override { + auto ik_config = std::make_shared(_use_smart, true); + ik_config->setDictPath(config::inverted_index_dict_path + "/ik"); + Dictionary::initial(*ik_config); + return std::make_shared(ik_config, true, false); + } + + PositionCapability position_capability() const override { + return PositionCapability::kAlwaysUnitIncrement; + } + +private: + bool _use_smart; +}; + +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/tokenizer/keyword/keyword_tokenizer.h b/be/src/storage/index/inverted/tokenizer/keyword/keyword_tokenizer.h index 0534923444213e..ad2cd13058b0e4 100644 --- a/be/src/storage/index/inverted/tokenizer/keyword/keyword_tokenizer.h +++ b/be/src/storage/index/inverted/tokenizer/keyword/keyword_tokenizer.h @@ -43,8 +43,15 @@ class KeywordTokenizer : public DorisTokenizer { return nullptr; } int32_t length = std::min(_char_length, MAX_TOKEN_LENGTH_LIMIT); + while (length > 0 && length < _char_length && + U8_IS_TRAIL(static_cast(_char_buffer[length]))) { + --length; + } std::string_view term(_char_buffer, length); set(token, term); + set_source_byte_offsets(term, 0); + token->setStartOffset(correct_source_offset(0)); + token->setEndOffset(correct_source_offset(length)); return token; } return nullptr; @@ -68,4 +75,4 @@ class KeywordTokenizer : public DorisTokenizer { int32_t _char_length = 0; }; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp index 33a39c1f0ae183..bfdd98cb13a52f 100644 --- a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp +++ b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp @@ -18,6 +18,7 @@ #include "storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h" #include "common/exception.h" +#include "util/utf8_check.h" namespace doris::segment_v2::inverted_index { @@ -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_start_offset(_offset)); + token->setEndOffset( + correct_source_offset(_offset + static_cast(_utf8_buffer.size()))); ++_gram_size; return token; @@ -91,6 +96,12 @@ void NGramTokenizer::reset() { _char_buffer = nullptr; _char_offset = 0; _char_length = _in->read((const void**)&_char_buffer, 0, static_cast(_in->size())); + // Malformed bytes are skipped while the valid neighbours are still indexed. Only the + // offset-aware path rejects them, because a source byte span cannot be mapped through them. + if (_source_byte_offsets_enabled && _char_length > 0 && + !validate_utf8(_char_buffer, _char_length)) { + throw Exception(ErrorCode::INVALID_ARGUMENT, "NGram tokenizer input is not valid UTF-8"); + } } void NGramTokenizer::init(int32_t min_gram, int32_t max_gram, bool edges_only) { diff --git a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h index ffc45bf3de9134..ecffe1564b6fd5 100644 --- a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h +++ b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h @@ -44,7 +44,7 @@ class NGramTokenizer : public DorisTokenizer { void update_last_non_token_char(); void consume() { - auto c = static_cast(_buffer[_buffer_start++]); + auto c = _buffer[_buffer_start++]; _offset += U8_LENGTH(c); } diff --git a/be/src/storage/index/inverted/tokenizer/pinyin/pinyin_tokenizer.cpp b/be/src/storage/index/inverted/tokenizer/pinyin/pinyin_tokenizer.cpp index 7f80ffdf2eeefd..4e9d477a98ebbe 100644 --- a/be/src/storage/index/inverted/tokenizer/pinyin/pinyin_tokenizer.cpp +++ b/be/src/storage/index/inverted/tokenizer/pinyin/pinyin_tokenizer.cpp @@ -48,6 +48,11 @@ PinyinTokenizer::PinyinTokenizer(std::shared_ptr= '0' && r.cp <= '9'); if (is_ascii_context) { - if (ascii_buff_start_byte < 0) ascii_buff_start_byte = r.byte_start; if (is_alnum && config_->keepNoneChinese) { if (config_->keepNoneChineseTogether) { + // The buffer only holds letters and digits, so remember where each one + // came from instead of assuming they are contiguous in the source. + if (ascii_buff.empty()) { + ascii_buff_start_byte = r.byte_start; + } ascii_buff.push_back(static_cast(r.cp)); + // Ignored offsets are replaced by the whole input span in next(). + if (!config_->ignorePinyinOffset) { + ascii_buff_rune_starts_.push_back(r.byte_start); + ascii_buff_rune_ends_.push_back(r.byte_end); + } } else { position_++; std::string single_char(1, static_cast(r.cp)); @@ -203,17 +217,30 @@ Token* PinyinTokenizer::next(Token* token) { candidate_offset_++; const std::string& text = item.term; - size_t size = std::min(text.size(), static_cast(LUCENE_MAX_WORD_LEN)); + // Clip on a rune boundary so a split rune is never published. + const size_t size = + utf8_prefix_at_most(text, static_cast(LUCENE_MAX_WORD_LEN)).first; token->setNoCopy(text.data(), 0, static_cast(size)); + int32_t start = item.start_offset; + int32_t end = item.end_offset; if (config_->ignorePinyinOffset) { - int total_byte_length = runes_.empty() ? 0 : runes_.back().byte_end; - token->setStartOffset(0); - token->setEndOffset(total_byte_length); - } else { - token->setStartOffset(item.start_offset); - token->setEndOffset(item.end_offset); + start = 0; + end = runes_.empty() ? 0 : runes_.back().byte_end; + } else if (size < text.size()) { + // A clipped candidate must not claim the source of what it did not emit; when the + // candidate is the source slice itself, that source is its own prefix. + const int32_t clipped_start = std::clamp(start, 0, _char_length); + const int32_t clipped_end = std::clamp(end, clipped_start, _char_length); + if (std::string_view(_char_buffer + clipped_start, clipped_end - clipped_start) == + text) { + start = clipped_start; + end = clipped_start + static_cast(size); + } } + token->setStartOffset(correct_source_start_offset(start)); + token->setEndOffset(correct_source_offset(end)); + publishCandidateProvenance(std::string_view(text.data(), size), start, end); int offset = item.position - last_increment_position_; if (offset < 0) offset = 0; @@ -226,6 +253,38 @@ Token* PinyinTokenizer::next(Token* token) { return nullptr; } +// Publish exact rune boundaries when the candidate is the untouched source slice, otherwise a +// conservative span over the whole candidate range; both are projected through the char filter. +void PinyinTokenizer::publishCandidateProvenance(std::string_view term, int32_t start, + int32_t end) { + has_current_span_ = false; + _source_byte_offsets.clear(); + _source_byte_end_offsets.clear(); + if (!_source_byte_offsets_enabled) { + return; + } + start = std::clamp(start, 0, _char_length); + end = std::clamp(end, start, _char_length); + const std::string_view source(_char_buffer + start, end - start); + if (source == term) { + set_source_byte_offsets(term, source, start); + if (!_source_byte_offsets.empty()) { + return; + } + } + current_span_end_ = correct_source_offset(end) - correct_source_start_offset(start); + has_current_span_ = true; +} + +bool PinyinTokenizer::get_conservative_source_byte_span(int32_t& start, int32_t& end) const { + if (!has_current_span_) { + return false; + } + start = 0; + end = current_span_end_; + return true; +} + void PinyinTokenizer::addCandidate(const TermItem& item_in) { std::string term = item_in.term; @@ -299,24 +358,46 @@ void PinyinTokenizer::parseBuff(std::string& ascii_buff, int& ascii_buff_start_b if (ascii_buff.empty()) return; if (!config_->keepNoneChinese) { ascii_buff.clear(); + ascii_buff_rune_starts_.clear(); + ascii_buff_rune_ends_.clear(); ascii_buff_start_byte = -1; return; } - // Use byte offset for ASCII buffer - // ascii_buff_start_byte is the byte position where the buffer started - int32_t buff_byte_size = static_cast(ascii_buff.size()); - int32_t buff_end_byte = ascii_buff_start_byte + buff_byte_size; + // Each buffered letter keeps its own source range, so a candidate spans from the first letter + // it covers to the end of the last one even when punctuation was skipped in between. Without + // offset tracking the ranges are never used, so letter positions stand in for them. + const bool tracked = !config_->ignorePinyinOffset; + DCHECK(!tracked || ascii_buff.size() == ascii_buff_rune_starts_.size()); + auto letter_start = [&](size_t i) { + return tracked ? ascii_buff_rune_starts_[i] + : ascii_buff_start_byte + static_cast(i); + }; + auto letter_end = [&](size_t i) { + return tracked ? ascii_buff_rune_ends_[i] + : ascii_buff_start_byte + static_cast(i) + 1; + }; + const int32_t buff_end_byte = letter_end(ascii_buff.size() - 1); if (config_->noneChinesePinyinTokenize) { std::vector result = PinyinAlphabetTokenizer::walk(ascii_buff); - int32_t start = ascii_buff_start_byte; + size_t covered = 0; + int32_t fixed_start = ascii_buff_start_byte; for (const std::string& t : result) { - int32_t end = config_->fixedPinyinOffset ? start + 1 - : start + static_cast(t.length()); + if (covered >= ascii_buff.size()) { + break; + } + const size_t last = std::min(covered + t.length(), ascii_buff.size()) - 1; + int32_t start = letter_start(covered); + int32_t end = letter_end(last); + if (config_->fixedPinyinOffset) { + start = fixed_start; + end = fixed_start + 1; + fixed_start = end; + } position_++; addCandidate(t, start, end, position_); - start = end; + covered = last + 1; } } else if (config_->keepFirstLetter || config_->keepSeparateFirstLetter || config_->keepFullPinyin || !config_->keepNoneChineseInJoinedFullPinyin) { @@ -324,6 +405,8 @@ void PinyinTokenizer::parseBuff(std::string& ascii_buff, int& ascii_buff_start_b addCandidate(ascii_buff, ascii_buff_start_byte, buff_end_byte, position_); } ascii_buff.clear(); + ascii_buff_rune_starts_.clear(); + ascii_buff_rune_ends_.clear(); ascii_buff_start_byte = -1; } diff --git a/be/src/storage/index/inverted/tokenizer/pinyin/pinyin_tokenizer.h b/be/src/storage/index/inverted/tokenizer/pinyin/pinyin_tokenizer.h index 23a116c0195eba..b454aac68d7678 100644 --- a/be/src/storage/index/inverted/tokenizer/pinyin/pinyin_tokenizer.h +++ b/be/src/storage/index/inverted/tokenizer/pinyin/pinyin_tokenizer.h @@ -19,6 +19,7 @@ #include #include +#include #include #include @@ -38,6 +39,13 @@ class PinyinTokenizer : public DorisTokenizer { Token* next(Token* token) override; void reset() override; + bool get_conservative_source_byte_span(int32_t& start, int32_t& end) const override; + +#ifdef BE_TEST + size_t ascii_scratch_capacity_for_test() const { + return ascii_buff_rune_starts_.capacity() + ascii_buff_rune_ends_.capacity(); + } +#endif private: bool done_; @@ -68,7 +76,15 @@ class PinyinTokenizer : public DorisTokenizer { std::vector runes_; + // Source range of every letter in the pending ASCII buffer; skipped punctuation leaves gaps. + std::vector ascii_buff_rune_starts_; + std::vector ascii_buff_rune_ends_; + // Conservative provenance of the current candidate when no exact rune map applies. + int32_t current_span_end_ = 0; + bool has_current_span_ = false; + bool hasMoreTokens() const; + void publishCandidateProvenance(std::string_view term, int32_t start, int32_t end); void addCandidate(const TermItem& item); void setTerm(std::string term, int start_offset, int end_offset, int position); diff --git a/be/src/storage/index/inverted/tokenizer/pinyin/smart_get_word.cpp b/be/src/storage/index/inverted/tokenizer/pinyin/smart_get_word.cpp index 1e554fe5fa3381..91a054f39f8404 100644 --- a/be/src/storage/index/inverted/tokenizer/pinyin/smart_get_word.cpp +++ b/be/src/storage/index/inverted/tokenizer/pinyin/smart_get_word.cpp @@ -72,40 +72,34 @@ void SmartGetWord::reset(const std::vector& runes) { } std::string SmartGetWord::frontWords() { - if (i_ >= runes_.size()) { - return NULL_RESULT; - } - - size_t tempEndIndex = 0; // Track the end position (exclusive) for matched word - branch_ = forest_; - - for (size_t j = i_; j < runes_.size(); j++) { - UChar32 cp = runes_[j].cp; - - // Move to next branch first - branch_ = branch_->getBranch(cp); - if (!branch_) { - break; - } + // Skip unmatched runes iteratively so long non-dictionary input cannot exhaust the stack. + for (; i_ < runes_.size(); i_++) { + size_t tempEndIndex = 0; // Track the end position (exclusive) for matched word + branch_ = forest_; + + for (size_t j = i_; j < runes_.size(); j++) { + UChar32 cp = runes_[j].cp; + + // Move to next branch first + branch_ = branch_->getBranch(cp); + if (!branch_) { + break; + } - // Then check if current branch represents a word end - if (branch_->getStatus() == SmartForest::WORD_END || - branch_->getStatus() == SmartForest::WORD_CONTINUE) { - tempEndIndex = j + 1; // End position is exclusive, so j+1 - temp_offe_ = i_; - param_ = branch_->getParam(); + // Then check if current branch represents a word end + if (branch_->getStatus() == SmartForest::WORD_END || + branch_->getStatus() == SmartForest::WORD_CONTINUE) { + tempEndIndex = j + 1; // End position is exclusive, so j+1 + temp_offe_ = i_; + param_ = branch_->getParam(); + } } - } - if (tempEndIndex > i_) { - offe = runes_[i_].byte_start; - std::string result = runes_to_utf8(runes_, i_, tempEndIndex); - i_ = tempEndIndex; - return result; - } else { - if (i_ < runes_.size()) { - i_++; - return frontWords(); + if (tempEndIndex > i_) { + offe = runes_[i_].byte_start; + std::string result = runes_to_utf8(runes_, i_, tempEndIndex); + i_ = tempEndIndex; + return result; } } diff --git a/be/src/storage/index/inverted/tokenizer/standard/standard_tokenizer.h b/be/src/storage/index/inverted/tokenizer/standard/standard_tokenizer.h index 8df5b6ad5e5799..af83b763ab5727 100644 --- a/be/src/storage/index/inverted/tokenizer/standard/standard_tokenizer.h +++ b/be/src/storage/index/inverted/tokenizer/standard/standard_tokenizer.h @@ -17,6 +17,8 @@ #pragma once +#include + #include "storage/index/inverted/tokenizer/standard/standard_tokenizer_impl.h" #include "storage/index/inverted/tokenizer/tokenizer.h" @@ -37,8 +39,13 @@ class StandardTokenizer : public DorisTokenizer { std::string_view term = _scanner->get_text(); size_t token_length = _scanner->yylength(); - if (token_length <= _max_token_length) { + if (std::cmp_less_equal(token_length, _max_token_length)) { set(t, term, _skipped_positions + 1); + const int32_t token_start = _scanner->get_token_start_offset(); + const int32_t token_end = _scanner->get_token_end_offset(); + set_source_byte_offsets(term, token_start); + t->setStartOffset(correct_source_start_offset(token_start)); + t->setEndOffset(correct_source_offset(token_end)); return t; } else { _skipped_positions++; @@ -78,4 +85,4 @@ class StandardTokenizer : public DorisTokenizer { int32_t _max_token_length = DEFAULT_MAX_TOKEN_LENGTH; }; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/tokenizer/standard/standard_tokenizer_impl.h b/be/src/storage/index/inverted/tokenizer/standard/standard_tokenizer_impl.h index 2ebc1e9676647d..a039a39f9da0c6 100644 --- a/be/src/storage/index/inverted/tokenizer/standard/standard_tokenizer_impl.h +++ b/be/src/storage/index/inverted/tokenizer/standard/standard_tokenizer_impl.h @@ -166,6 +166,10 @@ class StandardTokenizerImpl { return {_zz_buffer.data() + _zz_start_read, (size_t)(_zz_marked_pos - _zz_start_read)}; } + int32_t get_token_start_offset() const { return _zz_buffer_offset + _zz_start_read; } + + int32_t get_token_end_offset() const { return _zz_buffer_offset + _zz_marked_pos; } + inline void yyreset(const ReaderPtr& reader) { _zz_reader = reader; _zz_at_eof = false; @@ -174,6 +178,7 @@ class StandardTokenizerImpl { _zz_start_read = 0; _zz_end_read = 0; _zz_final_partial_char = 0; + _zz_buffer_offset = 0; _zz_lexical_state = YYINITIAL; } @@ -190,6 +195,7 @@ class StandardTokenizerImpl { bool zz_refill() { if (_zz_start_read > 0) { + _zz_buffer_offset += _zz_start_read; _zz_end_read += _zz_final_partial_char; _zz_final_partial_char = 0; @@ -291,9 +297,10 @@ class StandardTokenizerImpl { int32_t _zz_start_read = 0; int32_t _zz_end_read = 0; int32_t _zz_final_partial_char = 0; + int32_t _zz_buffer_offset = 0; bool _zz_at_eof = false; }; using StandardTokenizerImplPtr = std::unique_ptr; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/src/storage/index/inverted/tokenizer/tokenizer.h b/be/src/storage/index/inverted/tokenizer/tokenizer.h index 0b4ed4d4ab1ebe..bb93911ed03ae7 100644 --- a/be/src/storage/index/inverted/tokenizer/tokenizer.h +++ b/be/src/storage/index/inverted/tokenizer/tokenizer.h @@ -19,8 +19,14 @@ #include +#include +#include +#include +#include #include +#include +#include "storage/index/inverted/char_filter/char_filter.h" #include "storage/index/inverted/token_stream.h" namespace doris::segment_v2::inverted_index { @@ -39,12 +45,161 @@ class DorisTokenizer : public Tokenizer, public DorisTokenStream { using Tokenizer::reset; // Only use the parameterless reset method - void reset() override { _in = _in_pending; }; + void reset() override { + _in = _in_pending; + _source_byte_offsets.clear(); + _source_byte_end_offsets.clear(); + release_oversized_scratch(_source_byte_offsets); + release_oversized_scratch(_source_byte_end_offsets); + release_oversized_scratch(_source_offsets_scratch); + }; + + std::span get_source_byte_offsets() const override { + return _source_byte_offsets_enabled ? std::span {_source_byte_offsets} + : std::span {}; + } + + std::span get_source_byte_end_offsets() const override { + return _source_byte_offsets_enabled ? std::span {_source_byte_end_offsets} + : std::span {}; + } + + void set_source_byte_offsets_enabled(bool enabled) override { + _source_byte_offsets_enabled = enabled; + } + + size_t source_byte_offsets_capacity_for_test() const { + return _source_byte_offsets.capacity() + _source_byte_end_offsets.capacity(); + } protected: + int32_t correct_source_offset(int32_t offset) const { + const auto* char_filter = dynamic_cast(_in.get()); + return char_filter == nullptr ? offset : char_filter->correct_offset(offset); + } + + // Correct the offset a term starts at; see DorisCharFilter::correct_start_offset(). + int32_t correct_source_start_offset(int32_t offset) const { + const auto* char_filter = dynamic_cast(_in.get()); + return char_filter == nullptr ? offset : char_filter->correct_start_offset(offset); + } + + void set_source_byte_offsets(std::string_view term, int32_t source_start) { + set_source_byte_offsets(term, term, source_start); + } + + void set_source_byte_offsets(std::string_view term, std::string_view source, + int32_t source_start) { + _source_byte_offsets.clear(); + _source_byte_end_offsets.clear(); + if (!_source_byte_offsets_enabled) { + return; + } + + const auto* char_filter = dynamic_cast(_in.get()); + const int32_t corrected_start = char_filter == nullptr + ? source_start + : char_filter->correct_start_offset(source_start); + std::vector& source_offsets = _source_offsets_scratch; + source_offsets.clear(); + source_offsets.push_back(0); + const char* data = source.data(); + const auto length = static_cast(source.size()); + int32_t offset = 0; + while (offset < length) { + UChar32 code_point; + U8_NEXT(data, offset, length, code_point); + if (code_point < 0) { + return; + } + source_offsets.push_back(char_filter == nullptr + ? offset + : char_filter->correct_offset(source_start + offset) - + corrected_start); + } + + const int32_t term_runes = count_utf8_runes(term); + if (term_runes < 0) { + return; + } + publish_source_byte_offsets(term_runes, source_offsets); + } + + // Publish per-rune source boundaries for a term, widening repeated boundaries into + // conservative start/end spans so no rune claims an empty source range. source_offsets is + // the caller's reusable scratch: the common case swaps it with the published vector so + // both keep their capacity and ordinary tokens stop allocating after warm-up. + void publish_source_byte_offsets(int32_t term_runes, std::vector& source_offsets) { + _source_byte_offsets.clear(); + _source_byte_end_offsets.clear(); + if (!_source_byte_offsets_enabled || term_runes < 0 || source_offsets.empty()) { + return; + } + if (static_cast(term_runes + 1) == source_offsets.size()) { + const bool strictly_increasing = + std::ranges::adjacent_find(source_offsets, std::greater_equal<>()) == + source_offsets.end(); + if (strictly_increasing) { + _source_byte_offsets.swap(source_offsets); + return; + } + + _source_byte_offsets.resize(source_offsets.size()); + _source_byte_end_offsets.resize(term_runes); + for (int32_t i = 0; i < term_runes; ++i) { + int32_t start = source_offsets[i]; + int32_t end = source_offsets[i + 1]; + if (start == end) { + int32_t previous = i; + while (previous > 0 && source_offsets[previous] == start) { + --previous; + } + if (source_offsets[previous] != start) { + start = source_offsets[previous]; + } else { + int32_t next = i + 1; + while (next < term_runes && source_offsets[next] == end) { + ++next; + } + end = source_offsets[next]; + } + } + _source_byte_offsets[i] = start; + _source_byte_end_offsets[i] = end; + } + _source_byte_offsets.back() = source_offsets.back(); + return; + } + + const int32_t source_length = source_offsets.back(); + _source_byte_offsets.assign(term_runes + 1, 0); + _source_byte_offsets.back() = source_length; + _source_byte_end_offsets.assign(term_runes, source_length); + } + + static int32_t count_utf8_runes(std::string_view text) { + const char* data = text.data(); + const auto length = static_cast(text.size()); + int32_t offset = 0; + int32_t runes = 0; + while (offset < length) { + UChar32 code_point; + U8_NEXT(data, offset, length, code_point); + if (code_point < 0) { + return -1; + } + ++runes; + } + return runes; + } + ReaderPtr _in; ReaderPtr _in_pending; + std::vector _source_byte_offsets; + std::vector _source_byte_end_offsets; + std::vector _source_offsets_scratch; + bool _source_byte_offsets_enabled {false}; }; using TokenizerPtr = std::shared_ptr; -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/test/exprs/function/function_search_nested_test.cpp b/be/test/exprs/function/function_search_nested_test.cpp index 1861e26131bc10..c7cde2f695a50f 100644 --- a/be/test/exprs/function/function_search_nested_test.cpp +++ b/be/test/exprs/function/function_search_nested_test.cpp @@ -30,11 +30,14 @@ #include "core/block/block.h" #include "exprs/function/function_search.h" #include "exprs/function/variant_inverted_index_search.h" +#include "runtime/exec_env.h" +#include "runtime/index_policy/index_policy_mgr.h" #include "storage/index/inverted/query_v2/bit_set_query/bit_set_query.h" #include "storage/index/inverted/query_v2/query.h" #include "storage/index/inverted/query_v2/weight.h" #include "storage/segment/variant/nested_group_provider.h" #include "storage/segment/variant/variant_column_reader.h" +#include "util/defer_op.h" namespace doris { @@ -688,4 +691,35 @@ TEST_F(FunctionSearchNestedTest, NestedRootFallbackViaToplevelAPI) { } } +TEST(SearchAnalyzerContextTest, ReportsWrongFamilyComponentAsStatus) { + IndexPolicyMgr policy_mgr; + auto* exec_env = ExecEnv::GetInstance(); + auto* original_policy_mgr = exec_env->index_policy_mgr(); + exec_env->_index_policy_mgr = &policy_mgr; + Defer restore_policy_mgr([&] { exec_env->_index_policy_mgr = original_policy_mgr; }); + + // Replay can keep a component whose family no longer matches how the analyzer uses it. + TIndexPolicy char_filter; + char_filter.id = 300; + char_filter.name = "Wrong"; + char_filter.type = TIndexPolicyType::CHAR_FILTER; + char_filter.properties["type"] = "char_replace"; + TIndexPolicy analyzer; + analyzer.id = 301; + analyzer.name = "wrong_family_search_analyzer"; + analyzer.type = TIndexPolicyType::ANALYZER; + analyzer.properties["tokenizer"] = "keyword"; + analyzer.properties["token_filter"] = "Wrong"; + policy_mgr.apply_policy_changes({char_filter, analyzer}, {}); + + const std::map properties = { + {"analyzer", "wrong_family_search_analyzer"}}; + auto analyzer_ctx = build_search_analyzer_context(properties, "wrong_family_search_analyzer"); + ASSERT_FALSE(analyzer_ctx.has_value()); + EXPECT_EQ(analyzer_ctx.error().code(), ErrorCode::INVERTED_INDEX_ANALYZER_ERROR); + + const std::map valid = {{"parser", "english"}}; + EXPECT_TRUE(build_search_analyzer_context(valid, "english").has_value()); +} + } // namespace doris diff --git a/be/test/exprs/function/function_search_test.cpp b/be/test/exprs/function/function_search_test.cpp index c82e9f6096b1ab..31b1a701423851 100644 --- a/be/test/exprs/function/function_search_test.cpp +++ b/be/test/exprs/function/function_search_test.cpp @@ -30,6 +30,7 @@ #include #include +#include "common/exception.h" #include "core/assert_cast.h" #include "core/block/block.h" #include "core/column/column_nullable.h" @@ -185,6 +186,39 @@ class DummyInvertedIndexReader final : public segment_v2::InvertedIndexReader { segment_v2::InvertedIndexReaderType _reader_type = segment_v2::InvertedIndexReaderType::BKD; }; +// A bound SNII reader whose query throws, standing in for anything inside the search that +// raises instead of returning a Status. +class ThrowingSniiInvertedIndexReader final : public segment_v2::InvertedIndexReader { +public: + ThrowingSniiInvertedIndexReader(const TabletIndex* index_meta, + std::shared_ptr index_file_reader) + : segment_v2::InvertedIndexReader(index_meta, std::move(index_file_reader)) {} + + Status new_iterator(std::unique_ptr* /*iterator*/) override { + return Status::OK(); + } + + Status query(const segment_v2::IndexQueryContextPtr& /*context*/, + const std::string& /*column_name*/, const Field& /*query_value*/, + segment_v2::InvertedIndexQueryType /*query_type*/, + std::shared_ptr& /*bit_map*/, + const InvertedIndexAnalyzerCtx* /*analyzer_ctx*/ = nullptr) override { + throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR, + "token stream failed on first use"); + } + + Status try_query(const segment_v2::IndexQueryContextPtr& /*context*/, + const std::string& /*column_name*/, const Field& /*query_value*/, + segment_v2::InvertedIndexQueryType /*query_type*/, + size_t* /*count*/) override { + return Status::OK(); + } + + segment_v2::InvertedIndexReaderType type() override { + return segment_v2::InvertedIndexReaderType::FULLTEXT; + } +}; + class RejectingCluceneIndexFileReader final : public segment_v2::IndexFileReader { public: explicit RejectingCluceneIndexFileReader( @@ -3885,4 +3919,43 @@ TEST_F(FunctionSearchTest, TestSearcherCacheHandlesLifetime) { } // NESTED clause tests moved to function_search_nested_test.cpp +TEST_F(FunctionSearchTest, SearchConvertsExceptionInsideSearchToStatus) { + // VSearchExpr enters this overload directly, outside IFunction::execute(), so an exception + // raised anywhere inside the search has to come back as a Status. The bound SNII reader + // throws from its query, standing in for an analyzer whose first token stream fails: every + // tokenizer that loads lazily does so through a process-wide once-guard, so a real one cannot + // be made to fail deterministically inside a shared test binary. + std::map index_properties { + {INVERTED_INDEX_PARSER_KEY, INVERTED_INDEX_PARSER_STANDARD}}; + auto index_meta = make_test_inverted_index(41, index_properties); + auto index_file_reader = std::make_shared(); + auto reader = std::make_shared(&index_meta, index_file_reader); + segment_v2::InvertedIndexIterator iterator; + iterator.add_reader(segment_v2::InvertedIndexReaderType::FULLTEXT, reader); + + std::unordered_map data_type_with_names; + data_type_with_names.emplace( + "body", IndexFieldNameAndTypePair {"body", std::make_shared()}); + std::unordered_map iterators; + iterators["body"] = &iterator; + + TSearchParam search_param; + search_param.original_dsl = "body:hello"; + search_param.root = make_leaf_clause("TERM", "hello"); + TSearchFieldBinding binding; + binding.field_name = "body"; + binding.slot_index = 0; + binding.index_properties = index_properties; + binding.__isset.index_properties = true; + search_param.field_bindings = {binding}; + + InvertedIndexResultBitmap result; + Status status; + ASSERT_NO_THROW(status = function_search->evaluate_inverted_index_with_search_param( + search_param, data_type_with_names, iterators, 10, result, false)); + EXPECT_FALSE(status.ok()) << status; + EXPECT_NE(status.to_string().find("token stream failed on first use"), std::string::npos) + << status; +} + } // namespace doris diff --git a/be/test/runtime/index_policy/index_policy_mgr_test.cpp b/be/test/runtime/index_policy/index_policy_mgr_test.cpp index 3d2e40042e5fbe..8f9b50caecccae 100644 --- a/be/test/runtime/index_policy/index_policy_mgr_test.cpp +++ b/be/test/runtime/index_policy/index_policy_mgr_test.cpp @@ -28,10 +28,54 @@ #include "runtime/exec_env.h" #include "storage/index/inverted/analysis_factory_mgr.h" #include "storage/index/inverted/analyzer/analyzer.h" +#include "storage/index/inverted/inverted_index_parser.h" #include "util/defer_op.h" namespace doris { -namespace {} // namespace +namespace { + +TIndexPolicy make_analyzer_policy(int64_t id, std::string name, std::string tokenizer) { + TIndexPolicy policy; + policy.id = id; + policy.name = std::move(name); + policy.type = TIndexPolicyType::ANALYZER; + policy.properties["tokenizer"] = std::move(tokenizer); + return policy; +} + +TIndexPolicy make_tokenizer_policy(int64_t id, std::string name, std::string type) { + TIndexPolicy policy; + policy.id = id; + policy.name = std::move(name); + policy.type = TIndexPolicyType::TOKENIZER; + policy.properties["type"] = std::move(type); + return policy; +} + +size_t count_analyzer_terms(IndexPolicyMgr& manager, const std::string& name) { + auto analyzer = manager.get_policy_by_name(name); + auto reader = segment_v2::inverted_index::InvertedIndexAnalyzer::create_reader({}); + const std::string text = "one two"; + reader->init(text.data(), static_cast(text.size()), false); + return segment_v2::inverted_index::InvertedIndexAnalyzer::get_analyse_result(reader, + analyzer.get()) + .size(); +} + +void assert_collision_sequence(const std::vector& updates, int64_t older_id, + int64_t newer_id) { + IndexPolicyMgr manager; + manager.apply_policy_changes(updates, {}); + EXPECT_EQ(count_analyzer_terms(manager, "Colliding_Analyzer"), 2); + EXPECT_EQ(manager.get_index_policys().size(), 2); + + manager.apply_policy_changes({}, {older_id}); + EXPECT_EQ(count_analyzer_terms(manager, "Colliding_Analyzer"), 2); + manager.apply_policy_changes({}, {newer_id}); + EXPECT_THROW(manager.get_policy_by_name("colliding_analyzer"), Exception); +} + +} // namespace class IndexPolicyMgrTest : public testing::Test { protected: @@ -119,13 +163,64 @@ TEST_F(IndexPolicyMgrTest, TestApplyPolicyChanges) { ASSERT_NE(policies[duplicateId.id].name, "duplicate_id"); } - // Test duplicate name + // Legacy duplicate names are retained, with the higher ID authoritative. TIndexPolicy duplicateName; duplicateName.id = 8; duplicateName.name = "tokenizer2"; // Same as tokenizer2 mgr.apply_policy_changes({duplicateName}, {}); policies = mgr.get_index_policys(); - ASSERT_FALSE(policies.contains(duplicateName.id)); + ASSERT_TRUE(policies.contains(duplicateName.id)); +} + +TEST_F(IndexPolicyMgrTest, NormalizedNameCollisionUsesHigherIdIndependentOfArrivalOrder) { + TIndexPolicy older = make_analyzer_policy(100, "COLLIDING_ANALYZER", "keyword"); + TIndexPolicy newer = make_analyzer_policy(101, "colliding_analyzer", "standard"); + + assert_collision_sequence({older, newer}, older.id, newer.id); + assert_collision_sequence({newer, older}, older.id, newer.id); + + IndexPolicyMgr manager; + manager.apply_policy_changes({older, newer}, {}); + manager.apply_policy_changes({}, {newer.id}); + EXPECT_EQ(count_analyzer_terms(manager, "COLLIDING_ANALYZER"), 1); +} + +TEST_F(IndexPolicyMgrTest, LegacyExactNameCollisionPreservesDependentAnalyzerTerms) { + TIndexPolicy historical = make_tokenizer_policy(100, "IK_SMART", "standard"); + TIndexPolicy newer = make_tokenizer_policy(101, "ik_smart", "keyword"); + TIndexPolicy upper_dependent = make_analyzer_policy(102, "legacy_exact_analyzer", "IK_SMART"); + TIndexPolicy lower_dependent = + make_analyzer_policy(103, "normalized_exact_analyzer", "ik_smart"); + + for (const std::vector& updates : + {std::vector {historical, newer, upper_dependent, lower_dependent}, + std::vector {lower_dependent, upper_dependent, newer, historical}}) { + IndexPolicyMgr manager; + manager.apply_policy_changes(updates, {}); + + EXPECT_EQ(count_analyzer_terms(manager, upper_dependent.name), 2); + EXPECT_EQ(count_analyzer_terms(manager, lower_dependent.name), 1); + } +} + +TEST_F(IndexPolicyMgrTest, MatchDispatchPreservesReplayedExactIkTerms) { + IndexPolicyMgr manager; + const auto historical = make_analyzer_policy(110, "IK", "keyword"); + manager.apply_policy_changes({historical}, {}); + + const auto config = AnalyzerConfigParser::parse("IK", "english"); + ASSERT_TRUE(config.uses_provider()); + EXPECT_EQ(config.provider_name, historical.name); + EXPECT_EQ(config.analyzer_key, build_analyzer_key_from_properties({{"analyzer", "IK"}})); + auto analyzer = manager.get_analyzer_provider_by_name(config.provider_name, {})->get_analyzer(); + auto reader = segment_v2::inverted_index::InvertedIndexAnalyzer::create_reader({}); + const std::string text = "abc def"; + reader->init(text.data(), static_cast(text.size()), false); + const auto terms = segment_v2::inverted_index::InvertedIndexAnalyzer::get_analyse_result( + reader, analyzer.get()); + ASSERT_EQ(terms.size(), 1); + EXPECT_EQ(terms.front().get_single_term(), text); + EXPECT_EQ(count_analyzer_terms(manager, historical.name), 1); } TEST_F(IndexPolicyMgrTest, TestGetPolicyByName) { @@ -185,6 +280,218 @@ TEST_F(IndexPolicyMgrTest, TestTokenFilterProcessing) { ASSERT_NE(emptyAnalyzer, nullptr); } +TEST_F(IndexPolicyMgrTest, ReplayedPoliciesRejectWrongExactNestedFilterTypes) { + IndexPolicyMgr manager; + + auto make_filter = [](int64_t id, std::string name, TIndexPolicyType::type policy_type, + std::string factory_type) { + TIndexPolicy policy; + policy.id = id; + policy.name = std::move(name); + policy.type = policy_type; + policy.properties["type"] = std::move(factory_type); + return policy; + }; + auto make_container = [](int64_t id, std::string name, TIndexPolicyType::type policy_type, + std::string property, std::string filter_name) { + TIndexPolicy policy; + policy.id = id; + policy.name = std::move(name); + policy.type = policy_type; + if (policy_type == TIndexPolicyType::ANALYZER) { + policy.properties["tokenizer"] = "keyword"; + } + policy.properties[std::move(property)] = std::move(filter_name); + return policy; + }; + + manager.apply_policy_changes( + {make_filter(120, "AnalyzerToken", TIndexPolicyType::CHAR_FILTER, "char_replace"), + make_filter(121, "analyzertoken", TIndexPolicyType::TOKEN_FILTER, "lowercase"), + make_container(122, "wrong_analyzer_token_filter", TIndexPolicyType::ANALYZER, + "token_filter", "AnalyzerToken"), + make_filter(130, "AnalyzerChar", TIndexPolicyType::TOKEN_FILTER, "lowercase"), + make_filter(131, "analyzerchar", TIndexPolicyType::CHAR_FILTER, "char_replace"), + make_container(132, "wrong_analyzer_char_filter", TIndexPolicyType::ANALYZER, + "char_filter", "AnalyzerChar"), + make_filter(140, "NormalizerToken", TIndexPolicyType::CHAR_FILTER, "char_replace"), + make_filter(141, "normalizertoken", TIndexPolicyType::TOKEN_FILTER, "lowercase"), + make_container(142, "wrong_normalizer_token_filter", TIndexPolicyType::NORMALIZER, + "token_filter", "NormalizerToken"), + make_filter(150, "NormalizerChar", TIndexPolicyType::TOKEN_FILTER, "lowercase"), + make_filter(151, "normalizerchar", TIndexPolicyType::CHAR_FILTER, "char_replace"), + make_container(152, "wrong_normalizer_char_filter", TIndexPolicyType::NORMALIZER, + "char_filter", "NormalizerChar")}, + {}); + + auto expect_type_mismatch = [&manager](const std::string& name, + const std::string& expected_type) { + try { + manager.get_policy_by_name(name); + FAIL() << "Expected a nested filter type mismatch for " << name; + } catch (const Exception& exception) { + EXPECT_NE(std::string(exception.what()).find("expected " + expected_type), + std::string::npos); + } + }; + + expect_type_mismatch("wrong_analyzer_token_filter", "TOKEN_FILTER"); + expect_type_mismatch("wrong_analyzer_char_filter", "CHAR_FILTER"); + expect_type_mismatch("wrong_normalizer_token_filter", "TOKEN_FILTER"); + expect_type_mismatch("wrong_normalizer_char_filter", "CHAR_FILTER"); +} + +TEST_F(IndexPolicyMgrTest, ReplayedPoliciesRejectWrongExactTokenizerType) { + IndexPolicyMgr manager; + + // The exact name resolves to a char filter whose factory type is also a tokenizer type. + TIndexPolicy exact_char_filter; + exact_char_filter.id = 160; + exact_char_filter.name = "AnalyzerTokenizer"; + exact_char_filter.type = TIndexPolicyType::CHAR_FILTER; + exact_char_filter.properties["type"] = "empty"; + + TIndexPolicy normalized_tokenizer; + normalized_tokenizer.id = 161; + normalized_tokenizer.name = "analyzertokenizer"; + normalized_tokenizer.type = TIndexPolicyType::TOKENIZER; + normalized_tokenizer.properties["type"] = "standard"; + + TIndexPolicy analyzer; + analyzer.id = 162; + analyzer.name = "wrong_analyzer_tokenizer"; + analyzer.type = TIndexPolicyType::ANALYZER; + analyzer.properties["tokenizer"] = "AnalyzerTokenizer"; + + manager.apply_policy_changes({exact_char_filter, normalized_tokenizer, analyzer}, {}); + + try { + manager.get_policy_by_name("wrong_analyzer_tokenizer"); + FAIL() << "Expected a tokenizer type mismatch"; + } catch (const Exception& exception) { + EXPECT_NE(std::string(exception.what()).find("expected TOKENIZER"), std::string::npos) + << exception.what(); + } +} + +TEST_F(IndexPolicyMgrTest, BuiltinNormalizerWinsOverNormalizedLegacyPolicy) { + IndexPolicyMgr manager; + + // FE resolves "lowercase" to the built-in normalizer when only a case-distinct legacy + // policy exists, so BE must not bind that policy through the normalized fallback. + TIndexPolicy legacy; + legacy.id = 170; + legacy.name = "LOWERCASE"; + legacy.type = TIndexPolicyType::TOKEN_FILTER; + legacy.properties["type"] = "lowercase"; + manager.apply_policy_changes({legacy}, {}); + + EXPECT_NE(manager.get_policy_by_name("lowercase"), nullptr); + EXPECT_NE(manager.get_analyzer_by_name("lowercase"), nullptr); + std::string resolved_name; + std::string legacy_name; + auto provider = + manager.get_analyzer_provider_by_name("lowercase", {}, &resolved_name, &legacy_name); + EXPECT_NE(provider, nullptr); + EXPECT_EQ(resolved_name, "lowercase"); + EXPECT_TRUE(legacy_name.empty()); + + // The exact spelling still binds the replayed policy, which is not a top-level policy. + EXPECT_THROW(manager.get_policy_by_name("LOWERCASE"), Exception); +} + +TEST_F(IndexPolicyMgrTest, ExactCustomNormalizerDoesNotPublishBuiltinAlias) { + IndexPolicyMgr manager; + + // "lowercase" is reserved for the built-in normalizer, so an exact custom policy must not + // advertise it as a compatibility alias for reader selection. + TIndexPolicy legacy; + legacy.id = 180; + legacy.name = "LOWERCASE"; + legacy.type = TIndexPolicyType::NORMALIZER; + legacy.properties["token_filter"] = "asciifolding"; + manager.apply_policy_changes({legacy}, {}); + + std::string resolved_name; + std::string legacy_name; + auto provider = + manager.get_analyzer_provider_by_name("LOWERCASE", {}, &resolved_name, &legacy_name); + ASSERT_NE(provider, nullptr); + EXPECT_EQ(resolved_name, "LOWERCASE"); + EXPECT_TRUE(legacy_name.empty()) << legacy_name; +} + +TEST_F(IndexPolicyMgrTest, PolicyOnTheCanonicalNameLeavesOtherSpellingsOnTheBuiltin) { + IndexPolicyMgr manager; + + // A policy replayed under the canonical name shadows the built-in normalizer for that exact + // spelling only. FE relies on this to keep an index bound to the built-in: it persists the + // user's spelling when the canonical one is taken. + TIndexPolicy shadow; + shadow.id = 190; + shadow.name = "lowercase"; + shadow.type = TIndexPolicyType::NORMALIZER; + shadow.properties["token_filter"] = "asciifolding"; + manager.apply_policy_changes({shadow}, {}); + + // asciifolding leaves "Ab" alone, so the emitted term tells the two bindings apart. + const std::string text = "Ab"; + auto analyze = [&](const std::string& name) { + auto analyzer = manager.get_analyzer_by_name(name); + auto reader = segment_v2::inverted_index::InvertedIndexAnalyzer::create_reader({}); + reader->init(text.data(), static_cast(text.size()), false); + auto terms = segment_v2::inverted_index::InvertedIndexAnalyzer::get_analyse_result( + reader, analyzer.get()); + EXPECT_EQ(terms.size(), 1) << name; + return terms.empty() ? std::string() : terms[0].get_single_term(); + }; + + EXPECT_EQ(analyze("lowercase"), "Ab"); + EXPECT_EQ(analyze("LowerCase"), "ab"); + EXPECT_EQ(analyze("LOWERCASE"), "ab"); +} + +TEST_F(IndexPolicyMgrTest, BuiltinTokenizerNamesAreCaseInsensitive) { + TIndexPolicy analyzer; + analyzer.id = 20; + analyzer.name = "uppercase_ik_analyzer"; + analyzer.type = TIndexPolicyType::ANALYZER; + analyzer.properties["tokenizer"] = "IK_SMART"; + mgr.apply_policy_changes({analyzer}, {}); + + auto built = mgr.get_policy_by_name(analyzer.name); + ASSERT_NE(built, nullptr); +} + +TEST_F(IndexPolicyMgrTest, ExistingPolicyTakesPrecedenceOverNewBuiltinName) { + TIndexPolicy legacy_tokenizer; + legacy_tokenizer.id = 21; + legacy_tokenizer.name = "ik_smart"; + legacy_tokenizer.type = TIndexPolicyType::TOKENIZER; + legacy_tokenizer.properties["type"] = "ngram"; + legacy_tokenizer.properties["min_gram"] = "2"; + legacy_tokenizer.properties["max_gram"] = "2"; + + TIndexPolicy analyzer; + analyzer.id = 22; + analyzer.name = "legacy_collision_analyzer"; + analyzer.type = TIndexPolicyType::ANALYZER; + analyzer.properties["tokenizer"] = "IK_SMART"; + mgr.apply_policy_changes({legacy_tokenizer, analyzer}, {}); + + auto built = mgr.get_policy_by_name(analyzer.name); + ASSERT_NE(built, nullptr); + auto reader = segment_v2::inverted_index::InvertedIndexAnalyzer::create_reader({}); + const std::string text = "abcd"; + reader->init(text.data(), static_cast(text.size()), false); + auto terms = segment_v2::inverted_index::InvertedIndexAnalyzer::get_analyse_result(reader, + built.get()); + ASSERT_EQ(terms.size(), 3); + EXPECT_EQ(terms[0].get_single_term(), "ab"); + EXPECT_EQ(terms[1].get_single_term(), "bc"); + EXPECT_EQ(terms[2].get_single_term(), "cd"); +} + TEST_F(IndexPolicyMgrTest, AnalyzerProviderPreservesPurposeInsensitiveNormalizers) { auto builtin = mgr.get_analyzer_provider_by_name("lowercase"); auto builtin_analyzer = builtin->get_analyzer(); @@ -202,4 +509,4 @@ TEST_F(IndexPolicyMgrTest, AnalyzerProviderPreservesPurposeInsensitiveNormalizer EXPECT_EQ(configured->get_analyzer(), configured_analyzer); } -} // namespace doris \ No newline at end of file +} // namespace doris diff --git a/be/test/storage/index/inverted/analysis_factory_mgr_test.cpp b/be/test/storage/index/inverted/analysis_factory_mgr_test.cpp index b3f418aa7281fd..cf529203332145 100644 --- a/be/test/storage/index/inverted/analysis_factory_mgr_test.cpp +++ b/be/test/storage/index/inverted/analysis_factory_mgr_test.cpp @@ -199,8 +199,9 @@ TEST_F(AnalysisFactoryMgrTest, MultipleCreationsFromSameName) { TEST_F(AnalysisFactoryMgrTest, AllBuiltInTokenizersRegistered) { Settings empty_settings; - std::vector tokenizer_names = {"standard", "keyword", "ngram", "edge_ngram", - "char_group", "basic", "icu", "empty"}; + std::vector tokenizer_names = {"standard", "keyword", "ngram", "edge_ngram", + "char_group", "basic", "icu", "ik_smart", + "ik_max_word", "empty"}; for (const auto& name : tokenizer_names) { auto factory = @@ -290,4 +291,4 @@ TEST_F(AnalysisFactoryMgrTest, ConcurrentCreateIsThreadSafe) { EXPECT_EQ(success_count.load(), kNumThreads * kIterationsPerThread); } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/test/storage/index/inverted/analyzer/ik_anayzer_test.cpp b/be/test/storage/index/inverted/analyzer/ik_anayzer_test.cpp index 93d5f0d6f48a87..fc2c2095e84a11 100644 --- a/be/test/storage/index/inverted/analyzer/ik_anayzer_test.cpp +++ b/be/test/storage/index/inverted/analyzer/ik_anayzer_test.cpp @@ -23,11 +23,13 @@ #include #include "core/arena.h" +#include "storage/index/inverted/analyzer/custom_analyzer.h" #include "storage/index/inverted/analyzer/ik/IKAnalyzer.h" #include "storage/index/inverted/analyzer/ik/cfg/Configuration.h" #include "storage/index/inverted/analyzer/ik/core/AnalyzeContext.h" #include "storage/index/inverted/analyzer/ik/core/IKSegmenter.h" #include "storage/index/inverted/analyzer/ik/core/Lexeme.h" +#include "storage/index/inverted/tokenizer/ik/ik_tokenizer_factory.h" using namespace lucene::analysis; namespace doris::segment_v2 { @@ -128,6 +130,28 @@ class IKTokenizerTest : public ::testing::Test { // Test for Dictionary exception handling TEST_F(IKTokenizerTest, TestDictionaryExceptionHandling) { + // A named custom IK analyzer defers dictionary loading until its first token stream. + // That public boundary must translate CLucene failures into a Doris analyzer exception. + const std::string old_dict_path = config::inverted_index_dict_path; + config::inverted_index_dict_path = "/non_existent_path"; + inverted_index::CustomAnalyzerConfig::Builder builder; + builder.with_tokenizer_config("ik_smart", {}); + auto custom_analyzer = inverted_index::CustomAnalyzer::build_custom_analyzer(builder.build()); + auto reader = std::make_shared>(); + reader->init("test", 4, false); + bool caught_doris_exception = false; + try { + std::unique_ptr stream(custom_analyzer->tokenStream(L"", reader)); + } catch (const Exception& e) { + caught_doris_exception = true; + EXPECT_EQ(e.code(), ErrorCode::INVERTED_INDEX_ANALYZER_ERROR); + EXPECT_NE(e.message().find("Dictionary initialization failed"), std::string::npos); + } catch (const CLuceneError& e) { + ADD_FAILURE() << "Raw CLuceneError escaped custom analyzer: " << e.what(); + } + config::inverted_index_dict_path = old_dict_path; + EXPECT_TRUE(caught_doris_exception); + // Test 1: Load non-existent path { Configuration cfg; @@ -291,6 +315,101 @@ TEST_F(IKTokenizerTest, TestLargeInput) { ASSERT_EQ(datas.size(), 7000); } +TEST_F(IKTokenizerTest, TestOffsetsAcrossBufferRefill) { + std::string text; + for (int i = 0; i < 2000; ++i) { + text += "我 "; + } + + IKAnalyzer analyzer; + analyzer.initDict("./be/dict/ik"); + analyzer.setMode(true); + lucene::util::SStringReader reader; + reader.init(text.data(), text.size(), false); + std::unique_ptr stream(analyzer.tokenStream(L"", &reader)); + + Token token; + int token_index = 0; + while (stream->next(&token) != nullptr) { + ASSERT_EQ(token.startOffset(), token_index * 4); + ASSERT_EQ(token.endOffset(), token_index * 4 + 3); + ++token_index; + } + ASSERT_EQ(token_index, 2000); +} + +TEST_F(IKTokenizerTest, TestLegacyAndCustomResetContracts) { + IKAnalyzer analyzer; + analyzer.initDict("./be/dict/ik"); + analyzer.setMode(true); + const std::string text = "我来到北京"; + lucene::util::SStringReader legacy_reader; + legacy_reader.init(text.data(), text.size(), false); + std::unique_ptr legacy_stream(analyzer.tokenStream(L"", &legacy_reader)); + legacy_stream->reset(); + Token token; + ASSERT_NE(legacy_stream->next(&token), nullptr); + + inverted_index::IKTokenizerFactory factory(true); + factory.initialize({}); + auto custom_tokenizer = factory.create(); + auto custom_reader = std::make_shared>(); + custom_reader->init(text.data(), text.size(), false); + custom_tokenizer->set_reader(custom_reader); + custom_tokenizer->reset(); + custom_tokenizer->reset(); + ASSERT_NE(custom_tokenizer->next(&token), nullptr); +} + +TEST_F(IKTokenizerTest, TestLegacyAndCustomArrayIndexWriterResetContracts) { + auto assert_indexed = [](lucene::analysis::Analyzer* analyzer, bool custom) { + auto dir = std::make_shared(); + lucene::index::IndexWriter writer(dir.get(), analyzer, true); + writer.setUseCompoundFile(false); + + lucene::document::Document doc; + std::vector readers; + const std::vector values = {"我来到北京", "清华大学"}; + for (const auto& value : values) { + int32_t field_config = lucene::document::Field::STORE_NO; + field_config |= lucene::document::Field::INDEX_NONORMS; + field_config |= lucene::document::Field::INDEX_TOKENIZED; + auto* field = _CLNEW lucene::document::Field(L"content", field_config); + field->setOmitTermFreqAndPositions(false); + auto reader = std::make_shared>(); + reader->init(value.data(), value.size(), false); + TokenStream* stream = + custom ? static_cast(analyzer)->tokenStream( + field->name(), reader) + : analyzer->tokenStream(field->name(), reader.get()); + field->setValue(stream, true); + doc.add(*field); + readers.emplace_back(std::move(reader)); + } + ASSERT_NO_THROW(writer.addDocument(&doc)); + writer.close(); + + auto* index_reader = lucene::index::IndexReader::open(dir.get()); + lucene::index::Term beijing(L"content", L"北京"); + lucene::index::Term university(L"content", L"清华大学"); + EXPECT_EQ(index_reader->docFreq(&beijing), 1); + EXPECT_EQ(index_reader->docFreq(&university), 1); + index_reader->close(); + _CLLDELETE(index_reader); + }; + + IKAnalyzer legacy_analyzer; + legacy_analyzer.initDict("./be/dict/ik"); + legacy_analyzer.setMode(true); + assert_indexed(&legacy_analyzer, false); + + inverted_index::CustomAnalyzerConfig::Builder builder; + builder.with_tokenizer_config("ik_smart", {}); + auto custom_config = builder.build(); + auto custom_analyzer = inverted_index::CustomAnalyzer::build_custom_analyzer(custom_config); + assert_indexed(custom_analyzer.get(), true); +} + TEST_F(IKTokenizerTest, TestBufferExhaustCritical) { std::vector datas; // Test with buffer exhaustion critical case diff --git a/be/test/storage/index/inverted/char_filter/icu_normalizer_char_filter_factory_test.cpp b/be/test/storage/index/inverted/char_filter/icu_normalizer_char_filter_factory_test.cpp index 9551d43dbd6891..d72e07d36cf87f 100644 --- a/be/test/storage/index/inverted/char_filter/icu_normalizer_char_filter_factory_test.cpp +++ b/be/test/storage/index/inverted/char_filter/icu_normalizer_char_filter_factory_test.cpp @@ -265,4 +265,60 @@ TEST_F(ICUNormalizerCharFilterFactoryTest, InitializeOnlyFillsOnce) { filter->initialize(); } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +TEST_F(ICUNormalizerCharFilterFactoryTest, SparseOffsetCorrectionsRemainCompact) { + constexpr size_t unchanged_bytes = 4 * 1024 * 1024; + std::string input(unchanged_bytes, 'a'); + input += "L"; + input.append(unchanged_bytes, 'a'); + + Settings settings; + ICUNormalizerCharFilterFactory factory; + factory.initialize(settings); + + auto reader = make_reader(input); + auto filter = std::dynamic_pointer_cast(factory.create(reader)); + ASSERT_NE(filter, nullptr); + filter->init(input.data(), static_cast(input.size()), false); + + EXPECT_EQ(filter->correct_offset(static_cast(unchanged_bytes)), unchanged_bytes); + EXPECT_EQ(filter->correct_offset(static_cast(unchanged_bytes + 1)), + unchanged_bytes + 3); + EXPECT_EQ(filter->correct_offset(static_cast(unchanged_bytes + 2)), + unchanged_bytes + 4); +} + +TEST_F(ICUNormalizerCharFilterFactoryTest, DenseAlternatingEditsMapOffsetsWithoutSideTable) { + // Every second rune is folded 3 -> 1 with one unchanged byte in between, so nothing + // coalesces; the correction state must stay bounded by ICU's own edit encoding. + constexpr int32_t pairs = 100000; + std::string input; + input.reserve(static_cast(pairs) * 4); + for (int32_t i = 0; i < pairs; ++i) { + input += "aA"; + } + + Settings settings; + ICUNormalizerCharFilterFactory factory; + factory.initialize(settings); + + auto reader = make_reader(input); + auto filter = std::dynamic_pointer_cast(factory.create(reader)); + ASSERT_NE(filter, nullptr); + filter->init(input.data(), static_cast(input.size()), false); + ASSERT_EQ(filter->size(), static_cast(pairs) * 2); + + auto expect_pair = [&filter](int32_t k) { + EXPECT_EQ(filter->correct_offset(2 * k), 4 * k); + EXPECT_EQ(filter->correct_offset(2 * k + 1), 4 * k + 1); + EXPECT_EQ(filter->correct_offset(2 * k + 2), 4 * k + 4); + }; + for (int32_t k = 0; k < pairs; k += 997) { + expect_pair(k); + } + for (int32_t k = pairs - 1; k >= 0; k -= 991) { + expect_pair(k); + } + EXPECT_EQ(filter->correct_offset(2 * pairs), 4 * pairs); +} + +} // namespace doris::segment_v2::inverted_index diff --git a/be/test/storage/index/inverted/inverted_index_reader_analysis_purpose_test.cpp b/be/test/storage/index/inverted/inverted_index_reader_analysis_purpose_test.cpp index e0e9620e964411..7889b37dfe5c23 100644 --- a/be/test/storage/index/inverted/inverted_index_reader_analysis_purpose_test.cpp +++ b/be/test/storage/index/inverted/inverted_index_reader_analysis_purpose_test.cpp @@ -429,6 +429,31 @@ TEST_F(InvertedIndexReaderAnalysisPurposeTest, PartialAnalysisFailureDoesNotPubl EXPECT_EQ(snii_provider->emitted_tokens->load(std::memory_order_relaxed), 1); } +TEST_F(InvertedIndexReaderAnalysisPurposeTest, ClassicReaderConvertsAnalyzerFailureToStatus) { + auto file_reader = std::make_shared( + io::global_local_filesystem(), "./ut_dir/missing_classic_analysis_failure", + InvertedIndexStorageFormatPB::V2); + auto reader = FullTextIndexReader::create_shared(&_meta, file_reader); + auto emitted_tokens = std::make_shared>(0); + + QueryExecutionContext execution(/*scoring=*/false); + InvertedIndexAnalyzerCtx analyzer_ctx; + analyzer_ctx.parser_type = InvertedIndexParserType::PARSER_ENGLISH; + analyzer_ctx.analyzer = std::make_shared(emitted_tokens); + const Field query_value = Field::create_field("the history"); + auto original_bitmap = std::make_shared(); + original_bitmap->add(999); + std::shared_ptr bitmap = original_bitmap; + + Status status; + EXPECT_NO_THROW(status = reader->query(execution.context, "content", query_value, + InvertedIndexQueryType::MATCH_ANY_QUERY, bitmap, + &analyzer_ctx)); + EXPECT_EQ(status.code(), ErrorCode::INVERTED_INDEX_ANALYZER_ERROR) << status; + EXPECT_EQ(emitted_tokens->load(std::memory_order_relaxed), 1U); + EXPECT_EQ(bitmap, original_bitmap); +} + TEST_F(InvertedIndexReaderAnalysisPurposeTest, RegexpAndWildcardBypassAnalyzer) { for (const auto query_type : {InvertedIndexQueryType::MATCH_REGEXP_QUERY, InvertedIndexQueryType::WILDCARD_QUERY}) { diff --git a/be/test/storage/index/inverted/similarity/collection_statistics_test.cpp b/be/test/storage/index/inverted/similarity/collection_statistics_test.cpp index b2ea2c148235f2..aa7c8be7a3b354 100644 --- a/be/test/storage/index/inverted/similarity/collection_statistics_test.cpp +++ b/be/test/storage/index/inverted/similarity/collection_statistics_test.cpp @@ -31,19 +31,23 @@ #include "common/exception.h" #include "core/data_type/data_type_string.h" +#include "core/data_type/primitive_type.h" #include "exec/common/variant_util.h" #include "exprs/vexpr.h" #include "exprs/vexpr_context.h" #include "exprs/vliteral.h" +#include "exprs/vmatch_predicate.h" #include "exprs/vsearch.h" #include "exprs/vslot_ref.h" #include "io/fs/local_file_system.h" #include "runtime/exec_env.h" +#include "runtime/index_policy/index_policy_mgr.h" #include "storage/index/index_file_reader.h" #include "storage/index/index_file_writer.h" #include "storage/index/index_writer.h" #include "storage/index/inverted/analyzer/analyzer.h" #include "storage/index/inverted/inverted_index_desc.h" +#include "storage/index/inverted/similarity/predicate_collector.h" #include "storage/index/inverted/util/string_helper.h" #include "storage/index/snii/format/phrase_bigram.h" #include "storage/index/snii/query/bm25_scorer.h" @@ -56,6 +60,7 @@ #include "storage/rowset/rowset_reader.h" #include "storage/tablet/tablet_schema.h" #include "testutil/mock/mock_runtime_state.h" +#include "util/defer_op.h" #include "util/slice.h" namespace doris { @@ -136,6 +141,14 @@ class FixedAnalyzerProvider final : public segment_v2::inverted_index::AnalyzerP std::shared_ptr _analyzer; }; +class FailingAnalyzerProvider final : public segment_v2::inverted_index::AnalyzerProvider { +public: + std::shared_ptr get_analyzer() const override { + throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR, + "forced missing dictionary failure"); + } +}; + class MockVSlotRef : public VSlotRef { public: MockVSlotRef(const std::string& column_name, SlotId slot_id) @@ -2085,6 +2098,28 @@ TEST_F(CollectionStatisticsTest, CollectUsesMatchRequestAnalyzerProvider) { EXPECT_EQ(collect_info.logical_scoring_leaves[0].clauses[1].df_slot, 1u); } +TEST_F(CollectionStatisticsTest, CollectConvertsAnalyzerFailureToStatus) { + auto tablet_schema = create_tablet_schema_with_inverted_index(); + auto analyzer_ctx = std::make_shared(); + analyzer_ctx->analyzer_provider = + std::make_shared(); + + auto match_expr = std::make_shared(TExprNodeType::MATCH_PRED); + match_expr->set_analyzer_ctx(std::move(analyzer_ctx)); + match_expr->_children.push_back( + std::make_shared("content", SlotId(1))); + match_expr->_children.push_back(std::make_shared("query")); + + MatchPredicateCollector collector; + CollectInfoMap collect_infos; + Status status; + EXPECT_NO_THROW(status = collector.collect(runtime_state_.get(), tablet_schema, match_expr, + &collect_infos)); + EXPECT_EQ(status.code(), ErrorCode::INVERTED_INDEX_ANALYZER_ERROR); + EXPECT_NE(status.msg().find("forced missing dictionary failure"), std::string::npos); + EXPECT_TRUE(collect_infos.empty()); +} + TEST_F(CollectionStatisticsTest, CollectPhrasePrefixExcludesScoringTail) { auto tablet_schema = create_tablet_schema_with_inverted_index(); auto match_expr = std::make_shared(TExprNodeType::MATCH_PRED); @@ -2179,6 +2214,69 @@ TEST_F(CollectionStatisticsTest, MatchSelectsOnlyTheRuntimeAnalyzerIndex) { EXPECT_EQ(collect_info.logical_scoring_leaves.size(), 1u); } +TEST_F(CollectionStatisticsTest, MatchScoresLegacyMetadataUsingTheResolvedPolicy) { + IndexPolicyMgr policy_mgr; + auto* exec_env = ExecEnv::GetInstance(); + auto* original_policy_mgr = exec_env->index_policy_mgr(); + exec_env->_index_policy_mgr = &policy_mgr; + Defer restore_policy_mgr([&] { exec_env->_index_policy_mgr = original_policy_mgr; }); + TIndexPolicy policy; + policy.id = 100; + policy.name = "Foo"; + policy.type = TIndexPolicyType::ANALYZER; + policy.properties["tokenizer"] = "keyword"; + policy_mgr.apply_policy_changes({policy}, {}); + + TMatchPredicate match; + match.__set_analyzer_name("foo"); + match.__set_parser_type("english"); + match.__set_parser_lowercase(true); + match.__set_parser_stopwords("none"); + TExprNode node; + node.__set_node_type(TExprNodeType::MATCH_PRED); + node.__set_opcode(TExprOpcode::MATCH_PHRASE); + node.__set_type(create_type_desc(PrimitiveType::TYPE_BOOLEAN)); + node.__set_num_children(2); + node.__set_match_predicate(match); + auto predicate = VMatchPredicate::create_shared(node); + predicate->add_child( + std::make_shared("content", SlotId(1))); + predicate->add_child(std::make_shared("one two")); + + auto tablet_schema = create_tablet_schema_with_two_fulltext_indexes(); + TabletIndex legacy_index; + legacy_index._index_id = 30; + legacy_index._index_type = IndexType::INVERTED; + legacy_index._col_unique_ids.push_back(1); + legacy_index._properties = {{"analyzer", "foo"}, {"support_phrase", "true"}}; + tablet_schema->append_index(std::move(legacy_index)); + + MatchPredicateCollector collector; + CollectInfoMap collect_infos; + auto status = collector.collect(runtime_state_.get(), tablet_schema, predicate, &collect_infos); + ASSERT_TRUE(status.ok()) << status.msg(); + ASSERT_EQ(collect_infos.size(), 1U); + const auto& collect_info = collect_infos.begin()->second; + ASSERT_NE(collect_info.index_meta, nullptr); + EXPECT_EQ(collect_info.index_meta->index_id(), 30); + EXPECT_EQ(collect_info.unique_terms, std::vector({"one two"})); + ASSERT_EQ(collect_info.logical_scoring_leaves.size(), 1U); + EXPECT_EQ(collect_info.logical_scoring_leaves[0].clauses.size(), 1U); + + TabletIndex exact_index; + exact_index._index_id = 40; + exact_index._index_type = IndexType::INVERTED; + exact_index._col_unique_ids.push_back(1); + exact_index._properties = {{"analyzer", "Foo"}, {"support_phrase", "true"}}; + tablet_schema->append_index(std::move(exact_index)); + collect_infos.clear(); + status = collector.collect(runtime_state_.get(), tablet_schema, predicate, &collect_infos); + ASSERT_TRUE(status.ok()) << status.msg(); + ASSERT_EQ(collect_infos.size(), 1U); + ASSERT_NE(collect_infos.begin()->second.index_meta, nullptr); + EXPECT_EQ(collect_infos.begin()->second.index_meta->index_id(), 40); +} + TEST_F(CollectionStatisticsTest, MatchArrayStringSelectsFulltextLeafIndex) { auto tablet_schema = create_array_tablet_schema_with_keyword_and_fulltext_indexes(); auto match_expr = std::make_shared(TExprNodeType::MATCH_PRED); @@ -2598,4 +2696,34 @@ TEST_F(CollectionStatisticsTest, BuildFieldNameWithoutSuffix) { EXPECT_EQ(collector.build_field_name(42, ""), "42"); } +TEST_F(CollectionStatisticsTest, ScoringAnalyzerContextReportsWrongFamilyComponentAsStatus) { + IndexPolicyMgr policy_mgr; + auto* exec_env = ExecEnv::GetInstance(); + auto* original_policy_mgr = exec_env->index_policy_mgr(); + exec_env->_index_policy_mgr = &policy_mgr; + Defer restore_policy_mgr([&] { exec_env->_index_policy_mgr = original_policy_mgr; }); + + // Replay can keep a component whose family no longer matches how the analyzer uses it. + TIndexPolicy char_filter; + char_filter.id = 200; + char_filter.name = "Wrong"; + char_filter.type = TIndexPolicyType::CHAR_FILTER; + char_filter.properties["type"] = "char_replace"; + TIndexPolicy analyzer; + analyzer.id = 201; + analyzer.name = "wrong_family_analyzer"; + analyzer.type = TIndexPolicyType::ANALYZER; + analyzer.properties["tokenizer"] = "keyword"; + analyzer.properties["token_filter"] = "Wrong"; + policy_mgr.apply_policy_changes({char_filter, analyzer}, {}); + + const std::map properties = {{"analyzer", "wrong_family_analyzer"}}; + auto analyzer_ctx = analyzer_context_from_properties(properties); + ASSERT_FALSE(analyzer_ctx.has_value()); + EXPECT_EQ(analyzer_ctx.error().code(), ErrorCode::INVERTED_INDEX_ANALYZER_ERROR); + + const std::map valid = {{"parser", "english"}}; + EXPECT_TRUE(analyzer_context_from_properties(valid).has_value()); +} + } // namespace doris diff --git a/be/test/storage/index/inverted/token_filter/icu_normalizer_filter_factory_test.cpp b/be/test/storage/index/inverted/token_filter/icu_normalizer_filter_factory_test.cpp index a0d67584efbc3a..6c8b21bb8fef64 100644 --- a/be/test/storage/index/inverted/token_filter/icu_normalizer_filter_factory_test.cpp +++ b/be/test/storage/index/inverted/token_filter/icu_normalizer_filter_factory_test.cpp @@ -21,11 +21,16 @@ #include #include +#ifdef ADDRESS_SANITIZER +#include +#endif + #include #include #include #include "CLucene.h" +#include "storage/index/inverted/tokenizer/empty/empty_tokenizer_factory.h" #include "storage/index/inverted/tokenizer/keyword/keyword_tokenizer_factory.h" #include "storage/index/inverted/tokenizer/standard/standard_tokenizer_factory.h" @@ -50,6 +55,10 @@ TokenizerPtr create_tokenizer(const std::string& tokenizer_type, const std::stri KeywordTokenizerFactory factory; factory.initialize(settings); tokenizer = factory.create(); + } else if (tokenizer_type == "empty") { + EmptyTokenizerFactory factory; + factory.initialize(settings); + tokenizer = factory.create(); } else { throw std::invalid_argument("Unknown tokenizer type: " + tokenizer_type); } @@ -246,7 +255,7 @@ TEST_F(ICUNormalizerFilterFactoryTest, NonEmptyUnicodeSetCreatesFilteredNormaliz auto filter = factory.create(tokenizer); auto tokens = collect_tokens(filter); - EXPECT_GE(tokens.size(), 1u); + EXPECT_GE(tokens.size(), 1U); } TEST_F(ICUNormalizerFilterFactoryTest, CreateWithoutInitializeThrows) { @@ -292,4 +301,62 @@ TEST_F(ICUNormalizerFilterFactoryTest, ResetResetsUnderlyingStream) { EXPECT_EQ(first_pass, second_pass); } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +TEST_F(ICUNormalizerFilterFactoryTest, LargeInputSkipsUnusedOffsetMaps) { + constexpr size_t input_size = 4 * 1024 * 1024; + std::string input(input_size, 'a'); + input.back() = 'A'; + + Settings settings; + ICUNormalizerFilterFactory factory; + factory.initialize(settings); + + auto tokenizer = create_tokenizer("empty", input); + auto filter = factory.create(tokenizer); + + Token token; +#ifdef ADDRESS_SANITIZER + const size_t allocated_before = __sanitizer_get_current_allocated_bytes(); +#endif + ASSERT_NE(filter->next(&token), nullptr); +#ifdef ADDRESS_SANITIZER + const size_t allocated_after = __sanitizer_get_current_allocated_bytes(); + ASSERT_GE(allocated_after, allocated_before); + EXPECT_LT(allocated_after - allocated_before, input_size * 4); +#endif + EXPECT_TRUE(filter->get_source_byte_offsets().empty()); + EXPECT_TRUE(filter->get_source_byte_end_offsets().empty()); +} + +TEST_F(ICUNormalizerFilterFactoryTest, LargeInputUsesCompactEnabledSourceSpan) { + constexpr size_t input_size = 4 * 1024 * 1024; + std::string input(input_size, 'a'); + input.back() = 'A'; + + Settings settings; + ICUNormalizerFilterFactory factory; + factory.initialize(settings); + + auto tokenizer = create_tokenizer("empty", input); + auto filter = factory.create(tokenizer); + filter->set_source_byte_offsets_enabled(true); + + Token token; +#ifdef ADDRESS_SANITIZER + const size_t allocated_before = __sanitizer_get_current_allocated_bytes(); +#endif + ASSERT_NE(filter->next(&token), nullptr); +#ifdef ADDRESS_SANITIZER + const size_t allocated_after = __sanitizer_get_current_allocated_bytes(); + ASSERT_GE(allocated_after, allocated_before); + EXPECT_LT(allocated_after - allocated_before, input_size * 12); +#endif + EXPECT_TRUE(filter->get_source_byte_offsets().empty()); + EXPECT_TRUE(filter->get_source_byte_end_offsets().empty()); + int32_t source_start = -1; + int32_t source_end = -1; + EXPECT_TRUE(filter->get_conservative_source_byte_span(source_start, source_end)); + EXPECT_EQ(source_start, 0); + EXPECT_EQ(source_end, static_cast(input.size())); +} + +} // namespace doris::segment_v2::inverted_index diff --git a/be/test/storage/index/inverted/token_filter/pinyin_filter_test.cpp b/be/test/storage/index/inverted/token_filter/pinyin_filter_test.cpp index d62b0ba3e6718e..1a7b28198ff9e5 100644 --- a/be/test/storage/index/inverted/token_filter/pinyin_filter_test.cpp +++ b/be/test/storage/index/inverted/token_filter/pinyin_filter_test.cpp @@ -20,11 +20,24 @@ #include #include #include +#include #include #include "CLucene.h" +#include "storage/index/inverted/char_filter/icu_normalizer_char_filter_factory.h" +#include "storage/index/inverted/token_filter/ascii_folding_filter_factory.h" +#include "storage/index/inverted/token_filter/icu_normalizer_filter_factory.h" +#include "storage/index/inverted/token_filter/lower_case_filter_factory.h" #include "storage/index/inverted/token_filter/pinyin_filter_factory.h" +#include "storage/index/inverted/token_filter/word_delimiter_filter_factory.h" +#include "storage/index/inverted/tokenizer/basic/basic_tokenizer_factory.h" +#include "storage/index/inverted/tokenizer/char/char_group_tokenizer_factory.h" +#include "storage/index/inverted/tokenizer/empty/empty_tokenizer_factory.h" +#include "storage/index/inverted/tokenizer/icu/icu_tokenizer_factory.h" +#include "storage/index/inverted/tokenizer/ik/ik_tokenizer_factory.h" #include "storage/index/inverted/tokenizer/keyword/keyword_tokenizer_factory.h" +#include "storage/index/inverted/tokenizer/ngram/ngram_tokenizer_factory.h" +#include "storage/index/inverted/tokenizer/pinyin/pinyin_tokenizer_factory.h" #include "storage/index/inverted/tokenizer/standard/standard_tokenizer_factory.h" namespace doris::segment_v2::inverted_index { @@ -46,11 +59,45 @@ class PinyinFilterTest : public ::testing::Test { Settings settings; factory.initialize(settings); tokenizer = factory.create(); + } else if (tokenizer_type == "basic") { + BasicTokenizerFactory factory; + Settings settings; + factory.initialize(settings); + tokenizer = factory.create(); + } else if (tokenizer_type == "char_group") { + CharGroupTokenizerFactory factory; + Settings settings; + settings.set("tokenize_on_chars", "[whitespace]"); + factory.initialize(settings); + tokenizer = factory.create(); + } else if (tokenizer_type == "empty") { + EmptyTokenizerFactory factory; + Settings settings; + factory.initialize(settings); + tokenizer = factory.create(); + } else if (tokenizer_type == "icu") { + ICUTokenizerFactory factory; + Settings settings; + factory.initialize(settings); + tokenizer = factory.create(); + } else if (tokenizer_type == "ngram") { + NGramTokenizerFactory factory; + Settings settings; + settings.set("min_gram", "1"); + settings.set("max_gram", "1"); + settings.set("token_chars", "letter"); + factory.initialize(settings); + tokenizer = factory.create(); } else if (tokenizer_type == "keyword") { KeywordTokenizerFactory factory; Settings settings; factory.initialize(settings); tokenizer = factory.create(); + } else if (tokenizer_type == "ik_smart" || tokenizer_type == "ik_max_word") { + IKTokenizerFactory factory(tokenizer_type == "ik_smart"); + Settings settings; + factory.initialize(settings); + tokenizer = factory.create(); } else { throw std::invalid_argument("Unknown tokenizer type: " + tokenizer_type); } @@ -97,6 +144,18 @@ class PinyinFilterTest : public ::testing::Test { EXPECT_EQ(actual[i], expected[i]) << "Token[" << i << "] mismatch in " << test_case; } } + + void assertToken(const TokenFilterPtr& filter, Token* token, const std::string& expected_term, + int32_t expected_start, int32_t expected_end) { + ASSERT_NE(filter->next(token), nullptr); + EXPECT_EQ(std::string(token->termBuffer(), token->termLength()), expected_term); + EXPECT_EQ(token->startOffset(), expected_start); + EXPECT_EQ(token->endOffset(), expected_end); + } + + void assertEndOfTokens(const TokenFilterPtr& filter, Token* token) { + EXPECT_EQ(filter->next(token), nullptr); + } }; TEST_F(PinyinFilterTest, TestTokenFilter_StandardAnalyzer_FirstLetter) { @@ -127,6 +186,1420 @@ TEST_F(PinyinFilterTest, TestTokenFilter_KeywordAnalyzer_FirstLetter) { assertTokens(tokens, expected, "KeywordTokenizer + FirstLetter"); } +TEST_F(PinyinFilterTest, TestTokenFilter_IKTokenizers) { + std::unordered_map filter_config; + filter_config["keep_none_chinese"] = "false"; + filter_config["keep_first_letter"] = "true"; + filter_config["keep_full_pinyin"] = "false"; + filter_config["keep_separate_first_letter"] = "false"; + filter_config["keep_original"] = "true"; + filter_config["keep_joined_full_pinyin"] = "true"; + + auto smart_tokens = tokenizeWithFilter("我来到北京清华大学", "ik_smart", filter_config); + EXPECT_NE(std::ranges::find(smart_tokens, "清华大学"), smart_tokens.end()); + EXPECT_NE(std::ranges::find(smart_tokens, "qinghuadaxue"), smart_tokens.end()); + + auto max_word_tokens = tokenizeWithFilter("我来到北京清华大学", "ik_max_word", filter_config); + EXPECT_NE(std::ranges::find(max_word_tokens, "清华"), max_word_tokens.end()); + EXPECT_NE(std::ranges::find(max_word_tokens, "qinghua"), max_word_tokens.end()); + EXPECT_NE(std::ranges::find(max_word_tokens, "大学"), max_word_tokens.end()); + EXPECT_NE(std::ranges::find(max_word_tokens, "daxue"), max_word_tokens.end()); +} + +TEST_F(PinyinFilterTest, TestIKSmartOffsetsRemainDocumentRelative) { + std::unordered_map filter_config; + filter_config["keep_none_chinese"] = "false"; + filter_config["keep_first_letter"] = "true"; + filter_config["keep_full_pinyin"] = "false"; + filter_config["keep_separate_first_letter"] = "false"; + filter_config["keep_original"] = "true"; + filter_config["keep_joined_full_pinyin"] = "true"; + filter_config["ignore_pinyin_offset"] = "false"; + + auto tokenizer = createTokenizer("ik_smart", "我来到北京清华大学"); + PinyinFilterFactory filter_factory; + filter_factory.initialize(Settings(filter_config)); + auto filter = filter_factory.create(tokenizer); + + const std::vector> expected = { + {"我", 0, 3}, + {"wo", 0, 3}, + {"w", 0, 3}, + {"来到", 3, 9}, + {"laidao", 3, 9}, + {"ld", 3, 9}, + {"北京", 9, 15}, + {"beijing", 9, 15}, + {"bj", 9, 15}, + {"清华大学", 15, 27}, + {"qinghuadaxue", 15, 27}, + {"qhdx", 15, 27}}; + Token token; + for (const auto& [term, start, end] : expected) { + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), term); + EXPECT_EQ(token.startOffset(), start); + EXPECT_EQ(token.endOffset(), end); + } + EXPECT_EQ(filter->next(&token), nullptr); +} + +TEST_F(PinyinFilterTest, TestIKOffsetsPreserveFullwidthSourceBytes) { + std::unordered_map filter_config; + filter_config["keep_first_letter"] = "false"; + filter_config["keep_full_pinyin"] = "false"; + filter_config["keep_original"] = "false"; + filter_config["keep_none_chinese"] = "true"; + filter_config["none_chinese_pinyin_tokenize"] = "true"; + filter_config["ignore_pinyin_offset"] = "false"; + + auto tokenizer = createTokenizer("ik_smart", "LIUDE"); + PinyinFilterFactory filter_factory; + filter_factory.initialize(Settings(filter_config)); + auto filter = filter_factory.create(tokenizer); + + Token token; + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), "liu"); + EXPECT_EQ(token.startOffset(), 0); + EXPECT_EQ(token.endOffset(), 9); + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), "de"); + EXPECT_EQ(token.startOffset(), 9); + EXPECT_EQ(token.endOffset(), 15); + EXPECT_EQ(filter->next(&token), nullptr); +} + +TEST_F(PinyinFilterTest, TestIKOffsetsComposeWithICUNormalizerCharFilterAndReset) { + auto source = std::make_shared>(); + const std::string text = "LIUDE"; + source->init(text.data(), static_cast(text.size()), false); + ICUNormalizerCharFilterFactory char_filter_factory; + char_filter_factory.initialize({}); + auto reader = char_filter_factory.create(source); + + IKTokenizerFactory tokenizer_factory(true); + tokenizer_factory.initialize({}); + auto tokenizer = tokenizer_factory.create(); + tokenizer->set_reader(reader); + tokenizer->reset(); + + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("none_chinese_pinyin_tokenize", "true"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + auto assert_offsets = + [&filter](const std::vector>& expected) { + Token token; + for (const auto& [term, start, end] : expected) { + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), + term); + EXPECT_EQ(token.startOffset(), start); + EXPECT_EQ(token.endOffset(), end); + } + EXPECT_EQ(filter->next(&token), nullptr); + }; + assert_offsets({{"liu", 0, 9}, {"de", 9, 15}}); + + const std::string reset_text = "ABCD"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + assert_offsets({{"a", 0, 3}, {"b", 3, 6}, {"c", 6, 9}, {"d", 9, 12}}); +} + +TEST_F(PinyinFilterTest, TestIKExpandedLigatureOffsetsStayConservativeAndReset) { + auto source = std::make_shared>(); + // U+FB01 LATIN SMALL LIGATURE FI; nfkc_cf expands the 3-byte source rune to "fi". + const std::string text = "\xEF\xAC\x81"; + source->init(text.data(), static_cast(text.size()), false); + ICUNormalizerCharFilterFactory char_filter_factory; + char_filter_factory.initialize({}); + auto reader = char_filter_factory.create(source); + + IKTokenizerFactory tokenizer_factory(true); + tokenizer_factory.initialize({}); + auto tokenizer = tokenizer_factory.create(); + tokenizer->set_reader(reader); + tokenizer->reset(); + + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("keep_none_chinese_together", "false"); + settings.set("keep_none_chinese_in_first_letter", "false"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + auto assert_offsets = + [&filter](const std::vector>& expected) { + Token token; + for (const auto& [term, start, end] : expected) { + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), + term); + EXPECT_EQ(token.startOffset(), start); + EXPECT_EQ(token.endOffset(), end); + EXPECT_LT(token.startOffset(), token.endOffset()); + } + EXPECT_EQ(filter->next(&token), nullptr); + }; + // Both letters come from the single ligature, so each keeps the whole source span. + assert_offsets({{"f", 0, 3}, {"i", 0, 3}}); + + const std::string reset_text = "LI"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + assert_offsets({{"l", 0, 3}, {"i", 3, 6}}); +} + +TEST_F(PinyinFilterTest, TestChainedPinyinFiltersPublishCandidateSpansAndReset) { + Settings full_pinyin; + full_pinyin.set("keep_first_letter", "false"); + full_pinyin.set("keep_full_pinyin", "true"); + full_pinyin.set("keep_original", "false"); + full_pinyin.set("keep_joined_full_pinyin", "false"); + full_pinyin.set("keep_none_chinese", "false"); + full_pinyin.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory full_pinyin_factory; + full_pinyin_factory.initialize(full_pinyin); + + Settings letters; + letters.set("keep_first_letter", "false"); + letters.set("keep_full_pinyin", "false"); + letters.set("keep_original", "false"); + letters.set("keep_none_chinese", "true"); + letters.set("keep_none_chinese_together", "false"); + letters.set("keep_none_chinese_in_first_letter", "false"); + letters.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory letters_factory; + letters_factory.initialize(letters); + + const std::string text = "刘德华"; + auto tokenizer = createTokenizer("keyword", text); + auto filter = letters_factory.create(full_pinyin_factory.create(tokenizer)); + + auto assert_offsets = + [&filter](const std::vector>& expected) { + Token token; + for (const auto& [term, start, end] : expected) { + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), + term); + EXPECT_EQ(token.startOffset(), start); + EXPECT_EQ(token.endOffset(), end); + } + EXPECT_EQ(filter->next(&token), nullptr); + }; + // Pinyin letters are transformed text, so each letter keeps its Chinese rune's span. + assert_offsets({{"l", 0, 3}, + {"i", 0, 3}, + {"u", 0, 3}, + {"d", 3, 6}, + {"e", 3, 6}, + {"h", 6, 9}, + {"u", 6, 9}, + {"a", 6, 9}}); + + const std::string reset_text = "华刘"; + auto reset_reader = std::make_shared>(); + reset_reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reset_reader); + filter->reset(); + assert_offsets({{"h", 0, 3}, {"u", 0, 3}, {"a", 0, 3}, {"l", 3, 6}, {"i", 3, 6}, {"u", 3, 6}}); + + // An unchanged original token keeps exact rune provenance for the next filter. + Settings original; + original.set("keep_first_letter", "false"); + original.set("keep_full_pinyin", "false"); + original.set("keep_original", "true"); + original.set("keep_none_chinese", "false"); + original.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory original_factory; + original_factory.initialize(original); + auto original_tokenizer = createTokenizer("keyword", text); + filter = full_pinyin_factory.create(original_factory.create(original_tokenizer)); + assert_offsets({{"liu", 0, 3}, {"de", 3, 6}, {"hua", 6, 9}}); +} + +TEST_F(PinyinFilterTest, TestKeywordAndStandardOffsetsComposeWithICUNormalizerAndReset) { + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("none_chinese_pinyin_tokenize", "true"); + settings.set("ignore_pinyin_offset", "false"); + + for (const std::string tokenizer_type : {"keyword", "standard"}) { + auto source = std::make_shared>(); + const std::string text = "LIUDE"; + source->init(text.data(), static_cast(text.size()), false); + ICUNormalizerCharFilterFactory char_filter_factory; + char_filter_factory.initialize({}); + auto reader = char_filter_factory.create(source); + + TokenizerPtr tokenizer; + if (tokenizer_type == "keyword") { + KeywordTokenizerFactory tokenizer_factory; + tokenizer_factory.initialize({}); + tokenizer = tokenizer_factory.create(); + } else { + StandardTokenizerFactory tokenizer_factory; + tokenizer_factory.initialize({}); + tokenizer = tokenizer_factory.create(); + } + tokenizer->set_reader(reader); + tokenizer->reset(); + + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + Token token; + assertToken(filter, &token, "liu", 0, 9); + assertToken(filter, &token, "de", 9, 15); + assertEndOfTokens(filter, &token); + + const std::string reset_text = "ABCD"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + assertToken(filter, &token, "a", 0, 3); + assertToken(filter, &token, "b", 3, 6); + assertToken(filter, &token, "c", 6, 9); + assertToken(filter, &token, "d", 9, 12); + assertEndOfTokens(filter, &token); + } +} + +TEST_F(PinyinFilterTest, TestICUNormalizerFilterUsesConservativeSourceSpansAndReset) { + const std::string text = std::string("\xEF\xAC\x81") + "\xE5\x88\x98"; + auto reader = std::make_shared>(); + reader->init(text.data(), static_cast(text.size()), false); + + KeywordTokenizerFactory tokenizer_factory; + tokenizer_factory.initialize({}); + auto tokenizer = tokenizer_factory.create(); + tokenizer->set_reader(reader); + tokenizer->reset(); + + ICUNormalizerFilterFactory normalizer_factory; + normalizer_factory.initialize({}); + auto normalized = normalizer_factory.create(tokenizer); + + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "true"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "false"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory pinyin_factory; + pinyin_factory.initialize(settings); + auto filter = pinyin_factory.create(normalized); + + Token token; + assertToken(filter, &token, "liu", 0, 6); + assertEndOfTokens(filter, &token); + + const std::string reset_text = std::string("\xEF\xAC\x82") + "\xE6\xB5\x8B"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + assertToken(filter, &token, "ce", 0, 6); + assertEndOfTokens(filter, &token); +} + +TEST_F(PinyinFilterTest, TestICUCharFilterExpansionUsesConservativeRuneSpans) { + const std::string text = std::string("\xEF\xAC\x81") + "\xE5\x88\x98"; + auto source = std::make_shared>(); + source->init(text.data(), static_cast(text.size()), false); + ICUNormalizerCharFilterFactory char_filter_factory; + char_filter_factory.initialize({}); + auto reader = char_filter_factory.create(source); + + KeywordTokenizerFactory tokenizer_factory; + tokenizer_factory.initialize({}); + auto tokenizer = tokenizer_factory.create(); + tokenizer->set_reader(reader); + tokenizer->reset(); + + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "true"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("keep_none_chinese_together", "false"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory pinyin_factory; + pinyin_factory.initialize(settings); + auto filter = pinyin_factory.create(tokenizer); + + Token token; + assertToken(filter, &token, "f", 0, 3); + assertToken(filter, &token, "i", 0, 3); + assertToken(filter, &token, "liu", 3, 6); + assertEndOfTokens(filter, &token); + + const std::string reset_text = std::string("a") + "\xE5\x88\x98"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + assertToken(filter, &token, "a", 0, 1); + assertToken(filter, &token, "liu", 1, 4); + assertEndOfTokens(filter, &token); +} + +TEST_F(PinyinFilterTest, TestDefaultOffsetsPreserveWidthChangingTrimmedSourceSpan) { + const std::string text = std::string("\xE3\x80\x80") + "\xE5\x88\x98" + "\xE3\x80\x80"; + auto source = std::make_shared>(); + source->init(text.data(), static_cast(text.size()), false); + ICUNormalizerCharFilterFactory char_filter_factory; + char_filter_factory.initialize({}); + auto reader = char_filter_factory.create(source); + + KeywordTokenizerFactory tokenizer_factory; + tokenizer_factory.initialize({}); + auto tokenizer = tokenizer_factory.create(); + tokenizer->set_reader(reader); + tokenizer->reset(); + + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "true"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "false"); + PinyinFilterFactory pinyin_factory; + pinyin_factory.initialize(settings); + auto filter = pinyin_factory.create(tokenizer); + + Token token; + assertToken(filter, &token, "liu", 0, 9); + assertEndOfTokens(filter, &token); +} + +TEST_F(PinyinFilterTest, TestASCIIFoldingExpansionUsesConservativeSourceSpanAndReset) { + const std::string text = "ꜳ刘"; + auto reader = std::make_shared>(); + reader->init(text.data(), static_cast(text.size()), false); + + KeywordTokenizerFactory tokenizer_factory; + tokenizer_factory.initialize({}); + auto tokenizer = tokenizer_factory.create(); + tokenizer->set_reader(reader); + tokenizer->reset(); + + ASCIIFoldingFilterFactory folding_factory; + folding_factory.initialize({}); + auto folded = folding_factory.create(tokenizer); + + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "true"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory pinyin_factory; + pinyin_factory.initialize(settings); + auto filter = pinyin_factory.create(folded); + + Token token; + assertToken(filter, &token, "a", 0, 6); + assertToken(filter, &token, "a", 0, 6); + assertToken(filter, &token, "liu", 0, 6); + assertEndOfTokens(filter, &token); + + const std::string reset_text = "a刘"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + assertToken(filter, &token, "a", 0, 1); + assertToken(filter, &token, "liu", 1, 4); + assertEndOfTokens(filter, &token); +} + +TEST_F(PinyinFilterTest, TestLowercaseExpansionUsesConservativeSourceSpanAndReset) { + const std::string text = "İ刘"; + auto reader = std::make_shared>(); + reader->init(text.data(), static_cast(text.size()), false); + + KeywordTokenizerFactory tokenizer_factory; + tokenizer_factory.initialize({}); + auto tokenizer = tokenizer_factory.create(); + tokenizer->set_reader(reader); + tokenizer->reset(); + + LowerCaseFilterFactory lowercase_factory; + lowercase_factory.initialize({}); + auto lowercased = lowercase_factory.create(tokenizer); + + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "true"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory pinyin_factory; + pinyin_factory.initialize(settings); + auto filter = pinyin_factory.create(lowercased); + + Token token; + assertToken(filter, &token, "i", 0, 5); + assertToken(filter, &token, "liu", 0, 5); + assertEndOfTokens(filter, &token); + + const std::string reset_text = "A刘"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + assertToken(filter, &token, "a", 0, 1); + assertToken(filter, &token, "liu", 1, 4); + assertEndOfTokens(filter, &token); +} + +TEST_F(PinyinFilterTest, TestDefaultOffsetModeDoesNotRetainRuneMetadata) { + constexpr size_t input_size = 64 * 1024; + const std::string text(input_size, 'a'); + auto tokenizer = createTokenizer("empty", text); + PinyinFilterFactory factory; + factory.initialize({}); + auto filter = std::dynamic_pointer_cast(factory.create(tokenizer)); + ASSERT_NE(filter, nullptr); + + Token token; + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(filter->current_runes_capacity_for_test(), 0); + filter->reset(); + EXPECT_EQ(filter->current_runes_capacity_for_test(), 0); +} + +TEST_F(PinyinFilterTest, TestResetRetainsOrdinaryScratchAndReleasesOversizedScratch) { + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "true"); + settings.set("keep_none_chinese", "true"); + settings.set("none_chinese_pinyin_tokenize", "false"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory factory; + factory.initialize(settings); + + const std::string ordinary(256, 'a'); + auto ordinary_tokenizer = createTokenizer("empty", ordinary); + auto ordinary_filter = + std::dynamic_pointer_cast(factory.create(ordinary_tokenizer)); + ASSERT_NE(ordinary_filter, nullptr); + Token token; + ASSERT_NE(ordinary_filter->next(&token), nullptr); + const size_t token_capacity = ordinary_filter->current_token_capacity_for_test(); + const size_t source_capacity = ordinary_filter->current_source_capacity_for_test(); + const size_t rune_capacity = ordinary_filter->current_runes_capacity_for_test(); + const size_t offset_capacity = ordinary_filter->current_source_offsets_capacity_for_test(); + ASSERT_GT(token_capacity, 0); + ASSERT_GT(source_capacity, 0); + ASSERT_GT(rune_capacity, 0); + ASSERT_GT(offset_capacity, 0); + ordinary_filter->reset(); + EXPECT_EQ(ordinary_filter->current_token_capacity_for_test(), token_capacity); + EXPECT_EQ(ordinary_filter->current_source_capacity_for_test(), source_capacity); + EXPECT_EQ(ordinary_filter->current_runes_capacity_for_test(), rune_capacity); + EXPECT_EQ(ordinary_filter->current_source_offsets_capacity_for_test(), offset_capacity); + + const std::string oversized(96 * 1024, 'b'); + auto oversized_tokenizer = createTokenizer("empty", oversized); + auto oversized_filter = + std::dynamic_pointer_cast(factory.create(oversized_tokenizer)); + ASSERT_NE(oversized_filter, nullptr); + ASSERT_NE(oversized_filter->next(&token), nullptr); + oversized_filter->reset(); + EXPECT_LE(oversized_filter->current_token_capacity_for_test(), 64 * 1024); + EXPECT_LE(oversized_filter->current_source_capacity_for_test(), 64 * 1024); + EXPECT_LE(oversized_filter->current_runes_capacity_for_test() * sizeof(UChar32), 64 * 1024); + EXPECT_LE(oversized_filter->current_source_offsets_capacity_for_test() * sizeof(int32_t), + 64 * 1024); +} + +TEST_F(PinyinFilterTest, TestResetReleasesOversizedUpstreamProvenanceScratch) { + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "true"); + settings.set("keep_none_chinese", "true"); + settings.set("none_chinese_pinyin_tokenize", "false"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory pinyin_factory; + pinyin_factory.initialize(settings); + WordDelimiterFilterFactory delimiter_factory; + delimiter_factory.initialize({}); + + const std::string ordinary(256, 'a'); + const std::string oversized(96 * 1024, 'b'); + const std::string empty; + auto tokenizer = createTokenizer("empty", ordinary); + auto delimiter = + std::dynamic_pointer_cast(delimiter_factory.create(tokenizer)); + ASSERT_NE(delimiter, nullptr); + auto filter = pinyin_factory.create(delimiter); + + auto reset_to = [&](const std::string& text) { + auto reader = std::make_shared>(); + reader->init(text.data(), static_cast(text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + }; + auto collect = [&]() { + std::vector> tokens; + Token token; + while (filter->next(&token) != nullptr) { + tokens.emplace_back(std::string(token.termBuffer(), token.termLength()), + token.startOffset(), token.endOffset()); + } + return tokens; + }; + auto tokenizer_bytes = [&]() { + return tokenizer->source_byte_offsets_capacity_for_test() * sizeof(int32_t); + }; + + reset_to(ordinary); + const auto ordinary_tokens = collect(); + ASSERT_FALSE(ordinary_tokens.empty()); + const size_t ordinary_tokenizer_bytes = tokenizer_bytes(); + const size_t ordinary_delimiter_bytes = delimiter->scratch_capacity_bytes_for_test(); + ASSERT_GT(ordinary_tokenizer_bytes, 0); + reset_to(ordinary); + EXPECT_EQ(tokenizer_bytes(), ordinary_tokenizer_bytes); + EXPECT_EQ(delimiter->scratch_capacity_bytes_for_test(), ordinary_delimiter_bytes); + EXPECT_EQ(collect(), ordinary_tokens); + + reset_to(oversized); + ASSERT_FALSE(collect().empty()); + ASSERT_GT(tokenizer_bytes(), 64 * 1024); + ASSERT_GT(delimiter->scratch_capacity_bytes_for_test(), 64 * 1024); + + reset_to(empty); + EXPECT_LE(tokenizer_bytes(), 64 * 1024); + EXPECT_LE(delimiter->scratch_capacity_bytes_for_test(), 64 * 1024); + EXPECT_TRUE(collect().empty()); + + reset_to(ordinary); + EXPECT_EQ(collect(), ordinary_tokens); +} + +namespace { + +// Offset-aware Pinyin filter that emits every ASCII letter on its own. +PinyinFilterFactory make_letters_filter_factory() { + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("keep_none_chinese_together", "false"); + settings.set("keep_none_chinese_in_first_letter", "false"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory factory; + factory.initialize(settings); + return factory; +} + +void assert_stream(const TokenStreamPtr& filter, + const std::vector>& expected) { + Token token; + for (const auto& [term, start, end] : expected) { + ASSERT_NE(filter->next(&token), nullptr) << "missing " << term; + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), term); + EXPECT_EQ(token.startOffset(), start) << term; + EXPECT_EQ(token.endOffset(), end) << term; + } + EXPECT_EQ(filter->next(&token), nullptr); +} + +TokenizerPtr make_pinyin_tokenizer(const ReaderPtr& reader, bool tokenize_none_chinese) { + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "true"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("keep_none_chinese_together", "true"); + settings.set("none_chinese_pinyin_tokenize", tokenize_none_chinese ? "true" : "false"); + settings.set("ignore_pinyin_offset", "false"); + PinyinTokenizerFactory factory; + factory.initialize(settings); + auto tokenizer = factory.create(); + tokenizer->set_reader(reader); + tokenizer->reset(); + return tokenizer; +} + +} // namespace + +TEST_F(PinyinFilterTest, TestPinyinTokenizerPublishesCandidateProvenanceAndCorrectsOffsets) { + PinyinFilterFactory letters = make_letters_filter_factory(); + + // Transformed text keeps the whole Chinese rune span for every letter. + { + const std::string text = "中"; + auto reader = std::make_shared>(); + reader->init(text.data(), static_cast(text.size()), false); + auto filter = letters.create(make_pinyin_tokenizer(reader, true)); + assert_stream(filter, {{"z", 0, 3}, {"h", 0, 3}, {"o", 0, 3}, {"n", 0, 3}, {"g", 0, 3}}); + } + // A compacted ASCII buffer ends at the last letter's real byte and stays conservative. + { + const std::string text = "a-b"; + auto reader = std::make_shared>(); + reader->init(text.data(), static_cast(text.size()), false); + auto filter = letters.create(make_pinyin_tokenizer(reader, false)); + assert_stream(filter, {{"a", 0, 3}, {"b", 0, 3}}); + } + // Offsets follow a preceding char filter back to the source, with reset/reuse. + { + auto source = std::make_shared>(); + const std::string text = "\xEF\xAC\x81"; // U+FB01 expands to "fi" + source->init(text.data(), static_cast(text.size()), false); + ICUNormalizerCharFilterFactory char_filter_factory; + char_filter_factory.initialize({}); + auto reader = char_filter_factory.create(source); + auto tokenizer = make_pinyin_tokenizer(reader, false); + auto filter = letters.create(tokenizer); + assert_stream(filter, {{"f", 0, 3}, {"i", 0, 3}}); + + const std::string reset_text = "LI"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + assert_stream(filter, {{"l", 0, 3}, {"i", 3, 6}}); + } +} + +TEST_F(PinyinFilterTest, TestAsciiFoldingMalformedInputPublishesConservativeSpan) { + PinyinFilterFactory letters = make_letters_filter_factory(); + for (const bool preserve_original : {false, true}) { + SCOPED_TRACE(preserve_original); + const std::string text = "\xFF\xC3\x86"; // stray byte, then U+00C6 + auto tokenizer = createTokenizer("keyword", text); + Settings settings; + settings.set("preserve_original", preserve_original ? "true" : "false"); + ASCIIFoldingFilterFactory folding_factory; + folding_factory.initialize(settings); + auto folding = folding_factory.create(tokenizer); + auto filter = letters.create(folding); + + std::vector> expected = {{"a", 0, 3}, + {"e", 0, 3}}; + if (preserve_original) { + expected.emplace_back(text, 0, 3); + } + assert_stream(filter, expected); + + const std::string reset_text = "\xC3\x86"; + auto reset_reader = std::make_shared>(); + reset_reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reset_reader); + filter->reset(); + expected = {{"a", 0, 2}, {"e", 0, 2}}; + if (preserve_original) { + expected.emplace_back(reset_text, 0, 2); + } + assert_stream(filter, expected); + } +} + +TEST_F(PinyinFilterTest, TestWordDelimiterWithoutUpstreamProvenancePublishesTokenSpan) { + PinyinFilterFactory letters = make_letters_filter_factory(); + const std::string text = + "\xFF" + "a"; + auto tokenizer = createTokenizer("keyword", text); + WordDelimiterFilterFactory delimiter_factory; + delimiter_factory.initialize({}); + auto filter = letters.create(delimiter_factory.create(tokenizer)); + // Malformed upstream text has no rune map, so the part claims the whole token. + assert_stream(filter, {{"a", 0, 2}}); + + auto reset_to = [&](const std::string& reset_text) { + auto reader = std::make_shared>(); + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + }; + // The readers keep pointers into these strings, so they must outlive the assertions. + const std::string interior_text = + "a\xFF" + "b"; + // An interior malformed byte is not a delimiter, so the token passes through unchanged and + // its letters keep their exact byte positions. + reset_to(interior_text); + assert_stream(filter, {{"a", 0, 1}, {"b", 2, 3}}); + const std::string valid_text = "liu-de"; + reset_to(valid_text); + assert_stream(filter, {{"l", 0, 1}, {"i", 1, 2}, {"u", 2, 3}, {"d", 4, 5}, {"e", 5, 6}}); +} + +TEST_F(PinyinFilterTest, TestNGramInsideCharFilterExpansionKeepsSourceSpan) { + PinyinFilterFactory letters = make_letters_filter_factory(); + auto source = std::make_shared>(); + const std::string text = "\xEF\xAC\x81"; // U+FB01 expands to "fi" + source->init(text.data(), static_cast(text.size()), false); + ICUNormalizerCharFilterFactory char_filter_factory; + char_filter_factory.initialize({}); + auto reader = char_filter_factory.create(source); + + Settings settings; + settings.set("min_gram", "1"); + settings.set("max_gram", "1"); + NGramTokenizerFactory tokenizer_factory; + tokenizer_factory.initialize(settings); + auto tokenizer = tokenizer_factory.create(); + tokenizer->set_reader(reader); + tokenizer->reset(); + auto filter = letters.create(tokenizer); + // A gram that starts inside the expansion still owns the whole ligature's source span. + assert_stream(filter, {{"f", 0, 3}, {"i", 0, 3}}); + + const std::string reset_text = "LI"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + assert_stream(filter, {{"l", 0, 3}, {"i", 3, 6}}); +} + +TEST_F(PinyinFilterTest, TestPinyinTokenizerCollectsSourceScratchOnlyForOffsets) { + const std::string large(96 * 1024, 'a'); + Token token; + + auto ignoring_reader = std::make_shared>(); + ignoring_reader->init(large.data(), static_cast(large.size()), false); + PinyinTokenizerFactory default_factory; + default_factory.initialize({}); + auto ignoring = std::dynamic_pointer_cast(default_factory.create()); + ASSERT_NE(ignoring, nullptr); + ignoring->set_reader(ignoring_reader); + ignoring->reset(); + while (ignoring->next(&token) != nullptr) { + } + // Default settings ignore offsets, so no per-letter source ranges are collected. + EXPECT_EQ(ignoring->ascii_scratch_capacity_for_test(), 0); + + auto tracking_reader = std::make_shared>(); + tracking_reader->init(large.data(), static_cast(large.size()), false); + auto tracking = std::dynamic_pointer_cast( + make_pinyin_tokenizer(tracking_reader, false)); + ASSERT_NE(tracking, nullptr); + while (tracking->next(&token) != nullptr) { + } + EXPECT_GT(tracking->ascii_scratch_capacity_for_test(), 0); + + const std::string small = "ab"; + auto small_reader = std::make_shared>(); + small_reader->init(small.data(), static_cast(small.size()), false); + tracking->set_reader(small_reader); + tracking->reset(); + EXPECT_LE(tracking->ascii_scratch_capacity_for_test() * sizeof(int32_t), 64 * 1024); + while (tracking->next(&token) != nullptr) { + } +} + +TEST_F(PinyinFilterTest, TestPinyinFilterCollectsRuneIndicesOnlyForOffsets) { + const std::string large(4096, 'a'); + Token token; + + PinyinFilterFactory default_factory; + default_factory.initialize({}); + auto ignoring = std::dynamic_pointer_cast( + default_factory.create(createTokenizer("keyword", large))); + ASSERT_NE(ignoring, nullptr); + ASSERT_NE(ignoring->next(&token), nullptr); + EXPECT_EQ(ignoring->last_ascii_rune_index_capacity_for_test(), 0); + + Settings settings; + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory tracking_factory; + tracking_factory.initialize(settings); + auto tracking = std::dynamic_pointer_cast( + tracking_factory.create(createTokenizer("keyword", large))); + ASSERT_NE(tracking, nullptr); + ASSERT_NE(tracking->next(&token), nullptr); + EXPECT_GE(tracking->last_ascii_rune_index_capacity_for_test(), large.size()); +} + +TEST_F(PinyinFilterTest, TestIcuTokenizerTranscodesSourceOnlyForOffsets) { + const std::string text = "Hello"; + ICUTokenizerFactory factory; + factory.initialize({}); + Token token; + + auto plain = std::dynamic_pointer_cast(factory.create()); + ASSERT_NE(plain, nullptr); + auto plain_reader = std::make_shared>(); + plain_reader->init(text.data(), static_cast(text.size()), false); + plain->set_reader(plain_reader); + plain->reset(); + ASSERT_NE(plain->next(&token), nullptr); + EXPECT_EQ(plain->source_scratch_size_for_test(), 0); + + // Without lowercasing the term already is the source text, so it is reused as provenance input. + auto tracking = std::dynamic_pointer_cast(factory.create()); + ASSERT_NE(tracking, nullptr); + tracking->set_source_byte_offsets_enabled(true); + auto tracking_reader = std::make_shared>(); + tracking_reader->init(text.data(), static_cast(text.size()), false); + tracking->set_reader(tracking_reader); + tracking->reset(); + ASSERT_NE(tracking->next(&token), nullptr); + EXPECT_EQ(tracking->source_scratch_size_for_test(), 0); + EXPECT_EQ(tracking->get_source_byte_offsets().size(), text.size() + 1); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), text); +} + +TEST_F(PinyinFilterTest, TestCaseAndFoldingFiltersCountRunesOnlyForOffsets) { + Token token; + for (const bool enabled : {false, true}) { + SCOPED_TRACE(enabled); + // U+0130 lowercases to two code points, so the rune count changes when it is checked. + const std::string dotted = "\xC4\xB0"; + LowerCaseFilterFactory lower_factory; + lower_factory.initialize({}); + auto lower = std::dynamic_pointer_cast( + lower_factory.create(createTokenizer("keyword", dotted))); + ASSERT_NE(lower, nullptr); + lower->set_source_byte_offsets_enabled(enabled); + ASSERT_NE(lower->next(&token), nullptr); + EXPECT_EQ(lower->rune_count_changed_for_test(), enabled); + + const std::string ligature = "\xC3\x86"; // U+00C6 folds to "AE" + ASCIIFoldingFilterFactory folding_factory; + folding_factory.initialize({}); + auto folding = std::dynamic_pointer_cast( + folding_factory.create(createTokenizer("keyword", ligature))); + ASSERT_NE(folding, nullptr); + folding->set_source_byte_offsets_enabled(enabled); + ASSERT_NE(folding->next(&token), nullptr); + EXPECT_EQ(folding->rune_count_changed_for_test(), enabled); + } +} + +TEST_F(PinyinFilterTest, TestPinyinTokenizerClipsOriginalCandidateProvenance) { + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_none_chinese", "false"); + settings.set("keep_original", "true"); + settings.set("ignore_pinyin_offset", "false"); + PinyinTokenizerFactory factory; + factory.initialize(settings); + + // An over-cap original candidate publishes only its prefix, so it owns only that source. + const std::string ascii(300, 'a'); + auto ascii_reader = std::make_shared>(); + ascii_reader->init(ascii.data(), static_cast(ascii.size()), false); + auto tokenizer = factory.create(); + tokenizer->set_reader(ascii_reader); + tokenizer->set_source_byte_offsets_enabled(true); + tokenizer->reset(); + + Token token; + ASSERT_NE(tokenizer->next(&token), nullptr); + EXPECT_EQ(token.termLength(), 255); + EXPECT_EQ(token.startOffset(), 0); + EXPECT_EQ(token.endOffset(), 255); + EXPECT_EQ(tokenizer->get_source_byte_offsets().size(), 256); + EXPECT_EQ(tokenizer->get_source_byte_offsets().back(), 255); + + // A cap that falls inside a rune clips to the rune boundary before it (2-byte runes here, + // so the 255-byte cap would split the 128th one). + std::string latin; + for (int i = 0; i < 200; ++i) { + latin += "\xC3\xA9"; // U+00E9 + } + auto latin_reader = std::make_shared>(); + latin_reader->init(latin.data(), static_cast(latin.size()), false); + tokenizer->set_reader(latin_reader); + tokenizer->reset(); + ASSERT_NE(tokenizer->next(&token), nullptr); + EXPECT_EQ(token.termLength(), 254); + EXPECT_EQ(token.endOffset(), 254); + EXPECT_EQ(tokenizer->get_source_byte_offsets().size(), 128); +} + +TEST_F(PinyinFilterTest, TestOffsetTrackingReusesTokenizerScratchAcrossTokens) { + for (const std::string tokenizer_type : {"standard", "ik_max_word"}) { + SCOPED_TRACE(tokenizer_type); + const std::string text = "abcdefghijklmnopqrstuvwxyz bc de fg hi"; + auto tokenizer = createTokenizer(tokenizer_type, text); + tokenizer->set_source_byte_offsets_enabled(true); + Token token; + // The scratch and the published vector alternate, so after two tokens every + // following token must land in one of the two warmed buffers without reallocating. + std::vector capacities; + while (tokenizer->next(&token) != nullptr) { + capacities.push_back(tokenizer->source_byte_offsets_capacity_for_test()); + } + ASSERT_EQ(capacities.size(), 5); + EXPECT_GE(capacities[0], 27); + for (size_t i = 2; i < capacities.size(); ++i) { + EXPECT_EQ(capacities[i], capacities[i - 2]) << "token " << i; + } + } +} + +TEST_F(PinyinFilterTest, TestPinyinTrimmedKeywordOffsetsPreserveSourceBoundariesAndReset) { + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "true"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "false"); + settings.set("ignore_pinyin_offset", "false"); + + const std::string text = " 刘德华 "; + auto tokenizer = createTokenizer("keyword", text); + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + auto assert_offsets = + [&filter](const std::vector>& expected) { + Token token; + for (const auto& [term, start, end] : expected) { + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), + term); + EXPECT_EQ(token.startOffset(), start); + EXPECT_EQ(token.endOffset(), end); + } + EXPECT_EQ(filter->next(&token), nullptr); + }; + assert_offsets({{"liu", 2, 5}, {"de", 5, 8}, {"hua", 8, 11}}); + + const std::string reset_text = " \t测试 "; + auto reset_reader = std::make_shared>(); + reset_reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reset_reader); + filter->reset(); + assert_offsets({{"ce", 2, 5}, {"shi", 5, 8}}); +} + +TEST_F(PinyinFilterTest, TestIKOffsetsComposeWithICUNormalizerForManyTokensAndReset) { + std::string text; + constexpr int token_count = 4096; + for (int i = 0; i < token_count; ++i) { + text += "L "; + } + + auto source = std::make_shared>(); + source->init(text.data(), static_cast(text.size()), false); + ICUNormalizerCharFilterFactory char_filter_factory; + char_filter_factory.initialize({}); + auto reader = char_filter_factory.create(source); + + IKTokenizerFactory tokenizer_factory(true); + tokenizer_factory.initialize({}); + auto tokenizer = tokenizer_factory.create(); + tokenizer->set_reader(reader); + tokenizer->reset(); + + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("none_chinese_pinyin_tokenize", "true"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + for (int i = 0; i < token_count; ++i) { + Token token; + assertToken(filter, &token, "l", i * 4, i * 4 + 3); + } + Token token; + assertEndOfTokens(filter, &token); + + const std::string reset_text = "ABCD"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + for (const auto& [term, start, end] : std::vector> { + {"a", 0, 3}, {"b", 3, 6}, {"c", 6, 9}, {"d", 9, 12}}) { + assertToken(filter, &token, term, start, end); + } + assertEndOfTokens(filter, &token); +} + +TEST_F(PinyinFilterTest, TestPinyinWholeTokenAlternativesUseKeywordOffsets) { + Settings settings; + settings.set("keep_first_letter", "true"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "true"); + settings.set("keep_joined_full_pinyin", "true"); + settings.set("keep_none_chinese", "false"); + settings.set("ignore_pinyin_offset", "false"); + + const std::string text = "刘德华"; + auto tokenizer = createTokenizer("keyword", text); + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + const std::vector expected_terms = {"刘德华", "liudehua", "ldh"}; + Token token; + for (const auto& term : expected_terms) { + assertToken(filter, &token, term, 0, 9); + } + assertEndOfTokens(filter, &token); +} + +TEST_F(PinyinFilterTest, TestPinyinStandardOffsetsDoNotReusePreviousTokenState) { + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "true"); + settings.set("keep_original", "false"); + settings.set("keep_joined_full_pinyin", "false"); + settings.set("keep_none_chinese", "false"); + settings.set("ignore_pinyin_offset", "false"); + + const std::string text = "刘德华 测试"; + auto tokenizer = createTokenizer("standard", text); + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + auto assert_offsets = + [&filter](const std::vector>& expected) { + Token token; + for (const auto& [term, start, end] : expected) { + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), + term); + EXPECT_EQ(token.startOffset(), start); + EXPECT_EQ(token.endOffset(), end); + } + EXPECT_EQ(filter->next(&token), nullptr); + }; + + assert_offsets({{"liu", 0, 3}, {"de", 3, 6}, {"hua", 6, 9}, {"ce", 10, 13}, {"shi", 13, 16}}); + + const std::string reset_text = "测试 刘德华"; + auto reset_reader = std::make_shared>(); + reset_reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reset_reader); + filter->reset(); + assert_offsets({{"ce", 0, 3}, {"shi", 3, 6}, {"liu", 7, 10}, {"de", 10, 13}, {"hua", 13, 16}}); +} + +TEST_F(PinyinFilterTest, TestBasicTokenizerOffsetsRemainDocumentRelativeAfterReset) { + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "true"); + settings.set("keep_original", "false"); + settings.set("keep_joined_full_pinyin", "false"); + settings.set("keep_none_chinese", "false"); + settings.set("ignore_pinyin_offset", "false"); + + auto tokenizer = createTokenizer("basic", "刘德华"); + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + Token token; + assertToken(filter, &token, "liu", 0, 3); + assertToken(filter, &token, "de", 3, 6); + assertToken(filter, &token, "hua", 6, 9); + assertEndOfTokens(filter, &token); + + const std::string reset_text = "测试 刘德华"; + auto reset_reader = std::make_shared>(); + reset_reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reset_reader); + filter->reset(); + assertToken(filter, &token, "ce", 0, 3); + assertToken(filter, &token, "shi", 3, 6); + assertToken(filter, &token, "liu", 7, 10); + assertToken(filter, &token, "de", 10, 13); + assertToken(filter, &token, "hua", 13, 16); + assertEndOfTokens(filter, &token); +} + +TEST_F(PinyinFilterTest, TestOffsetAwarePinyinSupportsAllCustomTokenizers) { + const std::string text = "你好 世界"; + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "true"); + settings.set("keep_original", "false"); + settings.set("keep_joined_full_pinyin", "false"); + settings.set("keep_none_chinese", "false"); + settings.set("ignore_pinyin_offset", "false"); + + for (const std::string tokenizer_type : {"basic", "char_group", "empty", "icu", "ngram"}) { + SCOPED_TRACE(tokenizer_type); + auto tokenizer = createTokenizer(tokenizer_type, text); + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + Token token; + assertToken(filter, &token, "ni", 0, 3); + assertToken(filter, &token, "hao", 3, 6); + assertToken(filter, &token, "shi", 7, 10); + assertToken(filter, &token, "jie", 10, 13); + assertEndOfTokens(filter, &token); + } +} + +TEST_F(PinyinFilterTest, TestIKOffsetsComposeAcrossICUDeletionAndReset) { + const std::string text = std::string("liu") + "\xC2\xAD" + "de"; + auto source = std::make_shared>(); + source->init(text.data(), static_cast(text.size()), false); + ICUNormalizerCharFilterFactory char_filter_factory; + char_filter_factory.initialize({}); + auto inner_filter = char_filter_factory.create(source); + auto outer_filter = char_filter_factory.create(inner_filter); + + IKTokenizerFactory tokenizer_factory(true); + tokenizer_factory.initialize({}); + auto tokenizer = tokenizer_factory.create(); + tokenizer->set_reader(outer_filter); + tokenizer->reset(); + + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("none_chinese_pinyin_tokenize", "true"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + auto assert_offsets = + [&filter](const std::vector>& expected) { + Token token; + for (const auto& [term, start, end] : expected) { + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), + term); + EXPECT_EQ(token.startOffset(), start); + EXPECT_EQ(token.endOffset(), end); + } + EXPECT_EQ(filter->next(&token), nullptr); + }; + + assert_offsets({{"liu", 0, 5}, {"de", 5, 7}}); + + outer_filter->init(text.data(), static_cast(text.size()), false); + tokenizer->set_reader(outer_filter); + filter->reset(); + assert_offsets({{"liu", 0, 5}, {"de", 5, 7}}); +} + +TEST_F(PinyinFilterTest, TestIKOffsetsPreserveConnectorGaps) { + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("none_chinese_pinyin_tokenize", "true"); + settings.set("ignore_pinyin_offset", "false"); + + for (const std::string tokenizer_type : {"ik_smart", "ik_max_word"}) { + auto tokenizer = createTokenizer(tokenizer_type, "LIU-DE"); + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + Token token; + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), "liu"); + EXPECT_EQ(token.startOffset(), 0); + EXPECT_EQ(token.endOffset(), 9); + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), "de"); + EXPECT_EQ(token.startOffset(), 10); + EXPECT_EQ(token.endOffset(), 16); + } +} + +TEST_F(PinyinFilterTest, TestIKOffsetsStayAlignedWhenLongTermsAreClippedAndReset) { + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("none_chinese_pinyin_tokenize", "true"); + settings.set("ignore_pinyin_offset", "false"); + + std::string long_text; + for (int i = 0; i < 1024; ++i) { + long_text += "LIUDE"; + } + + auto tokenizer = createTokenizer("ik_smart", long_text); + PinyinFilterFactory filter_factory; + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + + Token token; + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), "liu"); + EXPECT_EQ(token.startOffset(), 0); + EXPECT_EQ(token.endOffset(), 9); + + auto reset_reader = std::make_shared>(); + const std::string reset_text = "LIUDE"; + reset_reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reset_reader); + filter->reset(); + + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), "liu"); + EXPECT_EQ(token.startOffset(), 0); + EXPECT_EQ(token.endOffset(), 9); +} + +TEST_F(PinyinFilterTest, TestIKSourceOffsetsAreOptIn) { + auto tokenizer = createTokenizer("ik_smart", "LIUDE"); + Token token; + ASSERT_NE(tokenizer->next(&token), nullptr); + EXPECT_TRUE(tokenizer->get_source_byte_offsets().empty()); + + tokenizer = createTokenizer("ik_smart", "LIUDE"); + PinyinFilterFactory filter_factory; + Settings settings; + settings.set("ignore_pinyin_offset", "false"); + filter_factory.initialize(settings); + auto filter = filter_factory.create(tokenizer); + EXPECT_NE(filter, nullptr); + ASSERT_NE(tokenizer->next(&token), nullptr); + auto offsets = tokenizer->get_source_byte_offsets(); + ASSERT_EQ(offsets.size(), 6); + EXPECT_EQ(std::vector(offsets.begin(), offsets.end()), + (std::vector {0, 3, 6, 9, 12, 15})); +} + +TEST_F(PinyinFilterTest, TestWordDelimiterPreservesIKSourceOffsets) { + auto tokenizer = createTokenizer("ik_smart", "LIUDE123"); + + WordDelimiterFilterFactory delimiter_factory; + delimiter_factory.initialize({}); + auto delimiter = delimiter_factory.create(tokenizer); + + Settings pinyin_settings; + pinyin_settings.set("keep_first_letter", "false"); + pinyin_settings.set("keep_full_pinyin", "false"); + pinyin_settings.set("keep_original", "false"); + pinyin_settings.set("keep_none_chinese", "true"); + pinyin_settings.set("none_chinese_pinyin_tokenize", "true"); + pinyin_settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory pinyin_factory; + pinyin_factory.initialize(pinyin_settings); + auto filter = pinyin_factory.create(delimiter); + + const std::vector> expected = { + {"liu", 0, 9}, {"de", 9, 15}, {"123", 15, 24}}; + Token token; + for (const auto& [term, start, end] : expected) { + ASSERT_NE(filter->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), term); + EXPECT_EQ(token.startOffset(), start); + EXPECT_EQ(token.endOffset(), end); + } + EXPECT_EQ(filter->next(&token), nullptr); +} + +TEST_F(PinyinFilterTest, TestWordDelimiterConcatenationPreservesSourceGapsAndReset) { + for (const std::string option : {"catenate_words", "catenate_all"}) { + SCOPED_TRACE(option); + const std::string text = "liu-de"; + auto tokenizer = createTokenizer("keyword", text); + Settings delimiter_settings; + delimiter_settings.set("generate_word_parts", "false"); + delimiter_settings.set("generate_number_parts", "false"); + delimiter_settings.set(option, "true"); + WordDelimiterFilterFactory delimiter_factory; + delimiter_factory.initialize(delimiter_settings); + auto delimiter = delimiter_factory.create(tokenizer); + + Settings pinyin_settings; + pinyin_settings.set("keep_first_letter", "false"); + pinyin_settings.set("keep_full_pinyin", "false"); + pinyin_settings.set("keep_original", "false"); + pinyin_settings.set("keep_none_chinese", "true"); + pinyin_settings.set("none_chinese_pinyin_tokenize", "true"); + pinyin_settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory pinyin_factory; + pinyin_factory.initialize(pinyin_settings); + auto filter = pinyin_factory.create(delimiter); + + Token token; + assertToken(filter, &token, "liu", 0, 3); + assertToken(filter, &token, "de", 4, 6); + assertEndOfTokens(filter, &token); + + const std::string reset_text = "de--liu"; + auto reader = std::make_shared>(); + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + filter->reset(); + assertToken(filter, &token, "de", 0, 2); + assertToken(filter, &token, "liu", 4, 7); + assertEndOfTokens(filter, &token); + } +} + +TEST_F(PinyinFilterTest, TestWordDelimiterPreservesPlainTokenizerOffsetsAfterReset) { + for (const std::string tokenizer_type : {"keyword", "standard"}) { + SCOPED_TRACE(tokenizer_type); + const bool keyword = tokenizer_type == "keyword"; + const std::string text = keyword ? "liu-de" : "LiuDe"; + auto tokenizer = createTokenizer(tokenizer_type, text); + + WordDelimiterFilterFactory delimiter_factory; + delimiter_factory.initialize({}); + auto delimiter = delimiter_factory.create(tokenizer); + + Settings settings; + settings.set("keep_first_letter", "false"); + settings.set("keep_full_pinyin", "false"); + settings.set("keep_original", "false"); + settings.set("keep_none_chinese", "true"); + settings.set("none_chinese_pinyin_tokenize", "true"); + settings.set("ignore_pinyin_offset", "false"); + PinyinFilterFactory pinyin_factory; + pinyin_factory.initialize(settings); + auto filter = pinyin_factory.create(delimiter); + + Token token; + assertToken(filter, &token, "liu", 0, 3); + assertToken(filter, &token, "de", keyword ? 4 : 3, keyword ? 6 : 5); + assertEndOfTokens(filter, &token); + + const std::string reset_text = keyword ? "de-liu" : "DeLiu"; + auto reset_reader = std::make_shared>(); + reset_reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reset_reader); + filter->reset(); + + assertToken(filter, &token, "de", 0, 2); + assertToken(filter, &token, "liu", keyword ? 3 : 2, keyword ? 6 : 5); + assertEndOfTokens(filter, &token); + } +} + TEST_F(PinyinFilterTest, TestTokenFilter_StandardAnalyzer_FullPinyin) { std::unordered_map config; config["keep_first_letter"] = "false"; diff --git a/be/test/storage/index/inverted/token_filter/word_delimiter_filter_test.cpp b/be/test/storage/index/inverted/token_filter/word_delimiter_filter_test.cpp index 32c4070ace6a2b..314cbccf193316 100644 --- a/be/test/storage/index/inverted/token_filter/word_delimiter_filter_test.cpp +++ b/be/test/storage/index/inverted/token_filter/word_delimiter_filter_test.cpp @@ -328,4 +328,46 @@ TEST(WordDelimiterFilterTest, SortedTokensWithPositionIncrement) { EXPECT_EQ(i, expected.size()) << "Token count mismatch. Check splitting rules."; } +TEST(WordDelimiterFilterTest, RepeatedFilterDropsTrailingMalformedByte) { + // Scanning a word absorbs a malformed byte, but the bounds of a later pass stop before it, so + // running the filter twice is not the same as running it once. It is not an idempotent filter. + const std::string text = std::string("abc\xFF") + " def"; + const int32_t flags = WordDelimiterFilter::GENERATE_WORD_PARTS; + + auto collect = [](const TokenStreamPtr& stream) { + std::vector terms; + Token token; + while (stream->next(&token)) { + terms.emplace_back(token.termBuffer(), token.termLength()); + } + return terms; + }; + + const std::vector once = collect(create_filter(text, flags)); + + ReaderPtr reader = std::make_shared>(); + reader->init(text.data(), text.size(), false); + Settings settings; + KeywordTokenizerFactory tokenizer_factory; + tokenizer_factory.initialize(settings); + auto tokenizer = tokenizer_factory.create(); + tokenizer->set_reader(reader); + auto inner = std::make_shared( + tokenizer, WordDelimiterIterator::DEFAULT_WORD_DELIM_TABLE, flags, + std::unordered_set {}); + auto outer = std::make_shared( + inner, WordDelimiterIterator::DEFAULT_WORD_DELIM_TABLE, flags, + std::unordered_set {}); + outer->reset(); + const std::vector twice = collect(outer); + + ASSERT_EQ(once.size(), 2); + EXPECT_EQ(once[0], std::string("abc\xFF")); + EXPECT_EQ(once[1], "def"); + ASSERT_EQ(twice.size(), 2); + EXPECT_EQ(twice[0], "abc"); + EXPECT_EQ(twice[1], "def"); + EXPECT_NE(once, twice); +} + } // namespace doris::segment_v2::inverted_index \ No newline at end of file diff --git a/be/test/storage/index/inverted/tokenizer/icu_tokenizer_factory_test.cpp b/be/test/storage/index/inverted/tokenizer/icu_tokenizer_factory_test.cpp index 4c8ef59bdff8e0..1811da63c1c42d 100644 --- a/be/test/storage/index/inverted/tokenizer/icu_tokenizer_factory_test.cpp +++ b/be/test/storage/index/inverted/tokenizer/icu_tokenizer_factory_test.cpp @@ -19,6 +19,12 @@ #include +#include + +#ifdef ADDRESS_SANITIZER +#include +#endif + namespace doris::segment_v2::inverted_index { TokenStreamPtr create_icu_tokenizer(const std::string& text, Settings settings = Settings()) { @@ -182,6 +188,103 @@ TEST_F(ICUTokenizerFactoryTest, LongText) { assert_tokenizer_output(long_text, expected); } +TEST_F(ICUTokenizerFactoryTest, LargeInputResetUsesBoundedOffsetMemory) { + constexpr size_t input_size = 4 * 1024 * 1024; + const std::string input(input_size, 'a'); + auto reader = std::make_shared>(); + reader->init(input.data(), static_cast(input.size()), false); + + ICUTokenizerFactory factory; + factory.initialize({}); + auto tokenizer = factory.create(); + tokenizer->set_reader(reader); + +#ifdef ADDRESS_SANITIZER + const size_t allocated_before = __sanitizer_get_current_allocated_bytes(); +#endif + tokenizer->reset(); +#ifdef ADDRESS_SANITIZER + const size_t allocated_after = __sanitizer_get_current_allocated_bytes(); + ASSERT_GE(allocated_after, allocated_before); + EXPECT_LT(allocated_after - allocated_before, input_size * 4); +#endif +} + +TEST_F(ICUTokenizerFactoryTest, ClipsLongTokensAtUnicodeScalarBoundary) { + const std::string expected(LUCENE_MAX_WORD_LEN - 1, 'a'); + const std::string text = expected + "\xF0\x90\x90\x80"; + auto reader = std::make_shared>(); + reader->init(text.data(), static_cast(text.size()), false); + + ICUTokenizerFactory factory; + factory.initialize({}); + auto tokenizer = factory.create(); + tokenizer->set_source_byte_offsets_enabled(true); + tokenizer->set_reader(reader); + tokenizer->reset(); + + Token token; + ASSERT_NE(tokenizer->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), expected); + EXPECT_EQ(token.startOffset(), 0); + EXPECT_EQ(token.endOffset(), expected.size()); + ASSERT_FALSE(tokenizer->get_source_byte_offsets().empty()); + EXPECT_EQ(tokenizer->get_source_byte_offsets().back(), expected.size()); + + const std::string reset_text = "reset"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + tokenizer->reset(); + ASSERT_NE(tokenizer->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), reset_text); + EXPECT_EQ(token.startOffset(), 0); + EXPECT_EQ(token.endOffset(), 5); +} + +TEST_F(ICUTokenizerFactoryTest, IndexesMalformedUtf8UnlessOffsetsAreTracked) { + const std::string malformed = std::string("alpha ") + static_cast(0xFF) + " beta"; + auto reader = std::make_shared>(); + reader->init(malformed.data(), static_cast(malformed.size()), false); + + ICUTokenizerFactory factory; + factory.initialize({}); + auto tokenizer = factory.create(); + tokenizer->set_reader(reader); + // A column may hold malformed bytes, so indexing keeps the valid words around them. + ASSERT_NO_THROW(tokenizer->reset()); + Token malformed_token; + ASSERT_NE(tokenizer->next(&malformed_token), nullptr); + EXPECT_EQ(std::string(malformed_token.termBuffer(), malformed_token.termLength()), + "alpha"); + ASSERT_NE(tokenizer->next(&malformed_token), nullptr); + EXPECT_EQ(std::string(malformed_token.termBuffer(), malformed_token.termLength()), + "beta"); +} + +TEST_F(ICUTokenizerFactoryTest, RejectsMalformedUtf8AndCanBeReset) { + const std::string malformed = std::string("alpha ") + static_cast(0xFF) + " beta"; + auto reader = std::make_shared>(); + reader->init(malformed.data(), static_cast(malformed.size()), false); + + ICUTokenizerFactory factory; + factory.initialize({}); + auto tokenizer = factory.create(); + // Malformed bytes have no source span, so the offset-aware path still rejects them. + tokenizer->set_source_byte_offsets_enabled(true); + tokenizer->set_reader(reader); + EXPECT_THROW(tokenizer->reset(), Exception); + + const std::string valid = "gamma delta"; + reader->init(valid.data(), static_cast(valid.size()), false); + tokenizer->set_reader(reader); + ASSERT_NO_THROW(tokenizer->reset()); + Token token; + ASSERT_NE(tokenizer->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), "gamma"); + EXPECT_EQ(token.startOffset(), 0); + EXPECT_EQ(token.endOffset(), 5); +} + TEST_F(ICUTokenizerFactoryTest, SpecialCharacters) { // Test special characters and symbols assert_tokenizer_output("Price: $100.50 (USD)", {{"Price", 1}, {"100.50", 1}, {"USD", 1}}); @@ -209,4 +312,4 @@ TEST_F(ICUTokenizerFactoryTest, UnicodeNormalization) { assert_tokenizer_output("café naïve résumé", {{"café", 1}, {"naïve", 1}, {"résumé", 1}}); } -} // namespace doris::segment_v2::inverted_index \ No newline at end of file +} // namespace doris::segment_v2::inverted_index diff --git a/be/test/storage/index/inverted/tokenizer/keyword_analyzer_test.cpp b/be/test/storage/index/inverted/tokenizer/keyword_analyzer_test.cpp index 1ec3e1f13e7e85..1b91ddf20b9ab6 100644 --- a/be/test/storage/index/inverted/tokenizer/keyword_analyzer_test.cpp +++ b/be/test/storage/index/inverted/tokenizer/keyword_analyzer_test.cpp @@ -144,4 +144,35 @@ TEST(KeywordTokenizerTest, LongInput) { EXPECT_EQ(tokens1[0].size(), 8192); } -} // namespace doris::segment_v2 \ No newline at end of file +TEST(KeywordTokenizerTest, LongInputStopsBeforeMultibyteRune) { + KeywordTokenizerFactory factory; + factory.initialize({}); + auto tokenizer = factory.create(); + tokenizer->set_source_byte_offsets_enabled(true); + + const std::string text = std::string(8191, 'a') + "\xE5\x88\x98"; + auto reader = std::make_shared>(); + reader->init(text.data(), static_cast(text.size()), false); + tokenizer->set_reader(reader); + tokenizer->reset(); + + Token token; + ASSERT_NE(tokenizer->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), + std::string(8191, 'a')); + EXPECT_EQ(token.startOffset(), 0); + EXPECT_EQ(token.endOffset(), 8191); + ASSERT_FALSE(tokenizer->get_source_byte_offsets().empty()); + EXPECT_EQ(tokenizer->get_source_byte_offsets().back(), 8191); + + const std::string reset_text = "\xE5\x88\x98"; + reader->init(reset_text.data(), static_cast(reset_text.size()), false); + tokenizer->set_reader(reader); + tokenizer->reset(); + ASSERT_NE(tokenizer->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), reset_text); + EXPECT_EQ(token.startOffset(), 0); + EXPECT_EQ(token.endOffset(), 3); +} + +} // namespace doris::segment_v2 diff --git a/be/test/storage/index/inverted/tokenizer/ngram_tokenizer_test.cpp b/be/test/storage/index/inverted/tokenizer/ngram_tokenizer_test.cpp index ac3d42cdcca11b..a82e8b8b56b638 100644 --- a/be/test/storage/index/inverted/tokenizer/ngram_tokenizer_test.cpp +++ b/be/test/storage/index/inverted/tokenizer/ngram_tokenizer_test.cpp @@ -59,6 +59,29 @@ TEST(NGramTokenizerTest, DefaultMinMaxValues) { ASSERT_EQ(tokens, expected); } +TEST(NGramTokenizerTest, IndexesMalformedUtf8UnlessOffsetsAreTracked) { + NGramTokenizerFactory factory; + std::unordered_map args; + args["min_gram"] = "2"; + args["max_gram"] = "2"; + Settings settings(args); + factory.initialize(settings); + + // A column may hold malformed bytes, so the grams around them are still indexed. + const std::string malformed = std::string("ab") + static_cast(0xFF) + "cd"; + auto tokens = tokenize(factory, malformed); + std::vector expected {"ab", "bc", "cd"}; + EXPECT_EQ(tokens, expected); + + // Malformed bytes have no source span, so the offset-aware path rejects them. + auto tokenizer = factory.create(); + tokenizer->set_source_byte_offsets_enabled(true); + ReaderPtr reader = std::make_shared>(); + reader->init(malformed.data(), malformed.size(), false); + tokenizer->set_reader(reader); + EXPECT_THROW(tokenizer->reset(), Exception); +} + TEST(NGramTokenizerTest, ValidMinMaxDifference) { NGramTokenizerFactory factory; std::unordered_map args; @@ -283,4 +306,36 @@ TEST(NGramTokenizerTest, WhitespaceTokenization) { ASSERT_EQ(tokens, expected); } +TEST(NGramTokenizerTest, RejectsMalformedUtf8AndCanBeReset) { + NGramTokenizerFactory factory; + std::unordered_map args; + args["min_gram"] = "1"; + args["max_gram"] = "1"; + factory.initialize(Settings(args)); + auto tokenizer = factory.create(); + // Malformed bytes have no source span, so only the offset-aware path rejects them. + tokenizer->set_source_byte_offsets_enabled(true); + + auto reader = std::make_shared>(); + const std::string leading_invalid = std::string(1, static_cast(0xFF)) + "ab"; + reader->init(leading_invalid.data(), static_cast(leading_invalid.size()), false); + tokenizer->set_reader(reader); + EXPECT_THROW(tokenizer->reset(), Exception); + + const std::string interior_invalid = std::string("a") + static_cast(0xFF) + "b"; + reader->init(interior_invalid.data(), static_cast(interior_invalid.size()), false); + tokenizer->set_reader(reader); + EXPECT_THROW(tokenizer->reset(), Exception); + + const std::string valid = "ab"; + reader->init(valid.data(), static_cast(valid.size()), false); + tokenizer->set_reader(reader); + ASSERT_NO_THROW(tokenizer->reset()); + Token token; + ASSERT_NE(tokenizer->next(&token), nullptr); + EXPECT_EQ(std::string(token.termBuffer(), token.termLength()), "a"); + EXPECT_EQ(token.startOffset(), 0); + EXPECT_EQ(token.endOffset(), 1); +} + } // namespace doris::segment_v2 diff --git a/be/test/storage/index/inverted_index_parser_test.cpp b/be/test/storage/index/inverted_index_parser_test.cpp index 0fd2e46dc3107f..1aaa4a6302bbb9 100644 --- a/be/test/storage/index/inverted_index_parser_test.cpp +++ b/be/test/storage/index/inverted_index_parser_test.cpp @@ -335,7 +335,7 @@ TEST_F(InvertedIndexParserTest, TestConstants) { // ============================================================================ // normalize_analyzer_key Tests -// New design: empty string stays empty, non-empty gets lowercased +// Resolved policy keys retain their exact spelling; empty permits fallback. // ============================================================================ TEST_F(InvertedIndexParserTest, NormalizeAnalyzerKey_EmptyInput) { @@ -343,22 +343,21 @@ TEST_F(InvertedIndexParserTest, NormalizeAnalyzerKey_EmptyInput) { EXPECT_EQ(normalize_analyzer_key(""), ""); } -TEST_F(InvertedIndexParserTest, NormalizeAnalyzerKey_UppercaseToLowercase) { - EXPECT_EQ(normalize_analyzer_key("CHINESE"), "chinese"); - EXPECT_EQ(normalize_analyzer_key("STANDARD"), "standard"); - EXPECT_EQ(normalize_analyzer_key("ENGLISH"), "english"); +TEST_F(InvertedIndexParserTest, NormalizeAnalyzerKey_PreservesUppercaseNames) { + EXPECT_EQ(normalize_analyzer_key("CHINESE"), "CHINESE"); + EXPECT_EQ(normalize_analyzer_key("STANDARD"), "STANDARD"); + EXPECT_EQ(normalize_analyzer_key("ENGLISH"), "ENGLISH"); } TEST_F(InvertedIndexParserTest, NormalizeAnalyzerKey_MixedCase) { - EXPECT_EQ(normalize_analyzer_key("ChInEsE"), "chinese"); - EXPECT_EQ(normalize_analyzer_key("StAnDaRd"), "standard"); - EXPECT_EQ(normalize_analyzer_key("My_Custom_Analyzer"), "my_custom_analyzer"); + EXPECT_EQ(normalize_analyzer_key("ChInEsE"), "ChInEsE"); + EXPECT_EQ(normalize_analyzer_key("StAnDaRd"), "StAnDaRd"); + EXPECT_EQ(normalize_analyzer_key("My_Custom_Analyzer"), "My_Custom_Analyzer"); } -TEST_F(InvertedIndexParserTest, NormalizeAnalyzerKey_NoneParser) { - // "none" is a distinct key - means keyword index (no tokenization) +TEST_F(InvertedIndexParserTest, NormalizeAnalyzerKey_CaseDistinctNoneNames) { EXPECT_EQ(normalize_analyzer_key("none"), "none"); - EXPECT_EQ(normalize_analyzer_key("NONE"), "none"); + EXPECT_EQ(normalize_analyzer_key("NONE"), "NONE"); } TEST_F(InvertedIndexParserTest, NormalizeAnalyzerKey_AlreadyLowercase) { @@ -385,25 +384,29 @@ TEST_F(InvertedIndexParserTest, BuildAnalyzerKeyFromProperties_CustomAnalyzer) { TEST_F(InvertedIndexParserTest, BuildAnalyzerKeyFromProperties_CustomAnalyzerUppercase) { std::map properties; properties[INVERTED_INDEX_ANALYZER_NAME_KEY] = "MY_CUSTOM"; - EXPECT_EQ(build_analyzer_key_from_properties(properties), "my_custom"); + EXPECT_EQ(build_analyzer_key_from_properties(properties), "MY_CUSTOM"); } TEST_F(InvertedIndexParserTest, BuildAnalyzerKeyFromProperties_Normalizer) { std::map properties; properties[INVERTED_INDEX_NORMALIZER_NAME_KEY] = "MY_NORMALIZER"; - EXPECT_EQ(build_analyzer_key_from_properties(properties), "my_normalizer"); + EXPECT_EQ(build_analyzer_key_from_properties(properties), "MY_NORMALIZER"); } TEST_F(InvertedIndexParserTest, BuildAnalyzerKeyFromProperties_ParserKey) { std::map properties; properties[INVERTED_INDEX_PARSER_KEY] = "chinese"; EXPECT_EQ(build_analyzer_key_from_properties(properties), "chinese"); + properties[INVERTED_INDEX_PARSER_KEY] = "CHINESE"; + EXPECT_EQ(build_analyzer_key_from_properties(properties), "chinese"); } TEST_F(InvertedIndexParserTest, BuildAnalyzerKeyFromProperties_ParserKeyAlias) { std::map properties; properties[INVERTED_INDEX_PARSER_KEY_ALIAS] = "standard"; EXPECT_EQ(build_analyzer_key_from_properties(properties), "standard"); + properties[INVERTED_INDEX_PARSER_KEY_ALIAS] = "STANDARD"; + EXPECT_EQ(build_analyzer_key_from_properties(properties), "standard"); } TEST_F(InvertedIndexParserTest, BuildAnalyzerKeyFromProperties_ParserNone) { @@ -427,7 +430,7 @@ TEST_F(InvertedIndexParserTest, BuildAnalyzerKeyFromProperties_NormalizerOverrid properties[INVERTED_INDEX_NORMALIZER_NAME_KEY] = "MY_NORMALIZER"; properties[INVERTED_INDEX_PARSER_KEY] = "chinese"; - EXPECT_EQ(build_analyzer_key_from_properties(properties), "my_normalizer"); + EXPECT_EQ(build_analyzer_key_from_properties(properties), "MY_NORMALIZER"); } TEST_F(InvertedIndexParserTest, BuildAnalyzerKeyFromProperties_AnalyzerOverridesNormalizer) { @@ -436,7 +439,7 @@ TEST_F(InvertedIndexParserTest, BuildAnalyzerKeyFromProperties_AnalyzerOverrides properties[INVERTED_INDEX_NORMALIZER_NAME_KEY] = "MY_NORMALIZER"; properties[INVERTED_INDEX_PARSER_KEY] = "chinese"; - EXPECT_EQ(build_analyzer_key_from_properties(properties), "my_analyzer"); + EXPECT_EQ(build_analyzer_key_from_properties(properties), "MY_ANALYZER"); } // ============================================================================ @@ -460,6 +463,22 @@ TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_OnlyAnalyzerCustom) { EXPECT_TRUE(config.uses_provider()); } +TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_PreservesExactLegacyIkBinding) { + const auto custom = AnalyzerConfigParser::parse("IK", "english"); + EXPECT_EQ(custom.provider_name, "IK"); + EXPECT_EQ(custom.parser_type, InvertedIndexParserType::PARSER_NONE); + EXPECT_TRUE(custom.uses_provider()); + + const auto builtin = AnalyzerConfigParser::parse("ik", "english"); + EXPECT_EQ(builtin.parser_type, InvertedIndexParserType::PARSER_IK); + EXPECT_FALSE(builtin.uses_provider()); + EXPECT_NE(custom.analyzer_key, builtin.analyzer_key); + EXPECT_EQ(custom.analyzer_key, build_analyzer_key_from_properties({{"analyzer", "IK"}})); + EXPECT_EQ(builtin.analyzer_key, build_analyzer_key_from_properties({{"analyzer", "ik"}})); + EXPECT_NE(build_analyzer_key_from_properties({{"analyzer", "Legacy"}}), + build_analyzer_key_from_properties({{"analyzer", "legacy"}})); +} + TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_OnlyAnalyzerBuiltin) { auto config = AnalyzerConfigParser::parse("chinese", ""); EXPECT_TRUE(config.provider_name.empty()); @@ -487,7 +506,7 @@ TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_BothAnalyzerAndParser) { auto config = AnalyzerConfigParser::parse("ik", "chinese"); EXPECT_TRUE(config.provider_name.empty()); EXPECT_EQ(config.parser_type, InvertedIndexParserType::PARSER_IK); - EXPECT_EQ(config.analyzer_key, "ik"); + EXPECT_EQ(config.analyzer_key, build_analyzer_key_from_properties({{"analyzer", "ik"}})); } TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_AnalyzerNameOverridesParserFallback) { @@ -499,7 +518,7 @@ TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_AnalyzerNameOverridesParser config = AnalyzerConfigParser::parse("ik", "chinese"); EXPECT_TRUE(config.provider_name.empty()); EXPECT_EQ(config.parser_type, InvertedIndexParserType::PARSER_IK); - EXPECT_EQ(config.analyzer_key, "ik"); + EXPECT_EQ(config.analyzer_key, build_analyzer_key_from_properties({{"analyzer", "ik"}})); config = AnalyzerConfigParser::parse("customer_analyzer", "english"); EXPECT_EQ(config.provider_name, "customer_analyzer"); @@ -507,11 +526,12 @@ TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_AnalyzerNameOverridesParser EXPECT_EQ(config.analyzer_key, "customer_analyzer"); } -TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_CaseInsensitive) { +TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_CaseDistinctNameUsesProvider) { auto config = AnalyzerConfigParser::parse("CHINESE", ""); - EXPECT_TRUE(config.provider_name.empty()); - EXPECT_EQ(config.analyzer_key, "chinese"); - EXPECT_EQ(config.parser_type, InvertedIndexParserType::PARSER_CHINESE); + EXPECT_EQ(config.provider_name, "CHINESE"); + EXPECT_EQ(config.analyzer_key, "CHINESE"); + EXPECT_EQ(config.parser_type, InvertedIndexParserType::PARSER_NONE); + EXPECT_TRUE(config.uses_provider()); } TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_UnknownAnalyzerAsCustom) { @@ -533,11 +553,14 @@ TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_AllBuiltinTypes) { {"icu", InvertedIndexParserType::PARSER_ICU}, {"basic", InvertedIndexParserType::PARSER_BASIC}, {"ik", InvertedIndexParserType::PARSER_IK}, + {"kuromoji", InvertedIndexParserType::PARSER_KUROMOJI}, }; for (const auto& [name, expected_type] : builtin_types) { auto config = AnalyzerConfigParser::parse(name, ""); EXPECT_EQ(config.parser_type, expected_type) << "Failed for: " << name; + EXPECT_EQ(config.analyzer_key, build_analyzer_key_from_properties({{"analyzer", name}})) + << "Failed for: " << name; EXPECT_TRUE(config.provider_name.empty()) << "Failed for: " << name; EXPECT_FALSE(config.uses_provider()) << "Failed for: " << name; } @@ -552,7 +575,10 @@ TEST_F(InvertedIndexParserTest, AnalyzerConfigParser_IsBuiltinAnalyzer) { EXPECT_TRUE(AnalyzerConfigParser::is_builtin_analyzer("basic")); EXPECT_TRUE(AnalyzerConfigParser::is_builtin_analyzer("ik")); EXPECT_TRUE(AnalyzerConfigParser::is_builtin_analyzer("none")); + EXPECT_TRUE(AnalyzerConfigParser::is_builtin_analyzer("kuromoji")); + EXPECT_FALSE(AnalyzerConfigParser::is_builtin_analyzer("CHINESE")); + EXPECT_FALSE(AnalyzerConfigParser::is_builtin_analyzer("IK")); EXPECT_FALSE(AnalyzerConfigParser::is_builtin_analyzer("my_custom")); EXPECT_FALSE(AnalyzerConfigParser::is_builtin_analyzer("unknown")); EXPECT_FALSE(AnalyzerConfigParser::is_builtin_analyzer("")); diff --git a/be/test/storage/segment/inverted_index_iterator_test.cpp b/be/test/storage/segment/inverted_index_iterator_test.cpp index 5483ecfe9478ee..09de1b596c4785 100644 --- a/be/test/storage/segment/inverted_index_iterator_test.cpp +++ b/be/test/storage/segment/inverted_index_iterator_test.cpp @@ -20,16 +20,26 @@ #include #include +#include #include #include +#include "common/config.h" #include "common/exception.h" #include "core/data_type/data_type_array.h" #include "core/data_type/data_type_nullable.h" #include "core/data_type/data_type_string.h" +#include "core/data_type/primitive_type.h" +#include "exprs/vmatch_predicate.h" +#include "runtime/exec_env.h" +#include "runtime/index_policy/index_policy_mgr.h" +#include "storage/index/index_reader_helper.h" +#include "storage/index/inverted/analyzer/analyzer.h" +#include "storage/index/inverted/analyzer/ik/dic/Dictionary.h" #include "storage/index/inverted/inverted_index_parser.h" #include "storage/index/inverted/inverted_index_reader.h" #include "storage/tablet/tablet_schema.h" +#include "util/defer_op.h" namespace doris::segment_v2 { @@ -52,6 +62,7 @@ class MockInvertedIndexReader : public InvertedIndexReader { InvertedIndexReaderType type() override { return _type; } void set_type(InvertedIndexReaderType type) { _type = type; } + bool queried = false; const std::map& get_index_properties() const override { return _properties; } @@ -60,6 +71,7 @@ class MockInvertedIndexReader : public InvertedIndexReader { const Field& query_value, InvertedIndexQueryType query_type, std::shared_ptr& roaring, const InvertedIndexAnalyzerCtx* analyzer_ctx = nullptr) override { + queried = true; return Status::OK(); } @@ -104,6 +116,34 @@ class InvertedIndexIteratorTest : public testing::Test { reader->set_type(type); return reader; } + + std::shared_ptr create_match_predicate(const std::string& analyzer_name) { + TMatchPredicate match; + match.__set_analyzer_name(analyzer_name); + match.__set_parser_type("english"); + match.__set_parser_mode("coarse_grained"); + match.__set_parser_lowercase(true); + match.__set_parser_stopwords("none"); + TExprNode node; + node.__set_node_type(TExprNodeType::MATCH_PRED); + node.__set_type(create_type_desc(PrimitiveType::TYPE_BOOLEAN)); + node.__set_num_children(2); + node.__set_match_predicate(match); + return VMatchPredicate::create_shared(node); + } + + void expect_matching_query_terms(const InvertedIndexReaderPtr& reader, + const VMatchPredicate& predicate, size_t expected_count) { + const std::string text = "one two"; + const auto indexed_terms = inverted_index::InvertedIndexAnalyzer::get_analyse_result( + text, reader->get_index_properties()); + auto query_reader = inverted_index::InvertedIndexAnalyzer::create_reader({}); + query_reader->init(text.data(), static_cast(text.size()), false); + const auto query_terms = inverted_index::InvertedIndexAnalyzer::get_analyse_result( + query_reader, predicate.query_analyzer_ctx()->get_analyzer().get()); + EXPECT_EQ(indexed_terms.size(), expected_count); + EXPECT_EQ(query_terms.size(), indexed_terms.size()); + } }; // ensure_normalized_key tests @@ -113,17 +153,17 @@ TEST_F(InvertedIndexIteratorTest, EnsureNormalizedKey_EmptyInput) { } TEST_F(InvertedIndexIteratorTest, EnsureNormalizedKey_Uppercase) { - EXPECT_EQ(InvertedIndexIterator::ensure_normalized_key("CHINESE"), "chinese"); + EXPECT_EQ(InvertedIndexIterator::ensure_normalized_key("CHINESE"), "CHINESE"); } TEST_F(InvertedIndexIteratorTest, EnsureNormalizedKey_MixedCase) { - EXPECT_EQ(InvertedIndexIterator::ensure_normalized_key("ChInEsE"), "chinese"); + EXPECT_EQ(InvertedIndexIterator::ensure_normalized_key("ChInEsE"), "ChInEsE"); } TEST_F(InvertedIndexIteratorTest, EnsureNormalizedKey_NonEmptyString) { - // Non-empty strings are normalized to lowercase + // Non-empty keys retain the resolved policy's spelling. EXPECT_EQ(InvertedIndexIterator::ensure_normalized_key("__default__"), "__default__"); - EXPECT_EQ(InvertedIndexIterator::ensure_normalized_key("NONE"), "none"); + EXPECT_EQ(InvertedIndexIterator::ensure_normalized_key("NONE"), "NONE"); } // add_reader tests @@ -169,6 +209,414 @@ TEST_F(InvertedIndexIteratorTest, AddReader_MultipleReadersWithDifferentKeys) { // Don't assert specific reader - fallback mode returns first available } +TEST_F(InvertedIndexIteratorTest, SelectBestReaderPreservesCaseDistinctLegacyAnalyzerKeys) { + for (const std::string legacy_name : {"IK", "Legacy"}) { + SCOPED_TRACE(legacy_name); + const std::string lowercase_name = legacy_name == "IK" ? "ik" : "legacy"; + auto legacy_reader = MockInvertedIndexReader::create({{"analyzer", legacy_name}}, 2); + auto lowercase_reader = MockInvertedIndexReader::create({{"analyzer", lowercase_name}}, 1); + auto column_type = std::make_shared(); + + for (const bool legacy_first : {true, false}) { + SCOPED_TRACE(legacy_first); + InvertedIndexIterator iterator; + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, + legacy_first ? legacy_reader : lowercase_reader); + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, + legacy_first ? lowercase_reader : legacy_reader); + + const auto legacy = iterator.select_best_reader( + column_type, InvertedIndexQueryType::MATCH_ANY_QUERY, legacy_name); + ASSERT_TRUE(legacy.has_value()) << legacy.error(); + EXPECT_EQ(*legacy, legacy_reader); + + const auto lowercase = iterator.select_best_reader( + column_type, InvertedIndexQueryType::MATCH_ANY_QUERY, + AnalyzerConfigParser::parse(lowercase_name, "").analyzer_key); + ASSERT_TRUE(lowercase.has_value()) << lowercase.error(); + EXPECT_EQ(*lowercase, lowercase_reader); + } + } +} + +TEST_F(InvertedIndexIteratorTest, MatchBindsOldFeNormalizedPolicyName) { + IndexPolicyMgr policy_mgr; + auto* exec_env = ExecEnv::GetInstance(); + auto* original_policy_mgr = exec_env->index_policy_mgr(); + exec_env->_index_policy_mgr = &policy_mgr; + Defer restore_policy_mgr([&] { exec_env->_index_policy_mgr = original_policy_mgr; }); + + TIndexPolicy policy; + policy.id = 100; + policy.name = "Foo"; + policy.type = TIndexPolicyType::ANALYZER; + policy.properties["tokenizer"] = "keyword"; + policy_mgr.apply_policy_changes({policy}, {}); + + auto reader = MockInvertedIndexReader::create({{"analyzer", "Foo"}}); + InvertedIndexIterator iterator; + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, reader); + + // Older FE versions lowercase the saved analyzer name in MATCH requests. + const auto predicate = create_match_predicate("foo"); + + const auto selected = iterator.select_best_reader( + std::make_shared(), InvertedIndexQueryType::MATCH_ANY_QUERY, + predicate->get_analyzer_key(), predicate->query_analyzer_ctx()->legacy_analyzer_key); + ASSERT_TRUE(selected.has_value()) << selected.error(); + EXPECT_EQ(*selected, reader); +} + +TEST_F(InvertedIndexIteratorTest, MatchBindsOldFeCanonicalizedPolicyName) { + IndexPolicyMgr policy_mgr; + auto* exec_env = ExecEnv::GetInstance(); + auto* original_policy_mgr = exec_env->index_policy_mgr(); + exec_env->_index_policy_mgr = &policy_mgr; + Defer restore_policy_mgr([&] { exec_env->_index_policy_mgr = original_policy_mgr; }); + + TIndexPolicy policy; + policy.id = 100; + policy.name = "Foo"; + policy.type = TIndexPolicyType::ANALYZER; + policy.properties["tokenizer"] = "keyword"; + policy_mgr.apply_policy_changes({policy}, {}); + + auto reader = MockInvertedIndexReader::create({{"analyzer", "foo"}}); + InvertedIndexIterator iterator; + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, reader); + + iterator.set_context(std::make_shared()); + InvertedIndexParam param {}; + param.column_type = std::make_shared(); + param.query_type = InvertedIndexQueryType::MATCH_ANY_QUERY; + // Older FE versions lowercase index properties while preserving replayed policy names. + for (const std::string name : {"foo", "Foo"}) { + SCOPED_TRACE(name); + const auto predicate = create_match_predicate(name); + EXPECT_EQ(predicate->get_analyzer_key(), "Foo"); + EXPECT_EQ(predicate->query_analyzer_ctx()->legacy_analyzer_key, "foo"); + param.analyzer_ctx = predicate->query_analyzer_ctx(); + reader->queried = false; + const auto status = iterator.read_from_index(¶m); + ASSERT_TRUE(status.ok()) << status; + EXPECT_TRUE(reader->queried); + } + + auto exact_reader = MockInvertedIndexReader::create({{"analyzer", "Foo"}}, 2); + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, exact_reader); + const auto predicate = create_match_predicate("Foo"); + param.analyzer_ctx = predicate->query_analyzer_ctx(); + reader->queried = false; + const auto status = iterator.read_from_index(¶m); + ASSERT_TRUE(status.ok()) << status; + EXPECT_TRUE(exact_reader->queried); + EXPECT_FALSE(reader->queried); +} + +TEST_F(InvertedIndexIteratorTest, MatchRejectsLegacyMetadataBoundToAnotherPolicy) { + IndexPolicyMgr policy_mgr; + auto* exec_env = ExecEnv::GetInstance(); + auto* original_policy_mgr = exec_env->index_policy_mgr(); + exec_env->_index_policy_mgr = &policy_mgr; + Defer restore_policy_mgr([&] { exec_env->_index_policy_mgr = original_policy_mgr; }); + + TIndexPolicy original; + original.id = 100; + original.name = "Foo"; + original.type = TIndexPolicyType::ANALYZER; + original.properties["tokenizer"] = "keyword"; + TIndexPolicy authoritative = original; + authoritative.id = 101; + authoritative.name = "FOO"; + authoritative.properties["tokenizer"] = "standard"; + policy_mgr.apply_policy_changes({authoritative, original}, {}); + + auto reader = MockInvertedIndexReader::create({{"analyzer", "foo"}}); + InvertedIndexIterator iterator; + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, reader); + iterator.set_context(std::make_shared()); + InvertedIndexParam param {}; + param.column_type = std::make_shared(); + param.query_type = InvertedIndexQueryType::MATCH_ANY_QUERY; + + const auto original_predicate = create_match_predicate("Foo"); + EXPECT_TRUE(original_predicate->query_analyzer_ctx()->legacy_analyzer_key.empty()); + param.analyzer_ctx = original_predicate->query_analyzer_ctx(); + const auto missing = iterator.read_from_index(¶m); + EXPECT_TRUE(missing.is()) << missing; + EXPECT_FALSE(reader->queried); + + const auto authoritative_predicate = create_match_predicate("FOO"); + EXPECT_EQ(authoritative_predicate->query_analyzer_ctx()->legacy_analyzer_key, "foo"); + param.analyzer_ctx = authoritative_predicate->query_analyzer_ctx(); + const auto status = iterator.read_from_index(¶m); + ASSERT_TRUE(status.ok()) << status; + EXPECT_TRUE(reader->queried); +} + +TEST_F(InvertedIndexIteratorTest, MatchRejectsLegacyAliasReservedForBuiltinAnalyzer) { + IndexPolicyMgr policy_mgr; + auto* exec_env = ExecEnv::GetInstance(); + auto* original_policy_mgr = exec_env->index_policy_mgr(); + exec_env->_index_policy_mgr = &policy_mgr; + Defer restore_policy_mgr([&] { exec_env->_index_policy_mgr = original_policy_mgr; }); + + TIndexPolicy policy; + policy.id = 100; + policy.name = "STANDARD"; + policy.type = TIndexPolicyType::ANALYZER; + policy.properties["tokenizer"] = "keyword"; + policy_mgr.apply_policy_changes({policy}, {}); + auto reader = MockInvertedIndexReader::create({{"analyzer", "standard"}}); + InvertedIndexIterator iterator; + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, reader); + iterator.set_context(std::make_shared()); + InvertedIndexParam param {}; + param.column_type = std::make_shared(); + param.query_type = InvertedIndexQueryType::MATCH_ANY_QUERY; + + const auto custom = create_match_predicate("STANDARD"); + param.analyzer_ctx = custom->query_analyzer_ctx(); + const auto missing = iterator.read_from_index(¶m); + EXPECT_TRUE(missing.is()) << missing; + EXPECT_FALSE(reader->queried); + + const auto builtin = create_match_predicate("standard"); + param.analyzer_ctx = builtin->query_analyzer_ctx(); + reader->queried = false; + const auto status = iterator.read_from_index(¶m); + ASSERT_TRUE(status.ok()) << status; + EXPECT_TRUE(reader->queried); + expect_matching_query_terms(reader, *builtin, 2); +} + +TEST_F(InvertedIndexIteratorTest, MatchReaderSelectionFollowsResolvedPolicyWhenNamesCollide) { + IndexPolicyMgr policy_mgr; + auto* exec_env = ExecEnv::GetInstance(); + auto* original_policy_mgr = exec_env->index_policy_mgr(); + exec_env->_index_policy_mgr = &policy_mgr; + Defer restore_policy_mgr([&] { exec_env->_index_policy_mgr = original_policy_mgr; }); + + TIndexPolicy original; + original.id = 100; + original.name = "Foo"; + original.type = TIndexPolicyType::ANALYZER; + original.properties["tokenizer"] = "keyword"; + TIndexPolicy authoritative = original; + authoritative.id = 101; + authoritative.name = "FOO"; + authoritative.properties["tokenizer"] = "standard"; + policy_mgr.apply_policy_changes({authoritative, original}, {}); + + auto original_reader = MockInvertedIndexReader::create({{"analyzer", "Foo"}}, 1); + auto authoritative_reader = MockInvertedIndexReader::create({{"analyzer", "FOO"}}, 2); + InvertedIndexIterator iterator; + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, original_reader); + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, authoritative_reader); + + for (const std::string name : {"Foo", "FOO", "foo", "fOo"}) { + SCOPED_TRACE(name); + const auto predicate = create_match_predicate(name); + const auto selected = iterator.select_best_reader( + std::make_shared(), InvertedIndexQueryType::MATCH_ANY_QUERY, + predicate->get_analyzer_key(), + predicate->query_analyzer_ctx()->legacy_analyzer_key); + ASSERT_TRUE(selected.has_value()) << selected.error(); + EXPECT_EQ(*selected, name == "Foo" ? original_reader : authoritative_reader); + EXPECT_EQ(predicate->query_analyzer_ctx()->analyzer_name, name == "Foo" ? "Foo" : "FOO"); + expect_matching_query_terms(*selected, *predicate, name == "Foo" ? 1 : 2); + } + + InvertedIndexIterator original_only; + original_only.add_reader(InvertedIndexReaderType::FULLTEXT, original_reader); + const auto normalized = create_match_predicate("foo"); + const auto missing = original_only.select_best_reader( + std::make_shared(), InvertedIndexQueryType::MATCH_ANY_QUERY, + normalized->get_analyzer_key(), normalized->query_analyzer_ctx()->legacy_analyzer_key); + ASSERT_FALSE(missing.has_value()); + EXPECT_TRUE(missing.error().is()); + + TIndexPolicy lowercase = original; + lowercase.id = 99; + lowercase.name = "foo"; + policy_mgr.apply_policy_changes({lowercase}, {}); + auto lowercase_reader = MockInvertedIndexReader::create({{"analyzer", "foo"}}, 3); + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, lowercase_reader); + const auto exact = create_match_predicate("foo"); + const auto selected = iterator.select_best_reader( + std::make_shared(), InvertedIndexQueryType::MATCH_ANY_QUERY, + exact->get_analyzer_key(), exact->query_analyzer_ctx()->legacy_analyzer_key); + ASSERT_TRUE(selected.has_value()) << selected.error(); + EXPECT_EQ(*selected, lowercase_reader); + EXPECT_EQ(exact->query_analyzer_ctx()->analyzer_name, "foo"); +} + +TEST_F(InvertedIndexIteratorTest, MatchBindsOldFeNormalizedNormalizerName) { + IndexPolicyMgr policy_mgr; + auto* exec_env = ExecEnv::GetInstance(); + auto* original_policy_mgr = exec_env->index_policy_mgr(); + exec_env->_index_policy_mgr = &policy_mgr; + Defer restore_policy_mgr([&] { exec_env->_index_policy_mgr = original_policy_mgr; }); + + TIndexPolicy policy; + policy.id = 100; + policy.name = "FooNormalizer"; + policy.type = TIndexPolicyType::NORMALIZER; + policy.properties["token_filter"] = "lowercase"; + policy_mgr.apply_policy_changes({policy}, {}); + + auto reader = MockInvertedIndexReader::create({{"normalizer", "FooNormalizer"}}); + InvertedIndexIterator iterator; + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, reader); + const auto predicate = create_match_predicate("foonormalizer"); + const auto selected = iterator.select_best_reader( + std::make_shared(), InvertedIndexQueryType::MATCH_ANY_QUERY, + predicate->get_analyzer_key(), predicate->query_analyzer_ctx()->legacy_analyzer_key); + ASSERT_TRUE(selected.has_value()) << selected.error(); + EXPECT_EQ(*selected, reader); + EXPECT_EQ(predicate->query_analyzer_ctx()->analyzer_name, policy.name); + + auto lowercase_reader = MockInvertedIndexReader::create({{"normalizer", "foonormalizer"}}); + InvertedIndexIterator lowercase_only; + lowercase_only.add_reader(InvertedIndexReaderType::FULLTEXT, lowercase_reader); + lowercase_only.set_context(std::make_shared()); + InvertedIndexParam param {}; + param.column_type = std::make_shared(); + param.query_type = InvertedIndexQueryType::MATCH_ANY_QUERY; + param.analyzer_ctx = predicate->query_analyzer_ctx(); + const auto status = lowercase_only.read_from_index(¶m); + ASSERT_TRUE(status.ok()) << status; + EXPECT_TRUE(lowercase_reader->queried); +} + +TEST_F(InvertedIndexIteratorTest, MatchBindsDistinctIkModesAndLowercaseStates) { + const auto original_dict_path = config::inverted_index_dict_path; + Defer restore_dict_path([&] { config::inverted_index_dict_path = original_dict_path; }); + const char* doris_home = std::getenv("DORIS_HOME"); + ASSERT_NE(doris_home, nullptr); + config::inverted_index_dict_path = std::string(doris_home) + "../../dict"; + Configuration dictionary_config; + dictionary_config.setDictPath(config::inverted_index_dict_path + "/ik"); + try { + Dictionary::initial(dictionary_config); + } catch (const CLuceneError&) { + // Another test may have initialized the shared dictionary with an invalid path. + Dictionary::getSingleton()->getConfiguration()->setDictPath( + dictionary_config.getDictPath()); + Dictionary::reload(); + } + const std::vector> properties { + {{"parser", "ik"}, {"lower_case", "false"}}, + {{"analyzer", "ik"}, {"lower_case", "false"}}, + {{"analyzer", "ik"}}, + {{"parser", "ik"}}}; + InvertedIndexIterator iterator; + std::vector> readers; + std::set keys; + for (size_t i = 0; i < properties.size(); ++i) { + auto reader = MockInvertedIndexReader::create(properties[i], i + 1); + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, reader); + readers.push_back(reader); + keys.insert(build_analyzer_key_from_properties(properties[i])); + } + EXPECT_EQ(keys.size(), properties.size()); + + for (size_t i = 0; i < properties.size(); ++i) { + SCOPED_TRACE(i); + TMatchPredicate match; + match.__set_analyzer_name("ik"); + match.__set_parser_type("ik"); + match.__set_parser_mode(get_parser_mode_string_from_properties(properties[i])); + match.__set_parser_lowercase(get_parser_lowercase_from_properties(properties[i]) == + "true"); + TExprNode node; + node.__set_node_type(TExprNodeType::MATCH_PRED); + node.__set_type(create_type_desc(PrimitiveType::TYPE_BOOLEAN)); + node.__set_num_children(2); + node.__set_match_predicate(match); + const auto predicate = VMatchPredicate::create_shared(node); + const auto selected = iterator.select_best_reader(std::make_shared(), + InvertedIndexQueryType::MATCH_ANY_QUERY, + predicate->get_analyzer_key()); + ASSERT_TRUE(selected.has_value()) << selected.error(); + EXPECT_EQ(*selected, readers[i]); + } +} + +TEST_F(InvertedIndexIteratorTest, MatchBindsEffectiveOuterCharacterFilters) { + const std::vector> properties { + {{"analyzer", "standard"}}, + {{"analyzer", "standard"}, + {"char_filter_type", "char_replace"}, + {"char_filter_pattern", "_-"}, + {"char_filter_replacement", " "}}, + {{"analyzer", "standard"}, + {"char_filter_type", "char_replace"}, + {"char_filter_pattern", "_-"}, + {"char_filter_replacement", "a"}}}; + InvertedIndexIterator iterator; + std::vector> readers; + for (size_t i = 0; i < properties.size(); ++i) { + auto reader = MockInvertedIndexReader::create(properties[i], i + 1); + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, reader); + readers.push_back(reader); + } + + for (size_t i = 0; i < properties.size(); ++i) { + SCOPED_TRACE(i); + auto query_properties = properties[i]; + query_properties["char_filter_type"] = "char_replace"; + query_properties["char_filter_pattern"] = i == 0 ? "a" : "-__-"; + query_properties["char_filter_replacement"] = i == 1 ? " " : "a"; + TMatchPredicate match; + match.__set_analyzer_name("standard"); + match.__set_parser_type("standard"); + match.__set_parser_lowercase(true); + match.__set_char_filter_map(get_parser_char_filter_map_from_properties(query_properties)); + TExprNode node; + node.__set_node_type(TExprNodeType::MATCH_PRED); + node.__set_type(create_type_desc(PrimitiveType::TYPE_BOOLEAN)); + node.__set_num_children(2); + node.__set_match_predicate(match); + const auto predicate = VMatchPredicate::create_shared(node); + const auto selected = iterator.select_best_reader(std::make_shared(), + InvertedIndexQueryType::MATCH_ANY_QUERY, + predicate->get_analyzer_key()); + ASSERT_TRUE(selected.has_value()) << selected.error(); + EXPECT_EQ(*selected, readers[i]); + } +} + +TEST_F(InvertedIndexIteratorTest, EncodedSelectionKeysDoNotCollideWithPolicyNames) { + for (const std::map& properties : + {std::map {{"analyzer", "ik"}}, + {{"analyzer", "standard"}, + {"char_filter_type", "char_replace"}, + {"char_filter_pattern", "_"}}}) { + const auto policy_name = build_analyzer_key_from_properties(properties); + SCOPED_TRACE(policy_name); + auto policy_reader = MockInvertedIndexReader::create({{"analyzer", policy_name}}, 1); + auto configured_reader = MockInvertedIndexReader::create(properties, 2); + InvertedIndexIterator iterator; + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, policy_reader); + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, configured_reader); + + const auto configured = AnalyzerConfigParser::parse( + properties.at("analyzer"), "", get_parser_mode_string_from_properties(properties), + true, get_parser_char_filter_map_from_properties(properties)); + const auto selected = iterator.select_best_reader(configured.analyzer_key); + ASSERT_TRUE(selected.has_value()); + EXPECT_EQ(*selected, configured_reader); + + const auto named = AnalyzerConfigParser::parse(policy_name, ""); + EXPECT_EQ(named.provider_name, policy_name); + EXPECT_NE(named.analyzer_key, configured.analyzer_key); + const auto selected_policy = iterator.select_best_reader(named.analyzer_key); + ASSERT_TRUE(selected_policy.has_value()); + EXPECT_EQ(*selected_policy, policy_reader); + } +} + TEST_F(InvertedIndexIteratorTest, AddReader_DuplicateIndexIdFails) { auto first_reader = create_mock_reader("chinese", InvertedIndexReaderType::FULLTEXT, 7); auto duplicate_reader = create_mock_reader("english", InvertedIndexReaderType::FULLTEXT, 7); @@ -365,14 +813,17 @@ TEST_F(InvertedIndexIteratorTest, EdgeCase_EmptyAnalyzerKeyQuery) { EXPECT_EQ(result.value(), reader); } -TEST_F(InvertedIndexIteratorTest, EdgeCase_CaseInsensitiveQuery) { +TEST_F(InvertedIndexIteratorTest, EdgeCase_CaseDistinctKeyDoesNotFallBackToBuiltin) { InvertedIndexIterator iterator; auto reader = create_mock_reader("chinese"); iterator.add_reader(InvertedIndexReaderType::FULLTEXT, reader); auto result = iterator.select_best_reader("CHINESE"); - EXPECT_TRUE(result.has_value()); - EXPECT_EQ(result.value(), reader); + EXPECT_FALSE(result.has_value()); + + auto builtin = iterator.select_best_reader("chinese"); + ASSERT_TRUE(builtin.has_value()); + EXPECT_EQ(*builtin, reader); } TEST_F(InvertedIndexIteratorTest, EdgeCase_GetReaderByType) { @@ -429,4 +880,81 @@ TEST_F(InvertedIndexIteratorTest, SelectBestReader_DeterministicByIndexId) { } } +TEST_F(InvertedIndexIteratorTest, PhraseSupportIsCheckedOnTheSelectedReader) { + // Two full-text indexes on one column, told apart by their analyzer and disagreeing about + // support_phrase. Index order must not decide whether a phrase query is allowed. + auto with_positions = MockInvertedIndexReader::create( + {{"analyzer", "phrase_analyzer"}, {"support_phrase", "true"}}, 1); + auto without_positions = MockInvertedIndexReader::create( + {{"analyzer", "plain_analyzer"}, {"support_phrase", "false"}}, 2); + + for (const bool positions_first : {true, false}) { + SCOPED_TRACE(positions_first); + InvertedIndexIterator iterator; + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, + positions_first ? with_positions : without_positions); + iterator.add_reader(InvertedIndexReaderType::FULLTEXT, + positions_first ? without_positions : with_positions); + iterator.set_context(std::make_shared()); + + InvertedIndexAnalyzerCtx analyzer_ctx; + InvertedIndexParam param {}; + param.column_type = std::make_shared(); + param.query_type = InvertedIndexQueryType::MATCH_PHRASE_QUERY; + param.analyzer_ctx = &analyzer_ctx; + + analyzer_ctx.analyzer_key = "phrase_analyzer"; + with_positions->queried = false; + auto status = iterator.read_from_index(¶m); + ASSERT_TRUE(status.ok()) << status; + EXPECT_TRUE(with_positions->queried); + + // The index that stored no positions is refused even when it is not the first full-text + // candidate, which is all the old preflight looked at. + analyzer_ctx.analyzer_key = "plain_analyzer"; + without_positions->queried = false; + status = iterator.read_from_index(¶m); + EXPECT_EQ(status.code(), ErrorCode::INDEX_INVALID_PARAMETERS) << status; + EXPECT_FALSE(without_positions->queried); + + // A non-positional query keeps using that same index. + param.query_type = InvertedIndexQueryType::MATCH_ANY_QUERY; + status = iterator.read_from_index(¶m); + ASSERT_TRUE(status.ok()) << status; + EXPECT_TRUE(without_positions->queried); + + // The first candidate of the type is what the old preflight looked at, and it disagrees + // with the selected reader in one of the two orderings. + EXPECT_EQ(IndexReaderHelper::is_support_phrase( + iterator.get_reader(InvertedIndexReaderType::FULLTEXT)), + positions_first); + } +} + +TEST_F(InvertedIndexIteratorTest, PhraseQueriesStillRunOnAnUntokenizedIndex) { + // An untokenized index never declares support_phrase, yet it answers phrase queries by + // matching the whole value as one term. The phrase check must only apply to tokenized + // indexes, otherwise MATCH_PHRASE on a plain string index starts failing. + auto untokenized = MockInvertedIndexReader::create({}, 3); + untokenized->set_type(InvertedIndexReaderType::STRING_TYPE); + ASSERT_FALSE(IndexReaderHelper::is_support_phrase(untokenized)); + + InvertedIndexIterator iterator; + iterator.add_reader(InvertedIndexReaderType::STRING_TYPE, untokenized); + iterator.set_context(std::make_shared()); + + InvertedIndexParam param {}; + param.column_type = std::make_shared(); + for (const auto query_type : {InvertedIndexQueryType::MATCH_PHRASE_QUERY, + InvertedIndexQueryType::MATCH_PHRASE_PREFIX_QUERY, + InvertedIndexQueryType::MATCH_PHRASE_EDGE_QUERY}) { + SCOPED_TRACE(static_cast(query_type)); + param.query_type = query_type; + untokenized->queried = false; + const auto status = iterator.read_from_index(¶m); + ASSERT_TRUE(status.ok()) << status; + EXPECT_TRUE(untokenized->queried); + } +} + } // namespace doris::segment_v2 diff --git a/be/test/storage/segment/inverted_index_writer_test.cpp b/be/test/storage/segment/inverted_index_writer_test.cpp index abcb235eeb2102..3575ca9f042888 100644 --- a/be/test/storage/segment/inverted_index_writer_test.cpp +++ b/be/test/storage/segment/inverted_index_writer_test.cpp @@ -33,6 +33,7 @@ #include #include "common/config.h" +#include "common/exception.h" #include "core/block/block.h" #include "core/data_type/data_type_factory.hpp" #include "core/data_type/data_type_number.h" @@ -118,6 +119,34 @@ class GappedTokenAnalyzer final : public lucene::analysis::Analyzer { std::unique_ptr _reusable; }; +class ImmediateFailureAnalyzer final : public lucene::analysis::Analyzer { +public: + bool isSDocOpt() override { return true; } + + lucene::analysis::TokenStream* tokenStream(const TCHAR*, lucene::util::Reader*) override { + throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR, + "forced analyzer construction failure"); + } + + lucene::analysis::TokenStream* reusableTokenStream(const TCHAR*, + lucene::util::Reader*) override { + throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR, + "forced analyzer construction failure"); + } + + lucene::analysis::TokenStream* tokenStream(const TCHAR*, + const inverted_index::ReaderPtr&) override { + throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR, + "forced analyzer construction failure"); + } + + lucene::analysis::TokenStream* reusableTokenStream(const TCHAR*, + const inverted_index::ReaderPtr&) override { + throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR, + "forced analyzer construction failure"); + } +}; + class InvertedIndexWriterTest : public testing::Test { using ExpectedDocMap = std::map>; @@ -1236,6 +1265,80 @@ TEST_F(InvertedIndexWriterTest, ErrorHandlingInFileWriter) { EXPECT_TRUE(status.ok()) << status; } +TEST_F(InvertedIndexWriterTest, AnalyzerExceptionReturnsStatus) { + auto tablet_schema = create_schema(); + + TabletIndexPB index_pb; + index_pb.set_index_type(IndexType::INVERTED); + index_pb.set_index_id(1); + index_pb.set_index_name("test_analyzer_failure"); + index_pb.add_col_unique_id(1); + TabletIndex index_meta; + index_meta.init_from_pb(index_pb); + + const std::string rowset_id = "test_analyzer_failure"; + const std::string index_path_prefix {InvertedIndexDescriptor::get_index_file_path_prefix( + local_segment_path(kTestDir, rowset_id, 0))}; + const std::string index_path = + InvertedIndexDescriptor::get_index_file_path_v2(index_path_prefix); + io::FileWriterPtr file_writer; + io::FileWriterOptions opts; + auto fs = io::global_local_filesystem(); + ASSERT_TRUE(fs->create_file(index_path, &file_writer, &opts).ok()); + IndexFileWriter index_file_writer(fs, index_path_prefix, rowset_id, 0, + InvertedIndexStorageFormatPB::V2, std::move(file_writer)); + + const TabletColumn& column = tablet_schema->column(1); + InvertedIndexColumnWriter writer( + column.name(), &index_file_writer, &index_meta); + ASSERT_TRUE(writer.init().ok()); + writer.set_analysis_for_test(inverted_index::InvertedIndexAnalyzer::create_reader({}), + std::make_shared()); + + const Slice value("value"); + Status status; + EXPECT_NO_THROW(status = writer.add_values(column.name(), &value, 1)); + EXPECT_EQ(status.code(), ErrorCode::INVERTED_INDEX_ANALYZER_ERROR) << status; +} + +TEST_F(InvertedIndexWriterTest, ArrayAnalyzerExceptionReturnsStatus) { + auto tablet_schema = create_schema(); + + TabletIndexPB index_pb; + index_pb.set_index_type(IndexType::INVERTED); + index_pb.set_index_id(1); + index_pb.set_index_name("test_array_analyzer_failure"); + index_pb.add_col_unique_id(1); + TabletIndex index_meta; + index_meta.init_from_pb(index_pb); + + const std::string rowset_id = "test_array_analyzer_failure"; + const std::string index_path_prefix {InvertedIndexDescriptor::get_index_file_path_prefix( + local_segment_path(kTestDir, rowset_id, 0))}; + const std::string index_path = + InvertedIndexDescriptor::get_index_file_path_v2(index_path_prefix); + io::FileWriterPtr file_writer; + io::FileWriterOptions opts; + auto fs = io::global_local_filesystem(); + ASSERT_TRUE(fs->create_file(index_path, &file_writer, &opts).ok()); + IndexFileWriter index_file_writer(fs, index_path_prefix, rowset_id, 0, + InvertedIndexStorageFormatPB::V2, std::move(file_writer)); + + const TabletColumn& column = tablet_schema->column(1); + InvertedIndexColumnWriter writer( + column.name(), &index_file_writer, &index_meta); + ASSERT_TRUE(writer.init().ok()); + writer.set_analysis_for_test(inverted_index::InvertedIndexAnalyzer::create_reader({}), + std::make_shared()); + + const Slice value("value"); + const uint64_t offsets[] = {0, 1}; + Status status; + EXPECT_NO_THROW(status = writer.add_array_values(sizeof(Slice), &value, nullptr, + reinterpret_cast(offsets), 1)); + EXPECT_EQ(status.code(), ErrorCode::INVERTED_INDEX_ANALYZER_ERROR) << status; +} + // Test case for array values with mixed null and non-null elements TEST_F(InvertedIndexWriterTest, ArrayValuesWithNulls) { // Create TabletSchema with array column (reference inverted_index_array_test.cpp) diff --git a/fe/fe-catalog/src/main/java/org/apache/doris/analysis/AnalyzerSelector.java b/fe/fe-catalog/src/main/java/org/apache/doris/analysis/AnalyzerSelector.java index 5c25588244f620..7a6c8c26200f45 100644 --- a/fe/fe-catalog/src/main/java/org/apache/doris/analysis/AnalyzerSelector.java +++ b/fe/fe-catalog/src/main/java/org/apache/doris/analysis/AnalyzerSelector.java @@ -22,6 +22,7 @@ import com.google.common.base.Strings; import java.util.Collections; +import java.util.Locale; import java.util.Map; /** @@ -107,18 +108,18 @@ private static Selection select(Map properties, String requested */ private static String determineIndexAnalyzer(String customAnalyzer, String parser) { if (!Strings.isNullOrEmpty(customAnalyzer)) { - return customAnalyzer.trim().toLowerCase(); + return customAnalyzer.trim(); } if (!Strings.isNullOrEmpty(parser) && !InvertedIndexProperties.INVERTED_INDEX_PARSER_NONE.equalsIgnoreCase(parser)) { - return parser.trim().toLowerCase(); + return parser.trim().toLowerCase(Locale.ROOT); } // Keyword index (parser=none) or no analyzer configured return ""; } private static String normalize(String analyzer) { - return analyzer == null ? "" : analyzer.trim().toLowerCase(); + return analyzer == null ? "" : analyzer.trim(); } /** @@ -145,7 +146,7 @@ private Selection(String userAnalyzer, String indexAnalyzer, String parser, bool this.indexAnalyzer = indexAnalyzer; this.parser = Strings.isNullOrEmpty(parser) ? InvertedIndexProperties.INVERTED_INDEX_PARSER_NONE - : parser.trim().toLowerCase(); + : parser.trim().toLowerCase(Locale.ROOT); this.explicit = explicit; } diff --git a/fe/fe-catalog/src/main/java/org/apache/doris/analysis/MatchPredicate.java b/fe/fe-catalog/src/main/java/org/apache/doris/analysis/MatchPredicate.java index d005827ea061cb..79693421295220 100644 --- a/fe/fe-catalog/src/main/java/org/apache/doris/analysis/MatchPredicate.java +++ b/fe/fe-catalog/src/main/java/org/apache/doris/analysis/MatchPredicate.java @@ -121,8 +121,7 @@ public MatchPredicate(Operator op, Expr e1, Expr e2, Type retType, this.invertedIndexParserStopwords = InvertedIndexProperties.getInvertedIndexParserStopwords(properties); if (!Strings.isNullOrEmpty(analyzer)) { - // Normalize to lowercase for case-insensitive matching - this.explicitAnalyzer = analyzer.trim().toLowerCase(); + this.explicitAnalyzer = analyzer.trim(); } fn = new Function(new FunctionName(op.name), Lists.newArrayList(e1.getType(), e2.getType()), retType, false, true, nullableMode); diff --git a/fe/fe-core/src/main/java/org/apache/doris/alter/SchemaChangeHandler.java b/fe/fe-core/src/main/java/org/apache/doris/alter/SchemaChangeHandler.java index da5d86202ec90f..4aed9785b5c2aa 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/alter/SchemaChangeHandler.java +++ b/fe/fe-core/src/main/java/org/apache/doris/alter/SchemaChangeHandler.java @@ -3344,6 +3344,12 @@ private boolean checkDuplicateIndexes(List indexes, IndexDefinition index Column column = olapTable.getColumn(columnName); if (column != null && (column.getType().isStringType() || column.getType().isVariantType())) { if (index.getIndexType() == IndexType.INVERTED) { + if (InvertedIndexUtil.hasSameNonIkAnalyzerSelector( + index.getProperties(), indexDef.getProperties())) { + throw new DdlException(indexDef.getIndexType() + + " index for column (" + columnName + + ") with the same analyzer selector already exists."); + } String existingIdentity = InvertedIndexUtil.getAnalyzerIdentity(index); String newIdentity = indexDef.getAnalyzerIdentity(); if (Objects.equals(existingIdentity, newIdentity)) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/analysis/InvertedIndexUtil.java b/fe/fe-core/src/main/java/org/apache/doris/analysis/InvertedIndexUtil.java index 57da08fe994c87..38b10ca17f9168 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/analysis/InvertedIndexUtil.java +++ b/fe/fe-core/src/main/java/org/apache/doris/analysis/InvertedIndexUtil.java @@ -25,17 +25,21 @@ import org.apache.doris.catalog.info.IndexType; import org.apache.doris.common.AnalysisException; import org.apache.doris.common.DdlException; +import org.apache.doris.indexpolicy.IndexPolicy; +import org.apache.doris.indexpolicy.IndexPolicyMgr; import org.apache.doris.nereids.trees.plans.commands.info.IndexDefinition; import org.apache.doris.nereids.types.DataType; import org.apache.doris.thrift.TInvertedIndexFileStorageFormat; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableSet; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -43,6 +47,12 @@ public class InvertedIndexUtil { private static final Logger LOG = LogManager.getLogger(InvertedIndexUtil.class); + // A MATCH analyzer name may select an index by its analyzer or its normalizer. + private static final Set BUILTIN_TOP_LEVEL_NAMES = ImmutableSet.builder() + .addAll(IndexPolicy.BUILTIN_ANALYZERS) + .addAll(IndexPolicy.BUILTIN_NORMALIZERS) + .build(); + public static String INVERTED_INDEX_PARSER_KEY = InvertedIndexProperties.INVERTED_INDEX_PARSER_KEY; public static String INVERTED_INDEX_PARSER_KEY_ALIAS = InvertedIndexProperties.INVERTED_INDEX_PARSER_KEY_ALIAS; public static String INVERTED_INDEX_PARSER_NONE = InvertedIndexProperties.INVERTED_INDEX_PARSER_NONE; @@ -337,23 +347,54 @@ private static void checkInvertedIndexProperties(Map 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 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 properties) { + normalizeResolvedPolicyName(properties, INVERTED_INDEX_ANALYZER_NAME_KEY, IndexPolicy.BUILTIN_ANALYZERS); + normalizeResolvedPolicyName(properties, INVERTED_INDEX_NORMALIZER_NAME_KEY, IndexPolicy.BUILTIN_NORMALIZERS); + } + + private static void normalizeResolvedPolicyName(Map properties, String key, + Set builtins) { + String name = properties.get(key); + if (name == null || name.isEmpty()) { + return; + } + properties.put(key, resolveAnalyzerName(name, builtins)); + } + + /** Resolve built-in names and retain the stored spelling of custom policies. */ + public static String resolveAnalyzerName(String name) { + return resolveAnalyzerName(name, BUILTIN_TOP_LEVEL_NAMES); + } + + // Validation resolves in the same order, so the stored name binds what it accepted. + private static String resolveAnalyzerName(String name, Set builtins) { + String trimmedName = name.trim(); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + String builtin = policyMgr.getTopLevelBuiltin(trimmedName, builtins); + if (builtin != null) { + return builtin; + } + IndexPolicy policy = policyMgr.getPolicyByName(trimmedName); + return policy == null ? trimmedName.toLowerCase(Locale.ROOT) : policy.getName(); + } + private static void checkAnalyzerName(String analyzerName, PrimitiveType colType) throws AnalysisException { if (analyzerName == null || analyzerName.isEmpty()) { return; @@ -393,16 +434,40 @@ public static boolean canHaveMultipleInvertedIndexes(DataType colType, List analyzerKeys = new HashSet<>(); + Set analyzerSelectors = new HashSet<>(); for (IndexDefinition indexDef : indexDefs) { - String key = buildAnalyzerIdentity(indexDef.getProperties()); + Map properties = indexDef.getProperties(); + String key = buildAnalyzerIdentity(properties); // HashSet.add() returns false if element already exists if (!analyzerKeys.add(key)) { return false; } + String selector = getAnalyzerSelector(properties); + if (!INVERTED_INDEX_PARSER_IK.equals(selector) && !analyzerSelectors.add(selector)) { + return false; + } } return true; } + private static String getAnalyzerSelector(Map properties) { + String preferredAnalyzer = InvertedIndexProperties.getPreferredAnalyzer(properties); + if (!Strings.isNullOrEmpty(preferredAnalyzer)) { + return resolveAnalyzerName(preferredAnalyzer); + } + String parser = InvertedIndexProperties.getInvertedIndexParser(properties); + return Strings.isNullOrEmpty(parser) + ? InvertedIndexProperties.INVERTED_INDEX_DEFAULT_ANALYZER_KEY + : parser.trim().toLowerCase(Locale.ROOT); + } + + public static boolean hasSameNonIkAnalyzerSelector( + Map leftProperties, Map rightProperties) { + String leftSelector = getAnalyzerSelector(leftProperties); + return !INVERTED_INDEX_PARSER_IK.equals(leftSelector) + && leftSelector.equals(getAnalyzerSelector(rightProperties)); + } + public static String buildAnalyzerIdentity(Map properties) { String preferredAnalyzer = InvertedIndexProperties.getPreferredAnalyzer(properties); String parser = InvertedIndexProperties.getInvertedIndexParser(properties); @@ -423,17 +488,39 @@ public static boolean isAnalyzerMatched(Map properties, String a buildAnalyzerIdentity(properties)); } + String resolvedAnalyzer = resolveAnalyzerName(normalizedAnalyzer); + return isAnalyzerNameMatched(properties, normalizedAnalyzer) + && (!INVERTED_INDEX_PARSER_IK.equals(resolvedAnalyzer) + || matchesBuiltinIkDefaults(properties)); + } + + /** + * Whether the index is served by the named analyzer, regardless of how a built-in IK index is + * configured. This name check is all that selected an index before built-in IK indexes were + * matched by their effective configuration. + */ + public static boolean isAnalyzerNameMatched(Map properties, String analyzer) { + String normalizedAnalyzer = Strings.isNullOrEmpty(analyzer) ? "" : analyzer.trim(); + if (normalizedAnalyzer.isEmpty()) { + return false; + } + String resolvedAnalyzer = resolveAnalyzerName(normalizedAnalyzer); String preferredAnalyzer = InvertedIndexProperties.getPreferredAnalyzer(properties); if (!Strings.isNullOrEmpty(preferredAnalyzer)) { - return normalizedAnalyzer.equalsIgnoreCase(preferredAnalyzer); + return resolvedAnalyzer.equals(resolveAnalyzerName(preferredAnalyzer)); } String parser = InvertedIndexProperties.getInvertedIndexParser(properties); if (Strings.isNullOrEmpty(parser)) { - return normalizedAnalyzer.equalsIgnoreCase("default") - || normalizedAnalyzer.equalsIgnoreCase(INVERTED_INDEX_PARSER_NONE); + return resolvedAnalyzer.equals("default") + || resolvedAnalyzer.equals(INVERTED_INDEX_PARSER_NONE); } - return normalizedAnalyzer.equalsIgnoreCase(parser); + return resolvedAnalyzer.equals(parser.trim().toLowerCase(Locale.ROOT)); + } + + private static boolean matchesBuiltinIkDefaults(Map properties) { + return buildAnalyzerIdentity(properties).equals( + buildAnalyzerIdentity(Map.of(INVERTED_INDEX_ANALYZER_NAME_KEY, INVERTED_INDEX_PARSER_IK))); } public static String getAnalyzerIdentity(Index index) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java b/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java index 310c442c3ce17b..0445415126ab8b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java +++ b/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java @@ -17,22 +17,102 @@ package org.apache.doris.analysis.invertedindex; +import org.apache.doris.analysis.InvertedIndexProperties; import org.apache.doris.catalog.Env; import org.apache.doris.indexpolicy.IndexPolicy; import org.apache.doris.indexpolicy.IndexPolicyTypeEnum; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableSet; +import com.ibm.icu.lang.UCharacter; +import com.ibm.icu.text.UnicodeSet; import org.apache.logging.log4j.Logger; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Deque; +import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Pattern; public final class AnalyzerIdentityBuilder { private static final String PROP_MAX_NGRAM_DIFF = "max_ngram_diff"; + private static final String KEYWORD_TOKENIZER = "keyword"; + private static final String LOWERCASE_TOKEN_FILTER = "lowercase"; + private static final String CHAR_REPLACE_FILTER = "char_replace"; + // The only stopwords value BE reads; anything else leaves the built-in stop word list on. + private static final String STOPWORDS_NONE = "none"; + // Built-in analyzers BE builds as their tokenizer plus a LowerCaseFilter that lower_case drops. + private static final Set LOWERCASE_FILTER_BUILTIN_ANALYZERS = ImmutableSet.of( + InvertedIndexProperties.INVERTED_INDEX_PARSER_BASIC, + InvertedIndexProperties.INVERTED_INDEX_PARSER_ICU); + // Built-in analyzers whose tokenizer lower-cases its own terms when lower_case is on. + private static final Set LOWERCASE_ONLY_BUILTIN_ANALYZERS = ImmutableSet.of( + InvertedIndexProperties.INVERTED_INDEX_PARSER_ENGLISH, + InvertedIndexProperties.INVERTED_INDEX_PARSER_CHINESE, + InvertedIndexProperties.INVERTED_INDEX_PARSER_KUROMOJI); + private static final String PROP_PATTERN = "pattern"; + private static final String PROP_REPLACEMENT = "replacement"; + // Defaults CharReplaceCharFilterFactory applies to a bare built-in reference. + private static final String CHAR_REPLACE_DEFAULT_PATTERN = ",._"; + private static final String CHAR_REPLACE_DEFAULT_REPLACEMENT = " "; + // Token filters that emit the same terms, offsets and provenance when applied twice in a row. + private static final Set IDEMPOTENT_TOKEN_FILTERS = ImmutableSet.of( + "lowercase", "asciifolding", "icu_normalizer"); + // Same separator BE uses between bracketed list entries. + private static final Pattern ENTRY_SEPARATOR = Pattern.compile("(?<=\\])\\s*,\\s*(?=\\[)"); + private static final Set WORD_DELIMITER_TYPES = ImmutableSet.of( + "LOWER", "UPPER", "ALPHA", "DIGIT", "ALPHANUM", "SUBWORD_DELIM"); + private static final Set CHAR_GROUP_TYPES = ImmutableSet.of( + "letter", "digit", "whitespace", "punctuation", "symbol", "cjk"); private AnalyzerIdentityBuilder() { } + /** + * Case-folding context of a char filter: the bytes that filters between it and the downstream + * fold rewrite, plus the unicode_set_filter the fold is restricted to (null for every code point). + */ + private static final class FoldContext { + private static final UnicodeSet NON_STARTERS = new UnicodeSet("[:^ccc=0:]").freeze(); + + private final boolean[] blockedBytes = new boolean[256]; + private final UnicodeSet foldSet; + + private FoldContext(UnicodeSet foldSet) { + this.foldSet = foldSet; + } + + private static FoldContext unfiltered() { + return new FoldContext(null); + } + + private void block(boolean[] sourceBytes) { + for (int i = 0; i < blockedBytes.length; ++i) { + blockedBytes[i] |= sourceBytes[i]; + } + } + + /** Whether the fold turns the upper-case ASCII byte into the lower-case one wherever it appears. */ + private boolean foldsByte(int upperByte, int lowerByte) { + if (blockedBytes[upperByte] || blockedBytes[lowerByte]) { + return false; + } + if (foldSet == null) { + return true; + } + // A filtered normalizer handles each in-set span on its own, so the folded byte must + // stay in its span, or the set must hold no combining mark that could compose with it. + return foldSet.contains(upperByte) + && (foldSet.contains(lowerByte) || !foldSet.containsSome(NON_STARTERS)); + } + } + public static String buildAnalyzerIdentity( Map properties, String preferredAnalyzer, @@ -45,14 +125,138 @@ public static String buildAnalyzerIdentity( } if (!Strings.isNullOrEmpty(preferredAnalyzer)) { + String builtinIkIdentity = resolveBuiltinIkAnalyzerIdentity(properties, preferredAnalyzer); + if (builtinIkIdentity != null) { + return appendOuterCharFilterIdentity( + builtinIkIdentity, properties, builtinIkFoldContext()); + } + // BE dispatches a canonical lowercase built-in before any custom policy of that name. + String builtinIdentity = builtinAnalyzerIdentity(preferredAnalyzer.trim(), properties); + if (builtinIdentity != null) { + return builtinIdentity; + } // For custom analyzer/normalizer, resolve to underlying config to build identity - return resolveAnalyzerIdentity(preferredAnalyzer, defaultAnalyzerKey, log); + return appendOuterCharFilterIdentity( + resolveAnalyzerIdentity(preferredAnalyzer, defaultAnalyzerKey, log), properties, + customAnalyzerFoldContext(preferredAnalyzer)); } if (Strings.isNullOrEmpty(parser) || parserNone.equalsIgnoreCase(parser)) { return defaultAnalyzerKey; } - return parser; + String legacyIkIdentity = resolveLegacyIkIdentity(properties, parser); + if (legacyIkIdentity != null) { + return appendOuterCharFilterIdentity( + legacyIkIdentity, properties, builtinIkFoldContext()); + } + // A parser name reaches BE's built-in dispatch after case folding and without a policy lookup. + String canonicalParser = parser.trim().toLowerCase(Locale.ROOT); + String builtinParserIdentity = builtinAnalyzerIdentity(canonicalParser, properties); + if (builtinParserIdentity != null) { + return builtinParserIdentity; + } + return appendOuterCharFilterIdentity(canonicalParser, properties, null); + } + + /** + * Identity of a built-in analyzer BE canonicalizes, or null for any other name. The basic and + * icu built-ins are their tokenizer plus a LowerCaseFilter that lower_case drops, so they share + * the identity of that custom pipeline, and unicode is another spelling of standard. + */ + private static String builtinAnalyzerIdentity(String name, Map properties) { + if (InvertedIndexProperties.INVERTED_INDEX_PARSER_STANDARD.equals(name) + || InvertedIndexProperties.INVERTED_INDEX_PARSER_UNICODE.equals(name)) { + return builtinStandardAnalyzerIdentity(properties); + } + if (LOWERCASE_ONLY_BUILTIN_ANALYZERS.contains(name)) { + return builtinLowercaseSettingIdentity(name, properties); + } + if (!LOWERCASE_FILTER_BUILTIN_ANALYZERS.contains(name)) { + return null; + } + boolean lowercase = isLowercaseEnabled(properties); + // Spell out the pipeline rather than resolving components, because the built-in keeps its + // own tokenizer even when a named policy shadows that name. + String identity = IndexPolicyTypeEnum.ANALYZER.name() + ":" + + (lowercase ? IndexPolicy.PROP_TOKEN_FILTER + "=" + LOWERCASE_TOKEN_FILTER + ";" : "") + + IndexPolicy.PROP_TOKENIZER + "=" + name + ";"; + return appendOuterCharFilterIdentity( + identity, properties, lowercase ? FoldContext.unfiltered() : null); + } + + /** + * Identity of the standard and unicode built-ins, which are the same StandardAnalyzer. Its + * tokenizer lower-cases every word and drops the stop words unless the settings turn those + * off, so both settings belong to the identity. + */ + private static String builtinStandardAnalyzerIdentity(Map properties) { + boolean lowercase = isLowercaseEnabled(properties); + StringBuilder identity = + new StringBuilder(InvertedIndexProperties.INVERTED_INDEX_PARSER_STANDARD); + if (!lowercase) { + identity.append(";").append( + InvertedIndexProperties.INVERTED_INDEX_PARSER_LOWERCASE_KEY).append("=false"); + } + if (STOPWORDS_NONE.equals( + properties.get(InvertedIndexProperties.INVERTED_INDEX_PARSER_STOPWORDS_KEY))) { + identity.append(";").append( + InvertedIndexProperties.INVERTED_INDEX_PARSER_STOPWORDS_KEY).append("=none"); + } + return appendOuterCharFilterIdentity( + identity.toString(), properties, lowercase ? FoldContext.unfiltered() : null); + } + + /** + * Identity of a built-in analyzer whose tokenizer lower-cases its terms unless lower_case turns + * that off, and that reads no other setting. + */ + private static String builtinLowercaseSettingIdentity( + String name, Map properties) { + boolean lowercase = isLowercaseEnabled(properties); + String identity = lowercase ? name + : name + ";" + InvertedIndexProperties.INVERTED_INDEX_PARSER_LOWERCASE_KEY + "=false"; + return appendOuterCharFilterIdentity( + identity, properties, lowercase ? FoldContext.unfiltered() : null); + } + + /** Whether BE lower-cases the terms of a built-in analyzer, which only lower_case=false stops. */ + private static boolean isLowercaseEnabled(Map properties) { + return !Boolean.FALSE.toString().equalsIgnoreCase( + properties.get(InvertedIndexProperties.INVERTED_INDEX_PARSER_LOWERCASE_KEY)); + } + + private static String resolveBuiltinIkAnalyzerIdentity( + Map properties, String analyzer) { + // BE dispatches canonical lowercase built-ins before custom policies. + // Preserve the identity of case-distinct legacy policies such as "IK". + if (!InvertedIndexProperties.INVERTED_INDEX_PARSER_IK.equals(analyzer.trim())) { + return null; + } + return buildBuiltinIkIdentity("ik_max_word", properties); + } + + private static String resolveLegacyIkIdentity(Map properties, String parser) { + if (!InvertedIndexProperties.INVERTED_INDEX_PARSER_IK.equalsIgnoreCase(parser)) { + return null; + } + String mode = properties.get(InvertedIndexProperties.INVERTED_INDEX_PARSER_MODE_KEY); + if (Strings.isNullOrEmpty(mode)) { + mode = InvertedIndexProperties.INVERTED_INDEX_PARSER_SMART; + } + String tokenizer = normalizeBuiltinComponentName(mode, IndexPolicyTypeEnum.TOKENIZER); + if (!"ik_smart".equals(tokenizer) && !"ik_max_word".equals(tokenizer)) { + return null; + } + // Legacy IK uses the built-in tokenizer even when a named policy shadows its mode. + return buildBuiltinIkIdentity(tokenizer, properties); + } + + private static String buildBuiltinIkIdentity(String tokenizer, Map properties) { + String identity = IndexPolicyTypeEnum.ANALYZER.name() + ":tokenizer=" + tokenizer + ";"; + if (!isLowercaseEnabled(properties)) { + identity += "lower_case=false;"; + } + return identity; } /** @@ -70,9 +274,8 @@ private static String resolveAnalyzerIdentity(String analyzerName, String defaul return analyzerName; } - // Check if it's a built-in normalizer - if (IndexPolicy.BUILTIN_NORMALIZERS.contains(analyzerName)) { - return "normalizer:" + analyzerName; + if (isBuiltinNormalizerBinding(analyzerName)) { + return builtinNormalizerIdentity(analyzerName); } // For custom analyzer/normalizer, get underlying config from IndexPolicyMgr @@ -116,6 +319,30 @@ private static String resolveAnalyzerIdentity(String analyzerName, String defaul } } + /** Whether BE builds the built-in normalizer for this name; an exact legacy policy shadows it. */ + private static boolean isBuiltinNormalizerBinding(String name) { + try { + Env env = Env.getCurrentEnv(); + if (env != null && env.getIndexPolicyMgr() != null) { + return env.getIndexPolicyMgr().getTopLevelBuiltin( + name, IndexPolicy.BUILTIN_NORMALIZERS) != null; + } + } catch (RuntimeException e) { + // Fall through to the name-only answer. + } + return IndexPolicy.BUILTIN_NORMALIZERS.contains( + Strings.nullToEmpty(name).trim().toLowerCase(Locale.ROOT)); + } + + /** + * BE builds a built-in normalizer as the keyword tokenizer plus the built-in token filter of + * the canonical name, so it shares the identity of that custom pipeline. + */ + private static String builtinNormalizerIdentity(String name) { + return buildIdentityFromPolicyProperties(IndexPolicyTypeEnum.NORMALIZER, + Map.of(IndexPolicy.PROP_TOKEN_FILTER, name.trim().toLowerCase(Locale.ROOT))); + } + /** * Build identity string from policy properties. * Uses TreeMap to ensure consistent key ordering. @@ -124,23 +351,38 @@ private static String buildIdentityFromPolicyProperties(IndexPolicyTypeEnum type Map properties) { // Use TreeMap to sort keys for consistent identity TreeMap sortedProps = new TreeMap<>(properties); + String tokenizerIdentity = resolveComponentIdentity( + properties.get(IndexPolicy.PROP_TOKENIZER), IndexPolicyTypeEnum.TOKENIZER); + FoldContext downstreamFold = foldsAsciiCaseAfterCharFilters(type, properties, tokenizerIdentity); + + IndexPolicyTypeEnum identityType = type; + if (type == IndexPolicyTypeEnum.NORMALIZER) { + // BE's CustomNormalizer is the keyword tokenizer plus the configured char and token + // filters, so it emits what the equivalent analyzer emits and shares its identity. + identityType = IndexPolicyTypeEnum.ANALYZER; + tokenizerIdentity = KEYWORD_TOKENIZER; + sortedProps.put(IndexPolicy.PROP_TOKENIZER, KEYWORD_TOKENIZER); + } StringBuilder sb = new StringBuilder(); - sb.append(type.name()).append(":"); + sb.append(identityType.name()).append(":"); for (Map.Entry entry : sortedProps.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); + String resolved = null; // For tokenizer, token_filter, char_filter - resolve recursively if needed if (IndexPolicy.PROP_TOKENIZER.equals(key)) { - sb.append("tokenizer=").append(resolveComponentIdentity(value, IndexPolicyTypeEnum.TOKENIZER)); + resolved = tokenizerIdentity; } else if (IndexPolicy.PROP_TOKEN_FILTER.equals(key)) { - sb.append("token_filter=").append(resolveTokenFilterIdentity(value)); + resolved = resolveTokenFilterIdentity(value); } else if (IndexPolicy.PROP_CHAR_FILTER.equals(key)) { - sb.append("char_filter=").append(resolveCharFilterIdentity(value)); + resolved = resolveCharFilterIdentity(value, downstreamFold); + } + if (!Strings.isNullOrEmpty(resolved)) { + sb.append(key).append("=").append(resolved).append(";"); } - sb.append(";"); } return sb.toString(); @@ -150,47 +392,624 @@ private static String buildIdentityFromPolicyProperties(IndexPolicyTypeEnum type * Resolve a component (tokenizer) to its identity. */ private static String resolveComponentIdentity(String name, IndexPolicyTypeEnum expectedType) { + return resolveComponentIdentity(name, expectedType, null); + } + + /** {@code fold} is the case-folding context of a char filter, or null without a downstream fold. */ + private static String resolveComponentIdentity( + String name, IndexPolicyTypeEnum expectedType, FoldContext fold) { if (Strings.isNullOrEmpty(name)) { return ""; } - // Check if it's a built-in component - if (expectedType == IndexPolicyTypeEnum.TOKENIZER - && IndexPolicy.BUILTIN_TOKENIZERS.contains(name)) { - return name; + // Existing named policies take precedence over built-ins for upgrade compatibility. + try { + Env env = Env.getCurrentEnv(); + if (env != null && env.getIndexPolicyMgr() != null) { + IndexPolicy policy = env.getIndexPolicyMgr().getPolicyByName(name); + if (policy != null && policy.getType() == expectedType) { + if (policy.isInvalid()) { + return "invalid-policy:" + policy.getId() + ":" + policy.getName(); + } + Map props = policy.getProperties(); + if (props != null && !props.isEmpty()) { + TreeMap sortedProps = new TreeMap<>(props); + String type = sortedProps.get(IndexPolicy.PROP_TYPE); + String normalizedType = normalizeBuiltinComponentName(type, expectedType); + if (normalizedType != null) { + if ("empty".equals(normalizedType)) { + return ""; + } + sortedProps.put(IndexPolicy.PROP_TYPE, normalizedType); + canonicalizeEffectiveComponentProperties( + sortedProps, normalizedType, expectedType); + if (sortedProps.size() == 1) { + return normalizedType; + } + } + if (expectedType == IndexPolicyTypeEnum.TOKENIZER + && "ngram".equals(sortedProps.get(IndexPolicy.PROP_TYPE))) { + // This setting only limits policy creation; it does not change emitted tokens. + sortedProps.remove(PROP_MAX_NGRAM_DIFF); + } + if (expectedType == IndexPolicyTypeEnum.CHAR_FILTER + && CHAR_REPLACE_FILTER.equals(sortedProps.get(IndexPolicy.PROP_TYPE))) { + String replacement = sortedProps.getOrDefault( + PROP_REPLACEMENT, CHAR_REPLACE_DEFAULT_REPLACEMENT); + String pattern = canonicalizeCharReplacePattern( + sortedProps.getOrDefault(PROP_PATTERN, CHAR_REPLACE_DEFAULT_PATTERN), + replacement, fold); + if (pattern.isEmpty()) { + return ""; + } + if (isCharReplaceDefault(pattern, replacement, fold)) { + // Restating the factory defaults is the bare built-in reference. + sortedProps.remove(PROP_PATTERN); + sortedProps.remove(PROP_REPLACEMENT); + } else { + sortedProps.put(PROP_PATTERN, pattern); + sortedProps.put(PROP_REPLACEMENT, replacement); + } + } + if (normalizedType != null && sortedProps.size() == 1) { + return normalizedType; + } + return sortedProps.toString(); + } + } + } + } catch (RuntimeException e) { + // Fall through to built-in resolution or the original name. + } + + String normalizedName = normalizeBuiltinComponentName(name, expectedType); + return "empty".equals(normalizedName) ? "" : normalizedName == null ? name : normalizedName; + } + + /** Whether this canonical char_replace configuration is what a bare built-in reference gets. */ + private static boolean isCharReplaceDefault(String pattern, String replacement, FoldContext fold) { + return CHAR_REPLACE_DEFAULT_REPLACEMENT.equals(replacement) + && canonicalizeCharReplacePattern( + CHAR_REPLACE_DEFAULT_PATTERN, replacement, fold).equals(pattern); + } + + private static void canonicalizeEffectiveComponentProperties( + TreeMap properties, String type, IndexPolicyTypeEnum expectedType) { + if ("pinyin".equals(type)) { + removeBooleanDefaults(properties, true, + "keep_first_letter", "keep_full_pinyin", "keep_none_chinese", + "keep_none_chinese_together", "keep_none_chinese_in_first_letter", + "lowercase", "trim_whitespace", "ignore_pinyin_offset", + "none_chinese_pinyin_tokenize"); + removeBooleanDefaults(properties, false, + "keep_separate_first_letter", "keep_joined_full_pinyin", "keep_original", + "keep_none_chinese_in_joined_full_pinyin", "remove_duplicated_term", + "fixed_pinyin_offset", "keep_separate_chinese"); + removeIntegerDefault(properties, "limit_first_letter_length", 16); + canonicalizePinyinDependencies(properties, expectedType); + return; + } + + if (expectedType == IndexPolicyTypeEnum.TOKEN_FILTER) { + if ("asciifolding".equals(type)) { + removeBooleanDefaults(properties, false, "preserve_original"); + } else if ("word_delimiter".equals(type)) { + removeBooleanDefaults(properties, true, "generate_word_parts", "generate_number_parts", + "split_on_case_change", "split_on_numerics", "stem_english_possessive"); + removeBooleanDefaults(properties, false, "catenate_words", "catenate_numbers", + "catenate_all", "preserve_original"); + canonicalizeWordSet(properties, "protected_words"); + canonicalizeTypeTable(properties); + } else if ("icu_normalizer".equals(type)) { + canonicalizeIcuNormalizerDefaults(properties, false); + } + return; + } + + if (expectedType == IndexPolicyTypeEnum.CHAR_FILTER) { + if ("icu_normalizer".equals(type)) { + canonicalizeIcuNormalizerDefaults(properties, true); + } + return; + } + + if (expectedType != IndexPolicyTypeEnum.TOKENIZER) { + return; + } + switch (type) { + case "ngram": + case "edge_ngram": + removeIntegerDefault(properties, "min_gram", 1); + removeIntegerDefault(properties, "max_gram", 2); + canonicalizeWordSet(properties, "token_chars"); + canonicalizeCustomTokenChars(properties); + break; + case "standard": + removeIntegerDefault(properties, "max_token_length", 255); + break; + case "char_group": + removeIntegerDefault(properties, "max_token_length", 255); + canonicalizeTokenizeOnChars(properties); + break; + case "keyword": + // BE only range-checks buffer_size; the emitted term is always capped by a constant. + properties.remove("buffer_size"); + break; + case "basic": + canonicalizeBasicExtraChars(properties); + break; + default: + break; } + } - // For custom component, get its properties + private static void removeBooleanDefaults( + TreeMap properties, boolean defaultValue, String... keys) { + for (String key : keys) { + String value = properties.get(key); + if (value == null || !("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value))) { + continue; + } + boolean parsed = Boolean.parseBoolean(value); + if (parsed == defaultValue) { + properties.remove(key); + } else { + properties.put(key, Boolean.toString(parsed)); + } + } + } + + private static void removeIntegerDefault( + TreeMap properties, String key, int defaultValue) { + String value = properties.get(key); + if (value == null) { + return; + } try { - Env env = Env.getCurrentEnv(); - if (env == null || env.getIndexPolicyMgr() == null) { - return name; + int parsed = Integer.parseInt(value); + if (parsed == defaultValue) { + properties.remove(key); + } else { + properties.put(key, Integer.toString(parsed)); } + } catch (NumberFormatException e) { + // Invalid policies keep their original identity. + } + } - IndexPolicy policy = env.getIndexPolicyMgr().getPolicyByName(name); - if (policy == null || policy.getType() != expectedType) { - return name; + private static void canonicalizeIcuNormalizerDefaults( + TreeMap properties, boolean hasMode) { + String name = properties.get("name"); + if (name != null) { + String normalizedName = name.trim().toLowerCase(Locale.ROOT); + if ("nfkc_cf".equals(normalizedName)) { + properties.remove("name"); + } else { + properties.put("name", normalizedName); } - if (policy.isInvalid()) { - return "invalid-policy:" + policy.getId() + ":" + policy.getName(); + } + String filter = properties.get("unicode_set_filter"); + if (filter != null && filter.isEmpty()) { + // BE treats an explicit empty string like an absent filter. + properties.remove("unicode_set_filter"); + } else if (filter != null) { + try { + UnicodeSet unicodeSet = new UnicodeSet(filter); + if (unicodeSet.isEmpty()) { + properties.remove("unicode_set_filter"); + } else { + properties.put("unicode_set_filter", unicodeSet.toPattern(false)); + } + } catch (IllegalArgumentException e) { + // Invalid policies keep their original identity. } + } + if (hasMode) { + canonicalizeIcuNormalizerMode(properties); + } + } + + private static void canonicalizeIcuNormalizerMode(TreeMap properties) { + removeStringDefault(properties, "mode", "compose"); + if (!"decompose".equals(properties.get("mode"))) { + return; + } + // BE ignores mode for nfd/nfkd, and nfc/nfkc in decompose mode are the same ICU instances. + String name = properties.get("name"); + if ("nfc".equals(name) || "nfd".equals(name)) { + properties.put("name", "nfd"); + properties.remove("mode"); + } else if ("nfkc".equals(name) || "nfkd".equals(name)) { + properties.put("name", "nfkd"); + properties.remove("mode"); + } + } - Map props = policy.getProperties(); - if (props == null || props.isEmpty()) { - return name; + // BE reads these settings as unordered sets of trimmed, non-empty words. + private static void canonicalizeWordSet(TreeMap properties, String key) { + String value = properties.get(key); + if (value == null) { + return; + } + TreeSet words = new TreeSet<>(); + for (String word : value.split(",")) { + String trimmed = trimAsciiWhitespace(word); + if (!trimmed.isEmpty()) { + words.add(trimmed); } + } + if (words.isEmpty()) { + properties.remove(key); + } else { + properties.put(key, String.join(",", words)); + } + } - // Build identity from sorted properties - TreeMap sortedProps = new TreeMap<>(props); - if (expectedType == IndexPolicyTypeEnum.TOKENIZER - && "ngram".equals(sortedProps.get(IndexPolicy.PROP_TYPE))) { - // This setting only limits policy creation; it does not change emitted tokens. - sortedProps.remove(PROP_MAX_NGRAM_DIFF); + // BE matches custom token characters as a code point set ORed with the named classes. + private static void canonicalizeCustomTokenChars(TreeMap properties) { + String value = properties.get("custom_token_chars"); + if (value == null) { + return; + } + String tokenChars = properties.getOrDefault("token_chars", ""); + Set classes = new TreeSet<>(List.of(tokenChars.split(","))); + StringBuilder canonical = new StringBuilder(); + value.codePoints().distinct().sorted() + .filter(codePoint -> !isCoveredByAsciiClass(codePoint, classes)) + .forEach(canonical::appendCodePoint); + if (canonical.length() == 0 && !value.isEmpty() && classes.remove("custom")) { + properties.remove("custom_token_chars"); + properties.put("token_chars", String.join(",", classes)); + return; + } + properties.put("custom_token_chars", canonical.toString()); + } + + // BE collects tokenize_on_chars entries into sets and checks the categories before the literals. + private static void canonicalizeTokenizeOnChars(TreeMap properties) { + List entries = parseEntryList(properties.get("tokenize_on_chars")); + if (entries == null) { + return; + } + TreeSet canonical = new TreeSet<>(entries); + Set classes = new TreeSet<>(canonical); + classes.retainAll(CHAR_GROUP_TYPES); + canonical.removeIf(entry -> entry.indexOf('\\') < 0 + && entry.codePointCount(0, entry.length()) == 1 + && isCoveredByAsciiClass(entry.codePointAt(0), classes)); + putEntryList(properties, "tokenize_on_chars", canonical); + } + + /** + * Whether a named character class of the ngram or char_group tokenizer already matches this + * code point. Only ASCII is judged: its categories never change between the ICU versions FE + * and BE link against, and the class predicates agree there. + */ + private static boolean isCoveredByAsciiClass(int codePoint, Set classes) { + if (codePoint >= 128) { + return false; + } + int type = UCharacter.getType(codePoint); + for (String name : classes) { + switch (name) { + case "letter": + if (UCharacter.isLetter(codePoint)) { + return true; + } + break; + case "digit": + if (UCharacter.isDigit(codePoint)) { + return true; + } + break; + case "whitespace": + if (UCharacter.isWhitespace(codePoint)) { + return true; + } + break; + case "punctuation": + if (type == UCharacter.START_PUNCTUATION || type == UCharacter.END_PUNCTUATION + || type == UCharacter.OTHER_PUNCTUATION || type == UCharacter.CONNECTOR_PUNCTUATION + || type == UCharacter.DASH_PUNCTUATION || type == UCharacter.INITIAL_PUNCTUATION + || type == UCharacter.FINAL_PUNCTUATION) { + return true; + } + break; + case "symbol": + if (type == UCharacter.CURRENCY_SYMBOL || type == UCharacter.MATH_SYMBOL + || type == UCharacter.OTHER_SYMBOL || type == UCharacter.MODIFIER_SYMBOL) { + return true; + } + break; + default: + break; } - return sortedProps.toString(); - } catch (RuntimeException e) { - return name; } + return false; + } + + // BE builds a per-character type map where a later rule for the same character wins. + private static void canonicalizeTypeTable(TreeMap properties) { + List rules = parseEntryList(properties.get("type_table")); + if (rules == null) { + return; + } + TreeMap types = new TreeMap<>(); + for (String rule : rules) { + int arrow = rule.lastIndexOf("=>"); + if (arrow < 0 || rule.indexOf('\n') >= 0 || rule.indexOf('\r') >= 0) { + return; + } + String character = trimAsciiWhitespace(rule.substring(0, arrow)); + String type = trimAsciiWhitespace(rule.substring(arrow + 2)); + // Escaped characters keep the original identity rather than reproducing BE unescaping. + if (character.indexOf('\\') >= 0 || character.codePointCount(0, character.length()) != 1 + || !WORD_DELIMITER_TYPES.contains(type)) { + return; + } + types.put(character.codePointAt(0), type); + } + // An explicit table uses BE's generated classification, including when all rules restate it. + TreeMap effectiveTypes = new TreeMap<>(types); + effectiveTypes.entrySet().removeIf( + entry -> entry.getValue().equals(defaultWordDelimiterType(entry.getKey()))); + if (effectiveTypes.isEmpty() && !types.isEmpty()) { + properties.put("type_table", ""); + return; + } + List canonicalRules = new ArrayList<>(); + for (Map.Entry entry : effectiveTypes.entrySet()) { + canonicalRules.add(new String(Character.toChars(entry.getKey())) + "=>" + entry.getValue()); + } + putEntryList(properties, "type_table", canonicalRules); + } + + /** BE's u_charType classification of an ASCII code point, or null for anything else. */ + private static String defaultWordDelimiterType(int codePoint) { + if (codePoint >= 128) { + return null; + } + switch (UCharacter.getType(codePoint)) { + case UCharacter.UPPERCASE_LETTER: + return "UPPER"; + case UCharacter.LOWERCASE_LETTER: + return "LOWER"; + case UCharacter.DECIMAL_DIGIT_NUMBER: + return "DIGIT"; + default: + return "SUBWORD_DELIM"; + } + } + + /** Parse a bracketed entry list as BE does, or return null for a malformed list. */ + private static List parseEntryList(String value) { + if (value == null) { + return null; + } + List entries = new ArrayList<>(); + String trimmed = trimAsciiWhitespace(value); + if (trimmed.isEmpty()) { + return entries; + } + for (String item : ENTRY_SEPARATOR.split(trimmed)) { + String entry = trimAsciiWhitespace(item); + if (entry.length() < 2 || entry.charAt(0) != '[' || entry.charAt(entry.length() - 1) != ']') { + return null; + } + String content = entry.substring(1, entry.length() - 1); + if (!content.isEmpty()) { + entries.add(content); + } + } + return entries; + } + + private static void putEntryList(TreeMap properties, String key, Collection entries) { + if (entries.isEmpty()) { + properties.remove(key); + return; + } + StringBuilder canonical = new StringBuilder(); + for (String entry : entries) { + if (canonical.length() > 0) { + canonical.append(","); + } + canonical.append("[").append(entry).append("]"); + } + properties.put(key, canonical.toString()); + } + + // Trim the same ASCII whitespace that BE trims. + private static String trimAsciiWhitespace(String value) { + int begin = 0; + int end = value.length(); + while (begin < end && isAsciiWhitespace(value.charAt(begin))) { + ++begin; + } + while (end > begin && isAsciiWhitespace(value.charAt(end - 1))) { + --end; + } + return value.substring(begin, end); + } + + private static boolean isAsciiWhitespace(char value) { + return value == ' ' || (value >= '\t' && value <= '\r'); + } + + // BE consumes an ASCII alphanumeric run before it consults extra_chars. + private static void canonicalizeBasicExtraChars(TreeMap properties) { + String extraChars = properties.get("extra_chars"); + if (extraChars == null) { + return; + } + boolean[] present = new boolean[128]; + for (int i = 0; i < extraChars.length(); ++i) { + char value = extraChars.charAt(i); + if (value >= present.length) { + return; + } + boolean alphanumeric = (value >= '0' && value <= '9') || (value >= 'A' && value <= 'Z') + || (value >= 'a' && value <= 'z'); + present[value] = !alphanumeric; + } + StringBuilder canonical = new StringBuilder(); + for (int i = 0; i < present.length; ++i) { + if (present[i]) { + canonical.append((char) i); + } + } + if (canonical.length() == 0) { + properties.remove("extra_chars"); + } else { + properties.put("extra_chars", canonical.toString()); + } + } + + private static void canonicalizePinyinDependencies( + TreeMap properties, IndexPolicyTypeEnum expectedType) { + Boolean keepFirstLetter = effectiveBoolean(properties, "keep_first_letter", true); + Boolean keepFullPinyin = effectiveBoolean(properties, "keep_full_pinyin", true); + Boolean keepSeparateFirstLetter = effectiveBoolean(properties, "keep_separate_first_letter", false); + Boolean keepOriginal = effectiveBoolean(properties, "keep_original", false); + Boolean keepNoneChinese = effectiveBoolean(properties, "keep_none_chinese", true); + Boolean keepNoneChineseTogether = effectiveBoolean(properties, "keep_none_chinese_together", true); + Boolean noneChinesePinyinTokenize = effectiveBoolean(properties, "none_chinese_pinyin_tokenize", true); + Boolean ignorePinyinOffset = effectiveBoolean(properties, "ignore_pinyin_offset", true); + Boolean keepJoinedFullPinyin = effectiveBoolean(properties, "keep_joined_full_pinyin", false); + // Read before the settings that gate the case-bearing outputs are dropped below. + boolean tokenizerEmitsSourceCase = pinyinTokenizerEmitsSourceCase(properties); + // Only the pinyin tokenizer also consults keep_none_chinese_in_joined_full_pinyin, when no + // other setting settles whether it emits an untokenized ASCII buffer. + boolean tokenizerReadsJoinedSetting = expectedType == IndexPolicyTypeEnum.TOKENIZER + && !Boolean.FALSE.equals(keepNoneChinese) + && !Boolean.FALSE.equals(keepNoneChineseTogether) + && !Boolean.TRUE.equals(noneChinesePinyinTokenize) + && !Boolean.TRUE.equals(keepFirstLetter) + && !Boolean.TRUE.equals(keepSeparateFirstLetter) + && !Boolean.TRUE.equals(keepFullPinyin); + + // The tokenizer only trims its candidates, and without the original every candidate is + // pinyin or ASCII alphanumerics; the token filter also trims the incoming token. + if (expectedType == IndexPolicyTypeEnum.TOKENIZER && Boolean.FALSE.equals(keepOriginal)) { + properties.remove("trim_whitespace"); + } + + // Without per-character outputs, all remaining candidates have position 1. + // Deduplicating by term or by term and position has the same effect. + if (Boolean.FALSE.equals(keepNoneChinese) + && Boolean.FALSE.equals(keepFullPinyin) + && Boolean.FALSE.equals(keepSeparateFirstLetter) + && Boolean.FALSE.equals(effectiveBoolean(properties, "keep_separate_chinese", false))) { + properties.remove("remove_duplicated_term"); + } + + if (Boolean.FALSE.equals(keepFirstLetter)) { + properties.remove("limit_first_letter_length"); + properties.remove("keep_none_chinese_in_first_letter"); + } + + if (Boolean.FALSE.equals(keepNoneChinese)) { + properties.remove("keep_none_chinese_together"); + properties.remove("none_chinese_pinyin_tokenize"); + } else if (Boolean.TRUE.equals(keepNoneChinese) && Boolean.FALSE.equals(keepNoneChineseTogether)) { + // BE emits each ASCII letter on its own here and never tokenizes an ASCII buffer. + properties.remove("none_chinese_pinyin_tokenize"); + } + + if (Boolean.TRUE.equals(ignorePinyinOffset) + || Boolean.FALSE.equals(keepNoneChinese) + || Boolean.FALSE.equals(keepNoneChineseTogether) + || Boolean.FALSE.equals(noneChinesePinyinTokenize)) { + properties.remove("fixed_pinyin_offset"); + } + + // The joined full pinyin buffer is only emitted behind keep_joined_full_pinyin. + if (Boolean.FALSE.equals(keepJoinedFullPinyin) && !tokenizerReadsJoinedSetting) { + properties.remove("keep_none_chinese_in_joined_full_pinyin"); + } + + // The token filter keeps this setting because its fallback can carry the source case. + if (expectedType == IndexPolicyTypeEnum.TOKENIZER && !tokenizerEmitsSourceCase) { + properties.remove("lowercase"); + } + } + + /** + * Whether any candidate of the pinyin tokenizer copies an ASCII letter from the source. The + * other candidates come from the pinyin dictionary or the ASCII alphabet tokenizer, which both + * emit lower-case text, so without one of these the lowercase setting cannot change a term. + * Settings that cannot be read as booleans count as possibly enabled. + */ + private static boolean pinyinTokenizerEmitsSourceCase(TreeMap properties) { + Boolean keepFirstLetter = effectiveBoolean(properties, "keep_first_letter", true); + Boolean keepFullPinyin = effectiveBoolean(properties, "keep_full_pinyin", true); + Boolean keepSeparateFirstLetter = effectiveBoolean(properties, "keep_separate_first_letter", false); + Boolean keepOriginal = effectiveBoolean(properties, "keep_original", false); + Boolean keepNoneChinese = effectiveBoolean(properties, "keep_none_chinese", true); + Boolean keepNoneChineseTogether = effectiveBoolean(properties, "keep_none_chinese_together", true); + Boolean noneChinesePinyinTokenize = effectiveBoolean(properties, "none_chinese_pinyin_tokenize", true); + Boolean keepJoinedFullPinyin = effectiveBoolean(properties, "keep_joined_full_pinyin", false); + Boolean keepNoneChineseInFirstLetter = + effectiveBoolean(properties, "keep_none_chinese_in_first_letter", true); + Boolean keepNoneChineseInJoinedFullPinyin = + effectiveBoolean(properties, "keep_none_chinese_in_joined_full_pinyin", false); + + // The buffered ASCII run is emitted whole unless it is split into dictionary syllables, and + // then only when some output other than the joined string asks for it. + boolean emitsAsciiBuffer = !Boolean.FALSE.equals(keepNoneChinese) + && !Boolean.FALSE.equals(keepNoneChineseTogether) + && !Boolean.TRUE.equals(noneChinesePinyinTokenize) + && (!Boolean.FALSE.equals(keepFirstLetter) + || !Boolean.FALSE.equals(keepSeparateFirstLetter) + || !Boolean.FALSE.equals(keepFullPinyin) + || !Boolean.TRUE.equals(keepNoneChineseInJoinedFullPinyin)); + // The aggregated first-letter and joined strings only collect ASCII behind their own gate. + return !Boolean.FALSE.equals(keepOriginal) + || emitsAsciiBuffer + || (!Boolean.FALSE.equals(keepNoneChinese) + && !Boolean.TRUE.equals(keepNoneChineseTogether)) + || (!Boolean.FALSE.equals(keepFirstLetter) + && !Boolean.FALSE.equals(keepNoneChineseInFirstLetter)) + || (!Boolean.FALSE.equals(keepJoinedFullPinyin) + && !Boolean.FALSE.equals(keepNoneChineseInJoinedFullPinyin)); + } + + private static Boolean effectiveBoolean( + TreeMap properties, String key, boolean defaultValue) { + String value = properties.get(key); + if (value == null) { + return defaultValue; + } + if ("true".equalsIgnoreCase(value)) { + return true; + } + if ("false".equalsIgnoreCase(value)) { + return false; + } + return null; + } + + private static void removeStringDefault( + TreeMap properties, String key, String defaultValue) { + if (defaultValue.equals(properties.get(key))) { + properties.remove(key); + } + } + + private static String normalizeBuiltinComponentName(String name, IndexPolicyTypeEnum expectedType) { + if (Strings.isNullOrEmpty(name)) { + return null; + } + String normalizedName = name.trim().toLowerCase(Locale.ROOT); + if ((expectedType == IndexPolicyTypeEnum.TOKENIZER + && IndexPolicy.BUILTIN_TOKENIZERS.contains(normalizedName)) + || (expectedType == IndexPolicyTypeEnum.TOKEN_FILTER + && IndexPolicy.BUILTIN_TOKEN_FILTERS.contains(normalizedName)) + || (expectedType == IndexPolicyTypeEnum.CHAR_FILTER + && IndexPolicy.BUILTIN_CHAR_FILTERS.contains(normalizedName))) { + return normalizedName; + } + return null; } /** @@ -206,17 +1025,21 @@ private static String resolveTokenFilterIdentity(String filterList) { 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(","); + String previous = null; + for (String filterName : filters) { + String filter = resolveComponentIdentity(filterName.trim(), IndexPolicyTypeEnum.TOKEN_FILTER); + if (Strings.isNullOrEmpty(filter)) { + continue; } - - if (IndexPolicy.BUILTIN_TOKEN_FILTERS.contains(filter)) { - sb.append(filter); - } else { - sb.append(resolveComponentIdentity(filter, IndexPolicyTypeEnum.TOKEN_FILTER)); + // Repeating an idempotent filter leaves the terms, offsets and provenance unchanged. + if (filter.equals(previous) && IDEMPOTENT_TOKEN_FILTERS.contains(filter)) { + continue; } + if (sb.length() > 0) { + sb.append(","); + } + sb.append(filter); + previous = filter; } return sb.toString(); } @@ -226,26 +1049,410 @@ 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 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 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; + } + // Repeating a char_replace filter rewrites the same bytes to the same byte again. + if (!filter.equals(identities.peekFirst()) || !isIdempotentCharFilter(filterName)) { + 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; + } + + /** + * Whether the filter is a usable char_replace, which replaces each pattern byte with the same + * single byte and so leaves the stream unchanged when it runs again. + */ + private static boolean isIdempotentCharFilter(String filterName) { + return charReplaceSourceBytes(filterName) != null; + } + + /** + * Bytes a char_replace filter rewrites, or null for any other filter. A bare built-in reference + * is instantiated with the factory defaults. + */ + private static boolean[] charReplaceSourceBytes(String filterName) { + String pattern = CHAR_REPLACE_DEFAULT_PATTERN; + String replacement = CHAR_REPLACE_DEFAULT_REPLACEMENT; + IndexPolicy policy = findPolicy(filterName, IndexPolicyTypeEnum.CHAR_FILTER); + if (policy != null) { + if (policy.isInvalid() || policy.getProperties() == null) { + return null; + } + Map properties = policy.getProperties(); + String type = normalizeBuiltinComponentName( + properties.get(IndexPolicy.PROP_TYPE), IndexPolicyTypeEnum.CHAR_FILTER); + if (!CHAR_REPLACE_FILTER.equals(type)) { + return null; } + pattern = properties.getOrDefault(PROP_PATTERN, CHAR_REPLACE_DEFAULT_PATTERN); + replacement = properties.getOrDefault(PROP_REPLACEMENT, CHAR_REPLACE_DEFAULT_REPLACEMENT); + } else if (!CHAR_REPLACE_FILTER.equals( + normalizeBuiltinComponentName(filterName, IndexPolicyTypeEnum.CHAR_FILTER))) { + return null; } - return sb.toString(); + // Replacing the single replacement byte with itself leaves the stream unchanged. + 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 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 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 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 properties) { + String name = icuNormalizerName(properties); + return "nfc".equals(name) || "nfd".equals(name) || "nfkc".equals(name) || "nfkd".equals(name); + } + + private static String icuNormalizerName(Map 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 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; + } + } else if (fold != null && replacementByte >= 'A' && replacementByte <= 'Z') { + // The downstream fold maps the replacement back to the lower-case byte it replaced. + int lowerByte = replacementByte + ('a' - 'A'); + if (fold.foldsByte(replacementByte, lowerByte)) { + replacedBytes[lowerByte] = false; + } + } + + StringBuilder canonical = new StringBuilder(); + for (int i = 0; i < replacedBytes.length; ++i) { + if (replacedBytes[i]) { + canonical.append((char) i); + } + } + return canonical.toString(); + } + + /** + * Fold context of a built-in IK analyzer. IK lower-cases single-byte ASCII in the buffer its + * lexeme text is copied from, which lower_case=false does not reach. + */ + private static FoldContext builtinIkFoldContext() { + return FoldContext.unfiltered(); + } + + /** + * 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 (isBuiltinNormalizerBinding(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 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 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 settings = resolveComponentSettings(name, IndexPolicyTypeEnum.TOKENIZER); + if (settings == null) { + return false; + } + String type = settings.get(IndexPolicy.PROP_TYPE); + // Judge the same canonical settings the tokenizer identity is built from. + canonicalizeEffectiveComponentProperties(settings, type, IndexPolicyTypeEnum.TOKENIZER); + switch (type) { + case "standard": + case "keyword": + case "icu": + case "basic": + return true; + case "ngram": + case "edge_ngram": + return !settings.containsKey("custom_token_chars"); + case "char_group": + return tokenizeOnCharsIgnoreAsciiLetters(settings.get("tokenize_on_chars")); + default: + return false; + } + } + + /** Settings of a named or built-in component with a canonical type, or null when unknown. */ + private static TreeMap resolveComponentSettings(String name, IndexPolicyTypeEnum expectedType) { + if (Strings.isNullOrEmpty(name)) { + return null; + } + TreeMap settings = new TreeMap<>(); + IndexPolicy policy = findPolicy(name, expectedType); + if (policy != null) { + if (policy.isInvalid()) { + return null; + } + if (policy.getProperties() != null) { + settings.putAll(policy.getProperties()); + } + } + String type = normalizeBuiltinComponentName( + settings.isEmpty() ? name : settings.get(IndexPolicy.PROP_TYPE), expectedType); + if (type == null) { + return null; + } + settings.put(IndexPolicy.PROP_TYPE, type); + return settings; + } + + // Escaped entries keep the conservative answer rather than reproducing BE unescaping. + private static boolean tokenizeOnCharsIgnoreAsciiLetters(String value) { + if (value == null) { + return true; + } + List entries = parseEntryList(value); + if (entries == null) { + return false; + } + for (String entry : entries) { + if (CHAR_GROUP_TYPES.contains(entry)) { + continue; + } + if (entry.indexOf('\\') >= 0 || entry.codePointCount(0, entry.length()) != 1) { + return false; + } + int codePoint = entry.codePointAt(0); + if ((codePoint >= 'A' && codePoint <= 'Z') || (codePoint >= 'a' && codePoint <= 'z')) { + return false; + } + } + return true; + } + + /** + * The first token filter that folds ASCII case, reached before any filter that could tell an + * upper-case letter from its lower-case form, or null when there is none. + */ + private static FoldContext tokenFiltersFoldAsciiCase(String filterList) { + if (Strings.isNullOrEmpty(filterList)) { + return null; + } + for (String filterName : filterList.split(",\\s*")) { + TreeMap settings = resolveComponentSettings( + filterName.trim(), IndexPolicyTypeEnum.TOKEN_FILTER); + if (settings == null) { + return null; + } + switch (settings.get(IndexPolicy.PROP_TYPE)) { + case "lowercase": + return FoldContext.unfiltered(); + case "empty": + case "asciifolding": + // ASCII bytes pass through ASCII folding unchanged. + continue; + case "icu_normalizer": + FoldContext fold = icuNormalizerFoldContext(settings); + if (fold != null) { + return fold; + } + if (isAsciiCaseTransparentIcuNormalizer(settings)) { + continue; + } + return null; + default: + return null; + } + } + return null; } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerKeyNormalizer.java b/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerKeyNormalizer.java index 057ad463d050ba..9be5a48e77b8ab 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerKeyNormalizer.java +++ b/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerKeyNormalizer.java @@ -17,6 +17,7 @@ package org.apache.doris.analysis.invertedindex; +import java.util.Locale; import java.util.Map; public final class AnalyzerKeyNormalizer { @@ -38,7 +39,7 @@ private static void normalizePropertyIfPresent(Map properties, S } String value = properties.get(key); if (value != null && !value.isEmpty()) { - properties.put(key, value.trim().toLowerCase()); + properties.put(key, value.trim().toLowerCase(Locale.ROOT)); } } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/OlapTable.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/OlapTable.java index 4645a7796c9d99..5229c6fc55d39a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/OlapTable.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/OlapTable.java @@ -4226,11 +4226,21 @@ private List filterIndexesByAnalyzer(List original, String analyze return original; } List matched = new ArrayList<>(); + List named = new ArrayList<>(); for (Index index : original) { if (InvertedIndexUtil.isAnalyzerMatched(index.getProperties(), analyzer)) { matched.add(index); + } else if (InvertedIndexUtil.isAnalyzerNameMatched(index.getProperties(), analyzer)) { + named.add(index); } } + // A built-in IK index is matched by its effective configuration, but an index created + // before that rule existed may be configured differently. When it is the only index that + // carries the requested name, keep serving the request from it as before; the predicate + // sends that index's own mode and lowercase settings to BE. + if (matched.isEmpty() && named.size() == 1) { + return named; + } return matched; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicy.java b/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicy.java index 12454b52c7083a..1b4362442eab92 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicy.java +++ b/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicy.java @@ -59,7 +59,8 @@ public class IndexPolicy implements Writable, GsonPostProcessable { public static final String PROP_TOKEN_FILTER = "token_filter"; public static final String PROP_CHAR_FILTER = "char_filter"; public static final Set BUILTIN_TOKENIZERS = ImmutableSet.of( - "empty", "ngram", "edge_ngram", "keyword", "standard", "char_group", "basic", "icu", "pinyin"); + "empty", "ngram", "edge_ngram", "keyword", "standard", "char_group", "basic", "icu", "pinyin", + "ik_smart", "ik_max_word"); public static final Set BUILTIN_TOKEN_FILTERS = ImmutableSet.of( "empty", "asciifolding", "word_delimiter", "lowercase", "pinyin", "icu_normalizer"); diff --git a/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicyMgr.java b/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicyMgr.java index f7b5b81c108f10..bccd7e3f215f31 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicyMgr.java +++ b/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicyMgr.java @@ -41,6 +41,7 @@ import java.io.DataOutput; import java.io.IOException; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -55,13 +56,65 @@ public class IndexPolicyMgr implements Writable, GsonPostProcessable { private final Map idToIndexPolicy = Maps.newHashMap(); // Keys are normalized to lowercase for case-insensitive lookup private final Map nameToIndexPolicy = Maps.newHashMap(); + // Legacy metadata can contain case-distinct names that share a normalized key. Keep exact + // bindings separately so a saved analyzer continues to resolve its original component. + private final transient Map exactNameToIndexPolicy = Maps.newHashMap(); /** * Normalize policy name to lowercase for case-insensitive lookup. * Policy names are case-insensitive in Doris. */ private static String normalizeKey(String name) { - return name == null ? null : name.trim().toLowerCase(); + return name == null ? null : name.trim().toLowerCase(Locale.ROOT); + } + + private static String exactKey(String name) { + return name == null ? null : name.trim(); + } + + // Callers hold either the read or write lock. Prefer an exact legacy name binding and + // retain normalized lookup only for interactive case-insensitive fallback. + private IndexPolicy getPolicyByNameLocked(String name) { + IndexPolicy exactPolicy = exactNameToIndexPolicy.get(exactKey(name)); + return exactPolicy != null ? exactPolicy : nameToIndexPolicy.get(normalizeKey(name)); + } + + // Callers hold either the read or write lock. BE dispatches a canonical built-in analyzer, then an + // exact policy, then a built-in by normalized name; return a spelling that reaches that built-in, + // or null for a policy. + private String resolveTopLevelBuiltinLocked(String name, Set builtins) { + String exactName = exactKey(name); + if (IndexPolicy.BUILTIN_ANALYZERS.contains(exactName) && builtins.contains(exactName)) { + return exactName; + } + if (exactNameToIndexPolicy.containsKey(exactName)) { + return null; + } + String normalizedName = normalizeKey(name); + if (!builtins.contains(normalizedName)) { + return null; + } + // BE checks an exact policy before the built-in normalizer, so the canonical spelling binds + // that policy instead; only the spelling given here still reaches the built-in. + if (!IndexPolicy.BUILTIN_ANALYZERS.contains(normalizedName) + && exactNameToIndexPolicy.containsKey(normalizedName)) { + return exactName; + } + return normalizedName; + } + + /** + * The spelling that makes an index's analyzer or normalizer name bind a built-in from + * {@code builtins}, or null when {@link #getPolicyByName} gives its binding. Validation uses the + * same order. + */ + public String getTopLevelBuiltin(String name, Set builtins) { + readLock(); + try { + return resolveTopLevelBuiltinLocked(name, builtins); + } finally { + readUnlock(); + } } private void writeLock() { @@ -80,10 +133,51 @@ private void readUnlock() { lock.readLock().unlock(); } + // Keep exact bindings and use the highest policy ID for normalized-name fallback. + // Callers hold the write lock. + private void registerPolicyNameLocked(IndexPolicy indexPolicy) { + String exactName = exactKey(indexPolicy.getName()); + IndexPolicy exactCurrent = exactNameToIndexPolicy.get(exactName); + if (exactCurrent == null || indexPolicy.getId() > exactCurrent.getId()) { + exactNameToIndexPolicy.put(exactName, indexPolicy); + } + + String normalizedName = normalizeKey(indexPolicy.getName()); + IndexPolicy current = nameToIndexPolicy.get(normalizedName); + if (current == null || indexPolicy.getId() > current.getId()) { + nameToIndexPolicy.put(normalizedName, indexPolicy); + } + if (current != null && current.getId() != indexPolicy.getId()) { + LOG.warn("Index policies '{}' (id={}) and '{}' (id={}) have the same normalized name; " + + "using the policy with the higher ID for name lookup", + current.getName(), current.getId(), indexPolicy.getName(), indexPolicy.getId()); + } + } + + private void unregisterPolicyNameLocked(IndexPolicy indexPolicy) { + String exactName = exactKey(indexPolicy.getName()); + String normalizedName = normalizeKey(indexPolicy.getName()); + IndexPolicy exactCurrent = exactNameToIndexPolicy.get(exactName); + IndexPolicy current = nameToIndexPolicy.get(normalizedName); + if (exactCurrent != null && exactCurrent.getId() == indexPolicy.getId()) { + exactNameToIndexPolicy.remove(exactName); + } + if (current != null && current.getId() == indexPolicy.getId()) { + nameToIndexPolicy.remove(normalizedName); + } + for (IndexPolicy remaining : idToIndexPolicy.values()) { + if (exactName.equals(exactKey(remaining.getName())) + || normalizedName.equals(normalizeKey(remaining.getName()))) { + registerPolicyNameLocked(remaining); + } + } + } + public List getCopiedIndexPolicies() { List copiedPolicies = Lists.newArrayList(); readLock(); try { + // Send every legacy policy to BE so exact bindings survive normalized-name collisions. copiedPolicies.addAll(idToIndexPolicy.values()); } finally { readUnlock(); @@ -92,15 +186,12 @@ public List 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(); try { - IndexPolicy policy = nameToIndexPolicy.get(normalizedName); + if (resolveTopLevelBuiltinLocked(analyzerName, IndexPolicy.BUILTIN_ANALYZERS) != null) { + return; + } + IndexPolicy policy = getPolicyByNameLocked(analyzerName); if (policy == null) { throw new DdlException("Analyzer '" + analyzerName + "' does not exist"); } @@ -129,46 +220,85 @@ private void validateReferencedComponentsUsableLocked(String analyzerName, Index } String tokenizerName = analyzerProperties.get(IndexPolicy.PROP_TOKENIZER); IndexPolicy tokenizer = tokenizerName == null - ? null : nameToIndexPolicy.get(normalizeKey(tokenizerName)); + ? null : getPolicyByNameLocked(tokenizerName); + if (tokenizer != null && tokenizer.getType() != IndexPolicyTypeEnum.TOKENIZER) { + throw new DdlException("Referenced policy '" + tokenizerName + "' is of type " + + tokenizer.getType() + " but expected " + IndexPolicyTypeEnum.TOKENIZER); + } if (tokenizer != null && tokenizer.isInvalid()) { throw new DdlException("Analyzer '" + analyzerName + "' references invalid tokenizer '" + tokenizerName + "'"); } String tokenFilterNames = analyzerProperties.get(IndexPolicy.PROP_TOKEN_FILTER); - if (tokenFilterNames == null || tokenFilterNames.isEmpty()) { - return; - } - for (String tokenFilterName : tokenFilterNames.split(",\\s*")) { - IndexPolicy tokenFilter = nameToIndexPolicy.get(normalizeKey(tokenFilterName)); - if (tokenFilter != null && tokenFilter.isInvalid()) { - throw new DdlException("Analyzer '" + analyzerName + "' references token filter '" - + tokenFilterName + "' of type '" - + tokenFilter.getProperties().get(IndexPolicy.PROP_TYPE) - + "', which is no longer supported"); + if (tokenFilterNames != null && !tokenFilterNames.isEmpty()) { + for (String tokenFilterName : tokenFilterNames.split(",\\s*")) { + IndexPolicy tokenFilter = getPolicyByNameLocked(tokenFilterName); + if (tokenFilter != null && tokenFilter.getType() != IndexPolicyTypeEnum.TOKEN_FILTER) { + throw new DdlException("Referenced policy '" + tokenFilterName + "' is of type " + + tokenFilter.getType() + " but expected " + IndexPolicyTypeEnum.TOKEN_FILTER); + } + if (tokenFilter != null && tokenFilter.isInvalid()) { + throw new DdlException("Analyzer '" + analyzerName + "' references token filter '" + + tokenFilterName + "' of type '" + + tokenFilter.getProperties().get(IndexPolicy.PROP_TYPE) + + "', which is no longer supported"); + } } } + + validateReferencedFilterTypesLocked(analyzerProperties.get(IndexPolicy.PROP_CHAR_FILTER), + IndexPolicyTypeEnum.CHAR_FILTER); } - public void validateNormalizerExists(String normalizerName) throws DdlException { - String normalizedName = normalizeKey(normalizerName); - // Built-in normalizers are stored in lowercase, so use normalized name for comparison - if (IndexPolicy.BUILTIN_NORMALIZERS.contains(normalizedName)) { + private void validateReferencedFilterTypesLocked(String filterNames, IndexPolicyTypeEnum expectedType) + throws DdlException { + if (filterNames == null || filterNames.isEmpty()) { return; } + for (String filterName : filterNames.split(",\\s*")) { + IndexPolicy filter = getPolicyByNameLocked(filterName); + if (filter != null && filter.getType() != expectedType) { + throw new DdlException("Referenced policy '" + filterName + "' is of type " + + filter.getType() + " but expected " + expectedType); + } + if (filter != null && filter.isInvalid()) { + throw new DdlException("Referenced " + expectedType + " policy '" + filterName + + "' is invalid"); + } + } + } + public void validateNormalizerExists(String normalizerName) throws DdlException { readLock(); try { - IndexPolicy policy = nameToIndexPolicy.get(normalizedName); + if (resolveTopLevelBuiltinLocked(normalizerName, IndexPolicy.BUILTIN_NORMALIZERS) != null) { + return; + } + IndexPolicy policy = getPolicyByNameLocked(normalizerName); if (policy == null) { throw new DdlException("Normalizer '" + normalizerName + "' does not exist"); } if (policy.getType() != IndexPolicyTypeEnum.NORMALIZER) { throw new DdlException("Policy '" + normalizerName + "' is not a normalizer"); } + // BE merges the normalizer property into the analyzer name and builds a built-in analyzer + // for these names before it looks up any policy, so such a binding never runs the policy. + if (IndexPolicy.BUILTIN_ANALYZERS.contains(policy.getName())) { + throw new DdlException("Normalizer '" + normalizerName + "' binds policy '" + policy.getName() + + "', whose name is a built-in analyzer name and is therefore never used;" + + " rename the policy or use the built-in analyzer instead"); + } if (policy.isInvalid()) { throw new DdlException("Normalizer '" + normalizerName + "' is invalid"); } + Map properties = policy.getProperties(); + if (properties != null) { + validateReferencedFilterTypesLocked(properties.get(IndexPolicy.PROP_TOKEN_FILTER), + IndexPolicyTypeEnum.TOKEN_FILTER); + validateReferencedFilterTypesLocked(properties.get(IndexPolicy.PROP_CHAR_FILTER), + IndexPolicyTypeEnum.CHAR_FILTER); + } } finally { readUnlock(); } @@ -181,21 +311,24 @@ public void createIndexPolicy(boolean ifNotExists, String policyName, } // Normalize policy name for case-insensitive comparison with built-in names String normalizedName = normalizeKey(policyName); - if (IndexPolicy.BUILTIN_TOKENIZERS.contains(normalizedName)) { - throw new DdlException("Policy name '" + policyName + "' conflicts with built-in tokenizer name"); - } - if (IndexPolicy.BUILTIN_TOKEN_FILTERS.contains(normalizedName)) { - throw new DdlException("Policy name '" + policyName + "' conflicts with built-in token filter name"); - } - if (IndexPolicy.BUILTIN_CHAR_FILTERS.contains(normalizedName)) { - throw new DdlException("Policy name '" + policyName + "' conflicts with built-in char filter name"); - } - if (IndexPolicy.BUILTIN_ANALYZERS.contains(normalizedName)) { - throw new DdlException("Policy name '" + policyName + "' conflicts with built-in analyzer name"); - } - writeLock(); try { + if (ifNotExists && nameToIndexPolicy.containsKey(normalizedName)) { + return; + } + if (IndexPolicy.BUILTIN_TOKENIZERS.contains(normalizedName)) { + throw new DdlException("Policy name '" + policyName + "' conflicts with built-in tokenizer name"); + } + if (IndexPolicy.BUILTIN_TOKEN_FILTERS.contains(normalizedName)) { + throw new DdlException("Policy name '" + policyName + "' conflicts with built-in token filter name"); + } + if (IndexPolicy.BUILTIN_CHAR_FILTERS.contains(normalizedName)) { + throw new DdlException("Policy name '" + policyName + "' conflicts with built-in char filter name"); + } + if (IndexPolicy.BUILTIN_ANALYZERS.contains(normalizedName)) { + throw new DdlException("Policy name '" + policyName + "' conflicts with built-in analyzer name"); + } + Map storedProperties = properties == null ? null : Maps.newHashMap(properties); validatePolicyProperties(type, storedProperties); @@ -218,9 +351,8 @@ public void createIndexPolicy(boolean ifNotExists, String policyName, throw new DdlException("Index policy number cannot exceed 100"); } - // Store with normalized key for case-insensitive lookup - nameToIndexPolicy.put(normalizedName, indexPolicy); idToIndexPolicy.put(indexPolicy.getId(), indexPolicy); + registerPolicyNameLocked(indexPolicy); Env.getCurrentEnv().getEditLog().logCreateIndexPolicy(indexPolicy); } finally { writeUnlock(); @@ -231,7 +363,7 @@ public void createIndexPolicy(boolean ifNotExists, String policyName, public IndexPolicy getPolicyByName(String name) { readLock(); try { - return nameToIndexPolicy.get(normalizeKey(name)); + return getPolicyByNameLocked(name); } finally { readUnlock(); } @@ -334,6 +466,17 @@ private void validateNormalizerProperties(Map properties) throws private void validatePolicyReference(String name, IndexPolicyTypeEnum expectedType) throws DdlException { String normalizedName = normalizeKey(name); + IndexPolicy policy = getPolicyByName(name); + if (policy != null) { + if (policy.getType() != expectedType) { + throw new DdlException("Referenced policy '" + name + "' is of type " + + policy.getType() + " but expected " + expectedType); + } + if (policy.isInvalid()) { + throw new DdlException("Referenced " + expectedType + " policy '" + name + "' is invalid"); + } + return; + } if (expectedType == IndexPolicyTypeEnum.TOKENIZER && IndexPolicy.BUILTIN_TOKENIZERS.contains(normalizedName)) { return; @@ -346,18 +489,7 @@ private void validatePolicyReference(String name, IndexPolicyTypeEnum expectedTy && IndexPolicy.BUILTIN_CHAR_FILTERS.contains(normalizedName)) { return; } - - IndexPolicy policy = getPolicyByName(name); - if (policy == null) { - throw new DdlException("Referenced " + expectedType + " policy '" + name + "' does not exist"); - } - if (policy.getType() != expectedType) { - throw new DdlException("Referenced policy '" + name + "' is of type " - + policy.getType() + " but expected " + expectedType); - } - if (policy.isInvalid()) { - throw new DdlException("Referenced " + expectedType + " policy '" + name + "' is invalid"); - } + throw new DdlException("Referenced " + expectedType + " policy '" + name + "' does not exist"); } private void validateTokenizerProperties(Map properties) throws DdlException { @@ -394,6 +526,10 @@ private void validateTokenizerProperties(Map properties) throws case "basic": validator = new BasicTokenizerValidator(); break; + case "ik_smart": + case "ik_max_word": + validator = new NoOperationValidator(type + " tokenizer"); + break; default: Set userFacingTypes = IndexPolicy.BUILTIN_TOKENIZERS.stream() .filter(t -> !t.equals("empty")) @@ -467,10 +603,9 @@ private void validateCharFilterProperties(Map properties) throws public void dropIndexPolicy(boolean isIfExists, String indexPolicyName, IndexPolicyTypeEnum type) throws DdlException, AnalysisException { - String normalizedName = normalizeKey(indexPolicyName); writeLock(); try { - IndexPolicy policyToDrop = nameToIndexPolicy.get(normalizedName); + IndexPolicy policyToDrop = getPolicyByNameLocked(indexPolicyName); if (policyToDrop == null) { if (isIfExists) { return; @@ -482,9 +617,9 @@ public void dropIndexPolicy(boolean isIfExists, String indexPolicyName, + indexPolicyName + "' by DROP " + type + " statement."); } if (policyToDrop.getType() == IndexPolicyTypeEnum.ANALYZER) { - checkAnalyzerNotUsedByIndex(policyToDrop.getName()); + checkAnalyzerNotUsedByIndex(policyToDrop); } else if (policyToDrop.getType() == IndexPolicyTypeEnum.NORMALIZER) { - checkNormalizerNotUsedByIndex(policyToDrop.getName()); + checkNormalizerNotUsedByIndex(policyToDrop); } if (policyToDrop.getType() == IndexPolicyTypeEnum.TOKENIZER || policyToDrop.getType() == IndexPolicyTypeEnum.TOKEN_FILTER @@ -493,7 +628,7 @@ public void dropIndexPolicy(boolean isIfExists, String indexPolicyName, } long id = policyToDrop.getId(); idToIndexPolicy.remove(id); - nameToIndexPolicy.remove(normalizedName); + unregisterPolicyNameLocked(policyToDrop); Env.getCurrentEnv().getEditLog().logDropIndexPolicy(new DropIndexPolicyLog(id)); } finally { writeUnlock(); @@ -508,11 +643,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 * @throws DdlException if the analyzer is in use by any index */ - private void checkAnalyzerNotUsedByIndex(String analyzerName) throws DdlException { - String normalizedName = normalizeKey(analyzerName); + private void checkAnalyzerNotUsedByIndex(IndexPolicy analyzer) throws DdlException { List databases = Env.getCurrentEnv().getInternalCatalog().getDbs(); for (Database db : databases) { List tables = db.getTables(); @@ -523,9 +657,8 @@ private void checkAnalyzerNotUsedByIndex(String analyzerName) throws DdlExceptio Map properties = index.getProperties(); String indexAnalyzer = properties == null ? null : properties.get(IndexPolicy.PROP_ANALYZER); - if (indexAnalyzer != null - && normalizedName.equals(normalizeKey(indexAnalyzer))) { - throw new DdlException("the analyzer " + analyzerName + " is used by index: " + if (indexBindsPolicyLocked(indexAnalyzer, IndexPolicy.BUILTIN_ANALYZERS, analyzer)) { + throw new DdlException("the analyzer " + analyzer.getName() + " is used by index: " + index.getIndexName() + " in table: " + db.getFullName() + "." + table.getName()); } @@ -542,11 +675,10 @@ private void checkAnalyzerNotUsedByIndex(String analyzerName) throws DdlExceptio * tables, and indexes. In large-scale clusters with many tables, this can be slow. * Consider maintaining a reverse index (normalizer -> tables) if this becomes a bottleneck. * - * @param normalizerName the normalizer name to check + * @param normalizer the normalizer policy to check * @throws DdlException if the normalizer is in use by any index */ - private void checkNormalizerNotUsedByIndex(String normalizerName) throws DdlException { - String normalizedName = normalizeKey(normalizerName); + private void checkNormalizerNotUsedByIndex(IndexPolicy normalizer) throws DdlException { List databases = Env.getCurrentEnv().getInternalCatalog().getDbs(); for (Database db : databases) { List
tables = db.getTables(); @@ -557,9 +689,8 @@ private void checkNormalizerNotUsedByIndex(String normalizerName) throws DdlExce Map properties = index.getProperties(); String indexNormalizer = properties == null ? null : properties.get(IndexPolicy.PROP_NORMALIZER); - if (indexNormalizer != null - && normalizedName.equals(normalizeKey(indexNormalizer))) { - throw new DdlException("the normalizer " + normalizerName + " is used by index: " + if (indexBindsPolicyLocked(indexNormalizer, IndexPolicy.BUILTIN_NORMALIZERS, normalizer)) { + throw new DdlException("the normalizer " + normalizer.getName() + " is used by index: " + index.getIndexName() + " in table: " + db.getFullName() + "." + table.getName()); } @@ -570,7 +701,6 @@ private void checkNormalizerNotUsedByIndex(String normalizerName) throws DdlExce } private void checkPolicyNotReferenced(IndexPolicy policy) throws DdlException { - String policyName = policy.getName(); IndexPolicyTypeEnum policyType = policy.getType(); for (IndexPolicy otherPolicy : idToIndexPolicy.values()) { @@ -585,28 +715,38 @@ private void checkPolicyNotReferenced(IndexPolicy policy) throws DdlException { if (policyType == IndexPolicyTypeEnum.TOKENIZER && otherType == IndexPolicyTypeEnum.ANALYZER) { String tokenizer = properties.get(IndexPolicy.PROP_TOKENIZER); - if (normalizeKey(policyName).equals(normalizeKey(tokenizer))) { - throw new DdlException("Cannot drop " + policyType + " policy '" + policyName + if (resolvesToPolicyLocked(tokenizer, policy)) { + throw new DdlException("Cannot drop " + policyType + " policy '" + policy.getName() + "' as it is referenced by " + otherType + " policy '" + otherPolicy.getName() + "'"); } } else if (policyType == IndexPolicyTypeEnum.TOKEN_FILTER) { - checkFilterReference(policyName, policyType, otherType, otherPolicy, + checkFilterReference(policy, otherType, otherPolicy, properties.get(IndexPolicy.PROP_TOKEN_FILTER)); } else if (policyType == IndexPolicyTypeEnum.CHAR_FILTER) { - checkFilterReference(policyName, policyType, otherType, otherPolicy, + checkFilterReference(policy, otherType, otherPolicy, properties.get(IndexPolicy.PROP_CHAR_FILTER)); } } } - private void checkFilterReference(String policyName, IndexPolicyTypeEnum policyType, - IndexPolicyTypeEnum referencingType, IndexPolicy referencingPolicy, - String filterList) throws DdlException { + private boolean resolvesToPolicyLocked(String policyName, IndexPolicy expectedPolicy) { + IndexPolicy resolvedPolicy = policyName == null ? null : getPolicyByNameLocked(policyName); + return resolvedPolicy != null && resolvedPolicy.getId() == expectedPolicy.getId(); + } + + // An index's analyzer or normalizer name reaches a policy only when no built-in takes precedence. + private boolean indexBindsPolicyLocked(String name, Set builtins, IndexPolicy expectedPolicy) { + return name != null && resolveTopLevelBuiltinLocked(name, builtins) == null + && resolvesToPolicyLocked(name, expectedPolicy); + } + + private void checkFilterReference(IndexPolicy policy, IndexPolicyTypeEnum referencingType, + IndexPolicy referencingPolicy, String filterList) throws DdlException { if (filterList != null && !filterList.isEmpty()) { for (String filter : filterList.split(",\\s*")) { - if (normalizeKey(policyName).equals(normalizeKey(filter))) { - throw new DdlException("Cannot drop " + policyType + " policy '" + policyName + if (resolvesToPolicyLocked(filter, policy)) { + throw new DdlException("Cannot drop " + policy.getType() + " policy '" + policy.getName() + "' as it is referenced by " + referencingType + " policy '" + referencingPolicy.getName() + "'"); } @@ -638,8 +778,7 @@ public void replayCreateIndexPolicy(IndexPolicy indexPolicy) { try { warnIfUnsupported(indexPolicy); idToIndexPolicy.put(indexPolicy.getId(), indexPolicy); - // Store with normalized key for case-insensitive lookup - nameToIndexPolicy.put(normalizeKey(indexPolicy.getName()), indexPolicy); + registerPolicyNameLocked(indexPolicy); LOG.debug("Replayed index policy: id={}, name={}", indexPolicy.getId(), indexPolicy.getName()); } finally { @@ -656,7 +795,7 @@ public void replayDropIndexPolicy(DropIndexPolicyLog dropLog) { } IndexPolicy indexPolicy = idToIndexPolicy.get(id); idToIndexPolicy.remove(id); - nameToIndexPolicy.remove(normalizeKey(indexPolicy.getName())); + unregisterPolicyNameLocked(indexPolicy); LOG.debug("Replayed drop index policy: {}", indexPolicy.getName()); } finally { writeUnlock(); @@ -677,13 +816,18 @@ public static IndexPolicyMgr read(DataInput in) throws IOException { @Override public void gsonPostProcess() throws IOException { - // Store with normalized key for case-insensitive lookup - nameToIndexPolicy.clear(); - idToIndexPolicy.forEach( - (id, indexPolicy) -> { - warnIfUnsupported(indexPolicy); - nameToIndexPolicy.put(normalizeKey(indexPolicy.getName()), indexPolicy); - }); + writeLock(); + try { + nameToIndexPolicy.clear(); + exactNameToIndexPolicy.clear(); + idToIndexPolicy.forEach( + (id, indexPolicy) -> { + warnIfUnsupported(indexPolicy); + registerPolicyNameLocked(indexPolicy); + }); + } finally { + writeUnlock(); + } } private static void warnIfUnsupported(IndexPolicy indexPolicy) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/ExpressionTranslator.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/ExpressionTranslator.java index 162bece0228660..4d75a50bb27588 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/ExpressionTranslator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/ExpressionTranslator.java @@ -30,6 +30,7 @@ import org.apache.doris.analysis.Expr; import org.apache.doris.analysis.FunctionCallExpr; import org.apache.doris.analysis.FunctionParams; +import org.apache.doris.analysis.InvertedIndexUtil; import org.apache.doris.analysis.IsNullPredicate; import org.apache.doris.analysis.LambdaFunctionCallExpr; import org.apache.doris.analysis.LambdaFunctionExpr; @@ -256,6 +257,9 @@ public Expr visitMatch(Match match, PlanTranslatorContext context) { // down for storage-level index evaluation (fast path). Index invertedIndex = null; String analyzer = match.getAnalyzer().orElse(null); + if (analyzer != null) { + analyzer = InvertedIndexUtil.resolveAnalyzerName(analyzer); + } Column column = slot.getOriginalColumn().orElse(null); OlapTable olapTbl = getOlapTableDirectly(slot); if (column != null && olapTbl != null) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/Match.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/Match.java index 2737ef31eb65b5..a46ae2aa6d3eb2 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/Match.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/Match.java @@ -48,15 +48,13 @@ public Match(List children, String symbol) { * Constructor with analyzer parameter. * @param children child expressions * @param symbol the match operator symbol - * @param analyzer the analyzer name (will be normalized to lowercase) + * @param analyzer the analyzer name, retaining the exact spelling of legacy policies */ public Match(List children, String symbol, String analyzer) { super(children, symbol); - // Normalize analyzer name to lowercase for case-insensitive matching this.analyzer = Optional.ofNullable(analyzer) .map(String::trim) - .filter(s -> !s.isEmpty()) - .map(String::toLowerCase); + .filter(s -> !s.isEmpty()); } /** diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateIndexOp.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateIndexOp.java index 2e448161a50308..f1d1411d6b2d8a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateIndexOp.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateIndexOp.java @@ -18,7 +18,9 @@ package org.apache.doris.nereids.trees.plans.commands.info; import org.apache.doris.alter.AlterOpType; +import org.apache.doris.analysis.InvertedIndexUtil; import org.apache.doris.catalog.Index; +import org.apache.doris.catalog.info.IndexType; import org.apache.doris.catalog.info.TableNameInfo; import org.apache.doris.common.AnalysisException; import org.apache.doris.common.UserException; @@ -78,6 +80,10 @@ public void validate(ConnectContext ctx) throws UserException { } indexDef.validate(); + if (indexDef.getIndexType() == IndexType.INVERTED) { + // Resolve names before duplicate checks and before the catalog index copies the properties. + InvertedIndexUtil.resolvePolicyNames(indexDef.getProperties()); + } index = indexDef.translateToCatalogStyle(); } diff --git a/fe/fe-core/src/test/java/org/apache/doris/alter/SchemaChangeHandlerTest.java b/fe/fe-core/src/test/java/org/apache/doris/alter/SchemaChangeHandlerTest.java index 1be3cca764a911..087b9901e156cc 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/alter/SchemaChangeHandlerTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/alter/SchemaChangeHandlerTest.java @@ -33,6 +33,10 @@ import org.apache.doris.common.DdlException; import org.apache.doris.common.FeConstants; import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.indexpolicy.DropIndexPolicyLog; +import org.apache.doris.indexpolicy.IndexPolicy; +import org.apache.doris.indexpolicy.IndexPolicyMgr; +import org.apache.doris.indexpolicy.IndexPolicyTypeEnum; import org.apache.doris.nereids.StatementContext; import org.apache.doris.nereids.parser.NereidsParser; import org.apache.doris.nereids.trees.plans.commands.AlterTableCommand; @@ -1217,7 +1221,7 @@ public void testAddDuplicateInvertedIndexException() throws Exception { } catch (Exception e) { // Verify the error message contains relevant info Assertions.assertTrue(e.getMessage().contains("INVERTED index for column (error_msg) " - + "with analyzer default analyzer already exists")); + + "with the same analyzer selector already exists")); } addInvertedIndexStmtStr = "alter table test.sc_dup add index idx_error_msg(error_msg), " + "add index idx_error_msg(error_msg)"; @@ -1229,6 +1233,541 @@ public void testAddDuplicateInvertedIndexException() throws Exception { } } + @Test + public void testAddInvertedIndexStoresCanonicalBuiltinAnalyzer() throws Exception { + createAnalyzerAliasTable("sc_ik_alias"); + alterTable("alter table test.sc_ik_alias add index idx_upper(c1) using inverted " + + "properties(\"analyzer\"=\"IK\")", connectContext); + jobSize++; + waitAlterJobDone(Env.getCurrentEnv().getSchemaChangeHandler().getAlterJobsV2()); + + OlapTable tbl = (OlapTable) Env.getCurrentInternalCatalog().getDbOrMetaException("test") + .getTableOrMetaException("sc_ik_alias", Table.TableType.OLAP); + tbl.readLock(); + try { + Assertions.assertEquals(1, tbl.getIndexes().size()); + Assertions.assertEquals("ik", tbl.getIndexes().get(0).getProperties().get("analyzer")); + } finally { + tbl.readUnlock(); + } + expectException("alter table test.sc_ik_alias add index idx_lower(c1) using inverted " + + "properties(\"analyzer\"=\"ik\")", "already exists"); + expectException("alter table test.sc_ik_alias add index idx_c2_lower(c2) using inverted " + + "properties(\"analyzer\"=\"ik\"), add index idx_c2_upper(c2) using inverted " + + "properties(\"analyzer\"=\"IK\")", "already exists"); + + IllegalStateException createError = Assertions.assertThrows(IllegalStateException.class, + () -> executeNereidsSql("CREATE TABLE test.sc_ik_alias_create (k INT, c1 VARCHAR(100),\n" + + "INDEX idx_lower(c1) USING INVERTED PROPERTIES('analyzer' = 'ik'),\n" + + "INDEX idx_upper(c1) USING INVERTED PROPERTIES('analyzer' = 'IK'))\n" + + "DUPLICATE KEY(k) DISTRIBUTED BY HASH(k) BUCKETS 1\n" + + "PROPERTIES ('replication_num' = '1')")); + Assertions.assertTrue(createError.getMessage().contains("cannot have multiple inverted indexes"), + createError.getMessage()); + } + + @Test + public void testAddInvertedIndexRejectsEquivalentComponentAliases() throws Exception { + createAnalyzerAliasTable("sc_component_alias"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + replayAliasPolicy(policyMgr, "alter_ngram_ld", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "ngram", "token_chars", "letter,digit")); + replayAliasPolicy(policyMgr, "alter_ngram_dll", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "ngram", "token_chars", "digit,letter,letter")); + replayAliasPolicy(policyMgr, "alter_ngram_ld_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "alter_ngram_ld")); + replayAliasPolicy(policyMgr, "alter_ngram_dll_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "alter_ngram_dll")); + replayAliasPolicy(policyMgr, "alter_nfd", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "name", "nfd")); + replayAliasPolicy(policyMgr, "alter_nfd_decompose", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "name", "nfd", "mode", "decompose")); + replayAliasPolicy(policyMgr, "alter_nfd_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "standard", "char_filter", "alter_nfd")); + replayAliasPolicy(policyMgr, "alter_nfd_decompose_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "standard", "char_filter", "alter_nfd_decompose")); + + expectException("alter table test.sc_component_alias add index idx_ld(c1) using inverted " + + "properties(\"analyzer\"=\"alter_ngram_ld_analyzer\"), add index idx_dll(c1) using inverted " + + "properties(\"analyzer\"=\"alter_ngram_dll_analyzer\")", "already exists"); + expectException("alter table test.sc_component_alias add index idx_nfd(c2) using inverted " + + "properties(\"analyzer\"=\"alter_nfd_analyzer\"), add index idx_nfd_decompose(c2) using inverted " + + "properties(\"analyzer\"=\"alter_nfd_decompose_analyzer\")", "already exists"); + } + + @Test + public void testAddInvertedIndexRejectsBufferSizeAndCaseFoldAliases() throws Exception { + createAnalyzerAliasTable("sc_fold_alias"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + replayAliasPolicy(policyMgr, "alter_keyword_256", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "keyword", "buffer_size", "256")); + replayAliasPolicy(policyMgr, "alter_keyword_512", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "keyword", "buffer_size", "512")); + replayAliasPolicy(policyMgr, "alter_keyword_256_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "alter_keyword_256")); + replayAliasPolicy(policyMgr, "alter_keyword_512_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "alter_keyword_512")); + replayAliasPolicy(policyMgr, "alter_lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a")); + replayAliasPolicy(policyMgr, "alter_x_to_y", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "x", "replacement", "y")); + replayAliasPolicy(policyMgr, "alter_fold", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer")); + replayAliasPolicy(policyMgr, "alter_x_fold_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "alter_x_to_y,alter_fold")); + replayAliasPolicy(policyMgr, "alter_lower_x_fold_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "alter_lower_a,alter_x_to_y,alter_fold")); + replayAliasPolicy(policyMgr, "alter_keyword_lower_1", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "lowercase")); + replayAliasPolicy(policyMgr, "alter_keyword_lower_2", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "lowercase")); + + expectException("alter table test.sc_fold_alias add index idx_keyword_256(c1) using inverted " + + "properties(\"analyzer\"=\"alter_keyword_256_analyzer\"), add index idx_keyword_512(c1) " + + "using inverted properties(\"analyzer\"=\"alter_keyword_512_analyzer\")", "already exists"); + expectException("alter table test.sc_fold_alias add index idx_x_fold(c2) using inverted " + + "properties(\"analyzer\"=\"alter_x_fold_analyzer\"), add index idx_lower_x_fold(c2) " + + "using inverted properties(\"analyzer\"=\"alter_lower_x_fold_analyzer\")", "already exists"); + expectException("alter table test.sc_fold_alias add index idx_outer_lower(c1) using inverted " + + "properties(\"analyzer\"=\"alter_keyword_lower_1\", \"char_filter_type\"=\"char_replace\", " + + "\"char_filter_pattern\"=\"A\", \"char_filter_replacement\"=\"a\"), " + + "add index idx_plain_lower(c1) using inverted " + + "properties(\"analyzer\"=\"alter_keyword_lower_2\")", "already exists"); + } + + @Test + public void testAddInvertedIndexRejectsBuiltinAnalyzerPipelineAliases() throws Exception { + createAnalyzerAliasTable("sc_builtin_pipeline_alias"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + replayAliasPolicy(policyMgr, "alter_basic_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic", "token_filter", "lowercase")); + replayAliasPolicy(policyMgr, "alter_icu_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "icu", "token_filter", "lowercase")); + replayAliasPolicy(policyMgr, "alter_basic_plain", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic")); + + expectException("alter table test.sc_builtin_pipeline_alias add index idx_builtin_basic(c1) " + + "using inverted properties(\"analyzer\"=\"basic\"), add index idx_custom_basic(c1) " + + "using inverted properties(\"analyzer\"=\"alter_basic_lower\")", "already exists"); + expectException("alter table test.sc_builtin_pipeline_alias add index idx_builtin_icu(c2) " + + "using inverted properties(\"parser\"=\"icu\"), add index idx_custom_icu(c2) " + + "using inverted properties(\"analyzer\"=\"alter_icu_lower\")", "already exists"); + expectException("alter table test.sc_builtin_pipeline_alias add index idx_cased_basic(c1) " + + "using inverted properties(\"analyzer\"=\"basic\", \"lower_case\"=\"false\"), " + + "add index idx_custom_plain(c1) using inverted " + + "properties(\"analyzer\"=\"alter_basic_plain\")", "already exists"); + expectException("alter table test.sc_builtin_pipeline_alias add index idx_standard(c2) " + + "using inverted properties(\"parser\"=\"standard\"), add index idx_unicode(c2) " + + "using inverted properties(\"parser\"=\"unicode\")", "already exists"); + expectException("alter table test.sc_builtin_pipeline_alias add index idx_analyzer_standard(c1) " + + "using inverted properties(\"analyzer\"=\"standard\"), add index idx_analyzer_unicode(c1) " + + "using inverted properties(\"analyzer\"=\"unicode\")", "already exists"); + } + + @Test + public void testAddInvertedIndexRejectsPinyinSettingsBehindDisabledGates() throws Exception { + createAnalyzerAliasTable("sc_pinyin_gate_alias"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + replayAliasPolicy(policyMgr, "alter_py_tf_plain", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin")); + replayAliasPolicy(policyMgr, "alter_py_tf_ascii_in_joined", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_none_chinese_in_joined_full_pinyin", "true")); + replayAliasPolicy(policyMgr, "alter_py_tf_separate", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_none_chinese_together", "false")); + replayAliasPolicy(policyMgr, "alter_py_tf_separate_untokenized", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_none_chinese_together", "false", + "none_chinese_pinyin_tokenize", "false")); + replayAliasPolicy(policyMgr, "alter_py_tk_plain", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "pinyin")); + replayAliasPolicy(policyMgr, "alter_py_tk_ascii_in_joined", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "pinyin", "keep_none_chinese_in_joined_full_pinyin", "true")); + replayAliasPolicy(policyMgr, "alter_py_tk_separate", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "pinyin", "keep_none_chinese_together", "false")); + replayAliasPolicy(policyMgr, "alter_py_tk_separate_untokenized", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "pinyin", "keep_none_chinese_together", "false", + "none_chinese_pinyin_tokenize", "false")); + for (String filter : new String[] {"alter_py_tf_plain", "alter_py_tf_ascii_in_joined", + "alter_py_tf_separate", "alter_py_tf_separate_untokenized"}) { + replayAliasPolicy(policyMgr, filter + "_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", filter)); + } + for (String tokenizer : new String[] {"alter_py_tk_plain", "alter_py_tk_ascii_in_joined", + "alter_py_tk_separate", "alter_py_tk_separate_untokenized"}) { + replayAliasPolicy(policyMgr, tokenizer + "_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", tokenizer)); + } + + expectException("alter table test.sc_pinyin_gate_alias add index idx_tf_plain(c1) using inverted " + + "properties(\"analyzer\"=\"alter_py_tf_plain_analyzer\"), add index idx_tf_ascii(c1) " + + "using inverted properties(\"analyzer\"=\"alter_py_tf_ascii_in_joined_analyzer\")", + "already exists"); + expectException("alter table test.sc_pinyin_gate_alias add index idx_tf_separate(c1) using inverted " + + "properties(\"analyzer\"=\"alter_py_tf_separate_analyzer\"), " + + "add index idx_tf_separate_untokenized(c1) using inverted " + + "properties(\"analyzer\"=\"alter_py_tf_separate_untokenized_analyzer\")", "already exists"); + expectException("alter table test.sc_pinyin_gate_alias add index idx_tk_plain(c2) using inverted " + + "properties(\"analyzer\"=\"alter_py_tk_plain_analyzer\"), add index idx_tk_ascii(c2) " + + "using inverted properties(\"analyzer\"=\"alter_py_tk_ascii_in_joined_analyzer\")", + "already exists"); + expectException("alter table test.sc_pinyin_gate_alias add index idx_tk_separate(c2) using inverted " + + "properties(\"analyzer\"=\"alter_py_tk_separate_analyzer\"), " + + "add index idx_tk_separate_untokenized(c2) using inverted " + + "properties(\"analyzer\"=\"alter_py_tk_separate_untokenized_analyzer\")", "already exists"); + } + + @Test + public void testAddInvertedIndexRejectsFoldAliasesThroughEmptySetNormalizerAndTransparentFilters() + throws Exception { + createAnalyzerAliasTable("sc_fold2_alias"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + replayAliasPolicy(policyMgr, "alter_fold2_lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a")); + replayAliasPolicy(policyMgr, "alter_fold2_empty_set", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[]")); + replayAliasPolicy(policyMgr, "alter_fold2_empty_only", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "alter_fold2_empty_set")); + replayAliasPolicy(policyMgr, "alter_fold2_lower_empty", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "alter_fold2_lower_a,alter_fold2_empty_set")); + replayAliasPolicy(policyMgr, "alter_fold2_norm_lower_1", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase")); + replayAliasPolicy(policyMgr, "alter_fold2_norm_lower_2", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase")); + replayAliasPolicy(policyMgr, "alter_fold2_ascii", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "asciifolding")); + replayAliasPolicy(policyMgr, "alter_fold2_ascii_lower_1", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "alter_fold2_ascii,lowercase")); + replayAliasPolicy(policyMgr, "alter_fold2_ascii_lower_2", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "alter_fold2_ascii,lowercase")); + + expectException("alter table test.sc_fold2_alias add index idx_empty_only(c1) using inverted " + + "properties(\"analyzer\"=\"alter_fold2_empty_only\"), add index idx_lower_empty(c1) " + + "using inverted properties(\"analyzer\"=\"alter_fold2_lower_empty\")", "already exists"); + expectException("alter table test.sc_fold2_alias add index idx_outer_norm_lower(c1) using inverted " + + "properties(\"normalizer\"=\"alter_fold2_norm_lower_1\", \"char_filter_type\"=\"char_replace\", " + + "\"char_filter_pattern\"=\"A\", \"char_filter_replacement\"=\"a\"), " + + "add index idx_norm_lower(c1) using inverted " + + "properties(\"normalizer\"=\"alter_fold2_norm_lower_2\")", "already exists"); + expectException("alter table test.sc_fold2_alias add index idx_outer_ascii_lower(c2) using inverted " + + "properties(\"analyzer\"=\"alter_fold2_ascii_lower_1\", \"char_filter_type\"=\"char_replace\", " + + "\"char_filter_pattern\"=\"A\", \"char_filter_replacement\"=\"a\"), " + + "add index idx_ascii_lower(c2) using inverted " + + "properties(\"analyzer\"=\"alter_fold2_ascii_lower_2\")", "already exists"); + } + + @Test + public void testAddInvertedIndexRejectsReplacementByteFilteredFoldAndBuiltinNormalizerAliases() + throws Exception { + createAnalyzerAliasTable("sc_fold3_alias"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + replayAliasPolicy(policyMgr, "alter_fold3_lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a")); + replayAliasPolicy(policyMgr, "alter_fold3_x_to_upper_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "Ax", "replacement", "A")); + replayAliasPolicy(policyMgr, "alter_fold3_fold", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer")); + replayAliasPolicy(policyMgr, "alter_fold3_fold_upper_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[A]")); + replayAliasPolicy(policyMgr, "alter_fold3_x_upper_fold", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "alter_fold3_x_to_upper_a,alter_fold3_fold")); + replayAliasPolicy(policyMgr, "alter_fold3_lower_x_upper_fold", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", + "char_filter", "alter_fold3_lower_a,alter_fold3_x_to_upper_a,alter_fold3_fold")); + replayAliasPolicy(policyMgr, "alter_fold3_fold_upper_a_only", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "alter_fold3_fold_upper_a")); + replayAliasPolicy(policyMgr, "alter_fold3_wd_b_digit", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "word_delimiter", "type_table", "[b => DIGIT]")); + replayAliasPolicy(policyMgr, "alter_fold3_wd_a_lower_b_digit", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "word_delimiter", "type_table", "[a => LOWER],[b => DIGIT]")); + replayAliasPolicy(policyMgr, "alter_fold3_wd_b_digit_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "alter_fold3_wd_b_digit")); + replayAliasPolicy(policyMgr, "alter_fold3_wd_a_lower_b_digit_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "alter_fold3_wd_a_lower_b_digit")); + replayAliasPolicy(policyMgr, "alter_fold3_norm_lower", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase")); + + expectException("alter table test.sc_fold3_alias add index idx_x_upper_fold(c1) using inverted " + + "properties(\"analyzer\"=\"alter_fold3_x_upper_fold\"), add index idx_lower_x_upper_fold(c1) " + + "using inverted properties(\"analyzer\"=\"alter_fold3_lower_x_upper_fold\")", "already exists"); + expectException("alter table test.sc_fold3_alias add index idx_outer_fold_upper_a(c2) using inverted " + + "properties(\"analyzer\"=\"alter_fold3_fold_upper_a_only\", \"char_filter_type\"=\"char_replace\", " + + "\"char_filter_pattern\"=\"A\", \"char_filter_replacement\"=\"a\"), " + + "add index idx_fold_upper_a(c2) using inverted " + + "properties(\"analyzer\"=\"alter_fold3_fold_upper_a_only\")", "already exists"); + expectException("alter table test.sc_fold3_alias add index idx_wd_b_digit(c1) using inverted " + + "properties(\"analyzer\"=\"alter_fold3_wd_b_digit_analyzer\"), add index idx_wd_a_lower_b_digit(c1) " + + "using inverted properties(\"analyzer\"=\"alter_fold3_wd_a_lower_b_digit_analyzer\")", + "already exists"); + expectException("alter table test.sc_fold3_alias add index idx_builtin_lowercase(c2) using inverted " + + "properties(\"normalizer\"=\"lowercase\"), add index idx_custom_lowercase(c2) using inverted " + + "properties(\"normalizer\"=\"alter_fold3_norm_lower\")", "already exists"); + } + + @Test + public void testMixedCaseBuiltinNormalizerIgnoresNormalizedLegacyPolicyInDdl() throws Exception { + createAnalyzerAliasTable("sc_mixed_lowercase"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + IndexPolicy legacy = replayAliasPolicy(policyMgr, "LOWERCASE", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding")); + try { + alterTable("alter table test.sc_mixed_lowercase add index idx_mixed(c1) using inverted " + + "properties(\"normalizer\"=\"LowerCase\")", connectContext); + jobSize++; + waitAlterJobDone(Env.getCurrentEnv().getSchemaChangeHandler().getAlterJobsV2()); + Assertions.assertEquals("lowercase", storedIndexProperty("sc_mixed_lowercase", "idx_mixed", "normalizer")); + expectException("alter table test.sc_mixed_lowercase add index idx_builtin(c1) using inverted " + + "properties(\"normalizer\"=\"lowercase\")", "already exists"); + + executeNereidsSql("CREATE TABLE test.sc_mixed_lowercase_create (k INT, c1 VARCHAR(100),\n" + + "INDEX idx_mixed(c1) USING INVERTED PROPERTIES('normalizer' = 'LowerCase'))\n" + + "DUPLICATE KEY(k) DISTRIBUTED BY HASH(k) BUCKETS 1\n" + + "PROPERTIES ('replication_num' = '1')"); + Assertions.assertEquals("lowercase", + storedIndexProperty("sc_mixed_lowercase_create", "idx_mixed", "normalizer")); + } finally { + policyMgr.replayDropIndexPolicy(new DropIndexPolicyLog(legacy.getId())); + } + } + + @Test + public void testCanonicalBuiltinAnalyzerIgnoresExactLegacyPolicyInDdl() throws Exception { + createAnalyzerAliasTable("sc_exact_ik_policy"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + IndexPolicy legacy = replayAliasPolicy(policyMgr, "ik", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "keyword")); + try { + alterTable("alter table test.sc_exact_ik_policy add index idx_ik(c1) using inverted " + + "properties(\"analyzer\"=\"ik\")", connectContext); + jobSize++; + waitAlterJobDone(Env.getCurrentEnv().getSchemaChangeHandler().getAlterJobsV2()); + Assertions.assertEquals("ik", storedIndexProperty("sc_exact_ik_policy", "idx_ik", "analyzer")); + + executeNereidsSql("CREATE TABLE test.sc_exact_ik_policy_create (k INT, c1 VARCHAR(100),\n" + + "INDEX idx_ik(c1) USING INVERTED PROPERTIES('analyzer' = 'ik'))\n" + + "DUPLICATE KEY(k) DISTRIBUTED BY HASH(k) BUCKETS 1\n" + + "PROPERTIES ('replication_num' = '1')"); + Assertions.assertEquals("ik", storedIndexProperty("sc_exact_ik_policy_create", "idx_ik", "analyzer")); + } finally { + policyMgr.replayDropIndexPolicy(new DropIndexPolicyLog(legacy.getId())); + } + } + + @Test + public void testAddInvertedIndexUsesExactLegacyLowercaseNormalizerIdentity() throws Exception { + createAnalyzerAliasTable("sc_exact_lowercase"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + List replayed = List.of( + replayAliasPolicy(policyMgr, "lowercase", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding")), + replayAliasPolicy(policyMgr, "alter_exact_norm_ascii", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding"))); + try { + expectException("alter table test.sc_exact_lowercase add index idx_legacy_lowercase(c1) " + + "using inverted properties(\"normalizer\"=\"lowercase\"), add index idx_ascii(c1) " + + "using inverted properties(\"normalizer\"=\"alter_exact_norm_ascii\")", "already exists"); + } finally { + for (IndexPolicy policy : replayed) { + policyMgr.replayDropIndexPolicy(new DropIndexPolicyLog(policy.getId())); + } + } + } + + @Test + public void testMixedCaseNormalizerKeepsBuiltinBindingWhenExactPolicyShadowsIt() throws Exception { + createAnalyzerAliasTable("sc_shadowed_lowercase"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + List replayed = List.of( + replayAliasPolicy(policyMgr, "lowercase", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding")), + replayAliasPolicy(policyMgr, "alter_shadow_norm_lower", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase"))); + try { + alterTable("alter table test.sc_shadowed_lowercase add index idx_mixed(c1) using inverted " + + "properties(\"normalizer\"=\"LowerCase\")", connectContext); + jobSize++; + waitAlterJobDone(Env.getCurrentEnv().getSchemaChangeHandler().getAlterJobsV2()); + // Canonicalizing to "lowercase" would make BE pick the shadowing policy instead. + Assertions.assertEquals("LowerCase", + storedIndexProperty("sc_shadowed_lowercase", "idx_mixed", "normalizer")); + expectException("alter table test.sc_shadowed_lowercase add index idx_equivalent(c1) " + + "using inverted properties(\"normalizer\"=\"alter_shadow_norm_lower\")", "already exists"); + + executeNereidsSql("CREATE TABLE test.sc_shadowed_lowercase_create (k INT, c1 VARCHAR(100),\n" + + "INDEX idx_mixed(c1) USING INVERTED PROPERTIES('normalizer' = 'LowerCase'),\n" + + "INDEX idx_legacy(c1) USING INVERTED PROPERTIES('normalizer' = 'lowercase'))\n" + + "DUPLICATE KEY(k) DISTRIBUTED BY HASH(k) BUCKETS 1\n" + + "PROPERTIES ('replication_num' = '1')"); + Assertions.assertEquals("LowerCase", + storedIndexProperty("sc_shadowed_lowercase_create", "idx_mixed", "normalizer")); + Assertions.assertEquals("lowercase", + storedIndexProperty("sc_shadowed_lowercase_create", "idx_legacy", "normalizer")); + } finally { + for (IndexPolicy policy : replayed) { + policyMgr.replayDropIndexPolicy(new DropIndexPolicyLog(policy.getId())); + } + } + } + + @Test + public void testNormalizerNamedAfterBuiltinAnalyzerIsRejectedInDdl() throws Exception { + createAnalyzerAliasTable("sc_unreachable_normalizer"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + List replayed = List.of( + replayAliasPolicy(policyMgr, "ik", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding")), + replayAliasPolicy(policyMgr, "alter_unreachable_norm_ascii", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding"))); + try { + expectException("alter table test.sc_unreachable_normalizer add index idx_ik(c1) using inverted " + + "properties(\"normalizer\"=\"ik\")", "built-in analyzer"); + Exception createError = Assertions.assertThrows(Exception.class, + () -> executeNereidsSql("CREATE TABLE test.sc_unreachable_normalizer_create " + + "(k INT, c1 VARCHAR(100),\n" + + "INDEX idx_ik(c1) USING INVERTED PROPERTIES('normalizer' = 'ik'))\n" + + "DUPLICATE KEY(k) DISTRIBUTED BY HASH(k) BUCKETS 1\n" + + "PROPERTIES ('replication_num' = '1')")); + Assertions.assertTrue(createError.getMessage().contains("built-in analyzer"), createError.getMessage()); + + alterTable("alter table test.sc_unreachable_normalizer add index idx_ascii(c1) using inverted " + + "properties(\"normalizer\"=\"alter_unreachable_norm_ascii\")", connectContext); + jobSize++; + waitAlterJobDone(Env.getCurrentEnv().getSchemaChangeHandler().getAlterJobsV2()); + Assertions.assertEquals("alter_unreachable_norm_ascii", + storedIndexProperty("sc_unreachable_normalizer", "idx_ascii", "normalizer")); + + executeNereidsSql("CREATE TABLE test.sc_unreachable_normalizer_create (k INT, c1 VARCHAR(100),\n" + + "INDEX idx_ascii(c1) USING INVERTED " + + "PROPERTIES('normalizer' = 'alter_unreachable_norm_ascii'))\n" + + "DUPLICATE KEY(k) DISTRIBUTED BY HASH(k) BUCKETS 1\n" + + "PROPERTIES ('replication_num' = '1')"); + Assertions.assertEquals("alter_unreachable_norm_ascii", + storedIndexProperty("sc_unreachable_normalizer_create", "idx_ascii", "normalizer")); + } finally { + for (IndexPolicy policy : replayed) { + policyMgr.replayDropIndexPolicy(new DropIndexPolicyLog(policy.getId())); + } + } + } + + @Test + public void testAddInvertedIndexRejectsRedundantTokenCharAndReverseCaseAliases() throws Exception { + createAnalyzerAliasTable("sc_fold4_alias"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + List replayed = Lists.newArrayList( + replayAliasPolicy(policyMgr, "alter_fold4_lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a")), + replayAliasPolicy(policyMgr, "alter_fold4_upper_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "a", "replacement", "A")), + replayAliasPolicy(policyMgr, "alter_fold4_ngram_letter", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "ngram", "token_chars", "letter")), + replayAliasPolicy(policyMgr, "alter_fold4_ngram_letter_a", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "ngram", "token_chars", "letter,custom", "custom_token_chars", "A")), + replayAliasPolicy(policyMgr, "alter_fold4_group_letter", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "char_group", "tokenize_on_chars", "[letter]")), + replayAliasPolicy(policyMgr, "alter_fold4_group_letter_a", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "char_group", "tokenize_on_chars", "[letter],[A]"))); + String[][] analyzers = { + {"alter_fold4_ngram_plain", "alter_fold4_ngram_letter", "alter_fold4_lower_a"}, + {"alter_fold4_ngram_custom_a", "alter_fold4_ngram_letter_a", "alter_fold4_lower_a"}, + {"alter_fold4_group_plain", "alter_fold4_group_letter", "alter_fold4_lower_a"}, + {"alter_fold4_group_literal_a", "alter_fold4_group_letter_a", "alter_fold4_lower_a"}, + {"alter_fold4_keyword_lower", "keyword", null}, + {"alter_fold4_upper_keyword_lower", "keyword", "alter_fold4_upper_a"}}; + for (String[] analyzer : analyzers) { + Map properties = Maps.newHashMap( + Map.of("tokenizer", analyzer[1], "token_filter", "lowercase")); + if (analyzer[2] != null) { + properties.put("char_filter", analyzer[2]); + } + replayed.add(replayAliasPolicy(policyMgr, analyzer[0], IndexPolicyTypeEnum.ANALYZER, properties)); + } + try { + Assertions.assertAll( + () -> expectException("alter table test.sc_fold4_alias add index idx_ngram_plain(c1) " + + "using inverted properties(\"analyzer\"=\"alter_fold4_ngram_plain\"), " + + "add index idx_ngram_custom_a(c1) " + + "using inverted properties(\"analyzer\"=\"alter_fold4_ngram_custom_a\")", + "already exists"), + () -> expectException("alter table test.sc_fold4_alias add index idx_group_plain(c2) " + + "using inverted properties(\"analyzer\"=\"alter_fold4_group_plain\"), " + + "add index idx_group_literal_a(c2) " + + "using inverted properties(\"analyzer\"=\"alter_fold4_group_literal_a\")", + "already exists"), + () -> expectException("alter table test.sc_fold4_alias add index idx_keyword_lower(c2) " + + "using inverted properties(\"analyzer\"=\"alter_fold4_keyword_lower\"), " + + "add index idx_upper_keyword_lower(c2) " + + "using inverted properties(\"analyzer\"=\"alter_fold4_upper_keyword_lower\")", + "already exists")); + } finally { + for (IndexPolicy policy : replayed) { + policyMgr.replayDropIndexPolicy(new DropIndexPolicyLog(policy.getId())); + } + } + } + + @Test + public void testAddInvertedIndexRejectsAdjacentDuplicateIdempotentFilterAliases() throws Exception { + createAnalyzerAliasTable("sc_dup_filter_alias"); + IndexPolicyMgr policyMgr = Env.getCurrentEnv().getIndexPolicyMgr(); + List replayed = Lists.newArrayList( + replayAliasPolicy(policyMgr, "alter_dup_dash", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "-", "replacement", " ")), + replayAliasPolicy(policyMgr, "alter_dup_icu_once", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "icu_normalizer")), + replayAliasPolicy(policyMgr, "alter_dup_icu_twice", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "icu_normalizer,icu_normalizer")), + replayAliasPolicy(policyMgr, "alter_dup_dash_once", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "alter_dup_dash")), + replayAliasPolicy(policyMgr, "alter_dup_dash_twice", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "alter_dup_dash,alter_dup_dash"))); + try { + Assertions.assertAll( + () -> expectException("alter table test.sc_dup_filter_alias add index idx_icu_once(c1) " + + "using inverted properties(\"analyzer\"=\"alter_dup_icu_once\"), " + + "add index idx_icu_twice(c1) " + + "using inverted properties(\"analyzer\"=\"alter_dup_icu_twice\")", + "already exists"), + () -> expectException("alter table test.sc_dup_filter_alias add index idx_dash_once(c2) " + + "using inverted properties(\"analyzer\"=\"alter_dup_dash_once\"), " + + "add index idx_dash_twice(c2) " + + "using inverted properties(\"analyzer\"=\"alter_dup_dash_twice\")", + "already exists")); + } finally { + for (IndexPolicy policy : replayed) { + policyMgr.replayDropIndexPolicy(new DropIndexPolicyLog(policy.getId())); + } + } + } + + private static String storedIndexProperty(String tableName, String indexName, String key) throws Exception { + OlapTable tbl = (OlapTable) Env.getCurrentInternalCatalog().getDbOrMetaException("test") + .getTableOrMetaException(tableName, Table.TableType.OLAP); + tbl.readLock(); + try { + return tbl.getIndexes().stream() + .filter(index -> index.getIndexName().equals(indexName)) + .findFirst() + .orElseThrow() + .getProperties() + .get(key); + } finally { + tbl.readUnlock(); + } + } + + private void createAnalyzerAliasTable(String tableName) throws Exception { + createTable("CREATE TABLE IF NOT EXISTS test." + tableName + + " (k INT, c1 VARCHAR(100), c2 VARCHAR(100))\n" + + "DUPLICATE KEY(k) DISTRIBUTED BY HASH(k) BUCKETS 1\n" + + "PROPERTIES ('replication_num' = '1', 'light_schema_change' = 'true');"); + } + + private static IndexPolicy replayAliasPolicy(IndexPolicyMgr policyMgr, String name, IndexPolicyTypeEnum type, + Map properties) { + IndexPolicy policy = new IndexPolicy(Env.getCurrentEnv().getNextId(), name, type, properties); + policyMgr.replayCreateIndexPolicy(policy); + return policy; + } + private void alterTable(String sql, ConnectContext connectContext) throws Exception { NereidsParser nereidsParser = new NereidsParser(); LogicalPlan parsed = nereidsParser.parseSingle(sql); diff --git a/fe/fe-core/src/test/java/org/apache/doris/analysis/InvertedIndexPropertiesTest.java b/fe/fe-core/src/test/java/org/apache/doris/analysis/InvertedIndexPropertiesTest.java index e439360a02616d..3cf1d5d9c57833 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/analysis/InvertedIndexPropertiesTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/analysis/InvertedIndexPropertiesTest.java @@ -17,22 +17,967 @@ package org.apache.doris.analysis; +import org.apache.doris.catalog.Column; import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.Function.NullableMode; +import org.apache.doris.catalog.Index; +import org.apache.doris.catalog.OlapTable; import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.catalog.Type; +import org.apache.doris.catalog.info.IndexType; import org.apache.doris.common.AnalysisException; +import org.apache.doris.indexpolicy.IndexPolicy; import org.apache.doris.indexpolicy.IndexPolicyMgr; +import org.apache.doris.indexpolicy.IndexPolicyTypeEnum; +import org.apache.doris.nereids.trees.expressions.MatchAny; +import org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral; +import org.apache.doris.nereids.trees.plans.commands.info.IndexDefinition; +import org.apache.doris.nereids.types.StringType; +import org.apache.doris.thrift.TExprNode; import org.apache.doris.thrift.TInvertedIndexFileStorageFormat; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import org.mockito.MockedStatic; import org.mockito.Mockito; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; public class InvertedIndexPropertiesTest { + @Test + public void testRejectsTypeOnlyAndExplicitDefaultPinyinAnalyzers() { + IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class); + Mockito.when(policyMgr.getPolicyByName("pinyin_type_only")).thenReturn(new IndexPolicy( + 1, "pinyin_type_only", IndexPolicyTypeEnum.TOKEN_FILTER, Map.of("type", "pinyin"))); + Mockito.when(policyMgr.getPolicyByName("pinyin_defaults")).thenReturn(new IndexPolicy( + 2, "pinyin_defaults", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_first_letter", "true", + "keep_full_pinyin", "true", "keep_original", "false", + "ignore_pinyin_offset", "true", "limit_first_letter_length", "16"))); + Mockito.when(policyMgr.getPolicyByName("type_only_analyzer")).thenReturn(new IndexPolicy( + 3, "type_only_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "pinyin_type_only"))); + Mockito.when(policyMgr.getPolicyByName("defaulted_analyzer")).thenReturn(new IndexPolicy( + 4, "defaulted_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "pinyin_defaults"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + IndexDefinition typeOnly = new IndexDefinition("idx_type_only", false, List.of("content"), + "INVERTED", Map.of("analyzer", "type_only_analyzer"), ""); + IndexDefinition explicitDefaults = new IndexDefinition("idx_explicit_defaults", false, + List.of("content"), "INVERTED", Map.of("analyzer", "defaulted_analyzer"), ""); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of(typeOnly, explicitDefaults))); + } + } + + @Test + public void testCreateTableRejectsEquivalentCanonicalComponentSettings() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(1, "basic_ab", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "basic", "extra_chars", "ab"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(2, "basic_baba", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "basic", "extra_chars", "baba"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(3, "basic_ab_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic_ab"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(4, "basic_baba_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic_baba"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(5, "pinyin_default", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(6, "pinyin_fixed", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "fixed_pinyin_offset", "true"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(7, "pinyin_default_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "pinyin_default"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(8, "pinyin_fixed_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "pinyin_fixed"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(9, "icu_default", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(10, "icu_empty", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[]"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(11, "icu_default_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "standard", "char_filter", "icu_default"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(12, "icu_empty_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "standard", "char_filter", "icu_empty"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_basic_ab", "basic_ab_analyzer"), + invertedIndexDefinition("idx_basic_baba", "basic_baba_analyzer")))), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_pinyin_default", "pinyin_default_analyzer"), + invertedIndexDefinition("idx_pinyin_fixed", "pinyin_fixed_analyzer")))), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_icu_default", "icu_default_analyzer"), + invertedIndexDefinition("idx_icu_empty", "icu_empty_analyzer"))))); + } + } + + @Test + public void testCreateTableRejectsReorderedCollectionAndIneffectiveModeAliases() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + long id = 100; + String[][] aliases = { + {"ngram_ld", "TOKENIZER", "type=ngram;token_chars=letter,digit"}, + {"ngram_dll", "TOKENIZER", "type=ngram;token_chars=digit,letter,letter"}, + {"edge_ab", "TOKENIZER", "type=edge_ngram;token_chars=letter,custom;custom_token_chars=ab"}, + {"edge_bba", "TOKENIZER", "type=edge_ngram;token_chars=custom,letter;custom_token_chars=bba"}, + {"group_ab", "TOKENIZER", "type=char_group;tokenize_on_chars=[a],[b]"}, + {"group_bba", "TOKENIZER", "type=char_group;tokenize_on_chars=[b],[a],[b]"}, + {"protect_ab", "TOKEN_FILTER", "type=word_delimiter;protected_words=foo,bar"}, + {"protect_bba", "TOKEN_FILTER", "type=word_delimiter;protected_words=bar,foo,bar"}, + {"types_ab", "TOKEN_FILTER", "type=word_delimiter;type_table=[a => DIGIT],[b => ALPHA]"}, + {"types_bba", "TOKEN_FILTER", "type=word_delimiter;type_table=[b => ALPHA],[a => ALPHA],[a => DIGIT]"}, + {"nfd_default", "CHAR_FILTER", "type=icu_normalizer;name=nfd"}, + {"nfd_decompose", "CHAR_FILTER", "type=icu_normalizer;name=nfd;mode=decompose"}}; + for (String[] alias : aliases) { + Map properties = new HashMap<>(); + for (String entry : alias[2].split(";")) { + String[] keyValue = entry.split("=", 2); + properties.put(keyValue[0], keyValue[1]); + } + IndexPolicyTypeEnum type = IndexPolicyTypeEnum.valueOf(alias[1]); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(id++, alias[0], type, properties)); + String componentKey = type == IndexPolicyTypeEnum.TOKENIZER ? "tokenizer" + : type == IndexPolicyTypeEnum.TOKEN_FILTER ? "token_filter" : "char_filter"; + Map analyzer = new HashMap<>(); + analyzer.put("tokenizer", "standard"); + analyzer.put(componentKey, alias[0]); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + id++, alias[0] + "_analyzer", IndexPolicyTypeEnum.ANALYZER, analyzer)); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + for (int i = 0; i < aliases.length; i += 2) { + String left = aliases[i][0]; + String right = aliases[i + 1][0]; + Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_" + left, left + "_analyzer"), + invertedIndexDefinition("idx_" + right, right + "_analyzer"))), + left + " and " + right + " must share one analyzer identity"); + } + } + } + + @Test + public void testCreateTableRejectsDefaultRestatingAndCoveredComponentAliases() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + long id = 200; + String[][] aliases = { + {"py_tk_plain", "TOKENIZER", "type=pinyin"}, + {"py_tk_untrimmed", "TOKENIZER", "type=pinyin;trim_whitespace=false"}, + {"py_tf_joined", "TOKEN_FILTER", "type=pinyin;keep_first_letter=false;keep_full_pinyin=false;" + + "keep_none_chinese=false;keep_joined_full_pinyin=true"}, + {"py_tf_joined_dedup", "TOKEN_FILTER", "type=pinyin;keep_first_letter=false;keep_full_pinyin=false;" + + "keep_none_chinese=false;keep_joined_full_pinyin=true;remove_duplicated_term=true"}, + {"basic_plain", "TOKENIZER", "type=basic"}, + {"basic_alnum", "TOKENIZER", "type=basic;extra_chars=A0"}, + {"ngram_letter", "TOKENIZER", "type=ngram;token_chars=letter"}, + {"ngram_letter_custom_a", "TOKENIZER", "type=ngram;token_chars=letter,custom;custom_token_chars=A"}, + {"group_letter", "TOKENIZER", "type=char_group;tokenize_on_chars=[letter]"}, + {"group_letter_a", "TOKENIZER", "type=char_group;tokenize_on_chars=[letter],[A]"}, + {"wd_b_digit", "TOKEN_FILTER", "type=word_delimiter;type_table=[b => DIGIT]"}, + {"wd_a_lower_b_digit", "TOKEN_FILTER", "type=word_delimiter;type_table=[a => LOWER],[b => DIGIT]"}}; + for (String[] alias : aliases) { + Map properties = new HashMap<>(); + for (String entry : alias[2].split(";")) { + String[] keyValue = entry.split("=", 2); + properties.put(keyValue[0], keyValue[1]); + } + IndexPolicyTypeEnum type = IndexPolicyTypeEnum.valueOf(alias[1]); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(id++, alias[0], type, properties)); + Map analyzer = new HashMap<>(); + analyzer.put("tokenizer", type == IndexPolicyTypeEnum.TOKENIZER ? alias[0] : "keyword"); + if (type == IndexPolicyTypeEnum.TOKEN_FILTER) { + analyzer.put("token_filter", alias[0]); + } + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + id++, alias[0] + "_analyzer", IndexPolicyTypeEnum.ANALYZER, analyzer)); + } + policyMgr.replayCreateIndexPolicy(new IndexPolicy(id++, "custom_lowercase", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + for (int i = 0; i < aliases.length; i += 2) { + String left = aliases[i][0]; + String right = aliases[i + 1][0]; + Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_" + left, left + "_analyzer"), + invertedIndexDefinition("idx_" + right, right + "_analyzer"))), + left + " and " + right + " must share one analyzer identity"); + } + Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedNormalizerIndexDefinition("idx_builtin_lowercase", "lowercase"), + invertedNormalizerIndexDefinition("idx_custom_lowercase", "custom_lowercase"))), + "the built-in lowercase normalizer must share the identity of its custom equivalent"); + } + } + + private static IndexDefinition invertedIndexDefinition(String name, String analyzer) { + return new IndexDefinition(name, false, List.of("content"), "INVERTED", + Map.of("analyzer", analyzer), ""); + } + + private static IndexDefinition invertedNormalizerIndexDefinition(String name, String normalizer) { + return new IndexDefinition(name, false, List.of("content"), "INVERTED", + Map.of("normalizer", normalizer), ""); + } + + private static IndexDefinition invertedIndexDefinitionWithOuterLowerA(String name, String analyzer) { + return new IndexDefinition(name, false, List.of("content"), "INVERTED", + Map.of("analyzer", analyzer, "char_filter_type", "char_replace", + "char_filter_pattern", "A", "char_filter_replacement", "a"), ""); + } + + private static IndexDefinition invertedIndexDefinitionWithProperties( + String name, Map properties) { + return new IndexDefinition(name, false, List.of("content"), "INVERTED", properties, ""); + } + + @Test + public void testCreateTableRejectsBuiltinAnalyzerPipelineAliases() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(1, "basic_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic", "token_filter", "lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(2, "icu_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "icu", "token_filter", "lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(3, "basic_plain", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_builtin_basic", "basic"), + invertedIndexDefinition("idx_custom_basic", "basic_lower"))), + "the built-in basic analyzer must share the identity of its custom equivalent"), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinitionWithProperties( + "idx_parser_basic", Map.of("parser", "basic")), + invertedIndexDefinition("idx_custom_basic", "basic_lower")))), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_builtin_icu", "icu"), + invertedIndexDefinition("idx_custom_icu", "icu_lower")))), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinitionWithProperties( + "idx_parser_standard", Map.of("parser", "standard")), + invertedIndexDefinitionWithProperties( + "idx_parser_unicode", Map.of("parser", "unicode")))), + "unicode is another spelling of the standard analyzer"), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_analyzer_standard", "standard"), + invertedIndexDefinition("idx_analyzer_unicode", "unicode")))), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinitionWithProperties("idx_builtin_basic_cased", + Map.of("analyzer", "basic", "lower_case", "false")), + invertedIndexDefinition("idx_custom_basic_plain", "basic_plain")))), + // lower_case=false drops the filter, so the two pipelines stay distinct. + () -> Assertions.assertTrue(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinitionWithProperties("idx_builtin_basic_cased", + Map.of("analyzer", "basic", "lower_case", "false")), + invertedIndexDefinition("idx_custom_basic", "basic_lower")))), + () -> Assertions.assertTrue(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_builtin_basic", "basic"), + invertedIndexDefinition("idx_custom_icu", "icu_lower"))))); + } + } + + @Test + public void testCreateTableRejectsAdjacentDuplicateIdempotentFilterAliases() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(1, "dup_dash", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "-", "replacement", " "))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(2, "dup_icu_once", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "icu_normalizer"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(3, "dup_icu_twice", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "icu_normalizer,icu_normalizer"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(4, "dup_dash_once", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "dup_dash"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(5, "dup_dash_twice", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "dup_dash,dup_dash"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(6, "dup_wd_once", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "word_delimiter"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(7, "dup_wd_twice", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "word_delimiter,word_delimiter"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_icu_once", "dup_icu_once"), + invertedIndexDefinition("idx_icu_twice", "dup_icu_twice")))), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_dash_once", "dup_dash_once"), + invertedIndexDefinition("idx_dash_twice", "dup_dash_twice")))), + () -> Assertions.assertTrue(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_wd_once", "dup_wd_once"), + invertedIndexDefinition("idx_wd_twice", "dup_wd_twice"))))); + } + } + + @Test + public void testCreateTableRejectsIneffectiveKeywordBufferSizeAliases() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(1, "keyword_256", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "keyword", "buffer_size", "256"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(2, "keyword_512", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "keyword", "buffer_size", "512"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(3, "keyword_256_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword_256"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(4, "keyword_512_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword_512"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_keyword_256", "keyword_256_analyzer"), + invertedIndexDefinition("idx_keyword_512", "keyword_512_analyzer")))); + } + } + + @Test + public void testCreateTableRejectsCaseFoldCarriedThroughNonInteractingCharReplace() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(1, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(2, "x_to_y", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "x", "replacement", "y"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(3, "a_to_b", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "a", "replacement", "b"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(4, "fold", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(5, "x_then_fold", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "x_to_y,fold"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(6, "lower_x_fold", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "lower_a,x_to_y,fold"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(7, "ab_then_fold", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "a_to_b,fold"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(8, "lower_ab_fold", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "lower_a,a_to_b,fold"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_x_then_fold", "x_then_fold"), + invertedIndexDefinition("idx_lower_x_fold", "lower_x_fold")))), + () -> Assertions.assertTrue(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_ab_then_fold", "ab_then_fold"), + invertedIndexDefinition("idx_lower_ab_fold", "lower_ab_fold"))))); + } + } + + @Test + public void testCreateTableRejectsOuterCaseFoldAbsorbedByCustomLowercaseAliases() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(1, "keyword_lower_1", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(2, "keyword_lower_2", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(3, "keyword_plain_1", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(4, "keyword_plain_2", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinitionWithOuterLowerA("idx_outer_lower", "keyword_lower_1"), + invertedIndexDefinition("idx_plain_lower", "keyword_lower_2")))), + () -> Assertions.assertTrue(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinitionWithOuterLowerA("idx_outer_plain", "keyword_plain_1"), + invertedIndexDefinition("idx_plain_plain", "keyword_plain_2"))))); + } + } + + private static IndexDefinition normalizerIndexDefinition(String name, String normalizer) { + return new IndexDefinition(name, false, List.of("content"), "INVERTED", + Map.of("normalizer", normalizer), ""); + } + + private static IndexDefinition normalizerIndexDefinitionWithOuterLowerA(String name, String normalizer) { + return new IndexDefinition(name, false, List.of("content"), "INVERTED", + Map.of("normalizer", normalizer, "char_filter_type", "char_replace", + "char_filter_pattern", "A", "char_filter_replacement", "a"), ""); + } + + @Test + public void testCreateTableRejectsPinyinSettingsBehindDisabledGates() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(1, "pinyin_tf_plain", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(2, "pinyin_tf_ascii_in_joined", + IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_none_chinese_in_joined_full_pinyin", "true"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(3, "pinyin_tf_separate", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_none_chinese_together", "false"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(4, "pinyin_tf_separate_untokenized", + IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_none_chinese_together", "false", + "none_chinese_pinyin_tokenize", "false"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(5, "pinyin_tk_plain", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "pinyin"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(6, "pinyin_tk_ascii_in_joined", + IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "pinyin", "keep_none_chinese_in_joined_full_pinyin", "true"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(7, "pinyin_tk_separate", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "pinyin", "keep_none_chinese_together", "false"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(8, "pinyin_tk_separate_untokenized", + IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "pinyin", "keep_none_chinese_together", "false", + "none_chinese_pinyin_tokenize", "false"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(9, "pinyin_tk_buffer_only", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "pinyin", "keep_first_letter", "false", "keep_full_pinyin", "false", + "none_chinese_pinyin_tokenize", "false"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(10, "pinyin_tk_buffer_only_ascii", + IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "pinyin", "keep_first_letter", "false", "keep_full_pinyin", "false", + "none_chinese_pinyin_tokenize", "false", + "keep_none_chinese_in_joined_full_pinyin", "true"))); + long id = 20; + for (String filter : new String[] {"pinyin_tf_plain", "pinyin_tf_ascii_in_joined", + "pinyin_tf_separate", "pinyin_tf_separate_untokenized"}) { + policyMgr.replayCreateIndexPolicy(new IndexPolicy(id++, filter + "_analyzer", + IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "keyword", "token_filter", filter))); + } + for (String tokenizer : new String[] {"pinyin_tk_plain", "pinyin_tk_ascii_in_joined", + "pinyin_tk_separate", "pinyin_tk_separate_untokenized", "pinyin_tk_buffer_only", + "pinyin_tk_buffer_only_ascii"}) { + policyMgr.replayCreateIndexPolicy(new IndexPolicy(id++, tokenizer + "_analyzer", + IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", tokenizer))); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_tf_plain", "pinyin_tf_plain_analyzer"), + invertedIndexDefinition("idx_tf_ascii", "pinyin_tf_ascii_in_joined_analyzer")))), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_tf_separate", "pinyin_tf_separate_analyzer"), + invertedIndexDefinition("idx_tf_separate_untokenized", + "pinyin_tf_separate_untokenized_analyzer")))), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_tk_plain", "pinyin_tk_plain_analyzer"), + invertedIndexDefinition("idx_tk_ascii", "pinyin_tk_ascii_in_joined_analyzer")))), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_tk_separate", "pinyin_tk_separate_analyzer"), + invertedIndexDefinition("idx_tk_separate_untokenized", + "pinyin_tk_separate_untokenized_analyzer")))), + () -> Assertions.assertTrue(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_tk_buffer_only", "pinyin_tk_buffer_only_analyzer"), + invertedIndexDefinition("idx_tk_buffer_only_ascii", + "pinyin_tk_buffer_only_ascii_analyzer"))))); + } + } + + @Test + public void testCreateTableRejectsEmptyUnicodeSetFoldAliases() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(1, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(2, "fold_empty", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[]"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(3, "fold_b", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[b]"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(4, "fold_empty_only", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "fold_empty"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(5, "lower_fold_empty", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "lower_a,fold_empty"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(6, "fold_b_only", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "fold_b"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(7, "lower_fold_b", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "lower_a,fold_b"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_fold_empty", "fold_empty_only"), + invertedIndexDefinition("idx_lower_fold_empty", "lower_fold_empty")))), + () -> Assertions.assertTrue(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_fold_b", "fold_b_only"), + invertedIndexDefinition("idx_lower_fold_b", "lower_fold_b"))))); + } + } + + @Test + public void testCreateTableRejectsOuterCaseFoldAbsorbedByNormalizerAliases() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(1, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(2, "norm_lower_1", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(3, "norm_lower_2", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(4, "norm_lower_a_then_lowercase", + IndexPolicyTypeEnum.NORMALIZER, Map.of("char_filter", "lower_a", "token_filter", "lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(5, "norm_ascii_1", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(6, "norm_ascii_2", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + normalizerIndexDefinitionWithOuterLowerA("idx_outer_norm_lower", "norm_lower_1"), + normalizerIndexDefinition("idx_norm_lower", "norm_lower_2")))), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + normalizerIndexDefinition("idx_norm_lower_a", "norm_lower_a_then_lowercase"), + normalizerIndexDefinition("idx_norm_lower", "norm_lower_2")))), + () -> Assertions.assertTrue(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + normalizerIndexDefinitionWithOuterLowerA("idx_outer_norm_ascii", "norm_ascii_1"), + normalizerIndexDefinition("idx_norm_ascii", "norm_ascii_2"))))); + } + } + + @Test + public void testCreateTableRejectsOuterCaseFoldThroughAsciiTransparentFilters() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(1, "ascii", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "asciifolding"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(2, "wd", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "word_delimiter"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(3, "ascii_lower_1", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "ascii,lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(4, "ascii_lower_2", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "ascii,lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(5, "wd_lower_1", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "wd,lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(6, "wd_lower_2", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "wd,lowercase"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinitionWithOuterLowerA("idx_outer_ascii_lower", "ascii_lower_1"), + invertedIndexDefinition("idx_ascii_lower", "ascii_lower_2")))), + () -> Assertions.assertTrue(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinitionWithOuterLowerA("idx_outer_wd_lower", "wd_lower_1"), + invertedIndexDefinition("idx_wd_lower", "wd_lower_2"))))); + } + } + + @Test + public void testRejectsAmbiguousOuterCharFiltersForSameAnalyzer() { + IndexDefinition replaceA = new IndexDefinition("idx_replace_a", false, List.of("content"), + "INVERTED", Map.of("analyzer", "standard", "char_filter_type", "char_replace", + "char_filter_pattern", "a", "char_filter_replacement", "b"), ""); + IndexDefinition replaceX = new IndexDefinition("idx_replace_x", false, List.of("content"), + "INVERTED", Map.of("analyzer", "standard", "char_filter_type", "char_replace", + "char_filter_pattern", "x", "char_filter_replacement", "y"), ""); + + Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of(replaceA, replaceX))); + + IndexDefinition defaultA = new IndexDefinition("idx_default_a", false, List.of("content"), + "INVERTED", Map.of("char_filter_type", "char_replace", "char_filter_pattern", "a", + "char_filter_replacement", "b"), ""); + IndexDefinition defaultX = new IndexDefinition("idx_default_x", false, List.of("content"), + "INVERTED", Map.of("char_filter_type", "char_replace", "char_filter_pattern", "x", + "char_filter_replacement", "y"), ""); + Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of(defaultA, defaultX))); + } + + @Test + public void testExplicitBuiltinIkSelectsMatchingModeAndLowercase() { + Column column = new Column("content", PrimitiveType.STRING); + Index smartNoLowercase = new Index(10, "idx_smart_no_lowercase", List.of("content"), + IndexType.INVERTED, Map.of("parser", "ik", "parser_mode", "ik_smart", "lower_case", "false"), ""); + Index smart = new Index(11, "idx_smart", List.of("content"), IndexType.INVERTED, + Map.of("parser", "ik", "parser_mode", "ik_smart"), ""); + Index maxWordNoLowercase = new Index(12, "idx_max_word_no_lowercase", List.of("content"), + IndexType.INVERTED, Map.of("parser", "ik", "parser_mode", "ik_max_word", "lower_case", "false"), ""); + Index maxWord = new Index(13, "idx_max_word", List.of("content"), IndexType.INVERTED, + Map.of("parser", "ik", "parser_mode", "ik_max_word"), ""); + OlapTable table = new OlapTable(); + table.setIndexes(List.of(smartNoLowercase, smart, maxWordNoLowercase, maxWord)); + + Index selected = table.getInvertedIndex(column, List.of(), "ik"); + Assertions.assertSame(maxWord, selected); + MatchPredicate predicate = new MatchPredicate(MatchPredicate.Operator.MATCH_ANY, + new StringLiteral("清华大学"), new StringLiteral("清华"), Type.BOOLEAN, + NullableMode.DEPEND_ON_ARGUMENT, selected, false, "ik"); + TExprNode node = new TExprNode(); + ExprToThriftVisitor.INSTANCE.visitMatchPredicate(predicate, node); + Assertions.assertEquals("ik", node.getMatchPredicate().getAnalyzerName()); + Assertions.assertEquals("ik_max_word", node.getMatchPredicate().getParserMode()); + Assertions.assertTrue(node.getMatchPredicate().isParserLowercase()); + Assertions.assertFalse(InvertedIndexUtil.isAnalyzerMatched(Map.of("parser", "ik"), "ik")); + Assertions.assertFalse(InvertedIndexUtil.isAnalyzerMatched( + Map.of("analyzer", "ik", "lower_case", "false"), "ik")); + } + + @Test + public void testExplicitBuiltinIkFallsBackToTheOnlyLegacyIndex() { + Column column = new Column("content", PrimitiveType.STRING); + Index legacySmart = new Index(20, "idx_legacy_smart", List.of("content"), IndexType.INVERTED, + Map.of("parser", "ik"), ""); + Index legacySmartNoLowercase = new Index(21, "idx_legacy_smart_no_lowercase", List.of("content"), + IndexType.INVERTED, Map.of("parser", "ik", "lower_case", "false"), ""); + Index maxWord = new Index(22, "idx_max_word", List.of("content"), IndexType.INVERTED, + Map.of("analyzer", "ik"), ""); + + // An index created before built-in IK was matched by configuration keeps answering the + // explicit request it answered before, and BE receives that index's own mode. + OlapTable legacyOnly = new OlapTable(); + legacyOnly.setIndexes(List.of(legacySmart)); + Index selected = legacyOnly.getInvertedIndex(column, List.of(), "ik"); + Assertions.assertSame(legacySmart, selected); + MatchPredicate predicate = new MatchPredicate(MatchPredicate.Operator.MATCH_ANY, + new StringLiteral("清华大学"), new StringLiteral("清华"), Type.BOOLEAN, + NullableMode.DEPEND_ON_ARGUMENT, selected, false, "ik"); + TExprNode node = new TExprNode(); + ExprToThriftVisitor.INSTANCE.visitMatchPredicate(predicate, node); + Assertions.assertEquals("ik", node.getMatchPredicate().getAnalyzerName()); + Assertions.assertEquals("ik_smart", node.getMatchPredicate().getParserMode()); + Assertions.assertTrue(node.getMatchPredicate().isParserLowercase()); + + // The index with the matching default configuration still wins when it exists. + OlapTable withDefault = new OlapTable(); + withDefault.setIndexes(List.of(legacySmart, maxWord)); + Assertions.assertSame(maxWord, withDefault.getInvertedIndex(column, List.of(), "ik")); + + // Two differently configured legacy indexes stay ambiguous. + OlapTable ambiguous = new OlapTable(); + ambiguous.setIndexes(List.of(legacySmart, legacySmartNoLowercase)); + Assertions.assertNull(ambiguous.getInvertedIndex(column, List.of(), "ik")); + + // Per-index matching is unchanged; the fallback is decided over the whole column. + Assertions.assertFalse(InvertedIndexUtil.isAnalyzerMatched(Map.of("parser", "ik"), "ik")); + Assertions.assertTrue(InvertedIndexUtil.isAnalyzerNameMatched(Map.of("parser", "ik"), "ik")); + Assertions.assertFalse(InvertedIndexUtil.isAnalyzerNameMatched(Map.of("parser", "ik"), "standard")); + } + + @Test + public void testMatchSelectionPreservesExactAnalyzerSpelling() { + MatchAny match = new MatchAny(new VarcharLiteral("abc def"), new VarcharLiteral("abc def"), " IK "); + Assertions.assertAll( + () -> Assertions.assertEquals("IK", match.getAnalyzer().orElseThrow()), + () -> Assertions.assertEquals("IK", + match.withChildren(match.children()).getAnalyzer().orElseThrow()), + () -> Assertions.assertEquals("IK", + AnalyzerSelector.select(Map.of("analyzer", "IK"), null).analyzer()), + () -> Assertions.assertEquals("IK", + AnalyzerSelector.select(Map.of("analyzer", "IK"), "IK").analyzer())); + } + + @Test + public void testAnalyzerMatchingKeepsReplayedCaseDistinctPoliciesSeparate() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 30, "Legacy", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "keyword"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 31, "legacy", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "standard"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertTrue(InvertedIndexUtil.isAnalyzerMatched(Map.of("analyzer", "Legacy"), "Legacy")); + Assertions.assertTrue(InvertedIndexUtil.isAnalyzerMatched(Map.of("analyzer", "legacy"), "legacy")); + Assertions.assertAll( + () -> Assertions.assertFalse(InvertedIndexUtil.isAnalyzerMatched( + Map.of("analyzer", "Legacy"), "legacy")), + () -> Assertions.assertFalse(InvertedIndexUtil.isAnalyzerMatched( + Map.of("analyzer", "legacy"), "Legacy"))); + } + } + + @Test + public void testMatchThriftKeepsImplicitAndExplicitLegacyIkBindings() { + Index index = new Index(1, "idx_legacy_ik", List.of("content"), + IndexType.INVERTED, Map.of("analyzer", "IK"), ""); + for (String analyzer : List.of("", "IK")) { + MatchPredicate predicate = new MatchPredicate(MatchPredicate.Operator.MATCH_ANY, + new StringLiteral("abc def"), new StringLiteral("abc def"), Type.BOOLEAN, + NullableMode.DEPEND_ON_ARGUMENT, index, false, analyzer); + TExprNode node = new TExprNode(); + ExprToThriftVisitor.INSTANCE.visitMatchPredicate(predicate, node); + Assertions.assertEquals("IK", node.getMatchPredicate().getAnalyzerName()); + } + } + + @Test + public void testAnalyzerResolutionKeepsCanonicalBuiltinsAndExactLegacyBindings() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 40, "IK", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "keyword"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertEquals("IK", InvertedIndexUtil.resolveAnalyzerName(" IK ")); + Assertions.assertEquals("ik", InvertedIndexUtil.resolveAnalyzerName("ik")); + Assertions.assertEquals("standard", InvertedIndexUtil.resolveAnalyzerName(" StAnDaRd ")); + Assertions.assertTrue(InvertedIndexUtil.isAnalyzerMatched(Map.of("analyzer", "IK"), "IK")); + Assertions.assertFalse(InvertedIndexUtil.isAnalyzerMatched(Map.of("analyzer", "IK"), "ik")); + Assertions.assertTrue(InvertedIndexUtil.isAnalyzerMatched(Map.of("analyzer", "ik"), "ik")); + Assertions.assertFalse(InvertedIndexUtil.isAnalyzerMatched(Map.of("analyzer", "ik"), "IK")); + Assertions.assertFalse(InvertedIndexUtil.isAnalyzerMatched(Map.of("parser", "ik"), "ik")); + Assertions.assertFalse(InvertedIndexUtil.isAnalyzerMatched(Map.of("parser", "ik"), "IK")); + } + } + + @Test + public void testMixedCaseBuiltinSpellingsStoreBuiltinDespiteNormalizedLegacyPolicies() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 50, "LOWERCASE", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 51, "IK", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "keyword"))); + Map mixedNormalizer = new HashMap<>(Map.of("normalizer", "LowerCase")); + Map mixedAnalyzer = new HashMap<>(Map.of("analyzer", "Ik")); + Map exactNormalizer = new HashMap<>(Map.of("normalizer", "LOWERCASE")); + + withIndexPolicyManager(policyMgr, () -> { + for (Map properties : List.of(mixedNormalizer, mixedAnalyzer, exactNormalizer)) { + Assertions.assertDoesNotThrow(() -> InvertedIndexUtil.checkInvertedIndexParser("c", + PrimitiveType.VARCHAR, properties, TInvertedIndexFileStorageFormat.V3)); + } + Assertions.assertAll( + () -> Assertions.assertEquals("lowercase", mixedNormalizer.get("normalizer")), + () -> Assertions.assertEquals("ik", mixedAnalyzer.get("analyzer")), + () -> Assertions.assertEquals("LOWERCASE", exactNormalizer.get("normalizer")), + () -> Assertions.assertEquals("lowercase", InvertedIndexUtil.resolveAnalyzerName("LowerCase")), + () -> Assertions.assertEquals("ik", InvertedIndexUtil.resolveAnalyzerName("Ik")), + () -> Assertions.assertEquals("LOWERCASE", InvertedIndexUtil.resolveAnalyzerName("LOWERCASE"))); + }); + } + + @Test + public void testCreateTableUsesExactLegacyLowercaseNormalizerIdentity() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 60, "lowercase", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 61, "norm_ascii", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 62, "norm_lower", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "lowercase"))); + + withIndexPolicyManager(policyMgr, () -> Assertions.assertAll( + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + normalizerIndexDefinition("idx_legacy_lowercase", "lowercase"), + normalizerIndexDefinition("idx_ascii", "norm_ascii")))), + () -> Assertions.assertTrue(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + normalizerIndexDefinition("idx_legacy_lowercase", "lowercase"), + normalizerIndexDefinition("idx_lower", "norm_lower")))))); + } + + @Test + public void testExactLowercasePolicyKeepsMixedCaseBuiltinNormalizerBinding() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 90, "lowercase", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 91, "standard", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "keyword"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 92, "norm_lower", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "lowercase"))); + Map mixedNormalizer = new HashMap<>(Map.of("normalizer", "LowerCase")); + Map mixedAnalyzer = new HashMap<>(Map.of("analyzer", "Standard")); + + withIndexPolicyManager(policyMgr, () -> { + for (Map properties : List.of(mixedNormalizer, mixedAnalyzer)) { + Assertions.assertDoesNotThrow(() -> InvertedIndexUtil.checkInvertedIndexParser("c", + PrimitiveType.VARCHAR, properties, TInvertedIndexFileStorageFormat.V3)); + } + Assertions.assertAll( + // The exact policy shadows the canonical name, so only the mixed spelling still + // reaches the built-in normalizer on BE. + () -> Assertions.assertEquals("LowerCase", mixedNormalizer.get("normalizer")), + () -> Assertions.assertEquals("LowerCase", + InvertedIndexUtil.resolveAnalyzerName("LowerCase")), + () -> Assertions.assertEquals("lowercase", + InvertedIndexUtil.resolveAnalyzerName("lowercase")), + // BE dispatches a built-in analyzer before any policy, so it stays canonical. + () -> Assertions.assertEquals("standard", mixedAnalyzer.get("analyzer")), + () -> Assertions.assertEquals("standard", + InvertedIndexUtil.resolveAnalyzerName("Standard")), + () -> Assertions.assertTrue(InvertedIndexUtil.isAnalyzerMatched( + Map.of("normalizer", "LowerCase"), "LowerCase")), + () -> Assertions.assertFalse(InvertedIndexUtil.isAnalyzerMatched( + Map.of("normalizer", "LowerCase"), "lowercase")), + () -> Assertions.assertTrue(InvertedIndexUtil.isAnalyzerMatched( + Map.of("normalizer", "lowercase"), "lowercase")), + () -> Assertions.assertTrue(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + normalizerIndexDefinition("idx_builtin", "LowerCase"), + normalizerIndexDefinition("idx_legacy", "lowercase")))), + () -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + normalizerIndexDefinition("idx_builtin", "LowerCase"), + normalizerIndexDefinition("idx_equivalent", "norm_lower"))))); + }); + } + + @Test + public void testCreateTableRejectsNormalizerNamedAfterBuiltinAnalyzer() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 100, "ik", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 101, "standard", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 102, "none", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "lowercase"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 103, "norm_ascii", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding"))); + + withIndexPolicyManager(policyMgr, () -> { + for (String normalizer : List.of("ik", "IK", " Standard ", "none")) { + AnalysisException error = Assertions.assertThrows(AnalysisException.class, + () -> InvertedIndexUtil.checkInvertedIndexParser("c", PrimitiveType.VARCHAR, + new HashMap<>(Map.of("normalizer", normalizer)), + TInvertedIndexFileStorageFormat.V3)); + Assertions.assertTrue(error.getMessage().contains("built-in analyzer"), error.getMessage()); + } + for (String normalizer : List.of("norm_ascii", "lowercase", "LowerCase")) { + Assertions.assertDoesNotThrow(() -> InvertedIndexUtil.checkInvertedIndexParser("c", + PrimitiveType.VARCHAR, new HashMap<>(Map.of("normalizer", normalizer)), + TInvertedIndexFileStorageFormat.V3)); + } + }); + } + + @Test + public void testCreateTableKeepsNormalizerWhoseExactNameEscapesBuiltinAnalyzers() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 105, "IK", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding"))); + Map exactSpelling = new HashMap<>(Map.of("normalizer", " IK ")); + Map normalizedSpelling = new HashMap<>(Map.of("normalizer", "ik")); + + withIndexPolicyManager(policyMgr, () -> { + for (Map properties : List.of(exactSpelling, normalizedSpelling)) { + Assertions.assertDoesNotThrow(() -> InvertedIndexUtil.checkInvertedIndexParser("c", + PrimitiveType.VARCHAR, properties, TInvertedIndexFileStorageFormat.V3)); + } + Assertions.assertAll( + () -> Assertions.assertEquals("IK", exactSpelling.get("normalizer")), + () -> Assertions.assertEquals("IK", normalizedSpelling.get("normalizer"))); + }); + } + + @Test + public void testCreateTableRejectsRedundantTokenCharAndReverseCaseAliases() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(70, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(71, "upper_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "a", "replacement", "A"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(72, "ngram_letter", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "ngram", "token_chars", "letter"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(73, "ngram_letter_a", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "ngram", "token_chars", "letter,custom", "custom_token_chars", "A"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(74, "group_letter", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "char_group", "tokenize_on_chars", "[letter]"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy(75, "group_letter_a", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "char_group", "tokenize_on_chars", "[letter],[A]"))); + String[][] analyzers = { + {"ngram_plain", "ngram_letter", "lower_a"}, + {"ngram_custom_a", "ngram_letter_a", "lower_a"}, + {"group_plain", "group_letter", "lower_a"}, + {"group_literal_a", "group_letter_a", "lower_a"}, + {"keyword_lower", "keyword", null}, + {"upper_keyword_lower", "keyword", "upper_a"}}; + long id = 80; + for (String[] analyzer : analyzers) { + Map properties = new HashMap<>( + Map.of("tokenizer", analyzer[1], "token_filter", "lowercase")); + if (analyzer[2] != null) { + properties.put("char_filter", analyzer[2]); + } + policyMgr.replayCreateIndexPolicy( + new IndexPolicy(id++, analyzer[0], IndexPolicyTypeEnum.ANALYZER, properties)); + } + + List checks = new ArrayList<>(); + for (int i = 0; i < analyzers.length; i += 2) { + String left = analyzers[i][0]; + String right = analyzers[i + 1][0]; + checks.add(() -> Assertions.assertFalse(InvertedIndexUtil.canHaveMultipleInvertedIndexes( + StringType.INSTANCE, List.of( + invertedIndexDefinition("idx_" + left, left), + invertedIndexDefinition("idx_" + right, right))), + left + " and " + right + " must share one analyzer identity")); + } + withIndexPolicyManager(policyMgr, () -> Assertions.assertAll(checks)); + } + private static void assertCheckCharFilterPropertiesThrows(Map props, String expectedMessage) { AnalysisException exception = Assertions.assertThrows(AnalysisException.class, () -> InvertedIndexUtil.checkCharFilterProperties(props)); @@ -391,6 +1336,27 @@ public void testPlainCustomAnalyzerBehaviorRemainsUnchanged() throws Exception { TInvertedIndexFileStorageFormat.V3))); } + @Test + public void testResolvedCustomPolicyKeepsExactLegacyNameInIndexProperties() throws Exception { + IndexPolicyMgr manager = Mockito.mock(IndexPolicyMgr.class); + IndexPolicy exactPolicy = new IndexPolicy( + 1, "IK_SMART", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "standard")); + Mockito.when(manager.getPolicyByName("IK_SMART")).thenReturn(exactPolicy); + + Map properties = new HashMap<>(Map.of("analyzer", " IK_SMART ")); + withIndexPolicyManager(manager, () -> Assertions.assertDoesNotThrow( + () -> InvertedIndexUtil.checkInvertedIndexParser("c", PrimitiveType.VARCHAR, properties, + TInvertedIndexFileStorageFormat.V3))); + + Assertions.assertEquals("IK_SMART", properties.get("analyzer")); + + Map normalizerProperties = new HashMap<>(Map.of("normalizer", " IK_SMART ")); + withIndexPolicyManager(manager, () -> Assertions.assertDoesNotThrow( + () -> InvertedIndexUtil.checkInvertedIndexParser("c", PrimitiveType.VARCHAR, + normalizerProperties, TInvertedIndexFileStorageFormat.V3))); + Assertions.assertEquals("IK_SMART", normalizerProperties.get("normalizer")); + } + // --- buildAnalyzerSqlFragment (migrated from InvertedIndexSqlGeneratorTest) --- @Test diff --git a/fe/fe-core/src/test/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilderTest.java b/fe/fe-core/src/test/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilderTest.java index c65afc497a110a..586f22635c47c9 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilderTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilderTest.java @@ -23,11 +23,13 @@ import org.apache.doris.indexpolicy.IndexPolicyMgr; import org.apache.doris.indexpolicy.IndexPolicyTypeEnum; +import com.ibm.icu.text.Normalizer2; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.mockito.Mockito; +import java.lang.reflect.Method; import java.util.HashMap; import java.util.Iterator; import java.util.Map; @@ -81,7 +83,10 @@ public void testBuiltInNormalizerPreferred() { "__default__", "none", null); - Assertions.assertEquals("normalizer:" + normalizer, identity); + // BE builds the built-in as a keyword tokenizer with the built-in filter of the same name. + Assertions.assertEquals( + IndexPolicyTypeEnum.ANALYZER.name() + ":token_filter=" + normalizer + + ";tokenizer=keyword;", identity); } @Test @@ -208,4 +213,2155 @@ private IndexPolicy analyzerPolicy(long id, String name, String tokenizer) { properties.put(IndexPolicy.PROP_TOKENIZER, tokenizer); return new IndexPolicy(id, name, IndexPolicyTypeEnum.ANALYZER, properties); } + + @Test + public void testReplayedExactIkAnalyzerUsesCustomIdentity() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(analyzerPolicy(30, "IK", "standard")); + policyMgr.replayCreateIndexPolicy(analyzerPolicy(31, "equivalent_standard", "standard")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String exact = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "IK"), "IK", "none", "__default__", "none", null); + String equivalent = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "equivalent_standard"), "equivalent_standard", + "none", "__default__", "none", null); + String builtin = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "ik"), "ik", "none", "__default__", "none", null); + Assertions.assertAll( + () -> Assertions.assertEquals(equivalent, exact), + () -> Assertions.assertNotEquals(builtin, exact)); + } + } + + @Test + public void testNamedCharReplaceIdentityUsesEffectiveByteSet() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + policyMgr.replayCreateIndexPolicy(analyzerPolicy(50, "plain_keyword", "keyword")); + String[] patterns = {"ab", "ba", "aabx", "x", " "}; + for (int i = 0; i < patterns.length; ++i) { + String filter = "byte_filter_" + i; + Map properties = new HashMap<>(); + properties.put("type", "char_replace"); + properties.put("pattern", patterns[i]); + if (i < 4) { + properties.put("replacement", "x"); + } + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 51 + i, filter, IndexPolicyTypeEnum.CHAR_FILTER, properties)); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 61 + i, "filtered_keyword_" + i, IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", filter))); + } + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String canonical = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "filtered_keyword_0", "none", "__default__", "none", null); + String plain = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "plain_keyword", "none", "__default__", "none", null); + Assertions.assertNotEquals(plain, canonical); + for (int i = 1; i < patterns.length; ++i) { + String identity = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "filtered_keyword_" + i, "none", "__default__", "none", null); + Assertions.assertEquals(i < 3 ? canonical : plain, identity); + } + } + } + + @Test + public void testNamedCharReplaceIdentityAccountsForIkLowercase() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 70, "ascii_lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a"))); + policyMgr.replayCreateIndexPolicy(analyzerPolicy(71, "plain_smart", "ik_smart")); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 72, "filtered_smart", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "ik_smart", "char_filter", "ascii_lower_a"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertEquals( + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "plain_smart", "none", "__default__", "none", null), + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "filtered_smart", "none", "__default__", "none", null)); + } + } + + @Test + public void testNamedCharReplaceContextUsesResolvedTokenizerAndFilterOrder() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 80, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 81, "a_to_b", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "a", "replacement", "b"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 82, "named_max_word", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "ik_max_word"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 83, "ik_smart", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "keyword"))); + long analyzerId = 84; + for (String tokenizer : new String[] {"named_max_word", "ik_smart"}) { + policyMgr.replayCreateIndexPolicy(analyzerPolicy(analyzerId++, "plain_" + tokenizer, tokenizer)); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + analyzerId++, "filtered_" + tokenizer, IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", tokenizer, "char_filter", "lower_a"))); + } + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 88, "ordered", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "named_max_word", "char_filter", "lower_a,a_to_b"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 89, "later_only", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "named_max_word", "char_filter", "a_to_b"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + for (String tokenizer : new String[] {"named_max_word", "ik_smart"}) { + String plain = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "plain_" + tokenizer, "none", "__default__", "none", null); + String filtered = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "filtered_" + tokenizer, "none", "__default__", "none", null); + Assertions.assertEquals("named_max_word".equals(tokenizer), plain.equals(filtered)); + } + Assertions.assertNotEquals( + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "ordered", "none", "__default__", "none", null), + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "later_only", "none", "__default__", "none", null)); + } + } + + @Test + public void testCaseFoldingCharFilterAbsorbsEarlierReplacement() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 90, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 91, "fold", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 92, "compose_only", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "name", "nfc"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 93, "filtered_fold", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[a-z]"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 94, "fold_only", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "ik_smart", "char_filter", "fold"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 95, "lower_then_fold", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "ik_smart", "char_filter", "lower_a,fold"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 96, "compose_only_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "ik_smart", "char_filter", "compose_only"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 97, "lower_then_compose", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "ik_smart", "char_filter", "lower_a,compose_only"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 98, "filtered_fold_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "ik_smart", "char_filter", "filtered_fold"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 99, "lower_then_filtered_fold", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "ik_smart", "char_filter", "lower_a,filtered_fold"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertEquals( + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "fold_only", "none", "__default__", "none", null), + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "lower_then_fold", "none", "__default__", "none", null)); + Assertions.assertNotEquals( + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "compose_only_analyzer", "none", "__default__", "none", null), + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "lower_then_compose", "none", "__default__", "none", null)); + Assertions.assertNotEquals( + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "filtered_fold_analyzer", "none", "__default__", "none", null), + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + nonEmptyProperties(), "lower_then_filtered_fold", "none", "__default__", "none", null)); + } + } + + @Test + public void testBuiltinTokenizerIdentityIsCanonicalized() throws Exception { + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + Assertions.assertEquals("ik_smart", + resolve.invoke(null, "IK_SMART", IndexPolicyTypeEnum.TOKENIZER)); + Assertions.assertEquals("ik_smart", + resolve.invoke(null, " IK_SMART ", IndexPolicyTypeEnum.TOKENIZER)); + } + + @Test + public void testBuiltinFilterIdentitiesAreCanonicalized() throws Exception { + Method resolveTokenFilters = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveTokenFilterIdentity", String.class); + resolveTokenFilters.setAccessible(true); + Method resolveCharFilters = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveCharFilterIdentity", String.class); + resolveCharFilters.setAccessible(true); + + Assertions.assertEquals("pinyin", resolveTokenFilters.invoke(null, "PINYIN")); + Assertions.assertEquals("icu_normalizer", resolveCharFilters.invoke(null, "ICU_NORMALIZER")); + Assertions.assertEquals("lowercase,pinyin", + resolveTokenFilters.invoke(null, "empty, lowercase, empty, pinyin")); + Assertions.assertEquals("char_replace", + resolveCharFilters.invoke(null, "empty, char_replace, empty")); + } + + @Test + public void testTypeOnlyIkPolicyMatchesBuiltinIdentity() throws Exception { + IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class); + Mockito.when(policyMgr.getPolicyByName("named_ik")).thenReturn(new IndexPolicy( + 1, "named_ik", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "ik_smart"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertEquals(resolve.invoke(null, "ik_smart", IndexPolicyTypeEnum.TOKENIZER), + resolve.invoke(null, "named_ik", IndexPolicyTypeEnum.TOKENIZER)); + } + } + + @Test + public void testExplicitPinyinDefaultsMatchTypeOnlyIdentity() throws Exception { + IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class); + Mockito.when(policyMgr.getPolicyByName("pinyin_type_only")).thenReturn(new IndexPolicy( + 1, "pinyin_type_only", IndexPolicyTypeEnum.TOKEN_FILTER, Map.of("type", "pinyin"))); + Mockito.when(policyMgr.getPolicyByName("pinyin_defaults")).thenReturn(new IndexPolicy( + 2, "pinyin_defaults", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_first_letter", "TRUE", + "keep_full_pinyin", "true", "keep_original", "FALSE", + "ignore_pinyin_offset", "true", "limit_first_letter_length", "016"))); + Mockito.when(policyMgr.getPolicyByName("pinyin_non_default")).thenReturn(new IndexPolicy( + 3, "pinyin_non_default", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_original", "true"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Object typeOnly = resolve.invoke(null, "pinyin_type_only", IndexPolicyTypeEnum.TOKEN_FILTER); + Assertions.assertEquals(typeOnly, + resolve.invoke(null, "pinyin_defaults", IndexPolicyTypeEnum.TOKEN_FILTER)); + Assertions.assertNotEquals(typeOnly, + resolve.invoke(null, "pinyin_non_default", IndexPolicyTypeEnum.TOKEN_FILTER)); + } + } + + @Test + public void testPinyinInactiveSettingsDoNotChangeIdentity() throws Exception { + IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class); + Mockito.when(policyMgr.getPolicyByName("pinyin_default_offsets")).thenReturn(new IndexPolicy( + 1, "pinyin_default_offsets", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin"))); + Mockito.when(policyMgr.getPolicyByName("pinyin_fixed_ignored")).thenReturn(new IndexPolicy( + 2, "pinyin_fixed_ignored", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "fixed_pinyin_offset", "true"))); + Mockito.when(policyMgr.getPolicyByName("pinyin_first_letter_disabled")).thenReturn(new IndexPolicy( + 3, "pinyin_first_letter_disabled", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_first_letter", "false"))); + Mockito.when(policyMgr.getPolicyByName("pinyin_first_letter_inactive_settings")) + .thenReturn(new IndexPolicy( + 4, "pinyin_first_letter_inactive_settings", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_first_letter", "false", + "limit_first_letter_length", "32", + "keep_none_chinese_in_first_letter", "false"))); + Mockito.when(policyMgr.getPolicyByName("pinyin_none_chinese_disabled")).thenReturn(new IndexPolicy( + 5, "pinyin_none_chinese_disabled", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_none_chinese", "false"))); + Mockito.when(policyMgr.getPolicyByName("pinyin_none_chinese_inactive_settings")) + .thenReturn(new IndexPolicy( + 6, "pinyin_none_chinese_inactive_settings", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "pinyin", "keep_none_chinese", "false", + "keep_none_chinese_together", "false", + "none_chinese_pinyin_tokenize", "false", + "fixed_pinyin_offset", "true"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertEquals( + resolve.invoke(null, "pinyin_default_offsets", IndexPolicyTypeEnum.TOKEN_FILTER), + resolve.invoke(null, "pinyin_fixed_ignored", IndexPolicyTypeEnum.TOKEN_FILTER)); + Assertions.assertEquals( + resolve.invoke(null, "pinyin_first_letter_disabled", IndexPolicyTypeEnum.TOKEN_FILTER), + resolve.invoke(null, "pinyin_first_letter_inactive_settings", + IndexPolicyTypeEnum.TOKEN_FILTER)); + Assertions.assertEquals( + resolve.invoke(null, "pinyin_none_chinese_disabled", IndexPolicyTypeEnum.TOKEN_FILTER), + resolve.invoke(null, "pinyin_none_chinese_inactive_settings", + IndexPolicyTypeEnum.TOKEN_FILTER)); + } + } + + @Test + public void testSetValuedComponentSettingsAreCanonicalized() throws Exception { + IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class); + Mockito.when(policyMgr.getPolicyByName("basic_ab")).thenReturn(new IndexPolicy( + 1, "basic_ab", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "basic", "extra_chars", "ab"))); + Mockito.when(policyMgr.getPolicyByName("basic_baba")).thenReturn(new IndexPolicy( + 2, "basic_baba", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "basic", "extra_chars", "baba"))); + Mockito.when(policyMgr.getPolicyByName("icu_unfiltered")).thenReturn(new IndexPolicy( + 3, "icu_unfiltered", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer"))); + Mockito.when(policyMgr.getPolicyByName("icu_empty_set")).thenReturn(new IndexPolicy( + 4, "icu_empty_set", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[]"))); + Mockito.when(policyMgr.getPolicyByName("icu_ab")).thenReturn(new IndexPolicy( + 5, "icu_ab", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[ab]"))); + Mockito.when(policyMgr.getPolicyByName("icu_ba")).thenReturn(new IndexPolicy( + 6, "icu_ba", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[ba]"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertEquals( + resolve.invoke(null, "basic_ab", IndexPolicyTypeEnum.TOKENIZER), + resolve.invoke(null, "basic_baba", IndexPolicyTypeEnum.TOKENIZER)); + Assertions.assertEquals( + resolve.invoke(null, "icu_unfiltered", IndexPolicyTypeEnum.CHAR_FILTER), + resolve.invoke(null, "icu_empty_set", IndexPolicyTypeEnum.CHAR_FILTER)); + Assertions.assertEquals( + resolve.invoke(null, "icu_ab", IndexPolicyTypeEnum.CHAR_FILTER), + resolve.invoke(null, "icu_ba", IndexPolicyTypeEnum.CHAR_FILTER)); + } + } + + @Test + public void testCollectionValuedComponentSettingsUseEffectiveValues() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "ngram_ld", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "ngram", "token_chars", "letter,digit")); + replayComponent(policyMgr, 2, "ngram_dll", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "ngram", "token_chars", "digit, letter,letter")); + replayComponent(policyMgr, 3, "ngram_l", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "ngram", "token_chars", "letter")); + replayComponent(policyMgr, 4, "edge_custom_ab", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "edge_ngram", "token_chars", "letter,custom", "custom_token_chars", "ab")); + replayComponent(policyMgr, 5, "edge_custom_bba", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "edge_ngram", "token_chars", "custom,letter,custom", "custom_token_chars", "bba")); + replayComponent(policyMgr, 6, "group_ab", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "char_group", "tokenize_on_chars", "[a],[b],[whitespace]")); + replayComponent(policyMgr, 7, "group_ba", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "char_group", "tokenize_on_chars", "[whitespace], [b],[a],[a]")); + replayComponent(policyMgr, 8, "protect_ab", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "word_delimiter", "protected_words", "foo,bar")); + replayComponent(policyMgr, 9, "protect_ba", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "word_delimiter", "protected_words", "bar, foo,foo")); + replayComponent(policyMgr, 10, "types_ab", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "word_delimiter", "type_table", "[a => DIGIT],[b => ALPHA]")); + replayComponent(policyMgr, 11, "types_overridden", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "word_delimiter", "type_table", "[b => ALPHA], [a => ALPHA],[a => DIGIT]")); + replayComponent(policyMgr, 12, "types_last_alpha", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "word_delimiter", "type_table", "[a => DIGIT],[a => ALPHA]")); + replayComponent(policyMgr, 13, "types_last_digit", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "word_delimiter", "type_table", "[a => ALPHA],[a => DIGIT]")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + IndexPolicyTypeEnum tokenizer = IndexPolicyTypeEnum.TOKENIZER; + IndexPolicyTypeEnum filter = IndexPolicyTypeEnum.TOKEN_FILTER; + Assertions.assertAll( + () -> Assertions.assertEquals(resolve.invoke(null, "ngram_ld", tokenizer), + resolve.invoke(null, "ngram_dll", tokenizer)), + () -> Assertions.assertNotEquals(resolve.invoke(null, "ngram_ld", tokenizer), + resolve.invoke(null, "ngram_l", tokenizer)), + () -> Assertions.assertEquals(resolve.invoke(null, "edge_custom_ab", tokenizer), + resolve.invoke(null, "edge_custom_bba", tokenizer)), + () -> Assertions.assertEquals(resolve.invoke(null, "group_ab", tokenizer), + resolve.invoke(null, "group_ba", tokenizer)), + () -> Assertions.assertEquals(resolve.invoke(null, "protect_ab", filter), + resolve.invoke(null, "protect_ba", filter)), + () -> Assertions.assertEquals(resolve.invoke(null, "types_ab", filter), + resolve.invoke(null, "types_overridden", filter)), + () -> Assertions.assertNotEquals(resolve.invoke(null, "types_last_alpha", filter), + resolve.invoke(null, "types_last_digit", filter))); + } + } + + @Test + public void testIcuNormalizerModeFollowsSelectedNormalizer() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "nfd_default", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "name", "nfd")); + replayComponent(policyMgr, 2, "nfd_decompose", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "name", "NFD", "mode", "decompose")); + replayComponent(policyMgr, 3, "nfd_compose", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "name", "nfd", "mode", "compose")); + replayComponent(policyMgr, 4, "nfkd_default", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "name", "nfkd")); + replayComponent(policyMgr, 5, "nfkd_decompose", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "name", "nfkd", "mode", "decompose")); + replayComponent(policyMgr, 6, "nfc_decompose", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "name", "nfc", "mode", "decompose")); + replayComponent(policyMgr, 7, "nfkc_decompose", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "name", "nfkc", "mode", "decompose")); + replayComponent(policyMgr, 8, "nfc_default", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "name", "nfc")); + replayComponent(policyMgr, 9, "fold_decompose", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "mode", "decompose")); + replayComponent(policyMgr, 10, "fold_default", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + IndexPolicyTypeEnum charFilter = IndexPolicyTypeEnum.CHAR_FILTER; + Object nfd = resolve.invoke(null, "nfd_default", charFilter); + Object nfkd = resolve.invoke(null, "nfkd_default", charFilter); + Assertions.assertAll( + () -> Assertions.assertEquals(nfd, resolve.invoke(null, "nfd_decompose", charFilter)), + () -> Assertions.assertEquals(nfd, resolve.invoke(null, "nfd_compose", charFilter)), + () -> Assertions.assertEquals(nfd, resolve.invoke(null, "nfc_decompose", charFilter)), + () -> Assertions.assertEquals(nfkd, resolve.invoke(null, "nfkd_decompose", charFilter)), + () -> Assertions.assertEquals(nfkd, resolve.invoke(null, "nfkc_decompose", charFilter)), + () -> Assertions.assertNotEquals(nfd, resolve.invoke(null, "nfc_default", charFilter)), + () -> Assertions.assertNotEquals(resolve.invoke(null, "fold_default", charFilter), + resolve.invoke(null, "fold_decompose", charFilter))); + } + } + + @Test + public void testExplicitEmptyUnicodeSetFilterMatchesAbsentFilter() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "char_default", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer")); + replayComponent(policyMgr, 2, "char_empty_string", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "")); + replayComponent(policyMgr, 3, "token_default", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "icu_normalizer")); + replayComponent(policyMgr, 4, "token_empty_string", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertEquals( + resolve.invoke(null, "char_default", IndexPolicyTypeEnum.CHAR_FILTER), + resolve.invoke(null, "char_empty_string", IndexPolicyTypeEnum.CHAR_FILTER)); + Assertions.assertEquals( + resolve.invoke(null, "token_default", IndexPolicyTypeEnum.TOKEN_FILTER), + resolve.invoke(null, "token_empty_string", IndexPolicyTypeEnum.TOKEN_FILTER)); + } + } + + private static void replayComponent(IndexPolicyMgr policyMgr, long id, String name, + IndexPolicyTypeEnum type, Map properties) { + policyMgr.replayCreateIndexPolicy(new IndexPolicy(id, name, type, properties)); + } + + private static String namedAnalyzerIdentity(String analyzer) { + return AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", analyzer), analyzer, "none", "__default__", "none", null); + } + + private static String namedAnalyzerIdentityWithOuterLowerA(String analyzer) { + return AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", analyzer, "char_filter_type", "char_replace", + "char_filter_pattern", "A", "char_filter_replacement", "a"), + analyzer, "none", "__default__", "none", null); + } + + @Test + public void testKeywordBufferSizeDoesNotChangeIdentity() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "keyword_plain", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "keyword")); + replayComponent(policyMgr, 2, "keyword_256", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "keyword", "buffer_size", "256")); + replayComponent(policyMgr, 3, "keyword_512", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "keyword", "buffer_size", "512")); + policyMgr.replayCreateIndexPolicy(analyzerPolicy(4, "keyword_plain_analyzer", "keyword_plain")); + policyMgr.replayCreateIndexPolicy(analyzerPolicy(5, "keyword_256_analyzer", "keyword_256")); + policyMgr.replayCreateIndexPolicy(analyzerPolicy(6, "keyword_512_analyzer", "keyword_512")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String plain = namedAnalyzerIdentity("keyword_plain_analyzer"); + Assertions.assertAll( + () -> Assertions.assertEquals(plain, namedAnalyzerIdentity("keyword_256_analyzer")), + () -> Assertions.assertEquals(plain, namedAnalyzerIdentity("keyword_512_analyzer"))); + } + } + + @Test + public void testCaseFoldCarriesThroughNonInteractingCharReplace() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a")); + replayComponent(policyMgr, 2, "x_to_y", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "x", "replacement", "y")); + replayComponent(policyMgr, 3, "a_to_b", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "a", "replacement", "b")); + replayComponent(policyMgr, 4, "upper_a_to_z", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "z")); + replayComponent(policyMgr, 5, "fold", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer")); + String[][] analyzers = { + {"x_then_fold", "x_to_y,fold"}, + {"lower_x_fold", "lower_a,x_to_y,fold"}, + {"ab_then_fold", "a_to_b,fold"}, + {"lower_ab_fold", "lower_a,a_to_b,fold"}, + {"az_then_fold", "upper_a_to_z,fold"}, + {"lower_az_fold", "lower_a,upper_a_to_z,fold"}}; + long id = 10; + for (String[] analyzer : analyzers) { + replayComponent(policyMgr, id++, analyzer[0], IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", analyzer[1])); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertEquals(namedAnalyzerIdentity("x_then_fold"), + namedAnalyzerIdentity("lower_x_fold")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("ab_then_fold"), + namedAnalyzerIdentity("lower_ab_fold")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("az_then_fold"), + namedAnalyzerIdentity("lower_az_fold"))); + } + } + + @Test + public void testOuterCharFilterAbsorbedByCustomCaseFoldingPipeline() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "fold", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer")); + replayComponent(policyMgr, 2, "x_to_y", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "x", "replacement", "y")); + replayComponent(policyMgr, 3, "group_on_upper_a", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "char_group", "tokenize_on_chars", "[A]")); + replayComponent(policyMgr, 4, "ngram_custom_a", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "ngram", "token_chars", "custom", "custom_token_chars", "a")); + replayComponent(policyMgr, 5, "keyword_lower_1", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "lowercase")); + replayComponent(policyMgr, 6, "keyword_lower_2", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "lowercase")); + replayComponent(policyMgr, 7, "keyword_plain", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword")); + replayComponent(policyMgr, 8, "fold_first", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "fold")); + replayComponent(policyMgr, 9, "x_then_fold", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "x_to_y,fold")); + replayComponent(policyMgr, 10, "group_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "group_on_upper_a", "token_filter", "lowercase")); + replayComponent(policyMgr, 11, "ngram_custom_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "ngram_custom_a", "token_filter", "lowercase")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertEquals(namedAnalyzerIdentity("keyword_lower_2"), + namedAnalyzerIdentityWithOuterLowerA("keyword_lower_1")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("fold_first"), + namedAnalyzerIdentityWithOuterLowerA("fold_first")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("x_then_fold"), + namedAnalyzerIdentityWithOuterLowerA("x_then_fold")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("keyword_plain"), + namedAnalyzerIdentityWithOuterLowerA("keyword_plain")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("group_lower"), + namedAnalyzerIdentityWithOuterLowerA("group_lower")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("ngram_custom_lower"), + namedAnalyzerIdentityWithOuterLowerA("ngram_custom_lower"))); + } + } + + private static String namedNormalizerIdentity(String normalizer) { + return AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("normalizer", normalizer), normalizer, "none", "__default__", "none", null); + } + + private static String namedNormalizerIdentityWithOuterLowerA(String normalizer) { + return AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("normalizer", normalizer, "char_filter_type", "char_replace", + "char_filter_pattern", "A", "char_filter_replacement", "a"), + normalizer, "none", "__default__", "none", null); + } + + /** Replay the same pinyin settings as a tokenizer and as a token filter under distinct names. */ + private static void replayPinyinPair(IndexPolicyMgr policyMgr, long id, String name, + Map properties) { + Map pinyin = new HashMap<>(properties); + pinyin.put("type", "pinyin"); + replayComponent(policyMgr, id, name + "_tk", IndexPolicyTypeEnum.TOKENIZER, pinyin); + replayComponent(policyMgr, id + 100, name + "_tf", IndexPolicyTypeEnum.TOKEN_FILTER, pinyin); + } + + private static Object pinyinIdentity(Method resolve, String name, IndexPolicyTypeEnum type) + throws Exception { + String suffix = type == IndexPolicyTypeEnum.TOKENIZER ? "_tk" : "_tf"; + return resolve.invoke(null, name + suffix, type); + } + + @Test + public void testPinyinNoneChineseInJoinedFullPinyinFollowsJoinedGate() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayPinyinPair(policyMgr, 1, "pinyin_plain", Map.of()); + replayPinyinPair(policyMgr, 2, "pinyin_ascii_in_joined", + Map.of("keep_none_chinese_in_joined_full_pinyin", "true")); + replayPinyinPair(policyMgr, 3, "pinyin_joined", Map.of("keep_joined_full_pinyin", "true")); + replayPinyinPair(policyMgr, 4, "pinyin_joined_with_ascii", + Map.of("keep_joined_full_pinyin", "true", "keep_none_chinese_in_joined_full_pinyin", "true")); + replayPinyinPair(policyMgr, 5, "pinyin_buffer_only", + Map.of("keep_first_letter", "false", "keep_full_pinyin", "false", + "none_chinese_pinyin_tokenize", "false")); + replayPinyinPair(policyMgr, 6, "pinyin_buffer_only_ascii", + Map.of("keep_first_letter", "false", "keep_full_pinyin", "false", + "none_chinese_pinyin_tokenize", "false", + "keep_none_chinese_in_joined_full_pinyin", "true")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + for (IndexPolicyTypeEnum type : new IndexPolicyTypeEnum[] { + IndexPolicyTypeEnum.TOKENIZER, IndexPolicyTypeEnum.TOKEN_FILTER}) { + Assertions.assertAll(type.name(), + () -> Assertions.assertEquals(pinyinIdentity(resolve, "pinyin_plain", type), + pinyinIdentity(resolve, "pinyin_ascii_in_joined", type)), + () -> Assertions.assertNotEquals(pinyinIdentity(resolve, "pinyin_joined", type), + pinyinIdentity(resolve, "pinyin_joined_with_ascii", type))); + } + // Only the pinyin tokenizer consults the flag for an untokenized ASCII buffer that + // no other setting emits. + Assertions.assertAll( + () -> Assertions.assertNotEquals( + pinyinIdentity(resolve, "pinyin_buffer_only", IndexPolicyTypeEnum.TOKENIZER), + pinyinIdentity(resolve, "pinyin_buffer_only_ascii", IndexPolicyTypeEnum.TOKENIZER)), + () -> Assertions.assertEquals( + pinyinIdentity(resolve, "pinyin_buffer_only", IndexPolicyTypeEnum.TOKEN_FILTER), + pinyinIdentity(resolve, "pinyin_buffer_only_ascii", IndexPolicyTypeEnum.TOKEN_FILTER))); + } + } + + @Test + public void testPinyinSeparateNoneChinesePathIgnoresPinyinTokenize() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayPinyinPair(policyMgr, 1, "pinyin_plain", Map.of()); + replayPinyinPair(policyMgr, 2, "pinyin_separate", Map.of("keep_none_chinese_together", "false")); + replayPinyinPair(policyMgr, 3, "pinyin_separate_untokenized", + Map.of("keep_none_chinese_together", "false", "none_chinese_pinyin_tokenize", "false", + "fixed_pinyin_offset", "true")); + replayPinyinPair(policyMgr, 4, "pinyin_untokenized", Map.of("none_chinese_pinyin_tokenize", "false")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + for (IndexPolicyTypeEnum type : new IndexPolicyTypeEnum[] { + IndexPolicyTypeEnum.TOKENIZER, IndexPolicyTypeEnum.TOKEN_FILTER}) { + Assertions.assertAll(type.name(), + () -> Assertions.assertEquals(pinyinIdentity(resolve, "pinyin_separate", type), + pinyinIdentity(resolve, "pinyin_separate_untokenized", type)), + () -> Assertions.assertNotEquals(pinyinIdentity(resolve, "pinyin_plain", type), + pinyinIdentity(resolve, "pinyin_untokenized", type))); + } + } + } + + @Test + public void testEmptyUnicodeSetIcuNormalizerFoldsCase() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a")); + replayComponent(policyMgr, 2, "fold_empty", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[]")); + replayComponent(policyMgr, 3, "fold_b", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[b]")); + replayComponent(policyMgr, 4, "fold_bad", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[b")); + String[][] analyzers = { + {"fold_empty_only", "fold_empty"}, + {"lower_fold_empty", "lower_a,fold_empty"}, + {"fold_b_only", "fold_b"}, + {"lower_fold_b", "lower_a,fold_b"}, + {"fold_bad_only", "fold_bad"}, + {"lower_fold_bad", "lower_a,fold_bad"}}; + long id = 10; + for (String[] analyzer : analyzers) { + replayComponent(policyMgr, id++, analyzer[0], IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", analyzer[1])); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertEquals(namedAnalyzerIdentity("fold_empty_only"), + namedAnalyzerIdentity("lower_fold_empty")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("fold_empty_only"), + namedAnalyzerIdentityWithOuterLowerA("fold_empty_only")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("fold_b_only"), + namedAnalyzerIdentity("lower_fold_b")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("fold_bad_only"), + namedAnalyzerIdentity("lower_fold_bad"))); + } + } + + @Test + public void testOuterCharFilterAbsorbedByNormalizerPipeline() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a")); + replayComponent(policyMgr, 2, "fold", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer")); + replayComponent(policyMgr, 3, "norm_lower_1", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase")); + replayComponent(policyMgr, 4, "norm_lower_2", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase")); + replayComponent(policyMgr, 5, "norm_lower_a_then_lowercase", IndexPolicyTypeEnum.NORMALIZER, + Map.of("char_filter", "lower_a", "token_filter", "lowercase")); + replayComponent(policyMgr, 6, "norm_fold", IndexPolicyTypeEnum.NORMALIZER, + Map.of("char_filter", "fold")); + replayComponent(policyMgr, 7, "norm_ascii_1", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding")); + replayComponent(policyMgr, 8, "norm_ascii_2", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertEquals(namedNormalizerIdentity("norm_lower_2"), + namedNormalizerIdentityWithOuterLowerA("norm_lower_1")), + () -> Assertions.assertEquals(namedNormalizerIdentity("norm_lower_2"), + namedNormalizerIdentity("norm_lower_a_then_lowercase")), + () -> Assertions.assertEquals(namedNormalizerIdentity("norm_fold"), + namedNormalizerIdentityWithOuterLowerA("norm_fold")), + () -> Assertions.assertNotEquals(namedNormalizerIdentity("norm_ascii_2"), + namedNormalizerIdentityWithOuterLowerA("norm_ascii_1"))); + } + } + + @Test + public void testOuterCharFilterAbsorbedThroughAsciiTransparentTokenFilters() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "ascii", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "asciifolding")); + replayComponent(policyMgr, 2, "ascii_keep", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "asciifolding", "preserve_original", "true")); + replayComponent(policyMgr, 3, "nfc_filter", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "icu_normalizer", "name", "nfc")); + replayComponent(policyMgr, 4, "icu_fold", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "icu_normalizer")); + replayComponent(policyMgr, 5, "icu_fold_empty", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[]")); + replayComponent(policyMgr, 6, "icu_fold_b", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[b]")); + replayComponent(policyMgr, 7, "wd", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "word_delimiter")); + String[][] analyzers = { + {"ascii_lower_1", "ascii,lowercase"}, + {"ascii_lower_2", "ascii,lowercase"}, + {"ascii_keep_lower", "ascii_keep,lowercase"}, + {"nfc_lower", "nfc_filter,lowercase"}, + {"icu_fold_only", "icu_fold"}, + {"ascii_icu_fold_empty", "ascii,icu_fold_empty"}, + {"ascii_only", "ascii"}, + {"nfc_only", "nfc_filter"}, + {"icu_fold_b_only", "icu_fold_b"}, + {"wd_lower", "wd,lowercase"}}; + long id = 10; + for (String[] analyzer : analyzers) { + replayComponent(policyMgr, id++, analyzer[0], IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", analyzer[1])); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertEquals(namedAnalyzerIdentity("ascii_lower_2"), + namedAnalyzerIdentityWithOuterLowerA("ascii_lower_1")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("ascii_keep_lower"), + namedAnalyzerIdentityWithOuterLowerA("ascii_keep_lower")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("nfc_lower"), + namedAnalyzerIdentityWithOuterLowerA("nfc_lower")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("icu_fold_only"), + namedAnalyzerIdentityWithOuterLowerA("icu_fold_only")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("ascii_icu_fold_empty"), + namedAnalyzerIdentityWithOuterLowerA("ascii_icu_fold_empty")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("ascii_only"), + namedAnalyzerIdentityWithOuterLowerA("ascii_only")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("nfc_only"), + namedAnalyzerIdentityWithOuterLowerA("nfc_only")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("icu_fold_b_only"), + namedAnalyzerIdentityWithOuterLowerA("icu_fold_b_only")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("wd_lower"), + namedAnalyzerIdentityWithOuterLowerA("wd_lower"))); + } + } + + @Test + public void testExplicitComponentDefaultsMatchBuiltinIdentity() throws Exception { + IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class); + Mockito.when(policyMgr.getPolicyByName("asciifolding_defaults")).thenReturn(new IndexPolicy( + 1, "asciifolding_defaults", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "asciifolding", "preserve_original", "FALSE"))); + Mockito.when(policyMgr.getPolicyByName("edge_ngram_defaults")).thenReturn(new IndexPolicy( + 2, "edge_ngram_defaults", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "edge_ngram", "min_gram", "01", "max_gram", "002"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertEquals("asciifolding", + resolve.invoke(null, "asciifolding_defaults", IndexPolicyTypeEnum.TOKEN_FILTER)); + Assertions.assertEquals("edge_ngram", + resolve.invoke(null, "edge_ngram_defaults", IndexPolicyTypeEnum.TOKENIZER)); + } + } + + @Test + public void testNamedEmptyFiltersAreOmittedFromIdentity() { + IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class); + Mockito.when(policyMgr.getPolicyByName("empty_token_filter")).thenReturn(new IndexPolicy( + 1, "empty_token_filter", IndexPolicyTypeEnum.TOKEN_FILTER, Map.of("type", "empty"))); + Mockito.when(policyMgr.getPolicyByName("empty_char_filter")).thenReturn(new IndexPolicy( + 2, "empty_char_filter", IndexPolicyTypeEnum.CHAR_FILTER, Map.of("type", "empty"))); + Mockito.when(policyMgr.getPolicyByName("plain")).thenReturn(new IndexPolicy( + 3, "plain", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "ik_smart"))); + Mockito.when(policyMgr.getPolicyByName("padded")).thenReturn(new IndexPolicy( + 4, "padded", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "ik_smart", "token_filter", "empty_token_filter,empty", + "char_filter", "empty,empty_char_filter"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertEquals( + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "plain"), "plain", "none", "__default__", "none", null), + AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "padded"), "padded", "none", "__default__", "none", null)); + } + } + + @Test + public void testOuterCharFilterDistinguishesNamedAnalyzerIdentity() { + IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class); + Mockito.when(policyMgr.getPolicyByName("smart")).thenReturn(new IndexPolicy( + 1, "smart", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "ik_smart"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String plain = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "smart"), "smart", "none", "__default__", "none", null); + String filtered = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "smart", "char_filter_type", "char_replace", + "char_filter_pattern", "-", "char_filter_replacement", " "), + "smart", "none", "__default__", "none", null); + String defaultReplacement = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "smart", "char_filter_type", "char_replace", + "char_filter_pattern", "-"), + "smart", "none", "__default__", "none", null); + Assertions.assertNotEquals(plain, filtered); + Assertions.assertEquals(filtered, defaultReplacement); + } + } + + @Test + public void testOuterCharFilterUsesCanonicalIkBaseAndByteSetSemantics() { + IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class); + Mockito.when(policyMgr.getPolicyByName("smart")).thenReturn(new IndexPolicy( + 1, "smart", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "ik_smart"))); + Mockito.when(policyMgr.getPolicyByName("shadowed_tokenizer")).thenReturn(new IndexPolicy( + 2, "shadowed_tokenizer", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard"))); + Mockito.when(policyMgr.getPolicyByName("shadowed")).thenReturn(new IndexPolicy( + 3, "shadowed", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "shadowed_tokenizer"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String legacyFiltered = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik", "parser_mode", "ik_smart", "char_filter_type", "char_replace", + "char_filter_pattern", "-", "char_filter_replacement", " "), + "", "ik", "__default__", "none", null); + String namedFiltered = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "smart", "char_filter_type", "char_replace", + "char_filter_pattern", "-", "char_filter_replacement", " "), + "smart", "none", "__default__", "none", null); + Assertions.assertEquals(legacyFiltered, namedFiltered); + + String plainSmart = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "smart"), "smart", "none", "__default__", "none", null); + String lowercasedByIk = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "smart", "char_filter_type", "char_replace", + "char_filter_pattern", "AaA", "char_filter_replacement", "a"), + "smart", "none", "__default__", "none", null); + Assertions.assertEquals(plainSmart, lowercasedByIk); + + String reordered = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "smart", "char_filter_type", "char_replace", + "char_filter_pattern", "_--a", "char_filter_replacement", "a"), + "smart", "none", "__default__", "none", null); + String canonical = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "smart", "char_filter_type", "char_replace", + "char_filter_pattern", "-_", "char_filter_replacement", "a"), + "smart", "none", "__default__", "none", null); + Assertions.assertEquals(canonical, reordered); + + String lowerCaseDisabled = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik", "parser_mode", "ik_smart", "lower_case", "false", + "char_filter_type", "char_replace", "char_filter_pattern", "A", + "char_filter_replacement", "a"), + "", "ik", "__default__", "none", null); + String lowerCaseDisabledPlain = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik", "parser_mode", "ik_smart", "lower_case", "false"), + "", "ik", "__default__", "none", null); + // IK folds single-byte ASCII in its own buffer whatever lower_case says. + Assertions.assertEquals(lowerCaseDisabledPlain, lowerCaseDisabled); + + String lowerCaseDisabledRewritten = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik", "parser_mode", "ik_smart", "lower_case", "false", + "char_filter_type", "char_replace", "char_filter_pattern", "-", + "char_filter_replacement", " "), + "", "ik", "__default__", "none", null); + Assertions.assertNotEquals(lowerCaseDisabledPlain, lowerCaseDisabledRewritten); + + String shadowed = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "shadowed", "char_filter_type", "char_replace", + "char_filter_pattern", "A", "char_filter_replacement", "a"), + "shadowed", "none", "__default__", "none", null); + String shadowedPlain = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "shadowed"), "shadowed", "none", "__default__", "none", null); + Assertions.assertNotEquals(shadowedPlain, shadowed); + } + } + + @Test + public void testLegacyIkIdentityMatchesEquivalentCustomAnalyzer() { + IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class); + Mockito.when(policyMgr.getPolicyByName("smart_analyzer")).thenReturn(new IndexPolicy( + 1, "smart_analyzer", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "ik_smart"))); + Mockito.when(policyMgr.getPolicyByName("max_word_analyzer")).thenReturn(new IndexPolicy( + 2, "max_word_analyzer", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "ik_max_word"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String customSmart = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "smart_analyzer"), "smart_analyzer", "none", "__default__", "none", null); + String customMaxWord = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "max_word_analyzer"), "max_word_analyzer", "none", + "__default__", "none", null); + + Assertions.assertEquals(customSmart, AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik"), "", "ik", "__default__", "none", null)); + Assertions.assertEquals(customSmart, AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik", "parser_mode", "ik_smart"), "", "ik", + "__default__", "none", null)); + Assertions.assertEquals(customMaxWord, AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik", "parser_mode", "ik_max_word"), "", "ik", + "__default__", "none", null)); + Assertions.assertEquals(customMaxWord, AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "ik"), "ik", "none", "__default__", "none", null)); + Assertions.assertNotEquals(customSmart, AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik", "char_filter_type", "char_replace", "char_filter_pattern", "-"), "", "ik", + "__default__", "none", null)); + Assertions.assertNotEquals(customSmart, AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik", "lower_case", "false"), "", "ik", + "__default__", "none", null)); + Assertions.assertNotEquals(customMaxWord, AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "ik", "char_filter_type", "char_replace", "char_filter_pattern", "-"), "ik", "none", + "__default__", "none", null)); + Assertions.assertNotEquals(customMaxWord, AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "ik", "lower_case", "false"), "ik", "none", + "__default__", "none", null)); + } + } + + @Test + public void testLegacyIkIdentityIgnoresShadowingTokenizerPolicy() { + IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class); + Mockito.when(policyMgr.getPolicyByName("ik_smart")).thenReturn(new IndexPolicy( + 1, "ik_smart", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard"))); + Mockito.when(policyMgr.getPolicyByName("smart_analyzer")).thenReturn(new IndexPolicy( + 2, "smart_analyzer", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "ik_smart"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String shadowedCustom = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "smart_analyzer"), "smart_analyzer", "none", + "__default__", "none", null); + String legacy = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik"), "", "ik", "__default__", "none", null); + Assertions.assertNotEquals(shadowedCustom, legacy); + } + } + + @Test + public void testDisabledLowercaseIkModesHaveDistinctIdentities() { + String analyzerMaxWord = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "ik", "lower_case", "false"), "ik", "none", + "__default__", "none", null); + String legacySmart = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik", "lower_case", "false"), "", "ik", + "__default__", "none", null); + String legacyMaxWord = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik", "parser_mode", "ik_max_word", "lower_case", "false"), + "", "ik", "__default__", "none", null); + String lowercaseMaxWord = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "ik"), "ik", "none", "__default__", "none", null); + String lowercaseSmart = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("parser", "ik"), "", "ik", "__default__", "none", null); + + Assertions.assertNotEquals(legacySmart, analyzerMaxWord); + Assertions.assertEquals(legacyMaxWord, analyzerMaxWord); + Assertions.assertNotEquals(lowercaseMaxWord, analyzerMaxWord); + Assertions.assertNotEquals(lowercaseSmart, legacySmart); + } + + private static Method resolveComponentIdentityMethod() throws Exception { + Method resolve = AnalyzerIdentityBuilder.class.getDeclaredMethod( + "resolveComponentIdentity", String.class, IndexPolicyTypeEnum.class); + resolve.setAccessible(true); + return resolve; + } + + @Test + public void testPinyinTokenizerTrimWhitespaceOnlyAffectsOriginalCandidate() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayPinyinPair(policyMgr, 1, "pinyin_plain", Map.of()); + replayPinyinPair(policyMgr, 2, "pinyin_untrimmed", Map.of("trim_whitespace", "false")); + replayPinyinPair(policyMgr, 3, "pinyin_original", Map.of("keep_original", "true")); + replayPinyinPair(policyMgr, 4, "pinyin_original_untrimmed", + Map.of("keep_original", "true", "trim_whitespace", "false")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = resolveComponentIdentityMethod(); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + IndexPolicyTypeEnum tokenizer = IndexPolicyTypeEnum.TOKENIZER; + IndexPolicyTypeEnum filter = IndexPolicyTypeEnum.TOKEN_FILTER; + Assertions.assertAll( + () -> Assertions.assertEquals(pinyinIdentity(resolve, "pinyin_plain", tokenizer), + pinyinIdentity(resolve, "pinyin_untrimmed", tokenizer)), + () -> Assertions.assertNotEquals(pinyinIdentity(resolve, "pinyin_original", tokenizer), + pinyinIdentity(resolve, "pinyin_original_untrimmed", tokenizer)), + () -> Assertions.assertNotEquals(pinyinIdentity(resolve, "pinyin_plain", filter), + pinyinIdentity(resolve, "pinyin_untrimmed", filter))); + } + } + + @Test + public void testPinyinDedupFlagIgnoredWhenAtMostOneCandidateIsEmitted() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + Map joinedOnly = Map.of("keep_first_letter", "false", "keep_full_pinyin", "false", + "keep_none_chinese", "false", "keep_joined_full_pinyin", "true"); + replayPinyinPair(policyMgr, 1, "pinyin_joined_only", joinedOnly); + Map joinedOnlyDedup = new HashMap<>(joinedOnly); + joinedOnlyDedup.put("remove_duplicated_term", "true"); + replayPinyinPair(policyMgr, 2, "pinyin_joined_only_dedup", joinedOnlyDedup); + Map nothing = Map.of("keep_first_letter", "false", "keep_full_pinyin", "false", + "keep_none_chinese", "false"); + replayPinyinPair(policyMgr, 3, "pinyin_nothing", nothing); + Map nothingDedup = new HashMap<>(nothing); + nothingDedup.put("remove_duplicated_term", "true"); + replayPinyinPair(policyMgr, 4, "pinyin_nothing_dedup", nothingDedup); + Map fullPinyin = new HashMap<>(joinedOnly); + fullPinyin.put("keep_full_pinyin", "true"); + replayPinyinPair(policyMgr, 5, "pinyin_full", fullPinyin); + Map fullPinyinDedup = new HashMap<>(fullPinyin); + fullPinyinDedup.put("remove_duplicated_term", "true"); + replayPinyinPair(policyMgr, 6, "pinyin_full_dedup", fullPinyinDedup); + Map separateChinese = new HashMap<>(joinedOnly); + separateChinese.put("keep_separate_chinese", "true"); + replayPinyinPair(policyMgr, 7, "pinyin_chinese", separateChinese); + Map separateChineseDedup = new HashMap<>(separateChinese); + separateChineseDedup.put("remove_duplicated_term", "true"); + replayPinyinPair(policyMgr, 8, "pinyin_chinese_dedup", separateChineseDedup); + Map firstLetterOnly = Map.of("keep_full_pinyin", "false", + "keep_none_chinese", "false", "keep_joined_full_pinyin", "false"); + replayPinyinPair(policyMgr, 9, "pinyin_first_only", firstLetterOnly); + Map firstLetterOnlyDedup = new HashMap<>(firstLetterOnly); + firstLetterOnlyDedup.put("remove_duplicated_term", "true"); + replayPinyinPair(policyMgr, 10, "pinyin_first_only_dedup", firstLetterOnlyDedup); + Map firstAndJoined = Map.of("keep_full_pinyin", "false", + "keep_none_chinese", "false", "keep_joined_full_pinyin", "true"); + replayPinyinPair(policyMgr, 11, "pinyin_first_and_joined", firstAndJoined); + Map firstAndJoinedDedup = new HashMap<>(firstAndJoined); + firstAndJoinedDedup.put("remove_duplicated_term", "true"); + replayPinyinPair(policyMgr, 12, "pinyin_first_and_joined_dedup", firstAndJoinedDedup); + Map originalOnly = Map.of("keep_first_letter", "false", "keep_full_pinyin", "false", + "keep_none_chinese", "false", "keep_original", "true"); + replayPinyinPair(policyMgr, 13, "pinyin_original_only", originalOnly); + Map originalOnlyDedup = new HashMap<>(originalOnly); + originalOnlyDedup.put("remove_duplicated_term", "true"); + replayPinyinPair(policyMgr, 14, "pinyin_original_only_dedup", originalOnlyDedup); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = resolveComponentIdentityMethod(); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + for (IndexPolicyTypeEnum type : new IndexPolicyTypeEnum[] { + IndexPolicyTypeEnum.TOKENIZER, IndexPolicyTypeEnum.TOKEN_FILTER}) { + Assertions.assertAll(type.name(), + () -> Assertions.assertEquals(pinyinIdentity(resolve, "pinyin_joined_only", type), + pinyinIdentity(resolve, "pinyin_joined_only_dedup", type)), + () -> Assertions.assertEquals(pinyinIdentity(resolve, "pinyin_nothing", type), + pinyinIdentity(resolve, "pinyin_nothing_dedup", type)), + () -> Assertions.assertEquals(pinyinIdentity(resolve, "pinyin_first_only", type), + pinyinIdentity(resolve, "pinyin_first_only_dedup", type)), + () -> Assertions.assertEquals( + pinyinIdentity(resolve, "pinyin_first_and_joined", type), + pinyinIdentity(resolve, "pinyin_first_and_joined_dedup", type)), + () -> Assertions.assertEquals( + pinyinIdentity(resolve, "pinyin_original_only", type), + pinyinIdentity(resolve, "pinyin_original_only_dedup", type)), + () -> Assertions.assertNotEquals(pinyinIdentity(resolve, "pinyin_full", type), + pinyinIdentity(resolve, "pinyin_full_dedup", type)), + () -> Assertions.assertNotEquals(pinyinIdentity(resolve, "pinyin_chinese", type), + pinyinIdentity(resolve, "pinyin_chinese_dedup", type))); + } + } + } + + @Test + public void testCharReplaceReplacementByteDoesNotBlockCaseFold() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a")); + replayComponent(policyMgr, 2, "x_to_upper_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "Ax", "replacement", "A")); + replayComponent(policyMgr, 3, "upper_a_to_b", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "Ab", "replacement", "b")); + replayComponent(policyMgr, 4, "fold", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer")); + String[][] analyzers = { + {"x_upper_fold", "x_to_upper_a,fold"}, + {"lower_x_upper_fold", "lower_a,x_to_upper_a,fold"}, + {"upper_b_fold", "upper_a_to_b,fold"}, + {"lower_upper_b_fold", "lower_a,upper_a_to_b,fold"}}; + long id = 10; + for (String[] analyzer : analyzers) { + replayComponent(policyMgr, id++, analyzer[0], IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", analyzer[1])); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertEquals(namedAnalyzerIdentity("x_upper_fold"), + namedAnalyzerIdentity("lower_x_upper_fold")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("upper_b_fold"), + namedAnalyzerIdentity("lower_upper_b_fold"))); + } + } + + @Test + public void testFilteredCaseFoldAbsorbsReplacementOfCodePointInsideSet() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a")); + String[][] folds = { + {"fold_upper_a", "[A]"}, + {"fold_upper_b", "[B]"}, + {"fold_lower_a", "[a]"}, + {"fold_both_a", "[Aa]"}, + {"fold_upper_a_mark", "[A\\u0301]"}}; + long id = 10; + for (String[] fold : folds) { + replayComponent(policyMgr, id++, fold[0], IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", fold[1])); + replayComponent(policyMgr, id++, fold[0] + "_tf", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", fold[1])); + replayComponent(policyMgr, id++, fold[0] + "_only", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", fold[0])); + replayComponent(policyMgr, id++, "lower_" + fold[0], IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "lower_a," + fold[0])); + replayComponent(policyMgr, id++, fold[0] + "_tf_only", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", fold[0] + "_tf")); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + for (String fold : new String[] {"fold_upper_a", "fold_both_a"}) { + Assertions.assertAll(fold, + () -> Assertions.assertEquals(namedAnalyzerIdentity(fold + "_only"), + namedAnalyzerIdentity("lower_" + fold)), + () -> Assertions.assertEquals(namedAnalyzerIdentity(fold + "_only"), + namedAnalyzerIdentityWithOuterLowerA(fold + "_only")), + () -> Assertions.assertEquals(namedAnalyzerIdentity(fold + "_tf_only"), + namedAnalyzerIdentityWithOuterLowerA(fold + "_tf_only"))); + } + // Outside the set nothing folds, and a set holding a combining mark but not the + // lower-case letter changes which span the mark is normalized with. + for (String fold : new String[] {"fold_upper_b", "fold_lower_a", "fold_upper_a_mark"}) { + Assertions.assertAll(fold, + () -> Assertions.assertNotEquals(namedAnalyzerIdentity(fold + "_only"), + namedAnalyzerIdentity("lower_" + fold)), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity(fold + "_only"), + namedAnalyzerIdentityWithOuterLowerA(fold + "_only")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity(fold + "_tf_only"), + namedAnalyzerIdentityWithOuterLowerA(fold + "_tf_only"))); + } + } + } + + @Test + public void testWordDelimiterTypeTableDropsRulesRestatingBeClassification() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + String[][] tables = { + {"wd_absent", null}, + {"wd_b_digit", "[b => DIGIT]"}, + {"wd_a_lower_b_digit", "[a => LOWER],[b => DIGIT]"}, + {"wd_ascii_defaults_b_digit", "[A => UPPER],[1 => DIGIT],[_ => SUBWORD_DELIM],[b => DIGIT]"}, + {"wd_a_lower", "[a => LOWER]"}, + {"wd_b_lower", "[b => LOWER]"}, + {"wd_a_alpha", "[a => ALPHA]"}, + {"wd_a_digit", "[a => DIGIT]"}, + {"wd_a_b_lower", "[a => LOWER],[b => LOWER]"}, + {"wd_latin_lower_b_digit", "[é => LOWER],[b => DIGIT]"}}; + long id = 1; + for (String[] table : tables) { + Map properties = new HashMap<>(); + properties.put("type", "word_delimiter"); + if (table[1] != null) { + properties.put("type_table", table[1]); + } + replayComponent(policyMgr, id++, table[0], IndexPolicyTypeEnum.TOKEN_FILTER, properties); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = resolveComponentIdentityMethod(); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + IndexPolicyTypeEnum filter = IndexPolicyTypeEnum.TOKEN_FILTER; + Object absent = resolve.invoke(null, "wd_absent", filter); + Object digitB = resolve.invoke(null, "wd_b_digit", filter); + Object lowerA = resolve.invoke(null, "wd_a_lower", filter); + Object lowerB = resolve.invoke(null, "wd_b_lower", filter); + Assertions.assertAll( + () -> Assertions.assertEquals(lowerA, lowerB), + () -> Assertions.assertEquals(digitB, resolve.invoke(null, "wd_a_lower_b_digit", filter)), + () -> Assertions.assertEquals(digitB, + resolve.invoke(null, "wd_ascii_defaults_b_digit", filter)), + () -> Assertions.assertNotEquals(digitB, + resolve.invoke(null, "wd_latin_lower_b_digit", filter)), + // BE seeds an explicit table from u_charType but its default table from + // u_isULowercase/u_isUUppercase/u_isdigit, which differ for Latin-1 code points. + () -> Assertions.assertNotEquals(absent, lowerA), + () -> Assertions.assertNotEquals(absent, resolve.invoke(null, "wd_a_b_lower", filter)), + () -> Assertions.assertNotEquals(lowerA, resolve.invoke(null, "wd_a_alpha", filter)), + () -> Assertions.assertNotEquals(lowerA, resolve.invoke(null, "wd_a_digit", filter)), + () -> Assertions.assertNotEquals(absent, resolve.invoke(null, "wd_a_digit", filter))); + } + } + + @Test + public void testBasicExtraCharsIgnoreAlphanumerics() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "basic_plain", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "basic")); + replayComponent(policyMgr, 2, "basic_alnum", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "basic", "extra_chars", "A0z")); + replayComponent(policyMgr, 3, "basic_dash", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "basic", "extra_chars", "-")); + replayComponent(policyMgr, 4, "basic_dash_alnum", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "basic", "extra_chars", "A-0")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = resolveComponentIdentityMethod(); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + IndexPolicyTypeEnum tokenizer = IndexPolicyTypeEnum.TOKENIZER; + Object plain = resolve.invoke(null, "basic_plain", tokenizer); + Object dash = resolve.invoke(null, "basic_dash", tokenizer); + Assertions.assertAll( + () -> Assertions.assertEquals(plain, resolve.invoke(null, "basic_alnum", tokenizer)), + () -> Assertions.assertEquals(dash, resolve.invoke(null, "basic_dash_alnum", tokenizer)), + () -> Assertions.assertNotEquals(plain, dash)); + } + } + + @Test + public void testNgramCustomTokenCharsCoveredByNamedClassesAreDropped() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + String[][] tokenizers = { + {"ngram_letter", "letter", null}, + {"ngram_letter_custom_a", "letter,custom", "A"}, + {"ngram_letter_custom_dash", "letter,custom", "-"}, + {"ngram_letter_custom_a_dash", "custom,letter", "A-"}, + {"ngram_letter_custom_latin", "letter,custom", "é"}, + {"ngram_digit", "digit", null}, + {"ngram_digit_custom_a", "digit,custom", "A"}, + {"ngram_classes", "digit,punctuation,symbol", null}, + {"ngram_classes_custom", "digit,punctuation,symbol,custom", "7-$"}}; + long id = 1; + for (String[] tokenizer : tokenizers) { + Map properties = new HashMap<>(); + properties.put("type", "ngram"); + properties.put("token_chars", tokenizer[1]); + if (tokenizer[2] != null) { + properties.put("custom_token_chars", tokenizer[2]); + } + replayComponent(policyMgr, id++, tokenizer[0], IndexPolicyTypeEnum.TOKENIZER, properties); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = resolveComponentIdentityMethod(); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + IndexPolicyTypeEnum tokenizer = IndexPolicyTypeEnum.TOKENIZER; + Object letter = resolve.invoke(null, "ngram_letter", tokenizer); + Object letterDash = resolve.invoke(null, "ngram_letter_custom_dash", tokenizer); + Assertions.assertAll( + () -> Assertions.assertEquals(letter, resolve.invoke(null, "ngram_letter_custom_a", tokenizer)), + () -> Assertions.assertEquals(letterDash, + resolve.invoke(null, "ngram_letter_custom_a_dash", tokenizer)), + () -> Assertions.assertEquals(resolve.invoke(null, "ngram_classes", tokenizer), + resolve.invoke(null, "ngram_classes_custom", tokenizer)), + () -> Assertions.assertNotEquals(letter, letterDash), + () -> Assertions.assertNotEquals(letter, + resolve.invoke(null, "ngram_letter_custom_latin", tokenizer)), + () -> Assertions.assertNotEquals(resolve.invoke(null, "ngram_digit", tokenizer), + resolve.invoke(null, "ngram_digit_custom_a", tokenizer))); + } + } + + @Test + public void testCharGroupLiteralsCoveredByCategoriesAreDropped() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + String[][] tokenizers = { + {"group_letter", "[letter]"}, + {"group_letter_a", "[letter],[A]"}, + {"group_letter_dash", "[letter],[-]"}, + {"group_letter_latin", "[letter],[é]"}, + {"group_digit", "[digit]"}, + {"group_digit_a", "[digit],[A]"}, + {"group_classes", "[digit],[punctuation],[symbol]"}, + {"group_classes_literals", "[7],[digit],[-],[punctuation],[$],[symbol]"}}; + long id = 1; + for (String[] tokenizer : tokenizers) { + replayComponent(policyMgr, id++, tokenizer[0], IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "char_group", "tokenize_on_chars", tokenizer[1])); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Method resolve = resolveComponentIdentityMethod(); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + IndexPolicyTypeEnum tokenizer = IndexPolicyTypeEnum.TOKENIZER; + Object letter = resolve.invoke(null, "group_letter", tokenizer); + Assertions.assertAll( + () -> Assertions.assertEquals(letter, resolve.invoke(null, "group_letter_a", tokenizer)), + () -> Assertions.assertEquals(resolve.invoke(null, "group_classes", tokenizer), + resolve.invoke(null, "group_classes_literals", tokenizer)), + () -> Assertions.assertNotEquals(letter, resolve.invoke(null, "group_letter_dash", tokenizer)), + () -> Assertions.assertNotEquals(letter, + resolve.invoke(null, "group_letter_latin", tokenizer)), + () -> Assertions.assertNotEquals(resolve.invoke(null, "group_digit", tokenizer), + resolve.invoke(null, "group_digit_a", tokenizer))); + } + } + + @Test + public void testBuiltinNormalizerIdentityMatchesEquivalentCustomNormalizer() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "norm_lower", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase")); + replayComponent(policyMgr, 2, "norm_ascii", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String custom = namedNormalizerIdentity("norm_lower"); + Assertions.assertAll( + () -> Assertions.assertEquals(custom, namedNormalizerIdentity("lowercase")), + () -> Assertions.assertEquals(custom, namedNormalizerIdentityWithOuterLowerA("lowercase")), + () -> Assertions.assertNotEquals(namedNormalizerIdentity("norm_ascii"), + namedNormalizerIdentity("lowercase"))); + } + } + + @Test + public void testExactLegacyLowercaseNormalizerShadowsBuiltinIdentity() { + IndexPolicyMgr exactPolicyMgr = new IndexPolicyMgr(); + replayComponent(exactPolicyMgr, 1, "lowercase", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding")); + replayComponent(exactPolicyMgr, 2, "norm_ascii", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding")); + replayComponent(exactPolicyMgr, 3, "norm_lower", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase")); + Env exactEnv = Mockito.mock(Env.class); + Mockito.when(exactEnv.getIndexPolicyMgr()).thenReturn(exactPolicyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(exactEnv); + String legacy = namedNormalizerIdentity("lowercase"); + Assertions.assertAll( + () -> Assertions.assertEquals(namedNormalizerIdentity("norm_ascii"), legacy), + () -> Assertions.assertNotEquals(namedNormalizerIdentity("norm_lower"), legacy), + () -> Assertions.assertNotEquals(legacy, namedNormalizerIdentityWithOuterLowerA("lowercase"))); + } + + // A policy that only matches after normalization does not shadow the built-in normalizer. + IndexPolicyMgr normalizedPolicyMgr = new IndexPolicyMgr(); + replayComponent(normalizedPolicyMgr, 1, "LOWERCASE", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "asciifolding")); + replayComponent(normalizedPolicyMgr, 3, "norm_lower", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase")); + Env normalizedEnv = Mockito.mock(Env.class); + Mockito.when(normalizedEnv.getIndexPolicyMgr()).thenReturn(normalizedPolicyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(normalizedEnv); + String builtin = namedNormalizerIdentity("lowercase"); + Assertions.assertAll( + () -> Assertions.assertEquals(namedNormalizerIdentity("norm_lower"), builtin), + () -> Assertions.assertEquals(builtin, namedNormalizerIdentityWithOuterLowerA("lowercase"))); + } + } + + @Test + public void testCaseTransparencyUsesCanonicalTokenizerSettings() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a")); + String[][] tokenizers = { + {"ngram_letter", "ngram", "token_chars", "letter", null}, + {"ngram_letter_a", "ngram", "token_chars", "letter,custom", "A"}, + {"ngram_digit_a", "ngram", "token_chars", "digit,custom", "A"}, + {"group_letter", "char_group", "tokenize_on_chars", "[letter]", null}, + {"group_letter_a", "char_group", "tokenize_on_chars", "[letter],[A]", null}, + {"group_digit_a", "char_group", "tokenize_on_chars", "[digit],[A]", null}}; + long id = 10; + for (String[] tokenizer : tokenizers) { + Map properties = new HashMap<>(Map.of("type", tokenizer[1], tokenizer[2], tokenizer[3])); + if (tokenizer[4] != null) { + properties.put("custom_token_chars", tokenizer[4]); + } + replayComponent(policyMgr, id++, tokenizer[0], IndexPolicyTypeEnum.TOKENIZER, properties); + replayComponent(policyMgr, id++, tokenizer[0] + "_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", tokenizer[0], "token_filter", "lowercase")); + replayComponent(policyMgr, id++, tokenizer[0] + "_fold_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", tokenizer[0], "char_filter", "lower_a", "token_filter", "lowercase")); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertEquals(namedAnalyzerIdentity("ngram_letter_fold_lower"), + namedAnalyzerIdentity("ngram_letter_a_fold_lower")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("ngram_letter_a_lower"), + namedAnalyzerIdentity("ngram_letter_a_fold_lower")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("group_letter_fold_lower"), + namedAnalyzerIdentity("group_letter_a_fold_lower")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("group_letter_a_lower"), + namedAnalyzerIdentity("group_letter_a_fold_lower")), + // A literal the named classes do not cover keeps the tokenizer case sensitive. + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("ngram_digit_a_lower"), + namedAnalyzerIdentity("ngram_digit_a_fold_lower")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("group_digit_a_lower"), + namedAnalyzerIdentity("group_digit_a_fold_lower"))); + } + } + + @Test + public void testDownstreamFoldErasesLowerToUpperReplacement() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "upper_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "a", "replacement", "A")); + replayComponent(policyMgr, 2, "upper_a_to_z", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "z")); + replayComponent(policyMgr, 3, "lower_a_to_z", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "a", "replacement", "z")); + replayComponent(policyMgr, 4, "fold", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer")); + replayComponent(policyMgr, 5, "fold_upper_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[A]")); + replayComponent(policyMgr, 6, "fold_upper_b", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[B]")); + String[][] analyzers = { + {"keyword_lower", null, "lowercase"}, + {"upper_keyword_lower", "upper_a", "lowercase"}, + {"keyword_plain", null, null}, + {"upper_keyword_plain", "upper_a", null}, + {"fold_only", "fold", null}, + {"upper_fold", "upper_a,fold", null}, + {"fold_a_only", "fold_upper_a", null}, + {"upper_fold_a", "upper_a,fold_upper_a", null}, + {"fold_b_only", "fold_upper_b", null}, + {"upper_fold_b", "upper_a,fold_upper_b", null}, + {"az_fold", "upper_a_to_z,fold", null}, + {"upper_az_fold", "upper_a,upper_a_to_z,fold", null}, + {"lower_az_fold", "lower_a_to_z,fold", null}, + {"upper_lower_az_fold", "upper_a,lower_a_to_z,fold", null}}; + long id = 10; + for (String[] analyzer : analyzers) { + Map properties = new HashMap<>(Map.of("tokenizer", "keyword")); + if (analyzer[1] != null) { + properties.put("char_filter", analyzer[1]); + } + if (analyzer[2] != null) { + properties.put("token_filter", analyzer[2]); + } + replayComponent(policyMgr, id++, analyzer[0], IndexPolicyTypeEnum.ANALYZER, properties); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String outerUpperA = AnalyzerIdentityBuilder.buildAnalyzerIdentity( + Map.of("analyzer", "keyword_lower", "char_filter_type", "char_replace", + "char_filter_pattern", "a", "char_filter_replacement", "A"), + "keyword_lower", "none", "__default__", "none", null); + Assertions.assertAll( + () -> Assertions.assertEquals(namedAnalyzerIdentity("keyword_lower"), + namedAnalyzerIdentity("upper_keyword_lower")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("keyword_lower"), outerUpperA), + () -> Assertions.assertEquals(namedAnalyzerIdentity("fold_only"), + namedAnalyzerIdentity("upper_fold")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("fold_a_only"), + namedAnalyzerIdentity("upper_fold_a")), + // Without a fold, outside the fold set, or behind a filter that rewrites either + // case of the letter, the replacement changes the output. + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("keyword_plain"), + namedAnalyzerIdentity("upper_keyword_plain")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("fold_b_only"), + namedAnalyzerIdentity("upper_fold_b")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("az_fold"), + namedAnalyzerIdentity("upper_az_fold")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("lower_az_fold"), + namedAnalyzerIdentity("upper_lower_az_fold"))); + } + } + + @Test + public void testNormalizerIdentityMatchesEquivalentKeywordAnalyzer() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "lower_a", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "A", "replacement", "a")); + replayComponent(policyMgr, 2, "norm_lower", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "lowercase")); + replayComponent(policyMgr, 3, "keyword_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "lowercase")); + replayComponent(policyMgr, 4, "standard_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "standard", "token_filter", "lowercase")); + replayComponent(policyMgr, 5, "norm_char_ascii", IndexPolicyTypeEnum.NORMALIZER, + Map.of("char_filter", "lower_a", "token_filter", "asciifolding")); + replayComponent(policyMgr, 6, "keyword_char_ascii", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "lower_a", "token_filter", "asciifolding")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertEquals(namedAnalyzerIdentity("keyword_lower"), + namedNormalizerIdentity("norm_lower")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("keyword_lower"), + namedNormalizerIdentity("lowercase")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("keyword_char_ascii"), + namedNormalizerIdentity("norm_char_ascii")), + // A normalizer is a keyword pipeline, so any other tokenizer stays distinct. + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("standard_lower"), + namedNormalizerIdentity("norm_lower"))); + } + } + + @Test + public void testExplicitCharReplaceDefaultsMatchBuiltinReference() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "default_replace", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", ",._")); + replayComponent(policyMgr, 2, "default_replace_explicit", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "._,", "replacement", " ")); + replayComponent(policyMgr, 3, "other_replacement", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", ",._", "replacement", "-")); + replayComponent(policyMgr, 4, "other_pattern", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", ",.")); + String[] filters = {"char_replace", "default_replace", "default_replace_explicit", + "other_replacement", "other_pattern"}; + long id = 10; + for (String filter : filters) { + replayComponent(policyMgr, id++, filter + "_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", filter)); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String builtin = namedAnalyzerIdentity("char_replace_analyzer"); + Assertions.assertAll( + () -> Assertions.assertEquals(builtin, namedAnalyzerIdentity("default_replace_analyzer")), + () -> Assertions.assertEquals(builtin, + namedAnalyzerIdentity("default_replace_explicit_analyzer")), + () -> Assertions.assertNotEquals(builtin, + namedAnalyzerIdentity("other_replacement_analyzer")), + () -> Assertions.assertNotEquals(builtin, namedAnalyzerIdentity("other_pattern_analyzer"))); + } + } + + @Test + public void testAdjacentDuplicateIdempotentFiltersCollapse() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "named_lower", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "lowercase")); + replayComponent(policyMgr, 2, "ascii_keep", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "asciifolding", "preserve_original", "true")); + String[][] analyzers = { + {"lower_once", "lowercase"}, + {"lower_twice", "lowercase,lowercase"}, + {"lower_thrice", "lowercase,lowercase,lowercase"}, + {"lower_named_lower", "lowercase,named_lower"}, + {"fold_once", "asciifolding"}, + {"fold_twice", "asciifolding,asciifolding"}, + {"fold_keep_once", "ascii_keep"}, + {"fold_keep_twice", "ascii_keep,ascii_keep"}, + {"lower_ascii", "lowercase,asciifolding"}, + {"lower_ascii_lower", "lowercase,asciifolding,lowercase"}, + {"pinyin_once", "pinyin"}, + {"pinyin_twice", "pinyin,pinyin"}}; + long id = 10; + for (String[] analyzer : analyzers) { + replayComponent(policyMgr, id++, analyzer[0], IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", analyzer[1])); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String once = namedAnalyzerIdentity("lower_once"); + Assertions.assertAll( + () -> Assertions.assertEquals(once, namedAnalyzerIdentity("lower_twice")), + () -> Assertions.assertEquals(once, namedAnalyzerIdentity("lower_thrice")), + () -> Assertions.assertEquals(once, namedAnalyzerIdentity("lower_named_lower")), + () -> Assertions.assertEquals(namedAnalyzerIdentity("fold_once"), + namedAnalyzerIdentity("fold_twice")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("fold_keep_once"), + namedAnalyzerIdentity("fold_keep_twice")), + // Only adjacent duplicates collapse, and only for a filter proven idempotent. + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("lower_ascii"), + namedAnalyzerIdentity("lower_ascii_lower")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("pinyin_once"), + namedAnalyzerIdentity("pinyin_twice"))); + } + } + + @Test + public void testAdjacentDuplicateIcuNormalizerFiltersCollapse() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "named_icu", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "icu_normalizer", "name", "NFKC_CF")); + replayComponent(policyMgr, 2, "empty_set_icu", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[]")); + replayComponent(policyMgr, 3, "icu_nfc", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "icu_normalizer", "name", "nfc")); + replayComponent(policyMgr, 4, "icu_ascii_only", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "icu_normalizer", "unicode_set_filter", "[a-z]")); + String[][] analyzers = { + {"icu_once", "icu_normalizer"}, + {"icu_twice", "icu_normalizer,icu_normalizer"}, + {"icu_thrice", "icu_normalizer,icu_normalizer,icu_normalizer"}, + {"icu_named_repeat", "icu_normalizer,named_icu"}, + {"icu_empty_set_repeat", "named_icu,empty_set_icu"}, + {"icu_lower", "icu_normalizer,lowercase"}, + {"icu_lower_icu", "icu_normalizer,lowercase,icu_normalizer"}, + {"nfc_once", "icu_nfc"}, + {"nfc_twice", "icu_nfc,icu_nfc"}, + {"ascii_only_once", "icu_ascii_only"}, + {"ascii_only_twice", "icu_ascii_only,icu_ascii_only"}}; + long id = 10; + for (String[] analyzer : analyzers) { + replayComponent(policyMgr, id++, analyzer[0], IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", analyzer[1])); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String once = namedAnalyzerIdentity("icu_once"); + Assertions.assertAll( + () -> Assertions.assertEquals(once, namedAnalyzerIdentity("icu_twice")), + () -> Assertions.assertEquals(once, namedAnalyzerIdentity("icu_thrice")), + () -> Assertions.assertEquals(once, namedAnalyzerIdentity("icu_named_repeat")), + () -> Assertions.assertEquals(once, namedAnalyzerIdentity("icu_empty_set_repeat")), + // Only adjacent repeats of the default form collapse. + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("icu_lower"), + namedAnalyzerIdentity("icu_lower_icu")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("nfc_once"), + namedAnalyzerIdentity("nfc_twice")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("ascii_only_once"), + namedAnalyzerIdentity("ascii_only_twice"))); + } + } + + @Test + public void testAdjacentDuplicateWordDelimiterFiltersKeepDistinctIdentities() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "named_word_delimiter", IndexPolicyTypeEnum.TOKEN_FILTER, + Map.of("type", "word_delimiter", "generate_word_parts", "true", + "split_on_case_change", "true", "preserve_original", "false")); + String[][] analyzers = { + {"wd_once", "word_delimiter"}, + {"wd_twice", "word_delimiter,word_delimiter"}, + {"wd_named_repeat", "word_delimiter,named_word_delimiter"}}; + long id = 10; + for (String[] analyzer : analyzers) { + replayComponent(policyMgr, id++, analyzer[0], IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", analyzer[1])); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String once = namedAnalyzerIdentity("wd_once"); + Assertions.assertAll( + () -> Assertions.assertNotEquals(once, namedAnalyzerIdentity("wd_twice")), + () -> Assertions.assertNotEquals(once, namedAnalyzerIdentity("wd_named_repeat"))); + } + } + + @Test + public void testAdjacentDuplicateCharReplaceFiltersCollapse() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "default_replace", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", ",._", "replacement", " ")); + replayComponent(policyMgr, 2, "dash_to_space", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "-", "replacement", " ")); + replayComponent(policyMgr, 3, "dot_to_space", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", ".", "replacement", " ")); + replayComponent(policyMgr, 4, "dash_to_x", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "-", "replacement", "x")); + replayComponent(policyMgr, 5, "fold", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "icu_normalizer")); + String[][] analyzers = { + {"replace_once", "char_replace"}, + {"replace_twice", "char_replace,char_replace"}, + {"replace_thrice", "char_replace,char_replace,char_replace"}, + {"replace_named_repeat", "char_replace,default_replace"}, + {"dash_once", "dash_to_space"}, + {"dash_twice", "dash_to_space,dash_to_space"}, + {"dash_dot", "dash_to_space,dot_to_space"}, + {"dash_dot_dash", "dash_to_space,dot_to_space,dash_to_space"}, + {"dash_then_x", "dash_to_space,dash_to_x"}, + {"fold_once", "fold"}, + {"fold_twice", "fold,fold"}}; + long id = 10; + for (String[] analyzer : analyzers) { + replayComponent(policyMgr, id++, analyzer[0], IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", analyzer[1])); + } + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + String once = namedAnalyzerIdentity("replace_once"); + String dashOnce = namedAnalyzerIdentity("dash_once"); + Assertions.assertAll( + () -> Assertions.assertEquals(once, namedAnalyzerIdentity("replace_twice")), + () -> Assertions.assertEquals(once, namedAnalyzerIdentity("replace_thrice")), + () -> Assertions.assertEquals(once, namedAnalyzerIdentity("replace_named_repeat")), + () -> Assertions.assertEquals(dashOnce, namedAnalyzerIdentity("dash_twice")), + // A repeat that another filter separates keeps both entries. + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("dash_dot"), + namedAnalyzerIdentity("dash_dot_dash")), + () -> Assertions.assertNotEquals(dashOnce, namedAnalyzerIdentity("dash_then_x")), + () -> Assertions.assertNotEquals(namedAnalyzerIdentity("fold_once"), + namedAnalyzerIdentity("fold_twice"))); + } + } + + @Test + public void testIcuNfkcCaseFoldIsIdempotentOverEveryCodePoint() { + Normalizer2 normalizer = Normalizer2.getNFKCCasefoldInstance(); + StringBuilder unstable = new StringBuilder(); + for (int codePoint = Character.MIN_CODE_POINT; codePoint <= Character.MAX_CODE_POINT; ++codePoint) { + if (codePoint >= Character.MIN_SURROGATE && codePoint <= Character.MAX_SURROGATE) { + continue; + } + String once = normalizer.normalize(new String(Character.toChars(codePoint))); + if (!once.equals(normalizer.normalize(once)) && unstable.length() < 200) { + unstable.append(String.format("U+%04X ", codePoint)); + } + } + Assertions.assertEquals("", unstable.toString(), + "code points whose NFKC_CF form still changes on a second pass"); + } + + @Test + public void testPinyinLowercaseIgnoredWhenOnlyFullPinyinIsEmitted() throws Exception { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + Map pinyinOnly = Map.of("keep_first_letter", "false", + "keep_none_chinese", "false", "keep_original", "false"); + replayPinyinPair(policyMgr, 1, "pinyin_only", pinyinOnly); + replayPinyinPair(policyMgr, 2, "pinyin_only_cased", casedPinyin(pinyinOnly)); + Map withOriginal = Map.of("keep_first_letter", "false", + "keep_none_chinese", "false", "keep_original", "true"); + replayPinyinPair(policyMgr, 3, "pinyin_original", withOriginal); + replayPinyinPair(policyMgr, 4, "pinyin_original_cased", casedPinyin(withOriginal)); + Map withAscii = Map.of("keep_first_letter", "false", "keep_original", "false"); + replayPinyinPair(policyMgr, 5, "pinyin_ascii", withAscii); + replayPinyinPair(policyMgr, 6, "pinyin_ascii_cased", casedPinyin(withAscii)); + Map withFirstLetter = Map.of("keep_none_chinese", "false", "keep_original", "false"); + replayPinyinPair(policyMgr, 7, "pinyin_first_letter", withFirstLetter); + replayPinyinPair(policyMgr, 8, "pinyin_first_letter_cased", casedPinyin(withFirstLetter)); + Map withJoined = Map.of("keep_first_letter", "false", + "keep_none_chinese", "false", "keep_original", "false", "keep_joined_full_pinyin", "true"); + replayPinyinPair(policyMgr, 9, "pinyin_joined", withJoined); + replayPinyinPair(policyMgr, 10, "pinyin_joined_cased", casedPinyin(withJoined)); + Map joinedWithAscii = new HashMap<>(withJoined); + joinedWithAscii.put("keep_none_chinese_in_joined_full_pinyin", "true"); + replayPinyinPair(policyMgr, 15, "pinyin_joined_ascii", joinedWithAscii); + replayPinyinPair(policyMgr, 16, "pinyin_joined_ascii_cased", casedPinyin(joinedWithAscii)); + Map firstLetterWithoutAscii = Map.of("keep_none_chinese", "false", + "keep_original", "false", "keep_none_chinese_in_first_letter", "false"); + replayPinyinPair(policyMgr, 17, "pinyin_first_letter_dict_only", firstLetterWithoutAscii); + replayPinyinPair(policyMgr, 18, "pinyin_first_letter_dict_only_cased", + casedPinyin(firstLetterWithoutAscii)); + Map untokenizedAscii = Map.of("keep_first_letter", "false", + "keep_original", "false", "none_chinese_pinyin_tokenize", "false"); + replayPinyinPair(policyMgr, 11, "pinyin_untokenized_ascii", untokenizedAscii); + replayPinyinPair(policyMgr, 12, "pinyin_untokenized_ascii_cased", casedPinyin(untokenizedAscii)); + Map separateAscii = Map.of("keep_first_letter", "false", + "keep_original", "false", "keep_none_chinese_together", "false"); + replayPinyinPair(policyMgr, 13, "pinyin_separate_ascii", separateAscii); + replayPinyinPair(policyMgr, 14, "pinyin_separate_ascii_cased", casedPinyin(separateAscii)); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + Method resolve = resolveComponentIdentityMethod(); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertEquals( + pinyinIdentity(resolve, "pinyin_only", IndexPolicyTypeEnum.TOKENIZER), + pinyinIdentity(resolve, "pinyin_only_cased", IndexPolicyTypeEnum.TOKENIZER)), + // Outputs that can carry the source case keep lowercase significant. + () -> Assertions.assertNotEquals( + pinyinIdentity(resolve, "pinyin_original", IndexPolicyTypeEnum.TOKENIZER), + pinyinIdentity(resolve, "pinyin_original_cased", IndexPolicyTypeEnum.TOKENIZER)), + () -> Assertions.assertEquals( + pinyinIdentity(resolve, "pinyin_ascii", IndexPolicyTypeEnum.TOKENIZER), + pinyinIdentity(resolve, "pinyin_ascii_cased", IndexPolicyTypeEnum.TOKENIZER)), + () -> Assertions.assertNotEquals( + pinyinIdentity(resolve, "pinyin_untokenized_ascii", IndexPolicyTypeEnum.TOKENIZER), + pinyinIdentity(resolve, "pinyin_untokenized_ascii_cased", IndexPolicyTypeEnum.TOKENIZER)), + () -> Assertions.assertNotEquals( + pinyinIdentity(resolve, "pinyin_separate_ascii", IndexPolicyTypeEnum.TOKENIZER), + pinyinIdentity(resolve, "pinyin_separate_ascii_cased", IndexPolicyTypeEnum.TOKENIZER)), + () -> Assertions.assertNotEquals( + pinyinIdentity(resolve, "pinyin_first_letter", IndexPolicyTypeEnum.TOKENIZER), + pinyinIdentity(resolve, "pinyin_first_letter_cased", IndexPolicyTypeEnum.TOKENIZER)), + // The aggregated outputs only carry the source case when their own ASCII gate is on. + () -> Assertions.assertEquals( + pinyinIdentity(resolve, "pinyin_joined", IndexPolicyTypeEnum.TOKENIZER), + pinyinIdentity(resolve, "pinyin_joined_cased", IndexPolicyTypeEnum.TOKENIZER)), + () -> Assertions.assertNotEquals( + pinyinIdentity(resolve, "pinyin_joined_ascii", IndexPolicyTypeEnum.TOKENIZER), + pinyinIdentity(resolve, "pinyin_joined_ascii_cased", IndexPolicyTypeEnum.TOKENIZER)), + () -> Assertions.assertEquals( + pinyinIdentity(resolve, "pinyin_first_letter_dict_only", + IndexPolicyTypeEnum.TOKENIZER), + pinyinIdentity(resolve, "pinyin_first_letter_dict_only_cased", + IndexPolicyTypeEnum.TOKENIZER)), + // The token filter has its own candidate sources, so it keeps the setting. + () -> Assertions.assertNotEquals( + pinyinIdentity(resolve, "pinyin_only", IndexPolicyTypeEnum.TOKEN_FILTER), + pinyinIdentity(resolve, "pinyin_only_cased", IndexPolicyTypeEnum.TOKEN_FILTER))); + } + } + + private static Map casedPinyin(Map properties) { + Map cased = new HashMap<>(properties); + cased.put("lowercase", "false"); + return cased; + } + + private static String builtinAnalyzerIdentity(String analyzer, Map extraProperties) { + Map properties = new HashMap<>(extraProperties); + properties.put("analyzer", analyzer); + return AnalyzerIdentityBuilder.buildAnalyzerIdentity( + properties, analyzer, "none", "__default__", "none", null); + } + + private static String parserIdentity(String parser, Map extraProperties) { + Map properties = new HashMap<>(extraProperties); + properties.put("parser", parser); + return AnalyzerIdentityBuilder.buildAnalyzerIdentity( + properties, "", parser, "__default__", "none", null); + } + + private static Map outerLowerA() { + return Map.of("char_filter_type", "char_replace", + "char_filter_pattern", "A", "char_filter_replacement", "a"); + } + + @Test + public void testBuiltinBasicAndIcuTakeTheirLowercasePipelineIdentity() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "basic_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic", "token_filter", "lowercase")); + replayComponent(policyMgr, 2, "basic_plain", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic")); + replayComponent(policyMgr, 3, "icu_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "icu", "token_filter", "lowercase")); + replayComponent(policyMgr, 4, "icu_plain", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "icu")); + replayComponent(policyMgr, 5, "basic_fold", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic", "token_filter", "asciifolding")); + replayComponent(policyMgr, 6, "x_to_y", IndexPolicyTypeEnum.CHAR_FILTER, + Map.of("type", "char_replace", "pattern", "x", "replacement", "y")); + replayComponent(policyMgr, 7, "basic_lower_replaced", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic", "char_filter", "x_to_y", "token_filter", "lowercase")); + replayComponent(policyMgr, 8, "basic_extra_chars", IndexPolicyTypeEnum.TOKENIZER, + Map.of("type", "basic", "extra_chars", "-")); + replayComponent(policyMgr, 9, "basic_extra_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic_extra_chars", "token_filter", "lowercase")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertEquals(namedAnalyzerIdentity("basic_lower"), + builtinAnalyzerIdentity("basic", Map.of())), + () -> Assertions.assertEquals(namedAnalyzerIdentity("basic_lower"), + parserIdentity("basic", Map.of())), + () -> Assertions.assertEquals(namedAnalyzerIdentity("icu_lower"), + builtinAnalyzerIdentity("icu", Map.of())), + () -> Assertions.assertEquals(namedAnalyzerIdentity("icu_lower"), + parserIdentity("icu", Map.of())), + // Settings the built-in analyzer never reads leave the pipeline alone. + () -> Assertions.assertEquals(builtinAnalyzerIdentity("basic", Map.of()), + builtinAnalyzerIdentity("basic", Map.of("stopwords", "none"))), + () -> Assertions.assertEquals(builtinAnalyzerIdentity("icu", Map.of()), + builtinAnalyzerIdentity("icu", Map.of("parser_mode", "fine_grained"))), + // lower_case=false drops the LowerCaseFilter, leaving the bare tokenizer. + () -> Assertions.assertEquals(namedAnalyzerIdentity("basic_plain"), + builtinAnalyzerIdentity("basic", Map.of("lower_case", "false"))), + () -> Assertions.assertEquals(namedAnalyzerIdentity("icu_plain"), + parserIdentity("icu", Map.of("lower_case", "false"))), + () -> Assertions.assertNotEquals(builtinAnalyzerIdentity("basic", Map.of()), + builtinAnalyzerIdentity("basic", Map.of("lower_case", "false"))), + () -> Assertions.assertNotEquals(builtinAnalyzerIdentity("icu", Map.of()), + builtinAnalyzerIdentity("icu", Map.of("lower_case", "false"))), + () -> Assertions.assertNotEquals(builtinAnalyzerIdentity("basic", Map.of()), + builtinAnalyzerIdentity("icu", Map.of())), + () -> Assertions.assertNotEquals(builtinAnalyzerIdentity("basic", Map.of()), + namedAnalyzerIdentity("basic_fold")), + () -> Assertions.assertNotEquals(builtinAnalyzerIdentity("basic", Map.of()), + namedAnalyzerIdentity("basic_lower_replaced")), + () -> Assertions.assertNotEquals(builtinAnalyzerIdentity("basic", Map.of()), + namedAnalyzerIdentity("basic_extra_lower"))); + } + } + + @Test + public void testBuiltinLowercaseAnalyzerAbsorbsOuterCharFilterLikeItsPipeline() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + replayComponent(policyMgr, 1, "basic_lower", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic", "token_filter", "lowercase")); + replayComponent(policyMgr, 2, "basic_plain", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "basic")); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + + Map lowerCaseOff = new HashMap<>(outerLowerA()); + lowerCaseOff.put("lower_case", "false"); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertAll( + () -> Assertions.assertEquals(builtinAnalyzerIdentity("basic", Map.of()), + builtinAnalyzerIdentity("basic", outerLowerA())), + () -> Assertions.assertEquals(namedAnalyzerIdentityWithOuterLowerA("basic_lower"), + builtinAnalyzerIdentity("basic", outerLowerA())), + () -> Assertions.assertEquals(namedAnalyzerIdentityWithOuterLowerA("basic_lower"), + parserIdentity("basic", outerLowerA())), + // Without the LowerCaseFilter the outer rewrite still changes the terms. + () -> Assertions.assertNotEquals( + builtinAnalyzerIdentity("basic", Map.of("lower_case", "false")), + builtinAnalyzerIdentity("basic", lowerCaseOff)), + () -> Assertions.assertEquals(namedAnalyzerIdentityWithOuterLowerA("basic_plain"), + builtinAnalyzerIdentity("basic", lowerCaseOff))); + } + } + + @Test + public void testUnicodeAndStandardShareOneBuiltinIdentity() { + Assertions.assertAll( + () -> Assertions.assertEquals( + parserIdentity("standard", Map.of()), parserIdentity("unicode", Map.of())), + () -> Assertions.assertEquals(builtinAnalyzerIdentity("standard", Map.of()), + builtinAnalyzerIdentity("unicode", Map.of())), + () -> Assertions.assertEquals( + parserIdentity("standard", Map.of()), builtinAnalyzerIdentity("unicode", Map.of())), + // Both spellings hand the same settings to the same StandardAnalyzer. + () -> Assertions.assertEquals(parserIdentity("standard", Map.of("lower_case", "false")), + parserIdentity("unicode", Map.of("lower_case", "false"))), + () -> Assertions.assertEquals(parserIdentity("standard", Map.of("stopwords", "none")), + parserIdentity("unicode", Map.of("stopwords", "none"))), + () -> Assertions.assertEquals(parserIdentity("standard", outerLowerA()), + parserIdentity("unicode", outerLowerA())), + // BE matches a parser name after folding its case, so the spelling cannot separate + // two otherwise identical indexes. + () -> Assertions.assertEquals(parserIdentity("standard", Map.of()), + parserIdentity("STANDARD", Map.of())), + () -> Assertions.assertEquals(parserIdentity("standard", Map.of()), + parserIdentity("Unicode", Map.of())), + () -> Assertions.assertEquals(parserIdentity("english", Map.of()), + parserIdentity("English", Map.of())), + // The other built-ins keep their own pipelines. + () -> Assertions.assertNotEquals( + parserIdentity("unicode", Map.of()), parserIdentity("english", Map.of())), + () -> Assertions.assertNotEquals( + parserIdentity("unicode", Map.of()), parserIdentity("chinese", Map.of())), + () -> Assertions.assertNotEquals( + parserIdentity("unicode", Map.of()), parserIdentity("basic", Map.of()))); + } + + @Test + public void testBuiltinStandardIdentityKeepsEffectiveSettings() { + Map noLowercase = Map.of("lower_case", "false"); + Map noStopwords = Map.of("stopwords", "none"); + Map neitherSetting = Map.of("lower_case", "false", "stopwords", "none"); + Map noLowercaseWithOuter = new HashMap<>(outerLowerA()); + noLowercaseWithOuter.put("lower_case", "false"); + Map noStopwordsWithOuter = new HashMap<>(outerLowerA()); + noStopwordsWithOuter.put("stopwords", "none"); + Assertions.assertAll( + // Either spelling hands the same settings to the same StandardAnalyzer. + () -> Assertions.assertEquals(parserIdentity("standard", neitherSetting), + builtinAnalyzerIdentity("unicode", neitherSetting)), + // The tokenizer reads both settings, so each one keeps the terms apart. + () -> Assertions.assertNotEquals(parserIdentity("standard", Map.of()), + parserIdentity("unicode", noLowercase)), + () -> Assertions.assertNotEquals(parserIdentity("standard", Map.of()), + parserIdentity("unicode", noStopwords)), + () -> Assertions.assertNotEquals(parserIdentity("standard", noLowercase), + parserIdentity("standard", noStopwords)), + () -> Assertions.assertNotEquals(parserIdentity("standard", neitherSetting), + parserIdentity("standard", noLowercase)), + () -> Assertions.assertNotEquals(parserIdentity("standard", neitherSetting), + parserIdentity("standard", noStopwords)), + // Lower-casing every word absorbs an outer rewrite of one letter to its lower form. + () -> Assertions.assertEquals(parserIdentity("standard", Map.of()), + parserIdentity("standard", outerLowerA())), + () -> Assertions.assertEquals(parserIdentity("standard", noStopwords), + parserIdentity("unicode", noStopwordsWithOuter)), + // Without that fold the outer rewrite still changes the terms. + () -> Assertions.assertNotEquals(parserIdentity("standard", noLowercase), + parserIdentity("standard", noLowercaseWithOuter))); + } + + @Test + public void testBuiltinAnalyzersReadingOnlyLowerCaseKeepThatSetting() { + Map noLowercase = Map.of("lower_case", "false"); + Map noLowercaseWithOuter = new HashMap<>(outerLowerA()); + noLowercaseWithOuter.put("lower_case", "false"); + Assertions.assertAll(() -> { + // Each of these tokenizers lower-cases its own terms, so the setting changes the terms. + for (String parser : new String[] {"english", "chinese", "kuromoji"}) { + Assertions.assertNotEquals(parserIdentity(parser, Map.of()), + parserIdentity(parser, noLowercase), parser); + // That fold absorbs an outer rewrite of one letter to its lower form. + Assertions.assertEquals(parserIdentity(parser, Map.of()), + parserIdentity(parser, outerLowerA()), parser); + Assertions.assertNotEquals(parserIdentity(parser, noLowercase), + parserIdentity(parser, noLowercaseWithOuter), parser); + // The built-ins stay distinct from each other. + Assertions.assertNotEquals(parserIdentity(parser, Map.of()), + parserIdentity("standard", Map.of()), parser); + } + }); + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/indexpolicy/PolicyValidatorTests.java b/fe/fe-core/src/test/java/org/apache/doris/indexpolicy/PolicyValidatorTests.java index dba7ab0d5b50cb..1755acec1ccf65 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/indexpolicy/PolicyValidatorTests.java +++ b/fe/fe-core/src/test/java/org/apache/doris/indexpolicy/PolicyValidatorTests.java @@ -17,8 +17,13 @@ package org.apache.doris.indexpolicy; +import org.apache.doris.catalog.Database; import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.Index; +import org.apache.doris.catalog.OlapTable; +import org.apache.doris.catalog.info.IndexType; import org.apache.doris.common.DdlException; +import org.apache.doris.datasource.InternalCatalog; import org.apache.doris.persist.EditLog; import org.junit.jupiter.api.Assertions; @@ -32,7 +37,10 @@ import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; +import java.lang.reflect.Method; import java.util.HashMap; +import java.util.List; +import java.util.Locale; import java.util.Map; public class PolicyValidatorTests { @@ -70,6 +78,12 @@ private static IndexPolicy roundTrip(IndexPolicy policy) throws Exception { return IndexPolicy.read(new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))); } + private static IndexPolicyMgr roundTrip(IndexPolicyMgr manager) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + manager.write(new DataOutputStream(bytes)); + return IndexPolicyMgr.read(new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))); + } + // @ParameterizedTest // @ValueSource(strings = {"yes", "no", "1", "0"}) // public void testAsciiFoldingValidator_InvalidBooleanValue(String value) { @@ -262,6 +276,401 @@ public void testNewNGramPolicyPersistsCompatibilityMarker() throws Exception { policyMgr.getPolicyByName("new_ngram").getProperties().get("max_ngram_diff")); } + @Test + public void testIkTokenizersAreBuiltIn() { + Assertions.assertTrue(IndexPolicy.BUILTIN_TOKENIZERS.contains("ik_smart")); + Assertions.assertTrue(IndexPolicy.BUILTIN_TOKENIZERS.contains("ik_max_word")); + } + + @Test + public void testExactLegacyPolicyPrecedesBuiltinValidation() { + IndexPolicyMgr manager = new IndexPolicyMgr(); + manager.replayCreateIndexPolicy(new IndexPolicy( + 41, "IK", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 43, "LOWERCASE", IndexPolicyTypeEnum.TOKEN_FILTER, Map.of("type", "lowercase"))); + + DdlException analyzerException = Assertions.assertThrows( + DdlException.class, () -> manager.validateAnalyzerExists("IK")); + Assertions.assertTrue(analyzerException.getMessage().contains("is not an analyzer")); + Assertions.assertDoesNotThrow(() -> manager.validateAnalyzerExists("ik")); + + DdlException normalizerException = Assertions.assertThrows( + DdlException.class, () -> manager.validateNormalizerExists("LOWERCASE")); + Assertions.assertTrue(normalizerException.getMessage().contains("is not a normalizer")); + Assertions.assertDoesNotThrow(() -> manager.validateNormalizerExists("lowercase")); + } + + @Test + public void testNormalizerNamedAfterBuiltinAnalyzerIsUnreachable() { + IndexPolicyMgr manager = new IndexPolicyMgr(); + manager.replayCreateIndexPolicy(new IndexPolicy( + 45, "ik", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 46, "none", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "lowercase"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 47, "norm_ascii", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding"))); + + for (String name : List.of("ik", "IK", "none", " NONE ")) { + DdlException error = Assertions.assertThrows( + DdlException.class, () -> manager.validateNormalizerExists(name)); + Assertions.assertTrue(error.getMessage().contains("built-in analyzer"), error.getMessage()); + } + Assertions.assertDoesNotThrow(() -> manager.validateNormalizerExists("norm_ascii")); + Assertions.assertDoesNotThrow(() -> manager.validateNormalizerExists("lowercase")); + } + + @Test + public void testExactCaseDistinctNormalizerPolicyRemainsReachable() { + IndexPolicyMgr manager = new IndexPolicyMgr(); + manager.replayCreateIndexPolicy(new IndexPolicy( + 48, "IK", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding"))); + + Assertions.assertDoesNotThrow(() -> manager.validateNormalizerExists("IK")); + Assertions.assertDoesNotThrow(() -> manager.validateNormalizerExists("ik")); + } + + @Test + public void testCreateNormalizerPolicyRejectsBuiltinAnalyzerName() { + IndexPolicyMgr manager = new IndexPolicyMgr(); + for (String name : List.of("ik", "IK", "none", "standard")) { + DdlException error = Assertions.assertThrows(DdlException.class, + () -> manager.createIndexPolicy(false, name, IndexPolicyTypeEnum.NORMALIZER, + new HashMap<>(Map.of("token_filter", "asciifolding")))); + Assertions.assertTrue(error.getMessage().contains("conflicts with built-in"), error.getMessage()); + } + } + + @Test + public void testReplayedAnalyzerUsesExactTokenizerBinding() throws Exception { + Map invalidNgram = Map.of( + "type", "ngram", "min_gram", "1", "max_gram", "3", "max_ngram_diff", "1"); + IndexPolicyMgr manager = new IndexPolicyMgr(); + manager.replayCreateIndexPolicy(new IndexPolicy( + 50, "Foo", IndexPolicyTypeEnum.TOKENIZER, invalidNgram)); + manager.replayCreateIndexPolicy(new IndexPolicy( + 51, "foo", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 52, "invalid_exact_tokenizer_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "Foo"))); + + manager.replayCreateIndexPolicy(new IndexPolicy( + 60, "Bar", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 61, "bar", IndexPolicyTypeEnum.TOKENIZER, invalidNgram)); + manager.replayCreateIndexPolicy(new IndexPolicy( + 62, "valid_exact_tokenizer_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "Bar"))); + + manager.replayCreateIndexPolicy(new IndexPolicy( + 70, "Baz", IndexPolicyTypeEnum.TOKEN_FILTER, Map.of("type", "lowercase"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 71, "baz", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 72, "wrong_type_exact_tokenizer_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "Baz"))); + + IndexPolicyMgr restored = roundTrip(manager); + DdlException invalidException = Assertions.assertThrows(DdlException.class, + () -> restored.validateAnalyzerExists("invalid_exact_tokenizer_analyzer")); + Assertions.assertTrue(invalidException.getMessage().contains("invalid tokenizer 'Foo'")); + Assertions.assertDoesNotThrow( + () -> restored.validateAnalyzerExists("valid_exact_tokenizer_analyzer")); + DdlException typeException = Assertions.assertThrows(DdlException.class, + () -> restored.validateAnalyzerExists("wrong_type_exact_tokenizer_analyzer")); + Assertions.assertTrue(typeException.getMessage().contains("expected TOKENIZER")); + } + + @Test + public void testReplayedPoliciesRejectWrongExactNestedFilterTypes() throws Exception { + IndexPolicyMgr manager = new IndexPolicyMgr(); + manager.replayCreateIndexPolicy(new IndexPolicy( + 80, "AnalyzerToken", IndexPolicyTypeEnum.CHAR_FILTER, Map.of("type", "char_replace"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 81, "analyzertoken", IndexPolicyTypeEnum.TOKEN_FILTER, Map.of("type", "lowercase"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 82, "wrong_analyzer_token_filter", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "AnalyzerToken"))); + + manager.replayCreateIndexPolicy(new IndexPolicy( + 90, "AnalyzerChar", IndexPolicyTypeEnum.TOKEN_FILTER, Map.of("type", "lowercase"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 91, "analyzerchar", IndexPolicyTypeEnum.CHAR_FILTER, Map.of("type", "char_replace"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 92, "wrong_analyzer_char_filter", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "char_filter", "AnalyzerChar"))); + + manager.replayCreateIndexPolicy(new IndexPolicy( + 100, "NormalizerToken", IndexPolicyTypeEnum.CHAR_FILTER, Map.of("type", "char_replace"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 101, "normalizertoken", IndexPolicyTypeEnum.TOKEN_FILTER, Map.of("type", "lowercase"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 102, "wrong_normalizer_token_filter", IndexPolicyTypeEnum.NORMALIZER, + Map.of("token_filter", "NormalizerToken"))); + + manager.replayCreateIndexPolicy(new IndexPolicy( + 110, "NormalizerChar", IndexPolicyTypeEnum.TOKEN_FILTER, Map.of("type", "lowercase"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 111, "normalizerchar", IndexPolicyTypeEnum.CHAR_FILTER, Map.of("type", "char_replace"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 112, "wrong_normalizer_char_filter", IndexPolicyTypeEnum.NORMALIZER, + Map.of("char_filter", "NormalizerChar"))); + + IndexPolicyMgr restored = roundTrip(manager); + DdlException analyzerTokenException = Assertions.assertThrows(DdlException.class, + () -> restored.validateAnalyzerExists("wrong_analyzer_token_filter")); + Assertions.assertTrue(analyzerTokenException.getMessage().contains("expected TOKEN_FILTER")); + DdlException analyzerCharException = Assertions.assertThrows(DdlException.class, + () -> restored.validateAnalyzerExists("wrong_analyzer_char_filter")); + Assertions.assertTrue(analyzerCharException.getMessage().contains("expected CHAR_FILTER")); + DdlException normalizerTokenException = Assertions.assertThrows(DdlException.class, + () -> restored.validateNormalizerExists("wrong_normalizer_token_filter")); + Assertions.assertTrue(normalizerTokenException.getMessage().contains("expected TOKEN_FILTER")); + DdlException normalizerCharException = Assertions.assertThrows(DdlException.class, + () -> restored.validateNormalizerExists("wrong_normalizer_char_filter")); + Assertions.assertTrue(normalizerCharException.getMessage().contains("expected CHAR_FILTER")); + } + + @Test + public void testIfNotExistsKeepsReplayedBuiltinTokenizerNameIdempotent() { + IndexPolicyMgr manager = new IndexPolicyMgr(); + IndexPolicy replayed = new IndexPolicy( + 42, "ik_smart", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard")); + manager.replayCreateIndexPolicy(replayed); + + Assertions.assertDoesNotThrow(() -> manager.createIndexPolicy( + true, "ik_smart", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard"))); + Assertions.assertSame(replayed, manager.getPolicyByName("ik_smart")); + + DdlException exception = Assertions.assertThrows(DdlException.class, + () -> new IndexPolicyMgr().createIndexPolicy( + true, "ik_max_word", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard"))); + Assertions.assertTrue(exception.getMessage().contains("conflicts with built-in tokenizer name")); + } + + @Test + public void testNamedIkTokenizerPolicyValidation() throws Exception { + Method validate = IndexPolicyMgr.class.getDeclaredMethod( + "validateTokenizerProperties", Map.class); + validate.setAccessible(true); + IndexPolicyMgr manager = new IndexPolicyMgr(); + Assertions.assertDoesNotThrow(() -> validate.invoke(manager, Map.of("type", "ik_smart"))); + Assertions.assertDoesNotThrow(() -> validate.invoke(manager, Map.of("type", "ik_max_word"))); + } + + @Test + public void testExistingPolicyPrecedesBuiltinAfterReplay() throws Exception { + IndexPolicyMgr manager = new IndexPolicyMgr(); + manager.replayCreateIndexPolicy(new IndexPolicy( + 42, "ik_smart", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard"))); + Method validate = IndexPolicyMgr.class.getDeclaredMethod( + "validatePolicyReference", String.class, IndexPolicyTypeEnum.class); + validate.setAccessible(true); + Assertions.assertDoesNotThrow( + () -> validate.invoke(manager, "IK_SMART", IndexPolicyTypeEnum.TOKENIZER)); + } + + @Test + public void testBuiltinIkValidationIsLocaleIndependent() throws Exception { + Locale originalLocale = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + IndexPolicyMgr manager = new IndexPolicyMgr(); + Method validate = IndexPolicyMgr.class.getDeclaredMethod( + "validatePolicyReference", String.class, IndexPolicyTypeEnum.class); + validate.setAccessible(true); + Assertions.assertDoesNotThrow( + () -> validate.invoke(manager, "IK_SMART", IndexPolicyTypeEnum.TOKENIZER)); + } finally { + Locale.setDefault(originalLocale); + } + } + + @Test + public void testReplayDropPreservesSurvivingLocaleCollision() { + IndexPolicyMgr manager = new IndexPolicyMgr(); + IndexPolicy older = new IndexPolicy( + 1, "IK_SMART", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard")); + IndexPolicy newer = new IndexPolicy( + 2, "ik_smart", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "keyword")); + + manager.replayCreateIndexPolicy(older); + manager.replayCreateIndexPolicy(newer); + Assertions.assertEquals(2, manager.getCopiedIndexPolicies().size()); + Assertions.assertTrue(manager.getCopiedIndexPolicies().containsAll(List.of(older, newer))); + Assertions.assertEquals(older.getId(), manager.getPolicyByName("IK_SMART").getId()); + manager.replayDropIndexPolicy(new DropIndexPolicyLog(older.getId())); + + Assertions.assertEquals(newer.getId(), manager.getPolicyByName("IK_SMART").getId()); + Assertions.assertEquals(List.of(newer), manager.getCopiedIndexPolicies()); + } + + @Test + public void testReplayDropRestoresOlderLocaleCollision() { + IndexPolicyMgr manager = new IndexPolicyMgr(); + IndexPolicy older = new IndexPolicy( + 1, "IK_SMART", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard")); + IndexPolicy newer = new IndexPolicy( + 2, "ik_smart", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "keyword")); + + manager.replayCreateIndexPolicy(older); + manager.replayCreateIndexPolicy(newer); + manager.replayDropIndexPolicy(new DropIndexPolicyLog(newer.getId())); + + Assertions.assertEquals(older.getId(), manager.getPolicyByName("ik_smart").getId()); + Assertions.assertEquals(List.of(older), manager.getCopiedIndexPolicies()); + } + + @Test + public void testImageRebuildPreservesLegacyExactNameBindings() throws Exception { + long newerId = 1L << 32; + IndexPolicyMgr manager = new IndexPolicyMgr(); + IndexPolicy newer = new IndexPolicy( + newerId, "ik_smart", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "keyword")); + IndexPolicy older = new IndexPolicy( + 1, "IK_SMART", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard")); + + manager.replayCreateIndexPolicy(newer); + manager.replayCreateIndexPolicy(older); + IndexPolicyMgr restored = roundTrip(manager); + + Assertions.assertEquals(older.getId(), restored.getPolicyByName("IK_SMART").getId()); + Assertions.assertEquals(newerId, restored.getPolicyByName("ik_smart").getId()); + Assertions.assertEquals(newerId, restored.getPolicyByName("Ik_Smart").getId()); + } + + @Test + public void testJournalAndImageKeepLegacyExactNameBindings() throws Exception { + IndexPolicyMgr manager = new IndexPolicyMgr(); + IndexPolicy historical = new IndexPolicy( + 1, "IK_SMART", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard")); + IndexPolicy newer = new IndexPolicy( + 2, "ik_smart", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "keyword")); + IndexPolicy dependent = new IndexPolicy( + 3, "legacy_exact_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "IK_SMART")); + + manager.replayCreateIndexPolicy(historical); + manager.replayCreateIndexPolicy(newer); + manager.replayCreateIndexPolicy(dependent); + Assertions.assertEquals(historical.getId(), manager.getPolicyByName("IK_SMART").getId()); + Assertions.assertEquals(newer.getId(), manager.getPolicyByName("ik_smart").getId()); + Assertions.assertEquals(3, manager.getCopiedIndexPolicies().size()); + + IndexPolicyMgr restored = roundTrip(manager); + Assertions.assertEquals(historical.getId(), restored.getPolicyByName("IK_SMART").getId()); + Assertions.assertEquals(newer.getId(), restored.getPolicyByName("ik_smart").getId()); + Assertions.assertEquals("IK_SMART", + restored.getPolicyByName(dependent.getName()).getProperties().get("tokenizer")); + Assertions.assertEquals(3, restored.getCopiedIndexPolicies().size()); + } + + @Test + public void testExactLegacyNameControlsValidationAndDropDependencies() throws Exception { + IndexPolicyMgr manager = new IndexPolicyMgr(); + IndexPolicy exactAnalyzer = new IndexPolicy( + 10, "LEGACY_ANALYZER", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "keyword")); + IndexPolicy normalizedNormalizer = new IndexPolicy( + 11, "legacy_analyzer", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "lowercase")); + IndexPolicy historicalTokenizer = new IndexPolicy( + 20, "IK_SMART", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "standard")); + IndexPolicy normalizedTokenizer = new IndexPolicy( + 21, "ik_smart", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "keyword")); + IndexPolicy dependentAnalyzer = new IndexPolicy( + 22, "legacy_exact_analyzer", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "IK_SMART")); + + manager.replayCreateIndexPolicy(exactAnalyzer); + manager.replayCreateIndexPolicy(normalizedNormalizer); + manager.replayCreateIndexPolicy(historicalTokenizer); + manager.replayCreateIndexPolicy(normalizedTokenizer); + manager.replayCreateIndexPolicy(dependentAnalyzer); + + Assertions.assertDoesNotThrow(() -> manager.validateAnalyzerExists("LEGACY_ANALYZER")); + Assertions.assertDoesNotThrow(() -> manager.validateNormalizerExists("legacy_analyzer")); + + Env env = Mockito.mock(Env.class); + Mockito.when(env.getEditLog()).thenReturn(Mockito.mock(EditLog.class)); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + Assertions.assertDoesNotThrow(() -> manager.dropIndexPolicy( + false, "ik_smart", IndexPolicyTypeEnum.TOKENIZER)); + Assertions.assertEquals(historicalTokenizer.getId(), manager.getPolicyByName("ik_smart").getId()); + Assertions.assertThrows(DdlException.class, () -> manager.dropIndexPolicy( + false, "IK_SMART", IndexPolicyTypeEnum.TOKENIZER)); + } + } + + @Test + public void testCanonicalBuiltinAnalyzerWinsValidationOverExactLegacyPolicy() { + IndexPolicyMgr manager = new IndexPolicyMgr(); + manager.replayCreateIndexPolicy(new IndexPolicy( + 30, "ik", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "keyword"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 31, "legacy_grams", IndexPolicyTypeEnum.TOKEN_FILTER, Map.of("type", "common_grams"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 32, "standard", IndexPolicyTypeEnum.ANALYZER, + Map.of("tokenizer", "keyword", "token_filter", "legacy_grams"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 33, "English", IndexPolicyTypeEnum.TOKENIZER, Map.of("type", "keyword"))); + manager.replayCreateIndexPolicy(new IndexPolicy( + 34, "lowercase", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "keyword"))); + + Assertions.assertAll( + () -> Assertions.assertDoesNotThrow(() -> manager.validateAnalyzerExists("ik")), + () -> Assertions.assertDoesNotThrow(() -> manager.validateAnalyzerExists("standard")), + () -> Assertions.assertTrue(Assertions.assertThrows(DdlException.class, + () -> manager.validateAnalyzerExists("English")).getMessage() + .contains("is not an analyzer")), + () -> Assertions.assertTrue(Assertions.assertThrows(DdlException.class, + () -> manager.validateNormalizerExists("lowercase")).getMessage() + .contains("is not a normalizer"))); + } + + @Test + public void testDropDependencyFollowsTopLevelBuiltinPrecedence() { + IndexPolicyMgr manager = new IndexPolicyMgr(); + IndexPolicy upperIk = new IndexPolicy( + 40, "IK", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "keyword")); + IndexPolicy upperLowercase = new IndexPolicy( + 41, "LOWERCASE", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding")); + IndexPolicy exactLowercase = new IndexPolicy( + 42, "lowercase", IndexPolicyTypeEnum.NORMALIZER, Map.of("token_filter", "asciifolding")); + OlapTable table = new OlapTable(); + Database db = Mockito.mock(Database.class); + Mockito.when(db.getTables()).thenReturn(List.of(table)); + InternalCatalog catalog = Mockito.mock(InternalCatalog.class); + Mockito.when(catalog.getDbs()).thenReturn(List.of(db)); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getEditLog()).thenReturn(Mockito.mock(EditLog.class)); + Mockito.when(env.getInternalCatalog()).thenReturn(catalog); + + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + manager.replayCreateIndexPolicy(upperIk); + manager.replayCreateIndexPolicy(upperLowercase); + table.setIndexes(List.of(invertedIndex(1, "analyzer", "ik"), invertedIndex(2, "normalizer", "lowercase"))); + Assertions.assertDoesNotThrow(() -> manager.dropIndexPolicy(false, "IK", IndexPolicyTypeEnum.ANALYZER)); + Assertions.assertDoesNotThrow( + () -> manager.dropIndexPolicy(false, "LOWERCASE", IndexPolicyTypeEnum.NORMALIZER)); + + manager.replayCreateIndexPolicy(upperIk); + manager.replayCreateIndexPolicy(exactLowercase); + table.setIndexes(List.of(invertedIndex(3, "analyzer", "IK"), invertedIndex(4, "normalizer", "lowercase"))); + Assertions.assertAll( + () -> Assertions.assertTrue(Assertions.assertThrows(DdlException.class, + () -> manager.dropIndexPolicy(false, "IK", IndexPolicyTypeEnum.ANALYZER)) + .getMessage().contains("is used by index")), + () -> Assertions.assertTrue(Assertions.assertThrows(DdlException.class, + () -> manager.dropIndexPolicy(false, "lowercase", IndexPolicyTypeEnum.NORMALIZER)) + .getMessage().contains("is used by index"))); + } + } + + private static Index invertedIndex(long id, String key, String name) { + return new Index(id, "idx_" + id, List.of("content"), IndexType.INVERTED, Map.of(key, name), ""); + } + // StandardTokenizerValidator Tests @Test public void testStandardTokenizerValidator_ValidProperties() throws Exception { diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/ExpressionTranslatorTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/ExpressionTranslatorTest.java index 06236be1f47967..3f94b346fbb66a 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/ExpressionTranslatorTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/ExpressionTranslatorTest.java @@ -20,10 +20,16 @@ import org.apache.doris.analysis.ArithmeticExpr; import org.apache.doris.analysis.ArithmeticExpr.Operator; import org.apache.doris.analysis.Expr; +import org.apache.doris.analysis.ExprToThriftVisitor; import org.apache.doris.analysis.IntLiteral; +import org.apache.doris.analysis.MatchPredicate; import org.apache.doris.analysis.SlotRef; +import org.apache.doris.catalog.Env; import org.apache.doris.catalog.Function.NullableMode; import org.apache.doris.catalog.Type; +import org.apache.doris.indexpolicy.IndexPolicy; +import org.apache.doris.indexpolicy.IndexPolicyMgr; +import org.apache.doris.indexpolicy.IndexPolicyTypeEnum; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.nereids.trees.expressions.BitNot; import org.apache.doris.nereids.trees.expressions.MatchAny; @@ -33,10 +39,16 @@ import org.apache.doris.nereids.trees.expressions.literal.NullLiteral; import org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral; import org.apache.doris.nereids.types.IntegerType; +import org.apache.doris.nereids.types.StringType; +import org.apache.doris.thrift.TExprNode; import com.google.common.collect.ImmutableList; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Map; public class ExpressionTranslatorTest { @@ -57,6 +69,32 @@ public void testMatch() { Assertions.assertThrows(AnalysisException.class, () -> translator.visitMatch(matchAny, null)); } + @Test + public void testMatchTranslationPreservesResolvedPolicyNames() { + IndexPolicyMgr policyMgr = new IndexPolicyMgr(); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 50, "IK", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "keyword"))); + policyMgr.replayCreateIndexPolicy(new IndexPolicy( + 51, "Legacy", IndexPolicyTypeEnum.ANALYZER, Map.of("tokenizer", "keyword"))); + Env env = Mockito.mock(Env.class); + Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr); + try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) { + mockedEnv.when(Env::getCurrentEnv).thenReturn(env); + for (Map.Entry binding : Map.of( + "IK", "IK", "ik", "ik", "StAnDaRd", "standard", "LEGACY", "Legacy").entrySet()) { + SlotReference slot = new SlotReference("content", StringType.INSTANCE, true); + PlanTranslatorContext context = new PlanTranslatorContext(); + context.addExprIdSlotRefPair(slot.getExprId(), new SlotRef(Type.STRING, true)); + MatchAny match = new MatchAny(slot, new VarcharLiteral("abc def"), binding.getKey()); + MatchPredicate predicate = Assertions.assertInstanceOf(MatchPredicate.class, + ExpressionTranslator.INSTANCE.visitMatch(match, context)); + TExprNode node = new TExprNode(); + ExprToThriftVisitor.INSTANCE.visitMatchPredicate(predicate, node); + Assertions.assertEquals(binding.getValue(), node.getMatchPredicate().getAnalyzerName()); + } + } + } + @Test void testFlattenAndOrNullable() { SlotReference a = new SlotReference("a", IntegerType.INSTANCE, true); SlotReference b = new SlotReference("b", IntegerType.INSTANCE, false); diff --git a/regression-test/data/inverted_index_p0/analyzer/test_analyzer_identity_semantics.out b/regression-test/data/inverted_index_p0/analyzer/test_analyzer_identity_semantics.out new file mode 100644 index 00000000000000..3689e2913730a9 --- /dev/null +++ b/regression-test/data/inverted_index_p0/analyzer/test_analyzer_identity_semantics.out @@ -0,0 +1,26 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !match_builtin_ik -- +1 +4 + +-- !match_builtin_ik_mode -- +3 + +-- !match_builtin_ik_lowercase -- +1 +4 + +-- !match_implicit_ik_smart_mode -- + +-- !match_implicit_ik_smart_uppercase -- +1 +4 + +-- !match_keyword_implicit -- +1 + +-- !match_keyword_uppercase -- +1 + +-- !match_keyword_fragment -- + diff --git a/regression-test/data/inverted_index_p0/analyzer/test_ik_custom_analyzer.out b/regression-test/data/inverted_index_p0/analyzer/test_ik_custom_analyzer.out new file mode 100644 index 00000000000000..c06741e51cd37b --- /dev/null +++ b/regression-test/data/inverted_index_p0/analyzer/test_ik_custom_analyzer.out @@ -0,0 +1,13 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !smart_tokenize -- +[{\n "token": "我"\n }, {\n "token": "wo"\n }, {\n "token": "w"\n }, {\n "token": "来到"\n }, {\n "token": "laidao"\n }, {\n "token": "ld"\n }, {\n "token": "北京"\n }, {\n "token": "beijing"\n }, {\n "token": "bj"\n }, {\n "token": "清华大学"\n }, {\n "token": "qinghuadaxue"\n }, {\n "token": "qhdx"\n }] + +-- !max_word_tokenize -- +[{\n "token": "我"\n }, {\n "token": "wo"\n }, {\n "token": "w"\n }, {\n "token": "来到"\n }, {\n "token": "laidao"\n }, {\n "token": "ld"\n }, {\n "token": "北京"\n }, {\n "token": "beijing"\n }, {\n "token": "bj"\n }, {\n "token": "清华大学"\n }, {\n "token": "qinghuadaxue"\n }, {\n "token": "qhdx"\n }, {\n "token": "清华"\n }, {\n "token": "qinghua"\n }, {\n "token": "qh"\n }, {\n "token": "大学"\n }, {\n "token": "daxue"\n }, {\n "token": "dx"\n }] + +-- !smart_match -- +1 + +-- !max_word_match -- +1 +3 diff --git a/regression-test/suites/inverted_index_p0/analyzer/test_analyzer_identity_semantics.groovy b/regression-test/suites/inverted_index_p0/analyzer/test_analyzer_identity_semantics.groovy new file mode 100644 index 00000000000000..0b72ff1f2d793b --- /dev/null +++ b/regression-test/suites/inverted_index_p0/analyzer/test_analyzer_identity_semantics.groovy @@ -0,0 +1,421 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Run separately from concurrent analyzer tests that share the global policy quota. +suite("test_analyzer_identity_semantics", "nonConcurrent") { + sql "DROP TABLE IF EXISTS test_identity_modes_create" + sql "DROP TABLE IF EXISTS test_identity_modes_alter" + sql "DROP TABLE IF EXISTS test_identity_max_word_create" + sql "DROP TABLE IF EXISTS test_identity_max_word_alter" + sql "DROP TABLE IF EXISTS test_identity_char_replace_create" + sql "DROP TABLE IF EXISTS test_identity_char_replace_alter" + sql "DROP TABLE IF EXISTS test_identity_basic_create" + sql "DROP TABLE IF EXISTS test_identity_basic_alter" + sql "DROP TABLE IF EXISTS test_identity_noop_create" + sql "DROP TABLE IF EXISTS test_identity_noop_alter" + sql "DROP TABLE IF EXISTS test_identity_fold_create" + sql "DROP TABLE IF EXISTS test_identity_fold_alter" + sql "DROP TABLE IF EXISTS test_identity_outer_filter_create" + sql "DROP TABLE IF EXISTS test_identity_outer_filter_alter" + for (String mode : ["ik_smart", "ik_max_word"]) { + sql "DROP TABLE IF EXISTS test_identity_ik_lowercase_create_${mode}" + sql "DROP TABLE IF EXISTS test_identity_ik_lowercase_alter_${mode}" + } + for (String analyzer : ["test_identity_ab", "test_identity_ba", "test_identity_duplicates", + "test_identity_noop", "test_identity_plain", + "test_identity_plain_ik_smart", "test_identity_filtered_ik_smart", + "test_identity_plain_ik_max_word", "test_identity_filtered_ik_max_word", + "test_identity_fold_only", "test_identity_lower_then_fold", + "test_identity_basic_ordered", "test_identity_basic_reordered", + "test_identity_basic_duplicates"]) { + try_sql "DROP INVERTED INDEX ANALYZER IF EXISTS ${analyzer}" + } + for (String tokenizer : ["test_identity_basic_ordered_tokenizer", + "test_identity_basic_reordered_tokenizer", + "test_identity_basic_duplicates_tokenizer"]) { + try_sql "DROP INVERTED INDEX TOKENIZER IF EXISTS ${tokenizer}" + } + for (String filter : ["test_identity_cf_ab", "test_identity_cf_ba", + "test_identity_cf_duplicates", "test_identity_cf_noop", "test_identity_cf_lower_a", + "test_identity_cf_fold"]) { + try_sql "DROP INVERTED INDEX CHAR_FILTER IF EXISTS ${filter}" + } + + sql """ + CREATE TABLE test_identity_modes_create ( + id INT, content STRING, + INDEX idx_smart (content) USING INVERTED PROPERTIES("parser"="ik", "lower_case"="false"), + INDEX idx_max_word (content) USING INVERTED PROPERTIES("analyzer"="ik", "lower_case"="false"), + INDEX idx_lowercase (content) USING INVERTED PROPERTIES("analyzer"="ik"), + INDEX idx_smart_lowercase (content) USING INVERTED PROPERTIES("parser"="ik") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + sql """ + CREATE TABLE test_identity_modes_alter ( + id INT, content STRING, + INDEX idx_smart (content) USING INVERTED PROPERTIES("parser"="ik", "lower_case"="false") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + test { + sql """ + CREATE TABLE test_identity_outer_filter_create ( + id INT, content STRING, + INDEX idx_replace_a (content) USING INVERTED PROPERTIES( + "analyzer"="standard", "char_filter_type"="char_replace", + "char_filter_pattern"="a", "char_filter_replacement"="b"), + INDEX idx_replace_x (content) USING INVERTED PROPERTIES( + "analyzer"="standard", "char_filter_type"="char_replace", + "char_filter_pattern"="x", "char_filter_replacement"="y") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + sql """ + CREATE TABLE test_identity_outer_filter_alter ( + id INT, content STRING, + INDEX idx_replace_a (content) USING INVERTED PROPERTIES( + "analyzer"="standard", "char_filter_type"="char_replace", + "char_filter_pattern"="a", "char_filter_replacement"="b") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + test { + sql """ + ALTER TABLE test_identity_outer_filter_alter ADD INDEX idx_replace_x (content) USING INVERTED + PROPERTIES("analyzer"="standard", "char_filter_type"="char_replace", + "char_filter_pattern"="x", "char_filter_replacement"="y") + """ + exception "same analyzer selector already exists" + } + sql """ + ALTER TABLE test_identity_modes_alter ADD INDEX idx_max_word (content) USING INVERTED + PROPERTIES("analyzer"="ik", "lower_case"="false") + """ + test { + sql """ + CREATE TABLE test_identity_max_word_create ( + id INT, content STRING, + INDEX idx_builtin (content) USING INVERTED PROPERTIES("analyzer"="ik", "lower_case"="false"), + INDEX idx_legacy (content) USING INVERTED + PROPERTIES("parser"="ik", "parser_mode"="ik_max_word", "lower_case"="false") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + sql """ + CREATE TABLE test_identity_max_word_alter ( + id INT, content STRING, + INDEX idx_builtin (content) USING INVERTED PROPERTIES("analyzer"="ik", "lower_case"="false") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + test { + sql """ + ALTER TABLE test_identity_max_word_alter ADD INDEX idx_legacy (content) USING INVERTED + PROPERTIES("parser"="ik", "parser_mode"="ik_max_word", "lower_case"="false") + """ + exception "already exists" + } + + for (def config : [["ordered", "+-"], ["reordered", "-+"], ["duplicates", "+-+-+"]]) { + sql """ + CREATE INVERTED INDEX TOKENIZER test_identity_basic_${config[0]}_tokenizer + PROPERTIES("type"="basic", "extra_chars"="${config[1]}") + """ + sql """ + CREATE INVERTED INDEX ANALYZER test_identity_basic_${config[0]} + PROPERTIES("tokenizer"="test_identity_basic_${config[0]}_tokenizer") + """ + } + sql """ + CREATE TABLE test_identity_basic_alter ( + id INT, content STRING, + INDEX idx_ordered (content) USING INVERTED + PROPERTIES("analyzer"="test_identity_basic_ordered") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + for (String analyzer : ["test_identity_basic_reordered", "test_identity_basic_duplicates"]) { + test { + sql """ + CREATE TABLE test_identity_basic_create ( + id INT, content STRING, + INDEX idx_ordered (content) USING INVERTED + PROPERTIES("analyzer"="test_identity_basic_ordered"), + INDEX idx_equivalent (content) USING INVERTED PROPERTIES("analyzer"="${analyzer}") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + test { + sql """ + ALTER TABLE test_identity_basic_alter ADD INDEX idx_equivalent (content) USING INVERTED + PROPERTIES("analyzer"="${analyzer}") + """ + exception "already exists" + } + } + + for (def config : [["ab", "ab"], ["ba", "ba"], ["duplicates", "aabx"], ["noop", "x"]]) { + sql """ + CREATE INVERTED INDEX CHAR_FILTER test_identity_cf_${config[0]} + PROPERTIES("type"="char_replace", "pattern"="${config[1]}", "replacement"="x") + """ + sql """ + CREATE INVERTED INDEX ANALYZER test_identity_${config[0]} + PROPERTIES("tokenizer"="keyword", "char_filter"="test_identity_cf_${config[0]}") + """ + } + sql """ + CREATE INVERTED INDEX ANALYZER test_identity_plain PROPERTIES("tokenizer"="keyword") + """ + for (String analyzer : ["test_identity_ab", "test_identity_ba", "test_identity_duplicates", + "test_identity_noop", "test_identity_plain"]) { + Exception lastException = null + boolean ready = false + for (int attempt = 0; attempt < 30; attempt++) { + try { + sql """SELECT TOKENIZE('probe', '"analyzer"="${analyzer}"')""" + ready = true + break + } catch (Exception e) { + lastException = e + sleep(1000) + } + } + assertTrue(ready, "Analyzer ${analyzer} was not ready: ${lastException?.message}") + } + sql """ + CREATE TABLE test_identity_char_replace_alter ( + id INT, content STRING, + INDEX idx_ab (content) USING INVERTED PROPERTIES("analyzer"="test_identity_ab") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + for (String analyzer : ["test_identity_ba", "test_identity_duplicates"]) { + test { + sql """ + CREATE TABLE test_identity_char_replace_create ( + id INT, content STRING, + INDEX idx_ab (content) USING INVERTED PROPERTIES("analyzer"="test_identity_ab"), + INDEX idx_equivalent (content) USING INVERTED PROPERTIES("analyzer"="${analyzer}") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + test { + sql """ + ALTER TABLE test_identity_char_replace_alter ADD INDEX idx_equivalent (content) USING INVERTED + PROPERTIES("analyzer"="${analyzer}") + """ + exception "already exists" + } + } + test { + sql """ + CREATE TABLE test_identity_noop_create ( + id INT, content STRING, + INDEX idx_plain (content) USING INVERTED PROPERTIES("analyzer"="test_identity_plain"), + INDEX idx_noop (content) USING INVERTED PROPERTIES("analyzer"="test_identity_noop") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + sql """ + CREATE TABLE test_identity_noop_alter ( + id INT, content STRING, + INDEX idx_plain (content) USING INVERTED PROPERTIES("analyzer"="test_identity_plain") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + test { + sql """ + ALTER TABLE test_identity_noop_alter ADD INDEX idx_noop (content) USING INVERTED + PROPERTIES("analyzer"="test_identity_noop") + """ + exception "already exists" + } + + sql """ + CREATE INVERTED INDEX CHAR_FILTER test_identity_cf_lower_a + PROPERTIES("type"="char_replace", "pattern"="A", "replacement"="a") + """ + sql """ + CREATE INVERTED INDEX CHAR_FILTER test_identity_cf_fold + PROPERTIES("type"="icu_normalizer") + """ + sql """ + CREATE INVERTED INDEX ANALYZER test_identity_fold_only + PROPERTIES("tokenizer"="ik_smart", "char_filter"="test_identity_cf_fold") + """ + sql """ + CREATE INVERTED INDEX ANALYZER test_identity_lower_then_fold + PROPERTIES("tokenizer"="ik_smart", + "char_filter"="test_identity_cf_lower_a,test_identity_cf_fold") + """ + test { + sql """ + CREATE TABLE test_identity_fold_create ( + id INT, content STRING, + INDEX idx_fold (content) USING INVERTED + PROPERTIES("analyzer"="test_identity_fold_only"), + INDEX idx_redundant (content) USING INVERTED + PROPERTIES("analyzer"="test_identity_lower_then_fold") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + sql """ + CREATE TABLE test_identity_fold_alter ( + id INT, content STRING, + INDEX idx_fold (content) USING INVERTED + PROPERTIES("analyzer"="test_identity_fold_only") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + test { + sql """ + ALTER TABLE test_identity_fold_alter + ADD INDEX idx_redundant (content) USING INVERTED + PROPERTIES("analyzer"="test_identity_lower_then_fold") + """ + exception "already exists" + } + for (String mode : ["ik_smart", "ik_max_word"]) { + sql """ + CREATE INVERTED INDEX ANALYZER test_identity_plain_${mode} + PROPERTIES("tokenizer"="${mode}") + """ + sql """ + CREATE INVERTED INDEX ANALYZER test_identity_filtered_${mode} + PROPERTIES("tokenizer"="${mode}", "char_filter"="test_identity_cf_lower_a") + """ + test { + sql """ + CREATE TABLE test_identity_ik_lowercase_create_${mode} ( + id INT, content STRING, + INDEX idx_plain (content) USING INVERTED + PROPERTIES("analyzer"="test_identity_plain_${mode}"), + INDEX idx_filtered (content) USING INVERTED + PROPERTIES("analyzer"="test_identity_filtered_${mode}") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + sql """ + CREATE TABLE test_identity_ik_lowercase_alter_${mode} ( + id INT, content STRING, + INDEX idx_plain (content) USING INVERTED + PROPERTIES("analyzer"="test_identity_plain_${mode}") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + test { + sql """ + ALTER TABLE test_identity_ik_lowercase_alter_${mode} + ADD INDEX idx_filtered (content) USING INVERTED + PROPERTIES("analyzer"="test_identity_filtered_${mode}") + """ + exception "already exists" + } + } + + sql "INSERT INTO test_identity_modes_create VALUES (1, 'abc def'), (2, 'zzz'), (3, '清华大学'), (4, 'ABC')" + qt_match_builtin_ik """ + SELECT id FROM test_identity_modes_create WHERE content MATCH 'abc' USING ANALYZER IK + ORDER BY id + """ + qt_match_builtin_ik_mode """ + SELECT id FROM test_identity_modes_create WHERE content MATCH '清华' USING ANALYZER ik + ORDER BY id + """ + qt_match_builtin_ik_lowercase """ + SELECT id FROM test_identity_modes_create WHERE content MATCH 'ABC' USING ANALYZER ik + ORDER BY id + """ + qt_match_implicit_ik_smart_mode """ + SELECT id FROM test_identity_modes_create WHERE content MATCH '清华' + ORDER BY id + """ + qt_match_implicit_ik_smart_uppercase """ + SELECT id FROM test_identity_modes_create WHERE content MATCH 'ABC' + ORDER BY id + """ + sql "INSERT INTO test_identity_noop_alter VALUES (1, 'abc def'), (2, 'zzz')" + qt_match_keyword_implicit """ + SELECT id FROM test_identity_noop_alter WHERE content MATCH 'abc def' + ORDER BY id + """ + qt_match_keyword_uppercase """ + SELECT id FROM test_identity_noop_alter + WHERE content MATCH 'abc def' USING ANALYZER TEST_IDENTITY_PLAIN + ORDER BY id + """ + qt_match_keyword_fragment """ + SELECT id FROM test_identity_noop_alter WHERE content MATCH 'abc' + ORDER BY id + """ + + // An index created before built-in IK was matched by configuration is the only IK index of + // this column, so an explicit request keeps binding it and is tokenized with its smart mode. + sql "DROP TABLE IF EXISTS test_identity_legacy_ik_only" + sql """ + CREATE TABLE test_identity_legacy_ik_only ( + id INT, content STRING, + INDEX idx_legacy (content) USING INVERTED PROPERTIES("parser"="ik") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES("replication_allocation"="tag.location.default: 1") + """ + sql "INSERT INTO test_identity_legacy_ik_only VALUES (1, 'abc def'), (2, 'zzz'), (3, '清华大学'), (4, 'ABC')" + def legacyIkIds = { String value -> + sql(""" + SELECT id FROM test_identity_legacy_ik_only + WHERE content MATCH '${value}' USING ANALYZER ik ORDER BY id + """).collect { it[0] as int } + } + assertEquals([1, 4], legacyIkIds("abc")) + assertEquals([3], legacyIkIds("清华大学")) + // ik_max_word would also index the two-character prefix of the university name; the legacy + // index is smart and must stay bound as such. + assertEquals([], legacyIkIds("清华")) +} diff --git a/regression-test/suites/inverted_index_p0/analyzer/test_analyzer_malformed_utf8_write.groovy b/regression-test/suites/inverted_index_p0/analyzer/test_analyzer_malformed_utf8_write.groovy new file mode 100644 index 00000000000000..bd39e1be9f0bf2 --- /dev/null +++ b/regression-test/suites/inverted_index_p0/analyzer/test_analyzer_malformed_utf8_write.groovy @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// A string column may hold bytes that are not valid UTF-8. Indexing them must keep working: +// the malformed bytes are skipped and the valid text around them stays searchable. +suite("test_analyzer_malformed_utf8_write", "p0") { + def ngramTable = "test_malformed_utf8_ngram" + def icuTable = "test_malformed_utf8_icu" + + sql "DROP TABLE IF EXISTS ${ngramTable}" + sql "DROP TABLE IF EXISTS ${icuTable}" + + sql """ + CREATE INVERTED INDEX TOKENIZER IF NOT EXISTS malformed_utf8_ngram_tokenizer + PROPERTIES + ( + "type" = "ngram", + "min_gram" = "2", + "max_gram" = "2" + ); + """ + + sql """ + CREATE INVERTED INDEX ANALYZER IF NOT EXISTS malformed_utf8_ngram_analyzer + PROPERTIES + ( + "tokenizer" = "malformed_utf8_ngram_tokenizer" + ); + """ + + sql """ + CREATE INVERTED INDEX ANALYZER IF NOT EXISTS malformed_utf8_icu_analyzer + PROPERTIES + ( + "tokenizer" = "icu" + ); + """ + + for (String analyzer : ["malformed_utf8_ngram_analyzer", "malformed_utf8_icu_analyzer"]) { + Exception lastException = null + boolean ready = false + for (int attempt = 0; attempt < 30; attempt++) { + try { + sql """SELECT TOKENIZE('probe', '"analyzer"="${analyzer}"')""" + ready = true + break + } catch (Exception e) { + lastException = e + sleep(1000) + } + } + assertTrue(ready, "Analyzer ${analyzer} was not ready: ${lastException?.message}") + } + + sql """ + CREATE TABLE ${ngramTable} ( + `id` int NOT NULL, + `ch` text NULL, + INDEX idx_ch (`ch`) USING INVERTED PROPERTIES("analyzer" = "malformed_utf8_ngram_analyzer") + ) ENGINE=OLAP + DUPLICATE KEY(`id`) + DISTRIBUTED BY HASH(`id`) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1"); + """ + + sql """ + CREATE TABLE ${icuTable} ( + `id` int NOT NULL, + `ch` text NULL, + INDEX idx_ch (`ch`) USING INVERTED PROPERTIES("analyzer" = "malformed_utf8_icu_analyzer") + ) ENGINE=OLAP + DUPLICATE KEY(`id`) + DISTRIBUTED BY HASH(`id`) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1"); + """ + + // An overlong encoding and a byte that can never start a sequence. + sql """ INSERT INTO ${ngramTable} VALUES (1, CONCAT('abcd', UNHEX('C0AF'), 'efgh')) """ + sql """ INSERT INTO ${ngramTable} VALUES (2, CONCAT('wxyz', UNHEX('FF'))) """ + sql """ INSERT INTO ${ngramTable} VALUES (3, 'plain') """ + sql """ INSERT INTO ${icuTable} VALUES (1, CONCAT('alpha ', UNHEX('FF'), ' beta')) """ + sql """ INSERT INTO ${icuTable} VALUES (2, 'gamma delta') """ + + sql "sync" + + // Every row was written, so the malformed bytes did not fail the index write. + assertEquals(3, sql("SELECT COUNT(*) FROM ${ngramTable}")[0][0]) + assertEquals(2, sql("SELECT COUNT(*) FROM ${icuTable}")[0][0]) + + // The valid text on both sides of the malformed bytes is still indexed. + assertEquals(1, sql("SELECT COUNT(*) FROM ${ngramTable} WHERE ch MATCH 'ab'")[0][0]) + assertEquals(1, sql("SELECT COUNT(*) FROM ${ngramTable} WHERE ch MATCH 'ef'")[0][0]) + assertEquals(1, sql("SELECT COUNT(*) FROM ${ngramTable} WHERE ch MATCH 'wx'")[0][0]) + assertEquals(1, sql("SELECT COUNT(*) FROM ${icuTable} WHERE ch MATCH 'alpha'")[0][0]) + assertEquals(1, sql("SELECT COUNT(*) FROM ${icuTable} WHERE ch MATCH 'beta'")[0][0]) +} diff --git a/regression-test/suites/inverted_index_p0/analyzer/test_ik_custom_analyzer.groovy b/regression-test/suites/inverted_index_p0/analyzer/test_ik_custom_analyzer.groovy new file mode 100644 index 00000000000000..9c40f39dc9296a --- /dev/null +++ b/regression-test/suites/inverted_index_p0/analyzer/test_ik_custom_analyzer.groovy @@ -0,0 +1,381 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Run separately from concurrent analyzer tests that share the global policy quota. +suite("test_ik_custom_analyzer", "nonConcurrent") { + def pinyinFilter = "test_ik_pinyin_filter" + def smartAnalyzer = "test_ik_smart_pinyin_analyzer" + def maxWordAnalyzer = "test_ik_max_word_pinyin_analyzer" + def smartOnlyAnalyzer = "test_ik_smart_only_analyzer" + def maxWordOnlyAnalyzer = "test_ik_max_word_only_analyzer" + def paddedSmartOnlyAnalyzer = "test_ik_padded_smart_only_analyzer" + def emptyTokenFilter = "test_ik_empty_token_filter" + def emptyCharFilter = "test_ik_empty_char_filter" + def emptyPaddedAnalyzer = "test_ik_empty_padded_analyzer" + + sql "DROP TABLE IF EXISTS test_ik_custom_analyzer" + sql "DROP TABLE IF EXISTS test_ik_legacy_custom_alter" + sql "DROP TABLE IF EXISTS test_ik_legacy_custom_create" + sql "DROP TABLE IF EXISTS test_ik_builtin_custom_alter" + sql "DROP TABLE IF EXISTS test_ik_builtin_custom_create" + sql "DROP TABLE IF EXISTS test_ik_padded_custom_alter" + sql "DROP TABLE IF EXISTS test_ik_padded_custom_create" + sql "DROP TABLE IF EXISTS test_ik_empty_custom_alter" + sql "DROP TABLE IF EXISTS test_ik_empty_custom_create" + sql "DROP TABLE IF EXISTS test_ik_outer_filter_alter" + sql "DROP TABLE IF EXISTS test_ik_outer_filter_create" + sql "DROP TABLE IF EXISTS test_ik_lowercase_outer_filter_alter" + sql "DROP TABLE IF EXISTS test_ik_lowercase_outer_filter_create" + try_sql "DROP INVERTED INDEX ANALYZER IF EXISTS ${emptyPaddedAnalyzer}" + try_sql "DROP INVERTED INDEX ANALYZER IF EXISTS ${smartAnalyzer}" + try_sql "DROP INVERTED INDEX ANALYZER IF EXISTS ${maxWordAnalyzer}" + try_sql "DROP INVERTED INDEX ANALYZER IF EXISTS ${smartOnlyAnalyzer}" + try_sql "DROP INVERTED INDEX ANALYZER IF EXISTS ${maxWordOnlyAnalyzer}" + try_sql "DROP INVERTED INDEX ANALYZER IF EXISTS ${paddedSmartOnlyAnalyzer}" + try_sql "DROP INVERTED INDEX TOKEN_FILTER IF EXISTS ${pinyinFilter}" + try_sql "DROP INVERTED INDEX TOKEN_FILTER IF EXISTS ${emptyTokenFilter}" + try_sql "DROP INVERTED INDEX CHAR_FILTER IF EXISTS ${emptyCharFilter}" + + sql """ + CREATE INVERTED INDEX TOKEN_FILTER IF NOT EXISTS ${pinyinFilter} + PROPERTIES ( + "type" = "pinyin", + "keep_none_chinese" = "false", + "keep_first_letter" = "true", + "keep_full_pinyin" = "false", + "keep_separate_first_letter" = "false", + "keep_original" = "true", + "keep_joined_full_pinyin" = "true" + ) + """ + sql """ + CREATE INVERTED INDEX ANALYZER IF NOT EXISTS ${smartAnalyzer} + PROPERTIES ( + "tokenizer" = "ik_smart", + "token_filter" = "${pinyinFilter}" + ) + """ + sql """ + CREATE INVERTED INDEX ANALYZER IF NOT EXISTS ${maxWordAnalyzer} + PROPERTIES ( + "tokenizer" = "ik_max_word", + "token_filter" = "${pinyinFilter}" + ) + """ + sql """ + CREATE INVERTED INDEX ANALYZER IF NOT EXISTS ${smartOnlyAnalyzer} + PROPERTIES ("tokenizer" = "ik_smart") + """ + sql """ + CREATE INVERTED INDEX ANALYZER IF NOT EXISTS ${maxWordOnlyAnalyzer} + PROPERTIES ("tokenizer" = "ik_max_word") + """ + sql """ + CREATE INVERTED INDEX ANALYZER IF NOT EXISTS ${paddedSmartOnlyAnalyzer} + PROPERTIES ("tokenizer" = " IK_SMART ") + """ + sql """ + CREATE INVERTED INDEX TOKEN_FILTER IF NOT EXISTS ${emptyTokenFilter} + PROPERTIES ("type" = "empty") + """ + sql """ + CREATE INVERTED INDEX CHAR_FILTER IF NOT EXISTS ${emptyCharFilter} + PROPERTIES ("type" = "empty") + """ + sql """ + CREATE INVERTED INDEX ANALYZER IF NOT EXISTS ${emptyPaddedAnalyzer} + PROPERTIES ( + "tokenizer" = "ik_smart", + "token_filter" = "empty,${emptyTokenFilter}", + "char_filter" = "${emptyCharFilter},empty" + ) + """ + + def waitAnalyzerReady = { analyzerName -> + int maxRetry = 30 + Exception lastException = null + for (int i = 0; i < maxRetry; i++) { + try { + sql """SELECT TOKENIZE('probe', '"analyzer"="${analyzerName}"')""" + return + } catch (Exception e) { + lastException = e + sleep(1000) + } + } + assertTrue(false, "Analyzer ${analyzerName} was not ready: ${lastException?.message}") + } + + waitAnalyzerReady(smartAnalyzer) + waitAnalyzerReady(maxWordAnalyzer) + waitAnalyzerReady(smartOnlyAnalyzer) + waitAnalyzerReady(maxWordOnlyAnalyzer) + waitAnalyzerReady(paddedSmartOnlyAnalyzer) + waitAnalyzerReady(emptyPaddedAnalyzer) + + test { + sql """ + CREATE TABLE test_ik_legacy_custom_create ( + id INT, + content STRING, + INDEX idx_legacy (content) USING INVERTED PROPERTIES("parser" = "ik"), + INDEX idx_custom (content) USING INVERTED PROPERTIES("analyzer" = "${smartOnlyAnalyzer}") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + + sql """ + CREATE TABLE test_ik_legacy_custom_alter ( + id INT, + content STRING, + INDEX idx_legacy (content) USING INVERTED + PROPERTIES("parser" = "ik", "parser_mode" = "ik_max_word") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + test { + sql """ + ALTER TABLE test_ik_legacy_custom_alter + ADD INDEX idx_custom (content) USING INVERTED + PROPERTIES("analyzer" = "${maxWordOnlyAnalyzer}") + """ + exception "already exists" + } + + test { + sql """ + CREATE TABLE test_ik_empty_custom_create ( + id INT, + content STRING, + INDEX idx_plain (content) USING INVERTED PROPERTIES("analyzer" = "${smartOnlyAnalyzer}"), + INDEX idx_empty (content) USING INVERTED PROPERTIES("analyzer" = "${emptyPaddedAnalyzer}") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + + sql """ + CREATE TABLE test_ik_empty_custom_alter ( + id INT, + content STRING, + INDEX idx_plain (content) USING INVERTED PROPERTIES("analyzer" = "${smartOnlyAnalyzer}") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + test { + sql """ + ALTER TABLE test_ik_empty_custom_alter + ADD INDEX idx_empty (content) USING INVERTED + PROPERTIES("analyzer" = "${emptyPaddedAnalyzer}") + """ + exception "already exists" + } + + test { + sql """ + CREATE TABLE test_ik_outer_filter_create ( + id INT, + content STRING, + INDEX idx_legacy (content) USING INVERTED + PROPERTIES("parser" = "ik", "parser_mode" = "ik_smart", + "char_filter_type" = "char_replace", "char_filter_pattern" = "-", + "char_filter_replacement" = " "), + INDEX idx_filtered (content) USING INVERTED + PROPERTIES("analyzer" = "${smartOnlyAnalyzer}", + "char_filter_type" = "char_replace", "char_filter_pattern" = "-", + "char_filter_replacement" = " ") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + + sql """ + CREATE TABLE test_ik_outer_filter_alter ( + id INT, + content STRING, + INDEX idx_legacy (content) USING INVERTED + PROPERTIES("parser" = "ik", "parser_mode" = "ik_smart", + "char_filter_type" = "char_replace", "char_filter_pattern" = "-", + "char_filter_replacement" = " ") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + test { + sql """ + ALTER TABLE test_ik_outer_filter_alter + ADD INDEX idx_filtered (content) USING INVERTED + PROPERTIES("analyzer" = "${smartOnlyAnalyzer}", + "char_filter_type" = "char_replace", "char_filter_pattern" = "-", + "char_filter_replacement" = " ") + """ + exception "already exists" + } + + test { + sql """ + CREATE TABLE test_ik_lowercase_outer_filter_create ( + id INT, + content STRING, + INDEX idx_plain (content) USING INVERTED + PROPERTIES("parser" = "ik", "parser_mode" = "ik_smart"), + INDEX idx_lowercase (content) USING INVERTED + PROPERTIES("analyzer" = "${smartOnlyAnalyzer}", + "char_filter_type" = "char_replace", "char_filter_pattern" = "AaA", + "char_filter_replacement" = "a") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + + sql """ + CREATE TABLE test_ik_lowercase_outer_filter_alter ( + id INT, + content STRING, + INDEX idx_plain (content) USING INVERTED + PROPERTIES("parser" = "ik", "parser_mode" = "ik_smart") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + test { + sql """ + ALTER TABLE test_ik_lowercase_outer_filter_alter + ADD INDEX idx_lowercase (content) USING INVERTED + PROPERTIES("analyzer" = "${smartOnlyAnalyzer}", + "char_filter_type" = "char_replace", "char_filter_pattern" = "AaA", + "char_filter_replacement" = "a") + """ + exception "already exists" + } + + test { + sql """ + CREATE TABLE test_ik_builtin_custom_create ( + id INT, + content STRING, + INDEX idx_builtin (content) USING INVERTED PROPERTIES("analyzer" = "ik"), + INDEX idx_custom (content) USING INVERTED PROPERTIES("analyzer" = "${maxWordOnlyAnalyzer}") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + + sql """ + CREATE TABLE test_ik_builtin_custom_alter ( + id INT, + content STRING, + INDEX idx_builtin (content) USING INVERTED PROPERTIES("analyzer" = "ik") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + test { + sql """ + ALTER TABLE test_ik_builtin_custom_alter + ADD INDEX idx_custom (content) USING INVERTED + PROPERTIES("analyzer" = "${maxWordOnlyAnalyzer}") + """ + exception "already exists" + } + + test { + sql """ + CREATE TABLE test_ik_padded_custom_create ( + id INT, + content STRING, + INDEX idx_plain (content) USING INVERTED PROPERTIES("analyzer" = "${smartOnlyAnalyzer}"), + INDEX idx_padded (content) USING INVERTED PROPERTIES("analyzer" = "${paddedSmartOnlyAnalyzer}") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + exception "cannot have multiple inverted indexes" + } + + sql """ + CREATE TABLE test_ik_padded_custom_alter ( + id INT, + content STRING, + INDEX idx_plain (content) USING INVERTED PROPERTIES("analyzer" = "${smartOnlyAnalyzer}") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ("replication_allocation" = "tag.location.default: 1") + """ + test { + sql """ + ALTER TABLE test_ik_padded_custom_alter + ADD INDEX idx_padded (content) USING INVERTED + PROPERTIES("analyzer" = "${paddedSmartOnlyAnalyzer}") + """ + exception "already exists" + } + + qt_smart_tokenize """ + SELECT TOKENIZE('我来到北京清华大学', '"analyzer"="${smartAnalyzer}"') + """ + qt_max_word_tokenize """ + SELECT TOKENIZE('我来到北京清华大学', '"analyzer"="${maxWordAnalyzer}"') + """ + + sql """ + CREATE TABLE test_ik_custom_analyzer ( + id INT, + content STRING, + INDEX idx_smart (content) USING INVERTED + PROPERTIES("analyzer" = "${smartAnalyzer}"), + INDEX idx_max_word (content) USING INVERTED + PROPERTIES("analyzer" = "${maxWordAnalyzer}") + ) DUPLICATE KEY(id) + DISTRIBUTED BY HASH(id) BUCKETS 1 + PROPERTIES ( + "replication_allocation" = "tag.location.default: 1", + "disable_auto_compaction" = "true" + ) + """ + sql """ + INSERT INTO test_ik_custom_analyzer VALUES + (1, '清华大学'), + (2, '北京大学'), + (3, '清华园') + """ + sql "SYNC" + + order_qt_smart_match """ + SELECT id FROM test_ik_custom_analyzer + WHERE content MATCH 'qinghuadaxue' USING ANALYZER ${smartAnalyzer} + ORDER BY id + """ + order_qt_max_word_match """ + SELECT id FROM test_ik_custom_analyzer + WHERE content MATCH 'qinghua' USING ANALYZER ${maxWordAnalyzer} + ORDER BY id + """ +}