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