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