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}