001package gudusoft.gsqlparser.catalog.runtime; 002 003import gudusoft.gsqlparser.sqlenv.CanonKey; 004import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 005import gudusoft.gsqlparser.sqlenv.IdentifierComponentRole; 006import gudusoft.gsqlparser.sqlenv.IdentifierInputForm; 007import gudusoft.gsqlparser.sqlenv.IdentifierRolePolicy; 008import gudusoft.gsqlparser.sqlenv.IdentifierService; 009 010import java.util.ArrayList; 011import java.util.Collections; 012import java.util.List; 013 014/** 015 * Identity of one named catalog object, built from EXPLICIT semantic roles (stage S0R of 016 * docs/refactor/identifier_identity_display_separation_plan.md). 017 * 018 * <h3>What this replaces and why</h3> 019 * The deleted {@code QualifiedCanonKey} inferred each component's role from how many components the 020 * name had. That is a guess, and it is wrong in both directions: it read BigQuery 021 * {@code customer.name} as {@code table.column} when the repository's own {@code FieldPath} and 022 * {@code TestBigQueryNestedColumns} say it is base column plus STRUCT field — and BigQuery folds 023 * table names case-sensitively while folding column names case-insensitively, so the guess changed 024 * the ANSWER, not merely the label. It also had no server level at all, so SQL Server's 025 * {@code srv.db.dbo.t} had its server compared as a catalog. 026 * 027 * <p>Here nothing is inferred. Each component carries its role; each OBJECT component carries its 028 * entity kind; the sequence is validated by {@link CatalogNameGrammar} at construction; and the 029 * comparison rules for each component come from the one mapping that owns them 030 * ({@link IdentifierRolePolicy} for role-decided components, 031 * {@link CatalogIdentifierPolicy#comparisonTypeOfKind} for objects). 032 * 033 * <h3>Equality</h3> 034 * Two keys are equal iff they have the same terminal kind, the same number of components, and 035 * component-wise equal role, kind and {@link CanonKey} — plus an equal database link. So a table 036 * named {@code x} never equals a view named {@code x}, and {@code db.s.t} never equals a name whose 037 * first component is a server. 038 * 039 * <h3>In-process only</h3> 040 * It contains {@link CanonKey}s, whose collation payloads are stable only within one JVM and 041 * collator configuration. Do NOT stringify it to produce a {@code CatalogObjectId}: that type 042 * promises cross-snapshot stability, which this cannot give. Use {@code PersistentIdentifierKey} 043 * for anything persisted, and never {@code CanonKey.getCanonText()} or this type's 044 * {@code toString()}. 045 */ 046public final class CatalogObjectKey { 047 048 /** One component: its role, its entity kind when it is an object, and its canonical key. */ 049 public static final class Component { 050 private final IdentifierComponentRole role; 051 private final CatalogObjectKind kind; 052 private final CanonKey key; 053 054 Component(IdentifierComponentRole role, CatalogObjectKind kind, CanonKey key) { 055 this.role = role; 056 this.kind = kind; 057 this.key = key; 058 } 059 060 public IdentifierComponentRole getRole() { 061 return role; 062 } 063 064 /** The entity kind, non-null exactly when the role is {@link IdentifierComponentRole#OBJECT}. */ 065 public CatalogObjectKind getKind() { 066 return kind; 067 } 068 069 public CanonKey getKey() { 070 return key; 071 } 072 073 @Override 074 public boolean equals(Object o) { 075 if (this == o) { 076 return true; 077 } 078 if (o == null || getClass() != o.getClass()) { 079 return false; 080 } 081 Component that = (Component) o; 082 return role == that.role && kind == that.kind && key.equals(that.key); 083 } 084 085 @Override 086 public int hashCode() { 087 int h = role.hashCode(); 088 h = 31 * h + (kind == null ? 0 : kind.hashCode()); 089 return 31 * h + key.hashCode(); 090 } 091 092 @Override 093 public String toString() { 094 return role + (kind == null ? "" : "(" + kind + ")") + "=" + key; 095 } 096 } 097 098 private final List<Component> components; 099 private final CatalogObjectKind terminalKind; 100 private final CatalogHierarchy hierarchy; 101 private final CanonKey databaseLink; 102 private final int hash; 103 104 private CatalogObjectKey(List<Component> components, CatalogObjectKind terminalKind, 105 CatalogHierarchy hierarchy, CanonKey databaseLink) { 106 this.components = Collections.unmodifiableList(new ArrayList<Component>(components)); 107 this.terminalKind = terminalKind; 108 this.hierarchy = hierarchy; 109 this.databaseLink = databaseLink; 110 int h = this.components.hashCode(); 111 h = 31 * h + terminalKind.hashCode(); 112 h = 31 * h + hierarchy.hashCode(); 113 this.hash = 31 * h + (databaseLink == null ? 0 : databaseLink.hashCode()); 114 } 115 116 // ------------------------------------------------------------------ builder 117 118 /** 119 * Assembles a key one component at a time. <strong>Deliberately not public.</strong> It is the 120 * widest and least compiler-guided construction path, so while the grammar is still growing it 121 * would be the way an unexamined shape gets built. Callers use the named factories below, which 122 * state the topology and the parent relationship. If production later proves a general entry 123 * point necessary, it should take an explicit layout and run the full grammar. 124 */ 125 static Builder builder(IdentifierService service, CatalogHierarchy hierarchy) { 126 return new Builder(service, hierarchy); 127 } 128 129 /** @see #builder(IdentifierService, CatalogHierarchy) */ 130 static final class Builder { 131 private final IdentifierService service; 132 private final CatalogHierarchy hierarchy; 133 private final List<IdentifierComponentRole> roles = new ArrayList<IdentifierComponentRole>(); 134 private final List<CatalogObjectKind> kinds = new ArrayList<CatalogObjectKind>(); 135 private final List<CanonKey> keys = new ArrayList<CanonKey>(); 136 private CanonKey databaseLink; 137 private boolean databaseLinkInherited; 138 139 private Builder(IdentifierService service, CatalogHierarchy hierarchy) { 140 if (service == null) { 141 throw new IllegalArgumentException("an identifier service is required: it carries " 142 + "the vendor profile the component keys are computed under"); 143 } 144 if (hierarchy == null) { 145 throw new IllegalArgumentException("the namespace topology must be declared"); 146 } 147 this.service = service; 148 this.hierarchy = hierarchy; 149 } 150 151 /** A container component — server, catalog or schema. */ 152 public Builder container(IdentifierComponentRole role, String text, IdentifierInputForm form) { 153 if (role == IdentifierComponentRole.OBJECT) { 154 throw new IllegalArgumentException("use object(kind, text, form) for an object"); 155 } 156 ESQLDataObjectType type = IdentifierRolePolicy.comparisonTypeOf(role); 157 return add(role, null, requireKey(text, type, form, role.toString())); 158 } 159 160 /** A named object component, with the entity kind that decides how its name folds. */ 161 public Builder object(CatalogObjectKind kind, String text, IdentifierInputForm form) { 162 ESQLDataObjectType type = CatalogIdentifierPolicy.comparisonTypeOfKind(kind); 163 return add(IdentifierComponentRole.OBJECT, kind, 164 requireKey(text, type, form, "OBJECT(" + kind + ")")); 165 } 166 167 /** The base column of a relation. */ 168 public Builder baseColumn(String text, IdentifierInputForm form) { 169 ESQLDataObjectType type = 170 IdentifierRolePolicy.comparisonTypeOf(IdentifierComponentRole.BASE_COLUMN); 171 return add(IdentifierComponentRole.BASE_COLUMN, null, 172 requireKey(text, type, form, "BASE_COLUMN")); 173 } 174 175 /** 176 * An Oracle-style database link. Stored out of line because it qualifies the whole name 177 * rather than occupying a position in the hierarchy — but it does participate in equality, 178 * since {@code t@remote} and {@code t} are different objects. 179 */ 180 public Builder databaseLink(String text, IdentifierInputForm form) { 181 if (databaseLink != null) { 182 throw new IllegalArgumentException(databaseLinkInherited 183 ? "this identity already carries its parent's database link; a child of a " 184 + "remote object is part of the same remote identity and may not change it" 185 : "a name has one database link"); 186 } 187 ESQLDataObjectType type = 188 IdentifierRolePolicy.comparisonTypeOf(IdentifierComponentRole.DATABASE_LINK); 189 this.databaseLink = requireKey(text, type, form, "DATABASE_LINK"); 190 return this; 191 } 192 193 private Builder add(IdentifierComponentRole role, CatalogObjectKind kind, CanonKey key) { 194 roles.add(role); 195 kinds.add(kind); 196 keys.add(key); 197 return this; 198 } 199 200 private CanonKey requireKey(String text, ESQLDataObjectType type, IdentifierInputForm form, 201 String what) { 202 if (text == null || text.isEmpty()) { 203 throw new IllegalArgumentException(what + " component has no text; an absent level " 204 + "is not an empty name — build a CatalogLookupName instead"); 205 } 206 CanonKey key = service.canonKeyOf(text, type, form); 207 if (key == null) { 208 throw new IllegalStateException("no canonical key for " + what + " text " + text); 209 } 210 return key; 211 } 212 213 /** 214 * Validates the whole sequence and builds the key. 215 * 216 * @param terminalKind what the name as a whole denotes; must agree with the last component 217 */ 218 CatalogObjectKey build(CatalogObjectKind terminalKind) { 219 CatalogNameGrammar.validateResolvedIdentity(roles, kinds, terminalKind, hierarchy); 220 if (databaseLink != null && !databaseLinkInherited) { 221 // Only a DIRECTLY declared link is checked against the shapes a remote reference is 222 // known to take. An inherited one needs no re-check and must not be re-judged: a 223 // column of a remote table is perfectly reachable as `t@remote.c`, and rejecting it 224 // would mean a remote relation could have no remote columns. 225 CatalogNameGrammar.requireLinkableTerminal(terminalKind); 226 } 227 List<Component> components = new ArrayList<Component>(roles.size()); 228 for (int i = 0; i < roles.size(); i++) { 229 components.add(new Component(roles.get(i), kinds.get(i), keys.get(i))); 230 } 231 requireCompatibleRuleDomains(keys, databaseLink); 232 return new CatalogObjectKey(components, terminalKind, hierarchy, databaseLink); 233 } 234 } 235 236 /** 237 * Every component of one identity must be composable with the others. 238 * 239 * <p>This is what stops an Oracle parent from being composed with a MySQL-canonicalized column: 240 * such a key can never equal the correctly built one, and the lookup misses silently. Getting 241 * the strictness right took two corrections, both of which were false NEGATIVES of their own: 242 * 243 * <ul> 244 * <li>comparing the producing SERVICES split two genuinely equal identities apart, because two 245 * services for one profile that differ only in carrying a {@code CollatorProvider} produce 246 * identical fold-based keys;</li> 247 * <li>requiring every component to use the same fold-versus-collation MECHANISM split a valid 248 * identity apart at its column, because one profile may fold its name group and collate 249 * its column group.</li> 250 * </ul> 251 * 252 * So the rule is: one rule policy everywhere, and one collation domain among the components that 253 * collate at all. 254 */ 255 private static void requireCompatibleRuleDomains(List<CanonKey> keys, CanonKey databaseLink) { 256 List<CanonKey> all = new ArrayList<CanonKey>(keys); 257 if (databaseLink != null) { 258 all.add(databaseLink); 259 } 260 CanonKey first = all.get(0); 261 String collatorDomain = first.getCollatorDomainId(); 262 for (int i = 1; i < all.size(); i++) { 263 CanonKey key = all.get(i); 264 // The POLICY must match everywhere, and policy equality is transitive, so comparing 265 // against the first component is sufficient for this half. 266 if (!first.sameRulePolicyAs(key)) { 267 throw new IllegalArgumentException("component " + i + " was canonicalized under " 268 + key.describeRuleDomain() + " but component 0 under " 269 + first.describeRuleDomain() 270 + ": a key mixing rule policies could never equal the correctly built one, " 271 + "and the lookup would miss silently"); 272 } 273 // The COLLATOR half is not transitive: a fold-based key is compatible with collator A 274 // and with collator B while A and B are incompatible, so a loop against component 0 275 // would accept [fold, A, B]. Accumulate the first collation domain seen and require 276 // every other collating component to agree with it. 277 String keyDomain = key.getCollatorDomainId(); 278 if (keyDomain == null) { 279 continue; 280 } 281 if (collatorDomain == null) { 282 collatorDomain = keyDomain; 283 } else if (!collatorDomain.equals(keyDomain)) { 284 throw new IllegalArgumentException("component " + i + " collates under " + keyDomain 285 + " but another component collates under " + collatorDomain 286 + ": two collators produce incomparable payloads"); 287 } 288 } 289 } 290 291 // ------------------------------------------------- parent-plus-local factories 292 293 /** 294 * A catalog, optionally under a server. Parts are passed SEPARATELY — a caller holding them 295 * apart must not join them, because joining and re-splitting is how boundary information is 296 * lost. 297 * 298 * <p>A catalog needs nothing above it: a name without a server means the local server, which is 299 * an absence rather than a missing level. 300 */ 301 public static CatalogObjectKey catalog(IdentifierService service, CatalogHierarchy hierarchy, 302 String server, String catalog, IdentifierInputForm form) { 303 Builder builder = builder(service, hierarchy); 304 if (server != null && !server.isEmpty()) { 305 builder.container(IdentifierComponentRole.SERVER, server, form); 306 } 307 return builder.container(IdentifierComponentRole.CATALOG, catalog, form) 308 .build(CatalogObjectKind.CATALOG); 309 } 310 311 /** 312 * A schema inside an already-identified catalog. The parent is REQUIRED on a topology that has a 313 * catalog level: a schema identity that does not name its catalog is not resolved, and keying it 314 * would collide with the same schema name in another catalog. Use 315 * {@link #rootSchema} for a topology that genuinely has no catalog. 316 */ 317 public static CatalogObjectKey schemaIn(IdentifierService service, CatalogObjectKey parentCatalog, 318 String schema, IdentifierInputForm form) { 319 requireNonNullParent(parentCatalog, "schema"); 320 requireKindOf(parentCatalog, CatalogObjectKind.CATALOG, "schema"); 321 Builder builder = builder(service, parentCatalog.hierarchy); 322 replay(builder, parentCatalog); 323 return builder.container(IdentifierComponentRole.SCHEMA, schema, form) 324 .build(CatalogObjectKind.SCHEMA); 325 } 326 327 /** 328 * A schema under a topology that has no catalog level. The topology must say so — that is what 329 * proves the missing catalog is structural rather than unresolved. 330 */ 331 public static CatalogObjectKey rootSchema(IdentifierService service, CatalogHierarchy hierarchy, 332 String schema, IdentifierInputForm form) { 333 requireNoCatalog(hierarchy, "schema"); 334 return builder(service, hierarchy) 335 .container(IdentifierComponentRole.SCHEMA, schema, form) 336 .build(CatalogObjectKind.SCHEMA); 337 } 338 339 /** An object inside an already-identified schema. */ 340 public static CatalogObjectKey objectInSchema(IdentifierService service, CatalogObjectKey parentSchema, 341 CatalogObjectKind kind, String localName, 342 IdentifierInputForm form) { 343 requireNonNullParent(parentSchema, "object"); 344 requireKindOf(parentSchema, CatalogObjectKind.SCHEMA, "object"); 345 Builder builder = builder(service, parentSchema.hierarchy); 346 replay(builder, parentSchema); 347 return builder.object(kind, localName, form).build(kind); 348 } 349 350 /** 351 * An object directly inside a catalog, for a schemaless topology such as MySQL's. The topology 352 * must declare that it has no schema level; otherwise the missing schema is an omission and this 353 * is a lookup, not an identity. 354 */ 355 public static CatalogObjectKey objectInCatalogOnly(IdentifierService service, 356 CatalogObjectKey parentCatalog, 357 CatalogObjectKind kind, String localName, 358 IdentifierInputForm form) { 359 requireNonNullParent(parentCatalog, "object"); 360 requireKindOf(parentCatalog, CatalogObjectKind.CATALOG, "object"); 361 requireNoSchema(parentCatalog.hierarchy, "object"); 362 Builder builder = builder(service, parentCatalog.hierarchy); 363 replay(builder, parentCatalog); 364 return builder.object(kind, localName, form).build(kind); 365 } 366 367 /** 368 * An object at the root of a topology with no container levels at all. Rare and deliberately 369 * awkward to reach: on any dialect that HAS containers, a bare object name is a lookup. 370 */ 371 public static CatalogObjectKey rootObject(IdentifierService service, CatalogHierarchy hierarchy, 372 CatalogObjectKind kind, String localName, 373 IdentifierInputForm form) { 374 if (hierarchy != CatalogHierarchy.ROOT_ONLY) { 375 throw new IllegalArgumentException("topology " + hierarchy + " has container levels, so " 376 + "a bare object name is not a resolved identity — build a CatalogLookupName"); 377 } 378 return builder(service, hierarchy).object(kind, localName, form).build(kind); 379 } 380 381 /** A column of an already-identified relation. */ 382 public static CatalogObjectKey column(IdentifierService service, CatalogObjectKey parentRelation, 383 String localName, IdentifierInputForm form) { 384 requireNonNullParent(parentRelation, "column"); 385 if (!CatalogNameGrammar.isRelation(parentRelation.terminalKind)) { 386 throw new IllegalArgumentException("a " + parentRelation.terminalKind 387 + " has no columns; only a table, view or materialized view does"); 388 } 389 Builder builder = builder(service, parentRelation.hierarchy); 390 replay(builder, parentRelation); 391 return builder.baseColumn(localName, form).build(CatalogObjectKind.COLUMN); 392 } 393 394 /** An index, constraint or package member of an already-identified parent object. */ 395 public static CatalogObjectKey childObject(IdentifierService service, CatalogObjectKey parent, 396 CatalogObjectKind childKind, String localName, 397 IdentifierInputForm form) { 398 requireNonNullParent(parent, "child object"); 399 Builder builder = builder(service, parent.hierarchy); 400 replay(builder, parent); 401 return builder.object(childKind, localName, form).build(childKind); 402 } 403 404 /** 405 * The same identity, reached through a database link. This is the only PUBLIC way to attach one, 406 * because the general builder is package-private — without it a caller could inherit a link but 407 * never declare one. 408 * 409 * <p>The local key must already be complete, and its terminal must be a shape a remote reference 410 * is known to take. Descendants composed onto the result inherit the link, which is correct: a 411 * column of a remote table is reachable as {@code t@remote.c}. 412 */ 413 public static CatalogObjectKey atDatabaseLink(IdentifierService service, 414 CatalogObjectKey completeLocalKey, 415 String link, IdentifierInputForm form) { 416 requireNonNullParent(completeLocalKey, "remote reference"); 417 if (completeLocalKey.databaseLink != null) { 418 throw new IllegalArgumentException("this identity already carries a database link"); 419 } 420 CatalogNameGrammar.requireLinkableTerminal(completeLocalKey.terminalKind); 421 Builder builder = builder(service, completeLocalKey.hierarchy); 422 replay(builder, completeLocalKey); 423 builder.databaseLinkInherited = false; 424 return builder.databaseLink(link, form).build(completeLocalKey.terminalKind); 425 } 426 427 private static void requireNonNullParent(CatalogObjectKey parent, String what) { 428 if (parent == null) { 429 throw new IllegalArgumentException("a " + what + " needs its parent's identity: it is " 430 + "scoped by the parent and has no meaning without it"); 431 } 432 } 433 434 private static void requireKindOf(CatalogObjectKey parent, CatalogObjectKind expected, 435 String what) { 436 if (parent.terminalKind != expected) { 437 throw new IllegalArgumentException("a " + what + "'s parent is a " + expected 438 + ", not a " + parent.terminalKind); 439 } 440 } 441 442 private static void requireNoCatalog(CatalogHierarchy hierarchy, String what) { 443 if (hierarchy == null || hierarchy.hasCatalog()) { 444 throw new IllegalArgumentException("topology " + hierarchy + " has a catalog level, so a " 445 + what + " that does not name it is not resolved"); 446 } 447 } 448 449 private static void requireNoSchema(CatalogHierarchy hierarchy, String what) { 450 if (hierarchy.hasSchema()) { 451 throw new IllegalArgumentException("topology " + hierarchy + " has a schema level, so an " 452 + what + " directly in a catalog is not resolved — name the schema, or declare a " 453 + "schemaless topology"); 454 } 455 } 456 457 /** 458 * Re-seeds a builder with an existing key's components. The components are already canonical, so 459 * nothing is re-parsed or re-folded — which is the point of composing from a parent identity 460 * rather than re-deriving one from a joined string. An inherited database link is carried along 461 * and may not then be changed: a child of a remote object is part of the same remote identity. 462 */ 463 private static void replay(Builder builder, CatalogObjectKey parent) { 464 if (parent.databaseLink != null) { 465 builder.databaseLink = parent.databaseLink; 466 builder.databaseLinkInherited = true; 467 } 468 for (Component component : parent.components) { 469 builder.add(component.role, component.kind, component.key); 470 } 471 } 472 473 // ------------------------------------------------------------------ accessors 474 475 /** The components, outermost first. Never empty. */ 476 public List<Component> getComponents() { 477 return components; 478 } 479 480 /** What the name as a whole denotes. */ 481 public CatalogObjectKind getTerminalKind() { 482 return terminalKind; 483 } 484 485 /** The namespace topology this identity was declared complete against. */ 486 public CatalogHierarchy getHierarchy() { 487 return hierarchy; 488 } 489 490 /** 491 * A description of the rule domain this identity's components were canonicalized under, for 492 * diagnostics. It is NOT part of equality: equality is component-wise {@link CanonKey} equality, 493 * and two keys whose components are equal are the same identity regardless of which service 494 * object produced them. 495 */ 496 public String describeRuleDomain() { 497 return components.get(0).getKey().describeRuleDomain(); 498 } 499 500 /** The database link qualifying this name, or null. */ 501 public CanonKey getDatabaseLink() { 502 return databaseLink; 503 } 504 505 public int size() { 506 return components.size(); 507 } 508 509 /** The innermost component — the object or column this key names. */ 510 public Component getLeaf() { 511 return components.get(components.size() - 1); 512 } 513 514 @Override 515 public boolean equals(Object o) { 516 if (this == o) { 517 return true; 518 } 519 if (o == null || getClass() != o.getClass()) { 520 return false; 521 } 522 CatalogObjectKey that = (CatalogObjectKey) o; 523 if (terminalKind != that.terminalKind || hierarchy != that.hierarchy 524 || !components.equals(that.components)) { 525 return false; 526 } 527 return databaseLink == null ? that.databaseLink == null 528 : databaseLink.equals(that.databaseLink); 529 } 530 531 @Override 532 public int hashCode() { 533 return hash; 534 } 535 536 @Override 537 public String toString() { 538 StringBuilder sb = new StringBuilder("CatalogObjectKey(").append(terminalKind).append(") "); 539 for (int i = 0; i < components.size(); i++) { 540 if (i > 0) { 541 sb.append(" / "); 542 } 543 sb.append(components.get(i)); 544 } 545 if (databaseLink != null) { 546 sb.append(" @").append(databaseLink); 547 } 548 return sb.toString(); 549 } 550}