001package gudusoft.gsqlparser.catalog.runtime; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.catalog.input.model.IdentifierConfig; 005import gudusoft.gsqlparser.sqlenv.CollatorProvider; 006import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 007import gudusoft.gsqlparser.sqlenv.IdentifierProfile; 008import gudusoft.gsqlparser.sqlenv.IdentifierService; 009 010import java.util.ArrayList; 011import java.util.BitSet; 012import java.util.List; 013import java.util.Objects; 014 015/** 016 * Thin wrapper over {@link IdentifierService} that keeps the {@code catalog/**} dependency 017 * direction one-way: the package depends on {@code IdentifierService} and 018 * {@link IdentifierConfig} but on no other {@code sqlenv} type. 019 * 020 * <p>Plan §9.2 / §9.5. Every case-folding, normalization, qualified-name comparison, and 021 * map key inside {@code catalog/**} routes through this class (or directly through 022 * {@code IdentifierService}); the forbidden-apis Maven plugin enforces this mechanically.</p> 023 * 024 * <p>{@link IdentifierConfig} fields that map to {@code IdentifierProfile.VendorFlags} 025 * (currently {@code mysqlLowerCaseTableNames} and {@code mssqlCollation}) are honored by 026 * constructing a per-call {@code IdentifierService} when they differ from defaults.</p> 027 * 028 * <p><strong>Round 1 scope cuts (tracked for P1B Round 2):</strong> 029 * <ul> 030 * <li>{@code foldUnquotedToUpper} / {@code foldUnquotedToLower} on 031 * {@link IdentifierConfig} are recorded but not yet projected onto 032 * {@code IdentifierProfile} — the per-call profile is still constructed via 033 * {@code IdentifierProfile.forVendor(vendor, flags)} which uses vendor-default fold 034 * rules. Callers who declare a non-default fold get vendor-default behavior.</li> 035 * <li>{@code tableCaseSensitive} / {@code columnCaseSensitive} (BigQuery split policy) 036 * are accepted but not honored: extending {@code IdentifierProfile} with per-object- 037 * group sensitivity flags is part of Round 2.</li> 038 * <li>{@link #areEqual} Tier-2 fallback for COLLATION_BASED dialects routes through 039 * {@link IdentifierService#areEqualStatic} (cached vendor defaults) and therefore 040 * does not honor a custom {@code mssqlCollation} or MySQL 041 * {@code lower_case_table_names=2}; Tier 1 (cfg-aware normalized comparison) 042 * handles the lctn=1 / lctn=2 case-insensitive cases since their normalize folds 043 * to a single canonical form.</li> 044 * <li>{@link #keyForMap} can produce different keys for two names that 045 * {@link #areEqual} considers equal in COLLATION_BASED dialects — same contract as 046 * {@code IdentifierService.canUseCompositeKey}. Snapshot lookups must fall back to 047 * {@code areEqual} on a key miss.</li> 048 * </ul> 049 * 050 * <p>The defaults-driven path (the primary use of this class in P1B/P1C) is fully covered 051 * by tests and is the one used by every Phase 1 reader and provider.</p> 052 */ 053public final class CatalogIdentifierPolicy { 054 055 private static final char KEY_SEPARATOR = '\u0001'; 056 057 private CatalogIdentifierPolicy() { 058 // Static utility — no instances. 059 } 060 061 /** 062 * Parse a possibly-quoted, possibly-multi-segment qualified name into a 063 * {@link CatalogQualifiedName} with both raw and normalized segments tracked. 064 * 065 * <p>Each segment is normalized using the {@link ESQLDataObjectType} appropriate for its 066 * position in the qualified name (catalog → schema → table → column), so vendor-specific 067 * dual policies like BigQuery's case-sensitive tables vs case-insensitive columns are 068 * applied per-segment rather than en bloc.</p> 069 */ 070 public static CatalogQualifiedName parse(String raw, CatalogObjectKind kind, 071 IdentifierConfig cfg, EDbVendor vendor) { 072 if (raw == null || raw.isEmpty()) { 073 throw new IllegalArgumentException("CatalogIdentifierPolicy.parse: raw must be non-empty"); 074 } 075 if (kind == null) { 076 throw new IllegalArgumentException("CatalogIdentifierPolicy.parse: kind is required"); 077 } 078 if (vendor == null) { 079 throw new IllegalArgumentException("CatalogIdentifierPolicy.parse: vendor is required"); 080 } 081 IdentifierService service = (cfg != null) ? serviceFor(cfg, vendor) : null; 082 SegmentSplit split = splitSegments(raw, vendor); 083 BitSet quoted = split.quoted; 084 List<String> rawSegments = split.segments; 085 List<String> normalized = new ArrayList<String>(rawSegments.size()); 086 for (int i = 0; i < rawSegments.size(); i++) { 087 String segment = rawSegments.get(i); 088 ESQLDataObjectType type = kindForSegment(kind, i, rawSegments.size()); 089 if (service != null) { 090 normalized.add(service.normalize(segment, type)); 091 } else { 092 normalized.add(IdentifierService.normalizeStatic(vendor, type, segment)); 093 } 094 } 095 return new CatalogQualifiedName(rawSegments, normalized, quoted, kind, vendor); 096 } 097 098 /** 099 * Build a {@link CatalogQualifiedName} from segments that have already been normalized 100 * by {@link IdentifierService}. Used by the lazy {@code TSQLEnv} bridge, where 101 * {@code TSQLEnv.doSearchSchemaObject} hands over already-normalized catalog/schema/ 102 * object segments and rejoining + re-parsing would mis-handle quoted-embedded-dot 103 * names like Oracle {@code "a.b"."c"}. 104 * 105 * <p>Each segment is taken verbatim — no further normalization is applied — and the 106 * resulting name carries identical raw and normalized segments. Quoted-flag bits are 107 * empty by default; callers that have lost quoting information at this layer 108 * (the legacy bridge path) accept the limitation that quoted-vs-unquoted distinction 109 * is no longer recoverable. Snapshot lookups on this name still match because the 110 * snapshot keys also derive from the same {@link IdentifierService} normalization.</p> 111 */ 112 public static CatalogQualifiedName fromAlreadyNormalizedSegments(List<String> segments, 113 CatalogObjectKind kind, 114 EDbVendor vendor) { 115 if (segments == null || segments.isEmpty()) { 116 throw new IllegalArgumentException( 117 "CatalogIdentifierPolicy.fromAlreadyNormalizedSegments: segments must be non-empty"); 118 } 119 if (kind == null) { 120 throw new IllegalArgumentException( 121 "CatalogIdentifierPolicy.fromAlreadyNormalizedSegments: kind is required"); 122 } 123 if (vendor == null) { 124 throw new IllegalArgumentException( 125 "CatalogIdentifierPolicy.fromAlreadyNormalizedSegments: vendor is required"); 126 } 127 // Defensive copy — don't trust the caller to keep their list stable. 128 List<String> normalized = new ArrayList<String>(segments); 129 for (int i = 0; i < normalized.size(); i++) { 130 String s = normalized.get(i); 131 if (s == null) { 132 throw new IllegalArgumentException( 133 "CatalogIdentifierPolicy.fromAlreadyNormalizedSegments: segment " + i + " is null"); 134 } 135 } 136 return new CatalogQualifiedName(normalized, normalized, new BitSet(), kind, vendor); 137 } 138 139 /** 140 * Map-friendly stable key for a {@link CatalogQualifiedName}. 141 * 142 * <p>Length-prefixes each normalized segment so quoted identifiers that contain dots — 143 * e.g., Oracle {@code "a.b"."c"} vs {@code "a"."b.c"} — produce distinct keys despite 144 * having the same flat join.</p> 145 * 146 * <p><strong>COLLATION_BASED limitation:</strong> for SQL Server / Azure SQL under a 147 * case-insensitive collation, the legacy {@code IdentifierService.normalize} preserves 148 * case (the legacy code-path uses Collator-bucketed lookup for those dialects). 149 * Consequently {@code keyForMap("dbo.Orders")} and {@code keyForMap("dbo.orders")} can 150 * differ even though {@link #areEqual} considers them equal. Snapshot/index lookups 151 * that key by {@code keyForMap} on those dialects must therefore fall back to 152 * {@link #areEqual} on a key miss; this is the same contract that 153 * {@code IdentifierService.canUseCompositeKey()} exposes for the legacy code-path. 154 * P1B/P1C may add a Collator-aware key bucket; tracked as a separate task.</p> 155 */ 156 public static String keyForMap(CatalogQualifiedName name) { 157 if (name == null) { 158 throw new IllegalArgumentException("CatalogIdentifierPolicy.keyForMap: name is required"); 159 } 160 StringBuilder sb = new StringBuilder(); 161 sb.append(name.kind().name()).append(KEY_SEPARATOR) 162 .append(name.vendor().name()).append(KEY_SEPARATOR); 163 List<String> segments = name.normalized(); 164 for (int i = 0; i < segments.size(); i++) { 165 String segment = segments.get(i); 166 sb.append(segment.length()).append(':').append(segment).append(KEY_SEPARATOR); 167 } 168 return sb.toString(); 169 } 170 171 /** 172 * Vendor-aware equality test for two qualified names. Per-segment comparisons use the 173 * {@link ESQLDataObjectType} that matches each segment's role (catalog/schema/table/ 174 * column) so dual-policy dialects compare correctly. 175 * 176 * <p>Comparison is two-tier: first the cfg-aware {@code normalized} segments are 177 * compared directly (which handles every dialect whose {@code normalize} captures 178 * equality, including non-default {@code IdentifierConfig}s like MySQL 179 * {@code lower_case_table_names=1/2}); on a mismatch we fall through to 180 * {@link IdentifierService#areEqualStatic} so Collator-based dialects (SQL Server / Azure 181 * SQL with case-insensitive collations) still report equality correctly.</p> 182 */ 183 public static boolean areEqual(CatalogQualifiedName a, CatalogQualifiedName b) { 184 if (a == null || b == null) { 185 return a == b; 186 } 187 if (a.kind() != b.kind() || a.vendor() != b.vendor() || a.size() != b.size()) { 188 return false; 189 } 190 // Tier 1: compare normalized forms (honors IdentifierConfig used at parse time). 191 if (a.normalized().equals(b.normalized())) { 192 return true; 193 } 194 // Tier 2: route through the legacy Collator-aware static path for COLLATION_BASED dialects. 195 for (int i = 0; i < a.size(); i++) { 196 ESQLDataObjectType type = kindForSegment(a.kind(), i, a.size()); 197 String aSeg = a.raw().get(i); 198 String bSeg = b.raw().get(i); 199 if (!IdentifierService.areEqualStatic(a.vendor(), type, aSeg, bSeg)) { 200 return false; 201 } 202 } 203 return true; 204 } 205 206 /** Normalize a single identifier segment per {@link IdentifierConfig}'s vendor. */ 207 public static String normalize(String raw, IdentifierConfig cfg) { 208 if (raw == null) return null; 209 if (cfg == null) { 210 throw new IllegalArgumentException("CatalogIdentifierPolicy.normalize: cfg is required"); 211 } 212 return serviceFor(cfg, cfg.vendor()).normalize(raw, ESQLDataObjectType.dotUnknown); 213 } 214 215 /** Normalize a segment with explicit quoted-ness (advisory). */ 216 public static String normalizeSegment(String raw, boolean wasQuoted, IdentifierConfig cfg) { 217 Objects.requireNonNull(cfg, "cfg"); 218 // The IdentifierService.normalize(...) method inspects raw for quote characters 219 // itself, so wasQuoted is treated as advisory metadata. Retained for symmetry with 220 // the plan §9.2 API and to surface intent at call-sites. 221 return serviceFor(cfg, cfg.vendor()).normalize(raw, ESQLDataObjectType.dotUnknown); 222 } 223 224 /** 225 * Public façade over {@link #serviceFor(IdentifierConfig, EDbVendor)} for callers 226 * outside this package (validator, readers, providers) that need an 227 * {@link IdentifierService} matching a {@link IdentifierConfig}. Vendor defaults 228 * to {@code cfg.vendor()} when {@code vendor} is {@code null}. 229 */ 230 public static IdentifierService identifierServiceFor(IdentifierConfig cfg, EDbVendor vendor) { 231 if (cfg == null) { 232 throw new IllegalArgumentException( 233 "CatalogIdentifierPolicy.identifierServiceFor: cfg is required"); 234 } 235 return serviceFor(cfg, vendor != null ? vendor : cfg.vendor()); 236 } 237 238 // ---------- internals ---------- 239 240 /** 241 * Build an {@link IdentifierService} that honors the supplied {@link IdentifierConfig}. 242 * Falls back to the cached default service when the config matches vendor defaults 243 * (avoids re-allocating on every parse for the common case). 244 */ 245 static IdentifierService serviceFor(IdentifierConfig cfg, EDbVendor vendorOverride) { 246 EDbVendor vendor = vendorOverride != null ? vendorOverride : cfg.vendor(); 247 IdentifierProfile.VendorFlags flags = vendorFlagsFrom(cfg); 248 return IdentifierService.forProfile(IdentifierProfile.forVendor(vendor, flags)); 249 } 250 251 static IdentifierProfile.VendorFlags vendorFlagsFrom(IdentifierConfig cfg) { 252 // Unconfigured values adopt the shared static defaults (P0d.2: MySQL mode 1) so 253 // this path cannot drift from VendorFlags.defaults(); explicit config still wins. 254 IdentifierProfile.VendorFlags d = IdentifierProfile.VendorFlags.defaults(); 255 int mysqlLctn = cfg.mysqlLowerCaseTableNames() != null 256 ? cfg.mysqlLowerCaseTableNames() : d.mysqlLowerCaseTableNames; 257 String collation = cfg.mssqlCollation() != null 258 ? cfg.mssqlCollation() : d.defaultCollation; 259 return new IdentifierProfile.VendorFlags(mysqlLctn, collation, 260 d.redshiftEnableCaseSensitive, d.snowflakeQuotedIdentifiersIgnoreCase); 261 } 262 263 /** 264 * For a qualified name with overall {@code kind}, return the {@link ESQLDataObjectType} 265 * appropriate for the segment at {@code segmentIndex} within a name of {@code total} 266 * segments. 267 * 268 * <p>Layouts assumed:</p> 269 * <ul> 270 * <li>4 segments → server.catalog.schema.object</li> 271 * <li>3 segments → catalog.schema.object</li> 272 * <li>2 segments → schema.object (or catalog.object on dialects without schemas)</li> 273 * <li>1 segment → object</li> 274 * </ul> 275 */ 276 static ESQLDataObjectType kindForSegment(CatalogObjectKind kind, int segmentIndex, int total) { 277 int distanceFromEnd = total - 1 - segmentIndex; 278 if (distanceFromEnd == 0) { 279 return mapKind(kind); 280 } 281 if (distanceFromEnd == 1) { 282 // Parent of the leaf: 283 // * for COLUMN / INDEX / CONSTRAINT, the parent is a TABLE 284 // * for TABLE / VIEW / MATERIALIZED_VIEW / ROUTINE / FUNCTION / PROCEDURE / 285 // PACKAGE / SYNONYM / SEQUENCE / TRIGGER / TYPE, the parent is a SCHEMA 286 switch (kind) { 287 case COLUMN: 288 case INDEX: 289 case CONSTRAINT: 290 return ESQLDataObjectType.dotTable; 291 default: 292 return ESQLDataObjectType.dotSchema; 293 } 294 } 295 if (distanceFromEnd == 2) { 296 // Grandparent: 297 // * for COLUMN, grandparent is SCHEMA 298 // * else CATALOG 299 switch (kind) { 300 case COLUMN: 301 case INDEX: 302 case CONSTRAINT: 303 return ESQLDataObjectType.dotSchema; 304 default: 305 return ESQLDataObjectType.dotCatalog; 306 } 307 } 308 // distanceFromEnd >= 3: server / catalog level 309 if (distanceFromEnd == 3 && (kind == CatalogObjectKind.COLUMN 310 || kind == CatalogObjectKind.INDEX || kind == CatalogObjectKind.CONSTRAINT)) { 311 return ESQLDataObjectType.dotCatalog; 312 } 313 return ESQLDataObjectType.dotUnknown; 314 } 315 316 /** 317 * Map a {@link CatalogObjectKind} to its leaf {@link ESQLDataObjectType}. Internal — 318 * use {@link #kindForSegment} when handling multi-segment names. 319 */ 320 /** 321 * The comparison/quoting {@link ESQLDataObjectType} for an object of this entity kind — the 322 * SINGLE owner of this mapping (stage S0R of 323 * docs/refactor/identifier_identity_display_separation_plan.md). 324 * 325 * <p>It lives here, not in {@code sqlenv}, because {@code CatalogObjectKind} is a catalog 326 * concept and {@code catalog.runtime} already depends on {@code sqlenv}. Putting the mapping 327 * in {@code sqlenv} would make the low-level identifier layer depend on the high-level catalog 328 * layer and close a package cycle. {@code sqlenv.IdentifierRolePolicy} therefore maps the 329 * ROLES it owns and refuses {@code OBJECT}, whose comparison type only a catalog caller knows. 330 * 331 * <p>Several kinds deliberately share a type: a view and a materialized view fold like a table 332 * because no dialect gives them separate case rules. Their IDENTITY stays separate because the 333 * key carries the kind itself. The kind discriminates the entity; the type decides how its 334 * spelling folds. 335 * 336 * @throws IllegalStateException for an unmapped kind. Falling through to {@code dotUnknown} is 337 * how a whole entity class acquires the wrong case semantics unnoticed, which is 338 * exactly what happened to SEQUENCE, INDEX and CONSTRAINT before the comparison types 339 * for them existed. 340 */ 341 public static ESQLDataObjectType comparisonTypeOfKind(CatalogObjectKind kind) { 342 if (kind == null) { 343 throw new IllegalArgumentException("catalog object kind is null"); 344 } 345 switch (kind) { 346 case CATALOG: return ESQLDataObjectType.dotCatalog; 347 case SCHEMA: return ESQLDataObjectType.dotSchema; 348 case TABLE: 349 case VIEW: 350 case MATERIALIZED_VIEW: return ESQLDataObjectType.dotTable; 351 case COLUMN: return ESQLDataObjectType.dotColumn; 352 case ROUTINE: return ESQLDataObjectType.dotRoutine; 353 case FUNCTION: return ESQLDataObjectType.dotFunction; 354 case PROCEDURE: return ESQLDataObjectType.dotProcedure; 355 case PACKAGE: return ESQLDataObjectType.dotOraclePackage; 356 case SYNONYM: return ESQLDataObjectType.dotSynonyms; 357 case TYPE: return ESQLDataObjectType.dotDataType; 358 case TRIGGER: return ESQLDataObjectType.dotTrigger; 359 case SEQUENCE: return ESQLDataObjectType.dotSequence; 360 case INDEX: return ESQLDataObjectType.dotIndex; 361 case CONSTRAINT: return ESQLDataObjectType.dotConstraint; 362 default: 363 throw new IllegalStateException("unmapped catalog object kind: " + kind 364 + "; add it to CatalogIdentifierPolicy.comparisonTypeOfKind rather than " 365 + "letting it fall through to dotUnknown"); 366 } 367 } 368 369 /** @deprecated use {@link #comparisonTypeOfKind}; kept so existing callers keep compiling. */ 370 @Deprecated 371 static ESQLDataObjectType mapKind(CatalogObjectKind kind) { 372 return comparisonTypeOfKind(kind); 373 } 374 375 /** Output of {@link #splitSegments}: parallel raw-segment list plus quoted-flag bits. */ 376 static final class SegmentSplit { 377 final List<String> segments; 378 final BitSet quoted; 379 380 SegmentSplit(List<String> segments, BitSet quoted) { 381 this.segments = segments; 382 this.quoted = quoted; 383 } 384 } 385 386 /** 387 * Split a qualified name into raw segments preserving any surrounding quote characters 388 * so {@link CatalogQualifiedName} can record per-segment quoted flags faithfully. 389 * 390 * <p>BigQuery special-case: a single backtick-delimited path that contains dots 391 * (e.g., {@code `project.dataset.table`}) is split internally, mirroring the 392 * convention also used by {@code SQLUtil.parseNames}.</p> 393 */ 394 static SegmentSplit splitSegments(String raw, EDbVendor vendor) { 395 // BigQuery wraps the whole path in backticks: split internally, mark unquoted. 396 if (vendor == EDbVendor.dbvbigquery 397 && raw.length() >= 2 398 && raw.charAt(0) == '`' 399 && raw.charAt(raw.length() - 1) == '`' 400 && raw.indexOf('.') > 0 401 && countUnescaped(raw, '`') == 2) { 402 String inner = raw.substring(1, raw.length() - 1); 403 return splitOnDot(inner, false); 404 } 405 List<String> out = new ArrayList<String>(); 406 BitSet quotedFlags = new BitSet(); 407 StringBuilder current = new StringBuilder(); 408 boolean currentQuoted = false; 409 char quoteChar = 0; 410 boolean inBracket = false; 411 for (int i = 0; i < raw.length(); i++) { 412 char c = raw.charAt(i); 413 if (quoteChar != 0) { 414 current.append(c); 415 if (c == quoteChar) { 416 if (i + 1 < raw.length() && raw.charAt(i + 1) == quoteChar) { 417 current.append(raw.charAt(i + 1)); 418 i++; 419 } else { 420 quoteChar = 0; 421 } 422 } 423 } else if (inBracket) { 424 current.append(c); 425 if (c == ']') { 426 inBracket = false; 427 } 428 } else if (c == '"' || c == '`') { 429 current.append(c); 430 quoteChar = c; 431 currentQuoted = true; 432 } else if (c == '[' && (vendor == EDbVendor.dbvmssql || vendor == EDbVendor.dbvazuresql)) { 433 current.append(c); 434 inBracket = true; 435 currentQuoted = true; 436 } else if (c == '.') { 437 if (currentQuoted) quotedFlags.set(out.size()); 438 out.add(current.toString()); 439 current.setLength(0); 440 currentQuoted = false; 441 } else { 442 current.append(c); 443 } 444 } 445 if (currentQuoted) quotedFlags.set(out.size()); 446 out.add(current.toString()); 447 return new SegmentSplit(out, quotedFlags); 448 } 449 450 private static SegmentSplit splitOnDot(String inner, boolean markQuoted) { 451 List<String> out = new ArrayList<String>(); 452 BitSet quoted = new BitSet(); 453 int start = 0; 454 for (int i = 0; i < inner.length(); i++) { 455 if (inner.charAt(i) == '.') { 456 String seg = inner.substring(start, i); 457 if (markQuoted) quoted.set(out.size()); 458 out.add(seg); 459 start = i + 1; 460 } 461 } 462 String last = inner.substring(start); 463 if (markQuoted) quoted.set(out.size()); 464 out.add(last); 465 return new SegmentSplit(out, quoted); 466 } 467 468 private static int countUnescaped(String s, char ch) { 469 int n = 0; 470 for (int i = 0; i < s.length(); i++) { 471 if (s.charAt(i) == ch) { 472 if (i + 1 < s.length() && s.charAt(i + 1) == ch) { 473 i++; 474 } else { 475 n++; 476 } 477 } 478 } 479 return n; 480 } 481}