001package gudusoft.gsqlparser.resolver2.namespace;
002
003import gudusoft.gsqlparser.nodes.TObjectNameList;
004import gudusoft.gsqlparser.nodes.TResultColumn;
005import gudusoft.gsqlparser.nodes.TResultColumnList;
006import gudusoft.gsqlparser.nodes.TTable;
007import gudusoft.gsqlparser.resolver2.ColumnLevel;
008import gudusoft.gsqlparser.resolver2.matcher.INameMatcher;
009import gudusoft.gsqlparser.resolver2.matcher.VendorNameMatcher;
010import gudusoft.gsqlparser.resolver2.model.ColumnSource;
011import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
012import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
013
014import java.util.*;
015
016/**
017 * Namespace representing a UNION/INTERSECT/EXCEPT query.
018 *
019 * Key characteristics:
020 * - Schema is defined by the FIRST branch (SQL standard)
021 * - Columns must be pushed down to ALL branches
022 * - hasStarColumn() returns true if ANY branch has SELECT *
023 * - addInferredColumn() propagates to ALL branches
024 *
025 * Example:
026 * FROM (
027 *     SELECT * FROM table_1
028 *     UNION ALL
029 *     SELECT * FROM table_2
030 *     UNION ALL
031 *     SELECT * FROM table_3
032 * ) Combined
033 *
034 * When outer query references "col_1", it should be pushed to ALL branches.
035 */
036public class UnionNamespace extends AbstractNamespace {
037
038    private final TSelectSqlStatement unionQuery;
039    private final String alias;
040
041    /** All branches of the UNION (flattened) */
042    private final List<TSelectSqlStatement> allBranches;
043
044    /** The derived table's alias column list, {@code (... UNION ALL ...) AS r(rank, type)}: renames the
045     *  columns by position (Mantis 4814). Null when absent. */
046    private TObjectNameList aliasColumns;
047
048    public void setAliasColumns(TObjectNameList aliasColumns) {
049        this.aliasColumns = (aliasColumns != null && aliasColumns.size() > 0) ? aliasColumns : null;
050    }
051
052    public TObjectNameList getAliasColumns() {
053        return aliasColumns;
054    }
055
056    /** Namespace for each branch */
057    private final List<SubqueryNamespace> branchNamespaces;
058
059    /** Inferred columns from star push-down */
060    private Map<String, ColumnSource> inferredColumns;
061
062    /** Track inferred column names */
063    private Set<String> inferredColumnNames;
064
065    public UnionNamespace(TSelectSqlStatement unionQuery,
066                          String alias,
067                          INameMatcher nameMatcher) {
068        super(unionQuery, nameMatcher);
069        this.unionQuery = unionQuery;
070        this.alias = alias;
071
072        // Flatten all UNION branches
073        this.allBranches = new ArrayList<>();
074        flattenUnionBranches(unionQuery, allBranches);
075
076        // Create namespace for each branch
077        this.branchNamespaces = new ArrayList<>();
078        for (int i = 0; i < allBranches.size(); i++) {
079            TSelectSqlStatement branch = allBranches.get(i);
080            SubqueryNamespace branchNs = new SubqueryNamespace(branch, "branch_" + i, nameMatcher);
081            branchNamespaces.add(branchNs);
082        }
083    }
084
085    /**
086     * Iteratively flatten UNION branches into a list.
087     * Handles nested UNION structures like: (A UNION B) UNION C
088     * Uses explicit stack to avoid StackOverflow on deeply nested chains.
089     */
090    private void flattenUnionBranches(TSelectSqlStatement stmt, List<TSelectSqlStatement> branches) {
091        if (stmt == null) {
092            return;
093        }
094
095        Deque<TSelectSqlStatement> stack = new ArrayDeque<>();
096        stack.push(stmt);
097
098        while (!stack.isEmpty()) {
099            TSelectSqlStatement current = stack.pop();
100            if (current == null) {
101                continue;
102            }
103            if (current.isCombinedQuery()) {
104                // Push right first so left is processed first (LIFO)
105                stack.push(current.getRightStmt());
106                stack.push(current.getLeftStmt());
107            } else {
108                branches.add(current);
109            }
110        }
111    }
112
113    @Override
114    public String getDisplayName() {
115        return alias != null ? alias : "<union>";
116    }
117
118    @Override
119    public TTable getFinalTable() {
120        // For UNION, return the first branch's final table
121        // This is used for single-table resolution
122        if (!branchNamespaces.isEmpty()) {
123            return branchNamespaces.get(0).getFinalTable();
124        }
125        return null;
126    }
127
128    @Override
129    public List<TTable> getAllFinalTables() {
130        // Return tables from ALL branches
131        List<TTable> allTables = new ArrayList<>();
132        for (SubqueryNamespace branchNs : branchNamespaces) {
133            branchNs.validate();
134            List<TTable> branchTables = branchNs.getAllFinalTables();
135            allTables.addAll(branchTables);
136        }
137        return allTables;
138    }
139
140    @Override
141    protected void doValidate() {
142        // Extract columns from first branch's SELECT list (SQL standard)
143        columnSources = new LinkedHashMap<>();
144
145        if (allBranches.isEmpty()) {
146            return;
147        }
148
149        // Validate all branch namespaces
150        for (SubqueryNamespace branchNs : branchNamespaces) {
151            branchNs.validate();
152        }
153
154        // Get columns from first branch (defines schema)
155        TSelectSqlStatement firstBranch = allBranches.get(0);
156        TResultColumnList selectList = firstBranch.getResultColumnList();
157        if (selectList == null) {
158            // (VALUES (1) UNION ALL VALUES (2)) AS r(x): the first branch is a VALUES row; its alias column list
159            // names the cells (Mantis 4814, Codex review)
160            if (aliasColumns != null && firstBranch.getValueClause() != null && firstBranch.getValueClause().getRows() != null
161                    && !firstBranch.getValueClause().getRows().isEmpty()) {
162                TResultColumnList firstRow = firstBranch.getValueClause().getRows().get(0);
163                for (int i = 0; i < aliasColumns.size() && i < firstRow.size(); i++) {
164                    if (aliasColumns.getObjectName(i) == null) continue;
165                    String colName = aliasColumns.getObjectName(i).getColumnNameOnly();
166                    columnSources.put(colName, new ColumnSource(this, colName, firstRow.getResultColumn(i), 1.0,
167                            "union_alias_column_list"));
168                }
169            }
170            return;
171        }
172
173        for (int i = 0; i < selectList.size(); i++) {
174            TResultColumn resultCol = selectList.getResultColumn(i);
175            String colName = getColumnName(resultCol);
176            if (colName == null) {
177                colName = "col_" + (i + 1);
178            }
179            // the first branch's own name: the branches are matched against it, not against the exposed name
180            String firstBranchName = colName;
181            // The alias column list renames the set operation's columns by position (Mantis 4814):
182            // FROM (SELECT 0 AS a UNION ALL SELECT 1) AS r(rank) exposes rank.
183            if (aliasColumns != null && i < aliasColumns.size() && aliasColumns.getObjectName(i) != null
184                    && !SubqueryNamespace.isStarResultColumn(resultCol)) {
185                colName = aliasColumns.getObjectName(i).getColumnNameOnly();
186            }
187
188            // For each column position, collect tables from ALL branches.
189            // UNION/MINUS columns are matched by POSITION, not by name.
190            // For each branch, check if the result column at that position is a simple column reference.
191            // If so, include all tables from that branch as potential sources.
192            List<TTable> columnTables = new ArrayList<>();
193
194            for (int branchIdx = 0; branchIdx < allBranches.size(); branchIdx++) {
195                TSelectSqlStatement branch = allBranches.get(branchIdx);
196                TResultColumnList branchSelectList = branch.getResultColumnList();
197                if (branchSelectList == null || i >= branchSelectList.size()) {
198                    continue;
199                }
200
201                TResultColumn branchResultCol = branchSelectList.getResultColumn(i);
202                if (branchResultCol == null || branchResultCol.getExpr() == null) {
203                    continue;
204                }
205
206                // Check if this result column is a simple column reference (not NULL or expression)
207                gudusoft.gsqlparser.nodes.TExpression expr = branchResultCol.getExpr();
208                if (expr.getExpressionType() == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) {
209                    gudusoft.gsqlparser.nodes.TObjectName objName = expr.getObjectOperand();
210                    if (objName != null) {
211                        // Get the column name at this position in the branch
212                        String branchColName = objName.getColumnNameOnly();
213
214                        // For UNION data lineage, only include tables where the column name
215                        // in this branch matches the column name from the first branch.
216                        // This prevents incorrect associations like CDS_APP.bankcode when
217                        // CDS_APP branch actually has c_mandant at that position.
218                        boolean columnNameMatches = nameMatcher.matches(branchColName, firstBranchName);
219
220                        // First try to get the source table from the column reference itself
221                        TTable sourceTable = objName.getSourceTable();
222                        if (sourceTable != null && !columnTables.contains(sourceTable) && columnNameMatches) {
223                            // Phase 1 resolved this column - add if column name matches
224                            columnTables.add(sourceTable);
225                        } else if (sourceTable == null && columnNameMatches) {
226                            // If sourceTable is not set (Phase 1 resolution hasn't happened),
227                            // check if there's a qualified reference (e.g., t.col)
228                            String tableQualifier = objName.getTableString();
229                            if (tableQualifier != null && !tableQualifier.isEmpty() && branch.tables != null) {
230                                // Qualified column - try to find the table by alias or name.
231                                // Cleanup follow-up to S15: route through the per-dialect
232                                // {@link gudusoft.gsqlparser.resolver2.matcher.INameMatcher},
233                                // and when the matcher is a {@link VendorNameMatcher}, route
234                                // through {@link ESQLDataObjectType#dotTable} explicitly. The
235                                // 2-arg {@code matches(...)} default binds to {@code dotColumn}
236                                // inside {@code VendorNameMatcher}, which produces wrong
237                                // results for BigQuery / MySQL on table-qualifier compares
238                                // (BigQuery: tables SENSITIVE / columns INSENSITIVE; MySQL:
239                                // tables depend on lower_case_table_names, columns always
240                                // INSENSITIVE). Same routing pattern as ListBasedScope and
241                                // CTEScope below.
242                                for (int ti = 0; ti < branch.tables.size(); ti++) {
243                                    TTable t = branch.tables.getTable(ti);
244                                    if (t != null) {
245                                        String alias = t.getAliasName();
246                                        String name = t.getTableName() != null ? t.getTableName().toString() : null;
247                                        if ((alias != null && tableNameMatches(alias, tableQualifier)) ||
248                                            (name != null && tableNameMatches(name, tableQualifier))) {
249                                            if (!columnTables.contains(t)) {
250                                                columnTables.add(t);
251                                            }
252                                            break;
253                                        }
254                                    }
255                                }
256                            } else if (branch.tables != null) {
257                                // Unqualified column reference - add only the first non-subquery/join table
258                                for (int ti = 0; ti < branch.tables.size(); ti++) {
259                                    TTable t = branch.tables.getTable(ti);
260                                    if (t != null && !columnTables.contains(t)) {
261                                        // Skip subqueries and joins - they're not final tables
262                                        if (t.getTableType() != gudusoft.gsqlparser.ETableSource.subquery &&
263                                            t.getTableType() != gudusoft.gsqlparser.ETableSource.join) {
264                                            columnTables.add(t);
265                                            break;  // Only add the first table for unqualified columns
266                                        }
267                                    }
268                                }
269                            }
270                        }
271                    }
272                }
273                // For non-column expressions (NULL, functions, etc.), don't add any tables
274            }
275
276            // Create column source with candidateTables from branches that have the column
277            // Always pass the list (even if empty) so getAllFinalTables() knows we explicitly
278            // determined the candidate tables rather than needing to delegate to namespace
279            ColumnSource source = new ColumnSource(
280                this,
281                colName,
282                resultCol,
283                1.0,
284                "union_column",
285                null,  // overrideTable
286                columnTables  // Pass empty list when no tables match
287            );
288
289            columnSources.put(colName, source);
290        }
291    }
292
293    /**
294     * Extract column name from TResultColumn.
295     */
296    private String getColumnName(TResultColumn resultCol) {
297        // Check for alias
298        if (resultCol.getAliasClause() != null &&
299            resultCol.getAliasClause().getAliasName() != null) {
300            return resultCol.getAliasClause().getAliasName().toString();
301        }
302
303        // Check for simple column reference
304        if (resultCol.getExpr() != null) {
305            gudusoft.gsqlparser.nodes.TExpression expr = resultCol.getExpr();
306            if (expr.getExpressionType() == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) {
307                gudusoft.gsqlparser.nodes.TObjectName objName = expr.getObjectOperand();
308                if (objName != null) {
309                    return objName.getColumnNameOnly();
310                }
311            }
312        }
313
314        return null;
315    }
316
317    @Override
318    public ColumnLevel hasColumn(String columnName) {
319        ensureValidated();
320
321        // Check in explicit columns from first branch (raw-keyed map; matcher-aware lookup
322        // routes through INameMatcher / IdentifierService so per-dialect rules apply).
323        if (containsColumnByMatcher(columnSources, columnName)) {
324            return ColumnLevel.EXISTS;
325        }
326
327        // Check in inferred columns. The map is raw-keyed (= ColumnSource.
328        // exposedName) — see addInferredColumn for why round-2 reverted from
329        // normalized keys. The matcher-aware helper handles per-dialect case
330        // rules including SQL Server COLLATION_BASED.
331        if (containsColumnByMatcher(inferredColumns, columnName)) {
332            return ColumnLevel.EXISTS;
333        }
334
335        // If any branch has SELECT *, unknown columns MAYBE exist
336        if (hasStarColumn()) {
337            return ColumnLevel.MAYBE;
338        }
339
340        return ColumnLevel.NOT_EXISTS;
341    }
342
343    /**
344     * Binding-diagnostic view of the set-operation output schema.
345     *
346     * <p>SQL exposes set-operation columns using the first branch's output
347     * names. Branch-local names from later operands are not visible to an
348     * outer query. When any branch contains {@code SELECT *}, the output
349     * shape is not authoritative for missing-output diagnostics.</p>
350     */
351    public ColumnLevel hasAuthoritativeOutputColumn(String columnName) {
352        ensureValidated();
353
354        if (columnName == null || columnName.isEmpty()) {
355            return ColumnLevel.MAYBE;
356        }
357
358        if (containsColumnByMatcher(columnSources, columnName)) {
359            return ColumnLevel.EXISTS;
360        }
361
362        if (containsColumnByMatcher(inferredColumns, columnName)) {
363            return ColumnLevel.EXISTS;
364        }
365
366        if (hasStarColumn()) {
367            return ColumnLevel.MAYBE;
368        }
369
370        if (columnSources != null && !columnSources.isEmpty()) {
371            return ColumnLevel.NOT_EXISTS;
372        }
373
374        return ColumnLevel.MAYBE;
375    }
376
377    /**
378     * Cleanup follow-up to S15: route table-qualifier compares through
379     * {@link ESQLDataObjectType#dotTable} when the matcher is vendor-aware.
380     * Without this, the 2-arg {@link INameMatcher#matches(String, String)}
381     * defaults to {@code dotColumn} semantics inside {@link VendorNameMatcher},
382     * producing wrong results on BigQuery / MySQL.
383     */
384    private boolean tableNameMatches(String storedName, String tableQualifier) {
385        if (nameMatcher instanceof VendorNameMatcher) {
386            return ((VendorNameMatcher) nameMatcher).matches(storedName, tableQualifier, ESQLDataObjectType.dotTable);
387        }
388        return nameMatcher.matches(storedName, tableQualifier);
389    }
390
391    @Override
392    public ColumnSource resolveColumn(String columnName) {
393        ensureValidated();
394
395        // First check explicit columns from first branch
396        ColumnSource source = super.resolveColumn(columnName);
397        if (source != null) {
398            return source;
399        }
400
401        // Then check inferred columns. Slice S1 + codex round 2: the map is
402        // raw-keyed (= ColumnSource.exposedName), so the exact-match probe
403        // is O(1) for the common case where the same identifier is queried
404        // again. For case-only-different references, the matcher loop walks
405        // values via getExposedName() so quote state is preserved.
406        if (inferredColumns != null) {
407            ColumnSource exact = inferredColumns.get(columnName);
408            if (exact != null) {
409                return exact;
410            }
411            for (ColumnSource entry : inferredColumns.values()) {
412                String exposed = entry != null ? entry.getExposedName() : null;
413                if (exposed != null && nameMatcher.matches(exposed, columnName)) {
414                    return entry;
415                }
416            }
417        }
418
419        // If has star column, auto-infer this column
420        if (hasStarColumn()) {
421            boolean added = addInferredColumn(columnName, 0.8, "auto_inferred_from_outer_reference");
422            if (added && inferredColumns != null) {
423                ColumnSource inferredSource = inferredColumns.get(columnName);
424                if (inferredSource != null) {
425                    return inferredSource;
426                }
427            }
428        }
429
430        return null;
431    }
432
433    @Override
434    public TSelectSqlStatement getSelectStatement() {
435        return unionQuery;
436    }
437
438    @Override
439    public boolean hasStarColumn() {
440        // Returns true if ANY branch has SELECT *
441        for (SubqueryNamespace branchNs : branchNamespaces) {
442            if (branchNs.hasStarColumn()) {
443                return true;
444            }
445        }
446        return false;
447    }
448
449    @Override
450    public boolean supportsDynamicInference() {
451        return hasStarColumn();
452    }
453
454    /**
455     * Slice S4 (plan §5.5): a set-operation's output schema is defined by the
456     * first branch (SQL standard, see class javadoc). The schema is
457     * authoritative once validated AND the first branch projects named
458     * columns; if any branch contains an unresolved {@code SELECT *} we treat
459     * the output as METADATA_UNAVAILABLE because position-based column
460     * matching across branches has not yet completed. S11 will refine this
461     * once arity-mismatch handling lands.
462     */
463    @Override
464    public MetadataState getMetadataState() {
465        ensureValidated();
466        if (columnSources == null || columnSources.isEmpty() || hasStarColumn()) {
467            return MetadataState.METADATA_UNAVAILABLE;
468        }
469        return MetadataState.FOUND;
470    }
471
472    @Override
473    public boolean addInferredColumn(String columnName, double confidence, String evidence) {
474        if (columnName == null || columnName.isEmpty()) {
475            return false;
476        }
477
478        // Initialize maps if needed
479        if (inferredColumns == null) {
480            inferredColumns = new LinkedHashMap<>();
481        }
482        if (inferredColumnNames == null) {
483            inferredColumnNames = new HashSet<>();
484        }
485
486        // Slice S1: dedupe checks must respect per-vendor identifier rules so
487        // that case-only-different inputs do not leave the map with two entries
488        // (one of which is then non-deterministically picked by the matcher
489        // loops in hasColumn / resolveColumn).
490        //
491        // Codex round 2 caveat: the dedupe gate must use {@link
492        // #containsColumnByMatcher} (which walks ColumnSource.exposedName for
493        // value compares) and the storage key must be the original-cased
494        // identifier — NOT the normalized form. Two matcher-distinct
495        // identifiers can normalize to the same key (e.g. Postgres {@code
496        // "mycol"} and unquoted {@code MYCOL} both normalize to {@code
497        // mycol}), so a normalized-keyed map cannot represent both. Raw
498        // (exposedName) keys avoid this collision.
499        if (containsColumnByMatcher(columnSources, columnName)) {
500            return false;
501        }
502        if (containsColumnByMatcher(inferredColumns, columnName)) {
503            return false;
504        }
505
506        // Collect final tables from ALL branches that support dynamic inference (have SELECT *)
507        // For data lineage, we need to track that columns could come from any UNION branch
508        // The formatter will output all candidates for UNION columns when isCandidatesFromUnion is true
509        java.util.List<TTable> candidateTables = new java.util.ArrayList<>();
510        for (SubqueryNamespace branchNs : branchNamespaces) {
511            // Only collect from branches that could have inferred columns
512            if (!branchNs.supportsDynamicInference()) {
513                continue;
514            }
515            branchNs.validate();
516            java.util.List<TTable> branchTables = branchNs.getAllFinalTables();
517            for (TTable table : branchTables) {
518                if (table != null && !candidateTables.contains(table)) {
519                    candidateTables.add(table);
520                }
521            }
522        }
523
524        // Create inferred column source for this union namespace WITH candidate tables
525        ColumnSource source = new ColumnSource(
526            this,
527            columnName,
528            null,
529            confidence,
530            evidence,
531            null,  // overrideTable is null for UNION columns
532            candidateTables.isEmpty() ? null : candidateTables
533        );
534
535        inferredColumns.put(columnName, source);
536        inferredColumnNames.add(columnName);
537
538        if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
539            System.out.println("[UnionNamespace] Added '" + columnName + "' to " + alias +
540                ", propagating to " + branchNamespaces.size() + " branches");
541        }
542
543        // CRITICAL: Propagate to branches that support dynamic inference (have SELECT *)
544        // Only propagate to branches with star columns - branches with explicit column lists
545        // either have the column explicitly or don't have it at all.
546        for (SubqueryNamespace branchNs : branchNamespaces) {
547            // Only propagate to branches that can accept inferred columns
548            if (!branchNs.supportsDynamicInference()) {
549                if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
550                    System.out.println("[UnionNamespace] Skipping branch " + branchNs.getDisplayName() +
551                        " (no star column)");
552                }
553                continue;
554            }
555            if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
556                System.out.println("[UnionNamespace] Propagating '" + columnName + "' to branch " + branchNs.getDisplayName());
557            }
558            branchNs.addInferredColumn(columnName, confidence, evidence + "_union_propagate");
559        }
560
561        return true;
562    }
563
564    @Override
565    public Set<String> getInferredColumns() {
566        if (inferredColumnNames == null) {
567            return Collections.emptySet();
568        }
569        return Collections.unmodifiableSet(inferredColumnNames);
570    }
571
572    /**
573     * Get all branch namespaces.
574     * Useful for external code that needs to iterate over branches.
575     */
576    public List<SubqueryNamespace> getBranchNamespaces() {
577        return Collections.unmodifiableList(branchNamespaces);
578    }
579
580    /**
581     * Get all branch SELECT statements.
582     */
583    public List<TSelectSqlStatement> getAllBranches() {
584        return Collections.unmodifiableList(allBranches);
585    }
586
587    /**
588     * Get the number of UNION branches.
589     */
590    public int getBranchCount() {
591        return allBranches.size();
592    }
593
594    @Override
595    public String toString() {
596        int totalColumns = (columnSources != null ? columnSources.size() : 0) +
597                          (inferredColumns != null ? inferredColumns.size() : 0);
598        return "UnionNamespace(" + getDisplayName() +
599               ", branches=" + allBranches.size() +
600               ", columns=" + totalColumns +
601               ", inferred=" + (inferredColumns != null ? inferredColumns.size() : 0) + ")";
602    }
603}