001package gudusoft.gsqlparser.resolver2.namespace; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 005import gudusoft.gsqlparser.nodes.TTable; 006import gudusoft.gsqlparser.nodes.TColumnDefinition; 007import gudusoft.gsqlparser.nodes.TColumnDefinitionList; 008import gudusoft.gsqlparser.nodes.TExpression; 009import gudusoft.gsqlparser.nodes.TObjectName; 010import gudusoft.gsqlparser.nodes.TObjectNameList; 011import gudusoft.gsqlparser.resolver2.ColumnLevel; 012import gudusoft.gsqlparser.resolver2.matcher.INameMatcher; 013import gudusoft.gsqlparser.resolver2.model.ColumnSource; 014import gudusoft.gsqlparser.resolver2.model.QualifiedName; 015import gudusoft.gsqlparser.resolver2.model.QualifiedNameResolver; 016import gudusoft.gsqlparser.sqlenv.TSQLCatalog; 017import gudusoft.gsqlparser.sqlenv.TSQLEnv; 018import gudusoft.gsqlparser.sqlenv.TSQLTable; 019import gudusoft.gsqlparser.sqlenv.TSQLColumn; 020 021import java.util.LinkedHashMap; 022import java.util.Map; 023 024/** 025 * Namespace representing a physical table. 026 * Provides column information from table metadata. 027 * 028 * Column sources come from: 029 * 1. Table metadata (if available from DDL) 030 * 2. External metadata providers (SQLEnv) 031 * 3. Inferred from usage (when no metadata available) 032 */ 033public class TableNamespace extends AbstractNamespace { 034 035 private final TTable table; 036 037 /** TSQLEnv for looking up table metadata */ 038 private TSQLEnv sqlEnv; 039 040 /** Database vendor for qualified name resolution */ 041 private EDbVendor vendor; 042 043 /** Qualified name resolver for normalizing table references */ 044 private QualifiedNameResolver qualifiedNameResolver; 045 046 /** The qualified name of this table (with defaults applied) */ 047 private QualifiedName qualifiedName; 048 049 /** Flag to track if this table has actual metadata (vs. only inferred columns) */ 050 private boolean hasMetadata = false; 051 052 /** 053 * Whether two generated outputs of a known table function ended up under ONE name — a partial 054 * alias list renames the leading outputs, and a renamed one can collide with a built-in name 055 * that follows ({@code STRING_SPLIT(...) AS f(ordinal)} renames {@code value} to {@code ordinal} 056 * and then meets the real {@code ordinal}). The name-keyed map then holds one of the two, so 057 * the enumerated set is NOT the whole schema and this namespace must not claim it is. 058 */ 059 private boolean generatedNameCollision = false; 060 061 /** The resolved TSQLTable from SQLEnv (if found) */ 062 private TSQLTable resolvedTable; 063 private java.util.Map<gudusoft.gsqlparser.sqlenv.CanonKey, String> catalogColumnKeys; 064 065 /** A namespace exposes catalog names bare; references still use SQL spelling. */ 066 public String getColumnLookupSpelling(String exposedName) { 067 ensureValidated(); 068 if (resolvedTable == null) return exposedName; 069 return gudusoft.gsqlparser.sqlenv.IdentifierCodec.encodeStored( 070 sqlEnv.getIdentifierService().getProfile(), ESQLDataObjectType.dotColumn, exposedName); 071 } 072 073 @Override 074 protected String findColumnKeyByMatcher(String lexicalName) { 075 if (catalogColumnKeys == null) return super.findColumnKeyByMatcher(lexicalName); 076 return catalogColumnKeys.get(sqlEnv.getIdentifierService().canonKeyOf(lexicalName, 077 ESQLDataObjectType.dotColumn, gudusoft.gsqlparser.sqlenv.IdentifierInputForm.SQL_LEXICAL)); 078 } 079 080 private boolean hasStatementMetadata; 081 private TSQLTable statementMetadata; 082 083 public void setStatementMetadata(TSQLTable metadata, QualifiedNameResolver resolver) { 084 hasStatementMetadata = true; 085 statementMetadata = metadata; 086 qualifiedNameResolver = resolver; 087 qualifiedName = null; 088 } 089 090 091 public TableNamespace(TTable table, INameMatcher nameMatcher) { 092 super(table, nameMatcher); 093 this.table = table; 094 } 095 096 public TableNamespace(TTable table, INameMatcher nameMatcher, TSQLEnv sqlEnv) { 097 super(table, nameMatcher); 098 this.table = table; 099 this.sqlEnv = sqlEnv; 100 // Default vendor - use setVendor() to override 101 if (sqlEnv != null) { 102 this.qualifiedNameResolver = new QualifiedNameResolver(sqlEnv, EDbVendor.dbvoracle); 103 } 104 } 105 106 /** 107 * Create a TableNamespace with full qualified name resolution support. 108 * 109 * @param table The table AST node 110 * @param nameMatcher The name matcher for case sensitivity 111 * @param sqlEnv The SQL environment for metadata lookup 112 * @param vendor The database vendor 113 */ 114 public TableNamespace(TTable table, INameMatcher nameMatcher, TSQLEnv sqlEnv, EDbVendor vendor) { 115 super(table, nameMatcher); 116 this.table = table; 117 this.sqlEnv = sqlEnv; 118 this.vendor = vendor; 119 if (sqlEnv != null) { 120 this.qualifiedNameResolver = new QualifiedNameResolver(sqlEnv, vendor); 121 } 122 } 123 124 public TableNamespace(TTable table) { 125 super(table); 126 this.table = table; 127 } 128 129 /** 130 * Set the TSQLEnv for metadata lookup. 131 * @param sqlEnv the SQL environment containing table metadata 132 */ 133 public void setSqlEnv(TSQLEnv sqlEnv) { 134 this.sqlEnv = sqlEnv; 135 if (sqlEnv != null && vendor != null) { 136 this.qualifiedNameResolver = new QualifiedNameResolver(sqlEnv, vendor); 137 } 138 // Reset cached qualified name so it's recomputed with new env 139 this.qualifiedName = null; 140 } 141 142 /** 143 * Get the TSQLEnv used for metadata lookup. 144 * @return the SQL environment, or null if not set 145 */ 146 public TSQLEnv getSqlEnv() { 147 return sqlEnv; 148 } 149 150 /** 151 * Set the database vendor. 152 * @param vendor the database vendor 153 */ 154 public void setVendor(EDbVendor vendor) { 155 this.vendor = vendor; 156 if (sqlEnv != null) { 157 this.qualifiedNameResolver = new QualifiedNameResolver(sqlEnv, vendor); 158 } 159 // Reset cached qualified name 160 this.qualifiedName = null; 161 } 162 163 /** 164 * Get the database vendor. 165 * @return the database vendor, or null if not set 166 */ 167 public EDbVendor getVendor() { 168 return vendor; 169 } 170 171 /** 172 * Get the qualified name resolver. 173 * @return the resolver, or null if sqlEnv is not set 174 */ 175 public QualifiedNameResolver getQualifiedNameResolver() { 176 return qualifiedNameResolver; 177 } 178 179 /** 180 * Get the fully qualified name of this table. 181 * 182 * <p>The qualified name is computed by applying defaults from TSQLEnv 183 * to the table's partial name. 184 * 185 * @return the qualified name, or null if table name is unavailable 186 */ 187 public QualifiedName getQualifiedName() { 188 if (qualifiedName != null) { 189 return qualifiedName; 190 } 191 192 TObjectName tableName = table.getTableName(); 193 if (tableName == null) { 194 return null; 195 } 196 197 if (qualifiedNameResolver != null) { 198 qualifiedName = qualifiedNameResolver.resolve(tableName); 199 } else { 200 // Fallback: build from table name parts without defaults 201 String catalog = tableName.getDatabaseString(); 202 String schema = tableName.getSchemaString(); 203 String name = tableName.getObjectString(); 204 if (name == null || name.isEmpty()) { 205 name = tableName.toString(); 206 } 207 if (name != null && !name.isEmpty()) { 208 qualifiedName = QualifiedName.forTable(catalog, schema, name); 209 } 210 } 211 212 return qualifiedName; 213 } 214 215 /** 216 * Get the resolved TSQLTable from SQLEnv. 217 * @return the resolved table, or null if not found in SQLEnv 218 */ 219 public TSQLTable getResolvedTable() { 220 return resolvedTable; 221 } 222 223 @Override 224 public String getDisplayName() { 225 String alias = table.getAliasName(); 226 if (alias != null && !alias.isEmpty()) { 227 return alias; 228 } 229 String fullName = table.getFullName(); 230 if (fullName != null && !fullName.isEmpty()) { 231 return fullName; 232 } 233 // Fallback to simple table name 234 String name = table.getName(); 235 if (name != null && !name.isEmpty()) { 236 return name; 237 } 238 // Last resort: try table name object 239 TObjectName tableName = table.getTableName(); 240 if (tableName != null) { 241 String tableStr = tableName.getTableString(); 242 if (tableStr != null && !tableStr.isEmpty()) { 243 return tableStr; 244 } 245 String tableNameStr = tableName.toString(); 246 if (tableNameStr != null && !tableNameStr.isEmpty()) { 247 return tableNameStr; 248 } 249 } 250 return ""; 251 } 252 253 @Override 254 public TTable getFinalTable() { 255 return table; 256 } 257 258 @Override 259 public TTable getSourceTable() { 260 return table; 261 } 262 263 @Override 264 protected void doValidate() { 265 // Load columns from metadata 266 columnSources = new LinkedHashMap<>(); 267 hasMetadata = false; 268 generatedNameCollision = false; 269 resolvedTable = null; 270 catalogColumnKeys = null; 271 272 // Priority 1: Check if table has column list (from DDL like CREATE TABLE) 273 TColumnDefinitionList columnDefs = table.getColumnDefinitions(); 274 if (columnDefs != null && columnDefs.size() > 0) { 275 // Has explicit column list from CREATE TABLE or metadata 276 hasMetadata = true; 277 for (int i = 0; i < columnDefs.size(); i++) { 278 TColumnDefinition colDef = columnDefs.getColumn(i); 279 TObjectName colNameObj = colDef.getColumnName(); 280 if (colNameObj == null) continue; 281 282 String colName = colNameObj.toString(); 283 284 ColumnSource source = new ColumnSource( 285 this, 286 colName, 287 colDef, 288 1.0, // Definite - from DDL metadata 289 "ddl_metadata" 290 ); 291 columnSources.put(colName, source); 292 } 293 return; // DDL metadata takes priority 294 } 295 296 // Priority 2: Try to load from TSQLEnv if available 297 if (sqlEnv != null) { 298 TObjectName tableName = table.getTableName(); 299 TSQLTable sqlTable = null; 300 if (tableName != null) { 301 sqlTable = hasStatementMetadata ? statementMetadata : sqlEnv.searchTable(tableName); 302 } 303 304 if (sqlTable != null) { 305 // Found table in SQLEnv - load all columns 306 resolvedTable = sqlTable; 307 catalogColumnKeys = new java.util.HashMap<>(); 308 hasMetadata = true; 309 310 for (TSQLColumn column : sqlTable.getColumnList()) { 311 String colName = column.getName(); 312 313 ColumnSource source = new ColumnSource( 314 this, 315 colName, 316 null, // No TColumnDefinition node from SQLEnv 317 1.0, // Definite - from SQLEnv metadata 318 "sqlenv_metadata" 319 ); 320 columnSources.put(colName, source); 321 catalogColumnKeys.put(sqlEnv.getIdentifierService().canonKeyOf(colName, 322 ESQLDataObjectType.dotColumn, gudusoft.gsqlparser.sqlenv.IdentifierInputForm.CATALOG_STORED), colName); 323 } 324 return; // SQLEnv metadata found 325 } 326 } 327 328 // Priority 3: Table-valued functions. 329 if (table.getTableType() == gudusoft.gsqlparser.ETableSource.function) { 330 // 3a: a built-in whose columns the vendor fixes (TableFunctionLayout, the one rule the SQL-LR 331 // producer publishes too): its names after an alias column list renames the leading ones 332 // POSITIONALLY (FROM TABLE(FLATTEN(input => t.c)) f(s, k)), or after PostgreSQL's table alias 333 // names the one column of a function of a base type (FROM unnest(a) AS u has the column u) 334 // a user table function the catalog declares (a CREATE FUNCTION of the input before the call): its 335 // declared columns, recorded on the call for every consumer of the layout 336 TableFunctionLayout.recordDeclared(table, sqlEnv); 337 TableFunctionLayout layout = TableFunctionLayout.of(table); 338 if (layout != null && layout.getNames() != null) { 339 for (String name : layout.getNames()) { 340 if (name != null) { 341 addKnownFunctionColumn(name); 342 } 343 } 344 // an unnest of an array of unknown element type may have a composite's columns as well 345 hasMetadata = layout.isClosed(); 346 return; 347 } 348 TObjectNameList aliasColumns = functionAliasColumns(); 349 350 // 3b: an explicit column alias list names the leading output columns of a function 351 // whose own columns are unknown 352 if (aliasColumns != null) { 353 for (int i = 0; i < aliasColumns.size(); i++) { 354 TObjectName colName = aliasColumns.getObjectName(i); 355 if (colName != null) { 356 addKnownFunctionColumn(colName.toString()); 357 } 358 } 359 // The function's own output schema is unknown, so a partial list 360 // leaves unnamed columns that keep names we cannot enumerate. 361 // Stay non-authoritative: those must remain inferable rather than 362 // being reported as non-existent. 363 hasMetadata = false; 364 return; 365 } 366 } 367 368 // Priority 4: No metadata available 369 // For tables without metadata, we'll handle this as MAYBE in hasColumn 370 // Columns will be inferred from usage in resolveColumn() 371 } 372 373 /** 374 * The explicit column alias list of this table, or null when there is none. 375 * 376 * <p>Snowflake, PostgreSQL and Presto/Trino all allow renaming a table 377 * function's output columns: {@code FROM f(...) alias (c1, c2, ...)}. The list 378 * may be shorter than the function's output — the remaining columns then keep 379 * their original names.</p> 380 */ 381 private TObjectNameList functionAliasColumns() { 382 if (table.getAliasClause() == null) { 383 return null; 384 } 385 TObjectNameList columns = table.getAliasClause().getColumns(); 386 if (columns == null || columns.size() == 0) { 387 return null; 388 } 389 return columns; 390 } 391 392 /** 393 * Add a known column for a table-valued function. 394 */ 395 private void addKnownFunctionColumn(String columnName) { 396 if (columnSources.containsKey(columnName)) { 397 generatedNameCollision = true; 398 } 399 ColumnSource source = new ColumnSource( 400 this, 401 columnName, 402 null, 403 1.0, // Definite - known function output column 404 "known_function_column" 405 ); 406 columnSources.put(columnName, source); 407 noteColumnSourceAdded(columnName); 408 } 409 410 @Override 411 public ColumnLevel hasColumn(String columnName) { 412 ensureValidated(); 413 414 // Check if column exists in known columns (including inferred ones). 415 // Canonical-key indexed lookup (Mantis 4684): the previous matcher loop 416 // over the inferred-column map made wide select lists O(N²), with a 417 // collator comparison per entry on SQL Server. 418 if (findColumnKeyByMatcher(columnName) != null) { 419 return ColumnLevel.EXISTS; 420 } 421 422 // If we don't have metadata, any column MAYBE exists 423 // (we can't definitively say it doesn't exist) 424 if (!hasMetadata) { 425 return ColumnLevel.MAYBE; 426 } 427 428 return ColumnLevel.NOT_EXISTS; 429 } 430 431 @Override 432 public ColumnSource resolveColumn(String columnName) { 433 ensureValidated(); 434 435 // First try to find in known columns 436 String matchedKey = findColumnKeyByMatcher(columnName); 437 ColumnSource existing = matchedKey == null ? null : columnSources.get(matchedKey); 438 if (existing != null) { 439 return existing; 440 } 441 442 // If no metadata available, create an inferred ColumnSource 443 // This allows columns to be resolved against tables without metadata 444 // Note: hasMetadata is false when we don't have DDL or SQLEnv metadata 445 if (!hasMetadata && columnName != null && !columnName.isEmpty()) { 446 // Create a column source with moderate confidence 447 // Since we don't have metadata, we're inferring this column exists 448 ColumnSource inferred = new ColumnSource( 449 this, 450 columnName, 451 null, // No definition node 452 0.8, // Moderate confidence - we don't have proof this column exists 453 "inferred_from_usage" 454 ); 455 456 // Cache it for future lookups 457 columnSources.put(columnName, inferred); 458 noteColumnSourceAdded(columnName); 459 460 return inferred; 461 } 462 463 return null; 464 } 465 466 public TTable getTable() { 467 return table; 468 } 469 470 /** 471 * Returns true if this namespace has actual metadata (from DDL or SQLEnv). 472 * When false, column resolution will infer columns from usage. 473 * 474 * @return true if metadata is available, false if columns are inferred 475 */ 476 public boolean hasMetadata() { 477 ensureValidated(); 478 return hasMetadata; 479 } 480 481 /** 482 * Slice S4: authoritative metadata-state tri-value (plan §5.5). 483 * 484 * <p>Distinguishes:</p> 485 * <ul> 486 * <li>{@link MetadataState#FOUND} — DDL columns present, or 487 * {@link TSQLEnv} returned a table with a non-empty column list, or 488 * a known table-valued function (e.g. STRING_SPLIT) populated its 489 * columns.</li> 490 * <li>{@link MetadataState#NOT_FOUND_IN_CATALOG} — a non-empty 491 * {@code TSQLEnv} was consulted but the table itself is absent. 492 * Never used when no environment has been configured.</li> 493 * <li>{@link MetadataState#METADATA_UNAVAILABLE} — no environment, an 494 * empty environment (no catalogs registered), the lookup returned a 495 * table without expanded columns (Q8 view-without-columns), or any 496 * other unauthoritative state.</li> 497 * </ul> 498 */ 499 @Override 500 public MetadataState getMetadataState() { 501 ensureValidated(); 502 503 // Authoritative: DDL columns or TSQLEnv-resolved table with columns — unless two 504 // generated outputs collapsed onto one name, in which case the map is not the schema. 505 if (hasMetadata && columnSources != null && !columnSources.isEmpty() && !generatedNameCollision) { 506 return MetadataState.FOUND; 507 } 508 509 // Found-but-unexpanded: TSQLEnv returned the table but no columns came 510 // through. Q8 view-without-columns falls here. Never NOT_FOUND_IN_CATALOG 511 // and never UNKNOWN_COLUMN — strict mode may emit a WARNING in S6. 512 if (resolvedTable != null) { 513 return MetadataState.METADATA_UNAVAILABLE; 514 } 515 516 // Lookup ran against a populated environment and missed entirely. The 517 // env has at least one catalog registered, so we trust the absence as 518 // an authoritative table-not-found signal. Bridges pre-register an 519 // empty default catalog/schema during construction (see 520 // CatalogRuntimeToSQLEnvBridge.applyDefaults), which preserves this 521 // behavior under lazy materialization. 522 if (sqlEnv != null && hasAnyCatalogContent(sqlEnv)) { 523 return MetadataState.NOT_FOUND_IN_CATALOG; 524 } 525 526 return MetadataState.METADATA_UNAVAILABLE; 527 } 528 529 private static boolean hasAnyCatalogContent(TSQLEnv env) { 530 if (env == null) { 531 return false; 532 } 533 java.util.List<TSQLCatalog> catalogs = env.getCatalogList(); 534 return catalogs != null && !catalogs.isEmpty(); 535 } 536 537 /** 538 * Add a USING clause column to this table's namespace. 539 * This is called during scope building when a JOIN...USING clause 540 * includes columns from this table. The USING column exists in BOTH 541 * tables of the join. 542 * 543 * @param columnName the name of the USING column 544 */ 545 public void addUsingColumn(String columnName) { 546 ensureValidated(); 547 548 // Only add if not already present 549 if (hasColumn(columnName) == ColumnLevel.EXISTS) { 550 return; 551 } 552 553 ColumnSource source = new ColumnSource( 554 this, 555 columnName, 556 null, // No definition node 557 1.0, // Definite - USING clause semantics guarantee the column exists 558 "using_clause_column" 559 ); 560 columnSources.put(columnName, source); 561 noteColumnSourceAdded(columnName); 562 } 563 564 @Override 565 public String toString() { 566 return "TableNamespace(" + getDisplayName() + ", columns=" + 567 (columnSources != null ? columnSources.size() : "?") + ")"; 568 } 569}