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 * // 构造索引键
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
092        for (EDbVendor vendor : EDbVendor.values()) {
093            IdentifierProfile profile = IdentifierProfile.forVendor(
094                vendor,
095                IdentifierProfile.VendorFlags.defaults()
096            );
097            CollatorProvider collatorProvider = (vendor == EDbVendor.dbvmssql || vendor == EDbVendor.dbvazuresql)
098                ? new CollatorProvider()
099                : null;
100            VENDOR_CACHE.put(vendor, new IdentifierService(profile, collatorProvider));
101        }
102    }
103
104    /**
105     * High-performance static normalize method with caching
106     *
107     * <p>This method provides a convenient static interface for identifier normalization
108     * while leveraging a pre-populated cache of IdentifierService instances.</p>
109     *
110     * <p><strong>Performance characteristics:</strong>
111     * <ul>
112     *   <li>O(1) cache lookup using EnumMap
113     *   <li>No object creation - services are pre-created and reused
114     *   <li>Thread-safe - all cached instances are immutable
115     *   <li>Handles all vendor-specific rules (collation, per-object-type rules, etc.)
116     * </ul>
117     *
118     * <p><strong>Usage example:</strong>
119     * <pre>
120     * String normalized = IdentifierService.normalizeStatic(
121     *     EDbVendor.dbvoracle,
122     *     ESQLDataObjectType.dotTable,
123     *     "MyTable"
124     * );
125     * // Result: "MYTABLE"
126     * </pre>
127     *
128     * @param dbVendor database vendor
129     * @param objectType object type (table, column, schema, etc.)
130     * @param identifier identifier to normalize (may be quoted)
131     * @return normalized identifier (unquoted and case-folded)
132     */
133    public static String normalizeStatic(EDbVendor dbVendor, ESQLDataObjectType objectType, String identifier) {
134        if (identifier == null || identifier.isEmpty()) {
135            return identifier;
136        }
137
138        // Get cached instance (O(1) lookup in EnumMap)
139        IdentifierService service = VENDOR_CACHE.get(dbVendor);
140        if (service == null) {
141            // Fallback for unexpected null (should never happen with pre-populated cache)
142            // Create on-demand instance
143            IdentifierProfile profile = IdentifierProfile.forVendor(dbVendor, IdentifierProfile.VendorFlags.defaults());
144            CollatorProvider collatorProvider = (dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql)
145                ? new CollatorProvider()
146                : null;
147            service = new IdentifierService(profile, collatorProvider);
148        }
149
150        return service.normalize(identifier, objectType);
151    }
152
153    /**
154     * High-performance static identifier comparison method with caching
155     *
156     * <p>This method provides a convenient static interface for identifier comparison
157     * while leveraging a pre-populated cache of IdentifierService instances.</p>
158     *
159     * <p><strong>Performance characteristics:</strong>
160     * <ul>
161     *   <li>O(1) cache lookup using EnumMap
162     *   <li>No object creation - services are pre-created and reused
163     *   <li>Thread-safe - all cached instances are immutable
164     *   <li>Handles all vendor-specific rules (case-sensitive, case-insensitive, collation-based)
165     * </ul>
166     *
167     * <p><strong>Usage example:</strong>
168     * <pre>
169     * boolean equal = IdentifierService.areEqualStatic(
170     *     EDbVendor.dbvoracle,
171     *     ESQLDataObjectType.dotTable,
172     *     "MyTable",
173     *     "MYTABLE"
174     * );
175     * // Result: true (Oracle is case-insensitive for unquoted identifiers)
176     * </pre>
177     *
178     * @param dbVendor database vendor
179     * @param objectType object type (table, column, schema, etc.)
180     * @param ident1 first identifier to compare (may be quoted)
181     * @param ident2 second identifier to compare (may be quoted)
182     * @return true if identifiers are equal according to vendor rules
183     */
184    public static boolean areEqualStatic(EDbVendor dbVendor, ESQLDataObjectType objectType, String ident1, String ident2) {
185        // Handle null cases
186        if (ident1 == null || ident2 == null) {
187            return ident1 == ident2;
188        }
189
190        // Fast path: reference equality
191        if (ident1 == ident2) {
192            return true;
193        }
194
195        // Get cached instance (O(1) lookup in EnumMap)
196        IdentifierService service = VENDOR_CACHE.get(dbVendor);
197        if (service == null) {
198            // Fallback for unexpected null (should never happen with pre-populated cache)
199            // Create on-demand instance
200            IdentifierProfile profile = IdentifierProfile.forVendor(dbVendor, IdentifierProfile.VendorFlags.defaults());
201            CollatorProvider collatorProvider = (dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql)
202                ? new CollatorProvider()
203                : null;
204            service = new IdentifierService(profile, collatorProvider);
205        }
206
207        return service.areEqual(ident1, ident2, objectType);
208    }
209
210    /**
211     * Static {@link #canonKey} accessor over the cached default-flags service per vendor
212     * (same cache {@link #areEqualStatic} uses, so key equality mirrors that relation).
213     */
214    public static CanonKey canonKeyStatic(EDbVendor dbVendor, ESQLDataObjectType objectType, String identifier) {
215        IdentifierService service = VENDOR_CACHE.get(dbVendor);
216        if (service == null) {
217            IdentifierProfile profile = IdentifierProfile.forVendor(dbVendor, IdentifierProfile.VendorFlags.defaults());
218            CollatorProvider collatorProvider = (dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql)
219                ? new CollatorProvider()
220                : null;
221            service = new IdentifierService(profile, collatorProvider);
222        }
223        return service.canonKey(identifier, objectType);
224    }
225
226    // ===== Instance Fields =====
227
228    private final IdentifierProfile profile;
229    private final CollatorProvider collatorProvider;  // SQL Server 专用(可选)
230
231    // ===== 构造函数 =====
232
233    /**
234     * 构造标识符服务
235     *
236     * @param profile 厂商标识符配置档案
237     * @param collatorProvider Collator 提供者(SQL Server 专用,可为 null)
238     */
239    public IdentifierService(IdentifierProfile profile, CollatorProvider collatorProvider) {
240        this.profile = profile;
241        this.collatorProvider = collatorProvider;
242    }
243
244    // ===== 规范化(用于索引键构造) =====
245
246    /**
247     * 规范化标识符(去引号 + 大小写折叠)
248     *
249     * <p>用于构造索引键,确保相同语义的标识符生成相同的键。
250     *
251     * @param identifier 原始标识符(可能带引号)
252     * @param objectType 对象类型
253     * @return 规范化后的标识符
254     */
255    public String normalize(String identifier, ESQLDataObjectType objectType) {
256        if (identifier == null || identifier.isEmpty()) {
257            return identifier;
258        }
259        return normalize(identifier, objectType, isQuoted(identifier, objectType));
260    }
261
262    /** Quote-state-threaded body: recognition runs ONCE per operand per public
263     *  entry point — areEqual previously re-recognized each operand up to four
264     *  times through the normalize/keyForMap/foldPayload chain (hot-path cost
265     *  surfaced by the U5 fork-pair benchmark). */
266    private String normalize(String identifier, ESQLDataObjectType objectType, boolean quoted) {
267        if (identifier == null || identifier.isEmpty()) {
268            return identifier;
269        }
270        IdentifierRules rules = profile.getRules(objectType);
271
272        // 1. 去引号
273        String unquoted = quoted ? removeQuotes(identifier, objectType) : identifier;
274
275        // 2. 大小写折叠
276        CaseFold fold = quoted ? rules.quotedFold : rules.unquotedFold;
277        return applyFold(unquoted, fold);
278    }
279
280    /**
281     * 应用大小写折叠
282     *
283     * <p>Since U5, folding is pinned to {@link GspCaseData} (UCD
284     * {@value GspCaseData#UNICODE_VERSION}) instead of the JVM's case tables, so canonical
285     * keys are identical on every JDK. Locale-conditional rules (tr/az/lt) are excluded by
286     * construction — the pinned tables carry only ROOT-locale mappings plus the
287     * Final_Sigma context rule.
288     */
289    private String applyFold(String str, CaseFold fold) {
290        switch (fold) {
291            case UPPER:
292                return GspCaseData.toUpperFull(str);
293            case LOWER:
294                return GspCaseData.toLowerFull(str);
295            case NONE:
296            default:
297                return str;
298        }
299    }
300
301    // ===== 比较(用于查找匹配) =====
302
303    /**
304     * 比较两个标识符是否相等(P0d.1:canonical-key equality)。
305     *
306     * <p>Defined as {@code canonKey(ident1).equals(canonKey(ident2))}, which makes this an
307     * equivalence relation by construction. The previous rule selection ("quoted compare if
308     * EITHER operand is quoted" over quote-stripped-but-unfolded text) was an OR-relation and
309     * non-transitive on quoted-case-preserving dialects: Oracle {@code foo ~ "foo"} and
310     * {@code foo ~ "FOO"} but {@code "foo" ≁ "FOO"}. Under the canonical relation each operand
311     * folds by its own quote state (unquoted {@code foo} → {@code FOO} matches quoted
312     * {@code "FOO"}, not quoted {@code "foo"}), matching how the names are actually stored.
313     *
314     * @param ident1 标识符 1
315     * @param ident2 标识符 2
316     * @param objectType 对象类型
317     * @return true 如果相等
318     */
319    public boolean areEqual(String ident1, String ident2, ESQLDataObjectType objectType) {
320        if (ident1 == null || ident2 == null) {
321            return ident1 == ident2;
322        }
323
324        // 快速路径:引用相同
325        if (ident1 == ident2) {
326            return true;
327        }
328
329        // Same service ⇒ same rule domain, so compare canonical payloads directly instead
330        // of allocating CanonKeys (this sits on the resolver2 matcher hot path). The
331        // payloads come from the SAME helpers canonKey uses, so
332        // areEqual(a,b) == canonKey(a).equals(canonKey(b)) holds by construction.
333        // Recognition once per operand (quote-state threading — see normalize).
334        IdentifierRules rules = profile.getRules(objectType);
335        boolean q1 = isQuoted(ident1, objectType);
336        boolean q2 = isQuoted(ident2, objectType);
337        boolean collation1 = effectiveCompare(rules, q1) == CaseCompare.COLLATION_BASED;
338        boolean collation2 = effectiveCompare(rules, q2) == CaseCompare.COLLATION_BASED;
339        if (collation1 != collation2) {
340            // Mixed payload kinds (possible only in custom profiles) never key-match.
341            return false;
342        }
343        if (collation1) {
344            String s1 = q1 ? removeQuotes(ident1, objectType) : ident1;
345            String s2 = q2 ? removeQuotes(ident2, objectType) : ident2;
346            if (collatorProvider != null) {
347                Collator collator = collatorProvider.getCollator(profile.getFlags().defaultCollation);
348                return collator.compare(s1, s2) == 0;
349            }
350            return caseCollapse(s1).equals(caseCollapse(s2));
351        }
352        return foldPayload(ident1, objectType, rules, q1)
353                .equals(foldPayload(ident2, objectType, rules, q2));
354    }
355
356    /** Effective compare rule for one operand's quote state (SAME_AS_UNQUOTED resolved). */
357    static CaseCompare effectiveCompare(IdentifierRules rules, boolean isQuoted) {
358        CaseCompare compare = isQuoted ? rules.quotedCompare : rules.unquotedCompare;
359        return compare == CaseCompare.SAME_AS_UNQUOTED ? rules.unquotedCompare : compare;
360    }
361
362    /**
363     * Canonical fold payload of one operand: the stored-form text ({@link #keyForMap}),
364     * case-collapsed when the effective compare is INSENSITIVE but the applied fold is
365     * NONE (MySQL/BigQuery columns keep the spelling yet compare case-insensitively).
366     */
367    private String foldPayload(String identifier, ESQLDataObjectType objectType, IdentifierRules rules) {
368        return foldPayload(identifier, objectType, rules, isQuoted(identifier, objectType));
369    }
370
371    private String foldPayload(String identifier, ESQLDataObjectType objectType,
372                               IdentifierRules rules, boolean quoted) {
373        String storedText = keyForMap(identifier, objectType, quoted);
374        CaseFold appliedFold = quoted ? rules.quotedFold : rules.unquotedFold;
375        if (effectiveCompare(rules, quoted) == CaseCompare.INSENSITIVE && appliedFold == CaseFold.NONE) {
376            storedText = caseCollapse(storedText);
377        }
378        return storedText;
379    }
380
381    // ===== 索引键构造(段级,用于分层索引) =====
382
383    /**
384     * 为 Map 索引构造键(单个标识符段)
385     *
386     * <p><strong>注意:</strong>此方法仅用于分层索引的单段键,不用于复合键。
387     *
388     * <p>对于 COLLATION_BASED(SQL Server),不做 fold,返回原始标识符
389     * (后续通过桶+Collator 比较)。
390     *
391     * @param identifier 标识符
392     * @param objectType 对象类型
393     * @return 索引键
394     */
395    public String keyForMap(String identifier, ESQLDataObjectType objectType) {
396        if (identifier == null || identifier.isEmpty()) {
397            return identifier;
398        }
399        return keyForMap(identifier, objectType, isQuoted(identifier, objectType));
400    }
401
402    /** Quote-state-threaded body (see {@link #normalize(String, ESQLDataObjectType, boolean)}). */
403    private String keyForMap(String identifier, ESQLDataObjectType objectType, boolean isQuoted) {
404       if (identifier == null || identifier.isEmpty()) {
405           return identifier;
406       }
407
408       // 强制单段输入校验(Phase 0: 默认启用)
409       assertSingleSegmentOrThrow(identifier,objectType);
410
411       IdentifierRules rules = profile.getRules(objectType);
412       CaseCompare compare = isQuoted ? rules.quotedCompare : rules.unquotedCompare;
413
414       // 处理 SAME_AS_UNQUOTED
415       if (compare == CaseCompare.SAME_AS_UNQUOTED) {
416           compare = rules.unquotedCompare;
417       }
418
419       // 对于 COLLATION_BASED,不折叠,返回原始标识符(去引号)。
420       // Return the ONCE-decoded payload directly — routing it through
421       // normalize() would decode a second time when the stored payload itself
422       // looks like a quoted lexical form (MSSQL [[foo]]] decodes to [foo],
423       // which normalize would re-decode to foo, colliding with the key of
424       // [foo] even though areEqual keeps the two distinct — Codex U5 round-1
425       // finding 3).
426       if (compare == CaseCompare.COLLATION_BASED) {
427           return isQuoted ? removeQuotes(identifier, objectType) : identifier;
428       }
429
430       // 其他情况:规范化后作为键
431       return normalize(identifier, objectType, isQuoted);
432    }
433
434    /**
435     * Build the canonical key of a single identifier segment (unification plan stage P0c).
436     *
437     * <p>Fold-based rules use the {@link #keyForMap} text as the payload; collation-based
438     * rules (SQL Server / Azure) use {@link java.text.CollationKey} bytes from the SAME
439     * collator {@link #areEqual} compares with, so key equality mirrors that collator. When
440     * a collation-based profile has no {@link CollatorProvider}, {@code areEqual} falls back
441     * to comparing {@link #caseCollapse} payloads (pinned Unicode simple case folding since
442     * U5); the key mirrors that exactly by carrying the collapsed decoded text.
443     *
444     * <p>NOTE: since P0d.1, {@link #areEqual} IS canonical-key equality. The legacy
445     * {@code TSQLEnv.compareIdentifier} façade migrates to this relation in P0d.2; until
446     * then the P0c equivalence proof measures that remaining engine's distance from it.
447     *
448     * @param identifier single identifier segment (may be quoted); null returns null
449     * @param objectType kind of database object the name refers to
450     * @return the canonical key, or null for a null identifier
451     */
452    public CanonKey canonKey(String identifier, ESQLDataObjectType objectType) {
453        if (identifier == null) {
454            return null;
455        }
456
457        IdentifierProfile.ObjectGroup group = profile.groupOf(objectType);
458        long fingerprint = profile.getFingerprint();
459        IdentifierRules rules = profile.getRules(objectType);
460
461        boolean quoted = isQuoted(identifier, objectType);
462        if (effectiveCompare(rules, quoted) == CaseCompare.COLLATION_BASED) {
463            String stripped = quoted ? removeQuotes(identifier, objectType) : identifier;
464            String collation = profile.getFlags().defaultCollation;
465            if (collatorProvider != null) {
466                String collatorId = collation + ":" + collatorProvider.getClass().getName();
467                byte[] keyBytes = collatorProvider.getCollator(collation)
468                        .getCollationKey(stripped).toByteArray();
469                return new CanonKey(profile.getVendor(), group, fingerprint, collatorId, stripped, keyBytes);
470            }
471            // No provider: areEqual falls back to comparing caseCollapse payloads
472            // (pinned C+S simple fold since U5) — mirror it.
473            return new CanonKey(profile.getVendor(), group, fingerprint,
474                    collation + ":none", caseCollapse(stripped), null);
475        }
476
477        // Same payload construction areEqual compares directly (see foldPayload).
478        return new CanonKey(profile.getVendor(), group, fingerprint, null,
479                foldPayload(identifier, objectType, rules, quoted), null);
480    }
481
482    /**
483     * Case-insensitive collapse of an identifier payload.
484     *
485     * <p>Since U5 this is defined by Unicode simple case folding (CaseFolding.txt
486     * statuses C+S at pinned UCD {@value GspCaseData#UNICODE_VERSION}), applied
487     * code-point-wise — NO LONGER by {@link String#equalsIgnoreCase}'s per-{@code char}
488     * upper-then-lower fold. The deltas are deliberate corrections: supplementary-plane
489     * case pairs (Deseret 𐐀/𐐨) now collapse equal, and {@code İ}/{@code ı} no longer
490     * collapse to ASCII {@code i} (CASEFOLD_ALGORITHM_CHANGE in the U0 allowlist).
491     */
492    private static String caseCollapse(String s) {
493        return GspCaseData.simpleFoldCollapse(s);
494    }
495
496    // ===== Persistent canonical-identity API (plan §6, slice U3) =====
497
498    /** Lazily computed V1 policy ids per object type (profile is immutable, so
499     *  each id is a constant of this service instance). The holder itself is
500     *  created on the FIRST V1 API call: IdentifierService instances exist
501     *  per catalog object (e.g. TSQLTable), and eagerly allocating two maps
502     *  on every one of them would bill unused U3 functionality to the whole
503     *  catalog (Codex U3 round-1 finding 5). */
504    private static final class PolicyIdCachesV1 {
505        final java.util.concurrent.ConcurrentHashMap<ESQLDataObjectType, String> persistent =
506                new java.util.concurrent.ConcurrentHashMap<>();
507        final java.util.concurrent.ConcurrentHashMap<ESQLDataObjectType, String> approximate =
508                new java.util.concurrent.ConcurrentHashMap<>();
509    }
510
511    private volatile PolicyIdCachesV1 policyIdCachesV1;
512
513    private PolicyIdCachesV1 policyIdCachesV1() {
514        PolicyIdCachesV1 caches = policyIdCachesV1;
515        if (caches == null) {
516            synchronized (this) {
517                caches = policyIdCachesV1;
518                if (caches == null) {
519                    caches = new PolicyIdCachesV1();
520                    policyIdCachesV1 = caches;
521                }
522            }
523        }
524        return caches;
525    }
526
527    /**
528     * Mint the persistable canonical key of one identifier segment.
529     *
530     * <p><b>Contract (no carve-out).</b> For two
531     * {@link IdentifierInputForm#SQL_LEXICAL} inputs {@code a}, {@code b}
532     * under the same service and object type:
533     * {@code persistentKeyV1(type, a, SQL_LEXICAL).equals(persistentKeyV1(type, b, SQL_LEXICAL))}
534     * ⟺ {@link #areEqual areEqual(a, b, type)}.
535     * {@link IdentifierInputForm#CATALOG_STORED} inputs join the same
536     * equivalence domain: a stored name keys equal to the lexical spelling
537     * that produced it (Oracle stored {@code MixedCase} ≡ lexical
538     * {@code "MixedCase"}; stored {@code FOO} ≡ lexical {@code foo}; MySQL
539     * mode-1 stored {@code straße} ≡ bare lexical {@code straße}). In cells
540     * whose unquoted branch folds while the quoted branch compares
541     * insensitively without folding (MySQL table names, Presto family), the
542     * unquoted fold and the quoted collapse are DIFFERENT maps wherever full
543     * folding and simple folding disagree (final sigma: {@code ΟΣ} lowers to
544     * {@code ος} while the collapse maps {@code ς} to {@code σ}) — that
545     * asymmetry is {@link #areEqual}'s own U5-certified branch semantics, and
546     * stored names side with the fold-image (the form the catalog actually
547     * holds), not with the quoted collapse. Keys are stable across JVMs and
548     * JDK versions — folding is pinned ({@link GspCaseData}), never JVM case
549     * tables — and the {@link PersistentIdentifierKey#getPolicyId() policyId}
550     * rotates exactly when the resolved equivalence behavior changes.
551     *
552     * <p><b>Payload semantics.</b> SQL_LEXICAL: strict codec decode for
553     * recognized quoted spellings ({@link IdentifierCodec#decodeLexical} —
554     * malformed input throws {@link IdentifierCodec.MalformedIdentifierException};
555     * this API never guesses identity), then the quote-state branch's fold,
556     * then collapse when the branch compares INSENSITIVE without folding.
557     * CATALOG_STORED: no recognition, no decode. SENSITIVE stored-name
558     * domains keep the stored text; INSENSITIVE domains take the cell's fold
559     * when any branch folds (idempotent on real catalog data — stored names
560     * ARE fold-images) and the case-collapse only in never-folding cells.
561     * (Refines the plan's "INSENSITIVE cells collapse / SENSITIVE cells keep
562     * the stored text": in folding cells the collapse is the wrong
563     * canonicalizer for stored names — see persistentPayloadV1 — and for
564     * folding quoted branches — Snowflake
565     * {@code QUOTED_IDENTIFIERS_IGNORE_CASE} — the fold keeps cross-form
566     * equality intact.)
567     *
568     * <p><b>Rejections — before descriptor construction AND before any
569     * decode/payload work.</b> Cells with both branches resolving to
570     * COLLATION_BASED throw {@link UnsupportedOperationException} naming the
571     * collation (collator weights are not portably reproducible); mixed-kind
572     * custom profiles (one branch collation-based, one fold-based — the
573     * {@link #areEqual} mixed-payload-kind guard case) throw the dedicated
574     * subtype {@link MixedKindProfileException} — the biconditional's scope
575     * is fold/fold cells. {@link #approximateKeyV1} serves both.
576     *
577     * <p>Null {@code name} returns null. The empty string returns a defined
578     * key (empty payload, real policyId): degenerate but stable.
579     *
580     * @param objectType kind of database object the name refers to
581     * @param name single identifier segment
582     * @param form how the string reached the caller — lexical SQL text or a
583     *             catalog-stored name; the forms are not interchangeable
584     * @return the persistent key, or null for a null name
585     */
586    public PersistentIdentifierKey persistentKeyV1(ESQLDataObjectType objectType, String name,
587                                                   IdentifierInputForm form) {
588        java.util.Objects.requireNonNull(objectType, "objectType");
589        java.util.Objects.requireNonNull(form, "form");
590        if (name == null) {
591            return null;
592        }
593        IdentifierRules rules = profile.getRules(objectType);
594        rejectNonFoldCellV1(rules);
595        String payload = persistentPayloadV1(name, objectType, form, rules);
596        String policyId = policyIdCachesV1().persistent.computeIfAbsent(objectType,
597                t -> IdentifierPolicyV1.persistentPolicyId(profile, t,
598                        GspCaseData.UNICODE_VERSION, GspCaseData.ALGORITHM_REV));
599        return new PersistentIdentifierKey(policyId, profile.groupOf(objectType), payload);
600    }
601
602    /**
603     * Mint the best-effort key that serves EVERY rule cell, including the
604     * collation-based and mixed-kind cells {@link #persistentKeyV1} rejects:
605     * quote-strip (lenient — a malformed quoted spelling keeps its identity
606     * as written, mirroring {@link #areEqual}'s totality) plus pinned simple
607     * case-fold collapse. CATALOG_STORED input is collapsed as-is.
608     *
609     * <p>Claims NO {@code areEqual} biconditional — see
610     * {@link ApproximateIdentifierKey}. The distinct type and the distinct
611     * {@code ak1-} policy namespace guarantee these keys can never be stored
612     * or matched where exact keys are expected.
613     *
614     * @param objectType kind of database object the name refers to
615     * @param name single identifier segment
616     * @param form lexical SQL text or catalog-stored name
617     * @return the approximate key, or null for a null name
618     */
619    public ApproximateIdentifierKey approximateKeyV1(ESQLDataObjectType objectType, String name,
620                                                     IdentifierInputForm form) {
621        java.util.Objects.requireNonNull(objectType, "objectType");
622        java.util.Objects.requireNonNull(form, "form");
623        if (name == null) {
624            return null;
625        }
626        String text = name;
627        if (form == IdentifierInputForm.SQL_LEXICAL
628                && IdentifierCodec.isQuoted(profile, objectType, name)) {
629            text = removeQuotes(name, objectType);
630        }
631        String policyId = policyIdCachesV1().approximate.computeIfAbsent(objectType,
632                t -> IdentifierPolicyV1.approximatePolicyId(profile, t,
633                        GspCaseData.UNICODE_VERSION, GspCaseData.ALGORITHM_REV));
634        return new ApproximateIdentifierKey(policyId, profile.groupOf(objectType),
635                caseCollapse(text));
636    }
637
638    /**
639     * Human-readable rendering of the V1 policies for one object type — a
640     * diagnostic, explicitly NOT the identity ({@code policyId} is the hash
641     * of the binary descriptor), so this formatting can improve without
642     * rotating any stored key.
643     *
644     * @param objectType kind of database object
645     * @return one-line description of the persistent and approximate policies
646     */
647    public String describePolicyV1(ESQLDataObjectType objectType) {
648        java.util.Objects.requireNonNull(objectType, "objectType");
649        IdentifierRules rules = profile.getRules(objectType);
650        CaseCompare effU = effectiveCompare(rules, false);
651        CaseCompare effQ = effectiveCompare(rules, true);
652        StringBuilder sb = new StringBuilder(160);
653        sb.append("policyV1{vendor=").append(IdentifierPolicyV1.vendorWire(profile.getVendor()))
654                .append(", group=").append(IdentifierPolicyV1.groupWire(profile.groupOf(objectType)))
655                .append(", cell=").append(rules.unquotedFold).append('/').append(effU)
656                .append('|').append(rules.quotedFold).append('/').append(effQ)
657                .append(", codec=").append(IdentifierCodec.CODEC_REVISION);
658        if (IdentifierCodec.consultsAnsiQuotes(profile.getVendor())) {
659            sb.append(", ansiQuotes=").append(profile.getFlags().mysqlAnsiQuotes);
660        }
661        sb.append(", mapRev=").append(IdentifierPolicyV1.mapRevision(objectType));
662        boolean collation = effU == CaseCompare.COLLATION_BASED || effQ == CaseCompare.COLLATION_BASED;
663        if (collation) {
664            sb.append(", persistent=UNSUPPORTED(")
665                    .append(effU == effQ ? "collation " + profile.getFlags().defaultCollation
666                            : "mixed-kind cell")
667                    .append(')');
668        } else {
669            sb.append(", ucd=").append(IdentifierPolicyV1.cellUsesUcd(rules)
670                            ? GspCaseData.UNICODE_VERSION + "/alg" + GspCaseData.ALGORITHM_REV
671                            : "excluded")
672                    .append(", persistent=")
673                    .append(policyIdCachesV1().persistent.computeIfAbsent(objectType,
674                            t -> IdentifierPolicyV1.persistentPolicyId(profile, t,
675                                    GspCaseData.UNICODE_VERSION, GspCaseData.ALGORITHM_REV)));
676        }
677        sb.append(", approximate=")
678                .append(policyIdCachesV1().approximate.computeIfAbsent(objectType,
679                        t -> IdentifierPolicyV1.approximatePolicyId(profile, t,
680                                GspCaseData.UNICODE_VERSION, GspCaseData.ALGORITHM_REV)))
681                .append('}');
682        return sb.toString();
683    }
684
685    /**
686     * Typed rejection for mixed-kind custom profiles (one branch
687     * collation-based, one fold-based — the cell shape {@link #areEqual}'s
688     * mixed-payload-kind guard never key-matches). A distinct TYPE, not just
689     * a distinct message, so callers can tell this rejection apart from the
690     * all-collation one programmatically (Codex U3 round-1 finding 2). It
691     * still extends {@link UnsupportedOperationException} so a caller
692     * handling "persistent keys unavailable for this cell" generically
693     * catches both.
694     */
695    public static final class MixedKindProfileException extends UnsupportedOperationException {
696
697        private static final long serialVersionUID = 1L;
698
699        private final CaseCompare unquotedKind;
700        private final CaseCompare quotedKind;
701
702        MixedKindProfileException(CaseCompare unquotedKind, CaseCompare quotedKind) {
703            super("persistentKeyV1: mixed-kind custom profile (unquoted=" + unquotedKind
704                    + ", quoted=" + quotedKind + ") rejected before descriptor construction - "
705                    + "the biconditional's scope is fold/fold cells; use approximateKeyV1");
706            this.unquotedKind = unquotedKind;
707            this.quotedKind = quotedKind;
708        }
709
710        /** Effective compare kind of the unquoted branch. */
711        public CaseCompare getUnquotedKind() {
712            return unquotedKind;
713        }
714
715        /** Effective compare kind of the quoted branch. */
716        public CaseCompare getQuotedKind() {
717            return quotedKind;
718        }
719    }
720
721    /** Typed rejection of the cells outside the fold/fold biconditional scope
722     *  — BEFORE any descriptor or payload work (plan §6), including strict
723     *  decoding: a malformed spelling on a rejected cell gets the cell
724     *  rejection, never a decode error. */
725    private void rejectNonFoldCellV1(IdentifierRules rules) {
726        CaseCompare effU = effectiveCompare(rules, false);
727        CaseCompare effQ = effectiveCompare(rules, true);
728        boolean collationU = effU == CaseCompare.COLLATION_BASED;
729        boolean collationQ = effQ == CaseCompare.COLLATION_BASED;
730        if (collationU && collationQ) {
731            throw new UnsupportedOperationException(
732                    "persistentKeyV1: this cell compares via collation '"
733                            + profile.getFlags().defaultCollation
734                            + "' - collator weights are not portably reproducible; "
735                            + "use approximateKeyV1 (no areEqual biconditional)");
736        }
737        if (collationU != collationQ) {
738            throw new MixedKindProfileException(effU, effQ);
739        }
740    }
741
742    /** Canonical persistent payload — byte-identical with {@link #foldPayload}
743     *  for well-formed lexical input, so key equality IS {@link #areEqual}.
744     *
745     *  <p>CATALOG_STORED (Codex U3 round-1 finding 1): a stored name under an
746     *  INSENSITIVE cell whose unquoted branch FOLDS is the fold-image of the
747     *  spelling that created it, so its canonical payload is the (idempotent)
748     *  unquoted fold of the stored text — NOT the case-collapse. Collapsing
749     *  broke the primary catalog↔SQL join wherever the fold and the simple
750     *  C+S collapse disagree: MySQL mode-1 stored {@code ος} (created by bare
751     *  {@code ΟΣ}, contextual lower) collapsed to {@code οσ} and stopped
752     *  keying with bare {@code ΟΣ}'s payload {@code ος}. (ß is NOT such a
753     *  case — U+00DF carries only an F mapping in CaseFolding, so the
754     *  collapse preserves it like the lower fold does; Codex round 2.)
755     *  Collapse remains correct for INSENSITIVE cells that never fold (MySQL
756     *  columns, BigQuery columns): there collapse IS the cell's canonical
757     *  payload on every branch. */
758    private String persistentPayloadV1(String name, ESQLDataObjectType objectType,
759                                       IdentifierInputForm form, IdentifierRules rules) {
760        if (form == IdentifierInputForm.CATALOG_STORED) {
761            if (effectiveCompare(rules, true) != CaseCompare.INSENSITIVE) {
762                // SENSITIVE stored-name domain: the stored text IS the identity.
763                return name;
764            }
765            CaseFold storedFold = rules.unquotedFold != CaseFold.NONE
766                    ? rules.unquotedFold : rules.quotedFold;
767            if (storedFold != CaseFold.NONE) {
768                return applyFold(name, storedFold);
769            }
770            return caseCollapse(name);
771        }
772        boolean quoted = IdentifierCodec.isQuoted(profile, objectType, name);
773        // Strict: MalformedIdentifierException propagates (unlike removeQuotes,
774        // which keeps malformed spellings' as-written identity for the total
775        // areEqual/keyForMap facade).
776        String text = quoted ? IdentifierCodec.decodeLexical(profile, objectType, name) : name;
777        CaseFold fold = quoted ? rules.quotedFold : rules.unquotedFold;
778        String payload = applyFold(text, fold);
779        if (effectiveCompare(rules, quoted) == CaseCompare.INSENSITIVE && fold == CaseFold.NONE) {
780            payload = caseCollapse(payload);
781        }
782        return payload;
783    }
784
785    /** Test hook (anti-false-rotation, plan §2 U0.2): the persistent policy id
786     *  under a caller-chosen UCD version/algorithm revision. Same rejections
787     *  as {@link #persistentKeyV1}. */
788    String persistentPolicyIdV1(ESQLDataObjectType objectType, String ucdVersion,
789                                int ucdAlgorithmRev) {
790        rejectNonFoldCellV1(profile.getRules(objectType));
791        return IdentifierPolicyV1.persistentPolicyId(profile, objectType, ucdVersion,
792                ucdAlgorithmRev);
793    }
794
795    /** Test hook: approximate policy id under a caller-chosen UCD version. */
796    String approximatePolicyIdV1(ESQLDataObjectType objectType, String ucdVersion,
797                                 int ucdAlgorithmRev) {
798        return IdentifierPolicyV1.approximatePolicyId(profile, objectType, ucdVersion,
799                ucdAlgorithmRev);
800    }
801
802    // ===== 复合键构造(全限定名级,可选) =====
803
804    /**
805     * 判断是否可以使用复合键快速路径
806     *
807     * <p>条件(必须全部满足):
808     * <ol>
809     * <li>所有对象组都是 SENSITIVE
810     * <li>输入没有引号字符
811     * <li>没有 COLLATION_BASED 类型
812     * </ol>
813     *
814     * @param qualifiedName 完整限定名(如 "db.schema.table")
815     * @return true 如果可以使用复合键
816     */
817    public boolean canUseCompositeKey(String qualifiedName) {
818        // 条件 1: 检查是否包含引号字符
819        if (containsQuoteChar(qualifiedName)) {
820            return false;
821        }
822
823        // 条件 2: 检查所有对象组是否全部 SENSITIVE
824        for (IdentifierProfile.ObjectGroup group : IdentifierProfile.ObjectGroup.values()) {
825            IdentifierRules rules = profile.getRulesByGroup(group);
826            if (rules.unquotedCompare != CaseCompare.SENSITIVE) {
827                return false;
828            }
829        }
830
831        return true;
832    }
833
834    /**
835     * 构造复合键(使用长度前缀编码避免冲突)
836     *
837     * <p>格式: {@code len1#segment1|len2#segment2|len3#segment3|objectType}
838     *
839     * <p>例如: {@code "3#db1|6#schema|5#table|dotTable"}
840     *
841     * <p><strong>优势:</strong>避免分隔符冲突(标识符可能包含 '.' 或 '|')
842     *
843     * @param segments 标识符段列表
844     * @param objectType 对象类型
845     * @return 复合键
846     */
847    public String buildCompositeKey(List<String> segments, ESQLDataObjectType objectType) {
848        StringBuilder sb = new StringBuilder(segments.size() * 20);
849        for (int i = 0; i < segments.size(); i++) {
850            String seg = segments.get(i);
851            if (i > 0) {
852                sb.append('|');
853            }
854            sb.append(seg.length()).append('#').append(seg);
855        }
856        sb.append('|').append(objectType.name());
857        return sb.toString();
858    }
859
860    /**
861     * 构造复合键(从完整限定名)
862     *
863     * @param qualifiedName 完整限定名(如 "db.schema.table")
864     * @param objectType 对象类型
865     * @return 复合键
866     */
867    public String buildCompositeKey(String qualifiedName, ESQLDataObjectType objectType) {
868        List<String> segments = SQLUtil.parseNames(qualifiedName);
869        return buildCompositeKey(segments, objectType);
870    }
871
872    // ===== 完整限定名处理(多段处理) =====
873
874    /**
875     * 解析完整限定名为段列表
876     *
877     * <p>包装 {@link SQLUtil#parseNames(String, EDbVendor)} 以支持厂商特定解析:
878     * <ul>
879     * <li>MSSQL: 保留 ".." 以便后续展开
880     * <li>BigQuery: 反引号内的点号仍视为层级分隔
881     * <li>其它: 按 '.' 分段,处理引号包裹的段
882     * </ul>
883     *
884     * @param qualifiedName 完整限定名(如 "db.schema.table" 或 "db..table")
885     * @return 段列表
886     */
887    public List<String> parseQualifiedName(String qualifiedName) {
888        return SQLUtil.parseNames(qualifiedName, profile.getVendor());
889    }
890
891    /**
892     * 厂商级预处理(展开特殊语法)
893     *
894     * <p>处理厂商特定语法:
895     * <ul>
896     * <li>MSSQL/Azure SQL: 将 "db..table" 展开为 "db.&lt;global or dbo&gt;.table"
897     * </ul>
898     *
899     * @param segments 原始段列表
900     * @param vendor 数据库厂商
901     * @return 展开后的段列表
902     */
903    public List<String> expandVendorSpecific(List<String> segments, EDbVendor vendor) {
904        return expandVendorSpecific(segments, vendor, null);
905    }
906
907    /**
908     * 厂商级预处理(展开特殊语法)
909     *
910     * <p>处理厂商特定语法:
911     * <ul>
912     * <li>MSSQL/Azure SQL: 将 "db..table" 展开为 "db.&lt;defaultSchema or dbo&gt;.table"
913     * </ul>
914     *
915     * @param segments 原始段列表
916     * @param vendor 数据库厂商
917     * @param defaultSchema 默认 schema,如果为 null 则使用 "dbo"
918     * @return 展开后的段列表
919     */
920    public List<String> expandVendorSpecific(List<String> segments, EDbVendor vendor, String defaultSchema) {
921        if (vendor != EDbVendor.dbvmssql && vendor != EDbVendor.dbvazuresql) {
922            return segments;
923        }
924
925        // MSSQL: 展开 ".." 为 ".<defaultSchema or dbo>."
926        // Note: We no longer use ModelBindingManager.getGlobalSchema() here because that is
927        // designed for DataFlowAnalyzer context. Using it here causes test pollution when
928        // tests fail without cleaning up the ThreadLocal state.
929        String schemaToUse = (defaultSchema != null && !defaultSchema.isEmpty()) ? defaultSchema : "dbo";
930
931        List<String> expanded = new ArrayList<>();
932        for (int i = 0; i < segments.size(); i++) {
933            String seg = segments.get(i);
934            if (seg.isEmpty() && i > 0 && i < segments.size() - 1) {
935                // 这是 ".." 中的空段
936                expanded.add(schemaToUse);
937            } else {
938                expanded.add(seg);
939            }
940        }
941        return expanded;
942    }
943
944    /**
945     * 规范化单个段(去引号 + 大小写折叠)
946     *
947     * <p>与 {@link #normalize(String, ESQLDataObjectType)} 类似,但语义明确为"单段"处理。
948     *
949     * @param segment 单个段标识符
950     * @param objectType 对象类型
951     * @return 规范化后的段
952     */
953    public String normalizeSegment(String segment, ESQLDataObjectType objectType) {
954        return normalize(segment, objectType);
955    }
956
957    /**
958     * 规范化完整限定名(等价于 SQLUtil.getIdentifierNormalName)
959     *
960     * <p>处理流程:
961     * <ol>
962     * <li>解析为段列表:{@link #parseQualifiedName(String)}
963     * <li>展开厂商特定语法:{@link #expandVendorSpecific(List, EDbVendor)}
964     * <li>根据 supportCatalog/supportSchema 决定各段类型
965     * <li>逐段规范化:{@link #normalizeSegment(String, ESQLDataObjectType)}
966     * <li>重新拼接为 "catalog.schema.table.column" 格式
967     * </ol>
968     *
969     * @param qualifiedName 完整限定名
970     * @param objectType 最终对象类型(dotTable/dotColumn/dotSchema 等)
971     * @return 规范化后的完整限定名
972     */
973    public String normalizeQualifiedName(String qualifiedName, ESQLDataObjectType objectType) {
974        if (qualifiedName == null || qualifiedName.isEmpty()) {
975            return qualifiedName;
976        }
977
978        List<String> segments = expandVendorSpecific(parseQualifiedName(qualifiedName), profile.getVendor());
979        if (segments.isEmpty()) {
980            return qualifiedName;
981        }
982
983        boolean supportCatalog = TSQLEnv.supportCatalog(profile.getVendor());
984        boolean supportSchema = TSQLEnv.supportSchema(profile.getVendor());
985
986        StringBuilder builder = new StringBuilder();
987
988        // 根据 supportCatalog/supportSchema 与 objectType 决定各段类型
989        if (supportCatalog && supportSchema) {
990            if (objectType == ESQLDataObjectType.dotColumn) {
991                if (segments.size() > 4) {
992                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
993                            .append(".")
994                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotSchema))
995                            .append(".")
996                            .append(normalizeSegment(segments.get(2), ESQLDataObjectType.dotTable))
997                            .append(".")
998                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 3), ESQLDataObjectType.dotColumn));
999                } else if (segments.size() == 4) {
1000                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1001                            .append(".")
1002                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotSchema))
1003                            .append(".")
1004                            .append(normalizeSegment(segments.get(2), ESQLDataObjectType.dotTable))
1005                            .append(".")
1006                            .append(normalizeSegment(segments.get(3), ESQLDataObjectType.dotColumn));
1007                } else if (segments.size() == 3) {
1008                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1009                            .append(".")
1010                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotTable))
1011                            .append(".")
1012                            .append(normalizeSegment(segments.get(2), ESQLDataObjectType.dotColumn));
1013                } else if (segments.size() == 2) {
1014                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotTable))
1015                            .append(".")
1016                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotColumn));
1017                } else if (segments.size() == 1) {
1018                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotColumn));
1019                }
1020            } else if (objectType == ESQLDataObjectType.dotTable
1021                    || objectType == ESQLDataObjectType.dotOraclePackage
1022                    || objectType == ESQLDataObjectType.dotFunction
1023                    || objectType == ESQLDataObjectType.dotProcedure
1024                    || objectType == ESQLDataObjectType.dotTrigger) {
1025                if (segments.size() > 3) {
1026                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1027                            .append(".")
1028                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotSchema))
1029                            .append(".")
1030                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 2), objectType));
1031                } else if (segments.size() == 3) {
1032                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1033                            .append(".")
1034                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotSchema))
1035                            .append(".")
1036                            .append(normalizeSegment(segments.get(2), objectType));
1037                } else if (segments.size() == 2) {
1038                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1039                            .append(".")
1040                            .append(normalizeSegment(segments.get(1), objectType));
1041                } else if (segments.size() == 1) {
1042                    builder.append(normalizeSegment(segments.get(0), objectType));
1043                }
1044            } else if (objectType == ESQLDataObjectType.dotSchema) {
1045                if (segments.size() > 2) {
1046                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1047                            .append(".")
1048                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 1), objectType));
1049                } else if (segments.size() == 2) {
1050                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1051                            .append(".")
1052                            .append(normalizeSegment(segments.get(1), objectType));
1053                } else if (segments.size() == 1) {
1054                    builder.append(normalizeSegment(segments.get(0), objectType));
1055                }
1056            } else if (objectType == ESQLDataObjectType.dotCatalog) {
1057                if (segments.size() > 1) {
1058                    builder.append(normalizeSegment(SQLUtil.mergeSegments(segments, 0), objectType));
1059                } else if (segments.size() == 1) {
1060                    builder.append(normalizeSegment(segments.get(0), objectType));
1061                }
1062            }
1063        } else if (supportCatalog) {
1064            if (objectType == ESQLDataObjectType.dotColumn) {
1065                if (segments.size() > 3) {
1066                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1067                            .append(".")
1068                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotTable))
1069                            .append(".")
1070                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 2), ESQLDataObjectType.dotColumn));
1071                } else if (segments.size() == 3) {
1072                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1073                            .append(".")
1074                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotTable))
1075                            .append(".")
1076                            .append(normalizeSegment(segments.get(2), ESQLDataObjectType.dotColumn));
1077                } else if (segments.size() == 2) {
1078                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotTable))
1079                            .append(".")
1080                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotColumn));
1081                } else if (segments.size() == 1) {
1082                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotColumn));
1083                }
1084            } else if (objectType == ESQLDataObjectType.dotTable
1085                    || objectType == ESQLDataObjectType.dotOraclePackage
1086                    || objectType == ESQLDataObjectType.dotFunction
1087                    || objectType == ESQLDataObjectType.dotProcedure
1088                    || objectType == ESQLDataObjectType.dotTrigger) {
1089                if (segments.size() > 2) {
1090                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1091                            .append(".")
1092                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 1), objectType));
1093                } else if (segments.size() == 2) {
1094                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog))
1095                            .append(".")
1096                            .append(normalizeSegment(segments.get(1), objectType));
1097                } else if (segments.size() == 1) {
1098                    builder.append(normalizeSegment(segments.get(0), objectType));
1099                }
1100            } else if (objectType == ESQLDataObjectType.dotSchema || objectType == ESQLDataObjectType.dotCatalog) {
1101                if (segments.size() > 1) {
1102                    builder.append(normalizeSegment(SQLUtil.mergeSegments(segments, 0), ESQLDataObjectType.dotCatalog));
1103                } else if (segments.size() == 1) {
1104                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotCatalog));
1105                }
1106            }
1107        } else if (supportSchema) {
1108            if (objectType == ESQLDataObjectType.dotColumn) {
1109                if (segments.size() > 3) {
1110                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1111                            .append(".")
1112                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotTable))
1113                            .append(".")
1114                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 2), ESQLDataObjectType.dotColumn));
1115                } else if (segments.size() == 3) {
1116                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1117                            .append(".")
1118                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotTable))
1119                            .append(".")
1120                            .append(normalizeSegment(segments.get(2), ESQLDataObjectType.dotColumn));
1121                } else if (segments.size() == 2) {
1122                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotTable))
1123                            .append(".")
1124                            .append(normalizeSegment(segments.get(1), ESQLDataObjectType.dotColumn));
1125                } else if (segments.size() == 1) {
1126                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotColumn));
1127                }
1128            } else if (objectType == ESQLDataObjectType.dotTable
1129                    || objectType == ESQLDataObjectType.dotOraclePackage
1130                    || objectType == ESQLDataObjectType.dotFunction
1131                    || objectType == ESQLDataObjectType.dotProcedure
1132                    || objectType == ESQLDataObjectType.dotTrigger) {
1133                if (segments.size() > 2) {
1134                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1135                            .append(".")
1136                            .append(normalizeSegment(SQLUtil.mergeSegments(segments, 1), objectType));
1137                } else if (segments.size() == 2) {
1138                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema))
1139                            .append(".")
1140                            .append(normalizeSegment(segments.get(1), objectType));
1141                } else if (segments.size() == 1) {
1142                    builder.append(normalizeSegment(segments.get(0), objectType));
1143                }
1144            } else if (objectType == ESQLDataObjectType.dotSchema || objectType == ESQLDataObjectType.dotCatalog) {
1145                if (segments.size() > 1) {
1146                    builder.append(normalizeSegment(SQLUtil.mergeSegments(segments, 0), ESQLDataObjectType.dotSchema));
1147                } else if (segments.size() == 1) {
1148                    builder.append(normalizeSegment(segments.get(0), ESQLDataObjectType.dotSchema));
1149                }
1150            }
1151        }
1152
1153        return builder.toString();
1154    }
1155
1156    /**
1157     * 生成层级索引所需的段级键列表
1158     *
1159     * <p>用于分层索引(hierarchical index),确保各层键由 {@link #keyForMap} 统一生成。
1160     *
1161     * <p><strong>注意:</strong>调用前应先调用 {@link #parseQualifiedName(String)} 和
1162     * {@link #expandVendorSpecific(List, EDbVendor)} 完成解析与展开。
1163     *
1164     * @param qualifiedName 完整限定名
1165     * @param partTypes 各段对应的对象类型列表(长度应与段数一致)
1166     * @return 段级键列表
1167     * @throws IllegalArgumentException 如果段数与类型数不匹配
1168     */
1169    public List<String> keysForHierarchy(String qualifiedName, List<ESQLDataObjectType> partTypes) {
1170        List<String> segments = expandVendorSpecific(parseQualifiedName(qualifiedName), profile.getVendor());
1171        if (segments.size() != partTypes.size()) {
1172            throw new IllegalArgumentException("Segment count (" + segments.size()
1173                    + ") does not match type count (" + partTypes.size() + ")");
1174        }
1175
1176        List<String> keys = new ArrayList<>();
1177        for (int i = 0; i < segments.size(); i++) {
1178            keys.add(keyForMap(segments.get(i), partTypes.get(i)));
1179        }
1180        return keys;
1181    }
1182
1183    /**
1184     * 断言标识符为单段,否则抛出异常
1185     *
1186     * <p>用于在 {@link #keyForMap} 入口处确保输入为单段标识符(不包含 '.' 分隔符)。
1187     *
1188     * <p>如果 {@link TBaseType#ALLOW_MULTI_SEGMENT_IN_KEY} 为 true(兼容模式),
1189     * 则仅记录警告日志而不抛异常。
1190     *
1191     * @param identifier 待检查的标识符
1192     * @throws IllegalArgumentException 如果标识符包含多段且未启用兼容模式
1193     */
1194    public void assertSingleSegmentOrThrow(String identifier, ESQLDataObjectType objectType) {
1195        if (identifier == null || identifier.isEmpty()) {
1196            return;
1197        }
1198
1199        // Compat mode is a no-op (the warning below is disabled), but this method sits on
1200        // the per-comparison hot path since P0d — segmenting just to discard the result
1201        // was ~15% of dlineage wall on comparison-dense files. Strict mode is unchanged.
1202        if (TBaseType.ALLOW_MULTI_SEGMENT_IN_KEY) {
1203            return;
1204        }
1205
1206        List<String> segments = parseQualifiedName(identifier);
1207        if (segments.size() > 1) {
1208            String message = "keyForMap requires single segment, but got " + segments.size()
1209                    + " segments: " + identifier;
1210            if (TBaseType.ALLOW_MULTI_SEGMENT_IN_KEY) {
1211                // 兼容模式:仅记录警告
1212               // System.err.println("[WARN] " + message);
1213               // System.err.println("[WARN] return by SQLUtil.getIdentifierNormalName:" + SQLUtil.getIdentifierNormalName(this.getProfile().getVendor(), identifier, objectType));
1214            } else {
1215                // 严格模式:抛出异常
1216                throw new IllegalArgumentException(message);
1217            }
1218        }
1219    }
1220
1221    // ===== 辅助方法 =====
1222
1223    /**
1224     * 判断标识符是否被引号包围
1225     *
1226     * <p>Since U5, recognition is the codec's per-vendor/per-role delimiter table
1227     * ({@link IdentifierCodec#isQuoted}) instead of the legacy first-char switch
1228     * ({@code TSQLEnv.isDelimitedIdentifier}). The deltas are deliberate
1229     * corrections: vendor-documented forms the legacy switch missed are now
1230     * recognized (Spark/Databricks backticks, PostgreSQL {@code U&"…"},
1231     * Sybase/SQLite brackets, ClickHouse backticks, MDX brackets, Power Query
1232     * {@code #"…"}, DAX per-role bracket vs apostrophe, MySQL {@code ANSI_QUOTES}
1233     * double quotes). SQL Server keeps apostrophe recognition in the COLUMN role
1234     * ({@code AS 'REGKEY'} names a real column; dropping it lost lineage edges —
1235     * see {@code IdentifierCodec.RULES_MSSQL_COLUMN}).
1236     */
1237    private boolean isQuoted(String identifier, ESQLDataObjectType objectType) {
1238        if (objectType != null) {
1239            return IdentifierCodec.isQuoted(profile, objectType, identifier);
1240        }
1241        // "Any type" callers (CatalogStore.getByName(name, null)): no single codec
1242        // role can represent "any" — DAX quotes table names with apostrophes but
1243        // columns/measures with brackets — so probe the role rule-sets and accept
1244        // either (Codex U5 round-1 finding 2). For every other vendor the two
1245        // probes hit the same table and the second is never reached.
1246        return IdentifierCodec.isQuoted(profile, ESQLDataObjectType.dotTable, identifier)
1247                || IdentifierCodec.isQuoted(profile, ESQLDataObjectType.dotColumn, identifier);
1248    }
1249
1250    /** The codec role that recognizes this spelling for a null ("any type") caller. */
1251    private ESQLDataObjectType recognizingRole(String identifier, ESQLDataObjectType objectType) {
1252        if (objectType != null) {
1253            return objectType;
1254        }
1255        return IdentifierCodec.isQuoted(profile, ESQLDataObjectType.dotTable, identifier)
1256                ? ESQLDataObjectType.dotTable : ESQLDataObjectType.dotColumn;
1257    }
1258
1259    /**
1260     * 移除标识符的引号
1261     *
1262     * <p>Since U5, a codec-recognized quoted spelling is DECODED
1263     * ({@link IdentifierCodec#decodeLexical}: delimiters removed, escapes resolved —
1264     * {@code [a]]b]} → {@code a]b}, {@code U&"d\0061ta"} → {@code data}) instead of
1265     * having its first/last code unit stripped. When strict decoding REJECTS the
1266     * input ({@code [a.b[} unterminated, {@code "EMPLOYEES"@"dblink"} trailing
1267     * text), the ORIGINAL spelling is returned unchanged: the string is not a
1268     * well-formed single quoted identifier, and guessing a payload by stripping
1269     * the first/last code units fabricates identity ({@code "EMPLOYEES"@"LD.X"}
1270     * strips to the nonsense {@code EMPLOYEES"@"LD.X}, which pre-U5 survived only
1271     * because a multi-segment safety net in {@code SQLUtil.normalizeIdentifier}
1272     * happened to discard it). Totality holds either way —
1273     * {@link #areEqual}/{@link #keyForMap} accept arbitrary text and malformed
1274     * spellings simply keep their identity as written.
1275     */
1276    private String removeQuotes(String identifier, ESQLDataObjectType objectType) {
1277        try {
1278            return IdentifierCodec.decodeLexical(profile,
1279                    recognizingRole(identifier, objectType), identifier);
1280        } catch (IdentifierCodec.MalformedIdentifierException e) {
1281            return identifier;
1282        }
1283    }
1284
1285    /**
1286     * 检查字符串是否包含引号字符
1287     *
1288     * <p>检查常见引号字符:", ', `, [, ]
1289     */
1290    private boolean containsQuoteChar(String str) {
1291        for (int i = 0; i < str.length(); i++) {
1292            char c = str.charAt(i);
1293            if (c == '"' || c == '\'' || c == '`' || c == '[' || c == ']') {
1294                return true;
1295            }
1296        }
1297        return false;
1298    }
1299
1300    // ===== Getter 方法 =====
1301
1302    /**
1303     * 获取标识符配置档案
1304     *
1305     * @return 配置档案
1306     */
1307    public IdentifierProfile getProfile() {
1308        return profile;
1309    }
1310
1311    /**
1312     * 获取 Collator 提供者
1313     *
1314     * @return Collator 提供者(可能为 null)
1315     */
1316    public CollatorProvider getCollatorProvider() {
1317        return collatorProvider;
1318    }
1319}