001package gudusoft.gsqlparser.resolver2.namespace; 002 003import gudusoft.gsqlparser.nodes.TObjectName; 004import gudusoft.gsqlparser.nodes.TObjectNameList; 005import gudusoft.gsqlparser.nodes.TResultColumn; 006import gudusoft.gsqlparser.nodes.TResultColumnList; 007import gudusoft.gsqlparser.nodes.TTable; 008import gudusoft.gsqlparser.nodes.TPivotClause; 009import gudusoft.gsqlparser.nodes.TPivotInClause; 010import gudusoft.gsqlparser.nodes.TUnpivotInClause; 011import gudusoft.gsqlparser.nodes.TUnpivotInClauseItem; 012import gudusoft.gsqlparser.nodes.TExpression; 013import gudusoft.gsqlparser.nodes.TExpressionList; 014import gudusoft.gsqlparser.nodes.TFunctionCall; 015import gudusoft.gsqlparser.nodes.TConstant; 016import gudusoft.gsqlparser.EDbVendor; 017import gudusoft.gsqlparser.EExpressionType; 018import gudusoft.gsqlparser.resolver2.ColumnLevel; 019import gudusoft.gsqlparser.resolver2.matcher.INameMatcher; 020import gudusoft.gsqlparser.resolver2.model.ColumnSource; 021 022import java.util.*; 023 024/** 025 * Namespace representing a PIVOT table. 026 * 027 * <p>A PIVOT transforms rows into columns using an aggregate function: 028 * <pre> 029 * SELECT ... 030 * FROM source_table 031 * PIVOT (aggregate_function(value_column) FOR pivot_column IN (val1, val2, ...)) AS alias 032 * </pre> 033 * 034 * <p>The PIVOT produces: 035 * <ul> 036 * <li>Columns from the IN clause (val1, val2, ...) - these are the pivoted columns</li> 037 * <li>Pass-through columns from the source table (all columns NOT used in aggregate or FOR clause)</li> 038 * </ul> 039 * 040 * <p>For column resolution purposes: 041 * <ul> 042 * <li>Pivot columns (from IN clause) resolve to the PivotNamespace</li> 043 * <li>Other columns may come from the source table (pass-through columns)</li> 044 * </ul> 045 */ 046public class PivotNamespace extends AbstractNamespace { 047 048 /** The pivot table this namespace represents */ 049 private final TTable pivotTable; 050 051 /** The source table that the PIVOT operates on */ 052 private final TTable sourceTable; 053 054 /** The PIVOT clause */ 055 private final TPivotClause pivotClause; 056 057 /** Alias for this pivot table (from AS clause) */ 058 private final String alias; 059 060 /** Namespace for the source table (for pass-through column resolution) */ 061 private INamespace sourceNamespace; 062 063 /** Columns defined by the PIVOT IN clause */ 064 private final Set<String> pivotColumns = new LinkedHashSet<>(); 065 066 /** Columns consumed by UNPIVOT IN (...) list (these do NOT exist as output columns) */ 067 private final Set<String> unpivotInColumns = new LinkedHashSet<>(); 068 069 /** Display name format for PIVOT table */ 070 public static final String PIVOT_TABLE_SUFFIX = "(piviot_table)"; 071 072 /** Display name format for UNPIVOT table */ 073 public static final String UNPIVOT_TABLE_SUFFIX = "(unpivot_table)"; 074 075 /** Whether this is an UNPIVOT clause */ 076 private boolean isUnpivot = false; 077 078 public PivotNamespace(TTable pivotTable, TPivotClause pivotClause, 079 TTable sourceTable, String alias, INameMatcher nameMatcher) { 080 super(pivotTable, nameMatcher); 081 this.pivotTable = pivotTable; 082 this.pivotClause = pivotClause; 083 this.sourceTable = sourceTable; 084 this.alias = alias; 085 // Check if this is an UNPIVOT clause 086 if (pivotClause != null) { 087 this.isUnpivot = (pivotClause.getType() == TPivotClause.unpivot); 088 } 089 } 090 091 /** 092 * Check if this is an UNPIVOT namespace. 093 */ 094 public boolean isUnpivot() { 095 return isUnpivot; 096 } 097 098 /** 099 * Set the namespace representing the source table. 100 * This is used for resolving pass-through columns. 101 */ 102 public void setSourceNamespace(INamespace sourceNamespace) { 103 this.sourceNamespace = sourceNamespace; 104 } 105 106 /** 107 * Get the namespace representing the source table. 108 */ 109 public INamespace getSourceNamespace() { 110 return sourceNamespace; 111 } 112 113 /** 114 * Get the pivot table. 115 */ 116 public TTable getPivotTable() { 117 return pivotTable; 118 } 119 120 /** 121 * Get the underlying table that PIVOT operates on. 122 * This is the table whose columns are being pivoted. 123 */ 124 public TTable getUnderlyingSourceTable() { 125 return sourceTable; 126 } 127 128 /** 129 * {@inheritDoc} 130 * For PivotNamespace, returns the PIVOT table (the result of the PIVOT operation). 131 * This is the immediate source table for columns resolved through this PIVOT. 132 */ 133 @Override 134 public TTable getSourceTable() { 135 return pivotTable; 136 } 137 138 /** 139 * Get the PIVOT clause. 140 */ 141 public TPivotClause getPivotClause() { 142 return pivotClause; 143 } 144 145 /** 146 * Get the set of pivot column names (from IN clause). 147 */ 148 public Set<String> getPivotColumns() { 149 return Collections.unmodifiableSet(pivotColumns); 150 } 151 152 /** 153 * Check if a column is a pivot column (from IN clause). 154 */ 155 public boolean isPivotColumn(String columnName) { 156 if (columnName == null) return false; 157 for (String pivotCol : pivotColumns) { 158 if (nameMatcher.matches(lookupSpelling(pivotCol), columnName)) { 159 return true; 160 } 161 } 162 return false; 163 } 164 165 @Override 166 public String getDisplayName() { 167 String suffix = isUnpivot ? UNPIVOT_TABLE_SUFFIX : PIVOT_TABLE_SUFFIX; 168 if (alias != null && !alias.isEmpty()) { 169 return alias + suffix; 170 } 171 return (isUnpivot ? "unpivot_alias" : "pivot_alias") + suffix; 172 } 173 174 @Override 175 public TTable getFinalTable() { 176 // Return the pivot table itself so that pivot columns are properly attributed 177 // to the pivot table (e.g., "(pivot-table:p(piviot_table)).[1]") 178 // For lineage tracing to source columns, use getAllFinalTables() 179 return pivotTable; 180 } 181 182 @Override 183 public List<TTable> getAllFinalTables() { 184 // Return source table for lineage tracing 185 if (sourceTable != null) { 186 List<TTable> tables = new ArrayList<>(); 187 tables.add(sourceTable); 188 return tables; 189 } 190 return Collections.emptyList(); 191 } 192 193 @Override 194 protected void doValidate() { 195 columnSources = new LinkedHashMap<>(); 196 pivotColumns.clear(); 197 unpivotInColumns.clear(); 198 199 if (pivotClause == null) { 200 return; 201 } 202 203 lookupSpellings.clear(); 204 for (String[] generated : generatedColumns(pivotClause)) { 205 String columnName = generated[0]; 206 if (columnName == null || columnName.isEmpty()) { 207 continue; 208 } 209 // a name stored as written (Oracle's 'direct') is looked up by its delimited spelling ("'direct'") 210 String lookup = generated[2] != null ? encodeStored(columnName) : columnName; 211 boolean known = false; 212 for (String existing : columnSources.keySet()) { 213 known |= nameMatcher.matches(lookupSpelling(existing), lookup); 214 } 215 if (!known) { 216 if (generated[2] != null) { 217 lookupSpellings.put(columnName, lookup); 218 } 219 addPivotColumn(columnName, generated[1]); 220 } 221 } 222 223 if (isUnpivot) { 224 // UNPIVOT case: Collect consumed IN(...) source columns so they are NOT treated as pass-through 225 // Example: UNPIVOT (col3 FOR col4 IN (p.col2, p.col3)) 226 // - p.col2, p.col3 are consumed source columns and should not be visible via unpivot alias 227 TUnpivotInClause unpivotInClause = pivotClause.getUnpivotInClause(); 228 if (unpivotInClause != null && unpivotInClause.getItems() != null) { 229 for (int i = 0; i < unpivotInClause.getItems().size(); i++) { 230 TUnpivotInClauseItem item = unpivotInClause.getItems().getElement(i); 231 if (item == null) continue; 232 233 if (item.getColumn() != null) { 234 String name = item.getColumn().getColumnNameOnly(); 235 if (name != null && !name.isEmpty()) { 236 unpivotInColumns.add(name); 237 } 238 } 239 if (item.getColumnList() != null) { 240 TObjectNameList cols = item.getColumnList(); 241 for (int j = 0; j < cols.size(); j++) { 242 TObjectName col = cols.getObjectName(j); 243 if (col != null) { 244 String name = col.getColumnNameOnly(); 245 if (name != null && !name.isEmpty()) { 246 unpivotInColumns.add(name); 247 } 248 } 249 } 250 } 251 } 252 } 253 } 254 } 255 256 /** 257 * The columns a PIVOT or UNPIVOT operator generates, in text order, each as {name, evidence, stored}, duplicates 258 * included (this namespace keeps the first of names its matcher equates). {@code stored} is non-null when the 259 * name is the column's stored name as written, an IN value of a vendor that names the column after the literal 260 * ({@link #inListValueColumnName}): it is looked up by its delimited spelling, never folded as an unquoted 261 * identifier ({@code IN ('direct')} is referenced as {@code "'direct'"}, not as {@code "'DIRECT'"}). 262 * 263 * <p>UNPIVOT: its value columns, then its FOR columns. PIVOT: the names of an alias column list ({@code AS p 264 * (a, b)}), which replace the generated names, else one name per IN item ({@link #inListItemAlias}, else the 265 * value: {@link #inListValueColumnName}), the columns of an IN subquery, the values of an IN value list.</p> 266 * 267 * <p>The one rule for the names a pivot generates: this namespace resolves by it, and the SQL-LR producer 268 * publishes by it ({@code TPivotClause.getGeneratedColumnNames()}, plan 09 slice 10), so a name is never 269 * published under one spelling and looked up under another.</p> 270 */ 271 public static List<String[]> generatedColumns(TPivotClause pivotClause) { 272 List<String[]> out = new ArrayList<>(); 273 if (pivotClause == null) { 274 return out; 275 } 276 if (pivotClause.getType() == TPivotClause.unpivot) { 277 // UNPIVOT (yearly_total FOR order_mode IN (store AS 'direct', internet AS 'online')) 278 // - yearly_total is the value column (new column containing values) 279 // - order_mode is the FOR column (new column containing labels) 280 // Note: For single value column UNPIVOT (like Oracle), use getValueColumn() (deprecated singular) 281 // For multi-value column UNPIVOT (like SQL Server), use getValueColumnList() 282 TObjectNameList valueColumns = pivotClause.getValueColumnList(); 283 if (valueColumns != null && valueColumns.size() > 0) { 284 for (int i = 0; i < valueColumns.size(); i++) { 285 TObjectName valueCol = valueColumns.getObjectName(i); 286 if (valueCol != null) { 287 out.add(new String[] {valueCol.getColumnNameOnly(), "unpivot_value_column", null}); 288 } 289 } 290 } else { 291 // Fallback to deprecated singular method for Oracle compatibility 292 @SuppressWarnings("deprecation") 293 TObjectName valueCol = pivotClause.getValueColumn(); 294 if (valueCol != null) { 295 out.add(new String[] {valueCol.getColumnNameOnly(), "unpivot_value_column", null}); 296 } 297 } 298 TObjectNameList pivotColumnList = pivotClause.getPivotColumnList(); 299 if (pivotColumnList != null && pivotColumnList.size() > 0) { 300 for (int i = 0; i < pivotColumnList.size(); i++) { 301 TObjectName forCol = pivotColumnList.getObjectName(i); 302 if (forCol != null) { 303 out.add(new String[] {forCol.getColumnNameOnly(), "unpivot_for_column", null}); 304 } 305 } 306 } else { 307 @SuppressWarnings("deprecation") 308 TObjectName forCol = pivotClause.getPivotColumn(); 309 if (forCol != null) { 310 out.add(new String[] {forCol.getColumnNameOnly(), "unpivot_for_column", null}); 311 } 312 } 313 return out; 314 } 315 // PIVOT: if the alias clause has a column list (e.g., AS p (col1, col2, col3)), its names REPLACE the 316 // generated ones (e.g., PIVOT(...) AS p (empid_renamed, Q1, Q2, Q3, Q4)) 317 if (pivotClause.getAliasClause() != null && pivotClause.getAliasClause().getColumns() != null 318 && pivotClause.getAliasClause().getColumns().size() > 0) { 319 for (TObjectName column : pivotClause.getAliasClause().getColumns()) { 320 // verbatim, quote state included, as inListItemAlias: p ("MiX") names MiX, not MIX (plan 09 slice 10) 321 out.add(new String[] {column.toString(), "pivot_alias_clause", null}); 322 } 323 return out; 324 } 325 TPivotInClause inClause = pivotClause.getPivotInClause(); 326 if (inClause == null) { 327 return out; 328 } 329 // Case 1: IN clause has items (e.g., IN ([1], [2], [3]) or IN ([Sammich], [Pickle])) 330 if (inClause.getItems() != null) { 331 TResultColumnList items = inClause.getItems(); 332 for (int i = 0; i < items.size(); i++) { 333 TResultColumn item = items.getResultColumn(i); 334 out.add(new String[] {inListItemColumnName(item), "pivot_in_clause", 335 inListItemStored(item) ? "stored" : null}); 336 } 337 } 338 // Case 2: IN clause has a subquery (e.g., IN (SELECT DISTINCT col FROM table)) 339 if (inClause.getSubQuery() != null) { 340 TResultColumnList subqueryColumns = inClause.getSubQuery().getResultColumnList(); 341 if (subqueryColumns != null) { 342 for (int i = 0; i < subqueryColumns.size(); i++) { 343 out.add(new String[] {stripDelimitersFrom(subqueryColumns.getResultColumn(i).getDisplayName()), 344 "pivot_in_subquery", null}); 345 } 346 } 347 } 348 // Case 3: IN clause has value list (e.g., IN ((val1, val2), (val3, val4))) 349 if (inClause.getValueList() != null) { 350 for (TExpressionList exprList : inClause.getValueList()) { 351 if (exprList != null) { 352 for (int i = 0; i < exprList.size(); i++) { 353 out.add(new String[] {extractColumnNameFromExpression(exprList.getExpression(i)), 354 "pivot_in_valuelist", null}); 355 } 356 } 357 } 358 } 359 return out; 360 } 361 362 /** The names of {@link #generatedColumns}, in order, duplicates included. */ 363 public static List<String> generatedColumnNames(TPivotClause pivotClause) { 364 List<String> names = new ArrayList<>(); 365 for (String[] generated : generatedColumns(pivotClause)) { 366 names.add(generated[0]); 367 } 368 return names; 369 } 370 371 /** Whether each name of {@link #generatedColumnNames} is a stored name as written (see {@link #generatedColumns}). */ 372 public static List<Boolean> generatedColumnsStored(TPivotClause pivotClause) { 373 List<Boolean> stored = new ArrayList<>(); 374 for (String[] generated : generatedColumns(pivotClause)) { 375 stored.add(Boolean.valueOf(generated[2] != null)); 376 } 377 return stored; 378 } 379 380 /** 381 * The name a PIVOT IN-list item gives its generated column: its alias ({@link #inListItemAlias}), else the name 382 * its value gives ({@link #inListValueColumnName}). The pre-resolver2 path ({@code TPivotInClause.linkColumnToTable}) 383 * publishes by it too. 384 */ 385 public static String inListItemColumnName(TResultColumn resultColumn) { 386 return extractPivotColumnName(resultColumn); 387 } 388 389 /** Whether the name of an IN-list item is its value's literal as written, a stored name (see generatedColumns). */ 390 public static boolean inListItemStored(TResultColumn resultColumn) { 391 if (resultColumn == null || inListItemAlias(resultColumn) != null || resultColumn.getExpr() == null) { 392 return false; 393 } 394 TExpression expr = resultColumn.getExpr(); 395 return expr.getExpressionType() == EExpressionType.simple_constant_t && expr.getConstantOperand() != null 396 && expr.getConstantOperand().getValueToken() != null && keepsLiteralAsWritten(expr.dbvendor); 397 } 398 399 /** 400 * The vendors that name the column of an unaliased IN value after the literal as written, quotes included: 401 * Oracle and Snowflake (Mantis 4662), Teradata (its documentation: the values are enclosed in double quotes and 402 * become the alias names, so {@code IN ('JAN')} is referenced as {@code "'JAN'"}). 403 */ 404 private static boolean keepsLiteralAsWritten(EDbVendor vendor) { 405 return vendor == EDbVendor.dbvsnowflake || vendor == EDbVendor.dbvoracle || vendor == EDbVendor.dbvteradata; 406 } 407 408 /** Display name -> lookup spelling of the generated names stored as written. */ 409 private final Map<String, String> lookupSpellings = new java.util.HashMap<>(); 410 411 /** The spelling a generated column is matched by: the delimited stored name, or the name itself. */ 412 private String lookupSpelling(String name) { 413 String lookup = lookupSpellings.get(name); 414 return lookup != null ? lookup : name; 415 } 416 417 /** The delimited spelling of a stored name, under the analysis's identifier profile. */ 418 private String encodeStored(String stored) { 419 gudusoft.gsqlparser.sqlenv.IdentifierProfile profile = nameMatcher 420 instanceof gudusoft.gsqlparser.resolver2.matcher.VendorNameMatcher 421 ? ((gudusoft.gsqlparser.resolver2.matcher.VendorNameMatcher) nameMatcher).getIdentifierService() 422 .getProfile() 423 : gudusoft.gsqlparser.sqlenv.IdentifierProfile.forVendor(pivotClause.dbvendor, 424 gudusoft.gsqlparser.sqlenv.IdentifierProfile.VendorFlags.defaults()); 425 return gudusoft.gsqlparser.sqlenv.IdentifierCodec.encodeStored(profile, 426 gudusoft.gsqlparser.sqlenv.ESQLDataObjectType.dotColumn, stored); 427 } 428 429 /** 430 * Extract the pivot column name from a result column in the IN clause. 431 * Handles both column references and constant values. 432 */ 433 private static String extractPivotColumnName(TResultColumn resultColumn) { 434 if (resultColumn == null) return null; 435 436 // An explicit alias names the generated column outright. 437 String alias = inListItemAlias(resultColumn); 438 if (alias != null) { 439 return alias; 440 } 441 442 TExpression expr = resultColumn.getExpr(); 443 if (expr != null) { 444 return extractColumnNameFromExpression(expr); 445 } 446 447 // Fallback to display name 448 return stripDelimitersFrom(resultColumn.getDisplayName()); 449 } 450 451 /** 452 * The explicit alias of one PIVOT IN-list item, or null when it has none. 453 * 454 * <p>{@code IN ('direct' AS Store)} names the generated column {@code Store}. 455 * Oracle, Snowflake, BigQuery and Teradata all accept this form and in every one 456 * of them the alias — not the value — is the output column name, so it takes 457 * precedence over any value-derived naming.</p> 458 * 459 * <p>Shared with {@code ScopeBuilder.addPivotInClauseColumns} and matched by 460 * {@code TPivotInClause.linkColumnToTable} on purpose. The name a pivot column 461 * is <em>published</em> under and the name it can be <em>resolved</em> by have 462 * to be produced by the same rule, or a reference to the real column reports 463 * NOT_FOUND while a stale name still matches.</p> 464 * 465 * <p>The alias is returned <em>verbatim</em>, quote state included. Stripping it 466 * would be wrong in both directions for {@code IN ('x' AS "Store")}: bare 467 * {@code SELECT Store} would become a false exact match, and the only valid 468 * reference, {@code SELECT "Store"}, would report NOT_FOUND. Retaining quote 469 * state is what the identifier normalization guide requires; comparison is the 470 * matcher's job, not this method's.</p> 471 */ 472 public static String inListItemAlias(TResultColumn resultColumn) { 473 if (resultColumn == null 474 || resultColumn.getAliasClause() == null 475 || resultColumn.getAliasClause().getAliasName() == null) { 476 return null; 477 } 478 String alias = resultColumn.getAliasClause().getAliasName().toString(); 479 return (alias == null || alias.isEmpty()) ? null : alias; 480 } 481 482 /** 483 * The name a PIVOT gives to the column generated by one unaliased IN-list value. 484 * 485 * <p>Vendor-specific, and the difference is not cosmetic. Oracle, Snowflake and 486 * Teradata name the column after the literal <em>as written</em>, quotes included, so 487 * {@code IN ('direct')} yields a column really named {@code 'direct'} that must 488 * be referenced as {@code "'direct'"} (Mantis 4662; that name is stored as written, 489 * so it is looked up by its delimited spelling: {@link #generatedColumns}). BigQuery requires its 490 * IN-list values to form valid identifiers and names the column {@code direct}, 491 * so the quotes are still stripped there and for every other vendor.</p> 492 * 493 * <p>Shared with {@code ScopeBuilder} for the same reason as 494 * {@link #inListItemAlias}.</p> 495 */ 496 public static String inListValueColumnName(EDbVendor vendor, String rawValue) { 497 if (keepsLiteralAsWritten(vendor)) { 498 return rawValue; 499 } 500 return stripDelimitersFrom(rawValue); 501 } 502 503 /** Strip a leading+trailing quote pair. SQL Server brackets are part of the identity. */ 504 public static String stripDelimitersFrom(String name) { 505 if (name == null || name.isEmpty()) { 506 return name; 507 } 508 // Strip double quotes (BigQuery style identifier) 509 if (name.startsWith("\"") && name.endsWith("\"") && name.length() > 2) { 510 return name.substring(1, name.length() - 1); 511 } 512 // Strip single quotes (string literal) 513 if (name.startsWith("'") && name.endsWith("'") && name.length() > 2) { 514 return name.substring(1, name.length() - 1); 515 } 516 // SQL Server brackets [] are preserved as they're part of the column identity 517 return name; 518 } 519 520 /** 521 * Extract column name from an expression (handles constants and column references). 522 */ 523 private static String extractColumnNameFromExpression(TExpression expr) { 524 if (expr == null) return null; 525 526 EExpressionType exprType = expr.getExpressionType(); 527 528 // Column reference (e.g., [Sammich], [Apple], "HOUSTON" in BigQuery) 529 // For BigQuery, double-quoted identifiers like "HOUSTON" are parsed as simple_object_name_t 530 // and we need to strip the quotes for display purposes. 531 if (exprType == EExpressionType.simple_object_name_t) { 532 TObjectName objName = expr.getObjectOperand(); 533 if (objName != null) { 534 // Strip delimiters (quotes) for display - per identifier_normalization.md 535 return stripDelimitersFrom(objName.toString()); 536 } 537 } 538 539 // Constant value (e.g., [1], [2], 'value', "HOUSTON") 540 if (exprType == EExpressionType.simple_constant_t) { 541 TConstant constant = expr.getConstantOperand(); 542 if (constant != null && constant.getValueToken() != null) { 543 // Vendor-specific: Oracle/Snowflake keep the literal as written. 544 return inListValueColumnName(expr.dbvendor, constant.getValueToken().toString()); 545 } 546 } 547 548 // Fallback: use expression string with delimiters stripped 549 return stripDelimitersFrom(expr.toString()); 550 } 551 552 /** 553 * Add a pivot column to the namespace. 554 */ 555 private void addPivotColumn(String columnName, String evidence) { 556 pivotColumns.add(columnName); 557 ColumnSource source = new ColumnSource( 558 this, 559 columnName, 560 null, 561 1.0, // Definite - from PIVOT IN clause 562 evidence 563 ); 564 columnSources.put(columnName, source); 565 } 566 567 @Override 568 public ColumnLevel hasColumn(String columnName) { 569 ensureValidated(); 570 571 // First check if it's a pivot column 572 if (isPivotColumn(columnName)) { 573 return ColumnLevel.EXISTS; 574 } 575 576 // UNPIVOT: IN(...) columns are consumed and do NOT exist in the virtual table output 577 if (isUnpivot && isUnpivotInColumn(columnName)) { 578 return ColumnLevel.NOT_EXISTS; 579 } 580 581 // Check in columnSources 582 for (String existingCol : columnSources.keySet()) { 583 if (nameMatcher.matches(lookupSpelling(existingCol), columnName)) { 584 return ColumnLevel.EXISTS; 585 } 586 } 587 588 // For PIVOT tables without explicit column list, any column MAYBE exists 589 // (pass-through columns from source table) 590 return ColumnLevel.MAYBE; 591 } 592 593 @Override 594 public ColumnSource resolveColumn(String columnName) { 595 ensureValidated(); 596 597 // First try to find in pivot columns (from IN clause) 598 for (Map.Entry<String, ColumnSource> entry : columnSources.entrySet()) { 599 if (nameMatcher.matches(lookupSpelling(entry.getKey()), columnName)) { 600 return entry.getValue(); 601 } 602 } 603 604 // UNPIVOT: IN(...) columns are consumed and must NOT resolve as pass-through columns 605 if (isUnpivot && isUnpivotInColumn(columnName)) { 606 return null; 607 } 608 609 // Delta 2: Delegate to source namespace for pass-through columns 610 // Pass-through columns are all source columns EXCEPT: 611 // - The FOR column (pivot_column) 612 // - The aggregate input column (value_column) 613 if (sourceNamespace != null) { 614 // Check if this is a pass-through column (not FOR or aggregate column) 615 if (!isForColumn(columnName) && !isAggregateInputColumn(columnName)) { 616 ColumnSource sourceColumn = sourceNamespace.resolveColumn(columnName); 617 if (sourceColumn != null) { 618 // Both PIVOT and UNPIVOT pass-through columns keep their source table attribution 619 // (e.g., SELECT year FROM ... UNPIVOT ... -> year attributed to source table) 620 // This matches the expected behavior where passthrough columns should be 621 // attributed to their original source table, not the PIVOT/UNPIVOT virtual table. 622 return sourceColumn; 623 } 624 } 625 } 626 627 return null; 628 } 629 630 private boolean isUnpivotInColumn(String columnName) { 631 if (columnName == null || columnName.isEmpty()) return false; 632 for (String consumed : unpivotInColumns) { 633 if (nameMatcher.matches(consumed, columnName)) { 634 return true; 635 } 636 } 637 return false; 638 } 639 640 /** 641 * Check if the column is the FOR column (pivot_column) in the PIVOT clause. 642 * The FOR column is consumed by PIVOT and should not be passed through. 643 */ 644 private boolean isForColumn(String columnName) { 645 if (pivotClause == null || columnName == null) { 646 return false; 647 } 648 649 // Try pivotColumnList first (modern API, used by BigQuery and others) 650 TObjectNameList pivotColumns = pivotClause.getPivotColumnList(); 651 if (pivotColumns != null && pivotColumns.size() > 0) { 652 for (int i = 0; i < pivotColumns.size(); i++) { 653 TObjectName forColumn = pivotColumns.getObjectName(i); 654 if (forColumn != null) { 655 String forColName = forColumn.getColumnNameOnly(); 656 if (forColName != null && nameMatcher.matches(forColName, columnName)) { 657 return true; 658 } 659 } 660 } 661 } 662 663 // Fallback to deprecated singular method 664 @SuppressWarnings("deprecation") 665 TObjectName forColumn = pivotClause.getPivotColumn(); 666 if (forColumn != null) { 667 String forColName = forColumn.getColumnNameOnly(); 668 if (forColName != null && nameMatcher.matches(forColName, columnName)) { 669 return true; 670 } 671 } 672 return false; 673 } 674 675 /** 676 * Check if the column is the aggregate input column (value_column) in the PIVOT clause. 677 * The aggregate input column is consumed by PIVOT and should not be passed through. 678 */ 679 private boolean isAggregateInputColumn(String columnName) { 680 if (pivotClause == null || columnName == null) { 681 return false; 682 } 683 684 // Try to extract columns from aggregate function (SQL Server / BigQuery style) 685 // e.g., PIVOT(AVG(departure_delay) FOR airline IN (...)) 686 TFunctionCall aggFunc = pivotClause.getAggregation_function(); 687 if (aggFunc != null && aggFunc.getArgs() != null) { 688 for (int i = 0; i < aggFunc.getArgs().size(); i++) { 689 TExpression argExpr = aggFunc.getArgs().getExpression(i); 690 if (argExpr != null && argExpr.getObjectOperand() != null) { 691 TObjectName argCol = argExpr.getObjectOperand(); 692 String argColName = argCol.getColumnNameOnly(); 693 if (argColName != null && nameMatcher.matches(argColName, columnName)) { 694 return true; 695 } 696 } 697 } 698 } 699 700 // Try to extract columns from aggregate function list (Oracle style) 701 TResultColumnList aggFuncList = pivotClause.getAggregation_function_list(); 702 if (aggFuncList != null) { 703 for (int i = 0; i < aggFuncList.size(); i++) { 704 TResultColumn rc = aggFuncList.getResultColumn(i); 705 if (rc != null && rc.getExpr() != null) { 706 TExpression expr = rc.getExpr(); 707 if (expr.getFunctionCall() != null && expr.getFunctionCall().getArgs() != null) { 708 for (int j = 0; j < expr.getFunctionCall().getArgs().size(); j++) { 709 TExpression argExpr = expr.getFunctionCall().getArgs().getExpression(j); 710 if (argExpr != null && argExpr.getObjectOperand() != null) { 711 TObjectName argCol = argExpr.getObjectOperand(); 712 String argColName = argCol.getColumnNameOnly(); 713 if (argColName != null && nameMatcher.matches(argColName, columnName)) { 714 return true; 715 } 716 } 717 } 718 } 719 } 720 } 721 } 722 723 // Fallback: use valueColumnList (for UNPIVOT or direct value column specification) 724 TObjectNameList valueColumns = pivotClause.getValueColumnList(); 725 if (valueColumns != null && valueColumns.size() > 0) { 726 for (int i = 0; i < valueColumns.size(); i++) { 727 TObjectName valueCol = valueColumns.getObjectName(i); 728 if (valueCol != null) { 729 String valueColName = valueCol.getColumnNameOnly(); 730 if (valueColName != null && nameMatcher.matches(valueColName, columnName)) { 731 return true; 732 } 733 } 734 } 735 } 736 737 // Fallback to deprecated singular method 738 @SuppressWarnings("deprecation") 739 TObjectName valueCol = pivotClause.getValueColumn(); 740 if (valueCol != null) { 741 String valueColName = valueCol.getColumnNameOnly(); 742 if (valueColName != null && nameMatcher.matches(valueColName, columnName)) { 743 return true; 744 } 745 } 746 return false; 747 } 748 749 /** 750 * Get all column sources for this PIVOT namespace. 751 * This includes both pivot columns (from IN clause) and pass-through columns from the source. 752 * 753 * <p>For PIVOT, the output columns are: 754 * <ul> 755 * <li>Pivot columns: generated from IN clause values (e.g., 'AA', 'KH', 'DL', '9E')</li> 756 * <li>Pass-through columns: source columns NOT consumed by FOR or aggregate</li> 757 * </ul> 758 * 759 * <p>This method is critical for star column expansion: when outer query uses SELECT * 760 * from a PIVOT table, we need to return ALL columns the pivot table exposes. 761 */ 762 @Override 763 public Map<String, ColumnSource> getAllColumnSources() { 764 ensureValidated(); 765 766 if (columnSources == null) { 767 return Collections.emptyMap(); 768 } 769 770 // Start with pivot columns (from IN clause) 771 Map<String, ColumnSource> allColumns = new LinkedHashMap<>(columnSources); 772 773 // Add pass-through columns from source namespace 774 // Pass-through = all source columns EXCEPT FOR column and aggregate input column 775 if (sourceNamespace != null) { 776 Map<String, ColumnSource> sourceColumns = sourceNamespace.getAllColumnSources(); 777 for (Map.Entry<String, ColumnSource> entry : sourceColumns.entrySet()) { 778 String colName = entry.getKey(); 779 780 // Skip if already a pivot column (from IN clause). Slice S1: 781 // matcher-aware containment so case-only-different source 782 // column names don't slip past on case-insensitive vendors. 783 // Both sides by their lookup spellings: a generated name stored as 784 // written ('direct') is no source column 'DIRECT' (plan 09 slice 10) 785 String sourceLookup = sourceNamespace instanceof TableNamespace 786 ? ((TableNamespace) sourceNamespace).getColumnLookupSpelling(colName) : colName; 787 boolean generated = false; 788 for (String existing : allColumns.keySet()) { 789 generated |= nameMatcher.matches(lookupSpelling(existing), sourceLookup); 790 } 791 if (generated) { 792 continue; 793 } 794 795 // Skip FOR column (consumed by PIVOT) 796 if (isForColumn(colName)) { 797 continue; 798 } 799 800 // Skip aggregate input column (consumed by PIVOT) 801 if (isAggregateInputColumn(colName)) { 802 continue; 803 } 804 805 // This is a pass-through column - add it with pivot table attribution 806 ColumnSource sourceCol = entry.getValue(); 807 ColumnSource passthroughCol = new ColumnSource( 808 this, // PivotNamespace 809 colName, 810 sourceCol.getDefinitionNode(), 811 sourceCol.getConfidence(), 812 "pivot_passthrough", 813 pivotTable, // attribute to pivot table 814 null 815 ); 816 allColumns.put(colName, passthroughCol); 817 } 818 } 819 return Collections.unmodifiableMap(allColumns); 820 } 821 822 @Override 823 public String toString() { 824 return "PivotNamespace(" + getDisplayName() + ", pivotCols=" + pivotColumns.size() + ")"; 825 } 826}