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.<global or dbo>.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.<defaultSchema or dbo>.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}