001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to you under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package gudusoft.gsqlparser.sqlenv; 018 019import gudusoft.gsqlparser.EDbVendor; 020 021import java.util.*; 022 023/** 024 * Unified storage abstraction for catalog objects using canonical keys. 025 * 026 * <p>This class replaces GSP's legacy triple-index architecture 027 * (schemaObjectList + objectIndex + tablesByName) with a clean, type-aware 028 * storage system backed by LinkedHashMaps for O(1) lookups.</p> 029 * 030 * <p>Key improvements over legacy storage:</p> 031 * <ul> 032 * <li>Canonical keys eliminate string suffix hacks ($function, $procedure, etc.)</li> 033 * <li>Per-type indexes for efficient type-specific lookups</li> 034 * <li>Vendor-aware case sensitivity via {@link NameService}</li> 035 * <li>Insertion order preservation for compatibility</li> 036 * <li>Full and partial name resolution support</li> 037 * </ul> 038 * 039 * <p>Thread safety: This class is thread-safe. All public methods are synchronized 040 * to support concurrent access from multiple threads without external synchronization.</p> 041 * 042 * @since 3.2.0.3 (Phase 1) 043 */ 044public class CatalogStore { 045 046 private final EDbVendor vendor; 047 private final NameService nameService; 048 private final IdentifierService identifierService; 049 050 // Primary storage: canonical key -> object 051 private final Map<Key, TSQLSchemaObject> objectIndex = new LinkedHashMap<>(); 052 053 // Per-type indexes for efficient type-specific lookups 054 private final Map<Key, TSQLSchemaObject> tableIndex = new LinkedHashMap<>(); 055 private final Map<Key, TSQLSchemaObject> functionIndex = new LinkedHashMap<>(); 056 private final Map<Key, TSQLSchemaObject> procedureIndex = new LinkedHashMap<>(); 057 private final Map<Key, TSQLSchemaObject> packageIndex = new LinkedHashMap<>(); 058 private final Map<Key, TSQLSchemaObject> triggerIndex = new LinkedHashMap<>(); 059 private final Map<Key, TSQLSchemaObject> columnIndex = new LinkedHashMap<>(); 060 061 /** 062 * 名称级(不含 catalog/schema)二级索引,用于“未给出完整路径”的部分查找。 063 * 064 * <p><b>为什么 value 是 {@code List<TSQLSchemaObject>}:</b></p> 065 * <ul> 066 * <li>同名对象可存在于不同 catalog/schema 下:如 {@code db1.dbo.orders} 067 * 与 {@code db1.sales.orders}。</li> 068 * <li>不同类型也可能同名(表/视图/函数等);可在 069 * {@link #getByName(String, gudusoft.gsqlparser.sqlenv.ESQLDataObjectType)} 070 * 中按类型再筛。</li> 071 * <li>需要先收集全部候选,再依据默认 schema/search_path/上下文判定或报歧义。</li> 072 * </ul> 073 * 074 * <p><b>典型场景:</b></p> 075 * <pre> 076 * -- 1) MSSQL:默认 schema 解析 077 * USE db1; 078 * SELECT * FROM orders; 079 * -- 候选:db1.dbo.orders、db1.sales.orders 080 * -- 解析器按默认 schema(如 dbo)或搜索路径挑选命中 081 * 082 * -- 2) BigQuery:默认 dataset 083 * -- 当前上下文: project.dataset = p.ds 084 * SELECT * FROM `users`; 085 * -- 候选:p.ds.users、p.other_ds.users(若可见) 086 * 087 * -- 3) 歧义诊断 088 * SELECT * FROM orders; 089 * -- 多个候选且规则不能唯一确定时,抛出歧义错误或给出建议 090 * </pre> 091 * 092 * <p><b>备注:</b></p> 093 * <ul> 094 * <li>键为 {@link IdentifierService#keyForMap(String, gudusoft.gsqlparser.sqlenv.ESQLDataObjectType)} 095 * (比较键,相等当且仅当 {@code areEqual}),不是规范化后的存储名。</li> 096 * <li>不用于“按 catalog/schema 分层列举”,此类需求请使用类型索引(如 {@code tableIndex})并基于 097 * {@link QualifiedName#getCatalog()} / {@link QualifiedName#getSchema()} 过滤。</li> 098 * </ul> 099 */ 100 private final Map<String, List<TSQLSchemaObject>> objectsByName = new LinkedHashMap<>(); 101 102 /** 103 * Creates a new CatalogStore for the specified database vendor. 104 * 105 * @param vendor the database vendor 106 * @deprecated Use {@link #CatalogStore(EDbVendor, IdentifierService)} instead 107 */ 108 @Deprecated 109 public CatalogStore(EDbVendor vendor) { 110 if (vendor == null) { 111 throw new IllegalArgumentException("vendor cannot be null"); 112 } 113 this.vendor = vendor; 114 this.nameService = new NameService(vendor); 115 this.identifierService = null; // Will fall back to TSQLEnv.normalizeIdentifier 116 } 117 118 /** 119 * Creates a new CatalogStore for the specified database vendor with IdentifierService. 120 * 121 * <p>This is the preferred constructor that uses IdentifierService for proper 122 * per-object-type identifier normalization.</p> 123 * 124 * @param vendor the database vendor 125 * @param identifierService the identifier service for normalization 126 * @since 3.2.0.3 (Phase 3.5) 127 */ 128 public CatalogStore(EDbVendor vendor, IdentifierService identifierService) { 129 if (vendor == null) { 130 throw new IllegalArgumentException("vendor cannot be null"); 131 } 132 if (identifierService == null) { 133 throw new IllegalArgumentException("identifierService cannot be null"); 134 } 135 this.vendor = vendor; 136 // The store's name service must observe the SAME configuration as the store's keys. 137 // Building it from the bare vendor left every NameService comparison on the vendor 138 // default while the indexes keyed under the analysis profile — the two halves of one 139 // lookup disagreeing is exactly how a reference reports NOT_FOUND against an object 140 // the index does hold. 141 this.nameService = NameService.forIdentifierService(identifierService); 142 this.identifierService = identifierService; 143 } 144 145 /** 146 * Returns the database vendor associated with this store. 147 * 148 * @return the database vendor 149 */ 150 public EDbVendor getVendor() { 151 return vendor; 152 } 153 154 /** 155 * Returns the name service used by this store. 156 * 157 * @return the name service 158 */ 159 public NameService getNameService() { 160 return nameService; 161 } 162 163 /** 164 * Adds or updates an object in the store. 165 * 166 * <p>The object is indexed by its fully-qualified canonical name and also 167 * added to type-specific and name-only indexes for efficient lookups.</p> 168 * 169 * @param object the schema object to add 170 * @return the previous object with the same key, or null if none existed 171 */ 172 public synchronized TSQLSchemaObject put(TSQLSchemaObject object) { 173 if (object == null) { 174 throw new IllegalArgumentException("object cannot be null"); 175 } 176 177 // Create canonical key 178 Key key = createKey(object); 179 180 // Add to primary index 181 TSQLSchemaObject previous = objectIndex.put(key, object); 182 183 // Add to type-specific index 184 Map<Key, TSQLSchemaObject> typeIndex = getTypeIndex(object.getDataObjectType()); 185 if (typeIndex != null) { 186 typeIndex.put(key, object); 187 } 188 189 // Add to name-only index 190 String normalizedName = normalizeObjectName(object); 191 objectsByName.computeIfAbsent(normalizedName, k -> new ArrayList<>()).add(object); 192 193 return previous; 194 } 195 196 /** 197 * Finds an object by its fully-qualified name and type. 198 * 199 * <p>This is an O(1) lookup using the canonical key index.</p> 200 * 201 * @param catalog the catalog name (may be null) 202 * @param schema the schema name (may be null) 203 * @param objectName the object name (required) 204 * @param type the object type 205 * @return the matching object, or null if not found 206 */ 207 public synchronized TSQLSchemaObject get(String catalog, String schema, String objectName, ESQLDataObjectType type) { 208 if (objectName == null || type == null) { 209 return null; 210 } 211 212 return objectIndex.get(key(catalog, schema, objectName, type, IdentifierInputForm.SQL_LEXICAL)); 213 } 214 215 /** 216 * Same lookup as {@link #get}, for components that are ALREADY normalized. {@link #get} 217 * takes SQL text ({@code get("hr_db", "public", "employees")} folds and finds 218 * {@code EMPLOYEES}); {@code TSQLEnv} normalizes before it asks, and passing its segments 219 * to {@link #get} folded them a second time. The two forms need two entries: the same 220 * string {@code Orders} is the table {@code ORDERS} as SQL text and the table 221 * {@code "Orders"} as a normalized name. 222 */ 223 public synchronized TSQLSchemaObject getNormalized(String catalog, String schema, String objectName, 224 ESQLDataObjectType type) { 225 if (objectName == null || type == null) { 226 return null; 227 } 228 return objectIndex.get(key(catalog, schema, objectName, type, IdentifierInputForm.CATALOG_STORED)); 229 } 230 231 /** 232 * Finds objects by name only, regardless of catalog/schema. 233 * 234 * <p>This is used for partial name resolution when the full path is not specified.</p> 235 * 236 * @param objectName the object name 237 * @param type the object type (optional - null returns all types) 238 * @return list of matching objects (may be empty, never null) 239 */ 240 public synchronized List<TSQLSchemaObject> getByName(String objectName, ESQLDataObjectType type) { 241 if (objectName == null) { 242 return Collections.emptyList(); 243 } 244 245// if (type == null){ 246// type = ESQLDataObjectType.dotTable; // Default to table type if none specified 247// } 248 249 // The comparison key (keyForMap), not the stored name: two names the service calls equal 250 // must find each other. MySQL lower_case_table_names=1 stores `İ` as İ but compares it 251 // equal to i; SQL Server _AI stores café but compares it equal to cafe. A null type keeps 252 // the "any type" reading it always had (keyForMap/normalize treat null alike). 253 List<TSQLSchemaObject> candidates = objectsByName.get(nameKey(objectName, type)); 254 255 if (candidates == null || candidates.isEmpty()) { 256 return Collections.emptyList(); 257 } 258 259 // Filter by type if specified 260 if (type != null) { 261 List<TSQLSchemaObject> filtered = new ArrayList<>(); 262 for (TSQLSchemaObject obj : candidates) { 263 if (obj.getDataObjectType() == type) { 264 filtered.add(obj); 265 } 266 } 267 return filtered; 268 } 269 270 return new ArrayList<>(candidates); 271 } 272 273 /** 274 * Finds all objects of a specific type. 275 * 276 * @param type the object type 277 * @return collection of all objects of that type (may be empty, never null) 278 */ 279 public synchronized Collection<TSQLSchemaObject> getByType(ESQLDataObjectType type) { 280 if (type == null) { 281 return Collections.emptyList(); 282 } 283 284 Map<Key, TSQLSchemaObject> typeIndex = getTypeIndex(type); 285 if (typeIndex == null) { 286 return Collections.emptyList(); 287 } 288 289 return new ArrayList<>(typeIndex.values()); 290 } 291 292 /** 293 * Removes an object from the store. 294 * 295 * @param object the object to remove 296 * @return true if the object was removed, false if it was not present 297 */ 298 public synchronized boolean remove(TSQLSchemaObject object) { 299 if (object == null) { 300 return false; 301 } 302 303 Key key = createKey(object); 304 305 // Remove from primary index 306 TSQLSchemaObject removed = objectIndex.remove(key); 307 308 if (removed != null) { 309 // Remove from type-specific index 310 Map<Key, TSQLSchemaObject> typeIndex = getTypeIndex(object.getDataObjectType()); 311 if (typeIndex != null) { 312 typeIndex.remove(key); 313 } 314 315 // Remove from name-only index 316 String normalizedName = normalizeObjectName(object); 317 List<TSQLSchemaObject> list = objectsByName.get(normalizedName); 318 if (list != null) { 319 list.remove(object); 320 if (list.isEmpty()) { 321 objectsByName.remove(normalizedName); 322 } 323 } 324 325 return true; 326 } 327 328 return false; 329 } 330 331 /** 332 * Returns the total number of objects in the store. 333 * 334 * @return the total count 335 */ 336 public synchronized int size() { 337 return objectIndex.size(); 338 } 339 340 /** 341 * Removes all objects from the store. 342 */ 343 public synchronized void clear() { 344 objectIndex.clear(); 345 tableIndex.clear(); 346 functionIndex.clear(); 347 procedureIndex.clear(); 348 packageIndex.clear(); 349 triggerIndex.clear(); 350 columnIndex.clear(); 351 objectsByName.clear(); 352 } 353 354 /** 355 * Returns all objects in insertion order. 356 * 357 * @return collection of all objects (may be empty, never null) 358 */ 359 public synchronized Collection<TSQLSchemaObject> getAll() { 360 return new ArrayList<>(objectIndex.values()); 361 } 362 363 /** 364 * Creates a canonical key for an object. 365 * 366 * <p>The key is normalized according to vendor-specific rules.</p> 367 */ 368 private Key createKey(TSQLSchemaObject object) { 369 // An object's own names come from getName(): already normalized, i.e. stored names. 370 String catalog = object.getSchema() != null && object.getSchema().getCatalog() != null 371 ? object.getSchema().getCatalog().getName() : null; 372 String schema = object.getSchema() != null ? object.getSchema().getName() : null; 373 return key(catalog, schema, object.getName(), object.getDataObjectType(), IdentifierInputForm.CATALOG_STORED); 374 } 375 376 /** 377 * The index key, built by the name service's canonical key 378 * ({@link IdentifierService#canonKeyOf}) with the input form STATED for every component. 379 * 380 * <p>The key used to be {@code normalize(name)} on a name that was already normalized, which 381 * was wrong twice over. It folded a second time, so Oracle {@code "Orders"} was keyed as 382 * {@code ORDERS}, answered to {@code orders} and collided with a real table {@code ORDERS}. 383 * And {@code normalize} preserves the spelling on a collation-based dialect, so on SQL Server 384 * {@code SalesOrder} could not be found as {@code salesorder}; that case was only rescued by 385 * TSQLEnv's legacy string index, which upper-cases the whole name. The canonical key is 386 * case-insensitive exactly where the dialect is, and quote-state sensitive exactly where 387 * the dialect is.</p> 388 */ 389 private Key key(String catalog, String schema, String objectName, ESQLDataObjectType type, 390 IdentifierInputForm form) { 391 return new Key(component(catalog, ESQLDataObjectType.dotCatalog, form), 392 component(schema, ESQLDataObjectType.dotSchema, form), 393 component(objectName, type, form), type); 394 } 395 396 private CanonKey component(String text, ESQLDataObjectType role, IdentifierInputForm form) { 397 if (text == null || text.isEmpty()) { 398 return null; 399 } 400 // TSQLEnv's placeholders for a tier the dialect does not have are not names of either form. 401 if ((role == ESQLDataObjectType.dotCatalog && TSQLEnv.DEFAULT_DB_NAME.equals(text)) 402 || (role == ESQLDataObjectType.dotSchema && TSQLEnv.DEFAULT_SCHEMA_NAME.equals(text))) { 403 form = IdentifierInputForm.CATALOG_STORED; 404 } 405 return keyService().canonKeyOf(text, role, form); 406 } 407 408 private IdentifierService keyService() { 409 if (identifierService != null) { 410 return identifierService; 411 } 412 if (fallbackKeyService == null) { 413 // Only reachable through the deprecated vendor-only constructor, which states no 414 // configuration at all; the vendor default is then the honest answer. 415 fallbackKeyService = IdentifierService.defaultServiceFor(vendor); 416 } 417 return fallbackKeyService; 418 } 419 420 private IdentifierService fallbackKeyService; 421 422 /** Index key: one canonical key per tier plus the object type. */ 423 private static final class Key { 424 private final CanonKey catalog; 425 private final CanonKey schema; 426 private final CanonKey objectName; 427 private final ESQLDataObjectType type; 428 private final int hash; 429 430 Key(CanonKey catalog, CanonKey schema, CanonKey objectName, ESQLDataObjectType type) { 431 this.catalog = catalog; 432 this.schema = schema; 433 this.objectName = objectName; 434 this.type = type; 435 this.hash = Objects.hash(catalog, schema, objectName, type); 436 } 437 438 @Override 439 public boolean equals(Object o) { 440 if (this == o) return true; 441 if (!(o instanceof Key)) return false; 442 Key k = (Key) o; 443 return type == k.type && Objects.equals(objectName, k.objectName) 444 && Objects.equals(schema, k.schema) && Objects.equals(catalog, k.catalog); 445 } 446 447 @Override 448 public int hashCode() { 449 return hash; 450 } 451 } 452 453 /** 454 * The name-only index key of an object: its stored name ({@code getName()}, already 455 * normalized) spelled back as SQL by the store's own profile, then keyed like a lookup. 456 */ 457 private String normalizeObjectName(TSQLSchemaObject object) { 458 String name = object.getName(); 459 if (name == null) { 460 name = ""; 461 } 462 ESQLDataObjectType type = object.getDataObjectType(); 463 // TSQLEnv's placeholders for a tier the dialect does not have are not stored names. 464 boolean placeholder = (type == ESQLDataObjectType.dotCatalog && TSQLEnv.DEFAULT_DB_NAME.equals(name)) 465 || (type == ESQLDataObjectType.dotSchema && TSQLEnv.DEFAULT_SCHEMA_NAME.equals(name)); 466 if (!name.isEmpty() && type != null && !placeholder) { 467 name = IdentifierCodec.encodeStored(keyService().getProfile(), type, name); 468 } 469 return nameKey(name, type); 470 } 471 472 /** 473 * Name-only index key of a SQL spelling: {@link IdentifierService#keyForMap}, whose equality is 474 * exactly {@link IdentifierService#areEqual}. Keyed by {@code normalize} (the stored name) 475 * before, which split names the service equates. 476 */ 477 private String nameKey(String lexical, ESQLDataObjectType type) { 478 if (lexical == null || lexical.isEmpty()) { 479 return lexical; 480 } 481 return keyService().keyForMap(lexical, type); 482 } 483 484 /** 485 * Returns the type-specific index for the given object type. 486 */ 487 private Map<Key, TSQLSchemaObject> getTypeIndex(ESQLDataObjectType type) { 488 switch (type) { 489 case dotTable: 490 return tableIndex; 491 case dotFunction: 492 return functionIndex; 493 case dotProcedure: 494 return procedureIndex; 495 case dotOraclePackage: 496 return packageIndex; 497 case dotTrigger: 498 return triggerIndex; 499 case dotColumn: 500 return columnIndex; 501 default: 502 return null; 503 } 504 } 505 506 /** 507 * Immutable qualified name used as a canonical key. 508 * 509 * <p>This value object combines catalog.schema.object + type into a single 510 * key with cached hashCode for efficient map lookups.</p> 511 */ 512 public static class QualifiedName { 513 private final String catalog; 514 private final String schema; 515 private final String objectName; 516 private final ESQLDataObjectType type; 517 private final int hashCode; 518 519 public QualifiedName(String catalog, String schema, String objectName, ESQLDataObjectType type) { 520 this.catalog = catalog; 521 this.schema = schema; 522 this.objectName = objectName; 523 this.type = type; 524 this.hashCode = computeHashCode(); 525 } 526 527 public String getCatalog() { 528 return catalog; 529 } 530 531 public String getSchema() { 532 return schema; 533 } 534 535 public String getObjectName() { 536 return objectName; 537 } 538 539 public ESQLDataObjectType getType() { 540 return type; 541 } 542 543 @Override 544 public boolean equals(Object obj) { 545 if (this == obj) return true; 546 if (!(obj instanceof QualifiedName)) return false; 547 548 QualifiedName other = (QualifiedName) obj; 549 return Objects.equals(catalog, other.catalog) 550 && Objects.equals(schema, other.schema) 551 && Objects.equals(objectName, other.objectName) 552 && type == other.type; 553 } 554 555 @Override 556 public int hashCode() { 557 return hashCode; 558 } 559 560 private int computeHashCode() { 561 return Objects.hash(catalog, schema, objectName, type); 562 } 563 564 @Override 565 public String toString() { 566 StringBuilder sb = new StringBuilder(); 567 if (catalog != null) { 568 sb.append(catalog).append('.'); 569 } 570 if (schema != null) { 571 sb.append(schema).append('.'); 572 } 573 sb.append(objectName); 574 sb.append(" (").append(type).append(')'); 575 return sb.toString(); 576 } 577 } 578}