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