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 755 if (customStmt instanceof TSelectSqlStatement) { 756 TSelectSqlStatement selectStmt = (TSelectSqlStatement) customStmt; 757 if (selectStmt.isCombinedQuery()) { 758 collectDDLFieldsFromStatement(selectStmt.getLeftStmt(), fields); 759 collectDDLFieldsFromStatement(selectStmt.getRightStmt(), fields); 760 return; 761 } 762 } 763 764 // Handle CREATE TABLE - collect constraint columns only 765 // Note: CTAS target columns (tuple aliases, standard aliases, simple column refs) 766 // are now fully handled by ScopeBuilder.preVisit(TResultColumn) and included in 767 // allColumnReferences. They will be output via collectResolvedFieldsGlobal(). 768 if (customStmt instanceof TCreateTableSqlStatement) { 769 TCreateTableSqlStatement createStmt = (TCreateTableSqlStatement) customStmt; 770 // Collect constraint columns (PRIMARY KEY, UNIQUE, FOREIGN KEY references) 771 collectConstraintColumns(createStmt, fields); 772 } 773 774 // Handle ALTER TABLE - collect constraint columns 775 if (customStmt instanceof TAlterTableStatement) { 776 TAlterTableStatement alterStmt = (TAlterTableStatement) customStmt; 777 collectAlterTableConstraintColumns(alterStmt, fields); 778 } 779 780 // Recurse for nested statements to find deeper DDLs (e.g. inside blocks) 781 for (int i = 0; i < customStmt.getStatements().size(); i++) { 782 Object nestedStmt = customStmt.getStatements().get(i); 783 if (nestedStmt instanceof TCustomSqlStatement) { 784 collectDDLFieldsFromStatement(nestedStmt, fields); 785 } 786 } 787 } 788 789 /** 790 * Optimized global field collection for all resolved columns. 791 * Iterates through all column references once globally, calculating table prefixes on-the-fly. 792 * This avoids the O(N*M) complexity of per-statement AST traversal for deep nesting. 793 */ 794 private void collectResolvedFieldsGlobal(Set<String> fields) { 795 if (buildResult == null) return; 796 797 // Get vendor info once 798 TSQLResolverConfig resolverConfig = resolver != null ? resolver.getConfig() : null; 799 EDbVendor vendor = resolverConfig != null ? resolverConfig.getVendor() : null; 800 if (vendor == null && statements != null && statements.size() > 0) { 801 vendor = statements.get(0).dbvendor; 802 } 803 boolean isSparkOrHive = vendor == EDbVendor.dbvsparksql || vendor == EDbVendor.dbvhive; 804 boolean isSnowflake = vendor == EDbVendor.dbvsnowflake; 805 boolean isSqlServer = vendor == EDbVendor.dbvmssql || vendor == EDbVendor.dbvazuresql; 806 807 808 // Single pass through all column references in the entire session 809 java.util.List<TObjectName> allColumnReferences = buildResult.getAllColumnReferences(); 810 for (TObjectName col : allColumnReferences) { 811 if (col == null) continue; 812 if (col.getValidate_column_status() == TBaseType.MARKED_NOT_A_COLUMN_IN_COLUMN_RESOLVER) continue; 813 if (col.getDbObjectType() == EDbObjectType.column_alias) continue; 814 815 // Get column name with vendor-specific handling 816 String columnName = getColumnDisplayName(col, isSparkOrHive, isSnowflake, isSqlServer); 817 818 if (columnName == null || columnName.isEmpty()) continue; 819 820 // Handle star columns specially when listStarColumn=false: 821 // - We need to process unqualified stars (*) for PIVOT/UNNEST column expansion 822 // - Other star columns (qualified like table.*, OUTPUT DELETED.*) should be skipped 823 if ("*".equals(columnName) && !listStarColumn) { 824 // Only process unqualified stars (SELECT *) for PIVOT/UNNEST expansion 825 if (!isUnqualifiedStar(col)) { 826 continue; // Skip qualified stars when listStarColumn=false 827 } 828 // For unqualified stars, continue processing to reach expandStarToAllTables() 829 } 830 831 // Skip ROWNUM pseudo-column when filtering to physical tables 832 // Note: ROWID is a real column stored in the table, but ROWNUM is a query-result pseudo-column 833 if (onlyPhysicalTables && "ROWNUM".equalsIgnoreCase(columnName)) continue; 834 835 // Get source table 836 TTable sourceTable = col.getSourceTable(); 837 838 // Handle star columns specially 839 if ("*".equals(columnName)) { 840 if (!shouldIncludeStarColumn(col, sourceTable)) continue; 841 842 // For unqualified star columns (SELECT *), expand to all tables in FROM clause 843 // Note: We use expandStarToAllTables() which adds table.* entries for each table 844 // The core resolver's star expansion (attributeNodesDerivedFromFromClause) is for 845 // internal resolution purposes, not for output formatting 846 if (isUnqualifiedStar(col)) { 847 expandStarToAllTables(col, fields); 848 continue; 849 } 850 } 851 852 // Check if this is a qualified star column with expanded attributes from push-down 853 // These are columns like "src.*" that have been expanded via TSQLResolver2's 854 // star column push-down algorithm 855 // Note: Use col.toString() instead of columnName, because columnName (from getColumnNameOnly) 856 // would be just "*", but we need "src.*" to detect qualified stars 857 String colFullString = col.toString(); 858 if (colFullString != null && colFullString.endsWith("*") && !colFullString.equals("*")) { 859 java.util.ArrayList<gudusoft.gsqlparser.TAttributeNode> expandedAttrs = 860 col.getAttributeNodesDerivedFromFromClause(); 861 if (expandedAttrs != null && !expandedAttrs.isEmpty()) { 862 // Only process if the star column's sourceTable is a physical table (not subquery/CTE/pivot) 863 // This ensures we output with the correct base table prefix 864 // PIVOT tables are excluded since their columns are output individually via PivotNamespace 865 if (sourceTable != null && 866 sourceTable.getTableType() != ETableSource.subquery && 867 sourceTable.getTableType() != ETableSource.openquery && 868 sourceTable.getTableType() != ETableSource.join && 869 sourceTable.getTableType() != ETableSource.pivoted_table && 870 !(sourceTable.isCTEName() && !showCTE) && 871 !shouldSkipTableForPhysicalFilter(sourceTable)) { 872 873 String tablePrefix = getFieldTablePrefix(sourceTable); 874 if (tablePrefix != null) { 875 // Output the star column itself if listStarColumn is true 876 if (listStarColumn) { 877 fields.add(tablePrefix + ".*"); 878 } 879 880 // Output the expanded individual columns using the star's source table 881 // This ensures columns pushed down through the star get the correct table prefix 882 // BUT skip columns that are explicitly resolved elsewhere (to a different table) 883 for (gudusoft.gsqlparser.TAttributeNode attr : expandedAttrs) { 884 if (attr == null) continue; 885 String attrName = attr.getName(); 886 if (attrName != null && !attrName.isEmpty() && !attrName.endsWith("*")) { 887 // Extract just the column name 888 String justColumnName = attrName; 889 int dotIdx = attrName.lastIndexOf('.'); 890 if (dotIdx >= 0) { 891 justColumnName = attrName.substring(dotIdx + 1); 892 } 893 894 // Check if this column is explicitly resolved to a DIFFERENT PHYSICAL table 895 // in the column references list - if so, skip it to avoid duplicates 896 // Note: We use getFinalTable() to trace through subqueries to the actual physical table 897 // This handles cases like: SELECT al1.COL1 FROM (SELECT t1.COL1, t2.* FROM T1 t1 JOIN T2 t2) al1 898 // where al1.COL1 should resolve to T1.COL1 (explicit column), not T2.COL1 (from star) 899 boolean resolvedToOtherPhysicalTable = false; 900 for (TObjectName otherCol : allColumnReferences) { 901 if (otherCol == null || otherCol == col) continue; 902 String otherColName = otherCol.getColumnNameOnly(); 903 if (otherColName != null && otherColName.equalsIgnoreCase(justColumnName)) { 904 // Get the final physical table through ColumnSource or sourceTable 905 TTable otherFinalTable = null; 906 ColumnSource otherColSource = otherCol.getColumnSource(); 907 if (otherColSource != null) { 908 otherFinalTable = otherColSource.getFinalTable(); 909 } 910 // Fallback to sourceTable if ColumnSource doesn't have finalTable 911 if (otherFinalTable == null) { 912 otherFinalTable = otherCol.getSourceTable(); 913 } 914 915 if (otherFinalTable != null && otherFinalTable != sourceTable) { 916 // Check if the other source is a physical table (not CTE/subquery) 917 if (otherFinalTable.getTableType() != ETableSource.subquery && 918 otherFinalTable.getTableType() != ETableSource.openquery && 919 otherFinalTable.getTableType() != ETableSource.join && 920 !otherFinalTable.isCTEName()) { 921 // Resolved to a different physical table - skip 922 resolvedToOtherPhysicalTable = true; 923 break; 924 } 925 } 926 } 927 } 928 929 if (!resolvedToOtherPhysicalTable) { 930 fields.add(tablePrefix + "." + normalizeColumnName(justColumnName)); 931 } 932 } 933 } 934 } 935 } 936 continue; // Star column fully handled 937 } 938 } 939 940 // Use ColumnSource to determine final table (if available) 941 ColumnSource source = col.getColumnSource(); 942 if (source != null) { 943 // Check if the resolution is ambiguous - if so, don't use getFinalTable() 944 // because getColumnSource() returns the first candidate but we shouldn't pick one 945 gudusoft.gsqlparser.resolver2.model.ResolutionResult colResolution = col.getResolution(); 946 boolean isResolutionAmbiguous = colResolution != null && colResolution.isAmbiguous(); 947 948 // For columns from UNION queries, get all tables and add an entry for each 949 java.util.List<TTable> allFinalTables = source.getAllFinalTables(); 950 if (allFinalTables != null && allFinalTables.size() > 1) { 951 // Multiple tables (UNION query) - add entry for each table 952 for (TTable unionTable : allFinalTables) { 953 if (unionTable == null) continue; 954 if (unionTable.getTableType() == ETableSource.subquery) continue; 955 if (unionTable.getTableType() == ETableSource.openquery) continue; 956 if (unionTable.getTableType() == ETableSource.join) continue; 957 if (unionTable.isCTEName() && !showCTE) continue; 958 if (shouldSkipTableForPhysicalFilter(unionTable)) continue; 959 960 String tablePrefix = getFieldTablePrefix(unionTable); 961 if (tablePrefix != null) { 962 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 963 String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : ""; 964 fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr); 965 } 966 } 967 continue; // Already handled this column for all tables 968 } 969 970 TTable finalTable = source.getFinalTable(); 971 // Only use finalTable if the resolution is NOT ambiguous 972 // For ambiguous resolutions, let the orphan handling deal with it 973 if (finalTable != null && !isResolutionAmbiguous) { 974 sourceTable = finalTable; 975 } else if (sourceTable == null && !source.isAmbiguous() && !isResolutionAmbiguous) { 976 // ColumnSource exists but getFinalTable() is null AND sourceTable not set 977 // AND not an ambiguous multi-table case 978 // Check if we have candidate tables (e.g., from UNION branches) 979 // If so, don't skip - let the orphan handling output all candidates 980 java.util.List<TTable> candidates = source.getCandidateTables(); 981 if (candidates == null || candidates.isEmpty()) { 982 // No candidates either - this is truly a calculated expression 983 // Skip these - they don't trace to physical tables 984 continue; 985 } 986 // Has candidate tables - fall through to orphan handling which will output them 987 } 988 // If getFinalTable() is null but sourceTable is set (e.g., UPDATE SET clause), 989 // use sourceTable as fallback 990 } 991 // If ColumnSource is null, fall back to col.getSourceTable() 992 // This handles INSERT columns, direct star columns, and other cases 993 // where the resolver doesn't set ColumnSource but the parser did resolve sourceTable 994 995 // Handle orphan columns (no definitive source table) 996 // Also handle columns that point to a subquery but couldn't be traced to a physical table 997 boolean isOrphan = (sourceTable == null); 998 boolean isUnresolvedSubqueryColumn = false; 999 1000 // Check if resolution or ColumnSource indicates ambiguity (multiple candidate tables) 1001 // Note: col.getResolution().isAmbiguous() checks the resolution result 1002 // source.isAmbiguous() checks if the ColumnSource has multiple candidate tables 1003 // For ambiguous resolutions, getColumnSource() returns the first candidate which won't have isAmbiguous()=true 1004 gudusoft.gsqlparser.resolver2.model.ResolutionResult resolvedResult = col.getResolution(); 1005 if ((resolvedResult != null && resolvedResult.isAmbiguous()) || 1006 (source != null && source.isAmbiguous())) { 1007 isUnresolvedSubqueryColumn = true; 1008 } else if (sourceTable != null && sourceTable.getTableType() == ETableSource.subquery && source == null) { 1009 // Check if this column is explicitly defined in the subquery's SELECT list 1010 // If so, it's a calculated/aliased column - skip it 1011 // If not, it's through an ambiguous star - mark as "missed" 1012 TSelectSqlStatement subquery = sourceTable.getSubquery(); 1013 if (subquery != null && subquery.getResultColumnList() != null) { 1014 boolean foundInSubquery = false; 1015 boolean hasAmbiguousStar = false; 1016 int tableCount = 0; 1017 1018 // Count tables in FROM clause 1019 if (subquery.tables != null) { 1020 tableCount = subquery.tables.size(); 1021 } 1022 1023 TResultColumnList resultCols = subquery.getResultColumnList(); 1024 for (int i = 0; i < resultCols.size(); i++) { 1025 TResultColumn rc = resultCols.getResultColumn(i); 1026 if (rc == null) continue; 1027 1028 String rcStr = rc.toString().trim(); 1029 // Check if it's an unqualified star with multiple tables 1030 if (rcStr.equals("*") && tableCount > 1) { 1031 hasAmbiguousStar = true; 1032 continue; 1033 } 1034 1035 // Check for explicit column/alias name 1036 String rcName = null; 1037 if (rc.getAliasClause() != null && rc.getAliasClause().getAliasName() != null) { 1038 rcName = rc.getAliasClause().getAliasName().toString(); 1039 } else if (rc.getExpr() != null && 1040 rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t && 1041 rc.getExpr().getObjectOperand() != null) { 1042 rcName = rc.getExpr().getObjectOperand().getColumnNameOnly(); 1043 } 1044 1045 if (rcName != null && nameMatcher.matches(rcName, columnName)) { 1046 foundInSubquery = true; 1047 break; 1048 } 1049 } 1050 1051 // Column is unresolved only if it's NOT found in explicit columns AND there's an ambiguous star 1052 isUnresolvedSubqueryColumn = !foundInSubquery && hasAmbiguousStar; 1053 } 1054 } 1055 1056 if (isOrphan || isUnresolvedSubqueryColumn) { 1057 // Check for candidate tables (e.g., from UNION branches) 1058 TTableList candidates = col.getCandidateTables(); 1059 boolean hasCandidates = candidates != null && candidates.size() > 0; 1060 1061 // If we have multiple candidate tables from UNION/CTE propagation, output all of them 1062 // For regular ambiguous columns (not from UNION), fall through to "missed" handling 1063 if (hasCandidates && candidates.size() > 1 && sourceTable == null && col.isCandidatesFromUnion()) { 1064 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 1065 String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : ""; 1066 for (int ci = 0; ci < candidates.size(); ci++) { 1067 TTable candidateTable = candidates.getTable(ci); 1068 if (candidateTable != null) { 1069 // Filter out tables that shouldn't be reported 1070 if (candidateTable.getTableType() == ETableSource.subquery) continue; 1071 if (candidateTable.getTableType() == ETableSource.openquery) continue; 1072 if (candidateTable.getTableType() == ETableSource.join) continue; 1073 if (candidateTable.isCTEName() && !showCTE) continue; 1074 if (shouldSkipTableForPhysicalFilter(candidateTable)) continue; 1075 1076 String tablePrefix = getFieldTablePrefix(candidateTable); 1077 if (tablePrefix != null) { 1078 fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr); 1079 } 1080 } 1081 } 1082 continue; // Already handled all candidates 1083 } 1084 1085 // Orphan column handling: 1086 // 1. Has candidates AND linkOrphanColumnToFirstTable=true → use first candidate table 1087 // 2. No candidates AND linkOrphanColumnToFirstTable=true → use statement's first physical table 1088 // 3. linkOrphanColumnToFirstTable=false → output missed.column 1089 // 4. No table available at all → output missed.column 1090 // Note: onlyPhysicalTables does NOT affect orphan column output 1091 if (sourceTable == null) { 1092 // Check if the column resolved to a CTE - if so, skip it when showCTE=false 1093 if (source != null) { 1094 TTable finalTable = source.getFinalTable(); 1095 if (finalTable != null && finalTable.isCTEName() && !showCTE) { 1096 continue; // Skip CTE columns when not showing CTEs 1097 } 1098 } 1099 1100 if (hasCandidates && linkOrphanColumnToFirstTable) { 1101 // Has candidates and linkOrphanColumnToFirstTable=true → use first candidate 1102 sourceTable = candidates.getTable(0); 1103 } else if (linkOrphanColumnToFirstTable) { 1104 // No candidates but linkOrphanColumnToFirstTable=true 1105 // → try statement's first physical table (matching TGetTableColumn behavior) 1106 TCustomSqlStatement ownStmt = col.getOwnStmt(); 1107 // If ownStmt is null, try to find containing statement from AST 1108 if (ownStmt == null) { 1109 ownStmt = findContainingStatement(col); 1110 } 1111 if (ownStmt != null) { 1112 TTable firstPhysicalTable = ownStmt.getFirstPhysicalTable(); 1113 if (firstPhysicalTable != null) { 1114 sourceTable = firstPhysicalTable; 1115 } 1116 } 1117 if (sourceTable == null) { 1118 // Still no table → output as missed 1119 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 1120 String positionStr = getColumnPositionStr(col); 1121 fields.add("missed." + columnName + positionStr + datatypeStr); 1122 continue; 1123 } 1124 } else { 1125 // linkOrphanColumnToFirstTable=false → output as missed 1126 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 1127 String positionStr = getColumnPositionStr(col); 1128 fields.add("missed." + columnName + positionStr + datatypeStr); 1129 continue; 1130 } 1131 } 1132 1133 // For unresolved subquery columns (ambiguous), output as missed if linkOrphanColumnToFirstTable is false 1134 if (isUnresolvedSubqueryColumn && !linkOrphanColumnToFirstTable) { 1135 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 1136 String positionStr = getColumnPositionStr(col); 1137 fields.add("missed." + columnName + positionStr + datatypeStr); 1138 continue; 1139 } 1140 } 1141 1142 // Filter out tables that shouldn't be reported 1143 if (sourceTable.getTableType() == ETableSource.subquery) continue; 1144 if (sourceTable.getTableType() == ETableSource.openquery) continue; 1145 if (sourceTable.getTableType() == ETableSource.join) continue; 1146 if (sourceTable.isCTEName() && !showCTE) continue; 1147 1148 // Skip non-physical tables (record variables, etc.) if onlyPhysicalTables is true 1149 if (shouldSkipTableForPhysicalFilter(sourceTable)) continue; 1150 1151 // Skip star columns for PIVOT tables - individual columns are output via PivotNamespace 1152 if ("*".equals(columnName) && sourceTable.getTableType() == ETableSource.pivoted_table) continue; 1153 1154 // Calculate table prefix on-the-fly (avoids pre-building map via AST traversal) 1155 String tablePrefix = getFieldTablePrefix(sourceTable); 1156 if (tablePrefix != null) { 1157 String datatypeStr = showDatatype ? getColumnDatatype(col) : ""; 1158 String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : ""; 1159 fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr); 1160 1161 // For star columns on UNNEST tables, expand 1162 if ("*".equals(columnName) && sourceTable.getTableType() == ETableSource.unnest) { 1163 expandUnnestStarColumns(sourceTable, tablePrefix, fields); 1164 } 1165 } 1166 } 1167 1168 // NOTE: TD_UNPIVOT columns are now collected by ScopeBuilder.processTDUnpivotTable() 1169 // which adds them to allColumnReferences. No need for fallback here. 1170 } 1171 1172 /** 1173 * Get the display name for a column, with vendor-specific handling. 1174 * 1175 * For STRUCT field access (BigQuery/Snowflake/SparkSQL), this uses the 1176 * ColumnSource.exposedName which contains the base column name, rather than 1177 * getColumnNameOnly() which returns the nested field name. 1178 */ 1179 private String getColumnDisplayName(TObjectName col, boolean isSparkOrHive, boolean isSnowflake, boolean isSqlServer) { 1180 String colString = col.toString(); 1181 String colNameOnly = col.getColumnNameOnly(); 1182 1183 // For STRUCT field access (detected by evidence marker), use ColumnSource.exposedName 1184 // which contains the base column name (e.g., "customer" for "customer.customer_id") 1185 ColumnSource columnSource = col.getColumnSource(); 1186 if (columnSource != null && "struct_field_access".equals(columnSource.getEvidence())) { 1187 String exposedName = columnSource.getExposedName(); 1188 if (exposedName != null && !exposedName.isEmpty()) { 1189 colNameOnly = exposedName; 1190 } 1191 } 1192 1193 if (isSqlServer && colNameOnly != null && 1194 colNameOnly.startsWith("[") && colNameOnly.endsWith("]") && 1195 colNameOnly.length() > 2) { 1196 // Normalize SQL Server bracketed identifiers to avoid duplicates like [col3] and col3 1197 // The TreeSet uses case-insensitive ordering but doesn't handle bracket normalization 1198 return normalizeColumnName(colNameOnly); 1199 } else if (isSparkOrHive && colString != null && colNameOnly != null && 1200 !colString.contains(".") && 1201 colString.startsWith("`") && colString.endsWith("`") && 1202 colString.length() > 2) { 1203 // Use DisplayNameNormalizer to handle backtick-quoted identifiers 1204 // This respects displayNameMode (DISPLAY strips quotes, SQL_RENDER preserves them) 1205 return normalizeColumnName(colString); 1206 } else if (isSnowflake && colString != null && colNameOnly != null && 1207 !colString.contains(".") && 1208 colString.startsWith("\"") && colString.endsWith("\"") && 1209 colString.length() > 2) { 1210 // Use DisplayNameNormalizer to handle double-quoted identifiers 1211 // This respects displayNameMode (DISPLAY strips quotes, SQL_RENDER preserves them) 1212 return normalizeColumnName(colString); 1213 } else if (isSnowflake && colString != null && colString.startsWith("$")) { 1214 // Snowflake stage file positional column (e.g., $1, $1:apMac) 1215 // Extract JSON path if present 1216 int colonIndex = colString.indexOf(':'); 1217 if (colonIndex > 0) { 1218 // Has JSON path - extract it (e.g., "$1:apMac" -> ":apMac") 1219 String jsonPath = colString.substring(colonIndex); 1220 return jsonPath; 1221 } else { 1222 // Simple positional column (e.g., "$1") - return as-is 1223 return colString; 1224 } 1225 } else { 1226 return normalizeColumnName(colNameOnly); 1227 } 1228 } 1229 1230 /** 1231 * Check if a star column should be included in output. 1232 */ 1233 private boolean shouldIncludeStarColumn(TObjectName col, TTable sourceTable) { 1234 // Skip if the star's source table reference is a CTE 1235 if (sourceTable != null && sourceTable.isCTEName()) return false; 1236 1237 // Skip if the star was resolved through a CTE namespace 1238 ColumnSource starSource = col.getColumnSource(); 1239 if (starSource != null && starSource.getSourceNamespace() instanceof CTENamespace) return false; 1240 1241 // Skip if the star's source table is a PIVOT/UNPIVOT table 1242 // PIVOT columns are output individually via PivotNamespace, not as table.* 1243 if (sourceTable != null && sourceTable.getTableType() == ETableSource.pivoted_table) return false; 1244 1245 // Find the SELECT statement containing this star column 1246 TParseTreeNode parent = col.getParentObjectName(); 1247 TSelectSqlStatement starSelectStmt = null; 1248 while (parent != null) { 1249 if (parent instanceof TSelectSqlStatement) { 1250 starSelectStmt = (TSelectSqlStatement) parent; 1251 break; 1252 } 1253 parent = parent.getParentObjectName(); 1254 } 1255 1256 if (starSelectStmt != null) { 1257 // Skip if in a nested procedural statement (e.g., inside BEGIN/END block) 1258 if (starSelectStmt.getParentStmt() != null) return false; 1259 1260 // Check if sourceTable is in the star's SELECT statement 1261 // This ensures we only report stars that trace to tables in their own SELECT 1262 if (sourceTable != null) { 1263 boolean foundInSameStmt = false; 1264 for (int ti = 0; ti < starSelectStmt.tables.size(); ti++) { 1265 if (starSelectStmt.tables.getTable(ti) == sourceTable) { 1266 foundInSameStmt = true; 1267 break; 1268 } 1269 } 1270 if (!foundInSameStmt) return false; 1271 } 1272 } 1273 1274 return true; 1275 } 1276 1277 /** 1278 * Check if a star column is unqualified (SELECT * vs SELECT t.*) 1279 */ 1280 private boolean isUnqualifiedStar(TObjectName col) { 1281 // Check if the column has a table prefix 1282 String colStr = col.toString(); 1283 // Unqualified star is just "*" without any prefix 1284 return "*".equals(colStr.trim()); 1285 } 1286 1287 /** 1288 * Find the statement that directly contains the given table in its tables list. 1289 * Returns the most specific (innermost) statement that has the table. 1290 */ 1291 private TCustomSqlStatement findStatementWithTable(TCustomSqlStatement stmt, TTable targetTable) { 1292 if (stmt == null) return null; 1293 1294 // Check if this statement directly contains the table 1295 if (stmt.tables != null) { 1296 for (int i = 0; i < stmt.tables.size(); i++) { 1297 if (stmt.tables.getTable(i) == targetTable) { 1298 return stmt; 1299 } 1300 } 1301 } 1302 1303 // Check nested statements (subqueries, etc.) 1304 if (stmt.getStatements() != null) { 1305 for (int i = 0; i < stmt.getStatements().size(); i++) { 1306 Object nested = stmt.getStatements().get(i); 1307 if (nested instanceof TCustomSqlStatement) { 1308 TCustomSqlStatement found = findStatementWithTable((TCustomSqlStatement) nested, targetTable); 1309 if (found != null) return found; 1310 } 1311 } 1312 } 1313 1314 // For SELECT statements, check left/right for UNION queries 1315 if (stmt instanceof TSelectSqlStatement) { 1316 TSelectSqlStatement selectStmt = (TSelectSqlStatement) stmt; 1317 if (selectStmt.isCombinedQuery()) { 1318 TCustomSqlStatement found = findStatementWithTable(selectStmt.getLeftStmt(), targetTable); 1319 if (found != null) return found; 1320 found = findStatementWithTable(selectStmt.getRightStmt(), targetTable); 1321 if (found != null) return found; 1322 } 1323 } 1324 1325 return null; 1326 } 1327 1328 /** 1329 * Expand unqualified star column to all tables in the FROM clause 1330 */ 1331 private void expandStarToAllTables(TObjectName col, Set<String> fields) { 1332 // Find the containing statement - try multiple approaches 1333 TCustomSqlStatement containingStmt = null; 1334 1335 // Approach 1 (PRIORITY): Find via TResultColumn parent 1336 // For star columns in SELECT list, traverse up to find the TResultColumn, 1337 // then get the containing statement from the result column list's parent. 1338 // This is more reliable than parent chain traversal because the star column's 1339 // TObjectName parent may have been modified during resolution. 1340 TParseTreeNode parent = col.getParentObjectName(); 1341 while (parent != null) { 1342 if (parent instanceof gudusoft.gsqlparser.nodes.TResultColumn) { 1343 // Found the result column - now get its parent statement 1344 gudusoft.gsqlparser.nodes.TResultColumn rc = (gudusoft.gsqlparser.nodes.TResultColumn) parent; 1345 TParseTreeNode rcParent = rc.getParentObjectName(); 1346 while (rcParent != null) { 1347 if (rcParent instanceof TCustomSqlStatement) { 1348 containingStmt = (TCustomSqlStatement) rcParent; 1349 break; 1350 } 1351 rcParent = rcParent.getParentObjectName(); 1352 } 1353 break; 1354 } 1355 if (parent instanceof TCustomSqlStatement) { 1356 containingStmt = (TCustomSqlStatement) parent; 1357 break; 1358 } 1359 parent = parent.getParentObjectName(); 1360 } 1361 1362 // Approach 2 (FALLBACK): Try to find via source table's statement 1363 if (containingStmt == null) { 1364 TTable sourceTable = col.getSourceTable(); 1365 if (sourceTable != null && sourceTable.getGsqlparser() != null) { 1366 // Find the statement containing this source table 1367 for (int i = 0; i < statements.size(); i++) { 1368 TCustomSqlStatement stmt = statements.get(i); 1369 containingStmt = findStatementWithTable(stmt, sourceTable); 1370 if (containingStmt != null) break; 1371 } 1372 } 1373 } 1374 1375 if (containingStmt == null || containingStmt.tables == null) { 1376 // Fallback: use first statement if available 1377 if (statements != null && statements.size() > 0) { 1378 TCustomSqlStatement firstStmt = statements.get(0); 1379 if (firstStmt != null && firstStmt.tables != null) { 1380 containingStmt = firstStmt; 1381 } 1382 } 1383 if (containingStmt == null || containingStmt.tables == null) { 1384 return; 1385 } 1386 } 1387 1388 // Add star column for each physical table in the FROM clause 1389 for (int i = 0; i < containingStmt.tables.size(); i++) { 1390 TTable table = containingStmt.tables.getTable(i); 1391 if (table == null) continue; 1392 1393 // Skip non-physical table types 1394 if (table.getTableType() == ETableSource.subquery) continue; 1395 if (table.getTableType() == ETableSource.openquery) continue; 1396 if (table.getTableType() == ETableSource.join) continue; 1397 if (table.isCTEName() && !showCTE) continue; 1398 1399 // Skip implicit lateral derived tables - they shouldn't have star columns 1400 if (table.getEffectType() == ETableEffectType.tetImplicitLateralDerivedTable) continue; 1401 1402 // Skip non-physical tables if onlyPhysicalTables is true 1403 // Note: PIVOT/UNPIVOT and UNNEST column expansion is now handled by the core resolver 1404 // This is a fallback path when the core resolver hasn't expanded the star 1405 if (shouldSkipTableForPhysicalFilter(table)) continue; 1406 1407 String tablePrefix = getFieldTablePrefix(table); 1408 if (tablePrefix != null) { 1409 // For UNNEST tables, add star if requested, then expand individual columns 1410 if (table.getTableType() == ETableSource.unnest) { 1411 if (listStarColumn) { 1412 fields.add(tablePrefix + ".*"); 1413 } 1414 expandUnnestStarColumns(table, tablePrefix, fields); 1415 } else if (table.getTableType() == ETableSource.pivoted_table) { 1416 // For PIVOT/UNPIVOT tables, output the generated columns from PivotNamespace 1417 // These are virtual tables so we don't output table.*, only individual columns 1418 if (buildResult != null) { 1419 INamespace namespace = buildResult.getNamespaceForTable(table); 1420 if (namespace != null) { 1421 Map<String, ColumnSource> columnSources = namespace.getAllColumnSources(); 1422 if (columnSources != null && !columnSources.isEmpty()) { 1423 for (String columnName : columnSources.keySet()) { 1424 if (columnName != null && !columnName.isEmpty()) { 1425 fields.add(tablePrefix + "." + normalizeColumnName(columnName)); 1426 } 1427 } 1428 } 1429 } 1430 } 1431 } else { 1432 // For regular tables, add star column if listStarColumn is true 1433 if (listStarColumn) { 1434 fields.add(tablePrefix + ".*"); 1435 } 1436 } 1437 } 1438 } 1439 } 1440 1441 private String getFieldTablePrefix(TTable table) { 1442 if (table.getTableType() == ETableSource.subquery) { 1443 return "(subquery, alias:" + table.getAliasName() + ")"; 1444 } else if (table.getTableType() == ETableSource.unnest) { 1445 String alias = table.getAliasName(); 1446 return "(unnest-table:" + (alias != null ? alias : "") + ")"; 1447 } else if (table.getTableType() == ETableSource.pivoted_table) { 1448 // Check if this is UNPIVOT or PIVOT 1449 boolean isUnpivot = false; 1450 // First try to get from TPivotClause 1451 if (table.getPivotedTable() != null && table.getPivotedTable().getPivotClause() != null) { 1452 isUnpivot = (table.getPivotedTable().getPivotClause().getType() == TPivotClause.unpivot); 1453 } else { 1454 // Fallback: check if the table alias suggests UNPIVOT 1455 // When no explicit alias is provided, TPivotClause.doParse() uses "unpivot_alias" 1456 String tableName = table.getTableName() != null ? table.getTableName().toString() : ""; 1457 String aliasName = table.getAliasName() != null ? table.getAliasName() : ""; 1458 isUnpivot = tableName.startsWith("unpivot_") || aliasName.startsWith("unpivot_"); 1459 } 1460 String prefix = isUnpivot ? "(unpivot-table:" : "(pivot-table:"; 1461 return prefix + normalizeTableName(table.getTableName()) + ")"; 1462 } else if (table.getTableType() == ETableSource.function) { 1463 return "(table-valued function:" + normalizeTableName(table.getTableName()) + ")"; 1464 } else if (table.getTableType() == ETableSource.lateralView) { 1465 return "(lateral_view:" + normalizeTableName(table.getTableName()) + ")"; 1466 } else if (table.isCTEName() && showCTE) { 1467 String tableName = table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName(); 1468 return tableName + "(CTE)"; 1469 } else { 1470 return table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName(); 1471 } 1472 } 1473 1474 /** 1475 * Normalize a table name using the configured name matcher. 1476 * 1477 * <p>When a VendorNameMatcher is configured, this delegates to the vendor-specific 1478 * qualified name normalization (handles case folding and multi-part identifiers). 1479 * Falls back to manual parsing and quote stripping if no matcher is configured.</p> 1480 * 1481 * @param tableName The TObjectName representing the table name 1482 * @return The normalized table name 1483 */ 1484 private String normalizeTableName(TObjectName tableName) { 1485 if (tableName == null) return ""; 1486 String fullName = tableName.toString(); 1487 if (fullName == null || fullName.isEmpty()) return ""; 1488 1489 // Use DisplayNameNormalizer for all modes - it handles DISPLAY, SQL_RENDER, CANONICAL correctly 1490 if (displayNameNormalizer != null) { 1491 return displayNameNormalizer.normalizeQualifiedName(fullName); 1492 } 1493 1494 // Fallback when no normalizer is available 1495 // Use VendorNameMatcher if available for vendor-specific normalization (includes case folding) 1496 if (nameMatcher instanceof VendorNameMatcher) { 1497 return ((VendorNameMatcher) nameMatcher).normalizeQualifiedName(fullName, ESQLDataObjectType.dotTable); 1498 } 1499 1500 // Fallback: manual parsing with quote stripping 1501 return normalizeTableNameFallback(fullName); 1502 } 1503 1504 /** 1505 * Fallback table name normalization when no vendor matcher is configured. 1506 * Handles both fully-quoted identifiers and multi-part identifiers. 1507 */ 1508 private String normalizeTableNameFallback(String fullName) { 1509 // First check if there are any dots outside of quotes (multi-part identifier) 1510 boolean hasUnquotedDot = false; 1511 char quoteChar = 0; 1512 for (int i = 0; i < fullName.length(); i++) { 1513 char c = fullName.charAt(i); 1514 if (quoteChar == 0) { 1515 if (c == '`' || c == '"' || c == '[') { 1516 quoteChar = (c == '[') ? ']' : c; 1517 } else if (c == '.') { 1518 hasUnquotedDot = true; 1519 break; 1520 } 1521 } else { 1522 if (c == quoteChar) { 1523 quoteChar = 0; 1524 } 1525 } 1526 } 1527 1528 // If no unquoted dots, check if entire string is quoted 1529 if (!hasUnquotedDot) { 1530 return stripQuotes(fullName); 1531 } 1532 1533 // Otherwise, split by dot to handle multi-part identifiers like `schema`.`table` 1534 // But we need to be careful not to split inside quotes 1535 StringBuilder result = new StringBuilder(); 1536 StringBuilder currentPart = new StringBuilder(); 1537 quoteChar = 0; 1538 1539 for (int i = 0; i < fullName.length(); i++) { 1540 char c = fullName.charAt(i); 1541 1542 if (quoteChar == 0) { 1543 // Not inside a quote 1544 if (c == '`' || c == '"' || c == '[') { 1545 quoteChar = (c == '[') ? ']' : c; 1546 currentPart.append(c); 1547 } else if (c == '.') { 1548 // End of a part 1549 if (result.length() > 0) result.append("."); 1550 result.append(stripQuotes(currentPart.toString())); 1551 currentPart.setLength(0); 1552 } else { 1553 currentPart.append(c); 1554 } 1555 } else { 1556 // Inside a quote 1557 currentPart.append(c); 1558 if (c == quoteChar) { 1559 quoteChar = 0; 1560 } 1561 } 1562 } 1563 1564 // Don't forget the last part 1565 if (currentPart.length() > 0) { 1566 if (result.length() > 0) result.append("."); 1567 result.append(stripQuotes(currentPart.toString())); 1568 } 1569 1570 return result.toString(); 1571 } 1572 1573 private void collectConstraintColumns(TCreateTableSqlStatement createStmt, Set<String> fields) { 1574 if (createStmt == null || createStmt.getTargetTable() == null) return; 1575 1576 String tableName = createStmt.getTargetTable().getTableName() != null 1577 ? normalizeTableName(createStmt.getTargetTable().getTableName()) : ""; 1578 1579 // Collect columns from table-level constraints 1580 if (createStmt.getTableConstraints() != null) { 1581 for (int i = 0; i < createStmt.getTableConstraints().size(); i++) { 1582 TConstraint constraint = createStmt.getTableConstraints().getConstraint(i); 1583 collectColumnsFromConstraint(constraint, tableName, fields); 1584 } 1585 } 1586 1587 // Collect columns from column-level constraints (inline constraints) 1588 if (createStmt.getColumnList() != null) { 1589 for (int i = 0; i < createStmt.getColumnList().size(); i++) { 1590 TColumnDefinition colDef = createStmt.getColumnList().getColumn(i); 1591 if (colDef != null && colDef.getConstraints() != null) { 1592 for (int j = 0; j < colDef.getConstraints().size(); j++) { 1593 TConstraint constraint = colDef.getConstraints().getConstraint(j); 1594 collectColumnsFromConstraint(constraint, tableName, fields); 1595 } 1596 } 1597 } 1598 } 1599 } 1600 1601 private void collectAlterTableConstraintColumns(TAlterTableStatement alterStmt, Set<String> fields) { 1602 if (alterStmt == null) return; 1603 1604 // Get table name from getTableName() first, fall back to getTargetTable() 1605 String tableName = ""; 1606 if (alterStmt.getTableName() != null) { 1607 tableName = normalizeTableName(alterStmt.getTableName()); 1608 } else if (alterStmt.getTargetTable() != null && alterStmt.getTargetTable().getTableName() != null) { 1609 tableName = normalizeTableName(alterStmt.getTargetTable().getTableName()); 1610 } 1611 if (tableName.isEmpty()) return; 1612 1613 // Collect from ALTER TABLE options (ADD CONSTRAINT, etc.) 1614 if (alterStmt.getAlterTableOptionList() != null) { 1615 for (int i = 0; i < alterStmt.getAlterTableOptionList().size(); i++) { 1616 TAlterTableOption option = alterStmt.getAlterTableOptionList().getAlterTableOption(i); 1617 if (option == null) continue; 1618 1619 // Handle constraints 1620 if (option.getConstraintList() != null) { 1621 for (int j = 0; j < option.getConstraintList().size(); j++) { 1622 TConstraint constraint = option.getConstraintList().getConstraint(j); 1623 collectColumnsFromConstraint(constraint, tableName, fields); 1624 } 1625 } 1626 1627 // Handle index columns (for AddConstraintPK, AddConstraintUnique, AddConstraintFK) 1628 // These store columns as TPTNodeList<TColumnWithSortOrder> 1629 if (option.getIndexCols() != null) { 1630 for (int j = 0; j < option.getIndexCols().size(); j++) { 1631 TColumnWithSortOrder colWithSort = option.getIndexCols().getElement(j); 1632 if (colWithSort != null && colWithSort.getColumnName() != null) { 1633 String colName = normalizeColumnName(colWithSort.getColumnName().getColumnNameOnly()); 1634 if (colName != null && !colName.isEmpty()) { 1635 fields.add(tableName + "." + colName); 1636 } 1637 } 1638 } 1639 } 1640 1641 // Note: We do NOT collect columns from ALTER COLUMN, DROP COLUMN, RENAME COLUMN, 1642 // CHANGE COLUMN operations. These are DDL targets (columns being modified), 1643 // not column references. The old resolver does not collect these either. 1644 1645 // Handle FOREIGN KEY references (for ADD CONSTRAINT FK) 1646 if (option.getReferencedObjectName() != null && option.getReferencedColumnList() != null) { 1647 String refTableName = normalizeTableName(option.getReferencedObjectName()); 1648 for (int j = 0; j < option.getReferencedColumnList().size(); j++) { 1649 TObjectName colObj = option.getReferencedColumnList().getObjectName(j); 1650 if (colObj != null) { 1651 String colName = normalizeColumnName(colObj.getColumnNameOnly()); 1652 if (colName != null && !colName.isEmpty()) { 1653 fields.add(refTableName + "." + colName); 1654 } 1655 } 1656 } 1657 } 1658 } 1659 } 1660 } 1661 1662 private void collectColumnsFromConstraint(TConstraint constraint, String tableName, Set<String> fields) { 1663 if (constraint == null) return; 1664 1665 // Collect columns from constraint's column list (TPTNodeList<TColumnWithSortOrder>) 1666 if (constraint.getColumnList() != null) { 1667 for (int i = 0; i < constraint.getColumnList().size(); i++) { 1668 TColumnWithSortOrder colWithSort = constraint.getColumnList().getElement(i); 1669 if (colWithSort != null && colWithSort.getColumnName() != null) { 1670 String columnName = normalizeColumnName(colWithSort.getColumnName().getColumnNameOnly()); 1671 if (columnName != null && !columnName.isEmpty()) { 1672 // Constraint columns don't include datatype 1673 // Get location from the column if available 1674 String locationStr = ""; 1675 if (showColumnLocation && colWithSort.getColumnName().getLocation() != null) { 1676 locationStr = "(" + colWithSort.getColumnName().getLocation() + ")"; 1677 } 1678 fields.add(tableName + "." + columnName + locationStr); 1679 } 1680 } 1681 } 1682 } 1683 1684 // Collect columns from FOREIGN KEY REFERENCES 1685 if (constraint.getReferencedColumnList() != null && constraint.getReferencedObject() != null) { 1686 String refTableName = normalizeTableName(constraint.getReferencedObject()); 1687 for (int i = 0; i < constraint.getReferencedColumnList().size(); i++) { 1688 TObjectName colName = constraint.getReferencedColumnList().getObjectName(i); 1689 if (colName != null) { 1690 String columnName = normalizeColumnName(colName.getColumnNameOnly()); 1691 if (columnName != null && !columnName.isEmpty()) { 1692 // Use location already set during parsing 1693 String locationStr = ""; 1694 if (showColumnLocation && colName.getLocation() != null) { 1695 locationStr = "(" + colName.getLocation() + ")"; 1696 } 1697 fields.add(refTableName + "." + columnName + locationStr); 1698 } 1699 } 1700 } 1701 } 1702 } 1703 1704 private void expandUnnestStarColumns(TTable unnestTable, String tablePrefix, Set<String> fields) { 1705 if (unnestTable == null || unnestTable.getTableType() != ETableSource.unnest) { 1706 return; 1707 } 1708 1709 // Get the implicit column name (alias or 'value') 1710 String alias = unnestTable.getAliasName(); 1711 String implicitColumnName = (alias != null && !alias.isEmpty()) ? alias : "value"; 1712 fields.add(tablePrefix + "." + implicitColumnName); 1713 1714 // Check for WITH OFFSET column 1715 TUnnestClause unnestClause = unnestTable.getUnnestClause(); 1716 if (unnestClause != null && unnestClause.getWithOffset() != null) { 1717 String offsetColumnName; 1718 if (unnestClause.getWithOffsetAlais() != null && 1719 unnestClause.getWithOffsetAlais().getAliasName() != null) { 1720 offsetColumnName = unnestClause.getWithOffsetAlais().getAliasName().toString(); 1721 } else { 1722 offsetColumnName = "offset"; 1723 } 1724 fields.add(tablePrefix + "." + offsetColumnName); 1725 } 1726 } 1727 1728 /** 1729 * Normalize a column name using the configured name matcher. 1730 * 1731 * <p>When a VendorNameMatcher is configured, this delegates to the vendor-specific 1732 * normalization logic (handles case folding according to vendor rules). 1733 * Falls back to simple quote stripping if no matcher is configured.</p> 1734 * 1735 * @param columnName The raw column name which may include quotes 1736 * @return The normalized column name 1737 */ 1738 private String normalizeColumnName(String columnName) { 1739 if (columnName == null || columnName.isEmpty()) return columnName; 1740 1741 // Use DisplayNameNormalizer for all modes - it handles DISPLAY, SQL_RENDER, CANONICAL correctly 1742 if (displayNameNormalizer != null) { 1743 return displayNameNormalizer.normalizeIdentifier(columnName); 1744 } 1745 1746 // Fallback when no normalizer is available 1747 // Use VendorNameMatcher if available for vendor-specific normalization (includes case folding) 1748 if (nameMatcher instanceof VendorNameMatcher) { 1749 return ((VendorNameMatcher) nameMatcher).normalize(columnName, ESQLDataObjectType.dotColumn); 1750 } 1751 1752 // Use generic INameMatcher if available 1753 if (nameMatcher != null) { 1754 return nameMatcher.normalize(columnName); 1755 } 1756 1757 // Fallback: simple quote stripping 1758 return stripQuotes(columnName); 1759 } 1760 1761 /** 1762 * Strip surrounding quotes from an identifier. 1763 * This is a fallback when no name matcher is configured. 1764 * 1765 * @param name The identifier which may include quotes 1766 * @return The identifier without surrounding quotes 1767 */ 1768 private String stripQuotes(String name) { 1769 if (name == null || name.isEmpty()) return name; 1770 1771 // Remove surrounding double quotes 1772 if (name.startsWith("\"") && name.endsWith("\"") && name.length() > 1) { 1773 return name.substring(1, name.length() - 1); 1774 } 1775 // Remove surrounding backticks (MySQL) 1776 if (name.startsWith("`") && name.endsWith("`") && name.length() > 1) { 1777 return name.substring(1, name.length() - 1); 1778 } 1779 // Remove surrounding square brackets (SQL Server) 1780 if (name.startsWith("[") && name.endsWith("]") && name.length() > 1) { 1781 return name.substring(1, name.length() - 1); 1782 } 1783 return name; 1784 } 1785 1786 private String getColumnDatatype(TObjectName col) { 1787 if (!showDatatype) return ""; 1788 1789 if (col.getLinkedColumnDef() != null) { 1790 TTypeName datatype = col.getLinkedColumnDef().getDatatype(); 1791 if (datatype != null) { 1792 String typeName = datatype.getDataTypeName(); 1793 if (typeName != null && !typeName.isEmpty()) { 1794 StringBuilder result = new StringBuilder(":" + typeName.toLowerCase()); 1795 // Add length if available (e.g., varchar:30, char:50) 1796 if (datatype.getLength() != null) { 1797 result.append(":").append(datatype.getLength().toString()); 1798 } 1799 // Add precision if available (e.g., decimal:10) 1800 else if (datatype.getPrecision() != null) { 1801 result.append(":").append(datatype.getPrecision().toString()); 1802 // Add scale if available (e.g., decimal:10:2) 1803 if (datatype.getScale() != null) { 1804 result.append(":").append(datatype.getScale().toString()); 1805 } 1806 } 1807 // Add display length if available (e.g., int:11 for MySQL INT(11)) 1808 else if (datatype.getDisplayLength() != null) { 1809 result.append(":").append(datatype.getDisplayLength().toString()); 1810 } 1811 return result.toString(); 1812 } 1813 } 1814 } 1815 return ""; 1816 } 1817 1818 // ========== CTE Column Collection ========== 1819 1820 /** 1821 * Collect CTE columns for the Ctes: output section. 1822 * Prioritizes CTENamespace from resolver when available, falls back to AST traversal. 1823 */ 1824 private void collectCTEColumns(Set<String> cteColumns) { 1825 if (buildResult == null) return; 1826 1827 // Get CTE namespaces from resolver 1828 Map<String, CTENamespace> cteNamespaces = buildResult.getCTENamespaces(); 1829 1830 if (cteNamespaces != null && !cteNamespaces.isEmpty()) { 1831 // Preferred: Use CTENamespace to get columns 1832 for (Map.Entry<String, CTENamespace> entry : cteNamespaces.entrySet()) { 1833 String cteName = entry.getKey(); 1834 CTENamespace ns = entry.getValue(); 1835 1836 Map<String, ColumnSource> columnSources = ns.getAllColumnSources(); 1837 if (columnSources != null) { 1838 for (String colName : columnSources.keySet()) { 1839 cteColumns.add(cteName + "." + colName); 1840 } 1841 } 1842 } 1843 } else { 1844 // Fallback: Collect from AST when namespaces not available 1845 for (int i = 0; i < statements.size(); i++) { 1846 collectCTEColumnsFromAST(statements.get(i), cteColumns); 1847 } 1848 } 1849 } 1850 1851 /** 1852 * Fallback method to collect CTE columns from AST when CTENamespace is not available. 1853 */ 1854 private void collectCTEColumnsFromAST(Object stmt, Set<String> cteColumns) { 1855 if (!(stmt instanceof TSelectSqlStatement)) { 1856 if (stmt instanceof TCustomSqlStatement) { 1857 TCustomSqlStatement customStmt = (TCustomSqlStatement) stmt; 1858 for (int i = 0; i < customStmt.getStatements().size(); i++) { 1859 collectCTEColumnsFromAST(customStmt.getStatements().get(i), cteColumns); 1860 } 1861 } 1862 return; 1863 } 1864 1865 TSelectSqlStatement selectStmt = (TSelectSqlStatement) stmt; 1866 1867 // Handle combined queries 1868 if (selectStmt.isCombinedQuery()) { 1869 collectCTEColumnsFromAST(selectStmt.getLeftStmt(), cteColumns); 1870 collectCTEColumnsFromAST(selectStmt.getRightStmt(), cteColumns); 1871 return; 1872 } 1873 1874 // Process CTEs in this statement 1875 if (selectStmt.getCteList() != null) { 1876 for (int i = 0; i < selectStmt.getCteList().size(); i++) { 1877 TCTE cte = selectStmt.getCteList().getCTE(i); 1878 String cteName = cte.getTableName().toString(); 1879 1880 // Get columns from CTE definition 1881 if (cte.getColumnList() != null) { 1882 for (int j = 0; j < cte.getColumnList().size(); j++) { 1883 TObjectName colName = cte.getColumnList().getObjectName(j); 1884 if (colName != null) { 1885 cteColumns.add(cteName + "." + colName.getColumnNameOnly()); 1886 } 1887 } 1888 } else if (cte.getSubquery() != null) { 1889 // Infer columns from subquery result columns 1890 TResultColumnList resultCols = cte.getSubquery().getResultColumnList(); 1891 if (resultCols != null) { 1892 for (int j = 0; j < resultCols.size(); j++) { 1893 TResultColumn rc = resultCols.getResultColumn(j); 1894 String colName = inferColumnName(rc); 1895 if (colName != null && !"*".equals(colName)) { 1896 cteColumns.add(cteName + "." + colName); 1897 } 1898 } 1899 } 1900 } 1901 } 1902 } 1903 1904 // Recursively process nested statements 1905 for (int i = 0; i < selectStmt.getStatements().size(); i++) { 1906 collectCTEColumnsFromAST(selectStmt.getStatements().get(i), cteColumns); 1907 } 1908 } 1909 1910 private String inferColumnName(TResultColumn rc) { 1911 if (rc == null) return null; 1912 1913 // Check for alias 1914 if (rc.getAliasClause() != null) { 1915 return rc.getAliasClause().toString(); 1916 } 1917 1918 // Check for simple column reference 1919 if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) { 1920 return rc.getExpr().getObjectOperand().getColumnNameOnly(); 1921 } 1922 1923 return null; 1924 } 1925 1926 // ========== Static Factory Methods ========== 1927 1928 /** 1929 * Create a formatter with default settings. 1930 */ 1931 public static TSQLResolver2ResultFormatter create(TSQLResolver2 resolver) { 1932 return new TSQLResolver2ResultFormatter(resolver); 1933 } 1934 1935 /** 1936 * Create a formatter configured to show CTE tables and columns. 1937 */ 1938 public static TSQLResolver2ResultFormatter createWithCTE(TSQLResolver2 resolver) { 1939 return new TSQLResolver2ResultFormatter(resolver).setShowCTE(true); 1940 } 1941 1942 /** 1943 * Find the containing SQL statement for a column reference by traversing up the AST. 1944 * This is used as a fallback when ownStmt is not set (e.g., for PL/SQL package constants). 1945 * 1946 * @param col The column reference 1947 * @return The containing statement, or null if not found 1948 */ 1949 private TCustomSqlStatement findContainingStatement(TObjectName col) { 1950 if (col == null) return null; 1951 1952 TParseTreeNode parent = col.getParentObjectName(); 1953 while (parent != null) { 1954 if (parent instanceof TCustomSqlStatement) { 1955 return (TCustomSqlStatement) parent; 1956 } 1957 parent = parent.getParentObjectName(); 1958 } 1959 return null; 1960 } 1961 1962 /** 1963 * Get the position string for a column reference, using the actual column name part's position 1964 * for multi-part identifiers (e.g., for "sch.pkg.col", use the position of "col" not "sch"). 1965 * This matches TGetTableColumn behavior. 1966 * 1967 * @param col The column reference 1968 * @return Position string in format "(line,column)" 1969 */ 1970 private String getColumnPositionStr(TObjectName col) { 1971 if (col == null) { 1972 return "(0,0)"; 1973 } 1974 // For multi-part names, use the partToken (column name part) position if available 1975 TSourceToken partToken = col.getPartToken(); 1976 if (partToken != null) { 1977 return "(" + partToken.lineNo + "," + partToken.columnNo + ")"; 1978 } 1979 // Fallback to the object name's position 1980 return "(" + col.getLineNo() + "," + col.getColumnNo() + ")"; 1981 } 1982 1983 /** 1984 * Create a formatter configured to show datatypes. 1985 */ 1986 public static TSQLResolver2ResultFormatter createWithDatatype(TSQLResolver2 resolver) { 1987 return new TSQLResolver2ResultFormatter(resolver).setShowDatatype(true); 1988 } 1989 1990 /** 1991 * Create a formatter configured to show CTE column definitions. 1992 */ 1993 public static TSQLResolver2ResultFormatter createWithCTEColumns(TSQLResolver2 resolver) { 1994 return new TSQLResolver2ResultFormatter(resolver).setShowColumnsOfCTE(true); 1995 } 1996 1997 /** 1998 * Read the expected output from a file. 1999 * Utility method for tests to load expected results. 2000 */ 2001 public static String getDesiredTablesColumns(String filePath) { 2002 try { 2003 return new String(java.nio.file.Files.readAllBytes( 2004 java.nio.file.Paths.get(filePath)), java.nio.charset.StandardCharsets.UTF_8); 2005 } catch (java.io.IOException e) { 2006 return ""; 2007 } 2008 } 2009}