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}