001package gudusoft.gsqlparser.resolver2; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.ETableSource; 005import gudusoft.gsqlparser.TSourceToken; 006import gudusoft.gsqlparser.nodes.TObjectName; 007import gudusoft.gsqlparser.nodes.TResultColumn; 008import gudusoft.gsqlparser.nodes.TResultColumnList; 009import gudusoft.gsqlparser.nodes.TTable; 010import gudusoft.gsqlparser.nodes.TTableList; 011import gudusoft.gsqlparser.stmt.TSelectSqlStatement; 012import gudusoft.gsqlparser.resolver2.matcher.INameMatcher; 013import gudusoft.gsqlparser.resolver2.model.AmbiguousColumnSource; 014import gudusoft.gsqlparser.resolver2.model.ColumnSource; 015import gudusoft.gsqlparser.resolver2.model.FieldPath; 016import gudusoft.gsqlparser.resolver2.model.ResolutionContext; 017import gudusoft.gsqlparser.resolver2.model.ResolutionResult; 018import gudusoft.gsqlparser.resolver2.namespace.INamespace; 019import gudusoft.gsqlparser.resolver2.namespace.UnnestNamespace; 020import gudusoft.gsqlparser.resolver2.scope.IScope; 021import gudusoft.gsqlparser.resolver2.scope.ResolvedImpl; 022 023import java.util.ArrayList; 024import java.util.Arrays; 025import java.util.Collections; 026import java.util.Comparator; 027import java.util.IdentityHashMap; 028import java.util.LinkedHashSet; 029import java.util.List; 030import java.util.Set; 031 032/** 033 * Core component for resolving column references to their sources. 034 * 035 * Key responsibilities: 036 * 1. Resolve TObjectName (column references) using scope tree 037 * 2. Handle qualified and unqualified names 038 * 3. Detect and report ambiguities 039 * 4. Apply GUESS_COLUMN_STRATEGY for ambiguous columns 040 * 5. Update TObjectName with resolution results 041 * 6. Update ResolutionContext for global querying 042 */ 043public class NameResolver { 044 045 private final INameMatcher nameMatcher; 046 private final ResolutionContext context; 047 private final TSQLResolverConfig config; 048 049 /** 050 * Create a NameResolver with full configuration. 051 * 052 * @param config The resolver configuration (includes name matcher and strategy) 053 * @param context The resolution context for tracking results 054 */ 055 public NameResolver(TSQLResolverConfig config, ResolutionContext context) { 056 this.config = config; 057 this.nameMatcher = config.getNameMatcher(); 058 this.context = context; 059 } 060 061 /** 062 * Create a NameResolver with just name matcher (backward compatibility). 063 * Uses default configuration for GUESS_COLUMN_STRATEGY. 064 * 065 * @deprecated Use NameResolver(TSQLResolverConfig, ResolutionContext) instead 066 */ 067 @Deprecated 068 public NameResolver(INameMatcher nameMatcher, ResolutionContext context) { 069 this.nameMatcher = nameMatcher; 070 this.context = context; 071 this.config = null; // Will use TBaseType.GUESS_COLUMN_STRATEGY 072 } 073 074 /** 075 * Get the effective GUESS_COLUMN_STRATEGY. 076 * Returns config value if available, otherwise TBaseType.GUESS_COLUMN_STRATEGY. 077 */ 078 private int getGuessColumnStrategy() { 079 if (config != null) { 080 return config.getGuessColumnStrategy(); 081 } 082 return gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY; 083 } 084 085 private static final boolean DEBUG_RESOLUTION = false; 086 087 /** 088 * Resolve a column reference (TObjectName) within a given scope. 089 * 090 * @param objName The column reference to resolve 091 * @param scope The scope where the reference appears 092 * @return Resolution result 093 */ 094 public ResolutionResult resolve(TObjectName objName, IScope scope) { 095 if (objName == null || scope == null) { 096 return ResolutionResult.notFound("<null>"); 097 } 098 099 // Extract name parts from TObjectName 100 List<String> nameParts = extractNameParts(objName); 101 if (nameParts.isEmpty()) { 102 return ResolutionResult.notFound("<empty>"); 103 } 104 105 if (DEBUG_RESOLUTION) { 106 System.out.println("[DEBUG-RESOLVE] Resolving: " + objName + 107 " nameParts=" + nameParts + " scopeType=" + scope.getScopeType()); 108 } 109 110 // Use scope to resolve the name 111 ResolvedImpl resolved = new ResolvedImpl(); 112 scope.resolve(nameParts, nameMatcher, false, resolved); 113 114 if (DEBUG_RESOLUTION) { 115 System.out.println("[DEBUG-RESOLVE] Resolved matches: " + resolved.getCount()); 116 if (resolved.getCount() > 1) { 117 for (ResolvedImpl.Match m : resolved.getMatches()) { 118 System.out.println("[DEBUG-RESOLVE] Match: " + m.namespace.getDisplayName() + 119 " type=" + m.namespace.getClass().getSimpleName() + 120 " id=" + System.identityHashCode(m.namespace) + 121 " remaining=" + m.remainingNames + 122 " scope=" + m.scope.getScopeType()); 123 } 124 } 125 } 126 127 // Process resolution results 128 ResolutionResult result = processResolvedMatches(objName, nameParts, resolved); 129 130 // Delta 3: Struct-field fallback for BigQuery/Snowflake 131 // If resolution failed and we have a 2-part qualified name like "customer.customer_id", 132 // try interpreting it as column.field (struct field access) instead of table.column. 133 // Only for 2-part names; 3+ part no-alias case (e.g., customer.address.city) is not 134 // handled here to avoid changing resolution paths for existing 3-part BigQuery patterns 135 // (e.g., purchases.first.msts) that are correctly handled by DataFlowAnalyzer heuristics. 136 // The alias case (o.customer.address.city) IS handled by resolveColumnPath() in 137 // processResolvedMatches() and does not depend on this fallback. 138 if (!result.isExactMatch() && nameParts.size() == 2 && isStructFieldVendor()) { 139 ResolutionResult structFieldResult = tryStructFieldFallback(objName, nameParts, scope); 140 if (structFieldResult != null && structFieldResult.isExactMatch()) { 141 result = structFieldResult; 142 if (DEBUG_RESOLUTION) { 143 System.out.println("[DEBUG-RESOLVE] Struct-field fallback succeeded for: " + objName); 144 } 145 } 146 } 147 148 // Delta 4: Side-channel hint for 3+ part no-alias struct access (BigQuery only). 149 // When resolution failed and we have 3+ parts (e.g., customer.address.city), 150 // set a StructFieldHint WITHOUT changing the resolution result or sourceTable. 151 // This provides struct path info to DataFlowAnalyzer without altering lineage topology. 152 if (!result.isExactMatch() && nameParts.size() >= 3 && isStructFieldHintVendor()) { 153 trySetStructFieldHint(objName, nameParts, scope); 154 } 155 156 if (DEBUG_RESOLUTION) { 157 System.out.println("[DEBUG-RESOLVE] Result: " + result.getStatus() + 158 (result.isExactMatch() && result.getColumnSource() != null ? 159 " source=" + result.getColumnSource().getExposedName() : "")); 160 } 161 162 // Update TObjectName and context 163 updateObjectNameWithResult(objName, result); 164 165 return result; 166 } 167 168 /** 169 * Check if the current vendor supports struct-field access syntax (column.field). 170 * Currently supported: BigQuery, Snowflake 171 */ 172 private boolean isStructFieldVendor() { 173 if (config == null) { 174 return false; 175 } 176 EDbVendor vendor = config.getVendor(); 177 return vendor == EDbVendor.dbvbigquery || vendor == EDbVendor.dbvsnowflake; 178 } 179 180 /** 181 * Check if the current vendor supports struct-field hint annotations. 182 * Currently BigQuery only (Snowflake uses schema.table.column for 3-part names). 183 */ 184 private boolean isStructFieldHintVendor() { 185 if (config == null) { 186 return false; 187 } 188 return config.getVendor() == EDbVendor.dbvbigquery; 189 } 190 191 /** 192 * Delta 4: Try to set a StructFieldHint for 3+ part no-alias struct access. 193 * 194 * For "customer.address.city" (3 parts, no alias): 195 * - Treat the first part ("customer") as a potential base column 196 * - If found in any visible namespace, set a hint with fieldPath=["address", "city"] 197 * - Does NOT change ResolutionResult or sourceTable 198 * 199 * @param objName The TObjectName to annotate with a hint 200 * @param nameParts The extracted name parts (e.g., ["customer", "address", "city"]) 201 * @param scope The scope to search in 202 */ 203 private void trySetStructFieldHint(TObjectName objName, List<String> nameParts, IScope scope) { 204 String baseColumnName = nameParts.get(0); 205 List<String> fieldPathSegments = nameParts.subList(1, nameParts.size()); 206 207 // Try to find the base column as an unqualified name 208 List<String> singlePartName = Collections.singletonList(baseColumnName); 209 ResolvedImpl resolved = new ResolvedImpl(); 210 scope.resolve(singlePartName, nameMatcher, false, resolved); 211 212 if (resolved.isEmpty()) { 213 return; // Base column not found in any namespace 214 } 215 216 // Check if any namespace actually has this column 217 for (ResolvedImpl.Match match : resolved.getMatches()) { 218 gudusoft.gsqlparser.resolver2.model.ColumnSource baseColumnSource = 219 match.namespace.resolveColumn(baseColumnName); 220 if (baseColumnSource != null) { 221 // Found the base column - create hint 222 gudusoft.gsqlparser.resolver2.model.FieldPath fieldPath = 223 gudusoft.gsqlparser.resolver2.model.FieldPath.of(fieldPathSegments); 224 gudusoft.gsqlparser.resolver2.model.StructFieldHint hint = 225 new gudusoft.gsqlparser.resolver2.model.StructFieldHint( 226 baseColumnName, fieldPath, 227 "struct_field_hint_no_alias", 0.7); 228 objName.setStructFieldHint(hint); 229 230 if (DEBUG_RESOLUTION) { 231 System.out.println("[DEBUG-RESOLVE] StructFieldHint set for: " + objName + 232 " -> base=" + baseColumnName + ", fieldPath=" + fieldPathSegments); 233 } 234 return; 235 } 236 } 237 } 238 239 /** 240 * Delta 3: Try to resolve a qualified name as struct-field access (column.field). 241 * 242 * In BigQuery/Snowflake, "customer.customer_id" might be: 243 * 1. Table "customer" with column "customer_id" (standard interpretation) 244 * 2. Column "customer" (STRUCT type) with field "customer_id" (struct-field access) 245 * 246 * If the standard interpretation failed, try the struct-field interpretation: 247 * - Treat the first part as an unqualified column name 248 * - If found, return the base column as the source (the STRUCT column) 249 * - Preserve the field path (segments beyond the base column) for downstream use 250 * 251 * @param objName The original TObjectName 252 * @param nameParts The extracted name parts (e.g., ["customer", "customer_id"]) 253 * @param scope The scope to search in 254 * @return Resolution result if struct-field interpretation succeeds, null otherwise 255 */ 256 private ResolutionResult tryStructFieldFallback(TObjectName objName, List<String> nameParts, IScope scope) { 257 // The base column is the first part (e.g., "customer" in "customer.customer_id") 258 String baseColumnName = nameParts.get(0); 259 260 // The field path is everything after the base column 261 // For "customer.customer_id", fieldPath = ["customer_id"] 262 // For "customer.address.city", fieldPath = ["address", "city"] 263 List<String> fieldPathSegments = nameParts.size() > 1 264 ? nameParts.subList(1, nameParts.size()) 265 : Collections.emptyList(); 266 267 // Try to resolve the base column as an unqualified name 268 List<String> singlePartName = Collections.singletonList(baseColumnName); 269 ResolvedImpl resolved = new ResolvedImpl(); 270 scope.resolve(singlePartName, nameMatcher, false, resolved); 271 272 if (resolved.isEmpty()) { 273 return null; // Base column not found 274 } 275 276 // Found potential matches - check if any namespace has this column 277 for (ResolvedImpl.Match match : resolved.getMatches()) { 278 INamespace namespace = match.namespace; 279 280 // Try to resolve the base column name in this namespace 281 ColumnSource baseColumnSource = namespace.resolveColumn(baseColumnName); 282 if (baseColumnSource != null) { 283 // Found the base column - return it as the source with field path preserved 284 if (DEBUG_RESOLUTION) { 285 System.out.println("[DEBUG-RESOLVE] Struct-field: found base column '" + 286 baseColumnName + "' in " + namespace.getDisplayName() + 287 ", fieldPath=" + fieldPathSegments); 288 } 289 290 // Create a new ColumnSource with: 291 // 1. struct_field_access evidence marker (for backward compatibility) 292 // 2. fieldPath preserved (new in Improvement B) 293 FieldPath fieldPath = FieldPath.of(fieldPathSegments); 294 ColumnSource structFieldSource = baseColumnSource.withFieldPath(fieldPath, "struct_field_access"); 295 296 return ResolutionResult.exactMatch(structFieldSource); 297 } 298 } 299 300 return null; 301 } 302 303 /** 304 * Extract name parts from TObjectName. 305 * Examples: 306 * - "col" -> ["col"] 307 * - "t.col" -> ["t", "col"] 308 * - "schema.table.col" -> ["schema", "table", "col"] 309 * 310 * For BigQuery/Snowflake struct field access like "customer.customer_id": 311 * - If schema/table tokens are not set, but toString() contains dots, 312 * extract all parts from toString() to capture field paths. 313 */ 314 private List<String> extractNameParts(TObjectName objName) { 315 List<String> parts = new ArrayList<>(); 316 317 // Add schema if present, but NOT when databaseToken is also set. 318 // When databaseToken is present, the name is fully qualified (db.schema.table.column) 319 // and the schema position IS the schema, not a table alias. Including it would cause 320 // the resolver to incorrectly match the schema against table aliases. (Mantis #4268) 321 if (objName.getSchemaToken() != null && objName.getDatabaseToken() == null) { 322 parts.add(objName.getSchemaString()); 323 } 324 325 // Add table/qualifier if present 326 if (objName.getTableToken() != null) { 327 parts.add(objName.getTableString()); 328 } 329 330 // Add column name 331 String columnName = objName.getColumnNameOnly(); 332 if (columnName != null) { 333 parts.add(columnName); 334 } 335 336 // Improvement B: Handle BigQuery/Snowflake struct field access 337 // If we only got a single part from standard extraction, 338 // but toString() contains more segments (dots), extract them 339 // This handles cases like "customer.customer_id" where: 340 // - getColumnNameOnly() returns "customer" 341 // - toString() returns "customer.customer_id" 342 if (isStructFieldVendor()) { 343 String fullName = objName.toString(); 344 if (fullName != null && fullName.contains(".")) { 345 // Check if fullName has more segments than what we extracted 346 String[] fullParts = splitNameParts(fullName); 347 if (fullParts.length > parts.size() || hasConsecutiveDuplicates(parts)) { 348 // Replace parts with full extracted segments 349 parts.clear(); 350 Collections.addAll(parts, fullParts); 351 } 352 } 353 } 354 355 return parts; 356 } 357 358 /** 359 * Check if a list has consecutive duplicate elements. 360 * This detects parser bugs where segments are duplicated. 361 * Example: ["customer", "customer", "address"] returns true. 362 */ 363 private boolean hasConsecutiveDuplicates(List<String> list) { 364 if (list == null || list.size() < 2) { 365 return false; 366 } 367 for (int i = 1; i < list.size(); i++) { 368 if (list.get(i) != null && list.get(i).equals(list.get(i - 1))) { 369 return true; 370 } 371 } 372 return false; 373 } 374 375 376 /** 377 * Split a dotted name into parts, handling quoted identifiers. 378 * Examples: 379 * - "a.b.c" -> ["a", "b", "c"] 380 * - "`a.b`.c" -> ["`a.b`", "c"] 381 * - "a.`b.c`" -> ["a", "`b.c`"] 382 * 383 * @param name The dotted name string 384 * @return Array of name parts 385 */ 386 private String[] splitNameParts(String name) { 387 if (name == null || name.isEmpty()) { 388 return new String[0]; 389 } 390 391 // Simple case: no quotes, just split on dots 392 if (!name.contains("`") && !name.contains("\"") && !name.contains("[")) { 393 return name.split("\\."); 394 } 395 396 // Complex case: handle quoted identifiers 397 List<String> parts = new ArrayList<>(); 398 StringBuilder current = new StringBuilder(); 399 char quoteChar = 0; 400 401 for (int i = 0; i < name.length(); i++) { 402 char c = name.charAt(i); 403 404 if (quoteChar != 0) { 405 // Inside a quoted identifier 406 current.append(c); 407 if (c == quoteChar) { 408 // Check for escaped quote (doubled) 409 if (i + 1 < name.length() && name.charAt(i + 1) == quoteChar) { 410 current.append(name.charAt(++i)); 411 } else { 412 // End of quoted identifier 413 quoteChar = 0; 414 } 415 } 416 } else if (c == '`' || c == '"' || c == '[') { 417 // Start of quoted identifier 418 quoteChar = (c == '[') ? ']' : c; 419 current.append(c); 420 } else if (c == '.') { 421 // Separator 422 if (current.length() > 0) { 423 parts.add(current.toString()); 424 current.setLength(0); 425 } 426 } else { 427 current.append(c); 428 } 429 } 430 431 // Add last part 432 if (current.length() > 0) { 433 parts.add(current.toString()); 434 } 435 436 return parts.toArray(new String[0]); 437 } 438 439 /** 440 * Process resolution matches and determine final result. 441 */ 442 private ResolutionResult processResolvedMatches(TObjectName objName, 443 List<String> nameParts, 444 ResolvedImpl resolved) { 445 String columnName = nameParts.get(nameParts.size() - 1); 446 447 if (resolved.isEmpty()) { 448 // No matches found 449 return ResolutionResult.notFound(columnName); 450 } 451 452 // Deduplicate matches by namespace identity 453 // The same namespace can be found through different scope paths (e.g., CTE scope and SELECT scope) 454 // but it's still the same source - not truly ambiguous 455 List<ResolvedImpl.Match> uniqueMatches = deduplicateMatchesByNamespace(resolved.getMatches()); 456 457 if (uniqueMatches.size() == 1) { 458 // Exactly one unique namespace - success! 459 ResolvedImpl.Match match = uniqueMatches.get(0); 460 INamespace namespace = match.namespace; 461 462 // For qualified names like "t.col", we need to resolve the column 463 // For unqualified names like "col", we need to find it in the namespace 464 ColumnSource columnSource; 465 466 if (!match.remainingNames.isEmpty()) { 467 // Still have parts to resolve (e.g., found table, need to find column) 468 // Phase B3: Support multi-segment paths (table.column.field...) 469 if (match.remainingNames.size() > 1 && isStructFieldVendor()) { 470 // Multiple segments: use resolveColumnPath for deep field access 471 // e.g., remainingNames = ["customer", "address", "city"] 472 // -> base column "customer", fieldPath ["address", "city"] 473 columnSource = namespace.resolveColumnPath(match.remainingNames); 474 475 if (columnSource == null) { 476 // Base column not found 477 String baseColName = match.remainingNames.get(0); 478 return ResolutionResult.notFound(baseColName, 479 "Column '" + baseColName + "' not found in " + namespace.getDisplayName()); 480 } 481 482 if (DEBUG_RESOLUTION) { 483 System.out.println("[DEBUG-RESOLVE] Deep path resolved: " + 484 match.remainingNames + " -> base=" + columnSource.getExposedName() + 485 ", fieldPath=" + (columnSource.hasFieldPath() ? columnSource.getFieldPath() : "none")); 486 } 487 } else { 488 // Single segment: regular column resolution 489 String remainingColName = match.remainingNames.get(match.remainingNames.size() - 1); 490 columnSource = namespace.resolveColumn(remainingColName); 491 492 if (columnSource == null) { 493 // Column not found in the namespace 494 return ResolutionResult.notFound(remainingColName, 495 "Column '" + remainingColName + "' not found in " + namespace.getDisplayName()); 496 } 497 } 498 } else { 499 // Name resolved to a table/namespace directly with no remaining parts 500 // This happens when the column name matches the table alias exactly. 501 // 502 // For UNNEST tables (e.g., "UNNEST(arr) AS x"), the alias "x" is ALSO 503 // the implicit column name. When user writes "x" as a column reference, 504 // the scope resolution matches the table alias "x", leaving remainingNames empty. 505 // We should still try to resolve "x" as a column in the namespace. 506 // 507 // Example: SELECT x FROM UNNEST([1,2,3]) AS x 508 // Here "x" in SELECT refers to the implicit column, not the table. 509 if (namespace instanceof UnnestNamespace) { 510 // For UNNEST, try to resolve the matched name as a column 511 columnSource = namespace.resolveColumn(columnName); 512 if (columnSource != null) { 513 if (DEBUG_RESOLUTION) { 514 System.out.println("[DEBUG-RESOLVE] UNNEST implicit column resolved: " + 515 columnName + " in " + namespace.getDisplayName()); 516 } 517 return ResolutionResult.exactMatch(columnSource); 518 } 519 } 520 521 // For other namespaces or if column not found, this is an error 522 return ResolutionResult.notFound(columnName, 523 "Name '" + columnName + "' resolves to a table, not a column"); 524 } 525 526 return ResolutionResult.exactMatch(columnSource); 527 } 528 529 // Multiple unique namespaces - truly ambiguous 530 List<ColumnSource> candidates = new ArrayList<>(); 531 // Mantis 4550: set when we synthesize a placeholder candidate for an 532 // ambiguous-star subquery (see below). Such a candidate means the column 533 // is genuinely ambiguous across derived tables and must NOT be collapsed 534 // by GUESS_COLUMN_STRATEGY into a single guessed match. 535 boolean synthesizedAmbStar = false; 536 537 for (ResolvedImpl.Match match : uniqueMatches) { 538 INamespace namespace = match.namespace; 539 540 // Resolve column in this namespace 541 // Phase B3: Support multi-segment paths 542 ColumnSource columnSource; 543 if (!match.remainingNames.isEmpty()) { 544 if (match.remainingNames.size() > 1 && isStructFieldVendor()) { 545 // Multi-segment: use resolveColumnPath 546 columnSource = namespace.resolveColumnPath(match.remainingNames); 547 } else { 548 // Single segment: use resolveColumn 549 String remainingColName = match.remainingNames.get(match.remainingNames.size() - 1); 550 columnSource = namespace.resolveColumn(remainingColName); 551 } 552 } else { 553 columnSource = namespace.resolveColumn(columnName); 554 } 555 556 // Mantis 4550: an ambiguous-star subquery (SELECT * over a multi-table 557 // FROM) cannot pin an unqualified column to a single base table and, 558 // under the default NOT_PICKUP strategy, resolveColumn() returns null. 559 // SelectScope now still reports it as an outer-scope candidate. Anchor a 560 // placeholder ColumnSource to the subquery so it counts as a distinct 561 // candidate (getSourceNamespace().getSourceTable() -> the derived table). 562 // finalTable is left null; candidateTables reconciliation expands the 563 // derived table into its base tables. The placeholder is NOT cached as an 564 // inferred column on the namespace, so resolveColumn() itself is unchanged. 565 // Only single-segment column references qualify (no deep struct path); 566 // qualified single-table refs never reach this multi-namespace branch 567 // (they resolve via the uniqueMatches.size()==1 path above), so this 568 // cannot make "T0_2.col" falsely resolve. 569 boolean singleSegment = match.remainingNames.isEmpty() 570 || match.remainingNames.size() == 1; 571 if (columnSource == null 572 && singleSegment 573 && namespace instanceof gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace 574 && ((gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) namespace).hasAmbiguousStar()) { 575 String placeholderName = match.remainingNames.isEmpty() 576 ? columnName 577 : match.remainingNames.get(match.remainingNames.size() - 1); 578 columnSource = new ColumnSource(namespace, placeholderName, null, 0.3, 579 "ambiguous_star_outer_candidate"); 580 synthesizedAmbStar = true; 581 } 582 583 if (columnSource != null) { 584 candidates.add(columnSource); 585 } 586 } 587 588 if (candidates.isEmpty()) { 589 // Resolved to tables, but none have the column 590 return ResolutionResult.notFound(columnName, 591 "Column '" + columnName + "' not found in any of " + resolved.getCount() + " tables"); 592 } 593 594 if (candidates.size() == 1) { 595 // Only one table actually has the column 596 return ResolutionResult.exactMatch(candidates.get(0)); 597 } 598 599 // Mantis 4550: when a placeholder ambiguous-star candidate was synthesized, 600 // the column is genuinely ambiguous across the derived tables. Force an 601 // AMBIGUOUS result regardless of GUESS_COLUMN_STRATEGY (nearest/farthest), 602 // which would otherwise collapse a definite sibling + maybe-star sibling 603 // back into a single EXACT match. 604 if (synthesizedAmbStar) { 605 sortCandidatesByTablePosition(candidates); 606 AmbiguousColumnSource ambiguousResult = new AmbiguousColumnSource(columnName, candidates); 607 return ResolutionResult.ambiguous(ambiguousResult); 608 } 609 610 // Sort candidates by their table's position in the SQL text (FROM clause order) 611 // This ensures consistent ordering for both ambiguous results and GUESS_COLUMN_STRATEGY 612 sortCandidatesByTablePosition(candidates); 613 614 // Check if all candidates are "inferred" (from tables without DDL metadata) 615 // If so, return AMBIGUOUS regardless of GUESS_COLUMN_STRATEGY 616 // Only apply GUESS_COLUMN_STRATEGY when we have definite knowledge about the columns 617 // 618 // The determination uses configurable thresholds: 619 // - minDefiniteConfidence: minimum confidence to be considered "definite" (default 0.9) 620 // - allowGuessWhenAllInferred: if true, allow guessing even when all candidates are inferred 621 boolean hasDefiniteCandidate = false; 622 double highestConfidence = 0.0; 623 double minDefiniteConf = config != null ? config.getMinDefiniteConfidence() : 0.9; 624 625 for (ColumnSource candidate : candidates) { 626 // A candidate is "definite" if it has high confidence and is not just inferred from usage 627 String evidence = candidate.getEvidence(); 628 double confidence = candidate.getConfidence(); 629 630 // Track highest confidence among candidates 631 if (confidence > highestConfidence) { 632 highestConfidence = confidence; 633 } 634 635 // Use structured evidence if available, otherwise check legacy evidence 636 boolean isInferred; 637 if (candidate.getEvidenceDetail() != null) { 638 // Use the structured evidence's own determination 639 isInferred = !candidate.getEvidenceDetail().isHighConfidence() || 640 candidate.getEvidenceDetail().isInferred(); 641 } else { 642 // Fallback to legacy logic 643 // "inferred_from_usage" means table without DDL metadata - not definite 644 // Low confidence (< minDefiniteConf) means we're guessing - not definite 645 // Null evidence with high confidence is also considered inferred 646 isInferred = (evidence == null || evidence.equals("inferred_from_usage")) || 647 confidence < minDefiniteConf; 648 } 649 650 if (!isInferred) { 651 hasDefiniteCandidate = true; 652 break; 653 } 654 } 655 656 // Check if we should allow guessing when all candidates are inferred 657 boolean allowGuessInferred = config != null && config.isAllowGuessWhenAllInferred(); 658 659 if (!hasDefiniteCandidate && !allowGuessInferred) { 660 // All candidates are from tables without DDL metadata (all inferred) 661 // Don't guess - return as ambiguous so formatter can handle appropriately 662 if (DEBUG_RESOLUTION) { 663 System.out.println("[DEBUG-RESOLVE] AMBIGUOUS (all inferred): " + columnName + " with " + candidates.size() + " candidates"); 664 for (ColumnSource c : candidates) { 665 System.out.println("[DEBUG-RESOLVE] - " + (c.getSourceNamespace() != null ? c.getSourceNamespace().getDisplayName() : "null") + 666 " evidence=" + c.getEvidence() + " confidence=" + c.getConfidence()); 667 } 668 } 669 AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates); 670 return ResolutionResult.ambiguous(ambiguous); 671 } 672 673 // Additional check: even if we have definite candidates or allow inferred guessing, 674 // require at least one candidate to meet the minConfidenceToGuess threshold 675 double minConfToGuess = config != null ? config.getMinConfidenceToGuess() : 0.95; 676 if (highestConfidence < minConfToGuess && !allowGuessInferred) { 677 // No candidate has sufficient confidence for guessing 678 if (DEBUG_RESOLUTION) { 679 System.out.println("[DEBUG-RESOLVE] AMBIGUOUS (confidence too low): " + columnName + 680 " highest=" + highestConfidence + " required=" + minConfToGuess); 681 } 682 AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates); 683 return ResolutionResult.ambiguous(ambiguous); 684 } 685 686 // Multiple tables have the column - apply GUESS_COLUMN_STRATEGY 687 // Candidates are already sorted by table position (done earlier) 688 int strategy = getGuessColumnStrategy(); 689 690 if (strategy == TSQLResolverConfig.GUESS_COLUMN_STRATEGY_NEAREST) { 691 // Pick the first candidate (nearest table in FROM clause order) 692 return ResolutionResult.exactMatch(candidates.get(0)); 693 } else if (strategy == TSQLResolverConfig.GUESS_COLUMN_STRATEGY_FARTHEST) { 694 // Pick the last candidate (farthest table in FROM clause order) 695 return ResolutionResult.exactMatch(candidates.get(candidates.size() - 1)); 696 } 697 698 // GUESS_COLUMN_STRATEGY_NOT_PICKUP: leave as ambiguous 699 if (DEBUG_RESOLUTION) { 700 System.out.println("[DEBUG-RESOLVE] AMBIGUOUS (NOT_PICKUP strategy): " + columnName + " with " + candidates.size() + " candidates"); 701 } 702 AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates); 703 return ResolutionResult.ambiguous(ambiguous); 704 } 705 706 /** 707 * Deduplicate matches by namespace identity. 708 * The same namespace can be found through different scope paths (e.g., CTE scope and SELECT scope) 709 * but it's still the same source - not truly ambiguous. 710 * 711 * @param matches All matches from scope resolution 712 * @return List of unique matches by namespace identity 713 */ 714 private List<ResolvedImpl.Match> deduplicateMatchesByNamespace(List<ResolvedImpl.Match> matches) { 715 if (matches == null || matches.size() <= 1) { 716 return matches; 717 } 718 719 // Use identity-based set to track unique namespaces 720 java.util.IdentityHashMap<INamespace, ResolvedImpl.Match> uniqueByNamespace = 721 new java.util.IdentityHashMap<>(); 722 723 for (ResolvedImpl.Match match : matches) { 724 if (match.namespace != null && !uniqueByNamespace.containsKey(match.namespace)) { 725 uniqueByNamespace.put(match.namespace, match); 726 } 727 } 728 729 return new ArrayList<>(uniqueByNamespace.values()); 730 } 731 732 /** 733 * Sort candidates by their source table's position in the SQL text. 734 * This ensures that when GUESS_COLUMN_STRATEGY_NEAREST or FARTHEST is applied, 735 * the candidates are ordered according to their actual position in the FROM clause. 736 * 737 * Uses the table's start token (lineNo, columnNo) to determine position. 738 * 739 * @param candidates List of ColumnSource candidates to sort in place 740 */ 741 private void sortCandidatesByTablePosition(List<ColumnSource> candidates) { 742 if (candidates == null || candidates.size() <= 1) { 743 return; 744 } 745 746 candidates.sort(new Comparator<ColumnSource>() { 747 @Override 748 public int compare(ColumnSource c1, ColumnSource c2) { 749 TTable t1 = c1.getFinalTable(); 750 TTable t2 = c2.getFinalTable(); 751 752 // If either table is null, maintain relative order 753 if (t1 == null && t2 == null) return 0; 754 if (t1 == null) return 1; // null tables go to the end 755 if (t2 == null) return -1; 756 757 TSourceToken token1 = t1.getStartToken(); 758 TSourceToken token2 = t2.getStartToken(); 759 760 // If either token is null, maintain relative order 761 if (token1 == null && token2 == null) return 0; 762 if (token1 == null) return 1; 763 if (token2 == null) return -1; 764 765 // Compare by line number first 766 int lineCmp = Long.compare(token1.lineNo, token2.lineNo); 767 if (lineCmp != 0) { 768 return lineCmp; 769 } 770 771 // If same line, compare by column number 772 return Long.compare(token1.columnNo, token2.columnNo); 773 } 774 }); 775 } 776 777 /** 778 * Update TObjectName with the resolution result. 779 * Also registers with ResolutionContext. 780 * 781 * For ambiguous columns (multiple candidate tables), this also populates 782 * TObjectName.candidateTables with all possible source tables. 783 * 784 * IMPORTANT: When resolution fails (notFound) and the column's sourceTable 785 * was set during Phase 1 to an UNNEST table, we clear the sourceTable. 786 * This is because UNNEST tables have a fixed set of columns (implicit column, 787 * offset, struct fields) and should NOT have arbitrary columns inferred. 788 * Clearing sourceTable allows the formatter to treat these as "missed" columns. 789 */ 790 private void updateObjectNameWithResult(TObjectName objName, ResolutionResult result) { 791 // Update TObjectName with resolution result 792 objName.setResolution(result); 793 794 // For notFound results, check if Phase 1 incorrectly linked to UNNEST table 795 // UNNEST tables have a fixed column set - don't allow inferred columns 796 if (!result.isExactMatch() && !result.isAmbiguous()) { 797 TTable currentSourceTable = objName.getSourceTable(); 798 if (currentSourceTable != null && 799 currentSourceTable.getTableType() == ETableSource.unnest) { 800 // Clear the incorrectly set sourceTable from Phase 1 801 // This column wasn't found in the UNNEST namespace, so it shouldn't 802 // be attributed to the UNNEST table 803 objName.setSourceTable(null); 804 if (DEBUG_RESOLUTION) { 805 System.out.println("[DEBUG-RESOLVE] Cleared incorrect UNNEST sourceTable for: " + 806 objName + " (not found in UNNEST namespace)"); 807 } 808 } 809 } 810 811 // For ambiguous results, populate candidateTables with all candidate tables 812 // IMPORTANT: Clear existing candidateTables first, as Phase 1 (linkColumnToTable) 813 // may have added candidates from incorrect scopes (e.g., MERGE target table for 814 // columns inside USING subquery). Phase 2 (NameResolver) has proper scope awareness 815 // and produces the authoritative candidate list. 816 if (result.isAmbiguous() && result.getAmbiguousSource() != null) { 817 AmbiguousColumnSource ambiguous = result.getAmbiguousSource(); 818 // Clear Phase 1 candidates before adding Phase 2's scope-aware candidates 819 objName.getCandidateTables().clear(); 820 for (ColumnSource candidate : ambiguous.getCandidates()) { 821 gudusoft.gsqlparser.nodes.TTable candidateTable = candidate.getFinalTable(); 822 if (candidateTable != null) { 823 objName.getCandidateTables().addTable(candidateTable); 824 continue; 825 } 826 // Mantis 4550: an ambiguous-star subquery placeholder has no single 827 // finalTable. Add its immediate derived table (the subquery) so the 828 // reconcile pass below expands it into the physical base tables that 829 // can actually supply the column (e.g. T0_2 -> A.A2, A.A3). 830 gudusoft.gsqlparser.nodes.TTable derivedTable = 831 candidate.getSourceNamespace() != null 832 ? candidate.getSourceNamespace().getSourceTable() : null; 833 if (derivedTable != null) { 834 objName.getCandidateTables().addTable(derivedTable); 835 continue; 836 } 837 // A candidate that cannot collapse to a single physical table 838 // (e.g. a UNION-backed derived table) would otherwise be dropped 839 // here. Trace its set-operation branches projection-aware, by the 840 // requested column, so only branches that actually supply the 841 // column contribute base tables (a constant branch like 842 // "SELECT 1 AS a" adds nothing). 843 addBranchBaseTablesForColumn(objName, candidate); 844 } 845 } 846 847 // Mantis 4550: reconcile candidateTables so they point to the physical 848 // base tables that could actually supply the column, rather than the 849 // intermediate derived tables. Phase 1 (linkColumnToTable) may leave 850 // stale candidates that include derived tables which cannot supply the 851 // column (e.g. a subquery with an explicit column list that omits it), 852 // and the EXACT/guessed-match path above does not clear them. See 853 // reconcileCandidateTablesToBaseTables() for the full rationale. 854 reconcileCandidateTablesToBaseTables(objName); 855 856 // Register with context for global querying 857 context.registerResolution(objName, result); 858 } 859 860 /** 861 * Mantis 4550: rewrite {@code candidateTables} so each entry is a physical 862 * base table that could supply the column, expanding any intermediate 863 * derived table (subquery / CTE) into the underlying tables it exposes. 864 * 865 * <p>Background: for an unqualified column whose source is ambiguous across 866 * several derived tables in the FROM clause, Phase 1 ({@code linkColumnToTable}) 867 * may populate {@code candidateTables} with the derived tables themselves. 868 * When Phase 2 resolves the column to a guessed EXACT match (rather than a 869 * truly AMBIGUOUS result) the scope-aware ambiguous branch that normally 870 * clears and rebuilds {@code candidateTables} is skipped, leaving those 871 * stale derived-table candidates in place. Some of them cannot even supply 872 * the column (e.g. a {@code SELECT x, ds FROM ...} subquery for column 873 * {@code S311}).</p> 874 * 875 * <p>This pass walks each candidate: physical tables are kept as-is, while 876 * a derived table is replaced by the set of physical base tables reachable 877 * through its projection that could supply the column. A derived table whose 878 * closed projection does not expose the column contributes nothing. Results 879 * are de-duplicated by qualified table name, preserving first-seen order, so 880 * the same physical table reached via two derived paths (or two aliases of a 881 * self-join) collapses to a single candidate.</p> 882 */ 883 private void reconcileCandidateTablesToBaseTables(TObjectName objName) { 884 TTableList candidates = objName.getCandidateTables(); 885 if (candidates == null || candidates.size() == 0) { 886 return; 887 } 888 889 String columnName = objName.getColumnNameOnly(); 890 if (columnName == null || columnName.isEmpty()) { 891 return; 892 } 893 894 List<TTable> expanded = new ArrayList<TTable>(); 895 Set<String> seenKeys = new LinkedHashSet<String>(); 896 IdentityHashMap<TSelectSqlStatement, Boolean> visiting = 897 new IdentityHashMap<TSelectSqlStatement, Boolean>(); 898 for (int i = 0; i < candidates.size(); i++) { 899 collectBaseTablesSupplyingColumn(candidates.getTable(i), columnName, 900 expanded, seenKeys, visiting); 901 } 902 903 // Only replace when expansion produced concrete base tables; otherwise 904 // keep the existing list rather than wiping resolution information. 905 if (expanded.isEmpty()) { 906 return; 907 } 908 909 candidates.clear(); 910 for (TTable t : expanded) { 911 candidates.addTable(t); 912 } 913 } 914 915 /** 916 * Add, to {@code objName}'s candidate tables, the base tables that supply the 917 * column for an ambiguous candidate whose {@link ColumnSource#getFinalTable()} 918 * is {@code null} (typically a set-operation/UNION-backed namespace). The 919 * namespace's SELECT statement is traced projection-aware by the requested 920 * column, so a branch whose projection at that position is a constant or 921 * expression contributes no base table. Falls back to every reachable final 922 * table only when projection-aware tracing yields nothing. 923 */ 924 private void addBranchBaseTablesForColumn(TObjectName objName, ColumnSource candidate) { 925 INamespace ns = candidate.getSourceNamespace(); 926 if (ns == null) { 927 return; 928 } 929 930 List<TTable> base = new ArrayList<TTable>(); 931 Set<String> seenKeys = new LinkedHashSet<String>(); 932 IdentityHashMap<TSelectSqlStatement, Boolean> visiting = 933 new IdentityHashMap<TSelectSqlStatement, Boolean>(); 934 935 String columnName = objName.getColumnNameOnly(); 936 TSelectSqlStatement nsSelect = ns.getSelectStatement(); 937 boolean traced = false; 938 if (nsSelect != null && columnName != null && !columnName.isEmpty()) { 939 collectFromSelectBody(nsSelect, columnName, -1, base, seenKeys, visiting); 940 traced = true; 941 } 942 943 if (!traced) { 944 // We could not trace the namespace projection-aware (no SELECT body to 945 // inspect): report every reachable final table rather than dropping 946 // the candidate entirely. When tracing DID run, an empty result means 947 // "no physical table supplies this column" (e.g. all branches project 948 // a constant), so we must not fall back and re-add those tables. 949 List<TTable> all = ns.getAllFinalTables(); 950 if (all != null) { 951 for (TTable t : all) { 952 if (t != null) { 953 base.add(t); 954 } 955 } 956 } 957 } 958 959 for (TTable t : base) { 960 objName.getCandidateTables().addTable(t); 961 } 962 } 963 964 /** 965 * Collect, into {@code out}, the physical base tables reachable from 966 * {@code table} that could supply {@code columnName}. Derived tables 967 * (subqueries / CTEs) are expanded through their projection; physical 968 * tables are added directly. De-duplicated by qualified table name via 969 * {@code seenKeys}. {@code visiting} guards against cyclic CTE/subquery 970 * references. 971 */ 972 private void collectBaseTablesSupplyingColumn(TTable table, String columnName, 973 List<TTable> out, Set<String> seenKeys, 974 IdentityHashMap<TSelectSqlStatement, Boolean> visiting) { 975 if (table == null) { 976 return; 977 } 978 979 TSelectSqlStatement body = getDerivedBody(table); 980 if (body == null) { 981 // Physical base table (or a reference we cannot trace further). 982 addBaseTable(table, out, seenKeys); 983 return; 984 } 985 986 if (visiting.containsKey(body)) { 987 return; 988 } 989 visiting.put(body, Boolean.TRUE); 990 try { 991 // A CTE with an explicit column list (e.g. WITH cte(a) AS (SELECT x 992 // FROM t)) renames its outputs, so the requested name must be mapped 993 // positionally onto the body's projection rather than matched by the 994 // body's own display names. 995 int forcedIndex = -1; 996 gudusoft.gsqlparser.nodes.TObjectNameList cteColumns = 997 (table.isCTEName() && table.getCTE() != null) ? table.getCTE().getColumnList() : null; 998 if (cteColumns != null && cteColumns.size() > 0) { 999 forcedIndex = indexOfMatchingName(cteColumns, columnName); 1000 if (forcedIndex < 0) { 1001 // The CTE's column list does not expose this name. 1002 return; 1003 } 1004 } 1005 1006 collectFromSelectBody(body, columnName, forcedIndex, out, seenKeys, visiting); 1007 } finally { 1008 visiting.remove(body); 1009 } 1010 } 1011 1012 /** 1013 * Collect base tables from a derived table's SELECT body. Handles set 1014 * operations (UNION / EXCEPT / INTERSECT) by flattening to leaf SELECTs and 1015 * mapping the column onto each leaf positionally, and plain SELECTs via 1016 * {@link #collectFromSimpleSelect}. 1017 * 1018 * @param forcedIndex when {@code >= 0}, the column is identified by position 1019 * (e.g. a CTE explicit column list); when {@code -1}, by output name. 1020 */ 1021 private void collectFromSelectBody(TSelectSqlStatement body, String columnName, 1022 int forcedIndex, List<TTable> out, Set<String> seenKeys, 1023 IdentityHashMap<TSelectSqlStatement, Boolean> visiting) { 1024 if (body == null) { 1025 return; 1026 } 1027 1028 if (body.isCombinedQuery()) { 1029 List<TSelectSqlStatement> leaves = new ArrayList<TSelectSqlStatement>(); 1030 flattenSetOperationLeaves(body, leaves); 1031 if (leaves.isEmpty()) { 1032 return; 1033 } 1034 // All set operands share one output shape by position. Determine the 1035 // position from the first leaf (or the forced/CTE index) and trace 1036 // that ordinal in every operand. 1037 int idx = forcedIndex; 1038 if (idx < 0) { 1039 idx = indexOfNamedProjection(leaves.get(0).getResultColumnList(), columnName); 1040 } 1041 if (idx < 0) { 1042 // The output ordinal is unknown (e.g. the leftmost operand is 1043 // SELECT *), so the column cannot be mapped onto each operand by 1044 // position. Matching later operands by name would silently drop a 1045 // renamed branch, so over-report every FROM table of every operand 1046 // instead. 1047 for (TSelectSqlStatement leaf : leaves) { 1048 expandAllFromTables(leaf, columnName, out, seenKeys, visiting); 1049 } 1050 return; 1051 } 1052 for (TSelectSqlStatement leaf : leaves) { 1053 collectFromSimpleSelect(leaf, columnName, idx, out, seenKeys, visiting); 1054 } 1055 return; 1056 } 1057 1058 collectFromSimpleSelect(body, columnName, forcedIndex, out, seenKeys, visiting); 1059 } 1060 1061 /** 1062 * Collect base tables from a single (non-combined) SELECT. 1063 * 1064 * <p>When {@code forcedIndex >= 0} the column is resolved by position onto 1065 * the projection (used for CTE column lists and set-operation operands); 1066 * otherwise it is matched by output name. A {@code SELECT *} or an 1067 * out-of-range/star position falls back to expanding every FROM table, since 1068 * any of them may supply the column.</p> 1069 */ 1070 private void collectFromSimpleSelect(TSelectSqlStatement select, String columnName, 1071 int forcedIndex, List<TTable> out, Set<String> seenKeys, 1072 IdentityHashMap<TSelectSqlStatement, Boolean> visiting) { 1073 if (select == null) { 1074 return; 1075 } 1076 TResultColumnList rcl = select.getResultColumnList(); 1077 if (rcl == null) { 1078 return; 1079 } 1080 1081 if (forcedIndex >= 0) { 1082 boolean hasStar = false; 1083 for (int i = 0; i < rcl.size(); i++) { 1084 if (starQualifier(rcl.getResultColumn(i)) != null) { 1085 hasStar = true; 1086 break; 1087 } 1088 } 1089 if (hasStar || forcedIndex >= rcl.size()) { 1090 // Positional mapping is unreliable: any FROM table may supply it. 1091 expandAllFromTables(select, columnName, out, seenKeys, visiting); 1092 return; 1093 } 1094 TResultColumn rc = rcl.getResultColumn(forcedIndex); 1095 TObjectName underlying = rc != null ? rc.getColumnFullname() : null; 1096 if (underlying != null) { 1097 traceNamedColumnIntoFrom(select, underlying, out, seenKeys, visiting); 1098 } 1099 // A computed projection at this position has no physical base table. 1100 return; 1101 } 1102 1103 // Match by output name. Collect base tables from EVERY projection that 1104 // could expose the column: each matching named projection (there may be 1105 // several with the same output name, e.g. "SELECT t1.c, t2.c") AND every 1106 // star projection. We do not early-return on the first match, so 1107 // genuinely ambiguous derived tables surface all of their suppliers. 1108 boolean unqualifiedStar = false; 1109 List<String> qualifiedStarPrefixes = new ArrayList<String>(); 1110 for (int i = 0; i < rcl.size(); i++) { 1111 TResultColumn rc = rcl.getResultColumn(i); 1112 if (rc == null) { 1113 continue; 1114 } 1115 String starPrefix = starQualifier(rc); 1116 if (starPrefix != null) { 1117 if (starPrefix.isEmpty()) { 1118 unqualifiedStar = true; 1119 } else { 1120 qualifiedStarPrefixes.add(starPrefix); 1121 } 1122 continue; 1123 } 1124 1125 String outName = rc.getDisplayName(); 1126 if (outName != null && nameMatcher.matches(outName, columnName)) { 1127 TObjectName underlying = rc.getColumnFullname(); 1128 if (underlying != null) { 1129 traceNamedColumnIntoFrom(select, underlying, out, seenKeys, visiting); 1130 } 1131 // A computed/expression column has no physical base table. 1132 } 1133 } 1134 1135 if (unqualifiedStar) { 1136 expandAllFromTables(select, columnName, out, seenKeys, visiting); 1137 } 1138 for (String prefix : qualifiedStarPrefixes) { 1139 TTable matched = findTableByAliasOrName(select, prefix); 1140 if (matched != null) { 1141 collectBaseTablesSupplyingColumn(matched, columnName, out, seenKeys, visiting); 1142 } 1143 } 1144 // No star and no named match: this SELECT cannot supply the column. 1145 } 1146 1147 /** 1148 * Expand every table in {@code select}'s FROM clause as a possible supplier 1149 * of {@code columnName}. 1150 */ 1151 private void expandAllFromTables(TSelectSqlStatement select, String columnName, 1152 List<TTable> out, Set<String> seenKeys, 1153 IdentityHashMap<TSelectSqlStatement, Boolean> visiting) { 1154 if (select == null || select.tables == null) { 1155 return; 1156 } 1157 for (int i = 0; i < select.tables.size(); i++) { 1158 collectBaseTablesSupplyingColumn(select.tables.getTable(i), 1159 columnName, out, seenKeys, visiting); 1160 } 1161 } 1162 1163 /** 1164 * Iteratively flatten a set-operation tree into the leaf SELECTs that 1165 * actually supply output column values, left to right. Uses an explicit 1166 * stack to avoid StackOverflowError on deeply nested set-operation chains. 1167 * 1168 * <p>The operator matters: UNION and INTERSECT take values from both 1169 * operands, but EXCEPT / MINUS take output values from the LEFT operand only 1170 * (the right operand merely filters rows), so its right branch is not a 1171 * source of the selected column and is skipped.</p> 1172 */ 1173 private void flattenSetOperationLeaves(TSelectSqlStatement stmt, List<TSelectSqlStatement> leaves) { 1174 java.util.Deque<TSelectSqlStatement> stack = new java.util.ArrayDeque<TSelectSqlStatement>(); 1175 stack.push(stmt); 1176 while (!stack.isEmpty()) { 1177 TSelectSqlStatement cur = stack.pop(); 1178 if (cur == null) { 1179 continue; 1180 } 1181 if (cur.isCombinedQuery()) { 1182 gudusoft.gsqlparser.ESetOperatorType op = cur.getSetOperatorType(); 1183 boolean rightSuppliesValues = 1184 op != gudusoft.gsqlparser.ESetOperatorType.except 1185 && op != gudusoft.gsqlparser.ESetOperatorType.minus; 1186 // Push right first (when it supplies values) so the left operand 1187 // is processed first. 1188 if (rightSuppliesValues && cur.getRightStmt() != null) { 1189 stack.push(cur.getRightStmt()); 1190 } 1191 if (cur.getLeftStmt() != null) { 1192 stack.push(cur.getLeftStmt()); 1193 } 1194 } else { 1195 leaves.add(cur); 1196 } 1197 } 1198 } 1199 1200 /** 1201 * Index of the first projection in {@code rcl} whose output name matches 1202 * {@code columnName}, or {@code -1} if none matches or a star projection 1203 * makes positional indexing unreliable. 1204 */ 1205 private int indexOfNamedProjection(TResultColumnList rcl, String columnName) { 1206 if (rcl == null) { 1207 return -1; 1208 } 1209 for (int i = 0; i < rcl.size(); i++) { 1210 TResultColumn rc = rcl.getResultColumn(i); 1211 if (rc == null) { 1212 continue; 1213 } 1214 if (starQualifier(rc) != null) { 1215 // A star shifts subsequent positions unpredictably. 1216 return -1; 1217 } 1218 String name = rc.getDisplayName(); 1219 if (name != null && nameMatcher.matches(name, columnName)) { 1220 return i; 1221 } 1222 } 1223 return -1; 1224 } 1225 1226 /** 1227 * Index of the first name in {@code names} that matches {@code columnName}. 1228 */ 1229 private int indexOfMatchingName(gudusoft.gsqlparser.nodes.TObjectNameList names, String columnName) { 1230 if (names == null) { 1231 return -1; 1232 } 1233 for (int i = 0; i < names.size(); i++) { 1234 TObjectName name = names.getObjectName(i); 1235 String n = name != null ? name.getColumnNameOnly() : null; 1236 if (n != null && nameMatcher.matches(n, columnName)) { 1237 return i; 1238 } 1239 } 1240 return -1; 1241 } 1242 1243 /** 1244 * Trace a named projection column (e.g. {@code t.col} or {@code col}) into 1245 * the FROM clause of {@code body} and collect the base tables that supply 1246 * the referenced column. 1247 */ 1248 private void traceNamedColumnIntoFrom(TSelectSqlStatement body, TObjectName underlying, 1249 List<TTable> out, Set<String> seenKeys, 1250 IdentityHashMap<TSelectSqlStatement, Boolean> visiting) { 1251 if (body == null || body.tables == null || underlying == null) { 1252 return; 1253 } 1254 String underlyingName = underlying.getColumnNameOnly(); 1255 if (underlyingName == null || underlyingName.isEmpty()) { 1256 return; 1257 } 1258 String tableQualifier = columnTableQualifier(underlying); 1259 if (tableQualifier != null && !tableQualifier.isEmpty()) { 1260 TTable matched = findTableByAliasOrName(body, tableQualifier); 1261 if (matched != null) { 1262 collectBaseTablesSupplyingColumn(matched, underlyingName, out, 1263 seenKeys, visiting); 1264 return; 1265 } 1266 } 1267 1268 // If resolver2 already resolved this inner projection column to a single 1269 // source (e.g. via DDL/sqlenv metadata), trust that source rather than 1270 // broadening to every FROM table. This keeps candidateTables accurate 1271 // for "SELECT c FROM t1 JOIN t2" when only t1 actually exposes c. 1272 TTable resolvedSource = underlying.getSourceTable(); 1273 if (resolvedSource != null && isInFromClause(body, resolvedSource)) { 1274 collectBaseTablesSupplyingColumn(resolvedSource, underlyingName, out, 1275 seenKeys, visiting); 1276 return; 1277 } 1278 1279 // Unqualified and unresolved: the underlying column may originate from 1280 // any FROM table. 1281 for (int i = 0; i < body.tables.size(); i++) { 1282 collectBaseTablesSupplyingColumn(body.tables.getTable(i), 1283 underlyingName, out, seenKeys, visiting); 1284 } 1285 } 1286 1287 /** 1288 * Whether {@code target} is one of the tables in {@code body}'s FROM clause 1289 * (by object identity). Used to ensure a resolved {@code sourceTable} really 1290 * belongs to this derived table's scope before trusting it. 1291 */ 1292 private boolean isInFromClause(TSelectSqlStatement body, TTable target) { 1293 if (body == null || body.tables == null || target == null) { 1294 return false; 1295 } 1296 for (int i = 0; i < body.tables.size(); i++) { 1297 if (body.tables.getTable(i) == target) { 1298 return true; 1299 } 1300 } 1301 return false; 1302 } 1303 1304 /** 1305 * Return the SELECT body of a derived table (inline subquery or CTE 1306 * reference), or {@code null} if {@code table} is a physical table. 1307 */ 1308 private TSelectSqlStatement getDerivedBody(TTable table) { 1309 if (table == null) { 1310 return null; 1311 } 1312 if (table.getSubquery() != null) { 1313 return table.getSubquery(); 1314 } 1315 if (table.isCTEName() && table.getCTE() != null) { 1316 return table.getCTE().getSubquery(); 1317 } 1318 return null; 1319 } 1320 1321 /** 1322 * Add a physical base table to {@code out}, de-duplicated by qualified name. 1323 */ 1324 private void addBaseTable(TTable table, List<TTable> out, Set<String> seenKeys) { 1325 if (table == null) { 1326 return; 1327 } 1328 String key = baseTableKey(table); 1329 if (seenKeys.add(key)) { 1330 out.add(table); 1331 } 1332 } 1333 1334 private String baseTableKey(TTable table) { 1335 String name = table.getFullName(); 1336 if (name == null || name.isEmpty()) { 1337 name = table.getName(); 1338 } 1339 if (name == null) { 1340 name = table.toString(); 1341 } 1342 return name == null ? "" : name.toLowerCase(); 1343 } 1344 1345 /** 1346 * If {@code rc} is a star result column, return its qualifier: an empty 1347 * string for an unqualified {@code *}, or the table prefix for {@code q.*}. 1348 * Returns {@code null} when {@code rc} is not a star column. 1349 */ 1350 private String starQualifier(TResultColumn rc) { 1351 if (rc == null || rc.getExpr() == null) { 1352 return null; 1353 } 1354 TObjectName operand = rc.getExpr().getObjectOperand(); 1355 String text = operand != null ? operand.toString() : rc.toString(); 1356 if (text == null) { 1357 return null; 1358 } 1359 text = text.trim(); 1360 if ("*".equals(text)) { 1361 return ""; 1362 } 1363 if (text.endsWith(".*")) { 1364 String prefix = text.substring(0, text.length() - 2).trim(); 1365 // Keep only the last segment of a multi-part prefix (e.g. a.b.* -> b). 1366 int dot = prefix.lastIndexOf('.'); 1367 if (dot >= 0) { 1368 prefix = prefix.substring(dot + 1).trim(); 1369 } 1370 return prefix; 1371 } 1372 return null; 1373 } 1374 1375 /** 1376 * Return the table qualifier of a (possibly) qualified column reference, 1377 * e.g. {@code "t"} for {@code t.col} or {@code "t"} for {@code s.t.col}. 1378 * Returns {@code null} for an unqualified column. 1379 */ 1380 private String columnTableQualifier(TObjectName column) { 1381 if (column == null) { 1382 return null; 1383 } 1384 String full = column.toString(); 1385 if (full == null) { 1386 return null; 1387 } 1388 int lastDot = full.lastIndexOf('.'); 1389 if (lastDot < 0) { 1390 return null; 1391 } 1392 String prefix = full.substring(0, lastDot).trim(); 1393 int prevDot = prefix.lastIndexOf('.'); 1394 if (prevDot >= 0) { 1395 prefix = prefix.substring(prevDot + 1).trim(); 1396 } 1397 return prefix.isEmpty() ? null : prefix; 1398 } 1399 1400 /** 1401 * Find a table in {@code body}'s FROM clause by alias or (last segment of) 1402 * name, matching case-insensitively. 1403 */ 1404 private TTable findTableByAliasOrName(TSelectSqlStatement body, String qualifier) { 1405 if (body == null || body.tables == null || qualifier == null) { 1406 return null; 1407 } 1408 for (int i = 0; i < body.tables.size(); i++) { 1409 TTable t = body.tables.getTable(i); 1410 if (t == null) { 1411 continue; 1412 } 1413 String alias = t.getAliasName(); 1414 if (alias != null && alias.equalsIgnoreCase(qualifier)) { 1415 return t; 1416 } 1417 String name = t.getName(); 1418 if (name != null) { 1419 int dot = name.lastIndexOf('.'); 1420 String shortName = dot >= 0 ? name.substring(dot + 1) : name; 1421 if (shortName.equalsIgnoreCase(qualifier)) { 1422 return t; 1423 } 1424 } 1425 } 1426 return null; 1427 } 1428 1429 /** 1430 * Resolve a column within a specific namespace (for direct lookups). 1431 */ 1432 public ResolutionResult resolveInNamespace(String columnName, INamespace namespace) { 1433 if (columnName == null || namespace == null) { 1434 return ResolutionResult.notFound("<null>"); 1435 } 1436 1437 ColumnSource source = namespace.resolveColumn(columnName); 1438 if (source != null) { 1439 return ResolutionResult.exactMatch(source); 1440 } 1441 1442 return ResolutionResult.notFound(columnName); 1443 } 1444 1445 /** 1446 * Find all namespaces that contain a given column. 1447 * Used for implementing full candidate collection in ambiguous scenarios. 1448 */ 1449 public List<INamespace> findNamespacesWithColumn(String columnName, IScope scope) { 1450 List<INamespace> result = new ArrayList<>(); 1451 1452 for (INamespace ns : scope.getVisibleNamespaces()) { 1453 if (ns.hasColumn(columnName) == ColumnLevel.EXISTS) { 1454 result.add(ns); 1455 } 1456 } 1457 1458 return result; 1459 } 1460 1461 public INameMatcher getNameMatcher() { 1462 return nameMatcher; 1463 } 1464 1465 public ResolutionContext getContext() { 1466 return context; 1467 } 1468}