001package gudusoft.gsqlparser.resolver2; 002 003import gudusoft.gsqlparser.EDbObjectType; 004import gudusoft.gsqlparser.EDbVendor; 005import gudusoft.gsqlparser.EExpressionType; 006import gudusoft.gsqlparser.ESqlClause; 007import gudusoft.gsqlparser.ESqlStatementType; 008import gudusoft.gsqlparser.ETableEffectType; 009import gudusoft.gsqlparser.ETableSource; 010import gudusoft.gsqlparser.TBaseType; 011import gudusoft.gsqlparser.TCustomSqlStatement; 012import gudusoft.gsqlparser.TSourceToken; 013import gudusoft.gsqlparser.TStatementList; 014import gudusoft.gsqlparser.nodes.*; 015import gudusoft.gsqlparser.resolver2.model.ColumnSource; 016import gudusoft.gsqlparser.resolver2.namespace.CTENamespace; 017import gudusoft.gsqlparser.resolver2.namespace.INamespace; 018import gudusoft.gsqlparser.stmt.TSelectSqlStatement; 019import gudusoft.gsqlparser.stmt.TMergeSqlStatement; 020import gudusoft.gsqlparser.stmt.TCreateTableSqlStatement; 021import gudusoft.gsqlparser.stmt.TAlterTableStatement; 022 023import gudusoft.gsqlparser.resolver2.format.DisplayNameMode; 024import gudusoft.gsqlparser.resolver2.format.DisplayNameNormalizer; 025import gudusoft.gsqlparser.resolver2.matcher.INameMatcher; 026import gudusoft.gsqlparser.resolver2.matcher.VendorNameMatcher; 027import gudusoft.gsqlparser.resolver2.result.IResolutionResult; 028import gudusoft.gsqlparser.resolver2.result.ResolutionResultImpl; 029import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 030 031import java.util.*; 032 033 034/** 035 * Formats the resolution results from {@link TSQLResolver2} into structured output 036 * for testing assertions and debugging. 037 * 038 * <p>This class provides a consistent way to extract and format <b>Tables / Fields / CTE</b> 039 * information from {@link TSQLResolver2} results, handling special scenarios such as 040 * <b>UNNEST</b>, <b>CTE</b>, <b>JOIN...USING</b>, <b>SELECT * EXCEPT</b>, etc.</p> 041 * 042 * <h2>Separation of Concerns Principle</h2> 043 * 044 * <p><b>Key Principle:</b> {@code TSQLResolver2ResultFormatter} is <b>only responsible for 045 * formatting output</b>, NOT for name resolution.</p> 046 * 047 * <ul> 048 * <li><b>Name resolution logic:</b> Must be implemented in {@link TSQLResolver2}, 049 * {@link ScopeBuilder}, and {@link NameResolver}</li> 050 * <li><b>Formatting logic:</b> Implemented in this class ({@code TSQLResolver2ResultFormatter})</li> 051 * </ul> 052 * 053 * <p><b>Violating this principle will cause:</b></p> 054 * <ul> 055 * <li>Users directly using the {@link TSQLResolver2} API will not get correct resolution results</li> 056 * <li>Data consistency issues in resolution results</li> 057 * </ul> 058 * 059 * <p>This means the formatter should only <b>read</b> data that has already been resolved 060 * by the resolver components. It should never attempt to perform resolution logic such as 061 * guessing table names for unresolved columns or modifying the AST nodes.</p> 062 * 063 * <h2>Configuration Options</h2> 064 * 065 * <p>The formatter supports various configuration options to control output:</p> 066 * <ul> 067 * <li>{@link #setShowCTE(boolean)} - Include CTE tables in output</li> 068 * <li>{@link #setOnlyPhysicalTables(boolean)} - Filter to physical tables only</li> 069 * <li>{@link #setShowUnnest(boolean)} - Include UNNEST tables when onlyPhysicalTables=true</li> 070 * <li>{@link #setShowPivotTable(boolean)} - Include PIVOT tables when onlyPhysicalTables=true</li> 071 * <li>{@link #setShowLateralView(boolean)} - Include LATERAL VIEW tables when onlyPhysicalTables=true</li> 072 * <li>{@link #setShowDatatype(boolean)} - Include column datatypes</li> 073 * <li>{@link #setShowColumnLocation(boolean)} - Include SQL clause location</li> 074 * <li>{@link #setShowTableEffect(boolean)} - Include table effect type</li> 075 * </ul> 076 * 077 * <h2>Usage Example</h2> 078 * 079 * <pre>{@code 080 * TSQLResolver2 resolver = new TSQLResolver2(env, statements, config); 081 * resolver.resolve(); 082 * 083 * TSQLResolver2ResultFormatter formatter = new TSQLResolver2ResultFormatter(resolver); 084 * formatter.setShowCTE(true); 085 * formatter.setShowDatatype(true); 086 * 087 * String result = formatter.format(); 088 * }</pre> 089 * 090 * @see TSQLResolver2 091 * @see ScopeBuilder 092 * @see NameResolver 093 * @see gudusoft.gsqlparser.util.TGetTableColumn 094 */ 095public class TSQLResolver2ResultFormatter { 096 097 // ========== Configuration Options ========== 098 099 /** Include CTE tables and their columns in Fields output */ 100 private boolean showCTE = false; 101 102 /** Include column datatypes in output (e.g., column:string) */ 103 private boolean showDatatype = false; 104 105 /** Include separate Ctes section with CTE column definitions */ 106 private boolean showColumnsOfCTE = false; 107 108 /** Include star columns (*) in output */ 109 private boolean listStarColumn = true; 110 111 /** Include table effect type in output (e.g., tableName(effectInsert)) */ 112 private boolean showTableEffect = false; 113 114 /** Include column location/clause in output (e.g., columnName(selectList)) */ 115 private boolean showColumnLocation = false; 116 117 /** 118 * Only include physical/base tables in output, excluding: 119 * - PL/SQL record variables (rec_xxx) 120 * - Cursor variables 121 * - Package variables 122 * - Other non-table sources 123 * 124 * This option makes output compatible with the old TGetTableColumn format. 125 */ 126 private boolean onlyPhysicalTables = false; 127 128 /** 129 * When true (default), UNNEST tables will be included in output even when onlyPhysicalTables=true. 130 * When false, UNNEST tables are excluded when onlyPhysicalTables=true. 131 */ 132 private boolean showUnnest = true; 133 134 /** 135 * When true (default), PIVOT tables will be included in output even when onlyPhysicalTables=true. 136 * When false, PIVOT tables are excluded when onlyPhysicalTables=true. 137 */ 138 private boolean showPivotTable = true; 139 140 /** 141 * When true (default), LATERAL VIEW tables will be included in output even when onlyPhysicalTables=true. 142 * When false, LATERAL VIEW tables are excluded when onlyPhysicalTables=true. 143 */ 144 private boolean showLateralView = true; 145 146 /** 147 * When true (default), orphan columns (columns that cannot be definitively linked 148 * to a single table due to ambiguity or missing metadata) will be linked to the 149 * first candidate table in candidateTables. 150 * 151 * When false, orphan columns will use "missed" as their table prefix in output. 152 * 153 * Example: For "SELECT column1 FROM tablea, tableb" where column1 exists in both tables: 154 * - linkOrphanColumnToFirstTable=true: outputs "tablea.column1" 155 * - linkOrphanColumnToFirstTable=false: outputs "missed.column1" 156 */ 157 private boolean linkOrphanColumnToFirstTable = true; 158 159 // ========== Internal State ========== 160 161 private final TSQLResolver2 resolver; 162 private final TStatementList statements; 163 private final ScopeBuildResult buildResult; 164 165 /** Name matcher for identifier normalization (from config or default) */ 166 private final INameMatcher nameMatcher; 167 168 /** Display name normalizer for stripping delimiters without case folding */ 169 private final DisplayNameNormalizer displayNameNormalizer; 170 171 /** Display name mode (DISPLAY, SQL_RENDER, CANONICAL) */ 172 private DisplayNameMode displayNameMode = DisplayNameMode.DISPLAY; 173 174 /** Resolution result interface for statement-centric access */ 175 private final IResolutionResult resolutionResult; 176 177 // ========== Constructor ========== 178 179 /** 180 * Create a formatter for the given resolver. 181 * 182 * @param resolver The TSQLResolver2 instance (must have called resolve()) 183 */ 184 public TSQLResolver2ResultFormatter(TSQLResolver2 resolver) { 185 this.resolver = resolver; 186 this.statements = resolver.getStatements(); 187 this.buildResult = resolver.getScopeBuildResult(); 188 // Use name matcher from resolver's config 189 TSQLResolverConfig config = resolver.getConfig(); 190 this.nameMatcher = config != null ? config.getNameMatcher() : null; 191 // Initialize display name normalizer 192 EDbVendor vendor = config != null ? config.getVendor() : getVendorFromStatements(); 193 this.displayNameNormalizer = new DisplayNameNormalizer(vendor); 194 if (config != null) { 195 this.displayNameMode = config.getDisplayNameMode(); 196 this.displayNameNormalizer.setMode(this.displayNameMode); 197 this.displayNameNormalizer.setStripDelimiters(config.isStripDelimitersForDisplay()); 198 } 199 // Create resolution result interface for statement-centric access 200 this.resolutionResult = (buildResult != null) 201 ? new ResolutionResultImpl(buildResult, statements) 202 : null; 203 } 204 205 /** 206 * Create a formatter with a specific configuration. 207 * 208 * @param resolver The TSQLResolver2 instance 209 * @param config Configuration to apply 210 */ 211 public TSQLResolver2ResultFormatter(TSQLResolver2 resolver, TSQLResolverConfig config) { 212 this.resolver = resolver; 213 this.statements = resolver.getStatements(); 214 this.buildResult = resolver.getScopeBuildResult(); 215 if (config != null) { 216 this.showDatatype = config.isShowDatatype(); 217 this.showCTE = config.isShowCTE(); 218 this.nameMatcher = config.getNameMatcher(); 219 this.displayNameMode = config.getDisplayNameMode(); 220 } else { 221 this.nameMatcher = null; 222 } 223 // Initialize display name normalizer 224 EDbVendor vendor = config != null ? config.getVendor() : getVendorFromStatements(); 225 this.displayNameNormalizer = new DisplayNameNormalizer(vendor); 226 if (config != null) { 227 this.displayNameNormalizer.setMode(this.displayNameMode); 228 this.displayNameNormalizer.setStripDelimiters(config.isStripDelimitersForDisplay()); 229 } 230 // Create resolution result interface for statement-centric access 231 this.resolutionResult = (buildResult != null) 232 ? new ResolutionResultImpl(buildResult, statements) 233 : null; 234 } 235 236 /** 237 * Get vendor from statements if not provided in config. 238 */ 239 private EDbVendor getVendorFromStatements() { 240 if (statements != null && statements.size() > 0) { 241 return statements.get(0).dbvendor; 242 } 243 return null; 244 } 245 246 // ========== Configuration Setters ========== 247 248 public TSQLResolver2ResultFormatter setShowCTE(boolean showCTE) { 249 this.showCTE = showCTE; 250 return this; 251 } 252 253 public TSQLResolver2ResultFormatter setShowDatatype(boolean showDatatype) { 254 this.showDatatype = showDatatype; 255 return this; 256 } 257 258 public TSQLResolver2ResultFormatter setShowColumnsOfCTE(boolean showColumnsOfCTE) { 259 this.showColumnsOfCTE = showColumnsOfCTE; 260 return this; 261 } 262 263 public TSQLResolver2ResultFormatter setListStarColumn(boolean listStarColumn) { 264 this.listStarColumn = listStarColumn; 265 return this; 266 } 267 268 /** 269 * Get the current display name mode. 270 * 271 * @return the current DisplayNameMode 272 */ 273 public DisplayNameMode getDisplayNameMode() { 274 return displayNameMode; 275 } 276 277 /** 278 * Set the display name mode for identifier formatting. 279 * 280 * <p>This controls how identifiers (table names, column names) are formatted in output:</p> 281 * <ul> 282 * <li>{@link DisplayNameMode#DISPLAY} - Strip delimiters, preserve original case 283 * (e.g., {@code [OrderID]} → {@code OrderID})</li> 284 * <li>{@link DisplayNameMode#SQL_RENDER} - Preserve delimiters for valid SQL regeneration 285 * (e.g., {@code [Order ID]} → {@code [Order ID]})</li> 286 * <li>{@link DisplayNameMode#CANONICAL} - Apply vendor-specific case folding 287 * (e.g., Oracle: {@code MyTable} → {@code MYTABLE})</li> 288 * </ul> 289 * 290 * <p>This method can be called after instantiation to change the mode for each SQL text 291 * being processed.</p> 292 * 293 * @param mode the DisplayNameMode to use 294 * @return this formatter for chaining 295 */ 296 public TSQLResolver2ResultFormatter setDisplayNameMode(DisplayNameMode mode) { 297 this.displayNameMode = mode != null ? mode : DisplayNameMode.DISPLAY; 298 this.displayNameNormalizer.setMode(this.displayNameMode); 299 return this; 300 } 301 302 /** 303 * Set whether to include table effect type in output. 304 * When true, tables will be displayed as tableName(effectType) where effectType 305 * indicates how the table is used (e.g., effectInsert, effectUpdate, effectSelect). 306 * 307 * @param showTableEffect true to include table effect 308 * @return this formatter for chaining 309 */ 310 public TSQLResolver2ResultFormatter setShowTableEffect(boolean showTableEffect) { 311 this.showTableEffect = showTableEffect; 312 return this; 313 } 314 315 /** 316 * Set whether to include column location/clause in output. 317 * When true, columns will be displayed as columnName(location) where location 318 * indicates the SQL clause where the column appears (e.g., selectList, where, groupBy). 319 * 320 * @param showColumnLocation true to include column location 321 * @return this formatter for chaining 322 */ 323 public TSQLResolver2ResultFormatter setShowColumnLocation(boolean showColumnLocation) { 324 this.showColumnLocation = showColumnLocation; 325 return this; 326 } 327 328 /** 329 * Set whether to only include physical/base tables in output. 330 * When true, excludes PL/SQL record variables, cursor variables, and other non-table sources. 331 * This makes output compatible with the old TGetTableColumn format. 332 * 333 * @param onlyPhysicalTables true to filter to physical tables only 334 * @return this formatter for chaining 335 */ 336 public TSQLResolver2ResultFormatter setOnlyPhysicalTables(boolean onlyPhysicalTables) { 337 this.onlyPhysicalTables = onlyPhysicalTables; 338 return this; 339 } 340 341 /** 342 * Set whether to include UNNEST tables in output when onlyPhysicalTables=true. 343 * Default is true (UNNEST tables are always included). 344 * 345 * @param showUnnest true to include UNNEST tables 346 * @return this formatter for chaining 347 */ 348 public TSQLResolver2ResultFormatter setShowUnnest(boolean showUnnest) { 349 this.showUnnest = showUnnest; 350 return this; 351 } 352 353 /** 354 * Set whether to include PIVOT tables in output when onlyPhysicalTables=true. 355 * Default is true (PIVOT tables are always included). 356 * 357 * @param showPivotTable true to include PIVOT tables 358 * @return this formatter for chaining 359 */ 360 public TSQLResolver2ResultFormatter setShowPivotTable(boolean showPivotTable) { 361 this.showPivotTable = showPivotTable; 362 return this; 363 } 364 365 /** 366 * Set whether to include LATERAL VIEW tables in output when onlyPhysicalTables=true. 367 * Default is true (LATERAL VIEW tables are always included). 368 * 369 * @param showLateralView true to include LATERAL VIEW tables 370 * @return this formatter for chaining 371 */ 372 public TSQLResolver2ResultFormatter setShowLateralView(boolean showLateralView) { 373 this.showLateralView = showLateralView; 374 return this; 375 } 376 377 /** 378 * Set whether orphan columns should be linked to the first candidate table. 379 * 380 * When true (default), orphan columns (columns that cannot be definitively linked 381 * to a single table) will be linked to the first candidate table. 382 * 383 * When false, orphan columns will use "missed" as their table prefix. 384 * 385 * @param linkOrphanColumnToFirstTable true to use first candidate, false to use "missed" 386 * @return this formatter for chaining 387 */ 388 public TSQLResolver2ResultFormatter setLinkOrphanColumnToFirstTable(boolean linkOrphanColumnToFirstTable) { 389 this.linkOrphanColumnToFirstTable = linkOrphanColumnToFirstTable; 390 return this; 391 } 392 393 // ========== Configuration Getters ========== 394 395 public boolean isShowCTE() { return showCTE; } 396 public boolean isShowDatatype() { return showDatatype; } 397 public boolean isShowColumnsOfCTE() { return showColumnsOfCTE; } 398 public boolean isOnlyPhysicalTables() { return onlyPhysicalTables; } 399 public boolean isShowUnnest() { return showUnnest; } 400 public boolean isShowPivotTable() { return showPivotTable; } 401 public boolean isShowLateralView() { return showLateralView; } 402 public boolean isListStarColumn() { return listStarColumn; } 403 public boolean isLinkOrphanColumnToFirstTable() { return linkOrphanColumnToFirstTable; } 404 public boolean isShowTableEffect() { return showTableEffect; } 405 public boolean isShowColumnLocation() { return showColumnLocation; } 406 407 /** 408 * Get the resolution result interface for statement-centric access. 409 * This provides a clean API for programmatically accessing resolution results. 410 * 411 * <p>Usage example:</p> 412 * <pre> 413 * IResolutionResult result = formatter.getResolutionResult(); 414 * for (TCustomSqlStatement stmt : parser.sqlstatements) { 415 * for (TTable table : result.getTables(stmt)) { 416 * System.out.println("Table: " + table.getFullName()); 417 * for (TObjectName col : result.getColumnsForTable(stmt, table)) { 418 * System.out.println(" Column: " + col.getColumnNameOnly()); 419 * } 420 * } 421 * } 422 * </pre> 423 * 424 * @return The resolution result interface, or null if resolver has not been called 425 */ 426 public IResolutionResult getResolutionResult() { 427 return resolutionResult; 428 } 429 430 // ========== Main Formatting Methods ========== 431 432 /** 433 * Format the resolver results into a structured string. 434 * 435 * @return Formatted string with Tables, Fields, and optionally Ctes sections 436 */ 437 public String format() { 438 // Collect tables 439 Set<String> tables = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); 440 for (int i = 0; i < statements.size(); i++) { 441 collectTablesFromStatement(statements.get(i), tables); 442 } 443 444 // Collect fields 445 Set<String> fields = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); 446 // 1. Collect DDL fields (constraints, CTAS targets) via AST traversal 447 for (int i = 0; i < statements.size(); i++) { 448 collectDDLFieldsFromStatement(statements.get(i), fields); 449 } 450 // 2. Collect resolved fields (SELECT/DML) via global single-pass optimization 451 collectResolvedFieldsGlobal(fields); 452 453 // Collect CTE columns if needed 454 Set<String> cteColumns = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); 455 if (showColumnsOfCTE) { 456 collectCTEColumns(cteColumns); 457 } 458 459 // Build result string 460 StringBuilder sb = new StringBuilder(); 461 sb.append("Tables:\n"); 462 for (String table : tables) { 463 sb.append(table).append("\n"); 464 } 465 sb.append("\n"); 466 sb.append("Fields:\n"); 467 for (String field : fields) { 468 sb.append(field).append("\n"); 469 } 470 471 if (showColumnsOfCTE && !cteColumns.isEmpty()) { 472 sb.append("\n"); 473 sb.append("Ctes:\n"); 474 for (String cteCol : cteColumns) { 475 sb.append(cteCol).append("\n"); 476 } 477 } 478 479 return sb.toString().trim(); 480 } 481 482 /** 483 * Get the list of tables found in the statements. 484 * 485 * @return Set of table names in sorted order 486 */ 487 public Set<String> getTables() { 488 Set<String> tables = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); 489 for (int i = 0; i < statements.size(); i++) { 490 collectTablesFromStatement(statements.get(i), tables); 491 } 492 return tables; 493 } 494 495 /** 496 * Get the list of fields (table.column) found in the statements. 497 * 498 * @return Set of field names in sorted order 499 */ 500 public Set<String> getFields() { 501 Set<String> fields = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); 502 for (int i = 0; i < statements.size(); i++) { 503 collectDDLFieldsFromStatement(statements.get(i), fields); 504 } 505 collectResolvedFieldsGlobal(fields); 506 return fields; 507 } 508 509 // ========== Table Collection ========== 510 511 private void collectTablesFromStatement(Object stmt, Set<String> tables) { 512 if (!(stmt instanceof TCustomSqlStatement)) { 513 return; 514 } 515 516 TCustomSqlStatement customStmt = (TCustomSqlStatement) stmt; 517 518 // For top-level statements, use optimized single-pass collection 519 if (customStmt.getParentStmt() == null) { 520 collectTablesSinglePass(customStmt, tables); 521 } else { 522 // For nested statements (rare case), use simple collection 523 collectTablesFromStatementDirect(customStmt, tables); 524 } 525 } 526 527 /** 528 * Optimized single-pass table collection for top-level statements. 529 */ 530 private void collectTablesSinglePass(TCustomSqlStatement topStmt, Set<String> tables) { 531 // Use a queue to avoid deep recursion, and a set to avoid re-processing 532 java.util.Deque<TCustomSqlStatement> queue = new java.util.ArrayDeque<>(); 533 Set<TCustomSqlStatement> visited = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); 534 queue.add(topStmt); 535 536 while (!queue.isEmpty()) { 537 TCustomSqlStatement stmt = queue.poll(); 538 if (stmt == null || visited.contains(stmt)) continue; 539 visited.add(stmt); 540 541 // Handle UNION/combined queries 542 if (stmt instanceof TSelectSqlStatement) { 543 TSelectSqlStatement selectStmt = (TSelectSqlStatement) stmt; 544 if (selectStmt.isCombinedQuery()) { 545 if (selectStmt.getLeftStmt() != null) queue.add(selectStmt.getLeftStmt()); 546 if (selectStmt.getRightStmt() != null) queue.add(selectStmt.getRightStmt()); 547 continue; 548 } 549 } 550 551 // Handle CREATE TABLE target 552 if (stmt instanceof TCreateTableSqlStatement) { 553 TCreateTableSqlStatement createStmt = (TCreateTableSqlStatement) stmt; 554 if (createStmt.getTargetTable() != null) { 555 tables.add(getTableDisplayName(createStmt.getTargetTable())); 556 } 557 } 558 559 // Collect tables from this statement 560 for (int i = 0; i < stmt.tables.size(); i++) { 561 TTable table = stmt.tables.getTable(i); 562 if (table == null) continue; 563 564 // Add subquery's statement to queue 565 if (table.getTableType() == ETableSource.subquery) { 566 if (table.getSubquery() != null) queue.add(table.getSubquery()); 567 continue; 568 } 569 570 // Skip JOINs (they're containers, not actual tables) 571 if (table.getTableType() == ETableSource.join) continue; 572 573 // Skip function tables (table-valued functions) - they're included in Fields, not Tables 574 if (table.getTableType() == ETableSource.function) continue; 575 576 // Skip CTEs if showCTE is false 577 if (table.isCTEName() && !showCTE) continue; 578 579 // Skip virtual trigger tables (deleted/inserted) - resolved to trigger target during name resolution 580 // This is unconditional - virtual trigger tables should never appear in output 581 if (resolver != null && resolver.getVirtualTriggerTables().contains(table)) continue; 582 583 // Skip non-physical tables (record variables, etc.) if onlyPhysicalTables is true 584 if (shouldSkipTableForPhysicalFilter(table)) continue; 585 586 tables.add(getTableDisplayName(table)); 587 } 588 589 // Add nested statements to queue 590 for (int i = 0; i < stmt.getStatements().size(); i++) { 591 Object nested = stmt.getStatements().get(i); 592 if (nested instanceof TCustomSqlStatement) { 593 queue.add((TCustomSqlStatement) nested); 594 } 595 } 596 } 597 } 598 599 /** 600 * Direct table collection for a single statement (no deep recursion). 601 */ 602 private void collectTablesFromStatementDirect(TCustomSqlStatement customStmt, Set<String> tables) { 603 // Handle CREATE TABLE target 604 if (customStmt instanceof TCreateTableSqlStatement) { 605 TCreateTableSqlStatement createStmt = (TCreateTableSqlStatement) customStmt; 606 if (createStmt.getTargetTable() != null) { 607 tables.add(getTableDisplayName(createStmt.getTargetTable())); 608 } 609 } 610 611 // Collect tables from statement 612 for (int i = 0; i < customStmt.tables.size(); i++) { 613 TTable table = customStmt.tables.getTable(i); 614 if (table == null) continue; 615 if (table.getTableType() == ETableSource.subquery) continue; 616 if (table.getTableType() == ETableSource.join) continue; 617 if (table.getTableType() == ETableSource.function) continue; 618 if (table.isCTEName() && !showCTE) continue; 619 // Skip virtual trigger tables (deleted/inserted) - resolved to trigger target during name resolution 620 if (resolver != null && resolver.getVirtualTriggerTables().contains(table)) continue; 621 if (shouldSkipTableForPhysicalFilter(table)) continue; 622 623 tables.add(getTableDisplayName(table)); 624 } 625 } 626 627 private String getTableDisplayName(TTable table) { 628 String baseName; 629 if (table.getTableType() == ETableSource.subquery) { 630 return "(subquery, alias:" + table.getAliasName() + ")"; 631 } else if (table.getTableType() == ETableSource.unnest) { 632 String alias = table.getAliasName(); 633 return (alias != null && !alias.isEmpty() ? alias : "") + "(unnest table)"; 634 } else if (table.getTableType() == ETableSource.pivoted_table) { 635 // For Tables section, return just the table name without prefix 636 baseName = table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName(); 637 } else if (table.getTableType() == ETableSource.function) { 638 return "(table-valued function:" + normalizeTableName(table.getTableName()) + ")"; 639 } else if (table.getTableType() == ETableSource.lateralView) { 640 return "(lateral_view:" + normalizeTableName(table.getTableName()) + ")"; 641 } else if (table.isCTEName()) { 642 baseName = table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName(); 643 } else { 644 baseName = table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName(); 645 } 646 647 // Append table effect type if enabled and table is a base table 648 if (showTableEffect && table.isBaseTable()) { 649 return baseName + "(" + table.getEffectType() + ")"; 650 } 651 return baseName; 652 } 653 654 /** 655 * Check if a table is a physical/base table based on table type information 656 * already determined by TSQLResolver2. 657 * 658 * <p>This method only uses semantic information available from the table object, 659 * not name-based heuristics. Non-physical tables include:</p> 660 * <ul> 661 * <li>Subqueries (tableType = subquery)</li> 662 * <li>JOINs (tableType = join)</li> 663 * <li>Table functions (tableType = function)</li> 664 * <li>CTEs (isCTEName = true)</li> 665 * </ul> 666 * 667 * <p>Note: Virtual trigger tables (deleted/inserted in SQL Server triggers) are 668 * filtered unconditionally in the table collection methods, not here.</p> 669 * 670 * @param table The TTable to check 671 * @return true if the table is a physical database table 672 */ 673 private boolean isPhysicalTable(TTable table) { 674 if (table == null) return false; 675 676 // Only objectname type tables can be physical tables 677 // This filters out subqueries, joins, functions, pivoted tables, unnest, etc. 678 if (table.getTableType() != ETableSource.objectname) { 679 return false; 680 } 681 682 // Tables from CTE are considered virtual, not physical 683 if (table.isCTEName()) { 684 return false; 685 } 686 687 String tableName = table.getName(); 688 if (tableName == null || tableName.isEmpty()) { 689 return false; 690 } 691 692 return true; 693 } 694 695 /** 696 * Check if a table should be skipped based on onlyPhysicalTables and the show* options. 697 * 698 * <p>When onlyPhysicalTables=true, tables are skipped unless:</p> 699 * <ul> 700 * <li>They are physical tables (isPhysicalTable returns true)</li> 701 * <li>They are UNNEST tables and showUnnest=true</li> 702 * <li>They are PIVOT tables and showPivotTable=true</li> 703 * <li>They are LATERAL VIEW tables and showLateralView=true</li> 704 * </ul> 705 * 706 * @param table The TTable to check 707 * @return true if the table should be skipped, false if it should be included 708 */ 709 private boolean shouldSkipTableForPhysicalFilter(TTable table) { 710 if (!onlyPhysicalTables) { 711 return false; // Not filtering, don't skip 712 } 713 714 // CTAS target tables are DDL targets, not existing physical tables 715 // Skip them when onlyPhysicalTables=true 716 if (buildResult != null && buildResult.isCTASTargetTable(table)) { 717 return true; 718 } 719 720 // Physical tables are always included when onlyPhysicalTables=true 721 if (isPhysicalTable(table)) { 722 return false; 723 } 724 725 // Check if table type should be included based on show* options 726 ETableSource tableType = table.getTableType(); 727 if (tableType == ETableSource.unnest && showUnnest) { 728 return false; 729 } 730 if (tableType == ETableSource.pivoted_table && showPivotTable) { 731 return false; 732 } 733 if (tableType == ETableSource.lateralView && showLateralView) { 734 return false; 735 } 736 737 // All other non-physical tables are skipped when onlyPhysicalTables=true 738 return true; 739 } 740 741 // ========== Field Collection ========== 742 743 /** 744 * Collect DDL fields (constraints, CTAS targets) from statements. 745 * This only handles DDL-specific columns not covered by the resolver's column list. 746 */ 747 private void collectDDLFieldsFromStatement(Object stmt, Set<String> fields) { 748 if (!(stmt instanceof TCustomSqlStatement)) { 749 return; 750 } 751 752 TCustomSqlStatement customStmt = (TCustomSqlStatement) stmt; 753 754 // Handle UNION/combined queries - follow left chain iteratively 755 if (customStmt instanceof TSelectSqlStatement) { 756 TSelectSqlStatement selectStmt = (TSelectSqlStatement) customStmt; 757 while (selectStmt.isCombinedQuery()) { 758 collectDDLFieldsFromStatement(selectStmt.getRightStmt(), fields); 759 selectStmt = selectStmt.getLeftStmt(); 760 } 761 if (selectStmt != customStmt) { 762 collectDDLFieldsFromStatement(selectStmt, fields); 763 return; 764 } 765 } 766 767 // Handle CREATE TABLE - collect constraint columns only 768 // Note: CTAS target columns (tuple aliases, standard aliases, simple column refs) 769 // are now fully handled by ScopeBuilder.preVisit(TResultColumn) and included in 770 // allColumnReferences. They will be output via collectResolvedFieldsGlobal(). 771 if (customStmt instanceof TCreateTableSqlStatement) { 772 TCreateTableSqlStatement createStmt = (TCreateTableSqlStatement) customStmt; 773 // Collect constraint columns (PRIMARY KEY, UNIQUE, FOREIGN KEY references) 774 collectConstraintColumns(createStmt, fields); 775 } 776 777 // Handle ALTER TABLE - collect constraint columns 778 if (customStmt instanceof TAlterTableStatement) { 779 TAlterTableStatement alterStmt = (TAlterTableStatement) customStmt; 780 collectAlterTableConstraintColumns(alterStmt, fields); 781 } 782 783 // Recurse for nested statements to find deeper DDLs (e.g. inside blocks) 784 for (int i = 0; i < customStmt.getStatements().size(); i++) { 785 Object nestedStmt = customStmt.getStatements().get(i); 786 if (nestedStmt instanceof TCustomSqlStatement) { 787 collectDDLFieldsFromStatement(nestedStmt, fields); 788 } 789 } 790 } 791 792 /** 793 * Optimized global field collection for all resolved columns. 794 * Iterates through all column references once globally, calculating table prefixes on-the-fly. 795 * This avoids the O(N*M) complexity of per-statement AST traversal for deep nesting. 796 */ 797 private void collectResolvedFieldsGlobal(Set<String> fields) { 798 if (buildResult == null) return; 799 800 // Get vendor info once 801 TSQLResolverConfig resolverConfig = resolver != null ? resolver.getConfig() : null; 802 EDbVendor vendor = resolverConfig != null ? resolverConfig.getVendor() : null; 803 if (vendor == null && statements != null && statements.size() > 0) { 804 vendor = statements.get(0).dbvendor; 805 } 806 boolean isSparkOrHive = vendor == EDbVendor.dbvsparksql || vendor == EDbVendor.dbvhive; 807 boolean isSnowflake = vendor == EDbVendor.dbvsnowflake; 808 boolean isSqlServer = vendor == EDbVendor.dbvmssql || vendor == EDbVendor.dbvazuresql; 809 810 811 // Single pass through all column references in the entire session 812 java.util.List<TObjectName> allColumnReferences = buildResult.getAllColumnReferences(); 813 for (TObjectName col : allColumnReferences) { 814 if (col == null) continue; 815 if (col.getValidate_column_status() == TBaseType.MARKED_NOT_A_COLUMN_IN_COLUMN_RESOLVER) continue; 816 if (col.getDbObjectType() == EDbObjectType.column_alias) continue; 817 818 // Get column name with vendor-specific handling 819 String columnName = getColumnDisplayName(col, isSparkOrHive, isSnowflake, isSqlServer); 820 821 if (columnName == null || columnName.isEmpty()) continue; 822 823 // Handle star columns specially when listStarColumn=false: 824 // - We need to process unqualified stars (*) for PIVOT/UNNEST column expansion 825 // - Other star columns (qualified like table.*, OUTPUT DELETED.*) should be skipped 826 if ("*".equals(columnName) && !listStarColumn) { 827 // Only process unqualified stars (SELECT *) for PIVOT/UNNEST expansion 828 if (!isUnqualifiedStar(col)) { 829 continue; // Skip qualified stars when listStarColumn=false 830 } 831 // For unqualified stars, continue processing to reach expandStarToAllTables() 832 } 833 834 // Skip ROWNUM pseudo-column when filtering to physical tables 835 // Note: ROWID is a real column stored in the table, but ROWNUM is a query-result pseudo-column 836 if (onlyPhysicalTables && "ROWNUM".equalsIgnoreCase(columnName)) continue; 837 838 // Get source table 839 TTable sourceTable = col.getSourceTable(); 840 841 // Handle star columns specially 842 if ("*".equals(columnName)) { 843 if (!shouldIncludeStarColumn(col, sourceTable)) continue; 844 845 // For unqualified star columns (SELECT *), expand to all tables in FROM clause 846 // Note: We use expandStarToAllTables() which adds table.* entries for each table 847 // The core resolver's star expansion (attributeNodesDerivedFromFromClause) is for 848 // internal resolution purposes, not for output formatting 849 if (isUnqualifiedStar(col)) { 850 expandStarToAllTables(col, fields); 851 continue; 852 } 853 } 854 855 // Check if this is a qualified star column with expanded attributes from push-down 856 // These are columns like "src.*" that have been expanded via TSQLResolver2's 857 // star column push-down algorithm 858 // Note: Use col.toString() instead of columnName, because columnName (from getColumnNameOnly) 859 // would be just "*", but we need "src.*" to detect qualified stars 860 String colFullString = col.toString(); 861 if (colFullString != null && colFullString.endsWith("*") && !colFullString.equals("*")) { 862 java.util.ArrayList<gudusoft.gsqlparser.TAttributeNode> expandedAttrs = 863 col.getAttributeNodesDerivedFromFromClause(); 864 if (expandedAttrs != null && !expandedAttrs.isEmpty()) { 865 // Only process if the star column's sourceTable is a physical table (not subquery/CTE/pivot) 866 // This ensures we output with the correct base table prefix 867 // PIVOT tables are excluded since their columns are output individually via PivotNamespace 868 if (sourceTable != null && 869 sourceTable.getTableType() != ETableSource.subquery && 870 sourceTable.getTableType() != ETableSource.openquery && 871 sourceTable.getTableType() != ETableSource.join && 872 sourceTable.getTableType() != ETableSource.pivoted_table && 873 !(sourceTable.isCTEName() && !showCTE) && 874 !shouldSkipTableForPhysicalFilter(sourceTable)) { 875 876 String tablePrefix = getFieldTablePrefix(sourceTable); 877 if (tablePrefix != null) { 878 // Output the star column itself if listStarColumn is true 879 if (listStarColumn) { 880 fields.add(tablePrefix + ".*"); 881 } 882 883 // Output the expanded individual columns using the star's source table 884 // This ensures columns pushed down through the star get the correct table prefix 885 // BUT skip columns that are explicitly resolved elsewhere (to a different table) 886 for (gudusoft.gsqlparser.TAttributeNode attr : expandedAttrs) { 887 if (attr == null) continue; 888 String attrName = attr.getName(); 889 if (attrName != null && !attrName.isEmpty() && !attrName.endsWith("*")) { 890 // Extract just the column name 891 String justColumnName = attrName; 892 int dotIdx = attrName.lastIndexOf('.'); 893 if (dotIdx >= 0) { 894 justColumnName = attrName.substring(dotIdx + 1); 895 } 896 897 // Check if this column is explicitly resolved to a DIFFERENT PHYSICAL table 898 // in the column references list - if so, skip it to avoid duplicates 899 // Note: We use getFinalTable() to trace through subqueries to the actual physical table 900 // This handles cases like: SELECT al1.COL1 FROM (SELECT t1.COL1, t2.* FROM T1 t1 JOIN T2 t2) al1 901 // where al1.COL1 should resolve to T1.COL1 (explicit column), not T2.COL1 (from star) 902 boolean resolvedToOtherPhysicalTable = false; 903 for (TObjectName otherCol : allColumnReferences) { 904 if (otherCol == null || otherCol == col) continue; 905 String otherColName = otherCol.getColumnNameOnly(); 906 if (otherColName != null && otherColName.equalsIgnoreCase(justColumnName)) { 907 // Get the final physical table through ColumnSource or sourceTable 908 TTable otherFinalTable = null; 909 ColumnSource otherColSource = otherCol.getColumnSource(); 910 if (otherColSource != null) { 911 otherFinalTable = otherColSource.getFinalTable(); 912 } 913 // Fallback to sourceTable if ColumnSource doesn't have finalTable 914 if (otherFinalTable == null) { 915 otherFinalTable = otherCol.getSourceTable(); 916 } 917 918 if (otherFinalTable != null && otherFinalTable != sourceTable) { 919 // Check if the other source is a physical table (not CTE/subquery) 920 if (otherFinalTable.getTableType() != ETableSource.subquery && 921 otherFinalTable.getTableType() != ETableSource.openquery && 922 otherFinalTable.getTableType() != ETableSource.join && 923 !otherFinalTable.isCTEName()) { 924 // Resolved to a different physical table - skip 925 resolvedToOtherPhysicalTable = true; 926 break; 927 } 928 } 929 } 930 } 931 932 if (!resolvedToOtherPhysicalTable) { 933 fields.add(tablePrefix + "." + normalizeColumnName(justColumnName)); 934 } 935 } 936 } 937 } 938 } 939 continue; // Star column fully handled 940 } 941 } 942 943 // Use ColumnSource to determine final table (if available) 944 ColumnSource source = col.getColumnSource(); 945 if (source != null) { 946 // Check if the resolution is ambiguous - if so, don't use getFinalTable() 947 // because getColumnSource() returns the first candidate but we shouldn't pick one 948 gudusoft.gsqlparser.resolver2.model.ResolutionResult colResolution = col.getResolution(); 949 boolean isResolutionAmbiguous = colResolution != null && colResolution.isAmbiguous(); 950 951 // For columns from UNION queries, get all tables and add an entry for each 952 java.util.List<TTable> allFinalTables = source.getAllFinalTables(); 953 if (allFinalTables != null && allFinalTables.size() > 1) { 954 // Multiple tables (UNION query) - add entry for each table 955 for (TTable unionTable : allFinalTables) { 956 if (unionTable == null) continue; 957 if (unionTable.getTableType() == ETableSource.subquery) continue; 958 if (unionTable.getTableType() == ETableSource.openquery) continue; 959 if (unionTable.getTableType() == ETableSource.join) continue; 960 if (unionTable.isCTEName() && !showCTE) continue; 961 if (shouldSkipTableForPhysicalFilter(unionTable)) continue; 962 963 String tablePrefix = getFieldTablePrefix(unionTable); 964 if (tablePrefix != null) { 965 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 966 String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : ""; 967 fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr); 968 } 969 } 970 continue; // Already handled this column for all tables 971 } 972 973 // Check if this is a column alias tracing (e.g., "col AS alias") 974 // When getFinalColumnName() is non-null, the column name is an alias. 975 // The original column reference inside the subquery already produces the 976 // correct entry in the output, so skip getFinalTable() to avoid duplicates. 977 String finalColName = source.getFinalColumnName(); 978 TTable finalTable = source.getFinalTable(); 979 // Only use finalTable if the resolution is NOT ambiguous 980 // For ambiguous resolutions, let the orphan handling deal with it 981 if (finalTable != null && !isResolutionAmbiguous && finalColName == null) { 982 sourceTable = finalTable; 983 } else if (sourceTable == null && !source.isAmbiguous() && !isResolutionAmbiguous) { 984 // ColumnSource exists but getFinalTable() is null AND sourceTable not set 985 // AND not an ambiguous multi-table case 986 // Check if we have candidate tables (e.g., from UNION branches) 987 // If so, don't skip - let the orphan handling output all candidates 988 java.util.List<TTable> candidates = source.getCandidateTables(); 989 if (candidates == null || candidates.isEmpty()) { 990 // No candidates either - this is truly a calculated expression 991 // Skip these - they don't trace to physical tables 992 continue; 993 } 994 // Has candidate tables - fall through to orphan handling which will output them 995 } 996 // If getFinalTable() is null but sourceTable is set (e.g., UPDATE SET clause), 997 // use sourceTable as fallback 998 } 999 // If ColumnSource is null, fall back to col.getSourceTable() 1000 // This handles INSERT columns, direct star columns, and other cases 1001 // where the resolver doesn't set ColumnSource but the parser did resolve sourceTable 1002 1003 // Handle orphan columns (no definitive source table) 1004 // Also handle columns that point to a subquery but couldn't be traced to a physical table 1005 boolean isOrphan = (sourceTable == null); 1006 boolean isUnresolvedSubqueryColumn = false; 1007 1008 // Check if resolution or ColumnSource indicates ambiguity (multiple candidate tables) 1009 // Note: col.getResolution().isAmbiguous() checks the resolution result 1010 // source.isAmbiguous() checks if the ColumnSource has multiple candidate tables 1011 // For ambiguous resolutions, getColumnSource() returns the first candidate which won't have isAmbiguous()=true 1012 gudusoft.gsqlparser.resolver2.model.ResolutionResult resolvedResult = col.getResolution(); 1013 if ((resolvedResult != null && resolvedResult.isAmbiguous()) || 1014 (source != null && source.isAmbiguous())) { 1015 isUnresolvedSubqueryColumn = true; 1016 } else if (sourceTable != null && sourceTable.getTableType() == ETableSource.subquery && source == null) { 1017 // Check if this column is explicitly defined in the subquery's SELECT list 1018 // If so, it's a calculated/aliased column - skip it 1019 // If not, it's through an ambiguous star - mark as "missed" 1020 TSelectSqlStatement subquery = sourceTable.getSubquery(); 1021 if (subquery != null && subquery.getResultColumnList() != null) { 1022 boolean foundInSubquery = false; 1023 boolean hasAmbiguousStar = false; 1024 int tableCount = 0; 1025 1026 // Count tables in FROM clause 1027 if (subquery.tables != null) { 1028 tableCount = subquery.tables.size(); 1029 } 1030 1031 TResultColumnList resultCols = subquery.getResultColumnList(); 1032 for (int i = 0; i < resultCols.size(); i++) { 1033 TResultColumn rc = resultCols.getResultColumn(i); 1034 if (rc == null) continue; 1035 1036 String rcStr = rc.toString().trim(); 1037 // Check if it's an unqualified star with multiple tables 1038 if (rcStr.equals("*") && tableCount > 1) { 1039 hasAmbiguousStar = true; 1040 continue; 1041 } 1042 1043 // Check for explicit column/alias name 1044 String rcName = null; 1045 if (rc.getAliasClause() != null && rc.getAliasClause().getAliasName() != null) { 1046 rcName = rc.getAliasClause().getAliasName().toString(); 1047 } else if (rc.getExpr() != null && 1048 rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t && 1049 rc.getExpr().getObjectOperand() != null) { 1050 rcName = rc.getExpr().getObjectOperand().getColumnNameOnly(); 1051 } 1052 1053 if (rcName != null && nameMatcher.matches(rcName, columnName)) { 1054 foundInSubquery = true; 1055 break; 1056 } 1057 } 1058 1059 // Column is unresolved only if it's NOT found in explicit columns AND there's an ambiguous star 1060 isUnresolvedSubqueryColumn = !foundInSubquery && hasAmbiguousStar; 1061 } 1062 } 1063 1064 if (isOrphan || isUnresolvedSubqueryColumn) { 1065 // Check for candidate tables (e.g., from UNION branches) 1066 TTableList candidates = col.getCandidateTables(); 1067 boolean hasCandidates = candidates != null && candidates.size() > 0; 1068 1069 // If we have multiple candidate tables from UNION/CTE propagation, output all of them 1070 // For regular ambiguous columns (not from UNION), fall through to "missed" handling 1071 if (hasCandidates && candidates.size() > 1 && sourceTable == null && col.isCandidatesFromUnion()) { 1072 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 1073 String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : ""; 1074 for (int ci = 0; ci < candidates.size(); ci++) { 1075 TTable candidateTable = candidates.getTable(ci); 1076 if (candidateTable != null) { 1077 // Filter out tables that shouldn't be reported 1078 if (candidateTable.getTableType() == ETableSource.subquery) continue; 1079 if (candidateTable.getTableType() == ETableSource.openquery) continue; 1080 if (candidateTable.getTableType() == ETableSource.join) continue; 1081 if (candidateTable.isCTEName() && !showCTE) continue; 1082 if (shouldSkipTableForPhysicalFilter(candidateTable)) continue; 1083 1084 String tablePrefix = getFieldTablePrefix(candidateTable); 1085 if (tablePrefix != null) { 1086 fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr); 1087 } 1088 } 1089 } 1090 continue; // Already handled all candidates 1091 } 1092 1093 // Orphan column handling: 1094 // 1. Has candidates AND linkOrphanColumnToFirstTable=true → use first candidate table 1095 // 2. No candidates AND linkOrphanColumnToFirstTable=true → use statement's first physical table 1096 // 3. linkOrphanColumnToFirstTable=false → output missed.column 1097 // 4. No table available at all → output missed.column 1098 // Note: onlyPhysicalTables does NOT affect orphan column output 1099 if (sourceTable == null) { 1100 // Check if the column resolved to a CTE - if so, skip it when showCTE=false 1101 if (source != null) { 1102 TTable finalTable = source.getFinalTable(); 1103 if (finalTable != null && finalTable.isCTEName() && !showCTE) { 1104 continue; // Skip CTE columns when not showing CTEs 1105 } 1106 } 1107 1108 if (hasCandidates && linkOrphanColumnToFirstTable) { 1109 // Has candidates and linkOrphanColumnToFirstTable=true → use first candidate 1110 sourceTable = candidates.getTable(0); 1111 } else if (linkOrphanColumnToFirstTable) { 1112 // No candidates but linkOrphanColumnToFirstTable=true 1113 // → try statement's first physical table (matching TGetTableColumn behavior) 1114 TCustomSqlStatement ownStmt = col.getOwnStmt(); 1115 // If ownStmt is null, try to find containing statement from AST 1116 if (ownStmt == null) { 1117 ownStmt = findContainingStatement(col); 1118 } 1119 if (ownStmt != null) { 1120 TTable firstPhysicalTable = ownStmt.getFirstPhysicalTable(); 1121 if (firstPhysicalTable != null) { 1122 sourceTable = firstPhysicalTable; 1123 } 1124 } 1125 if (sourceTable == null) { 1126 // Still no table → output as missed 1127 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 1128 String positionStr = getColumnPositionStr(col); 1129 fields.add("missed." + columnName + positionStr + datatypeStr); 1130 continue; 1131 } 1132 } else { 1133 // linkOrphanColumnToFirstTable=false → output as missed 1134 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 1135 String positionStr = getColumnPositionStr(col); 1136 fields.add("missed." + columnName + positionStr + datatypeStr); 1137 continue; 1138 } 1139 } 1140 1141 // For unresolved subquery columns (ambiguous), output as missed if linkOrphanColumnToFirstTable is false 1142 if (isUnresolvedSubqueryColumn && !linkOrphanColumnToFirstTable) { 1143 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 1144 String positionStr = getColumnPositionStr(col); 1145 fields.add("missed." + columnName + positionStr + datatypeStr); 1146 continue; 1147 } 1148 } 1149 1150 // Filter out tables that shouldn't be reported 1151 if (sourceTable.getTableType() == ETableSource.subquery) continue; 1152 if (sourceTable.getTableType() == ETableSource.openquery) continue; 1153 if (sourceTable.getTableType() == ETableSource.join) continue; 1154 if (sourceTable.isCTEName() && !showCTE) continue; 1155 1156 // Skip non-physical tables (record variables, etc.) if onlyPhysicalTables is true 1157 if (shouldSkipTableForPhysicalFilter(sourceTable)) continue; 1158 1159 // Skip star columns for PIVOT tables - individual columns are output via PivotNamespace 1160 if ("*".equals(columnName) && sourceTable.getTableType() == ETableSource.pivoted_table) continue; 1161 1162 // Calculate table prefix on-the-fly (avoids pre-building map via AST traversal) 1163 String tablePrefix = getFieldTablePrefix(sourceTable); 1164 if (tablePrefix != null) { 1165 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 1166 String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : ""; 1167 fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr); 1168 1169 // For star columns on UNNEST tables, expand 1170 if ("*".equals(columnName) && sourceTable.getTableType() == ETableSource.unnest) { 1171 expandUnnestStarColumns(sourceTable, tablePrefix, fields); 1172 } 1173 } 1174 } 1175 1176 // NOTE: TD_UNPIVOT columns are now collected by ScopeBuilder.processTDUnpivotTable() 1177 // which adds them to allColumnReferences. No need for fallback here. 1178 } 1179 1180 /** 1181 * Get the display name for a column, with vendor-specific handling. 1182 * 1183 * For STRUCT field access (BigQuery/Snowflake/SparkSQL), this uses the 1184 * ColumnSource.exposedName which contains the base column name, rather than 1185 * getColumnNameOnly() which returns the nested field name. 1186 */ 1187 private String getColumnDisplayName(TObjectName col, boolean isSparkOrHive, boolean isSnowflake, boolean isSqlServer) { 1188 String colString = col.toString(); 1189 String colNameOnly = col.getColumnNameOnly(); 1190 1191 // For STRUCT field access (detected by evidence marker), use ColumnSource.exposedName 1192 // which contains the base column name (e.g., "customer" for "customer.customer_id") 1193 ColumnSource columnSource = col.getColumnSource(); 1194 if (columnSource != null && "struct_field_access".equals(columnSource.getEvidence())) { 1195 String exposedName = columnSource.getExposedName(); 1196 if (exposedName != null && !exposedName.isEmpty()) { 1197 colNameOnly = exposedName; 1198 } 1199 } 1200 1201 if (isSqlServer && colNameOnly != null && 1202 colNameOnly.startsWith("[") && colNameOnly.endsWith("]") && 1203 colNameOnly.length() > 2) { 1204 // Normalize SQL Server bracketed identifiers to avoid duplicates like [col3] and col3 1205 // The TreeSet uses case-insensitive ordering but doesn't handle bracket normalization 1206 return normalizeColumnName(colNameOnly); 1207 } else if (isSparkOrHive && colString != null && colNameOnly != null && 1208 !colString.contains(".") && 1209 colString.startsWith("`") && colString.endsWith("`") && 1210 colString.length() > 2) { 1211 // Use DisplayNameNormalizer to handle backtick-quoted identifiers 1212 // This respects displayNameMode (DISPLAY strips quotes, SQL_RENDER preserves them) 1213 return normalizeColumnName(colString); 1214 } else if (isSnowflake && colString != null && colNameOnly != null && 1215 !colString.contains(".") && 1216 colString.startsWith("\"") && colString.endsWith("\"") && 1217 colString.length() > 2) { 1218 // Use DisplayNameNormalizer to handle double-quoted identifiers 1219 // This respects displayNameMode (DISPLAY strips quotes, SQL_RENDER preserves them) 1220 return normalizeColumnName(colString); 1221 } else if (isSnowflake && colString != null && colString.startsWith("$")) { 1222 // Snowflake stage file positional column (e.g., $1, $1:apMac) 1223 // Extract JSON path if present 1224 int colonIndex = colString.indexOf(':'); 1225 if (colonIndex > 0) { 1226 // Has JSON path - extract it (e.g., "$1:apMac" -> ":apMac") 1227 String jsonPath = colString.substring(colonIndex); 1228 return jsonPath; 1229 } else { 1230 // Simple positional column (e.g., "$1") - return as-is 1231 return colString; 1232 } 1233 } else { 1234 return normalizeColumnName(colNameOnly); 1235 } 1236 } 1237 1238 /** 1239 * Check if a star column should be included in output. 1240 */ 1241 private boolean shouldIncludeStarColumn(TObjectName col, TTable sourceTable) { 1242 // Skip if the star's source table reference is a CTE 1243 if (sourceTable != null && sourceTable.isCTEName()) return false; 1244 1245 // Skip if the star was resolved through a CTE namespace 1246 ColumnSource starSource = col.getColumnSource(); 1247 if (starSource != null && starSource.getSourceNamespace() instanceof CTENamespace) return false; 1248 1249 // Skip if the star's source table is a PIVOT/UNPIVOT table 1250 // PIVOT columns are output individually via PivotNamespace, not as table.* 1251 if (sourceTable != null && sourceTable.getTableType() == ETableSource.pivoted_table) return false; 1252 1253 // Find the SELECT statement containing this star column 1254 TParseTreeNode parent = col.getParentObjectName(); 1255 TSelectSqlStatement starSelectStmt = null; 1256 while (parent != null) { 1257 if (parent instanceof TSelectSqlStatement) { 1258 starSelectStmt = (TSelectSqlStatement) parent; 1259 break; 1260 } 1261 parent = parent.getParentObjectName(); 1262 } 1263 1264 if (starSelectStmt != null) { 1265 // Skip if in a nested procedural statement (e.g., inside BEGIN/END block) 1266 if (starSelectStmt.getParentStmt() != null) return false; 1267 1268 // Check if sourceTable is in the star's SELECT statement 1269 // This ensures we only report stars that trace to tables in their own SELECT 1270 if (sourceTable != null) { 1271 boolean foundInSameStmt = false; 1272 for (int ti = 0; ti < starSelectStmt.tables.size(); ti++) { 1273 if (starSelectStmt.tables.getTable(ti) == sourceTable) { 1274 foundInSameStmt = true; 1275 break; 1276 } 1277 } 1278 if (!foundInSameStmt) return false; 1279 } 1280 } 1281 1282 return true; 1283 } 1284 1285 /** 1286 * Check if a star column is unqualified (SELECT * vs SELECT t.*) 1287 */ 1288 private boolean isUnqualifiedStar(TObjectName col) { 1289 // Check if the column has a table prefix 1290 String colStr = col.toString(); 1291 // Unqualified star is just "*" without any prefix 1292 return "*".equals(colStr.trim()); 1293 } 1294 1295 /** 1296 * Find the statement that directly contains the given table in its tables list. 1297 * Returns the most specific (innermost) statement that has the table. 1298 */ 1299 private TCustomSqlStatement findStatementWithTable(TCustomSqlStatement stmt, TTable targetTable) { 1300 if (stmt == null) return null; 1301 1302 // Check if this statement directly contains the table 1303 if (stmt.tables != null) { 1304 for (int i = 0; i < stmt.tables.size(); i++) { 1305 if (stmt.tables.getTable(i) == targetTable) { 1306 return stmt; 1307 } 1308 } 1309 } 1310 1311 // Check nested statements (subqueries, etc.) 1312 if (stmt.getStatements() != null) { 1313 for (int i = 0; i < stmt.getStatements().size(); i++) { 1314 Object nested = stmt.getStatements().get(i); 1315 if (nested instanceof TCustomSqlStatement) { 1316 TCustomSqlStatement found = findStatementWithTable((TCustomSqlStatement) nested, targetTable); 1317 if (found != null) return found; 1318 } 1319 } 1320 } 1321 1322 // For SELECT statements, iteratively check UNION branches 1323 if (stmt instanceof TSelectSqlStatement) { 1324 TSelectSqlStatement selectStmt = (TSelectSqlStatement) stmt; 1325 if (selectStmt.isCombinedQuery()) { 1326 Deque<TSelectSqlStatement> stack = new ArrayDeque<>(); 1327 stack.push(selectStmt); 1328 while (!stack.isEmpty()) { 1329 TSelectSqlStatement current = stack.pop(); 1330 if (current.isCombinedQuery()) { 1331 if (current.getRightStmt() != null) stack.push(current.getRightStmt()); 1332 if (current.getLeftStmt() != null) stack.push(current.getLeftStmt()); 1333 } else { 1334 TCustomSqlStatement found = findStatementWithTable(current, targetTable); 1335 if (found != null) return found; 1336 } 1337 } 1338 } 1339 } 1340 1341 return null; 1342 } 1343 1344 /** 1345 * Expand unqualified star column to all tables in the FROM clause 1346 */ 1347 private void expandStarToAllTables(TObjectName col, Set<String> fields) { 1348 // Find the containing statement - try multiple approaches 1349 TCustomSqlStatement containingStmt = null; 1350 1351 // Approach 1 (PRIORITY): Find via TResultColumn parent 1352 // For star columns in SELECT list, traverse up to find the TResultColumn, 1353 // then get the containing statement from the result column list's parent. 1354 // This is more reliable than parent chain traversal because the star column's 1355 // TObjectName parent may have been modified during resolution. 1356 TParseTreeNode parent = col.getParentObjectName(); 1357 while (parent != null) { 1358 if (parent instanceof gudusoft.gsqlparser.nodes.TResultColumn) { 1359 // Found the result column - now get its parent statement 1360 gudusoft.gsqlparser.nodes.TResultColumn rc = (gudusoft.gsqlparser.nodes.TResultColumn) parent; 1361 TParseTreeNode rcParent = rc.getParentObjectName(); 1362 while (rcParent != null) { 1363 if (rcParent instanceof TCustomSqlStatement) { 1364 containingStmt = (TCustomSqlStatement) rcParent; 1365 break; 1366 } 1367 rcParent = rcParent.getParentObjectName(); 1368 } 1369 break; 1370 } 1371 if (parent instanceof TCustomSqlStatement) { 1372 containingStmt = (TCustomSqlStatement) parent; 1373 break; 1374 } 1375 parent = parent.getParentObjectName(); 1376 } 1377 1378 // Approach 2 (FALLBACK): Try to find via source table's statement 1379 if (containingStmt == null) { 1380 TTable sourceTable = col.getSourceTable(); 1381 if (sourceTable != null && sourceTable.getGsqlparser() != null) { 1382 // Find the statement containing this source table 1383 for (int i = 0; i < statements.size(); i++) { 1384 TCustomSqlStatement stmt = statements.get(i); 1385 containingStmt = findStatementWithTable(stmt, sourceTable); 1386 if (containingStmt != null) break; 1387 } 1388 } 1389 } 1390 1391 if (containingStmt == null || containingStmt.tables == null) { 1392 // Fallback: use first statement if available 1393 if (statements != null && statements.size() > 0) { 1394 TCustomSqlStatement firstStmt = statements.get(0); 1395 if (firstStmt != null && firstStmt.tables != null) { 1396 containingStmt = firstStmt; 1397 } 1398 } 1399 if (containingStmt == null || containingStmt.tables == null) { 1400 return; 1401 } 1402 } 1403 1404 // Add star column for each physical table in the FROM clause 1405 for (int i = 0; i < containingStmt.tables.size(); i++) { 1406 TTable table = containingStmt.tables.getTable(i); 1407 if (table == null) continue; 1408 1409 // Skip non-physical table types 1410 if (table.getTableType() == ETableSource.subquery) continue; 1411 if (table.getTableType() == ETableSource.openquery) continue; 1412 if (table.getTableType() == ETableSource.join) continue; 1413 if (table.isCTEName() && !showCTE) continue; 1414 1415 // Skip implicit lateral derived tables - they shouldn't have star columns 1416 if (table.getEffectType() == ETableEffectType.tetImplicitLateralDerivedTable) continue; 1417 1418 // Skip non-physical tables if onlyPhysicalTables is true 1419 // Note: PIVOT/UNPIVOT and UNNEST column expansion is now handled by the core resolver 1420 // This is a fallback path when the core resolver hasn't expanded the star 1421 if (shouldSkipTableForPhysicalFilter(table)) continue; 1422 1423 String tablePrefix = getFieldTablePrefix(table); 1424 if (tablePrefix != null) { 1425 // For UNNEST tables, add star if requested, then expand individual columns 1426 if (table.getTableType() == ETableSource.unnest) { 1427 if (listStarColumn) { 1428 fields.add(tablePrefix + ".*"); 1429 } 1430 expandUnnestStarColumns(table, tablePrefix, fields); 1431 } else if (table.getTableType() == ETableSource.pivoted_table) { 1432 // For PIVOT/UNPIVOT tables, output the generated columns from PivotNamespace 1433 // These are virtual tables so we don't output table.*, only individual columns 1434 if (buildResult != null) { 1435 INamespace namespace = buildResult.getNamespaceForTable(table); 1436 if (namespace != null) { 1437 Map<String, ColumnSource> columnSources = namespace.getAllColumnSources(); 1438 if (columnSources != null && !columnSources.isEmpty()) { 1439 for (String columnName : columnSources.keySet()) { 1440 if (columnName != null && !columnName.isEmpty()) { 1441 fields.add(tablePrefix + "." + normalizeColumnName(columnName)); 1442 } 1443 } 1444 } 1445 } 1446 } 1447 } else { 1448 // For regular tables, add star column if listStarColumn is true 1449 if (listStarColumn) { 1450 fields.add(tablePrefix + ".*"); 1451 } 1452 } 1453 } 1454 } 1455 } 1456 1457 private String getFieldTablePrefix(TTable table) { 1458 if (table.getTableType() == ETableSource.subquery) { 1459 return "(subquery, alias:" + table.getAliasName() + ")"; 1460 } else if (table.getTableType() == ETableSource.unnest) { 1461 String alias = table.getAliasName(); 1462 return "(unnest-table:" + (alias != null ? alias : "") + ")"; 1463 } else if (table.getTableType() == ETableSource.pivoted_table) { 1464 // Check if this is UNPIVOT or PIVOT 1465 boolean isUnpivot = false; 1466 // First try to get from TPivotClause 1467 if (table.getPivotedTable() != null && table.getPivotedTable().getPivotClause() != null) { 1468 isUnpivot = (table.getPivotedTable().getPivotClause().getType() == TPivotClause.unpivot); 1469 } else { 1470 // Fallback: check if the table alias suggests UNPIVOT 1471 // When no explicit alias is provided, TPivotClause.doParse() uses "unpivot_alias" 1472 String tableName = table.getTableName() != null ? table.getTableName().toString() : ""; 1473 String aliasName = table.getAliasName() != null ? table.getAliasName() : ""; 1474 isUnpivot = tableName.startsWith("unpivot_") || aliasName.startsWith("unpivot_"); 1475 } 1476 String prefix = isUnpivot ? "(unpivot-table:" : "(pivot-table:"; 1477 return prefix + normalizeTableName(table.getTableName()) + ")"; 1478 } else if (table.getTableType() == ETableSource.function) { 1479 return "(table-valued function:" + normalizeTableName(table.getTableName()) + ")"; 1480 } else if (table.getTableType() == ETableSource.lateralView) { 1481 return "(lateral_view:" + normalizeTableName(table.getTableName()) + ")"; 1482 } else if (table.isCTEName() && showCTE) { 1483 String tableName = table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName(); 1484 return tableName + "(CTE)"; 1485 } else { 1486 return table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName(); 1487 } 1488 } 1489 1490 /** 1491 * Normalize a table name using the configured name matcher. 1492 * 1493 * <p>When a VendorNameMatcher is configured, this delegates to the vendor-specific 1494 * qualified name normalization (handles case folding and multi-part identifiers). 1495 * Falls back to manual parsing and quote stripping if no matcher is configured.</p> 1496 * 1497 * @param tableName The TObjectName representing the table name 1498 * @return The normalized table name 1499 */ 1500 private String normalizeTableName(TObjectName tableName) { 1501 if (tableName == null) return ""; 1502 String fullName = tableName.toString(); 1503 if (fullName == null || fullName.isEmpty()) return ""; 1504 1505 // Use DisplayNameNormalizer for all modes - it handles DISPLAY, SQL_RENDER, CANONICAL correctly 1506 if (displayNameNormalizer != null) { 1507 return displayNameNormalizer.normalizeQualifiedName(fullName); 1508 } 1509 1510 // Fallback when no normalizer is available 1511 // Use VendorNameMatcher if available for vendor-specific normalization (includes case folding) 1512 if (nameMatcher instanceof VendorNameMatcher) { 1513 return ((VendorNameMatcher) nameMatcher).normalizeQualifiedName(fullName, ESQLDataObjectType.dotTable); 1514 } 1515 1516 // Fallback: manual parsing with quote stripping 1517 return normalizeTableNameFallback(fullName); 1518 } 1519 1520 /** 1521 * Fallback table name normalization when no vendor matcher is configured. 1522 * Handles both fully-quoted identifiers and multi-part identifiers. 1523 */ 1524 private String normalizeTableNameFallback(String fullName) { 1525 // First check if there are any dots outside of quotes (multi-part identifier) 1526 boolean hasUnquotedDot = false; 1527 char quoteChar = 0; 1528 for (int i = 0; i < fullName.length(); i++) { 1529 char c = fullName.charAt(i); 1530 if (quoteChar == 0) { 1531 if (c == '`' || c == '"' || c == '[') { 1532 quoteChar = (c == '[') ? ']' : c; 1533 } else if (c == '.') { 1534 hasUnquotedDot = true; 1535 break; 1536 } 1537 } else { 1538 if (c == quoteChar) { 1539 quoteChar = 0; 1540 } 1541 } 1542 } 1543 1544 // If no unquoted dots, check if entire string is quoted 1545 if (!hasUnquotedDot) { 1546 return stripQuotes(fullName); 1547 } 1548 1549 // Otherwise, split by dot to handle multi-part identifiers like `schema`.`table` 1550 // But we need to be careful not to split inside quotes 1551 StringBuilder result = new StringBuilder(); 1552 StringBuilder currentPart = new StringBuilder(); 1553 quoteChar = 0; 1554 1555 for (int i = 0; i < fullName.length(); i++) { 1556 char c = fullName.charAt(i); 1557 1558 if (quoteChar == 0) { 1559 // Not inside a quote 1560 if (c == '`' || c == '"' || c == '[') { 1561 quoteChar = (c == '[') ? ']' : c; 1562 currentPart.append(c); 1563 } else if (c == '.') { 1564 // End of a part 1565 if (result.length() > 0) result.append("."); 1566 result.append(stripQuotes(currentPart.toString())); 1567 currentPart.setLength(0); 1568 } else { 1569 currentPart.append(c); 1570 } 1571 } else { 1572 // Inside a quote 1573 currentPart.append(c); 1574 if (c == quoteChar) { 1575 quoteChar = 0; 1576 } 1577 } 1578 } 1579 1580 // Don't forget the last part 1581 if (currentPart.length() > 0) { 1582 if (result.length() > 0) result.append("."); 1583 result.append(stripQuotes(currentPart.toString())); 1584 } 1585 1586 return result.toString(); 1587 } 1588 1589 private void collectConstraintColumns(TCreateTableSqlStatement createStmt, Set<String> fields) { 1590 if (createStmt == null || createStmt.getTargetTable() == null) return; 1591 1592 String tableName = createStmt.getTargetTable().getTableName() != null 1593 ? normalizeTableName(createStmt.getTargetTable().getTableName()) : ""; 1594 1595 // Collect columns from table-level constraints 1596 if (createStmt.getTableConstraints() != null) { 1597 for (int i = 0; i < createStmt.getTableConstraints().size(); i++) { 1598 TConstraint constraint = createStmt.getTableConstraints().getConstraint(i); 1599 collectColumnsFromConstraint(constraint, tableName, fields); 1600 } 1601 } 1602 1603 // Collect columns from column-level constraints (inline constraints) 1604 if (createStmt.getColumnList() != null) { 1605 for (int i = 0; i < createStmt.getColumnList().size(); i++) { 1606 TColumnDefinition colDef = createStmt.getColumnList().getColumn(i); 1607 if (colDef != null && colDef.getConstraints() != null) { 1608 for (int j = 0; j < colDef.getConstraints().size(); j++) { 1609 TConstraint constraint = colDef.getConstraints().getConstraint(j); 1610 collectColumnsFromConstraint(constraint, tableName, fields); 1611 } 1612 } 1613 } 1614 } 1615 } 1616 1617 private void collectAlterTableConstraintColumns(TAlterTableStatement alterStmt, Set<String> fields) { 1618 if (alterStmt == null) return; 1619 1620 // Get table name from getTableName() first, fall back to getTargetTable() 1621 String tableName = ""; 1622 if (alterStmt.getTableName() != null) { 1623 tableName = normalizeTableName(alterStmt.getTableName()); 1624 } else if (alterStmt.getTargetTable() != null && alterStmt.getTargetTable().getTableName() != null) { 1625 tableName = normalizeTableName(alterStmt.getTargetTable().getTableName()); 1626 } 1627 if (tableName.isEmpty()) return; 1628 1629 // Collect from ALTER TABLE options (ADD CONSTRAINT, etc.) 1630 if (alterStmt.getAlterTableOptionList() != null) { 1631 for (int i = 0; i < alterStmt.getAlterTableOptionList().size(); i++) { 1632 TAlterTableOption option = alterStmt.getAlterTableOptionList().getAlterTableOption(i); 1633 if (option == null) continue; 1634 1635 // Handle constraints 1636 if (option.getConstraintList() != null) { 1637 for (int j = 0; j < option.getConstraintList().size(); j++) { 1638 TConstraint constraint = option.getConstraintList().getConstraint(j); 1639 collectColumnsFromConstraint(constraint, tableName, fields); 1640 } 1641 } 1642 1643 // Handle index columns (for AddConstraintPK, AddConstraintUnique, AddConstraintFK) 1644 // These store columns as TPTNodeList<TColumnWithSortOrder> 1645 if (option.getIndexCols() != null) { 1646 for (int j = 0; j < option.getIndexCols().size(); j++) { 1647 TColumnWithSortOrder colWithSort = option.getIndexCols().getElement(j); 1648 if (colWithSort != null && colWithSort.getColumnName() != null) { 1649 String colName = normalizeColumnName(colWithSort.getColumnName().getColumnNameOnly()); 1650 if (colName != null && !colName.isEmpty()) { 1651 fields.add(tableName + "." + colName); 1652 } 1653 } 1654 } 1655 } 1656 1657 // Note: We do NOT collect columns from ALTER COLUMN, DROP COLUMN, RENAME COLUMN, 1658 // CHANGE COLUMN operations. These are DDL targets (columns being modified), 1659 // not column references. The old resolver does not collect these either. 1660 1661 // Handle FOREIGN KEY references (for ADD CONSTRAINT FK) 1662 if (option.getReferencedObjectName() != null && option.getReferencedColumnList() != null) { 1663 String refTableName = normalizeTableName(option.getReferencedObjectName()); 1664 for (int j = 0; j < option.getReferencedColumnList().size(); j++) { 1665 TObjectName colObj = option.getReferencedColumnList().getObjectName(j); 1666 if (colObj != null) { 1667 String colName = normalizeColumnName(colObj.getColumnNameOnly()); 1668 if (colName != null && !colName.isEmpty()) { 1669 fields.add(refTableName + "." + colName); 1670 } 1671 } 1672 } 1673 } 1674 } 1675 } 1676 } 1677 1678 private void collectColumnsFromConstraint(TConstraint constraint, String tableName, Set<String> fields) { 1679 if (constraint == null) return; 1680 1681 // Collect columns from constraint's column list (TPTNodeList<TColumnWithSortOrder>) 1682 if (constraint.getColumnList() != null) { 1683 for (int i = 0; i < constraint.getColumnList().size(); i++) { 1684 TColumnWithSortOrder colWithSort = constraint.getColumnList().getElement(i); 1685 if (colWithSort != null && colWithSort.getColumnName() != null) { 1686 String columnName = normalizeColumnName(colWithSort.getColumnName().getColumnNameOnly()); 1687 if (columnName != null && !columnName.isEmpty()) { 1688 // Constraint columns don't include datatype 1689 // Get location from the column if available 1690 String locationStr = ""; 1691 if (showColumnLocation && colWithSort.getColumnName().getLocation() != null) { 1692 locationStr = "(" + colWithSort.getColumnName().getLocation() + ")"; 1693 } 1694 fields.add(tableName + "." + columnName + locationStr); 1695 } 1696 } 1697 } 1698 } 1699 1700 // Collect columns from FOREIGN KEY REFERENCES 1701 if (constraint.getReferencedColumnList() != null && constraint.getReferencedObject() != null) { 1702 String refTableName = normalizeTableName(constraint.getReferencedObject()); 1703 for (int i = 0; i < constraint.getReferencedColumnList().size(); i++) { 1704 TObjectName colName = constraint.getReferencedColumnList().getObjectName(i); 1705 if (colName != null) { 1706 String columnName = normalizeColumnName(colName.getColumnNameOnly()); 1707 if (columnName != null && !columnName.isEmpty()) { 1708 // Use location already set during parsing 1709 String locationStr = ""; 1710 if (showColumnLocation && colName.getLocation() != null) { 1711 locationStr = "(" + colName.getLocation() + ")"; 1712 } 1713 fields.add(refTableName + "." + columnName + locationStr); 1714 } 1715 } 1716 } 1717 } 1718 } 1719 1720 private void expandUnnestStarColumns(TTable unnestTable, String tablePrefix, Set<String> fields) { 1721 if (unnestTable == null || unnestTable.getTableType() != ETableSource.unnest) { 1722 return; 1723 } 1724 1725 // Get the implicit column name (alias or 'value') 1726 String alias = unnestTable.getAliasName(); 1727 String implicitColumnName = (alias != null && !alias.isEmpty()) ? alias : "value"; 1728 fields.add(tablePrefix + "." + implicitColumnName); 1729 1730 // Check for WITH OFFSET column 1731 TUnnestClause unnestClause = unnestTable.getUnnestClause(); 1732 if (unnestClause != null && unnestClause.getWithOffset() != null) { 1733 String offsetColumnName; 1734 if (unnestClause.getWithOffsetAlais() != null && 1735 unnestClause.getWithOffsetAlais().getAliasName() != null) { 1736 offsetColumnName = unnestClause.getWithOffsetAlais().getAliasName().toString(); 1737 } else { 1738 offsetColumnName = "offset"; 1739 } 1740 fields.add(tablePrefix + "." + offsetColumnName); 1741 } 1742 } 1743 1744 /** 1745 * Normalize a column name using the configured name matcher. 1746 * 1747 * <p>When a VendorNameMatcher is configured, this delegates to the vendor-specific 1748 * normalization logic (handles case folding according to vendor rules). 1749 * Falls back to simple quote stripping if no matcher is configured.</p> 1750 * 1751 * @param columnName The raw column name which may include quotes 1752 * @return The normalized column name 1753 */ 1754 private String normalizeColumnName(String columnName) { 1755 if (columnName == null || columnName.isEmpty()) return columnName; 1756 1757 // Use DisplayNameNormalizer for all modes - it handles DISPLAY, SQL_RENDER, CANONICAL correctly 1758 if (displayNameNormalizer != null) { 1759 return displayNameNormalizer.normalizeIdentifier(columnName); 1760 } 1761 1762 // Fallback when no normalizer is available 1763 // Use VendorNameMatcher if available for vendor-specific normalization (includes case folding) 1764 if (nameMatcher instanceof VendorNameMatcher) { 1765 return ((VendorNameMatcher) nameMatcher).normalize(columnName, ESQLDataObjectType.dotColumn); 1766 } 1767 1768 // Use generic INameMatcher if available 1769 if (nameMatcher != null) { 1770 return nameMatcher.normalize(columnName); 1771 } 1772 1773 // Fallback: simple quote stripping 1774 return stripQuotes(columnName); 1775 } 1776 1777 /** 1778 * Strip surrounding quotes from an identifier. 1779 * This is a fallback when no name matcher is configured. 1780 * 1781 * @param name The identifier which may include quotes 1782 * @return The identifier without surrounding quotes 1783 */ 1784 private String stripQuotes(String name) { 1785 if (name == null || name.isEmpty()) return name; 1786 1787 // Remove surrounding double quotes 1788 if (name.startsWith("\"") && name.endsWith("\"") && name.length() > 1) { 1789 return name.substring(1, name.length() - 1); 1790 } 1791 // Remove surrounding backticks (MySQL) 1792 if (name.startsWith("`") && name.endsWith("`") && name.length() > 1) { 1793 return name.substring(1, name.length() - 1); 1794 } 1795 // Remove surrounding square brackets (SQL Server) 1796 if (name.startsWith("[") && name.endsWith("]") && name.length() > 1) { 1797 return name.substring(1, name.length() - 1); 1798 } 1799 return name; 1800 } 1801 1802 private String getColumnDatatype(TObjectName col) { 1803 if (!showDatatype) return ""; 1804 1805 if (col.getLinkedColumnDef() != null) { 1806 TTypeName datatype = col.getLinkedColumnDef().getDatatype(); 1807 if (datatype != null) { 1808 String typeName = datatype.getDataTypeName(); 1809 if (typeName != null && !typeName.isEmpty()) { 1810 StringBuilder result = new StringBuilder(":" + typeName.toLowerCase()); 1811 // Add length if available (e.g., varchar:30, char:50) 1812 if (datatype.getLength() != null) { 1813 result.append(":").append(datatype.getLength().toString()); 1814 } 1815 // Add precision if available (e.g., decimal:10) 1816 else if (datatype.getPrecision() != null) { 1817 result.append(":").append(datatype.getPrecision().toString()); 1818 // Add scale if available (e.g., decimal:10:2) 1819 if (datatype.getScale() != null) { 1820 result.append(":").append(datatype.getScale().toString()); 1821 } 1822 } 1823 // Add display length if available (e.g., int:11 for MySQL INT(11)) 1824 else if (datatype.getDisplayLength() != null) { 1825 result.append(":").append(datatype.getDisplayLength().toString()); 1826 } 1827 return result.toString(); 1828 } 1829 } 1830 } 1831 return ""; 1832 } 1833 1834 // ========== CTE Column Collection ========== 1835 1836 /** 1837 * Collect CTE columns for the Ctes: output section. 1838 * Prioritizes CTENamespace from resolver when available, falls back to AST traversal. 1839 */ 1840 private void collectCTEColumns(Set<String> cteColumns) { 1841 if (buildResult == null) return; 1842 1843 // Get CTE namespaces from resolver 1844 Map<String, CTENamespace> cteNamespaces = buildResult.getCTENamespaces(); 1845 1846 if (cteNamespaces != null && !cteNamespaces.isEmpty()) { 1847 // Preferred: Use CTENamespace to get columns 1848 for (Map.Entry<String, CTENamespace> entry : cteNamespaces.entrySet()) { 1849 String cteName = entry.getKey(); 1850 CTENamespace ns = entry.getValue(); 1851 1852 Map<String, ColumnSource> columnSources = ns.getAllColumnSources(); 1853 if (columnSources != null) { 1854 for (String colName : columnSources.keySet()) { 1855 cteColumns.add(cteName + "." + colName); 1856 } 1857 } 1858 } 1859 } else { 1860 // Fallback: Collect from AST when namespaces not available 1861 for (int i = 0; i < statements.size(); i++) { 1862 collectCTEColumnsFromAST(statements.get(i), cteColumns); 1863 } 1864 } 1865 } 1866 1867 /** 1868 * Fallback method to collect CTE columns from AST when CTENamespace is not available. 1869 */ 1870 private void collectCTEColumnsFromAST(Object stmt, Set<String> cteColumns) { 1871 if (!(stmt instanceof TSelectSqlStatement)) { 1872 if (stmt instanceof TCustomSqlStatement) { 1873 TCustomSqlStatement customStmt = (TCustomSqlStatement) stmt; 1874 for (int i = 0; i < customStmt.getStatements().size(); i++) { 1875 collectCTEColumnsFromAST(customStmt.getStatements().get(i), cteColumns); 1876 } 1877 } 1878 return; 1879 } 1880 1881 TSelectSqlStatement selectStmt = (TSelectSqlStatement) stmt; 1882 1883 // Handle combined queries - follow left chain iteratively 1884 if (selectStmt.isCombinedQuery()) { 1885 TSelectSqlStatement current = selectStmt; 1886 while (current.isCombinedQuery()) { 1887 collectCTEColumnsFromAST(current.getRightStmt(), cteColumns); 1888 current = current.getLeftStmt(); 1889 } 1890 collectCTEColumnsFromAST(current, cteColumns); 1891 return; 1892 } 1893 1894 // Process CTEs in this statement 1895 if (selectStmt.getCteList() != null) { 1896 for (int i = 0; i < selectStmt.getCteList().size(); i++) { 1897 TCTE cte = selectStmt.getCteList().getCTE(i); 1898 String cteName = cte.getTableName().toString(); 1899 1900 // Get columns from CTE definition 1901 if (cte.getColumnList() != null) { 1902 for (int j = 0; j < cte.getColumnList().size(); j++) { 1903 TObjectName colName = cte.getColumnList().getObjectName(j); 1904 if (colName != null) { 1905 cteColumns.add(cteName + "." + colName.getColumnNameOnly()); 1906 } 1907 } 1908 } else if (cte.getSubquery() != null) { 1909 // Infer columns from subquery result columns 1910 TResultColumnList resultCols = cte.getSubquery().getResultColumnList(); 1911 if (resultCols != null) { 1912 for (int j = 0; j < resultCols.size(); j++) { 1913 TResultColumn rc = resultCols.getResultColumn(j); 1914 String colName = inferColumnName(rc); 1915 if (colName != null && !"*".equals(colName)) { 1916 cteColumns.add(cteName + "." + colName); 1917 } 1918 } 1919 } 1920 } 1921 } 1922 } 1923 1924 // Recursively process nested statements 1925 for (int i = 0; i < selectStmt.getStatements().size(); i++) { 1926 collectCTEColumnsFromAST(selectStmt.getStatements().get(i), cteColumns); 1927 } 1928 } 1929 1930 private String inferColumnName(TResultColumn rc) { 1931 if (rc == null) return null; 1932 1933 // Check for alias 1934 if (rc.getAliasClause() != null) { 1935 return rc.getAliasClause().toString(); 1936 } 1937 1938 // Check for simple column reference 1939 if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) { 1940 return rc.getExpr().getObjectOperand().getColumnNameOnly(); 1941 } 1942 1943 return null; 1944 } 1945 1946 // ========== Static Factory Methods ========== 1947 1948 /** 1949 * Create a formatter with default settings. 1950 */ 1951 public static TSQLResolver2ResultFormatter create(TSQLResolver2 resolver) { 1952 return new TSQLResolver2ResultFormatter(resolver); 1953 } 1954 1955 /** 1956 * Create a formatter configured to show CTE tables and columns. 1957 */ 1958 public static TSQLResolver2ResultFormatter createWithCTE(TSQLResolver2 resolver) { 1959 return new TSQLResolver2ResultFormatter(resolver).setShowCTE(true); 1960 } 1961 1962 /** 1963 * Find the containing SQL statement for a column reference by traversing up the AST. 1964 * This is used as a fallback when ownStmt is not set (e.g., for PL/SQL package constants). 1965 * 1966 * @param col The column reference 1967 * @return The containing statement, or null if not found 1968 */ 1969 private TCustomSqlStatement findContainingStatement(TObjectName col) { 1970 if (col == null) return null; 1971 1972 TParseTreeNode parent = col.getParentObjectName(); 1973 while (parent != null) { 1974 if (parent instanceof TCustomSqlStatement) { 1975 return (TCustomSqlStatement) parent; 1976 } 1977 parent = parent.getParentObjectName(); 1978 } 1979 return null; 1980 } 1981 1982 /** 1983 * Get the position string for a column reference, using the actual column name part's position 1984 * for multi-part identifiers (e.g., for "sch.pkg.col", use the position of "col" not "sch"). 1985 * This matches TGetTableColumn behavior. 1986 * 1987 * @param col The column reference 1988 * @return Position string in format "(line,column)" 1989 */ 1990 private String getColumnPositionStr(TObjectName col) { 1991 if (col == null) { 1992 return "(0,0)"; 1993 } 1994 // For multi-part names, use the partToken (column name part) position if available 1995 TSourceToken partToken = col.getPartToken(); 1996 if (partToken != null) { 1997 return "(" + partToken.lineNo + "," + partToken.columnNo + ")"; 1998 } 1999 // Fallback to the object name's position 2000 return "(" + col.getLineNo() + "," + col.getColumnNo() + ")"; 2001 } 2002 2003 /** 2004 * Create a formatter configured to show datatypes. 2005 */ 2006 public static TSQLResolver2ResultFormatter createWithDatatype(TSQLResolver2 resolver) { 2007 return new TSQLResolver2ResultFormatter(resolver).setShowDatatype(true); 2008 } 2009 2010 /** 2011 * Create a formatter configured to show CTE column definitions. 2012 */ 2013 public static TSQLResolver2ResultFormatter createWithCTEColumns(TSQLResolver2 resolver) { 2014 return new TSQLResolver2ResultFormatter(resolver).setShowColumnsOfCTE(true); 2015 } 2016 2017 /** 2018 * Read the expected output from a file. 2019 * Utility method for tests to load expected results. 2020 */ 2021 public static String getDesiredTablesColumns(String filePath) { 2022 try { 2023 return new String(java.nio.file.Files.readAllBytes( 2024 java.nio.file.Paths.get(filePath)), java.nio.charset.StandardCharsets.UTF_8); 2025 } catch (java.io.IOException e) { 2026 return ""; 2027 } 2028 } 2029}