001package gudusoft.gsqlparser.sqlenv;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.TBaseType;
005import gudusoft.gsqlparser.sqlenv.IdentifierRules.CaseCompare;
006import gudusoft.gsqlparser.sqlenv.IdentifierRules.CaseFold;
007import gudusoft.gsqlparser.sqlenv.unicode.GspCaseData;
008import gudusoft.gsqlparser.util.SQLUtil;
009
010import java.text.Collator;
011import java.util.ArrayList;
012import java.util.EnumMap;
013import java.util.List;
014import java.util.Map;
015
016/**
017 * 标识符服务(Identifier Service)
018 *
019 * <p>提供所有标识符规范化和比较的统一入口。</p>
020 *
021 * <p><strong>为什么需要它(面向入门者):</strong><br>
022 * 不同数据库对名称的大小写和引号行为差别很大(是否区分大小写、是否折叠为大/小写、是否受 collation 影响)。
023 * 如果在业务代码里到处手写 {@code toLowerCase/equalsIgnoreCase},很容易出错且难以维护。
024 * 本服务将“如何折叠与如何比较”的规则集中在一个地方,确保全局一致、可扩展、易测试。</p>
025 *
026 * <p><strong>与其它类的关系:</strong>
027 * <ul>
028 *   <li>{@link IdentifierRules}:一张“规则卡片”(策略),描述未引号/带引号的折叠(fold)与比较(compare)。</li>
029 *   <li>{@link IdentifierProfile}:一个“厂商档案”,为某个数据库厂商打包不同对象组(表/列/函数)的规则,并携带厂商开关(如 MySQL lower_case_table_names、SQL Server collation)。</li>
030 *   <li>IdentifierService:基于 Profile 执行规范化与比较,向外提供统一的 {@code normalize/areEqual/keyForMap} 接口(门面)。</li>
031 * </ul>
032 *
033 * <p><strong>设计理念与收益:</strong>
034 * <ul>
035 *   <li>一致性:所有地方都通过同一入口生成 Map 键与做比较,避免行为分裂。</li>
036 *   <li>可扩展:新增厂商仅需补一份规则工厂;旧调用不变。</li>
037 *   <li>可测试:规则与服务可单测覆盖,验证各厂商/对象类型是否符合预期。</li>
038 *   <li>性能友好:{@link #keyForMap} 先做规范化,Map 查找 O(1);SQL Server 经 {@link CollatorProvider} 做 collation 比较。</li>
039 * </ul>
040 *
041 * <p><strong>使用了哪些设计模式:</strong>
042 * <ul>
043 *   <li>策略(Strategy):{@link IdentifierRules} 即为可替换的规则策略。</li>
044 *   <li>工厂(Factory):{@code IdentifierRules.forOracle()/forPostgreSQL()}、{@code IdentifierProfile.forVendor(...)} 产出预设策略组合。</li>
045 *   <li>门面(Facade):本类用少量方法对外隐藏折叠/比较/引号/Collator 细节。</li>
046 *   <li>依赖注入(DI):构造时注入 {@link IdentifierProfile} 与可选 {@link CollatorProvider},便于替换与测试。</li>
047 *   <li>不可变值对象(Immutable):规则/flags 不可变,线程安全、可缓存。</li>
048 *   <li>Provider:{@link CollatorProvider} 解耦 SQL Server 的 collation 依赖。</li>
049 * </ul>
050 *
051 * <p><strong>关键约束(必须遵守):</strong>
052 * <ul>
053 *   <li>所有索引键构造必须通过 {@link #keyForMap}</li>
054 *   <li>所有标识符比较必须通过 {@link #areEqual}</li>
055 *   <li>禁止在业务代码中直接调用 {@code String.toUpperCase/toLowerCase/equalsIgnoreCase}</li>
056 * </ul>
057 *
058 * <p><strong>使用示例:</strong>
059 * <pre>
060 * IdentifierService service = new IdentifierService(profile, collatorProvider);
061 *
062 * // 规范化标识符(用于索引键)
063 * String key = service.normalize("MyTable", ESQLDataObjectType.dotTable);
064 * // Oracle: "MYTABLE", PostgreSQL: "mytable", ClickHouse: "MyTable"
065 *
066 * // 比较两个标识符
067 * boolean eq = service.areEqual("MyTable", "MYTABLE", ESQLDataObjectType.dotTable);
068 * // Oracle: true, ClickHouse: false
069 *
070 * // 构造索引键 (key equality is exactly areEqual; NOT the stored name normalize returns)
071 * String mapKey = service.keyForMap("MyTable", ESQLDataObjectType.dotTable);
072 * </pre>
073 *
074 * @since 3.1.0.9
075 */
076public class IdentifierService {
077
078    // ===== Static Cache for High-Performance Normalization =====
079
080    /**
081     * Static cache: one IdentifierService instance per database vendor
082     *
083     * <p>Pre-populated at class loading time for maximum performance.
084     * EnumMap provides O(1) lookup with minimal memory overhead.</p>
085     *
086     * <p>Thread-safe: all cached instances are immutable.</p>
087     */
088    private static final Map<EDbVendor, IdentifierService> VENDOR_CACHE = new EnumMap<>(EDbVendor.class);
089
090    static {
091        // Pre-populate cache for all vendors at startup. These entries are the DEFAULT
092        // configuration and stay that way: a per-analysis profile never replaces one, so the
093        // vendor-only static API cannot change meaning under a caller's feet (design §5.3).
094        for (EDbVendor vendor : EDbVendor.values()) {
095            IdentifierProfile profile = IdentifierProfile.forVendor(
096                vendor,
097                IdentifierProfile.VendorFlags.defaults()
098            );
099            VENDOR_CACHE.put(vendor, new IdentifierService(profile, collatorProviderFor(vendor)));
100        }
101    }
102
103    /**
104     * High-performance static normalize method with caching
105     *
106     * <p>This method provides a convenient static interface for identifier normalization
107     * while leveraging a pre-populated cache of IdentifierService instances.</p>
108     *
109     * <p><strong>Performance characteristics:</strong>
110     * <ul>
111     *   <li>O(1) cache lookup using EnumMap
112     *   <li>No object creation - services are pre-created and reused
113     *   <li>Thread-safe - all cached instances are immutable
114     *   <li>Handles all vendor-specific rules (collation, per-object-type rules, etc.)
115     * </ul>
116     *
117     * <p><strong>Usage example:</strong>
118     * <pre>
119     * String normalized = IdentifierService.normalizeStatic(
120     *     EDbVendor.dbvoracle,
121     *     ESQLDataObjectType.dotTable,
122     *     "MyTable"
123     * );
124     * // Result: "MYTABLE"
125     * </pre>
126     *
127     * @param dbVendor database vendor
128     * @param objectType object type (table, column, schema, etc.)
129     * @param identifier identifier to normalize (may be quoted)
130     * @return normalized identifier (unquoted and case-folded)
131     */
132    public static String normalizeStatic(EDbVendor dbVendor, ESQLDataObjectType objectType, String identifier) {
133        if (identifier == null || identifier.isEmpty()) {
134            return identifier;
135        }
136
137        // Get cached instance (O(1) lookup in EnumMap)
138        IdentifierService service = VENDOR_CACHE.get(dbVendor);
139        if (service == null) {
140            // Fallback for unexpected null (should never happen with pre-populated cache)
141            // Create on-demand instance
142            IdentifierProfile profile = IdentifierProfile.forVendor(dbVendor, IdentifierProfile.VendorFlags.defaults());
143            CollatorProvider collatorProvider = (dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql)
144                ? new CollatorProvider()
145                : null;
146            service = new IdentifierService(profile, collatorProvider);
147        }
148
149        return service.normalize(identifier, objectType);
150    }
151
152    /**
153     * High-performance static identifier comparison method with caching
154     *
155     * <p>This method provides a convenient static interface for identifier comparison
156     * while leveraging a pre-populated cache of IdentifierService instances.</p>
157     *
158     * <p><strong>Performance characteristics:</strong>
159     * <ul>
160     *   <li>O(1) cache lookup using EnumMap
161     *   <li>No object creation - services are pre-created and reused
162     *   <li>Thread-safe - all cached instances are immutable
163     *   <li>Handles all vendor-specific rules (case-sensitive, case-insensitive, collation-based)
164     * </ul>
165     *
166     * <p><strong>Usage example:</strong>
167     * <pre>
168     * boolean equal = IdentifierService.areEqualStatic(
169     *     EDbVendor.dbvoracle,
170     *     ESQLDataObjectType.dotTable,
171     *     "MyTable",
172     *     "MYTABLE"
173     * );
174     * // Result: true (Oracle is case-insensitive for unquoted identifiers)
175     * </pre>
176     *
177     * @param dbVendor database vendor
178     * @param objectType object type (table, column, schema, etc.)
179     * @param ident1 first identifier to compare (may be quoted)
180     * @param ident2 second identifier to compare (may be quoted)
181     * @return true if identifiers are equal according to vendor rules
182     */
183    public static boolean areEqualStatic(EDbVendor dbVendor, ESQLDataObjectType objectType, String ident1, String ident2) {
184        // Handle null cases
185        if (ident1 == null || ident2 == null) {
186            return ident1 == ident2;
187        }
188
189        // Fast path: reference equality
190        if (ident1 == ident2) {
191            return true;
192        }
193
194        // Get cached instance (O(1) lookup in EnumMap)
195        IdentifierService service = VENDOR_CACHE.get(dbVendor);
196        if (service == null) {
197            // Fallback for unexpected null (should never happen with pre-populated cache)
198            // Create on-demand instance
199            IdentifierProfile profile = IdentifierProfile.forVendor(dbVendor, IdentifierProfile.VendorFlags.defaults());
200            CollatorProvider collatorProvider = (dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql)
201                ? new CollatorProvider()
202                : null;
203            service = new IdentifierService(profile, collatorProvider);
204        }
205
206        return service.areEqual(ident1, ident2, objectType);
207    }
208
209    /**
210     * Static {@link #canonKey} accessor over the cached default-flags service per vendor
211     * (same cache {@link #areEqualStatic} uses, so key equality mirrors that relation).
212     */
213    public static CanonKey canonKeyStatic(EDbVendor dbVendor, ESQLDataObjectType objectType, String identifier) {
214        IdentifierService service = VENDOR_CACHE.get(dbVendor);
215        if (service == null) {
216            IdentifierProfile profile = IdentifierProfile.forVendor(dbVendor, IdentifierProfile.VendorFlags.defaults());
217            CollatorProvider collatorProvider = (dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql)
218                ? new CollatorProvider()
219                : null;
220            service = new IdentifierService(profile, collatorProvider);
221        }
222        return service.canonKey(identifier, objectType);
223    }
224
225    /**
226     * Canonical key for one component, with its provenance stated rather than guessed (stage S0R).
227     *
228     * <p>The two forms are not interchangeable and this is the one place the difference is handled:
229     * a {@link IdentifierInputForm#SQL_LEXICAL} text is a spelling the user wrote, so its own quote
230     * state decides whether case folds; a {@link IdentifierInputForm#CATALOG_STORED} text is
231     * already the name a catalog holds, so it is re-encoded to its canonical quoted spelling first.
232     * On Snowflake a stored {@code foo} is the quoted name {@code "foo"}, NOT the unquoted
233     * {@code foo} that folds to {@code FOO} — a factory that guessed would merge the two.
234     *
235     * @param text       the component text; null returns null
236     * @param objectType the comparison/quoting type for this component's role
237     * @param form       where the text came from
238     */
239    public CanonKey canonKeyOf(String text, ESQLDataObjectType objectType, IdentifierInputForm form) {
240        if (objectType == null) {
241            throw new IllegalArgumentException("objectType is null");
242        }
243        if (form == null) {
244            throw new IllegalArgumentException("input form is null: provenance must be stated, "
245                    + "because a stored name and a SQL spelling fold differently");
246        }
247        if (text == null) {
248            return null;
249        }
250        String lexical;
251        switch (form) {
252            case CATALOG_STORED:
253                lexical = IdentifierCodec.encodeStored(profile, objectType, text);
254                break;
255            case SQL_LEXICAL:
256                lexical = text;
257                break;
258            default:
259                // Exhaustive on purpose: a new input form must be handled deliberately, not
260                // silently treated as a SQL spelling — which would fold a stored name.
261                throw new IllegalStateException("unhandled identifier input form: " + form);
262        }
263        return canonKey(lexical, objectType);
264    }
265
266    /**
267     * Static {@link #qualifiedCanonKeyFromLexical} accessor over the same cached
268     * default-flags service per vendor, so qualified key equality mirrors the relation
269     * {@link #areEqualStatic} defines segment by segment.
270     */
271    public static QualifiedCanonKey qualifiedCanonKeyFromLexicalStatic(EDbVendor dbVendor,
272            ESQLDataObjectType objectType, String qualifiedName) {
273        return defaultService(dbVendor).qualifiedCanonKeyFromLexical(qualifiedName, objectType);
274    }
275
276    /** Static {@link #qualifiedCanonKeyFromStored(List, ESQLDataObjectType)} accessor. */
277    public static QualifiedCanonKey qualifiedCanonKeyFromStoredStatic(EDbVendor dbVendor,
278            ESQLDataObjectType objectType, List<String> storedSegments) {
279        return defaultService(dbVendor).qualifiedCanonKeyFromStored(storedSegments, objectType);
280    }
281
282    /**
283     * The cached default-flags service for a vendor, or a fresh one when the cache has no
284     * entry — the same lookup-then-build the static accessors above it already perform.
285     */
286    private static IdentifierService defaultService(EDbVendor dbVendor) {
287        IdentifierService service = VENDOR_CACHE.get(dbVendor);
288        if (service == null) {
289            service = forProfile(IdentifierProfile.forVendor(
290                    dbVendor, IdentifierProfile.VendorFlags.defaults()));
291        }
292        return service;
293    }
294
295    /**
296     * The vendor's DEFAULT-configuration service — the same instance the vendor-only static
297     * methods above use.
298     *
299     * <p>Explicitly the default policy, not "the current one": nothing switches what this
300     * returns, so an independent caller keeps the semantics it has always had. Code inside one
301     * analysis must instead carry that analysis's own service (see
302     * {@link TSQLEnv#getIdentifierService()}), because a per-analysis
303     * {@link IdentifierProfile} is invisible here by design.
304     *
305     * @param dbVendor database vendor
306     * @return the default-configuration service for that vendor; never null
307     */
308    public static IdentifierService defaultServiceFor(EDbVendor dbVendor) {
309        java.util.Objects.requireNonNull(dbVendor, "dbVendor");
310        return defaultService(dbVendor);
311    }
312
313    /**
314     * Build the service for one {@link IdentifierProfile}, attaching the
315     * {@link CollatorProvider} the profile's vendor needs.
316     *
317     * <p>Single place that decides which vendors collate, so a new entry point cannot
318     * accidentally build a SQL Server service without a collator — which silently downgrades
319     * collation comparison to the pinned case-collapse fallback.
320     *
321     * @param profile the frozen configuration for one analysis (or a vendor default)
322     * @return a service executing the one comparison engine under that profile
323     */
324    public static IdentifierService forProfile(IdentifierProfile profile) {
325        java.util.Objects.requireNonNull(profile, "profile");
326        return new IdentifierService(profile, collatorProviderFor(profile.getVendor()));
327    }
328
329    /**
330     * The {@link CollatorProvider} a vendor's identifier rules require, or null when the vendor
331     * folds instead of collating.
332     *
333     * @param vendor database vendor
334     * @return a new collator provider for collation-based vendors, else null
335     */
336    public static CollatorProvider collatorProviderFor(EDbVendor vendor) {
337        return (vendor == EDbVendor.dbvmssql || vendor == EDbVendor.dbvazuresql)
338                ? new CollatorProvider()
339                : null;
340    }
341
342    // ===== Instance Fields =====
343
344    private final IdentifierProfile profile;
345    private final CollatorProvider collatorProvider;  // SQL Server 专用(可选)
346
347    // ===== 构造函数 =====
348
349    /**
350     * 构造标识符服务
351     *
352     * @param profile 厂商标识符配置档案
353     * @param collatorProvider Collator 提供者(SQL Server 专用,可为 null)
354     */
355    public IdentifierService(IdentifierProfile profile, CollatorProvider collatorProvider) {
356        this.profile = profile;
357        this.collatorProvider = collatorProvider;
358    }
359
360    // ===== 规范化(用于索引键构造) =====
361
362    /**
363     * 规范化标识符(去引号 + 大小写折叠)
364     *
365     * <p>用于构造索引键,确保相同语义的标识符生成相同的键。
366     *
367     * @param identifier 原始标识符(可能带引号)
368     * @param objectType 对象类型
369     * @return 规范化后的标识符
370     */
371    public String normalize(String identifier, ESQLDataObjectType objectType) {
372        if (identifier == null || identifier.isEmpty()) {
373            return identifier;
374        }
375        return normalize(identifier, objectType, isQuoted(identifier, objectType));
376    }
377
378    /** Quote-state-threaded body: recognition runs ONCE per operand per public
379     *  entry point — areEqual previously re-recognized each operand up to four
380     *  times through the normalize/keyForMap/foldPayload chain (hot-path cost
381     *  surfaced by the U5 fork-pair benchmark). */
382    private String normalize(String identifier, ESQLDataObjectType objectType, boolean quoted) {
383        if (identifier == null || identifier.isEmpty()) {
384            return identifier;
385        }
386        IdentifierRules rules = profile.getRules(objectType);
387
388        // 1. 去引号
389        String unquoted = quoted ? removeQuotes(identifier, objectType) : identifier;
390
391        // 2. 大小写折叠
392        CaseFold fold = quoted ? rules.quotedFold : rules.unquotedFold;
393        // 3. The length the database keeps (PostgreSQL: 63 UTF-8 bytes), quoted or not.
394        return clipName(applyFold(unquoted, fold), rules, identifier, objectType, quoted);
395    }
396
397    /** Cut to the cell's stored length ({@link IdentifierRules#maxUtf8Bytes}); a no-op without one. */
398    private static String clip(String text, IdentifierRules rules) {
399        return rules.maxUtf8Bytes > 0 ? GspCaseData.clipUtf8Bytes(text, rules.maxUtf8Bytes) : text;
400    }
401
402    /**
403     * {@link #clip} for a name that arrived as a SQL spelling: the database shortens IDENTIFIER
404     * tokens — a well-formed delimited spelling, or a valid undelimited one — and nothing else.
405     * Lineage also hands this service texts that are not names at all (an unaliased result column
406     * is labelled with its expression text, {@code COALESCE(SUM(...))}); cutting those would only
407     * damage the label and could merge two expressions that share a 63-byte prefix. The spelling
408     * check runs only when the text is actually longer than the limit.
409     */
410    private String clipName(String text, IdentifierRules rules, String spelling,
411                            ESQLDataObjectType objectType, boolean quoted) {
412        if (rules.maxUtf8Bytes <= 0) {
413            return text;
414        }
415        String clipped = GspCaseData.clipUtf8Bytes(text, rules.maxUtf8Bytes);
416        if (clipped == text || !isIdentifierToken(spelling, objectType, quoted)) {
417            return text;
418        }
419        return clipped;
420    }
421
422    /**
423     * Whether a spelling is an identifier token (see {@link #clipName}): a well-formed delimited
424     * spelling, or an undelimited one the lexer reads as one identifier. The undelimited rule is
425     * PostgreSQL's scanner ({@code ident_start = [A-Za-z\200-\377_]},
426     * {@code ident_cont = [A-Za-z\200-\377_0-9$]}): every non-ASCII character is an identifier
427     * character, letter or not — {@code xxx😀} is one name and is cut like any other — while an
428     * ASCII space, parenthesis or operator ends the token. PostgreSQL is the only vendor whose
429     * cell truncates. Deliberately wider than {@link IdentifierCodec#isVendorValidSpelling}, which
430     * admits only non-ASCII letters because it answers a different question (is a bare spelling
431     * safe to WRITE).
432     */
433    private boolean isIdentifierToken(String spelling, ESQLDataObjectType objectType, boolean quoted) {
434        if (quoted) {
435            try {
436                IdentifierCodec.decodeLexical(profile, recognizingRole(spelling, objectType), spelling);
437                return true;
438            } catch (IdentifierCodec.MalformedIdentifierException e) {
439                return false;
440            }
441        }
442        if (spelling.isEmpty()) {
443            return false;
444        }
445        char first = spelling.charAt(0);
446        if (first < 0x80 && !isAsciiLetter(first) && first != '_') {
447            return false;
448        }
449        for (int i = 1; i < spelling.length(); i++) {
450            char c = spelling.charAt(i);
451            if (c < 0x80 && !isAsciiLetter(c) && !(c >= '0' && c <= '9') && c != '_' && c != '$') {
452                return false;
453            }
454        }
455        return true;
456    }
457
458    private static boolean isAsciiLetter(char c) {
459        return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
460    }
461
462    /**
463     * 应用大小写折叠
464     *
465     * <p>Since U5, folding is pinned to {@link GspCaseData} (UCD
466     * {@value GspCaseData#UNICODE_VERSION}) instead of the JVM's case tables, so canonical
467     * keys are identical on every JDK. Locale-conditional rules (tr/az/lt) are excluded by
468     * construction — the pinned tables carry only ROOT-locale mappings plus the
469     * Final_Sigma context rule.
470     */
471    private String applyFold(String str, CaseFold fold) {
472        switch (fold) {
473            case UPPER:
474                return GspCaseData.toUpperFull(str);
475            case LOWER:
476                return GspCaseData.toLowerFull(str);
477            case UPPER_SIMPLE:
478                return GspCaseData.toUpperSimple(str);
479            case LOWER_SIMPLE:
480                return GspCaseData.toLowerSimple(str);
481            case LOWER_ASCII:
482                return GspCaseData.toLowerAscii(str);
483            case NONE:
484            default:
485                return str;
486        }
487    }
488
489    // ===== 比较(用于查找匹配) =====
490
491    /**
492     * 比较两个标识符是否相等(P0d.1:canonical-key equality)。
493     *
494     * <p>Defined as {@code canonKey(ident1).equals(canonKey(ident2))}, which makes this an
495     * equivalence relation by construction. The previous rule selection ("quoted compare if
496     * EITHER operand is quoted" over quote-stripped-but-unfolded text) was an OR-relation and
497     * non-transitive on quoted-case-preserving dialects: Oracle {@code foo ~ "foo"} and
498     * {@code foo ~ "FOO"} but {@code "foo" ≁ "FOO"}. Under the canonical relation each operand
499     * folds by its own quote state (unquoted {@code foo} → {@code FOO} matches quoted
500     * {@code "FOO"}, not quoted {@code "foo"}), matching how the names are actually stored.
501     *
502     * @param ident1 标识符 1
503     * @param ident2 标识符 2
504     * @param objectType 对象类型
505     * @return true 如果相等
506     */
507    public boolean areEqual(String ident1, String ident2, ESQLDataObjectType objectType) {
508        if (ident1 == null || ident2 == null) {
509            return ident1 == ident2;
510        }
511
512        // 快速路径:引用相同
513        if (ident1 == ident2) {
514            return true;
515        }
516
517        // Same service ⇒ same rule domain, so compare canonical payloads directly instead
518        // of allocating CanonKeys (this sits on the resolver2 matcher hot path). The
519        // payloads come from the SAME helpers canonKey uses, so
520        // areEqual(a,b) == canonKey(a).equals(canonKey(b)) holds by construction.
521        // Recognition once per operand (quote-state threading — see normalize).
522        IdentifierRules rules = profile.getRules(objectType);
523        boolean q1 = isQuoted(ident1, objectType);
524        boolean q2 = isQuoted(ident2, objectType);
525        boolean collation1 = effectiveCompare(rules, q1) == CaseCompare.COLLATION_BASED;
526        boolean collation2 = effectiveCompare(rules, q2) == CaseCompare.COLLATION_BASED;
527        if (collation1 != collation2) {
528            // Mixed payload kinds (possible only in custom profiles) never key-match.
529            return false;
530        }
531        if (collation1) {
532            String s1 = q1 ? removeQuotes(ident1, objectType) : ident1;
533            String s2 = q2 ? removeQuotes(ident2, objectType) : ident2;
534            if (collatorProvider != null) {
535                Collator collator = collatorProvider.getCollator(profile.getFlags().defaultCollation);
536                return collator.compare(s1, s2) == 0;
537            }
538            return caseCollapse(s1).equals(caseCollapse(s2));
539        }
540        return foldPayload(ident1, objectType, rules, q1)
541                .equals(foldPayload(ident2, objectType, rules, q2));
542    }
543
544    /**
545     * Whether UNQUOTED names of one object category compare case-sensitively under this profile:
546     * the one answer {@link NameService#isCaseSensitive} and the resolver2 name matcher give.
547     *
548     * <p>A {@link CaseCompare#SENSITIVE SENSITIVE} cell is case-sensitive and an
549     * {@link CaseCompare#INSENSITIVE INSENSITIVE} one is not. A
550     * {@link CaseCompare#COLLATION_BASED COLLATION_BASED} cell answers from its collation
551     * ({@link CollatorProvider#isCaseSensitiveCollation}): {@code _CS_AI} and {@code _BIN}/
552     * {@code _BIN2} are case-sensitive although they are compared through a collator, so the
553     * compare kind alone is not the answer. Quoted operands may follow a different rule;
554     * {@link #areEqual} is the complete relation, this is a one-bit summary of the unquoted branch.
555     *
556     * @param objectType the object category
557     * @return true when unquoted names that differ only in case are different names
558     */
559    public boolean isCaseSensitive(ESQLDataObjectType objectType) {
560        switch (profile.getRules(objectType).unquotedCompare) {
561            case SENSITIVE:
562                return true;
563            case COLLATION_BASED:
564                return CollatorProvider.isCaseSensitiveCollation(profile.getFlags().defaultCollation);
565            default:
566                return false;
567        }
568    }
569
570    /** Effective compare rule for one operand's quote state (SAME_AS_UNQUOTED resolved). */
571    static CaseCompare effectiveCompare(IdentifierRules rules, boolean isQuoted) {
572        CaseCompare compare = isQuoted ? rules.quotedCompare : rules.unquotedCompare;
573        return compare == CaseCompare.SAME_AS_UNQUOTED ? rules.unquotedCompare : compare;
574    }
575
576    /**
577     * Canonical fold payload of one operand: the stored-form text ({@link #storedForm}),
578     * case-collapsed when the effective compare is INSENSITIVE but the applied fold is
579     * NONE (MySQL/BigQuery columns keep the spelling yet compare case-insensitively).
580     */
581    private String foldPayload(String identifier, ESQLDataObjectType objectType, IdentifierRules rules) {
582        return foldPayload(identifier, objectType, rules, isQuoted(identifier, objectType));
583    }
584
585    private String foldPayload(String identifier, ESQLDataObjectType objectType,
586                               IdentifierRules rules, boolean quoted) {
587        String storedText = storedForm(identifier, objectType, quoted);
588        CaseFold appliedFold = quoted ? rules.quotedFold : rules.unquotedFold;
589        if (effectiveCompare(rules, quoted) == CaseCompare.INSENSITIVE && appliedFold == CaseFold.NONE) {
590            storedText = clipName(collapse(storedText, rules), rules, identifier, objectType, quoted);
591        }
592        return storedText;
593    }
594
595    /**
596     * The image under which an INSENSITIVE, non-folding branch of {@code rules} compares
597     * ({@link IdentifierRules#collapse}): Unicode simple case folding unless the cell names the
598     * vendor's own mapping (MySQL: the simple lowercase mapping for tables and columns, the simple
599     * uppercase mapping for routines).
600     */
601    private static String collapse(String s, IdentifierRules rules) {
602        switch (rules.collapse) {
603            case LOWER_SIMPLE:
604                return GspCaseData.toLowerSimple(s);
605            case UPPER_SIMPLE:
606                return GspCaseData.toUpperSimple(s);
607            case SIMPLE_FOLD:
608            default:
609                return caseCollapse(s);
610        }
611    }
612
613    // ===== 索引键构造(段级,用于分层索引) =====
614
615    /**
616     * 为 Map 索引构造键(单个标识符段)
617     *
618     * <p><strong>注意:</strong>此方法仅用于分层索引的单段键,不用于复合键。
619     *
620     * <p>Key equality is exactly {@link #areEqual}: {@code keyForMap(a).equals(keyForMap(b))} if
621     * and only if {@code areEqual(a, b, objectType)}, for every cell of the profile. It is the
622     * comparison payload, which is NOT the stored name ({@link #normalize}) wherever the two
623     * differ: a branch that compares case-insensitively without folding (MySQL columns, MySQL
624     * table names under {@code lower_case_table_names} 1 and 2, quoted or not) keys by its
625     * collapse, so {@code `İ`} and {@code i} get one key, as they are one table; a
626     * collation-based cell (SQL Server) keys by the collation key of the same collator
627     * {@link #areEqual} compares with (or, with no collator, by the case collapse {@link #areEqual}
628     * falls back to), so {@code café} and {@code cafe} get one key under {@code _AI} and
629     * {@code SalesOrder} and {@code salesorder} one key under {@code _CI}. Up to 2026-10 this
630     * returned the stored name, whose equality is neither: two equal names could get two keys.
631     *
632     * @param identifier 标识符
633     * @param objectType 对象类型
634     * @return 索引键
635     */
636    public String keyForMap(String identifier, ESQLDataObjectType objectType) {
637        if (identifier == null || identifier.isEmpty()) {
638            return identifier;
639        }
640        boolean quoted = isQuoted(identifier, objectType);
641        IdentifierRules rules = profile.getRules(objectType);
642        if (effectiveCompare(rules, quoted) == CaseCompare.COLLATION_BASED) {
643            assertSingleSegmentOrThrow(identifier, objectType);
644            // The ONCE-decoded payload (see storedForm: MSSQL [[foo]]] decodes to [foo], which
645            // must not be decoded again into foo).
646            String stripped = quoted ? removeQuotes(identifier, objectType) : identifier;
647            if (collatorProvider != null) {
648                return collationKeyText(collatorProvider.getCollator(profile.getFlags().defaultCollation)
649                        .getCollationKey(stripped).toByteArray());
650            }
651            return caseCollapse(stripped);
652        }
653        return foldPayload(identifier, objectType, rules, quoted);
654    }
655
656    /**
657     * A collation key as map-key text: a NUL marker, then the key bytes in hex. Two texts are
658     * equal exactly when the keys are, i.e. when the collator compares the names equal.
659     */
660    private static String collationKeyText(byte[] key) {
661        char[] out = new char[1 + 2 * key.length];
662        out[0] = '\u0000';
663        for (int i = 0; i < key.length; i++) {
664            int b = key[i] & 0xFF;
665            out[1 + 2 * i] = HEX[b >>> 4];
666            out[2 + 2 * i] = HEX[b & 0xF];
667        }
668        return new String(out);
669    }
670
671    private static final char[] HEX = "0123456789abcdef".toCharArray();
672
673    /**
674     * The stored form a fold-based payload starts from: the decoded, folded and clipped name
675     * ({@link #normalize}); for a collation-based branch the once-decoded spelling.
676     */
677    private String storedForm(String identifier, ESQLDataObjectType objectType, boolean isQuoted) {
678       if (identifier == null || identifier.isEmpty()) {
679           return identifier;
680       }
681
682       // 强制单段输入校验(Phase 0: 默认启用)
683       assertSingleSegmentOrThrow(identifier,objectType);
684
685       IdentifierRules rules = profile.getRules(objectType);
686       CaseCompare compare = isQuoted ? rules.quotedCompare : rules.unquotedCompare;
687
688       // 处理 SAME_AS_UNQUOTED
689       if (compare == CaseCompare.SAME_AS_UNQUOTED) {
690           compare = rules.unquotedCompare;
691       }
692
693       // 对于 COLLATION_BASED,不折叠,返回原始标识符(去引号)。
694       // Return the ONCE-decoded payload directly — routing it through
695       // normalize() would decode a second time when the stored payload itself
696       // looks like a quoted lexical form (MSSQL [[foo]]] decodes to [foo],
697       // which normalize would re-decode to foo, colliding with the key of
698       // [foo] even though areEqual keeps the two distinct — Codex U5 round-1
699       // finding 3).
700       if (compare == CaseCompare.COLLATION_BASED) {
701           return isQuoted ? removeQuotes(identifier, objectType) : identifier;
702       }
703
704       // 其他情况:规范化后作为键
705       return normalize(identifier, objectType, isQuoted);
706    }
707
708    /**
709     * Build the canonical key of a single identifier segment (unification plan stage P0c).
710     *
711     * <p>Fold-based rules use the {@link #keyForMap} text (the fold payload) as the payload; collation-based
712     * rules (SQL Server / Azure) use {@link java.text.CollationKey} bytes from the SAME
713     * collator {@link #areEqual} compares with, so key equality mirrors that collator. When
714     * a collation-based profile has no {@link CollatorProvider}, {@code areEqual} falls back
715     * to comparing {@link #caseCollapse} payloads (pinned Unicode simple case folding since
716     * U5); the key mirrors that exactly by carrying the collapsed decoded text.
717     *
718     * <p>NOTE: since P0d.1, {@link #areEqual} IS canonical-key equality. The legacy
719     * {@code TSQLEnv.compareIdentifier} façade migrates to this relation in P0d.2; until
720     * then the P0c equivalence proof measures that remaining engine's distance from it.
721     *
722     * @param identifier single identifier segment (may be quoted); null returns null
723     * @param objectType kind of database object the name refers to
724     * @return the canonical key, or null for a null identifier
725     */
726    public CanonKey canonKey(String identifier, ESQLDataObjectType objectType) {
727        if (identifier == null) {
728            return null;
729        }
730
731        IdentifierProfile.ObjectGroup group = profile.groupOf(objectType);
732        IdentifierRules rules = profile.getRules(objectType);
733        // The CATEGORY's effective cell, not the group's: with per-category configuration two
734        // categories of one group can compare differently, and a key must not equate payloads
735        // produced under two different relations (design §7.1).
736        IdentifierRules ruleDomain = rules.resolvedCell();
737
738        boolean quoted = isQuoted(identifier, objectType);
739        if (effectiveCompare(rules, quoted) == CaseCompare.COLLATION_BASED) {
740            String stripped = quoted ? removeQuotes(identifier, objectType) : identifier;
741            String collation = profile.getFlags().defaultCollation;
742            if (collatorProvider != null) {
743                String collatorId = collation + ":" + collatorProvider.getClass().getName();
744                byte[] keyBytes = collatorProvider.getCollator(collation)
745                        .getCollationKey(stripped).toByteArray();
746                return new CanonKey(profile.getVendor(), group, profile, ruleDomain,
747                        collatorId, stripped, keyBytes);
748            }
749            // No provider: areEqual falls back to comparing caseCollapse payloads
750            // (pinned C+S simple fold since U5) — mirror it.
751            return new CanonKey(profile.getVendor(), group, profile, ruleDomain,
752                    collation + ":none", caseCollapse(stripped), null);
753        }
754
755        // Same payload construction areEqual compares directly (see foldPayload).
756        return new CanonKey(profile.getVendor(), group, profile, ruleDomain, null,
757                foldPayload(identifier, objectType, rules, quoted), null);
758    }
759
760    /**
761     * Case-insensitive collapse of an identifier payload.
762     *
763     * <p>Since U5 this is defined by Unicode simple case folding (CaseFolding.txt
764     * statuses C+S at pinned UCD {@value GspCaseData#UNICODE_VERSION}), applied
765     * code-point-wise — NO LONGER by {@link String#equalsIgnoreCase}'s per-{@code char}
766     * upper-then-lower fold. The deltas are deliberate corrections: supplementary-plane
767     * case pairs (Deseret 𐐀/𐐨) now collapse equal, and {@code İ}/{@code ı} no longer
768     * collapse to ASCII {@code i} (CASEFOLD_ALGORITHM_CHANGE in the U0 allowlist).
769     */
770    private static String caseCollapse(String s) {
771        return GspCaseData.simpleFoldCollapse(s);
772    }
773
774    // ===== Persistent canonical-identity API (plan §6, slice U3) =====
775
776    /** Lazily computed V1 policy ids per object type (profile is immutable, so
777     *  each id is a constant of this service instance). The holder itself is
778     *  created on the FIRST V1 API call: IdentifierService instances exist
779     *  per catalog object (e.g. TSQLTable), and eagerly allocating two maps
780     *  on every one of them would bill unused U3 functionality to the whole
781     *  catalog (Codex U3 round-1 finding 5). */
782    private static final class PolicyIdCachesV1 {
783        final java.util.concurrent.ConcurrentHashMap<ESQLDataObjectType, String> persistent =
784                new java.util.concurrent.ConcurrentHashMap<>();
785        final java.util.concurrent.ConcurrentHashMap<ESQLDataObjectType, String> approximate =
786                new java.util.concurrent.ConcurrentHashMap<>();
787    }
788
789    private volatile PolicyIdCachesV1 policyIdCachesV1;
790
791    private PolicyIdCachesV1 policyIdCachesV1() {
792        PolicyIdCachesV1 caches = policyIdCachesV1;
793        if (caches == null) {
794            synchronized (this) {
795                caches = policyIdCachesV1;
796                if (caches == null) {
797                    caches = new PolicyIdCachesV1();
798                    policyIdCachesV1 = caches;
799                }
800            }
801        }
802        return caches;
803    }
804
805    /**
806     * Mint the persistable canonical key of one identifier segment.
807     *
808     * <p><b>Contract (no carve-out).</b> For two
809     * {@link IdentifierInputForm#SQL_LEXICAL} inputs {@code a}, {@code b}
810     * under the same service and object type:
811     * {@code persistentKeyV1(type, a, SQL_LEXICAL).equals(persistentKeyV1(type, b, SQL_LEXICAL))}
812     * ⟺ {@link #areEqual areEqual(a, b, type)}.
813     * {@link IdentifierInputForm#CATALOG_STORED} inputs join the same
814     * equivalence domain: a stored name keys equal to the lexical spelling
815     * that produced it (Oracle stored {@code MixedCase} ≡ lexical
816     * {@code "MixedCase"}; stored {@code FOO} ≡ lexical {@code foo}; MySQL
817     * mode-1 stored {@code straße} ≡ bare lexical {@code straße}). In cells
818     * whose unquoted branch folds while the quoted branch compares
819     * insensitively without folding (the MySQL forks' table names, Presto
820     * family), the unquoted fold and the quoted collapse are DIFFERENT maps
821     * wherever full folding and simple folding disagree (final sigma:
822     * {@code ΟΣ} lowers to {@code ος} while the collapse maps {@code ς} to
823     * {@code σ}; MySQL itself, verified 2026-10-11, uses the simple lowercase
824     * mapping for both, so {@code ΟΣ} and {@code `ΟΣ`} are both {@code οσ}) — that
825     * asymmetry is {@link #areEqual}'s own U5-certified branch semantics, and
826     * stored names side with the fold-image (the form the catalog actually
827     * holds), not with the quoted collapse. Keys are stable across JVMs and
828     * JDK versions — folding is pinned ({@link GspCaseData}), never JVM case
829     * tables — and the {@link PersistentIdentifierKey#getPolicyId() policyId}
830     * rotates exactly when the resolved equivalence behavior changes.
831     *
832     * <p><b>Payload semantics.</b> SQL_LEXICAL: strict codec decode for
833     * recognized quoted spellings ({@link IdentifierCodec#decodeLexical} —
834     * malformed input throws {@link IdentifierCodec.MalformedIdentifierException};
835     * this API never guesses identity), then the quote-state branch's fold,
836     * then collapse when the branch compares INSENSITIVE without folding.
837     * CATALOG_STORED: no recognition, no decode. SENSITIVE stored-name
838     * domains keep the stored text; INSENSITIVE domains take the cell's fold
839     * when any branch folds (idempotent on real catalog data — stored names
840     * ARE fold-images) and the case-collapse only in never-folding cells.
841     * (Refines the plan's "INSENSITIVE cells collapse / SENSITIVE cells keep
842     * the stored text": in folding cells the collapse is the wrong
843     * canonicalizer for stored names — see persistentPayloadV1 — and for
844     * folding quoted branches — Snowflake
845     * {@code QUOTED_IDENTIFIERS_IGNORE_CASE} — the fold keeps cross-form
846     * equality intact.)
847     *
848     * <p><b>Rejections — before descriptor construction AND before any
849     * decode/payload work.</b> Cells with both branches resolving to
850     * COLLATION_BASED throw {@link UnsupportedOperationException} naming the
851     * collation (collator weights are not portably reproducible); mixed-kind
852     * custom profiles (one branch collation-based, one fold-based — the
853     * {@link #areEqual} mixed-payload-kind guard case) throw the dedicated
854     * subtype {@link MixedKindProfileException} — the biconditional's scope
855     * is fold/fold cells. {@link #approximateKeyV1} serves both.
856     *
857     * <p>Null {@code name} returns null. The empty string returns a defined
858     * key (empty payload, real policyId): degenerate but stable.
859     *
860     * @param objectType kind of database object the name refers to
861     * @param name single identifier segment
862     * @param form how the string reached the caller — lexical SQL text or a
863     *             catalog-stored name; the forms are not interchangeable
864     * @return the persistent key, or null for a null name
865     */
866    public PersistentIdentifierKey persistentKeyV1(ESQLDataObjectType objectType, String name,
867                                                   IdentifierInputForm form) {
868        java.util.Objects.requireNonNull(objectType, "objectType");
869        java.util.Objects.requireNonNull(form, "form");
870        if (name == null) {
871            return null;
872        }
873        IdentifierRules rules = profile.getRules(objectType);
874        rejectNonFoldCellV1(rules);
875        String payload = persistentPayloadV1(name, objectType, form, rules);
876        String policyId = policyIdCachesV1().persistent.computeIfAbsent(objectType,
877                t -> IdentifierPolicyV1.persistentPolicyId(profile, t,
878                        GspCaseData.UNICODE_VERSION, GspCaseData.ALGORITHM_REV));
879        return new PersistentIdentifierKey(policyId, profile.groupOf(objectType), payload);
880    }
881
882    /**
883     * Mint the best-effort key that serves EVERY rule cell, including the
884     * collation-based and mixed-kind cells {@link #persistentKeyV1} rejects:
885     * quote-strip (lenient — a malformed quoted spelling keeps its identity
886     * as written, mirroring {@link #areEqual}'s totality) plus pinned simple
887     * case-fold collapse. CATALOG_STORED input is collapsed as-is.
888     *
889     * <p>Claims NO {@code areEqual} biconditional — see
890     * {@link ApproximateIdentifierKey}. The distinct type and the distinct
891     * {@code ak1-} policy namespace guarantee these keys can never be stored
892     * or matched where exact keys are expected.
893     *
894     * @param objectType kind of database object the name refers to
895     * @param name single identifier segment
896     * @param form lexical SQL text or catalog-stored name
897     * @return the approximate key, or null for a null name
898     */
899    public ApproximateIdentifierKey approximateKeyV1(ESQLDataObjectType objectType, String name,
900                                                     IdentifierInputForm form) {
901        java.util.Objects.requireNonNull(objectType, "objectType");
902        java.util.Objects.requireNonNull(form, "form");
903        if (name == null) {
904            return null;
905        }
906        String text = name;
907        if (form == IdentifierInputForm.SQL_LEXICAL
908                && IdentifierCodec.isQuoted(profile, objectType, name)) {
909            text = removeQuotes(name, objectType);
910        }
911        String policyId = policyIdCachesV1().approximate.computeIfAbsent(objectType,
912                t -> IdentifierPolicyV1.approximatePolicyId(profile, t,
913                        GspCaseData.UNICODE_VERSION, GspCaseData.ALGORITHM_REV));
914        return new ApproximateIdentifierKey(policyId, profile.groupOf(objectType),
915                caseCollapse(text));
916    }
917
918    /**
919     * Human-readable rendering of the V1 policies for one object type — a
920     * diagnostic, explicitly NOT the identity ({@code policyId} is the hash
921     * of the binary descriptor), so this formatting can improve without
922     * rotating any stored key.
923     *
924     * @param objectType kind of database object
925     * @return one-line description of the persistent and approximate policies
926     */
927    public String describePolicyV1(ESQLDataObjectType objectType) {
928        java.util.Objects.requireNonNull(objectType, "objectType");
929        IdentifierRules rules = profile.getRules(objectType);
930        CaseCompare effU = effectiveCompare(rules, false);
931        CaseCompare effQ = effectiveCompare(rules, true);
932        StringBuilder sb = new StringBuilder(160);
933        sb.append("policyV1{vendor=").append(IdentifierPolicyV1.vendorWire(profile.getVendor()))
934                .append(", group=").append(IdentifierPolicyV1.groupWire(profile.groupOf(objectType)))
935                .append(", cell=").append(rules.unquotedFold).append('/').append(effU)
936                .append('|').append(rules.quotedFold).append('/').append(effQ);
937        if (rules.collapse != IdentifierRules.CaseCollapse.SIMPLE_FOLD) {
938            sb.append(", collapse=").append(rules.collapse);
939        }
940        if (rules.maxUtf8Bytes > 0) {
941            sb.append(", maxUtf8Bytes=").append(rules.maxUtf8Bytes);
942        }
943        sb.append(", codec=").append(IdentifierCodec.CODEC_REVISION);
944        if (IdentifierCodec.consultsAnsiQuotes(profile.getVendor())) {
945            sb.append(", ansiQuotes=").append(profile.getFlags().mysqlAnsiQuotes);
946        }
947        sb.append(", mapRev=").append(IdentifierPolicyV1.mapRevision(objectType));
948        boolean collation = effU == CaseCompare.COLLATION_BASED || effQ == CaseCompare.COLLATION_BASED;
949        if (collation) {
950            sb.append(", persistent=UNSUPPORTED(")
951                    .append(effU == effQ ? "collation " + profile.getFlags().defaultCollation
952                            : "mixed-kind cell")
953                    .append(')');
954        } else {
955            sb.append(", ucd=").append(IdentifierPolicyV1.cellUsesUcd(rules)
956                            ? GspCaseData.UNICODE_VERSION + "/alg" + GspCaseData.ALGORITHM_REV
957                            : "excluded")
958                    .append(", persistent=")
959                    .append(policyIdCachesV1().persistent.computeIfAbsent(objectType,
960                            t -> IdentifierPolicyV1.persistentPolicyId(profile, t,
961                                    GspCaseData.UNICODE_VERSION, GspCaseData.ALGORITHM_REV)));
962        }
963        sb.append(", approximate=")
964                .append(policyIdCachesV1().approximate.computeIfAbsent(objectType,
965                        t -> IdentifierPolicyV1.approximatePolicyId(profile, t,
966                                GspCaseData.UNICODE_VERSION, GspCaseData.ALGORITHM_REV)))
967                .append('}');
968        return sb.toString();
969    }
970
971    /**
972     * Typed rejection for mixed-kind custom profiles (one branch
973     * collation-based, one fold-based — the cell shape {@link #areEqual}'s
974     * mixed-payload-kind guard never key-matches). A distinct TYPE, not just
975     * a distinct message, so callers can tell this rejection apart from the
976     * all-collation one programmatically (Codex U3 round-1 finding 2). It
977     * still extends {@link UnsupportedOperationException} so a caller
978     * handling "persistent keys unavailable for this cell" generically
979     * catches both.
980     */
981    public static final class MixedKindProfileException extends UnsupportedOperationException {
982
983        private static final long serialVersionUID = 1L;
984
985        private final CaseCompare unquotedKind;
986        private final CaseCompare quotedKind;
987
988        MixedKindProfileException(CaseCompare unquotedKind, CaseCompare quotedKind) {
989            super("persistentKeyV1: mixed-kind custom profile (unquoted=" + unquotedKind
990                    + ", quoted=" + quotedKind + ") rejected before descriptor construction - "
991                    + "the biconditional's scope is fold/fold cells; use approximateKeyV1");
992            this.unquotedKind = unquotedKind;
993            this.quotedKind = quotedKind;
994        }
995
996        /** Effective compare kind of the unquoted branch. */
997        public CaseCompare getUnquotedKind() {
998            return unquotedKind;
999        }
1000
1001        /** Effective compare kind of the quoted branch. */
1002        public CaseCompare getQuotedKind() {
1003            return quotedKind;
1004        }
1005    }
1006
1007    /** Typed rejection of the cells outside the fold/fold biconditional scope
1008     *  — BEFORE any descriptor or payload work (plan §6), including strict
1009     *  decoding: a malformed spelling on a rejected cell gets the cell
1010     *  rejection, never a decode error. */
1011    private void rejectNonFoldCellV1(IdentifierRules rules) {
1012        CaseCompare effU = effectiveCompare(rules, false);
1013        CaseCompare effQ = effectiveCompare(rules, true);
1014        boolean collationU = effU == CaseCompare.COLLATION_BASED;
1015        boolean collationQ = effQ == CaseCompare.COLLATION_BASED;
1016        if (collationU && collationQ) {
1017            throw new UnsupportedOperationException(
1018                    "persistentKeyV1: this cell compares via collation '"
1019                            + profile.getFlags().defaultCollation
1020                            + "' - collator weights are not portably reproducible; "
1021                            + "use approximateKeyV1 (no areEqual biconditional)");
1022        }
1023        if (collationU != collationQ) {
1024            throw new MixedKindProfileException(effU, effQ);
1025        }
1026    }
1027
1028    /** Canonical persistent payload — byte-identical with {@link #foldPayload}
1029     *  for well-formed lexical input, so key equality IS {@link #areEqual}.
1030     *
1031     *  <p>CATALOG_STORED (Codex U3 round-1 finding 1): a stored name under an
1032     *  INSENSITIVE cell whose unquoted branch FOLDS is the fold-image of the
1033     *  spelling that created it, so its canonical payload is the (idempotent)
1034     *  unquoted fold of the stored text — NOT the case-collapse. Collapsing
1035     *  broke the primary catalog↔SQL join wherever the fold and the simple
1036     *  C+S collapse disagree: a MySQL-fork mode-1 stored {@code ος} (created by bare
1037     *  {@code ΟΣ}, contextual lower) collapsed to {@code οσ} and stopped
1038     *  keying with bare {@code ΟΣ}'s payload {@code ος}. (ß is NOT such a
1039     *  case — U+00DF carries only an F mapping in CaseFolding, so the
1040     *  collapse preserves it like the lower fold does; Codex round 2.)
1041     *  Collapse remains correct for INSENSITIVE cells that never fold (MySQL
1042     *  columns, BigQuery columns): there collapse IS the cell's canonical
1043     *  payload on every branch. */
1044    private String persistentPayloadV1(String name, ESQLDataObjectType objectType,
1045                                       IdentifierInputForm form, IdentifierRules rules) {
1046        if (form == IdentifierInputForm.CATALOG_STORED) {
1047            // Every result is cut to the cell's stored length last (a stored name already fits,
1048            // so this only keeps an over-long input consistent with its lexical spelling).
1049            if (effectiveCompare(rules, true) != CaseCompare.INSENSITIVE) {
1050                // SENSITIVE stored-name domain: the stored text IS the identity.
1051                return clip(name, rules);
1052            }
1053            CaseFold storedFold = rules.unquotedFold != CaseFold.NONE
1054                    ? rules.unquotedFold : rules.quotedFold;
1055            if (storedFold != CaseFold.NONE) {
1056                return clip(applyFold(name, storedFold), rules);
1057            }
1058            return clip(collapse(clip(name, rules), rules), rules);
1059        }
1060        boolean quoted = IdentifierCodec.isQuoted(profile, objectType, name);
1061        // Strict: MalformedIdentifierException propagates (unlike removeQuotes,
1062        // which keeps malformed spellings' as-written identity for the total
1063        // areEqual/keyForMap facade).
1064        String text = quoted ? IdentifierCodec.decodeLexical(profile, objectType, name) : name;
1065        CaseFold fold = quoted ? rules.quotedFold : rules.unquotedFold;
1066        // Same sequence as foldPayload: fold, cut, then (non-folding INSENSITIVE) collapse, cut.
1067        String payload = clipName(applyFold(text, fold), rules, name, objectType, quoted);
1068        if (effectiveCompare(rules, quoted) == CaseCompare.INSENSITIVE && fold == CaseFold.NONE) {
1069            payload = clipName(collapse(payload, rules), rules, name, objectType, quoted);
1070        }
1071        return payload;
1072    }
1073
1074    /** Test hook (anti-false-rotation, plan §2 U0.2): the persistent policy id
1075     *  under a caller-chosen UCD version/algorithm revision. Same rejections
1076     *  as {@link #persistentKeyV1}. */
1077    String persistentPolicyIdV1(ESQLDataObjectType objectType, String ucdVersion,
1078                                int ucdAlgorithmRev) {
1079        rejectNonFoldCellV1(profile.getRules(objectType));
1080        return IdentifierPolicyV1.persistentPolicyId(profile, objectType, ucdVersion,
1081                ucdAlgorithmRev);
1082    }
1083
1084    /** Test hook: approximate policy id under a caller-chosen UCD version. */
1085    String approximatePolicyIdV1(ESQLDataObjectType objectType, String ucdVersion,
1086                                 int ucdAlgorithmRev) {
1087        return IdentifierPolicyV1.approximatePolicyId(profile, objectType, ucdVersion,
1088                ucdAlgorithmRev);
1089    }
1090
1091    // ===== 复合键构造(全限定名级,可选) =====
1092
1093    /**
1094     * 判断是否可以使用复合键快速路径
1095     *
1096     * <p>条件(必须全部满足):
1097     * <ol>
1098     * <li>所有对象组都是 SENSITIVE
1099     * <li>输入没有引号字符
1100     * <li>没有 COLLATION_BASED 类型
1101     * </ol>
1102     *
1103     * @param qualifiedName 完整限定名(如 "db.schema.table")
1104     * @return true 如果可以使用复合键
1105     */
1106    public boolean canUseCompositeKey(String qualifiedName) {
1107        // 条件 1: 检查是否包含引号字符
1108        if (containsQuoteChar(qualifiedName)) {
1109            return false;
1110        }
1111
1112        // 条件 2: every object CATEGORY must be SENSITIVE. Iterating the three groups was
1113        // sufficient only while a group's rule governed all of its categories; with per-category
1114        // configuration a table-insensitive override inside NAME_GROUP would have left this
1115        // shortcut on, and a shortcut that cannot be proved correct must close (design §7.1).
1116        for (ESQLDataObjectType type : ESQLDataObjectType.values()) {
1117            IdentifierRules rules = profile.getRules(type);
1118            if (rules.unquotedCompare != CaseCompare.SENSITIVE) {
1119                return false;
1120            }
1121            // buildCompositeKey joins the RAW segments, so it is a key only where a name is its
1122            // own comparison payload: no fold and no truncation (a cut name equals every longer
1123            // name it is cut from, and raw segments would key them apart).
1124            if (rules.unquotedFold != CaseFold.NONE || rules.maxUtf8Bytes > 0) {
1125                return false;
1126            }
1127        }
1128
1129        return true;
1130    }
1131
1132    /**
1133     * 构造复合键(使用长度前缀编码避免冲突)
1134     *
1135     * <p>格式: {@code len1#segment1|len2#segment2|len3#segment3|objectType}
1136     *
1137     * <p>例如: {@code "3#db1|6#schema|5#table|dotTable"}
1138     *
1139     * <p><strong>优势:</strong>避免分隔符冲突(标识符可能包含 '.' 或 '|')
1140     *
1141     * @param segments 标识符段列表
1142     * @param objectType 对象类型
1143     * @return 复合键
1144     */
1145    public String buildCompositeKey(List<String> segments, ESQLDataObjectType objectType) {
1146        StringBuilder sb = new StringBuilder(segments.size() * 20);
1147        for (int i = 0; i < segments.size(); i++) {
1148            String seg = segments.get(i);
1149            if (i > 0) {
1150                sb.append('|');
1151            }
1152            sb.append(seg.length()).append('#').append(seg);
1153        }
1154        sb.append('|').append(objectType.name());
1155        return sb.toString();
1156    }
1157
1158    /**
1159     * 构造复合键(从完整限定名)
1160     *
1161     * @param qualifiedName 完整限定名(如 "db.schema.table")
1162     * @param objectType 对象类型
1163     * @return 复合键
1164     */
1165    public String buildCompositeKey(String qualifiedName, ESQLDataObjectType objectType) {
1166        List<String> segments = SQLUtil.parseNames(qualifiedName);
1167        return buildCompositeKey(segments, objectType);
1168    }
1169
1170    // ===== Qualified identity keys (S0 of the identity/display separation plan) =====
1171    //
1172    // These supersede buildCompositeKey/canUseCompositeKey for identity purposes. That
1173    // pair cannot represent quote state and refuses (canUseCompositeKey == false) every
1174    // name containing a quote character, which is exactly the shape that loses lineage
1175    // (MantisBT 4728). Nothing in production calls these yet; stages S2/S3 migrate the
1176    // catalog and dlineage indexes onto them.
1177
1178    /**
1179     * Identity key for a qualified name <strong>as written in SQL</strong> — delimiters and
1180     * case exactly as the user spelled them, e.g. {@code db."My Schema".t}.
1181     *
1182     * <p>Each segment is keyed under its own object type ({@link QualifiedNameHierarchy}),
1183     * so a schema segment is compared by schema rules and a column segment by column rules.
1184     * Segment boundaries and arity are preserved: a name is never joined back into a dotted
1185     * string, because that would merge the path {@code a.f} with the single column named
1186     * {@code "A.F"}.
1187     *
1188     * <p><strong>No omitted segment is filled in.</strong> SQL Server's {@code db..t} keys as
1189     * three segments with an empty middle one, NOT as {@code db.dbo.t}: which schema an
1190     * omission means is a property of the session, and a context-free key that guessed
1191     * {@code dbo} would silently bind the wrong object whenever the default schema is
1192     * something else. Resolving an omission is a lookup concern and needs explicit context.
1193     *
1194     * @param qualifiedName the name as written; null or empty returns null
1195     * @param objectType    what the whole name denotes
1196     * @return the key, or {@code null} when the name is null or empty
1197     * @throws UnsupportedOperationException when this (vendor, objectType) pair has no
1198     *         hierarchy mapping — {@code dotUnknown}, {@code dotRoutine}, {@code dotDblink},
1199     *         {@code dotDataType}, {@code dotParameter}. Probe first with
1200     *         {@link QualifiedNameHierarchy#hasHierarchyMapping} if the caller can be handed
1201     *         an arbitrary type. Throwing rather than returning null is deliberate: a
1202     *         {@code HashMap} accepts a null key, so a forgotten check would quietly merge
1203     *         every unsupported object into one bucket.
1204     */
1205    public QualifiedCanonKey qualifiedCanonKeyFromLexical(String qualifiedName, ESQLDataObjectType objectType) {
1206        if (qualifiedName == null || qualifiedName.isEmpty()) {
1207            return null;
1208        }
1209        List<String> segments = QualifiedNameHierarchy.split(profile.getVendor(), qualifiedName);
1210        return build(segments, objectType, false, qualifiedName);
1211    }
1212
1213    /**
1214     * Identity key from stored segments — text already in the form a catalog holds, with no
1215     * delimiters, one list entry per hierarchy level.
1216     *
1217     * <p>Stored text is re-encoded to its canonical lexical spelling
1218     * ({@link IdentifierCodec#encodeStored}) before canonicalization, so a stored name that
1219     * merely looks unquoted is not folded as though the user had written it unquoted. On
1220     * Snowflake a catalog entry stored as {@code foo} is the quoted name {@code "foo"}, not
1221     * the unquoted {@code foo} that folds to {@code FOO} — which is why this is a separate
1222     * method and why there is no factory that guesses provenance.
1223     *
1224     * <p>There is deliberately no overload taking a dot-joined string: a stored segment may
1225     * itself contain a dot, so joining and re-splitting is lossy in a way no amount of
1226     * documentation makes safe.
1227     *
1228     * @param storedSegments stored text per level, outermost first; null or empty returns null
1229     * @param objectType     what the whole name denotes
1230     * @throws UnsupportedOperationException as documented on {@link #qualifiedCanonKeyFromLexical}
1231     */
1232    public QualifiedCanonKey qualifiedCanonKeyFromStored(List<String> storedSegments, ESQLDataObjectType objectType) {
1233        if (storedSegments == null || storedSegments.isEmpty()) {
1234            return null;
1235        }
1236        return build(storedSegments, objectType, true, String.valueOf(storedSegments));
1237    }
1238
1239    /**
1240     * Identity key of a qualified name whose segments arrived in DIFFERENT forms: one entry per
1241     * hierarchy level, outermost first, each with the form it arrived in. A segment the SQL spelled
1242     * is {@link IdentifierInputForm#SQL_LEXICAL}; a name a catalog or a session default holds is
1243     * {@link IdentifierInputForm#CATALOG_STORED}. Each segment is canonicalized by its own form, so a
1244     * stored default schema {@code Mixed} and the SQL spelling {@code "Mixed"} give the same key, and
1245     * neither is re-read through the other.
1246     *
1247     * <p>As with {@link #qualifiedCanonKeyFromStored}, there is no dot-joined overload: the forms
1248     * belong to segments, and joining loses which is which.
1249     *
1250     * @param segments text per level, outermost first; null or empty returns null
1251     * @param forms    the form of each segment, the same length as {@code segments}
1252     * @param objectType what the whole name denotes
1253     * @throws IllegalArgumentException when the forms do not match the segments one to one (also when
1254     *         the vendor's hierarchy would merge segments, since a merged segment has no single form)
1255     * @throws UnsupportedOperationException as documented on {@link #qualifiedCanonKeyFromLexical}
1256     */
1257    public QualifiedCanonKey qualifiedCanonKeyFromSegments(List<String> segments, List<IdentifierInputForm> forms,
1258                                                           ESQLDataObjectType objectType) {
1259        if (segments == null || segments.isEmpty()) {
1260            return null;
1261        }
1262        if (forms == null || forms.size() != segments.size()) {
1263            throw new IllegalArgumentException("one input form per segment: " + segments + " / " + forms);
1264        }
1265        List<QualifiedNameHierarchy.Segment> plan =
1266                QualifiedNameHierarchy.plan(profile.getVendor(), objectType, segments);
1267        if (plan == null) {
1268            throw new UnsupportedOperationException("no identifier hierarchy for "
1269                    + profile.getVendor() + "/" + objectType + " (name: " + segments
1270                    + "); probe QualifiedNameHierarchy.hasHierarchyMapping first");
1271        }
1272        if (plan.size() != segments.size()) {
1273            throw new IllegalArgumentException("the hierarchy merges segments of " + segments
1274                    + ", so their input forms no longer line up");
1275        }
1276        List<CanonKey> keys = new ArrayList<CanonKey>(plan.size());
1277        for (int i = 0; i < plan.size(); i++) {
1278            QualifiedNameHierarchy.Segment segment = plan.get(i);
1279            String lexical = forms.get(i) == IdentifierInputForm.CATALOG_STORED
1280                    ? IdentifierCodec.encodeStored(profile, segment.getType(), segment.getText())
1281                    : segment.getText();
1282            CanonKey key = canonKey(lexical, segment.getType());
1283            if (key == null) {
1284                throw new IllegalStateException("null canonical key for segment of " + segments);
1285            }
1286            keys.add(key);
1287        }
1288        return new QualifiedCanonKey(keys, objectType);
1289    }
1290
1291    private QualifiedCanonKey build(List<String> segments, ESQLDataObjectType objectType,
1292                                    boolean stored, String forMessage) {
1293        List<QualifiedNameHierarchy.Segment> plan =
1294                QualifiedNameHierarchy.plan(profile.getVendor(), objectType, segments);
1295        if (plan == null) {
1296            throw new UnsupportedOperationException("no identifier hierarchy for "
1297                    + profile.getVendor() + "/" + objectType + " (name: " + forMessage
1298                    + "); probe QualifiedNameHierarchy.hasHierarchyMapping first");
1299        }
1300        List<CanonKey> keys = new ArrayList<CanonKey>(plan.size());
1301        for (QualifiedNameHierarchy.Segment segment : plan) {
1302            String lexical = stored
1303                    ? IdentifierCodec.encodeStored(profile, segment.getType(), segment.getText())
1304                    : segment.getText();
1305            CanonKey key = canonKey(lexical, segment.getType());
1306            if (key == null) {
1307                // canonKey only returns null for a null identifier, which a segment never is.
1308                throw new IllegalStateException("null canonical key for segment of " + forMessage);
1309            }
1310            keys.add(key);
1311        }
1312        return new QualifiedCanonKey(keys, objectType);
1313    }
1314
1315    // ===== 完整限定名处理(多段处理) =====
1316
1317    /**
1318     * 解析完整限定名为段列表
1319     *
1320     * <p>包装 {@link SQLUtil#parseNames(String, EDbVendor)} 以支持厂商特定解析:
1321     * <ul>
1322     * <li>MSSQL: 保留 ".." 以便后续展开
1323     * <li>BigQuery: 反引号内的点号仍视为层级分隔
1324     * <li>其它: 按 '.' 分段,处理引号包裹的段
1325     * </ul>
1326     *
1327     * @param qualifiedName 完整限定名(如 "db.schema.table" 或 "db..table")
1328     * @return 段列表
1329     */
1330    public List<String> parseQualifiedName(String qualifiedName) {
1331        return SQLUtil.parseNames(qualifiedName, profile.getVendor());
1332    }
1333
1334    /**
1335     * 厂商级预处理(展开特殊语法)
1336     *
1337     * <p>处理厂商特定语法:
1338     * <ul>
1339     * <li>MSSQL/Azure SQL: 将 "db..table" 展开为 "db.&lt;global or dbo&gt;.table"
1340     * </ul>
1341     *
1342     * @param segments 原始段列表
1343     * @param vendor 数据库厂商
1344     * @return 展开后的段列表
1345     */
1346    public List<String> expandVendorSpecific(List<String> segments, EDbVendor vendor) {
1347        return expandVendorSpecific(segments, vendor, null);
1348    }
1349
1350    /**
1351     * 厂商级预处理(展开特殊语法)
1352     *
1353     * <p>处理厂商特定语法:
1354     * <ul>
1355     * <li>MSSQL/Azure SQL: 将 "db..table" 展开为 "db.&lt;defaultSchema or dbo&gt;.table"
1356     * </ul>
1357     *
1358     * @param segments 原始段列表
1359     * @param vendor 数据库厂商
1360     * @param defaultSchema 默认 schema,如果为 null 则使用 "dbo"
1361     * @return 展开后的段列表
1362     */
1363    public List<String> expandVendorSpecific(List<String> segments, EDbVendor vendor, String defaultSchema) {
1364        if (vendor != EDbVendor.dbvmssql && vendor != EDbVendor.dbvazuresql) {
1365            return segments;
1366        }
1367
1368        // MSSQL: 展开 ".." 为 ".<defaultSchema or dbo>."
1369        // Note: We no longer use ModelBindingManager.getGlobalSchema() here because that is
1370        // designed for DataFlowAnalyzer context. Using it here causes test pollution when
1371        // tests fail without cleaning up the ThreadLocal state.
1372        String schemaToUse = (defaultSchema != null && !defaultSchema.isEmpty()) ? defaultSchema : "dbo";
1373
1374        List<String> expanded = new ArrayList<>();
1375        for (int i = 0; i < segments.size(); i++) {
1376            String seg = segments.get(i);
1377            if (seg.isEmpty() && i > 0 && i < segments.size() - 1) {
1378                // 这是 ".." 中的空段
1379                expanded.add(schemaToUse);
1380            } else {
1381                expanded.add(seg);
1382            }
1383        }
1384        return expanded;
1385    }
1386
1387    /**
1388     * 规范化单个段(去引号 + 大小写折叠)
1389     *
1390     * <p>与 {@link #normalize(String, ESQLDataObjectType)} 类似,但语义明确为"单段"处理。
1391     *
1392     * @param segment 单个段标识符
1393     * @param objectType 对象类型
1394     * @return 规范化后的段
1395     */
1396    public String normalizeSegment(String segment, ESQLDataObjectType objectType) {
1397        return normalize(segment, objectType);
1398    }
1399
1400    /**
1401     * 规范化完整限定名(等价于 SQLUtil.getIdentifierNormalName)
1402     *
1403     * <p>处理流程:
1404     * <ol>
1405     * <li>解析为段列表:{@link #parseQualifiedName(String)}
1406     * <li>展开厂商特定语法:{@link #expandVendorSpecific(List, EDbVendor)}
1407     * <li>根据 supportCatalog/supportSchema 决定各段类型
1408     * <li>逐段规范化:{@link #normalizeSegment(String, ESQLDataObjectType)}
1409     * <li>重新拼接为 "catalog.schema.table.column" 格式
1410     * </ol>
1411     *
1412     * @param qualifiedName 完整限定名
1413     * @param objectType 最终对象类型(dotTable/dotColumn/dotSchema 等)
1414     * @return 规范化后的完整限定名
1415     */
1416    public String normalizeQualifiedName(String qualifiedName, ESQLDataObjectType objectType) {
1417        if (qualifiedName == null || qualifiedName.isEmpty()) {
1418            return qualifiedName;
1419        }
1420
1421        List<String> segments = expandVendorSpecific(parseQualifiedName(qualifiedName), profile.getVendor());
1422        if (segments.isEmpty()) {
1423            return qualifiedName;
1424        }
1425
1426        boolean supportCatalog = TSQLEnv.supportCatalog(profile.getVendor());
1427        boolean supportSchema = TSQLEnv.supportSchema(profile.getVendor());
1428
1429        StringBuilder builder = new StringBuilder();
1430
1431        // 根据 supportCatalog/supportSchema 与 objectType 决定各段类型
1432        if (supportCatalog && supportSchema) {
1433            if (objectType == ESQLDataObjectType.dotColumn) {
1434                if (segments.size() > 4) {
1435                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1436                            .append(".")
1437                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotSchema))
1438                            .append(".")
1439                            .append(normalizeSegment(segments.get(2), ESQLDataObjectType.dotTable))
1440                            .append(".")
1441                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 3), ESQLDataObjectType.dotColumn));
1442                } else if (segments.size() == 4) {
1443                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1444                            .append(".")
1445                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotSchema))
1446                            .append(".")
1447                            .append(normalizeSegment(segments.get(2), ESQLDataObjectType.dotTable))
1448                            .append(".")
1449                            .append(normalizeSegment(segments.get(3), ESQLDataObjectType.dotColumn));
1450                } else if (segments.size() == 3) {
1451                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1452                            .append(".")
1453                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotTable))
1454                            .append(".")
1455                            .append(normalizeSegment(segments.get(2), ESQLDataObjectType.dotColumn));
1456                } else if (segments.size() == 2) {
1457                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotTable))
1458                            .append(".")
1459                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotColumn));
1460                } else if (segments.size() == 1) {
1461                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotColumn));
1462                }
1463            } else if (objectType == ESQLDataObjectType.dotTable
1464                    || objectType == ESQLDataObjectType.dotOraclePackage
1465                    || objectType == ESQLDataObjectType.dotFunction
1466                    || objectType == ESQLDataObjectType.dotProcedure
1467                    || objectType == ESQLDataObjectType.dotTrigger) {
1468                if (segments.size() > 3) {
1469                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1470                            .append(".")
1471                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotSchema))
1472                            .append(".")
1473                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 2), objectType));
1474                } else if (segments.size() == 3) {
1475                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1476                            .append(".")
1477                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotSchema))
1478                            .append(".")
1479                            .append(normalizeSegment(segments.get(2), objectType));
1480                } else if (segments.size() == 2) {
1481                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1482                            .append(".")
1483                            .append(normalizeSegment(segments.get(1), objectType));
1484                } else if (segments.size() == 1) {
1485                    builder.append(normalizeSegment(segments.get(0), objectType));
1486                }
1487            } else if (objectType == ESQLDataObjectType.dotSchema) {
1488                if (segments.size() > 2) {
1489                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1490                            .append(".")
1491                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 1), objectType));
1492                } else if (segments.size() == 2) {
1493                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1494                            .append(".")
1495                            .append(normalizeSegment(segments.get(1), objectType));
1496                } else if (segments.size() == 1) {
1497                    builder.append(normalizeSegment(segments.get(0), objectType));
1498                }
1499            } else if (objectType == ESQLDataObjectType.dotCatalog) {
1500                if (segments.size() > 1) {
1501                    builder.append(normalizeSegment(SQLUtil.mergeSegments(segments, 0), objectType));
1502                } else if (segments.size() == 1) {
1503                    builder.append(normalizeSegment(segments.get(0), objectType));
1504                }
1505            }
1506        } else if (supportCatalog) {
1507            if (objectType == ESQLDataObjectType.dotColumn) {
1508                if (segments.size() > 3) {
1509                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1510                            .append(".")
1511                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotTable))
1512                            .append(".")
1513                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 2), ESQLDataObjectType.dotColumn));
1514                } else if (segments.size() == 3) {
1515                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1516                            .append(".")
1517                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotTable))
1518                            .append(".")
1519                            .append(normalizeSegment(segments.get(2), ESQLDataObjectType.dotColumn));
1520                } else if (segments.size() == 2) {
1521                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotTable))
1522                            .append(".")
1523                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotColumn));
1524                } else if (segments.size() == 1) {
1525                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotColumn));
1526                }
1527            } else if (objectType == ESQLDataObjectType.dotTable
1528                    || objectType == ESQLDataObjectType.dotOraclePackage
1529                    || objectType == ESQLDataObjectType.dotFunction
1530                    || objectType == ESQLDataObjectType.dotProcedure
1531                    || objectType == ESQLDataObjectType.dotTrigger) {
1532                if (segments.size() > 2) {
1533                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1534                            .append(".")
1535                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 1), objectType));
1536                } else if (segments.size() == 2) {
1537                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1538                            .append(".")
1539                            .append(normalizeSegment(segments.get(1), objectType));
1540                } else if (segments.size() == 1) {
1541                    builder.append(normalizeSegment(segments.get(0), objectType));
1542                }
1543            } else if (objectType == ESQLDataObjectType.dotSchema || objectType == ESQLDataObjectType.dotCatalog) {
1544                if (segments.size() > 1) {
1545                    builder.append(normalizeSegment(SQLUtil.mergeSegments(segments, 0), ESQLDataObjectType.dotCatalog));
1546                } else if (segments.size() == 1) {
1547                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog));
1548                }
1549            }
1550        } else if (supportSchema) {
1551            if (objectType == ESQLDataObjectType.dotColumn) {
1552                if (segments.size() > 3) {
1553                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1554                            .append(".")
1555                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotTable))
1556                            .append(".")
1557                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 2), ESQLDataObjectType.dotColumn));
1558                } else if (segments.size() == 3) {
1559                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1560                            .append(".")
1561                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotTable))
1562                            .append(".")
1563                            .append(normalizeSegment(segments.get(2), ESQLDataObjectType.dotColumn));
1564                } else if (segments.size() == 2) {
1565                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotTable))
1566                            .append(".")
1567                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotColumn));
1568                } else if (segments.size() == 1) {
1569                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotColumn));
1570                }
1571            } else if (objectType == ESQLDataObjectType.dotTable
1572                    || objectType == ESQLDataObjectType.dotOraclePackage
1573                    || objectType == ESQLDataObjectType.dotFunction
1574                    || objectType == ESQLDataObjectType.dotProcedure
1575                    || objectType == ESQLDataObjectType.dotTrigger) {
1576                if (segments.size() > 2) {
1577                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1578                            .append(".")
1579                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 1), objectType));
1580                } else if (segments.size() == 2) {
1581                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1582                            .append(".")
1583                            .append(normalizeSegment(segments.get(1), objectType));
1584                } else if (segments.size() == 1) {
1585                    builder.append(normalizeSegment(segments.get(0), objectType));
1586                }
1587            } else if (objectType == ESQLDataObjectType.dotSchema || objectType == ESQLDataObjectType.dotCatalog) {
1588                if (segments.size() > 1) {
1589                    builder.append(normalizeSegment(SQLUtil.mergeSegments(segments, 0), ESQLDataObjectType.dotSchema));
1590                } else if (segments.size() == 1) {
1591                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema));
1592                }
1593            }
1594        }
1595
1596        return builder.toString();
1597    }
1598
1599    /**
1600     * 生成层级索引所需的段级键列表
1601     *
1602     * <p>用于分层索引(hierarchical index),确保各层键由 {@link #keyForMap} 统一生成。
1603     *
1604     * <p><strong>注意:</strong>调用前应先调用 {@link #parseQualifiedName(String)} 和
1605     * {@link #expandVendorSpecific(List, EDbVendor)} 完成解析与展开。
1606     *
1607     * @param qualifiedName 完整限定名
1608     * @param partTypes 各段对应的对象类型列表(长度应与段数一致)
1609     * @return 段级键列表
1610     * @throws IllegalArgumentException 如果段数与类型数不匹配
1611     */
1612    public List<String> keysForHierarchy(String qualifiedName, List<ESQLDataObjectType> partTypes) {
1613        List<String> segments = expandVendorSpecific(parseQualifiedName(qualifiedName), profile.getVendor());
1614        if (segments.size() != partTypes.size()) {
1615            throw new IllegalArgumentException("Segment count (" + segments.size()
1616                    + ") does not match type count (" + partTypes.size() + ")");
1617        }
1618
1619        List<String> keys = new ArrayList<>();
1620        for (int i = 0; i < segments.size(); i++) {
1621            keys.add(keyForMap(segments.get(i), partTypes.get(i)));
1622        }
1623        return keys;
1624    }
1625
1626    /**
1627     * 断言标识符为单段,否则抛出异常
1628     *
1629     * <p>用于在 {@link #keyForMap} 入口处确保输入为单段标识符(不包含 '.' 分隔符)。
1630     *
1631     * <p>如果 {@link TBaseType#ALLOW_MULTI_SEGMENT_IN_KEY} 为 true(兼容模式),
1632     * 则仅记录警告日志而不抛异常。
1633     *
1634     * @param identifier 待检查的标识符
1635     * @throws IllegalArgumentException 如果标识符包含多段且未启用兼容模式
1636     */
1637    public void assertSingleSegmentOrThrow(String identifier, ESQLDataObjectType objectType) {
1638        if (identifier == null || identifier.isEmpty()) {
1639            return;
1640        }
1641
1642        // Compat mode is a no-op (the warning below is disabled), but this method sits on
1643        // the per-comparison hot path since P0d — segmenting just to discard the result
1644        // was ~15% of dlineage wall on comparison-dense files. Strict mode is unchanged.
1645        if (TBaseType.ALLOW_MULTI_SEGMENT_IN_KEY) {
1646            return;
1647        }
1648
1649        List<String> segments = parseQualifiedName(identifier);
1650        if (segments.size() > 1) {
1651            String message = "keyForMap requires single segment, but got " + segments.size()
1652                    + " segments: " + identifier;
1653            if (TBaseType.ALLOW_MULTI_SEGMENT_IN_KEY) {
1654                // 兼容模式:仅记录警告
1655               // System.err.println("[WARN] " + message);
1656               // System.err.println("[WARN] return by SQLUtil.getIdentifierNormalName:" + SQLUtil.getIdentifierNormalName(this.getProfile().getVendor(), identifier, objectType));
1657            } else {
1658                // 严格模式:抛出异常
1659                throw new IllegalArgumentException(message);
1660            }
1661        }
1662    }
1663
1664    // ===== 辅助方法 =====
1665
1666    /**
1667     * 判断标识符是否被引号包围
1668     *
1669     * <p>Since U5, recognition is the codec's per-vendor/per-role delimiter table
1670     * ({@link IdentifierCodec#isQuoted}) instead of the legacy first-char switch
1671     * ({@code TSQLEnv.isDelimitedIdentifier}). The deltas are deliberate
1672     * corrections: vendor-documented forms the legacy switch missed are now
1673     * recognized (Spark/Databricks backticks, PostgreSQL {@code U&"…"},
1674     * Sybase/SQLite brackets, ClickHouse backticks, MDX brackets, Power Query
1675     * {@code #"…"}, DAX per-role bracket vs apostrophe, MySQL {@code ANSI_QUOTES}
1676     * double quotes). SQL Server keeps apostrophe recognition in the COLUMN role
1677     * ({@code AS 'REGKEY'} names a real column; dropping it lost lineage edges —
1678     * see {@code IdentifierCodec.RULES_MSSQL_COLUMN}).
1679     */
1680    private boolean isQuoted(String identifier, ESQLDataObjectType objectType) {
1681        if (objectType != null) {
1682            return IdentifierCodec.isQuoted(profile, objectType, identifier);
1683        }
1684        // "Any type" callers (CatalogStore.getByName(name, null)): no single codec
1685        // role can represent "any" — DAX quotes table names with apostrophes but
1686        // columns/measures with brackets — so probe the role rule-sets and accept
1687        // either (Codex U5 round-1 finding 2). For every other vendor the two
1688        // probes hit the same table and the second is never reached.
1689        return IdentifierCodec.isQuoted(profile, ESQLDataObjectType.dotTable, identifier)
1690                || IdentifierCodec.isQuoted(profile, ESQLDataObjectType.dotColumn, identifier);
1691    }
1692
1693    /** The codec role that recognizes this spelling for a null ("any type") caller. */
1694    private ESQLDataObjectType recognizingRole(String identifier, ESQLDataObjectType objectType) {
1695        if (objectType != null) {
1696            return objectType;
1697        }
1698        return IdentifierCodec.isQuoted(profile, ESQLDataObjectType.dotTable, identifier)
1699                ? ESQLDataObjectType.dotTable : ESQLDataObjectType.dotColumn;
1700    }
1701
1702    /**
1703     * 移除标识符的引号
1704     *
1705     * <p>Since U5, a codec-recognized quoted spelling is DECODED
1706     * ({@link IdentifierCodec#decodeLexical}: delimiters removed, escapes resolved —
1707     * {@code [a]]b]} → {@code a]b}, {@code U&"d\0061ta"} → {@code data}) instead of
1708     * having its first/last code unit stripped. When strict decoding REJECTS the
1709     * input ({@code [a.b[} unterminated, {@code "EMPLOYEES"@"dblink"} trailing
1710     * text), the ORIGINAL spelling is returned unchanged: the string is not a
1711     * well-formed single quoted identifier, and guessing a payload by stripping
1712     * the first/last code units fabricates identity ({@code "EMPLOYEES"@"LD.X"}
1713     * strips to the nonsense {@code EMPLOYEES"@"LD.X}, which pre-U5 survived only
1714     * because a multi-segment safety net in {@code SQLUtil.normalizeIdentifier}
1715     * happened to discard it). Totality holds either way —
1716     * {@link #areEqual}/{@link #keyForMap} accept arbitrary text and malformed
1717     * spellings simply keep their identity as written.
1718     */
1719    private String removeQuotes(String identifier, ESQLDataObjectType objectType) {
1720        try {
1721            return IdentifierCodec.decodeLexical(profile,
1722                    recognizingRole(identifier, objectType), identifier);
1723        } catch (IdentifierCodec.MalformedIdentifierException e) {
1724            return identifier;
1725        }
1726    }
1727
1728    /**
1729     * 检查字符串是否包含引号字符
1730     *
1731     * <p>检查常见引号字符:", ', `, [, ]
1732     */
1733    private boolean containsQuoteChar(String str) {
1734        for (int i = 0; i < str.length(); i++) {
1735            char c = str.charAt(i);
1736            if (c == '"' || c == '\'' || c == '`' || c == '[' || c == ']') {
1737                return true;
1738            }
1739        }
1740        return false;
1741    }
1742
1743    // ===== Getter 方法 =====
1744
1745    /**
1746     * 获取标识符配置档案
1747     *
1748     * @return 配置档案
1749     */
1750    public IdentifierProfile getProfile() {
1751        return profile;
1752    }
1753
1754    /**
1755     * 获取 Collator 提供者
1756     *
1757     * @return Collator 提供者(可能为 null)
1758     */
1759    public CollatorProvider getCollatorProvider() {
1760        return collatorProvider;
1761    }
1762}