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