001package gudusoft.gsqlparser.resolver2; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.ETableSource; 005import gudusoft.gsqlparser.ETokenType; 006import gudusoft.gsqlparser.TSourceToken; 007import gudusoft.gsqlparser.nodes.TObjectName; 008import gudusoft.gsqlparser.nodes.TResultColumn; 009import gudusoft.gsqlparser.nodes.TResultColumnList; 010import gudusoft.gsqlparser.nodes.TTable; 011import gudusoft.gsqlparser.nodes.TTableList; 012import gudusoft.gsqlparser.stmt.TSelectSqlStatement; 013import gudusoft.gsqlparser.resolver2.matcher.INameMatcher; 014import gudusoft.gsqlparser.resolver2.model.AmbiguousColumnSource; 015import gudusoft.gsqlparser.resolver2.model.ColumnSource; 016import gudusoft.gsqlparser.resolver2.model.FieldPath; 017import gudusoft.gsqlparser.resolver2.model.ResolutionContext; 018import gudusoft.gsqlparser.resolver2.model.ResolutionResult; 019import gudusoft.gsqlparser.resolver2.namespace.CTENamespace; 020import gudusoft.gsqlparser.resolver2.namespace.INamespace; 021import gudusoft.gsqlparser.resolver2.namespace.TableFunctionLayout; 022import gudusoft.gsqlparser.resolver2.namespace.TableNamespace; 023import gudusoft.gsqlparser.resolver2.namespace.UnnestNamespace; 024import gudusoft.gsqlparser.resolver2.scope.FromScope; 025import gudusoft.gsqlparser.resolver2.scope.IScope; 026import gudusoft.gsqlparser.resolver2.scope.ResolvedImpl; 027import gudusoft.gsqlparser.resolver2.scope.SelectScope; 028import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 029import gudusoft.gsqlparser.util.SQLUtil; 030 031import java.util.ArrayList; 032import java.util.Arrays; 033import java.util.Collections; 034import java.util.Comparator; 035import java.util.IdentityHashMap; 036import java.util.LinkedHashSet; 037import java.util.List; 038import java.util.Set; 039 040/** 041 * Core component for resolving column references to their sources. 042 * 043 * Key responsibilities: 044 * 1. Resolve TObjectName (column references) using scope tree 045 * 2. Handle qualified and unqualified names 046 * 3. Detect and report ambiguities 047 * 4. Apply GUESS_COLUMN_STRATEGY for ambiguous columns 048 * 5. Update TObjectName with resolution results 049 * 6. Update ResolutionContext for global querying 050 */ 051public class NameResolver { 052 053 private final INameMatcher nameMatcher; 054 private final ResolutionContext context; 055 private final TSQLResolverConfig config; 056 057 /** 058 * Create a NameResolver with full configuration. 059 * 060 * @param config The resolver configuration (includes name matcher and strategy) 061 * @param context The resolution context for tracking results 062 */ 063 public NameResolver(TSQLResolverConfig config, ResolutionContext context) { 064 this.config = config; 065 this.nameMatcher = config.getNameMatcher(); 066 this.context = context; 067 } 068 069 /** 070 * Binding-trace capture sink (dynamic-SQL publication proof, R4 step 3); 071 * {@code null} unless {@code TSQLResolverConfig.captureBindingTrace} — 072 * the hook below is then a single null check. Observation-only. 073 */ 074 private gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry bindingTraceRegistry; 075 076 public void setBindingTraceRegistry( 077 gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry registry) { 078 this.bindingTraceRegistry = registry; 079 } 080 081 /** 082 * Record how {@code objName} was bound. The winning match's namespace, 083 * scope, path and remaining names are exactly the information 084 * {@code processResolvedMatches} discards when constructing 085 * {@link ResolutionResult}; the trace preserves them for the P3 proof. 086 * When the result did not come from the recorded matches (e.g. the 087 * struct-field fallback), the match data no longer corresponds and the 088 * trace comes out incomplete — fail-closed by construction. 089 */ 090 private void captureBindingTrace(TObjectName objName, IScope startingScope, 091 ResolvedImpl resolved, ResolutionResult result, boolean fallbackUsed) { 092 ResolvedImpl.Match winning = resolved.getCount() > 0 ? resolved.getMatches().get(0) : null; 093 int climb = -1; 094 if (winning != null) { 095 int hops = 0; 096 for (IScope s = startingScope; s != null; s = s.getParent(), hops++) { 097 if (s == winning.scope) { 098 climb = hops; 099 break; 100 } 101 } 102 } 103 bindingTraceRegistry.register(gudusoft.gsqlparser.resolver2.binding.BindingTrace 104 .scopeResolution(objName, 105 result.getStatus(), 106 winning != null ? winning.namespace : null, 107 winning != null ? definitionNodeOf(winning.namespace) : null, 108 objName.getSourceTable(), 109 String.valueOf(startingScope.getScopeType()), 110 winning != null ? String.valueOf(winning.scope.getScopeType()) : null, 111 winning != null ? String.valueOf(winning.path) : null, 112 resolved.getCount(), 113 climb, 114 // A column reference matched via its container namespace 115 // legitimately leaves ONE remaining segment (the column 116 // itself); more than one means a partially-consumed path 117 // (struct access) whose tail binding we did not see. 118 winning != null && winning.remainingNames.size() <= 1, 119 result.isExactMatch(), 120 fallbackUsed)); 121 } 122 123 /** 124 * The AST node that defines a namespace's binder — what the publication 125 * proof checks spans against. Unknown namespace kinds return null and the 126 * trace comes out incomplete (fail-closed). 127 */ 128 private static Object definitionNodeOf(INamespace ns) { 129 if (ns instanceof gudusoft.gsqlparser.resolver2.namespace.TableNamespace) { 130 return ((gudusoft.gsqlparser.resolver2.namespace.TableNamespace) ns).getTable(); 131 } 132 if (ns instanceof gudusoft.gsqlparser.resolver2.namespace.CTENamespace) { 133 return ((gudusoft.gsqlparser.resolver2.namespace.CTENamespace) ns).getCTE(); 134 } 135 if (ns instanceof gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) { 136 return ((gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) ns).getSubquery(); 137 } 138 return null; 139 } 140 141 /** 142 * Create a NameResolver with just name matcher (backward compatibility). 143 * Uses default configuration for GUESS_COLUMN_STRATEGY. 144 * 145 * @deprecated Use NameResolver(TSQLResolverConfig, ResolutionContext) instead 146 */ 147 @Deprecated 148 public NameResolver(INameMatcher nameMatcher, ResolutionContext context) { 149 this.nameMatcher = nameMatcher; 150 this.context = context; 151 this.config = null; // Will use TBaseType.GUESS_COLUMN_STRATEGY 152 } 153 154 /** 155 * Get the effective GUESS_COLUMN_STRATEGY. 156 * Returns config value if available, otherwise TBaseType.GUESS_COLUMN_STRATEGY. 157 */ 158 private int getGuessColumnStrategy() { 159 if (config != null) { 160 return config.getGuessColumnStrategy(); 161 } 162 return gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY; 163 } 164 165 private static final boolean DEBUG_RESOLUTION = false; 166 167 /** 168 * Resolve a column reference (TObjectName) within a given scope. 169 * 170 * @param objName The column reference to resolve 171 * @param scope The scope where the reference appears 172 * @return Resolution result 173 */ 174 /** 175 * A subquery's unqualified name compared with a column of its own tables is the enclosing query's when its own 176 * tables do not prove it and that query does, by the rule the parse applies too 177 * ({@link TSelectSqlStatement#outerCorrelationTable}): bind it to that table there (Mantis 4797). 178 * 179 * @return true when the name was bound 180 */ 181 private boolean bindToOuterCorrelationTable(TObjectName objName, IScope scope, List<String> nameParts, 182 ResolvedImpl resolved) { 183 if ((nameParts.size() != 1) || !(scope instanceof SelectScope) 184 || !(scope.getNode() instanceof TSelectSqlStatement)) { 185 return false; 186 } 187 TTable outer = TSelectSqlStatement.outerCorrelationTable(objName, (TSelectSqlStatement) scope.getNode(), null); 188 if (outer == null) { 189 return false; 190 } 191 for (IScope enclosing = ((SelectScope) scope).getLookupParent(); enclosing instanceof SelectScope; 192 enclosing = ((SelectScope) enclosing).getLookupParent()) { 193 FromScope from = ((SelectScope) enclosing).getFromScope(); 194 if (from == null) { 195 continue; 196 } 197 for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : from.getChildren()) { 198 if (child.getNamespace().getSourceTable() == outer) { 199 resolved.found(child.getNamespace(), child.isNullable(), enclosing, 200 new gudusoft.gsqlparser.resolver2.model.ResolvePath(), nameParts); 201 return true; 202 } 203 } 204 } 205 return false; 206 } 207 208 public ResolutionResult resolve(TObjectName objName, IScope scope) { 209 if (objName == null || scope == null) { 210 return ResolutionResult.notFound("<null>"); 211 } 212 213 // ClickHouse expression-CTE alias (WITH <expr> AS ident): phase 1 214 // resolved it to a scalar symbol (expressionCteRef). It is not a 215 // column of any relation — namespace matching would fabricate a 216 // binding to whichever FROM table happens to be in scope. 217 if (objName.getExpressionCteRef() != null && objName.getSourceTable() == null) { 218 return ResolutionResult.notFound(objName.toString(), 219 "expression-CTE alias, resolved as a scalar symbol"); 220 } 221 222 // Extract name parts from TObjectName 223 List<String> nameParts = extractNameParts(objName); 224 if (nameParts.isEmpty()) { 225 return ResolutionResult.notFound("<empty>"); 226 } 227 228 // Trino family: the dotted identifiers of the name decide what it reads, not the name slots the grammar 229 // filled (see readsRowFields), and the longest qualifier that names a relation in scope wins, as in Trino. 230 // A qualified table name (sch.events.id, sch.events.payload.address) is that table's column, followed by 231 // fields when parts remain; otherwise a head that names a relation makes a name of three or more parts that 232 // relation's column followed by fields: e.payload.address.city is the column payload of e, whose slots read 233 // database e, schema payload, table address (Mantis 4822). 234 // 235 // When neither applies and no part names a relation, the head is a column and every later part a field 236 // (payload.address.city; Mantis 4811 did this for two parts). That reading is decided from the identifiers, 237 // not the slots, so a name already re-slotted reads the same on resolver2's later passes. A later part that is 238 // the name of a table in scope (x.events.id FROM events) keeps the grammar's table reading, as GSP accepts a 239 // qualifier it does not know before a table name; an alias cannot be qualified, so it does not count. 240 List<String> chain = readsRowFields(objName) ? identifierChain(objName) : Collections.<String>emptyList(); 241 ResolvedImpl resolved = new ResolvedImpl(); 242 boolean qualifiedRelation = false; 243 boolean relationHead = false; 244 ResolutionResult columnHeadResult = null; 245 if (chain.size() >= 2) { 246 boolean headNamesRelation = qualifierNamesVisibleRelation(chain.get(0), scope); 247 if (chain.size() >= 3 && findQualifiedRelation(chain, scope, resolved)) { 248 nameParts = chain; 249 qualifiedRelation = true; 250 } else if (chain.size() >= 3 && headNamesRelation) { 251 nameParts = chain; 252 relationHead = true; 253 } else if (!headNamesRelation && (!namesVisibleTable(chain.subList(1, chain.size() - 1), scope) 254 || declaresColumn(chain.get(0), scope))) { 255 columnHeadResult = tryStructFieldFallback(objName, chain, scope); 256 } 257 } 258 // Redshift SUPER and Oracle object columns: o.customer.address.city is the column customer of the relation o 259 // with the attribute path address.city. The grammar slots of a four-part name put o in the database slot, 260 // which the slot reading drops, so the name read as address.city and was NOT_FOUND. When the written head 261 // names a relation in scope and no qualified table name matches, the written parts are read instead 262 // (Mantis 4839, 4840). 263 if (chain.isEmpty() && readsAttributePaths()) { 264 List<String> attributeChain = identifierChain(objName); 265 if (attributeChain.size() >= 3 && nameParts.size() < attributeChain.size() 266 && qualifierNamesVisibleRelation(attributeChain.get(0), scope) 267 && !findQualifiedRelation(attributeChain, scope, new ResolvedImpl())) { 268 nameParts = attributeChain; 269 } 270 } 271 // A wildcard below a column, payload.* or payload.address.*, reads every field of it, as BigQuery customer.* 272 // does: the same reading when no part before the wildcard names a relation, and the same owner rule. 273 boolean columnStar = false; 274 if (readsRowFields(objName) && chain.isEmpty()) { 275 List<String> starChain = new ArrayList<String>(); 276 for (TSourceToken token : objName.getStarQualifierChain()) { 277 starChain.add(token.toString()); 278 } 279 if (!starChain.isEmpty() && !qualifierNamesVisibleRelation(starChain.get(0), scope) 280 && (!namesVisibleTable(starChain.subList(1, starChain.size()), scope) 281 || declaresColumn(starChain.get(0), scope))) { 282 List<String> parts = new ArrayList<String>(starChain); 283 parts.add(objName.getColumnNameOnly()); 284 columnHeadResult = tryStructFieldFallback(objName, parts, scope); 285 columnStar = columnHeadResult != null; 286 } else if (starChain.size() >= 3 && qualifierNamesVisibleRelation(starChain.get(0), scope)) { 287 // e.payload.address.*: the column payload of e, then fields, then the wildcard 288 ResolvedImpl relationColumn = new ResolvedImpl(); 289 List<String> columnParts = starChain.subList(0, 2); 290 scope.resolve(columnParts, nameMatcher, false, relationColumn); 291 ResolutionResult column = processResolvedMatches(objName, columnParts, relationColumn, scope); 292 if (column.isExactMatch() && column.getColumnSource() != null) { 293 List<String> fields = new ArrayList<String>(starChain.subList(2, starChain.size())); 294 fields.add(objName.getColumnNameOnly()); 295 columnHeadResult = ResolutionResult.exactMatch(column.getColumnSource().withFieldPath( 296 FieldPath.of(fields), "struct_field_access")); 297 columnStar = true; 298 } 299 } 300 } 301 302 if (DEBUG_RESOLUTION) { 303 System.out.println("[DEBUG-RESOLVE] Resolving: " + objName + 304 " nameParts=" + nameParts + " scopeType=" + scope.getScopeType()); 305 } 306 307 boolean fallbackUsed = false; 308 ResolutionResult result; 309 if (columnHeadResult != null && columnHeadResult.isExactMatch()) { 310 result = columnHeadResult; 311 fallbackUsed = true; 312 } else { 313 // Use scope to resolve the name 314 if (!qualifiedRelation && !bindToOuterCorrelationTable(objName, scope, nameParts, resolved)) { 315 scope.resolve(nameParts, nameMatcher, false, resolved); 316 } 317 318 if (DEBUG_RESOLUTION) { 319 System.out.println("[DEBUG-RESOLVE] Resolved matches: " + resolved.getCount()); 320 if (resolved.getCount() > 1) { 321 for (ResolvedImpl.Match m : resolved.getMatches()) { 322 System.out.println("[DEBUG-RESOLVE] Match: " + m.namespace.getDisplayName() + 323 " type=" + m.namespace.getClass().getSimpleName() + 324 " id=" + System.identityHashCode(m.namespace) + 325 " remaining=" + m.remainingNames + 326 " scope=" + m.scope.getScopeType()); 327 } 328 } 329 } 330 331 // Process resolution results 332 result = processResolvedMatches(objName, nameParts, resolved, scope); 333 334 // Delta 3: Struct-field fallback for BigQuery/Snowflake 335 // If resolution failed and we have a 2-part qualified name like "customer.customer_id", 336 // try interpreting it as column.field (struct field access) instead of table.column. 337 // Only for 2-part names; 3+ part no-alias case (e.g., customer.address.city) is not 338 // handled here to avoid changing resolution paths for existing 3-part BigQuery patterns 339 // (e.g., purchases.first.msts) that are correctly handled by DataFlowAnalyzer heuristics. 340 // The alias case (o.customer.address.city) IS handled by resolveColumnPath() in 341 // processResolvedMatches() and does not depend on this fallback. 342 if (!result.isExactMatch() && nameParts.size() == 2 && isStructFieldVendor()) { 343 ResolutionResult structFieldResult = tryStructFieldFallback(objName, nameParts, scope); 344 if (structFieldResult != null && structFieldResult.isExactMatch()) { 345 result = structFieldResult; 346 fallbackUsed = true; 347 if (DEBUG_RESOLUTION) { 348 System.out.println("[DEBUG-RESOLVE] Struct-field fallback succeeded for: " + objName); 349 } 350 } else if (structFieldResult != null && structFieldResult.isAmbiguous() 351 && config != null && config.getVendor() == EDbVendor.dbvbigquery) { 352 // BigQuery: customer.name, with no relation customer in scope, reads the field name of a column 353 // customer that several relations provide. Recording that, rather than a table customer that no 354 // FROM clause names, lets lineage publish no owner (STRUCT tree plan rule R3). 355 result = structFieldResult; 356 objName.columnToProperty(); 357 } 358 } 359 360 if (!result.isExactMatch() && columnHeadResult != null && columnHeadResult.isAmbiguous() && columnStar) { 361 // payload.* where several relations could provide payload: undecided, not a relation payload 362 result = columnHeadResult; 363 } else if (!result.isExactMatch() && columnHeadResult != null && columnHeadResult.isAmbiguous() 364 && chain.size() >= 3) { 365 // The reading settles the role but not the owner: payload is a column of one of the relations 366 // in scope, not a schema payload with a table address that no FROM clause names, which lineage 367 // would publish. (Two parts read as an unknown table payload leave an orphan column, as before.) 368 result = columnHeadResult; 369 objName.columnToFieldPath(chain.size() - 1); 370 } 371 } 372 if (relationHead && !result.isExactMatch()) { 373 // Likewise when the scope cannot bind the relation the head names (the name of an aliased table, which 374 // GSP accepts as a qualifier): its second part is still the column, not the last one. 375 objName.columnToFieldPath(chain.size() - 2); 376 } 377 378 // Delta 4: Side-channel hint for 3+ part no-alias struct access (BigQuery only). 379 // When resolution failed and we have 3+ parts (e.g., customer.address.city), 380 // set a StructFieldHint WITHOUT changing the resolution result or sourceTable. 381 // This provides struct path info to DataFlowAnalyzer without altering lineage topology. 382 if (!result.isExactMatch() && nameParts.size() >= 3 && isStructFieldHintVendor()) { 383 ResolutionResult undecided = trySetStructFieldHint(objName, nameParts, scope); 384 if (undecided != null) { 385 // customer.address.city where several relations could provide customer: the reading is a field 386 // path, not a table customer.address that no FROM clause names, and the owner is undecided 387 // (STRUCT tree plan rule R3). The name keeps its slots: its name slots cannot hold a column with two 388 // fields, and the candidates carry the path. 389 result = undecided; 390 } 391 } 392 393 if (DEBUG_RESOLUTION) { 394 System.out.println("[DEBUG-RESOLVE] Result: " + result.getStatus() + 395 (result.isExactMatch() && result.getColumnSource() != null ? 396 " source=" + result.getColumnSource().getExposedName() : "")); 397 } 398 399 // Update TObjectName and context 400 updateObjectNameWithResult(objName, result); 401 402 // Binding-trace capture (observation-only; null unless opted in). 403 // Runs AFTER the update so the trace observes the post-resolution 404 // sourceTable — the identity the registry's staleness check compares 405 // against when later correction handlers change or clear it. 406 if (bindingTraceRegistry != null) { 407 captureBindingTrace(objName, scope, resolved, result, fallbackUsed); 408 } 409 410 return result; 411 } 412 413 /** 414 * Check if the current vendor supports struct-field access syntax (column.field). 415 * Currently supported: BigQuery, Snowflake 416 */ 417 /** 418 * The single source a {@code JOIN ... USING} merge leaves for this column, or null when the 419 * candidates are not one merged column. 420 * 421 * <p>SQL exposes a merged column ONCE, so an unqualified reference to it is not ambiguous 422 * across the sides that merged it — {@code SELECT c FROM a JOIN b USING (c)} names one column. 423 * Three things have to hold before that can be said, and each of them is a way this went wrong 424 * in review: 425 * <ul> 426 * <li>the merge is declared on the scope that produced these candidates, not on any ancestor. 427 * An outer {@code USING (c)} says nothing about an inner query's own two providers of 428 * {@code c}, and consulting ancestors turned that inner ambiguity into an exact match. 429 * <li>the merge covers EVERY candidate. {@code SELECT c FROM a JOIN b USING(c), d} has three 430 * providers and only two of them merged, so it stays ambiguous. 431 * <li>the join type says which side the value comes from. INNER and LEFT keep the left side, 432 * RIGHT keeps the right; FULL is {@code COALESCE(a.c, b.c)} and cannot be represented by 433 * one source, so it is left ambiguous rather than answered with half of it. 434 * </ul> 435 */ 436 private ColumnSource mergedUsingSource(ResolvedImpl resolved, String columnName, 437 List<ColumnSource> candidates) { 438 EDbVendor vendor = config != null ? config.getVendor() : null; 439 if (vendor == null || columnName == null || candidates.size() < 2) { 440 return null; 441 } 442 List<gudusoft.gsqlparser.nodes.TTable> candidateTables = 443 new ArrayList<gudusoft.gsqlparser.nodes.TTable>(); 444 for (ColumnSource candidate : candidates) { 445 // The RELATION the candidate came from, which for a derived table or CTE is the FROM 446 // item itself — not getFinalTable(), which follows through to the underlying base table 447 // and so never matches the join's participants. 448 gudusoft.gsqlparser.nodes.TTable table = relationOf(candidate.getSourceNamespace()); 449 if (table == null) { 450 return null; // cannot prove the candidate took part in any merge 451 } 452 candidateTables.add(table); 453 } 454 455 for (ResolvedImpl.Match match : resolved.getMatches()) { 456 if (!(match.scope instanceof gudusoft.gsqlparser.resolver2.scope.SelectScope)) { 457 continue; 458 } 459 gudusoft.gsqlparser.resolver2.scope.SelectScope.UsingMerge merge = 460 ((gudusoft.gsqlparser.resolver2.scope.SelectScope) match.scope) 461 .findCoveringMerge(config.identifierService(), columnName, candidateTables); 462 if (merge == null) { 463 continue; 464 } 465 gudusoft.gsqlparser.nodes.TTable owner = mergedValueOwner(merge); 466 if (owner == null) { 467 return null; 468 } 469 for (ColumnSource candidate : candidates) { 470 if (relationOf(candidate.getSourceNamespace()) == owner) { 471 return candidate; 472 } 473 } 474 return null; 475 } 476 return null; 477 } 478 479 /** 480 * The FROM item a namespace stands for, or null when it does not stand for one. 481 * 482 * <p>Deliberately a switch over the namespace kinds rather than a call to {@code 483 * getFinalTable()}: that method resolves THROUGH a derived table to the base table underneath, 484 * which is the right answer for lineage and the wrong one here, where the question is which 485 * side of the join this candidate is. 486 */ 487 private static gudusoft.gsqlparser.nodes.TTable relationOf(INamespace namespace) { 488 if (namespace instanceof gudusoft.gsqlparser.resolver2.namespace.TableNamespace) { 489 return ((gudusoft.gsqlparser.resolver2.namespace.TableNamespace) namespace).getTable(); 490 } 491 if (namespace instanceof gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) { 492 return ((gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) namespace).getSourceTable(); 493 } 494 if (namespace instanceof gudusoft.gsqlparser.resolver2.namespace.CTENamespace) { 495 return ((gudusoft.gsqlparser.resolver2.namespace.CTENamespace) namespace).getReferencingTable(); 496 } 497 return null; 498 } 499 500 /** 501 * Which participant a merged column's value comes from, or null when no single one does. 502 * Participants are held in join order, left side first. 503 */ 504 private static gudusoft.gsqlparser.nodes.TTable mergedValueOwner( 505 gudusoft.gsqlparser.resolver2.scope.SelectScope.UsingMerge merge) { 506 List<gudusoft.gsqlparser.nodes.TTable> participants = merge.getParticipants(); 507 if (participants.isEmpty()) { 508 return null; 509 } 510 gudusoft.gsqlparser.EJoinType joinType = merge.getJoinType(); 511 if (joinType == gudusoft.gsqlparser.EJoinType.full 512 || joinType == gudusoft.gsqlparser.EJoinType.fullouter) { 513 // COALESCE of both sides: naming one would publish half a dependency as if it were all 514 // of it, so this stays ambiguous. 515 return null; 516 } 517 if (joinType == gudusoft.gsqlparser.EJoinType.right 518 || joinType == gudusoft.gsqlparser.EJoinType.rightouter) { 519 return participants.get(participants.size() - 1); 520 } 521 return participants.get(0); 522 } 523 524 private boolean isStructFieldVendor() { 525 if (config == null) { 526 return false; 527 } 528 return gudusoft.gsqlparser.nodes.structured.NestedAccessProfile.forVendor(config.getVendor()) 529 .isStructFieldVendor(); 530 } 531 532 /** 533 * Whether a relation followed by two or more parts ({@code o.customer.address.city}) is a column of that 534 * relation with a field path, rather than its last part as the column: the struct-field vendors, and the Trino 535 * family, whose relation-headed names reach here with all their dotted identifiers (Mantis 4822). 536 */ 537 /** Redshift SUPER and Oracle object columns: an attribute path after a relation-qualified column (Mantis 4839). */ 538 private boolean readsAttributePaths() { 539 return config != null && gudusoft.gsqlparser.nodes.structured.NestedAccessProfile 540 .forVendor(config.getVendor()).readsAttributePaths(); 541 } 542 543 private boolean readsColumnPaths() { 544 // Redshift SUPER and Oracle object columns: o.customer.address.city is the column customer of the 545 // relation o with the attribute path address.city, not a four-part name (Mantis 4839, 4840) 546 return isStructFieldVendor() || (config != null && (gudusoft.gsqlparser.nodes.structured.NestedAccessProfile.forVendor(config.getVendor()).readsFieldChains() 547 || readsAttributePaths())); 548 } 549 550 /** 551 * The Trino family: Athena, Presto, and Trino, which this library parses with the Presto grammar. Trino reads a 552 * dotted name whose qualifier names no relation in scope as a column followed by fields of its ROW value, while 553 * these PostgreSQL-style grammars fill the name slots as if every qualifier were a table, schema or database: 554 * {@code payload.items[1].name} as table {@code payload}, {@code payload.address.city} as schema {@code payload}, 555 * table {@code address}. So in this family TSQLResolver2 reads a column name from its dotted identifiers, a read 556 * of a field is a read of its column, and lineage models a STRUCT column as the declared column 557 * (Mantis 4811, 4822). 558 * 559 * @param vendor the vendor of the analysis; a Trino analysis reads statements the Presto grammar stamped 560 * {@code dbvpresto} 561 * 562 * @deprecated read {@code NestedAccessProfile.forVendor(vendor).isTrinoFamily()} 563 */ 564 @Deprecated 565 public static boolean isTrinoFamily(EDbVendor vendor) { 566 return gudusoft.gsqlparser.nodes.structured.NestedAccessProfile.forVendor(vendor).isTrinoFamily(); 567 } 568 569 /** 570 * The vendors that read a dotted name by what its qualifiers name, as {@link #isTrinoFamily the Trino family} 571 * does: a qualifier that names a relation in scope is that relation, otherwise the first part is a column and 572 * every later part a field of its STRUCT value. Hive, Spark SQL and Databricks resolve {@code payload.name} and 573 * {@code customer.address.city} the same way, so their field reads are reads of the declared column with a field 574 * path, and lineage models a STRUCT column as a tree, as for BigQuery and the Trino family (Mantis 4821, 4837). 575 * 576 * @deprecated read {@code NestedAccessProfile.forVendor(vendor).readsFieldChains()} 577 */ 578 @Deprecated 579 public static boolean readsFieldChains(EDbVendor vendor) { 580 return gudusoft.gsqlparser.nodes.structured.NestedAccessProfile.forVendor(vendor).readsFieldChains(); 581 } 582 583 /** 584 * Whether a column name is read from its dotted identifiers ({@link #isTrinoFamily}) rather than from its name 585 * slots: never a re-split of the rendered name, which would cut a subscript such as {@code items[1]}. 586 */ 587 private boolean readsRowFields(TObjectName objName) { 588 return config != null && objName != null && gudusoft.gsqlparser.nodes.structured.NestedAccessProfile.forVendor(config.getVendor()).readsFieldChains(); 589 } 590 591 /** The dotted identifiers of a name as written: {@link TObjectName#getIdentifierChain()}. */ 592 private static List<String> identifierChain(TObjectName objName) { 593 List<TSourceToken> tokens = objName.getIdentifierChain(); 594 List<String> parts = new ArrayList<String>(tokens.size()); 595 for (TSourceToken token : tokens) { 596 parts.add(token.toString()); 597 } 598 return parts; 599 } 600 601 /** 602 * Whether a relation in {@code scope} declares a column {@code name}, by DDL or metadata rather than by inference 603 * from its use. A name that starts with such a column reads it, even when a later part is also the name of a table 604 * in scope: with {@code source(payload ROW(events ROW(id)))} declared, {@code payload.events.id FROM source, events} 605 * reads the field {@code events.id} of {@code payload}. An undeclared head ({@code x.events.id FROM events}) keeps 606 * the table reading GSP accepts. 607 */ 608 private boolean declaresColumn(String name, IScope scope) { 609 ResolvedImpl resolved = new ResolvedImpl(); 610 scope.resolve(Collections.singletonList(name), nameMatcher, false, resolved); 611 for (ResolvedImpl.Match match : resolved.getMatches()) { 612 ColumnSource source = match.namespace.resolveColumn(name); 613 if (source != null && source.hasDefiniteEvidence()) { 614 return true; 615 } 616 } 617 return false; 618 } 619 620 /** 621 * Whether any of {@code parts} is the name of a table visible in {@code scope}, aliased or not. An alias does not 622 * count: it is never qualified, so {@code payload.address.city} with a relation aliased {@code address} in scope 623 * is still not a table reading (Mantis 4822). 624 */ 625 private boolean namesVisibleTable(List<String> parts, IScope scope) { 626 if (parts.isEmpty()) { 627 return false; 628 } 629 for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : visibleRelations(scope)) { 630 TTable table = child.getNamespace() == null ? null : child.getNamespace().getSourceTable(); 631 String tableName = table == null || table.getTableName() == null ? null 632 : table.getTableName().getObjectString(); 633 if (tableName == null || tableName.isEmpty()) { 634 continue; 635 } 636 for (String part : parts) { 637 if (nameMatcher.matches(part, tableName)) { 638 return true; 639 } 640 } 641 } 642 return false; 643 } 644 645 /** 646 * Whether {@code qualifier} names a relation visible in {@code scope}: its alias, or the table's own name even 647 * when it is aliased. A failed {@code e.missing} in {@code FROM events e} is a column that relation lacks, not 648 * the field {@code missing} of a column {@code e}; and Trino hides an aliased table's own name, but GSP links 649 * {@code events.id} in {@code FROM events e} to that table. Reading either qualifier as a column would invent 650 * a source (Mantis 4811). 651 */ 652 private boolean qualifierNamesVisibleRelation(String qualifier, IScope scope) { 653 if (qualifier == null) { 654 return false; 655 } 656 if (namesLateralView(qualifier, scope)) { 657 return true; 658 } 659 for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : visibleRelations(scope)) { 660 String alias = child.getAlias(); 661 if (alias != null && !alias.isEmpty() && nameMatcher.matches(qualifier, alias)) { 662 return true; 663 } 664 TTable table = child.getNamespace() == null ? null : child.getNamespace().getSourceTable(); 665 if (table == null) { 666 continue; 667 } 668 if (table.getAliasName() != null && !table.getAliasName().isEmpty() 669 && nameMatcher.matches(qualifier, table.getAliasName())) { 670 return true; 671 } 672 String tableName = table.getTableName() == null ? null : table.getTableName().getObjectString(); 673 if (tableName != null && !tableName.isEmpty() && nameMatcher.matches(qualifier, tableName)) { 674 return true; 675 } 676 } 677 return false; 678 } 679 680 /** 681 * Binds the longest prefix of two or three parts of {@code chain}, short of its last part, that is the qualified 682 * name of a table in scope ({@code schema.table}, {@code catalog.schema.table}, aliased or not), searching the 683 * innermost scope first, and records each such table of that scope in {@code resolved} with the rest of the chain 684 * as the names left to resolve in it. Trino tries the longest qualifier first, so {@code sch.events.id} is the 685 * column id of the table sch.events in scope even when an enclosing query aliases another relation {@code sch}, 686 * which the head alone would name; and {@code sch.events.payload.address} is its column payload with a field. 687 * resolver2 otherwise binds a single-part qualifier only (Mantis 4822). 688 * 689 * @return whether a table was found 690 */ 691 private boolean findQualifiedRelation(List<String> chain, IScope scope, ResolvedImpl resolved) { 692 for (int length = Math.min(chain.size() - 1, 3); length >= 2; length--) { 693 List<String> qualifier = chain.subList(0, length); 694 Set<IScope> seen = Collections.newSetFromMap(new IdentityHashMap<IScope, Boolean>()); 695 for (IScope current = scope; current != null && seen.add(current); current = current.getParent()) { 696 boolean found = false; 697 for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : relationsOf(current)) { 698 TTable table = child.getNamespace() == null ? null : child.getNamespace().getSourceTable(); 699 if (table != null && table.getTableName() != null 700 && qualifiedNameEndsWith(table.getTableName(), qualifier)) { 701 resolved.found(child.getNamespace(), child.isNullable(), current, 702 new gudusoft.gsqlparser.resolver2.model.ResolvePath(), 703 chain.subList(length, chain.size())); 704 found = true; 705 } 706 } 707 if (found) { 708 return true; 709 } 710 } 711 } 712 return false; 713 } 714 715 /** Whether the qualified name of a table ends with {@code parts}: {@code sch.events} for {@code db.sch.events}. */ 716 private boolean qualifiedNameEndsWith(TObjectName tableName, List<String> parts) { 717 List<String> qualified = new ArrayList<String>(3); 718 if (!tableName.getDatabaseString().isEmpty()) { 719 qualified.add(tableName.getDatabaseString()); 720 } 721 if (!tableName.getSchemaString().isEmpty()) { 722 qualified.add(tableName.getSchemaString()); 723 } 724 qualified.add(tableName.getObjectString()); 725 if (parts.size() > qualified.size()) { 726 return false; 727 } 728 for (int i = 1; i <= parts.size(); i++) { 729 if (!nameMatcher.matches(parts.get(parts.size() - i), qualified.get(qualified.size() - i))) { 730 return false; 731 } 732 } 733 return true; 734 } 735 736 /** 737 * Whether {@code qualifier} is the alias of a Hive-family LATERAL VIEW of a SELECT in scope, 738 * {@code explode_table} in {@code LATERAL VIEW explode(arr) explode_table AS col1}: a relation of that SELECT 739 * (its {@code LateralViewNamespace}), so {@code explode_table.col1} is its column, not a field of a column 740 * (Mantis 4821, 4845). 741 */ 742 private boolean namesLateralView(String qualifier, IScope scope) { 743 Set<IScope> seen = Collections.newSetFromMap(new IdentityHashMap<IScope, Boolean>()); 744 for (IScope current = scope; current != null && seen.add(current); current = current.getParent()) { 745 if (!(current.getNode() instanceof TSelectSqlStatement)) { 746 continue; 747 } 748 TSelectSqlStatement select = (TSelectSqlStatement) current.getNode(); 749 if (select.tables == null) { 750 continue; 751 } 752 for (int i = 0; i < select.tables.size(); i++) { 753 TTable table = select.tables.getTable(i); 754 if (table.getLateralViewList() == null) { 755 continue; 756 } 757 for (gudusoft.gsqlparser.nodes.TLateralView view : table.getLateralViewList()) { 758 if (view.getTableAlias() != null && view.getTableAlias().getAliasName() != null 759 && nameMatcher.matches(qualifier, view.getTableAlias().getAliasName().toString())) { 760 return true; 761 } 762 } 763 } 764 } 765 return false; 766 } 767 768 /** 769 * The relations visible in {@code scope} and its enclosing scopes, with the FROM scopes of SELECT, UPDATE, 770 * DELETE and MERGE. Walked by hand: IScope.getVisibleNamespaces() recurses between a SELECT scope and its FROM 771 * scope, whose parent is that SELECT scope. 772 */ 773 private static List<gudusoft.gsqlparser.resolver2.model.ScopeChild> visibleRelations(IScope scope) { 774 List<gudusoft.gsqlparser.resolver2.model.ScopeChild> children = 775 new ArrayList<gudusoft.gsqlparser.resolver2.model.ScopeChild>(); 776 Set<IScope> seen = Collections.newSetFromMap(new IdentityHashMap<IScope, Boolean>()); 777 for (IScope current = scope; current != null && seen.add(current); current = current.getParent()) { 778 children.addAll(relationsOf(current)); 779 } 780 return children; 781 } 782 783 /** The relations of one scope: its children and those of its FROM scope ({@link #fromScopeOf}). */ 784 private static List<gudusoft.gsqlparser.resolver2.model.ScopeChild> relationsOf(IScope scope) { 785 List<gudusoft.gsqlparser.resolver2.model.ScopeChild> children = 786 new ArrayList<gudusoft.gsqlparser.resolver2.model.ScopeChild>(scope.getChildren()); 787 IScope from = fromScopeOf(scope); 788 if (from != null) { 789 children.addAll(from.getChildren()); 790 } 791 return children; 792 } 793 794 /** The FROM scope a SELECT, UPDATE, DELETE or MERGE scope keeps its relations in, or null. */ 795 private static IScope fromScopeOf(IScope scope) { 796 if (scope instanceof gudusoft.gsqlparser.resolver2.scope.SelectScope) { 797 return ((gudusoft.gsqlparser.resolver2.scope.SelectScope) scope).getFromScope(); 798 } 799 if (scope instanceof gudusoft.gsqlparser.resolver2.scope.UpdateScope) { 800 return ((gudusoft.gsqlparser.resolver2.scope.UpdateScope) scope).getFromScope(); 801 } 802 if (scope instanceof gudusoft.gsqlparser.resolver2.scope.DeleteScope) { 803 return ((gudusoft.gsqlparser.resolver2.scope.DeleteScope) scope).getFromScope(); 804 } 805 if (scope instanceof gudusoft.gsqlparser.resolver2.scope.MergeScope) { 806 return ((gudusoft.gsqlparser.resolver2.scope.MergeScope) scope).getFromScope(); 807 } 808 return null; 809 } 810 811 /** 812 * Check if the current vendor supports struct-field hint annotations. 813 * Currently BigQuery only (Snowflake uses schema.table.column for 3-part names). 814 */ 815 private boolean isStructFieldHintVendor() { 816 if (config == null) { 817 return false; 818 } 819 return config.getVendor() == EDbVendor.dbvbigquery; 820 } 821 822 /** 823 * Delta 4: Try to set a StructFieldHint for 3+ part no-alias struct access. 824 * 825 * For "customer.address.city" (3 parts, no alias): 826 * - Treat the first part ("customer") as a potential base column 827 * - If found in any visible namespace, set a hint with fieldPath=["address", "city"] 828 * - Does NOT change ResolutionResult or sourceTable 829 * 830 * @param objName The TObjectName to annotate with a hint 831 * @param nameParts The extracted name parts (e.g., ["customer", "address", "city"]) 832 * @param scope The scope to search in 833 */ 834 private ResolutionResult trySetStructFieldHint(TObjectName objName, List<String> nameParts, IScope scope) { 835 // A qualified name is not a field path: in ds.t.c FROM ds.t the head is not a column and t is the table 836 // (STRUCT tree plan rule R4). The head would be found as an inferred column of any table without DDL. 837 if (qualifierNamesVisibleRelation(nameParts.get(0), scope) 838 || (namesVisibleTable(nameParts.subList(1, nameParts.size() - 1), scope) 839 && !declaresColumn(nameParts.get(0), scope))) { 840 return null; 841 } 842 String baseColumnName = nameParts.get(0); 843 List<String> fieldPathSegments = nameParts.subList(1, nameParts.size()); 844 845 // Try to find the base column as an unqualified name 846 List<String> singlePartName = Collections.singletonList(baseColumnName); 847 ResolvedImpl resolved = new ResolvedImpl(); 848 scope.resolve(singlePartName, nameMatcher, false, resolved); 849 850 if (resolved.isEmpty()) { 851 return null; // Base column not found in any namespace 852 } 853 854 // Several relations that could provide the base column leave its owner undecided, as for two parts 855 // (tryStructFieldFallback): the finding is that ambiguity, with the field read below each candidate. 856 List<INamespace> providers = new ArrayList<INamespace>(); 857 List<ColumnSource> fieldReads = new ArrayList<ColumnSource>(); 858 for (ResolvedImpl.Match match : resolved.getMatches()) { 859 if (providers.contains(match.namespace)) { 860 continue; 861 } 862 ColumnSource base = match.namespace.resolveColumn(baseColumnName); 863 if (base != null) { 864 providers.add(match.namespace); 865 fieldReads.add(base); 866 } 867 } 868 if (fieldReads.size() > 1 && mergedUsingSource(resolved, baseColumnName, fieldReads) == null) { 869 sortCandidatesByTablePosition(fieldReads); 870 List<ColumnSource> candidates = new ArrayList<ColumnSource>(fieldReads.size()); 871 for (ColumnSource base : fieldReads) { 872 candidates.add(base.withFieldPath(FieldPath.of(fieldPathSegments), "struct_field_access")); 873 } 874 return ResolutionResult.ambiguous(new AmbiguousColumnSource(baseColumnName, candidates)); 875 } 876 877 // Check if any namespace actually has this column 878 for (ResolvedImpl.Match match : resolved.getMatches()) { 879 gudusoft.gsqlparser.resolver2.model.ColumnSource baseColumnSource = 880 match.namespace.resolveColumn(baseColumnName); 881 if (baseColumnSource != null) { 882 // Found the base column - create hint 883 gudusoft.gsqlparser.resolver2.model.FieldPath fieldPath = 884 gudusoft.gsqlparser.resolver2.model.FieldPath.of(fieldPathSegments); 885 gudusoft.gsqlparser.resolver2.model.StructFieldHint hint = 886 new gudusoft.gsqlparser.resolver2.model.StructFieldHint( 887 baseColumnName, fieldPath, 888 "struct_field_hint_no_alias", 0.7); 889 objName.setStructFieldHint(hint); 890 891 if (DEBUG_RESOLUTION) { 892 System.out.println("[DEBUG-RESOLVE] StructFieldHint set for: " + objName + 893 " -> base=" + baseColumnName + ", fieldPath=" + fieldPathSegments); 894 } 895 return null; 896 } 897 } 898 return null; 899 } 900 901 /** 902 * Delta 3: Try to resolve a qualified name as struct-field access (column.field). 903 * 904 * In BigQuery/Snowflake, "customer.customer_id" might be: 905 * 1. Table "customer" with column "customer_id" (standard interpretation) 906 * 2. Column "customer" (STRUCT type) with field "customer_id" (struct-field access) 907 * 908 * If the standard interpretation failed, try the struct-field interpretation: 909 * - Treat the first part as an unqualified column name 910 * - If found, return the base column as the source (the STRUCT column) 911 * - Preserve the field path (segments beyond the base column) for downstream use 912 * 913 * @param objName The original TObjectName 914 * @param nameParts The extracted name parts (e.g., ["customer", "customer_id"]) 915 * @param scope The scope to search in 916 * @return Resolution result if struct-field interpretation succeeds, null otherwise 917 */ 918 private ResolutionResult tryStructFieldFallback(TObjectName objName, List<String> nameParts, IScope scope) { 919 // The base column is the first part (e.g., "customer" in "customer.customer_id") 920 String baseColumnName = nameParts.get(0); 921 922 // The field path is everything after the base column 923 // For "customer.customer_id", fieldPath = ["customer_id"] 924 // For "customer.address.city", fieldPath = ["address", "city"] 925 List<String> fieldPathSegments = nameParts.size() > 1 926 ? nameParts.subList(1, nameParts.size()) 927 : Collections.emptyList(); 928 929 // Try to resolve the base column as an unqualified name 930 List<String> singlePartName = Collections.singletonList(baseColumnName); 931 ResolvedImpl resolved = new ResolvedImpl(); 932 scope.resolve(singlePartName, nameMatcher, false, resolved); 933 934 if (resolved.isEmpty()) { 935 return null; // Base column not found 936 } 937 938 // EVERY namespace that could provide the base column, not the first one found. 939 // 940 // `SELECT customer.name FROM orders, items` settles the ROLE — `customer` is not a relation 941 // in scope, so it is a column and `name` is a field inside it — but it does not settle the 942 // OWNER: `customer` could belong to either relation and nothing in the statement says 943 // which. Returning on the first match answered `orders` and reported EXACT_MATCH, which is 944 // a guess wearing a definite answer's clothes: nothing downstream could tell it apart from 945 // a binding the statement actually proves. 946 // 947 // Guessing is not forbidden here, it is OPT-IN, and this is not where it is opted into: 948 // dlineage's Option.setLinkOrphanColumnToFirstTable(boolean) defaults to false and, when 949 // switched on, links the column to the first relation AND records the others as candidate 950 // parents AND emits a LinkOrphanColumn diagnostic. A guess made here instead would be 951 // silent and unconditional, which is why the resolver must decline. 952 // 953 // So: exactly one provider is a resolution; several is an ambiguity this layer must not 954 // resolve, and the caller then leaves the reference unresolved, which is what every 955 // non-struct dialect already does for the same shape. 956 List<INamespace> providerNamespaces = new ArrayList<INamespace>(); 957 List<ColumnSource> providedBases = new ArrayList<ColumnSource>(); 958 for (ResolvedImpl.Match match : resolved.getMatches()) { 959 INamespace namespace = match.namespace; 960 // One namespace can appear under several matches (a relation reachable by name and by 961 // alias, say). Counting those as separate owners would refuse a perfectly decided 962 // single-relation query, so identity decides, not match count. 963 boolean already = false; 964 for (INamespace seen : providerNamespaces) { 965 if (seen == namespace) { 966 already = true; 967 break; 968 } 969 } 970 if (already) { 971 continue; 972 } 973 974 // Try to resolve the base column name in this namespace 975 ColumnSource baseColumnSource = namespace.resolveColumn(baseColumnName); 976 if (baseColumnSource == null) { 977 continue; 978 } 979 providerNamespaces.add(namespace); 980 providedBases.add(baseColumnSource); 981 } 982 983 ColumnSource providedBase = null; 984 if (providedBases.size() == 1) { 985 providedBase = providedBases.get(0); 986 } else if (providedBases.size() > 1) { 987 // Several relations could own the base. A JOIN USING merge makes that one column after 988 // all; otherwise the owner is genuinely undecided. 989 providedBase = mergedUsingSource(resolved, baseColumnName, providedBases); 990 if (providedBase == null) { 991 if (DEBUG_RESOLUTION) { 992 System.out.println("[DEBUG-RESOLVE] Struct-field: base column '" 993 + baseColumnName + "' could come from " + providedBases.size() 994 + " relations — declining to guess an owner"); 995 } 996 // AMBIGUOUS, not null: the owner being undecided is a FINDING, and a later stage 997 // that sees NOT_FOUND cannot tell it apart from a name nothing provides. 998 sortCandidatesByTablePosition(providedBases); 999 // Each candidate is the field read below its base, so the finding says what was read, not 1000 // only whose column it might be. 1001 List<ColumnSource> fieldReads = new ArrayList<ColumnSource>(providedBases.size()); 1002 for (ColumnSource base : providedBases) { 1003 fieldReads.add(base.withFieldPath(FieldPath.of(fieldPathSegments), "struct_field_access")); 1004 } 1005 return ResolutionResult.ambiguous( 1006 new AmbiguousColumnSource(baseColumnName, fieldReads)); 1007 } 1008 } 1009 1010 if (providedBase != null) { 1011 // Found the base column in exactly one relation - return it with the field path 1012 if (DEBUG_RESOLUTION) { 1013 System.out.println("[DEBUG-RESOLVE] Struct-field: found base column '" + 1014 baseColumnName + "', fieldPath=" + fieldPathSegments); 1015 } 1016 1017 // Create a new ColumnSource with: 1018 // 1. struct_field_access evidence marker (for backward compatibility) 1019 // 2. fieldPath preserved (new in Improvement B) 1020 FieldPath fieldPath = FieldPath.of(fieldPathSegments); 1021 ColumnSource structFieldSource = providedBase.withFieldPath(fieldPath, "struct_field_access"); 1022 1023 return ResolutionResult.exactMatch(structFieldSource); 1024 } 1025 1026 return null; 1027 } 1028 1029 /** 1030 * Extract name parts from TObjectName. 1031 * Examples: 1032 * - "col" -> ["col"] 1033 * - "t.col" -> ["t", "col"] 1034 * - "schema.table.col" -> ["schema", "table", "col"] 1035 * 1036 * For BigQuery/Snowflake struct field access like "customer.customer_id": 1037 * - If schema/table tokens are not set, but toString() contains dots, 1038 * extract all parts from toString() to capture field paths. 1039 */ 1040 private List<String> extractNameParts(TObjectName objName) { 1041 List<String> parts = new ArrayList<>(); 1042 1043 // Add schema if present, but NOT when databaseToken is also set. 1044 // When databaseToken is present, the name is fully qualified (db.schema.table.column) 1045 // and the schema position IS the schema, not a table alias. Including it would cause 1046 // the resolver to incorrectly match the schema against table aliases. (Mantis #4268) 1047 if (objName.getSchemaToken() != null && objName.getDatabaseToken() == null) { 1048 parts.add(objName.getSchemaString()); 1049 } 1050 1051 // Add table/qualifier if present 1052 if (objName.getTableToken() != null) { 1053 parts.add(objName.getTableString()); 1054 } 1055 1056 // Add column name 1057 String columnName = objName.getColumnNameOnly(); 1058 if (columnName != null) { 1059 parts.add(columnName); 1060 } 1061 1062 // Improvement B: Handle BigQuery/Snowflake struct field access 1063 // If we only got a single part from standard extraction, 1064 // but toString() contains more segments (dots), extract them 1065 // This handles cases like "customer.customer_id" where: 1066 // - getColumnNameOnly() returns "customer" 1067 // - toString() returns "customer.customer_id" 1068 if (isStructFieldVendor()) { 1069 String fullName = objName.toString(); 1070 if (fullName != null && fullName.contains(".")) { 1071 // Check if fullName has more segments than what we extracted 1072 String[] fullParts = splitNameParts(fullName); 1073 if (fullParts.length > parts.size() || hasConsecutiveDuplicates(parts)) { 1074 // Replace parts with full extracted segments 1075 parts.clear(); 1076 Collections.addAll(parts, fullParts); 1077 } 1078 } 1079 } 1080 1081 return parts; 1082 } 1083 1084 /** 1085 * Check if a list has consecutive duplicate elements. 1086 * This detects parser bugs where segments are duplicated. 1087 * Example: ["customer", "customer", "address"] returns true. 1088 */ 1089 private boolean hasConsecutiveDuplicates(List<String> list) { 1090 if (list == null || list.size() < 2) { 1091 return false; 1092 } 1093 for (int i = 1; i < list.size(); i++) { 1094 if (list.get(i) != null && list.get(i).equals(list.get(i - 1))) { 1095 return true; 1096 } 1097 } 1098 return false; 1099 } 1100 1101 1102 /** 1103 * Split a dotted name into parts, handling quoted identifiers. 1104 * Examples: 1105 * - "a.b.c" -> ["a", "b", "c"] 1106 * - "`a.b`.c" -> ["`a.b`", "c"] 1107 * - "a.`b.c`" -> ["a", "`b.c`"] 1108 * 1109 * @param name The dotted name string 1110 * @return Array of name parts 1111 */ 1112 private String[] splitNameParts(String name) { 1113 if (name == null || name.isEmpty()) { 1114 return new String[0]; 1115 } 1116 1117 // Simple case: no quotes, just split on dots 1118 if (!name.contains("`") && !name.contains("\"") && !name.contains("[")) { 1119 return name.split("\\."); 1120 } 1121 1122 // Complex case: handle quoted identifiers 1123 List<String> parts = new ArrayList<>(); 1124 StringBuilder current = new StringBuilder(); 1125 char quoteChar = 0; 1126 1127 for (int i = 0; i < name.length(); i++) { 1128 char c = name.charAt(i); 1129 1130 if (quoteChar != 0) { 1131 // Inside a quoted identifier 1132 current.append(c); 1133 if (c == quoteChar) { 1134 // Check for escaped quote (doubled) 1135 if (i + 1 < name.length() && name.charAt(i + 1) == quoteChar) { 1136 current.append(name.charAt(++i)); 1137 } else { 1138 // End of quoted identifier 1139 quoteChar = 0; 1140 } 1141 } 1142 } else if (c == '`' || c == '"' || c == '[') { 1143 // Start of quoted identifier 1144 quoteChar = (c == '[') ? ']' : c; 1145 current.append(c); 1146 } else if (c == '.') { 1147 // Separator 1148 if (current.length() > 0) { 1149 parts.add(current.toString()); 1150 current.setLength(0); 1151 } 1152 } else { 1153 current.append(c); 1154 } 1155 } 1156 1157 // Add last part 1158 if (current.length() > 0) { 1159 parts.add(current.toString()); 1160 } 1161 1162 return parts.toArray(new String[0]); 1163 } 1164 1165 /** 1166 * Process resolution matches and determine final result. 1167 */ 1168 /** 1169 * A wildcard over a CTE expands to the CTE's projection, so it binds to the CTE itself. 1170 * A CTE whose projection has no star holds no column that could stand for the wildcard 1171 * (the CTE used to invent one from its base table). Whether the reference IS the wildcard 1172 * comes from the parse, the asterisk part token, never from its text: {@code x."*"} is a 1173 * column. 1174 */ 1175 private static ColumnSource wildcardOverCte(TObjectName objName, INamespace namespace) { 1176 if (!(namespace instanceof CTENamespace) || objName == null || objName.getPartToken() == null 1177 || objName.getPartToken().tokentype != ETokenType.ttasterisk) { 1178 return null; 1179 } 1180 return new ColumnSource(namespace, objName.getColumnNameOnly(), null, 1.0, "cte_star_expansion"); 1181 } 1182 1183 /** An unqualified wildcard whose FROM clause is a single CTE: see {@link #wildcardOverCte}. */ 1184 private static ColumnSource wildcardOverSoleCte(TObjectName objName, List<String> nameParts, IScope scope) { 1185 if (nameParts.size() != 1 || !(scope instanceof SelectScope)) { 1186 return null; 1187 } 1188 FromScope from = ((SelectScope) scope).getFromScope(); 1189 if (from == null || from.getChildren().size() != 1) { 1190 return null; 1191 } 1192 return wildcardOverCte(objName, from.getChildren().get(0).getNamespace()); 1193 } 1194 1195 private ResolutionResult processResolvedMatches(TObjectName objName, 1196 List<String> nameParts, 1197 ResolvedImpl resolved, 1198 IScope scope) { 1199 String columnName = nameParts.get(nameParts.size() - 1); 1200 1201 if (resolved.isEmpty()) { 1202 ColumnSource star = wildcardOverSoleCte(objName, nameParts, scope); 1203 if (star != null) { 1204 return ResolutionResult.exactMatch(star); 1205 } 1206 // No matches found 1207 return ResolutionResult.notFound(columnName); 1208 } 1209 1210 // Deduplicate matches by namespace identity 1211 // The same namespace can be found through different scope paths (e.g., CTE scope and SELECT scope) 1212 // but it's still the same source - not truly ambiguous 1213 List<ResolvedImpl.Match> uniqueMatches = deduplicateMatchesByNamespace(resolved.getMatches()); 1214 1215 if (uniqueMatches.size() == 1) { 1216 // Exactly one unique namespace - success! 1217 ResolvedImpl.Match match = uniqueMatches.get(0); 1218 INamespace namespace = match.namespace; 1219 1220 // For qualified names like "t.col", we need to resolve the column 1221 // For unqualified names like "col", we need to find it in the namespace 1222 ColumnSource columnSource; 1223 1224 if (!match.remainingNames.isEmpty()) { 1225 // Still have parts to resolve (e.g., found table, need to find column) 1226 // Phase B3: Support multi-segment paths (table.column.field...) 1227 if (match.remainingNames.size() > 1 && readsColumnPaths()) { 1228 // Multiple segments: use resolveColumnPath for deep field access 1229 // e.g., remainingNames = ["customer", "address", "city"] 1230 // -> base column "customer", fieldPath ["address", "city"] 1231 columnSource = namespace.resolveColumnPath(match.remainingNames); 1232 1233 if (columnSource == null) { 1234 // Base column not found 1235 String baseColName = match.remainingNames.get(0); 1236 return ResolutionResult.notFound(baseColName, 1237 "Column '" + baseColName + "' not found in " + namespace.getDisplayName()); 1238 } 1239 1240 if (DEBUG_RESOLUTION) { 1241 System.out.println("[DEBUG-RESOLVE] Deep path resolved: " + 1242 match.remainingNames + " -> base=" + columnSource.getExposedName() + 1243 ", fieldPath=" + (columnSource.hasFieldPath() ? columnSource.getFieldPath() : "none")); 1244 } 1245 } else { 1246 // Single segment: regular column resolution 1247 String remainingColName = match.remainingNames.get(match.remainingNames.size() - 1); 1248 columnSource = namespace.resolveColumn(remainingColName); 1249 if (columnSource == null) { 1250 columnSource = wildcardOverCte(objName, namespace); 1251 } 1252 1253 if (columnSource == null) { 1254 // Column not found in the namespace 1255 return ResolutionResult.notFound(remainingColName, 1256 "Column '" + remainingColName + "' not found in " + namespace.getDisplayName()); 1257 } 1258 } 1259 } else { 1260 // Name resolved to a table/namespace directly with no remaining parts 1261 // This happens when the column name matches the table alias exactly. 1262 // 1263 // For UNNEST tables (e.g., "UNNEST(arr) AS x"), the alias "x" is ALSO 1264 // the implicit column name. When user writes "x" as a column reference, 1265 // the scope resolution matches the table alias "x", leaving remainingNames empty. 1266 // We should still try to resolve "x" as a column in the namespace. 1267 // 1268 // Example: SELECT x FROM UNNEST([1,2,3]) AS x 1269 // Here "x" in SELECT refers to the implicit column, not the table. 1270 // A call whose layout names its column after the alias (PostgreSQL: FROM unnest(a) AS u, FROM 1271 // generate_series(1, 9) g, or the function's own name without an alias) is the same case 1272 // (TableFunctionLayout, plan 09 slice 11). 1273 if (namespace instanceof UnnestNamespace || (namespace instanceof TableNamespace 1274 && ((TableNamespace) namespace).getTable() != null 1275 && TableFunctionLayout.of(((TableNamespace) namespace).getTable()) != null 1276 && TableFunctionLayout.of(((TableNamespace) namespace).getTable()).getNames() != null)) { 1277 // For UNNEST, try to resolve the matched name as a column 1278 columnSource = namespace.resolveColumn(columnName); 1279 if (columnSource != null) { 1280 if (DEBUG_RESOLUTION) { 1281 System.out.println("[DEBUG-RESOLVE] UNNEST implicit column resolved: " + 1282 columnName + " in " + namespace.getDisplayName()); 1283 } 1284 if (namespace instanceof TableNamespace) { 1285 // the alias matched the call first, so the other FROM items were never asked: a 1286 // declared column of the same name competes (PostgreSQL: CREATE TABLE t(g INT); 1287 // SELECT g FROM t, generate_series(1, 9) g is ambiguous, as with AS s(g)) 1288 List<ColumnSource> competing = declaredColumnsOfOtherItems(scope, namespace, columnName); 1289 if (!competing.isEmpty()) { 1290 competing.add(columnSource); 1291 sortCandidatesByTablePosition(competing); 1292 return ResolutionResult.ambiguous(new AmbiguousColumnSource(columnName, competing)); 1293 } 1294 } 1295 return ResolutionResult.exactMatch(columnSource); 1296 } 1297 } 1298 1299 // For other namespaces or if column not found, this is an error 1300 return ResolutionResult.notFound(columnName, 1301 "Name '" + columnName + "' resolves to a table, not a column"); 1302 } 1303 1304 if (isInferredCandidate(columnSource)) { 1305 List<UnnestNamespace> valueTables = unknownValueTablesIn(scope, objName); 1306 valueTables.remove(namespace); 1307 if (!valueTables.isEmpty()) { 1308 // SELECT item_name FROM orders_raw, UNNEST(items) without DDL: item_name may be a column of 1309 // orders_raw or a field of the unnested element, and nothing proves which; the read stays 1310 // ambiguous instead of binding to the table (Mantis 4828, STRUCT tree plan rule R3). The value 1311 // table answers hasColumn() with NOT_EXISTS, so it is not among the matches. 1312 List<ColumnSource> all = new ArrayList<ColumnSource>(); 1313 all.add(columnSource); 1314 addValueTableCandidates(all, valueTables, columnName); 1315 sortCandidatesByTablePosition(all); 1316 return ResolutionResult.ambiguous(new AmbiguousColumnSource(columnName, all)); 1317 } 1318 } 1319 return ResolutionResult.exactMatch(columnSource); 1320 } 1321 1322 // Multiple unique namespaces - truly ambiguous 1323 List<ColumnSource> candidates = new ArrayList<>(); 1324 // Mantis 4550: set when we synthesize a placeholder candidate for an 1325 // ambiguous-star subquery (see below). Such a candidate means the column 1326 // is genuinely ambiguous across derived tables and must NOT be collapsed 1327 // by GUESS_COLUMN_STRATEGY into a single guessed match. 1328 boolean synthesizedAmbStar = false; 1329 // the unaliased UNNEST value tables whose element type is unknown (Mantis 4828) 1330 List<UnnestNamespace> unknownValueTables = new ArrayList<UnnestNamespace>(); 1331 1332 for (ResolvedImpl.Match match : uniqueMatches) { 1333 INamespace namespace = match.namespace; 1334 1335 // Resolve column in this namespace 1336 // Phase B3: Support multi-segment paths 1337 ColumnSource columnSource; 1338 if (!match.remainingNames.isEmpty()) { 1339 if (match.remainingNames.size() > 1 && readsColumnPaths()) { 1340 // Multi-segment: use resolveColumnPath 1341 columnSource = namespace.resolveColumnPath(match.remainingNames); 1342 } else { 1343 // Single segment: use resolveColumn 1344 String remainingColName = match.remainingNames.get(match.remainingNames.size() - 1); 1345 columnSource = namespace.resolveColumn(remainingColName); 1346 } 1347 } else { 1348 columnSource = namespace.resolveColumn(columnName); 1349 } 1350 1351 // Mantis 4550: an ambiguous-star subquery (SELECT * over a multi-table 1352 // FROM) cannot pin an unqualified column to a single base table and, 1353 // under the default NOT_PICKUP strategy, resolveColumn() returns null. 1354 // SelectScope now still reports it as an outer-scope candidate. Anchor a 1355 // placeholder ColumnSource to the subquery so it counts as a distinct 1356 // candidate (getSourceNamespace().getSourceTable() -> the derived table). 1357 // finalTable is left null; candidateTables reconciliation expands the 1358 // derived table into its base tables. The placeholder is NOT cached as an 1359 // inferred column on the namespace, so resolveColumn() itself is unchanged. 1360 // Only single-segment column references qualify (no deep struct path); 1361 // qualified single-table refs never reach this multi-namespace branch 1362 // (they resolve via the uniqueMatches.size()==1 path above), so this 1363 // cannot make "T0_2.col" falsely resolve. 1364 boolean singleSegment = match.remainingNames.isEmpty() 1365 || match.remainingNames.size() == 1; 1366 if (columnSource == null && singleSegment && namespace instanceof UnnestNamespace 1367 && ((UnnestNamespace) namespace).mayHoldUnknownFields() 1368 && !insideUnnestArgument(objName, (UnnestNamespace) namespace)) { 1369 unknownValueTables.add((UnnestNamespace) namespace); 1370 } 1371 if (columnSource == null 1372 && singleSegment 1373 && namespace instanceof gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace 1374 && ((gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) namespace).hasAmbiguousStar()) { 1375 String placeholderName = match.remainingNames.isEmpty() 1376 ? columnName 1377 : match.remainingNames.get(match.remainingNames.size() - 1); 1378 columnSource = new ColumnSource(namespace, placeholderName, null, 0.3, 1379 "ambiguous_star_outer_candidate"); 1380 synthesizedAmbStar = true; 1381 } 1382 1383 if (columnSource != null) { 1384 candidates.add(columnSource); 1385 } 1386 } 1387 1388 if (candidates.isEmpty()) { 1389 // Resolved to tables, but none have the column 1390 return ResolutionResult.notFound(columnName, 1391 "Column '" + columnName + "' not found in any of " + resolved.getCount() + " tables"); 1392 } 1393 1394 if (candidates.size() == 1 && isInferredCandidate(candidates.get(0))) { 1395 // a value table answers hasColumn() with NOT_EXISTS, so it is not among the matches: look at the FROM 1396 // scope for every one of them 1397 for (UnnestNamespace valueTable : unknownValueTablesIn(scope, objName)) { 1398 if (!unknownValueTables.contains(valueTable)) { 1399 unknownValueTables.add(valueTable); 1400 } 1401 } 1402 } 1403 if (candidates.size() == 1 && !unknownValueTables.isEmpty() && isInferredCandidate(candidates.get(0))) { 1404 // SELECT item_name FROM orders_raw, UNNEST(items) without DDL: item_name may be a column of orders_raw 1405 // or a field of the unnested element, and nothing proves which; the read stays ambiguous instead of 1406 // binding to the table (Mantis 4828, STRUCT tree plan rule R3). With DDL the table's authority decides, 1407 // and the unnest namespace's declared fields answer first. 1408 addValueTableCandidates(candidates, unknownValueTables, columnName); 1409 sortCandidatesByTablePosition(candidates); 1410 return ResolutionResult.ambiguous(new AmbiguousColumnSource(columnName, candidates)); 1411 } 1412 1413 if (candidates.size() == 1) { 1414 // Only one table actually has the column 1415 return ResolutionResult.exactMatch(candidates.get(0)); 1416 } 1417 1418 // Mantis 4550: when a placeholder ambiguous-star candidate was synthesized, 1419 // the column is genuinely ambiguous across the derived tables. Force an 1420 // AMBIGUOUS result regardless of GUESS_COLUMN_STRATEGY (nearest/farthest), 1421 // which would otherwise collapse a definite sibling + maybe-star sibling 1422 // back into a single EXACT match. 1423 if (synthesizedAmbStar) { 1424 sortCandidatesByTablePosition(candidates); 1425 AmbiguousColumnSource ambiguousResult = new AmbiguousColumnSource(columnName, candidates); 1426 return ResolutionResult.ambiguous(ambiguousResult); 1427 } 1428 1429 // Sort candidates by their table's position in the SQL text (FROM clause order) 1430 // This ensures consistent ordering for both ambiguous results and GUESS_COLUMN_STRATEGY 1431 sortCandidatesByTablePosition(candidates); 1432 1433 // A JOIN USING column is ONE column, not one per side. 1434 ColumnSource merged = mergedUsingSource(resolved, columnName, candidates); 1435 if (merged != null) { 1436 return ResolutionResult.exactMatch(merged); 1437 } 1438 1439 // Check if all candidates are "inferred" (from tables without DDL metadata) 1440 // If so, return AMBIGUOUS regardless of GUESS_COLUMN_STRATEGY 1441 // Only apply GUESS_COLUMN_STRATEGY when we have definite knowledge about the columns 1442 // 1443 // The determination uses configurable thresholds: 1444 // - minDefiniteConfidence: minimum confidence to be considered "definite" (default 0.9) 1445 // - allowGuessWhenAllInferred: if true, allow guessing even when all candidates are inferred 1446 boolean hasDefiniteCandidate = false; 1447 double highestConfidence = 0.0; 1448 double minDefiniteConf = config != null ? config.getMinDefiniteConfidence() : 0.9; 1449 1450 for (ColumnSource candidate : candidates) { 1451 // A candidate is "definite" if it has high confidence and is not just inferred from usage 1452 String evidence = candidate.getEvidence(); 1453 double confidence = candidate.getConfidence(); 1454 1455 // Track highest confidence among candidates 1456 if (confidence > highestConfidence) { 1457 highestConfidence = confidence; 1458 } 1459 1460 // Use structured evidence if available, otherwise check legacy evidence 1461 boolean isInferred; 1462 if (candidate.getEvidenceDetail() != null) { 1463 // Use the structured evidence's own determination 1464 isInferred = !candidate.getEvidenceDetail().isHighConfidence() || 1465 candidate.getEvidenceDetail().isInferred(); 1466 } else { 1467 // Fallback to legacy logic 1468 // "inferred_from_usage" means table without DDL metadata - not definite 1469 // Low confidence (< minDefiniteConf) means we're guessing - not definite 1470 // Null evidence with high confidence is also considered inferred 1471 isInferred = (evidence == null || evidence.equals("inferred_from_usage")) || 1472 confidence < minDefiniteConf; 1473 } 1474 1475 if (!isInferred) { 1476 hasDefiniteCandidate = true; 1477 break; 1478 } 1479 } 1480 1481 // Check if we should allow guessing when all candidates are inferred 1482 boolean allowGuessInferred = config != null && config.isAllowGuessWhenAllInferred(); 1483 1484 if (!hasDefiniteCandidate && !allowGuessInferred) { 1485 // All candidates are from tables without DDL metadata (all inferred) 1486 // Don't guess - return as ambiguous so formatter can handle appropriately 1487 if (DEBUG_RESOLUTION) { 1488 System.out.println("[DEBUG-RESOLVE] AMBIGUOUS (all inferred): " + columnName + " with " + candidates.size() + " candidates"); 1489 for (ColumnSource c : candidates) { 1490 System.out.println("[DEBUG-RESOLVE] - " + (c.getSourceNamespace() != null ? c.getSourceNamespace().getDisplayName() : "null") + 1491 " evidence=" + c.getEvidence() + " confidence=" + c.getConfidence()); 1492 } 1493 } 1494 AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates); 1495 return ResolutionResult.ambiguous(ambiguous); 1496 } 1497 1498 // Additional check: even if we have definite candidates or allow inferred guessing, 1499 // require at least one candidate to meet the minConfidenceToGuess threshold 1500 double minConfToGuess = config != null ? config.getMinConfidenceToGuess() : 0.95; 1501 if (highestConfidence < minConfToGuess && !allowGuessInferred) { 1502 // No candidate has sufficient confidence for guessing 1503 if (DEBUG_RESOLUTION) { 1504 System.out.println("[DEBUG-RESOLVE] AMBIGUOUS (confidence too low): " + columnName + 1505 " highest=" + highestConfidence + " required=" + minConfToGuess); 1506 } 1507 AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates); 1508 return ResolutionResult.ambiguous(ambiguous); 1509 } 1510 1511 // Multiple tables have the column - apply GUESS_COLUMN_STRATEGY 1512 // Candidates are already sorted by table position (done earlier) 1513 int strategy = getGuessColumnStrategy(); 1514 1515 if (strategy == TSQLResolverConfig.GUESS_COLUMN_STRATEGY_NEAREST) { 1516 // Pick the first candidate (nearest table in FROM clause order) 1517 return ResolutionResult.exactMatch(candidates.get(0)); 1518 } else if (strategy == TSQLResolverConfig.GUESS_COLUMN_STRATEGY_FARTHEST) { 1519 // Pick the last candidate (farthest table in FROM clause order) 1520 return ResolutionResult.exactMatch(candidates.get(candidates.size() - 1)); 1521 } 1522 1523 // GUESS_COLUMN_STRATEGY_NOT_PICKUP: leave as ambiguous 1524 if (DEBUG_RESOLUTION) { 1525 System.out.println("[DEBUG-RESOLVE] AMBIGUOUS (NOT_PICKUP strategy): " + columnName + " with " + candidates.size() + " candidates"); 1526 } 1527 AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates); 1528 return ResolutionResult.ambiguous(ambiguous); 1529 } 1530 1531 /** 1532 * Deduplicate matches by namespace identity. 1533 * The same namespace can be found through different scope paths (e.g., CTE scope and SELECT scope) 1534 * but it's still the same source - not truly ambiguous. 1535 * 1536 * @param matches All matches from scope resolution 1537 * @return List of unique matches by namespace identity 1538 */ 1539 private List<ResolvedImpl.Match> deduplicateMatchesByNamespace(List<ResolvedImpl.Match> matches) { 1540 if (matches == null || matches.size() <= 1) { 1541 return matches; 1542 } 1543 1544 // Use identity-based set to track unique namespaces 1545 java.util.IdentityHashMap<INamespace, ResolvedImpl.Match> uniqueByNamespace = 1546 new java.util.IdentityHashMap<>(); 1547 1548 for (ResolvedImpl.Match match : matches) { 1549 if (match.namespace != null && !uniqueByNamespace.containsKey(match.namespace)) { 1550 uniqueByNamespace.put(match.namespace, match); 1551 } 1552 } 1553 1554 return new ArrayList<>(uniqueByNamespace.values()); 1555 } 1556 1557 /** 1558 * Sort candidates by their source table's position in the SQL text. 1559 * This ensures that when GUESS_COLUMN_STRATEGY_NEAREST or FARTHEST is applied, 1560 * the candidates are ordered according to their actual position in the FROM clause. 1561 * 1562 * Uses the table's start token (lineNo, columnNo) to determine position. 1563 * 1564 * @param candidates List of ColumnSource candidates to sort in place 1565 */ 1566 private void sortCandidatesByTablePosition(List<ColumnSource> candidates) { 1567 if (candidates == null || candidates.size() <= 1) { 1568 return; 1569 } 1570 1571 candidates.sort(new Comparator<ColumnSource>() { 1572 @Override 1573 public int compare(ColumnSource c1, ColumnSource c2) { 1574 TTable t1 = c1.getFinalTable(); 1575 TTable t2 = c2.getFinalTable(); 1576 1577 // If either table is null, maintain relative order 1578 if (t1 == null && t2 == null) return 0; 1579 if (t1 == null) return 1; // null tables go to the end 1580 if (t2 == null) return -1; 1581 1582 TSourceToken token1 = t1.getStartToken(); 1583 TSourceToken token2 = t2.getStartToken(); 1584 1585 // If either token is null, maintain relative order 1586 if (token1 == null && token2 == null) return 0; 1587 if (token1 == null) return 1; 1588 if (token2 == null) return -1; 1589 1590 // Compare by line number first 1591 int lineCmp = Long.compare(token1.lineNo, token2.lineNo); 1592 if (lineCmp != 0) { 1593 return lineCmp; 1594 } 1595 1596 // If same line, compare by column number 1597 return Long.compare(token1.columnNo, token2.columnNo); 1598 } 1599 }); 1600 } 1601 1602 /** 1603 * Update TObjectName with the resolution result. 1604 * Also registers with ResolutionContext. 1605 * 1606 * For ambiguous columns (multiple candidate tables), this also populates 1607 * TObjectName.candidateTables with all possible source tables. 1608 * 1609 * IMPORTANT: When resolution fails (notFound) and the column's sourceTable 1610 * was set during Phase 1 to an UNNEST table, we clear the sourceTable. 1611 * This is because UNNEST tables have a fixed set of columns (implicit column, 1612 * offset, struct fields) and should NOT have arbitrary columns inferred. 1613 * Clearing sourceTable allows the formatter to treat these as "missed" columns. 1614 */ 1615 private void updateObjectNameWithResult(TObjectName objName, ResolutionResult result) { 1616 // Update TObjectName with resolution result 1617 objName.setResolution(result); 1618 1619 // For notFound results, check if Phase 1 incorrectly linked to UNNEST table 1620 // UNNEST tables have a fixed column set - don't allow inferred columns 1621 if (!result.isExactMatch() && !result.isAmbiguous()) { 1622 TTable currentSourceTable = objName.getSourceTable(); 1623 if (currentSourceTable != null && 1624 currentSourceTable.getTableType() == ETableSource.unnest) { 1625 // Clear the incorrectly set sourceTable from Phase 1 1626 // This column wasn't found in the UNNEST namespace, so it shouldn't 1627 // be attributed to the UNNEST table 1628 objName.setSourceTable(null); 1629 if (DEBUG_RESOLUTION) { 1630 System.out.println("[DEBUG-RESOLVE] Cleared incorrect UNNEST sourceTable for: " + 1631 objName + " (not found in UNNEST namespace)"); 1632 } 1633 } 1634 } 1635 1636 // For ambiguous results, populate candidateTables with all candidate tables 1637 // IMPORTANT: Clear existing candidateTables first, as Phase 1 (linkColumnToTable) 1638 // may have added candidates from incorrect scopes (e.g., MERGE target table for 1639 // columns inside USING subquery). Phase 2 (NameResolver) has proper scope awareness 1640 // and produces the authoritative candidate list. 1641 if (result.isAmbiguous() && result.getAmbiguousSource() != null) { 1642 AmbiguousColumnSource ambiguous = result.getAmbiguousSource(); 1643 // Clear Phase 1 candidates before adding Phase 2's scope-aware candidates 1644 objName.getCandidateTables().clear(); 1645 for (ColumnSource candidate : ambiguous.getCandidates()) { 1646 gudusoft.gsqlparser.nodes.TTable candidateTable = candidate.getFinalTable(); 1647 if (candidateTable != null) { 1648 objName.getCandidateTables().addTable(candidateTable); 1649 continue; 1650 } 1651 // Mantis 4550: an ambiguous-star subquery placeholder has no single 1652 // finalTable. Add its immediate derived table (the subquery) so the 1653 // reconcile pass below expands it into the physical base tables that 1654 // can actually supply the column (e.g. T0_2 -> A.A2, A.A3). 1655 gudusoft.gsqlparser.nodes.TTable derivedTable = 1656 candidate.getSourceNamespace() != null 1657 ? candidate.getSourceNamespace().getSourceTable() : null; 1658 if (derivedTable != null) { 1659 objName.getCandidateTables().addTable(derivedTable); 1660 continue; 1661 } 1662 // A candidate that cannot collapse to a single physical table 1663 // (e.g. a UNION-backed derived table) would otherwise be dropped 1664 // here. Trace its set-operation branches projection-aware, by the 1665 // requested column, so only branches that actually supply the 1666 // column contribute base tables (a constant branch like 1667 // "SELECT 1 AS a" adds nothing). 1668 addBranchBaseTablesForColumn(objName, candidate); 1669 } 1670 } 1671 1672 // Mantis 4550: reconcile candidateTables so they point to the physical 1673 // base tables that could actually supply the column, rather than the 1674 // intermediate derived tables. Phase 1 (linkColumnToTable) may leave 1675 // stale candidates that include derived tables which cannot supply the 1676 // column (e.g. a subquery with an explicit column list that omits it), 1677 // and the EXACT/guessed-match path above does not clear them. See 1678 // reconcileCandidateTablesToBaseTables() for the full rationale. 1679 reconcileCandidateTablesToBaseTables(objName); 1680 1681 // Register with context for global querying 1682 context.registerResolution(objName, result); 1683 } 1684 1685 /** 1686 * Mantis 4550: rewrite {@code candidateTables} so each entry is a physical 1687 * base table that could supply the column, expanding any intermediate 1688 * derived table (subquery / CTE) into the underlying tables it exposes. 1689 * 1690 * <p>Background: for an unqualified column whose source is ambiguous across 1691 * several derived tables in the FROM clause, Phase 1 ({@code linkColumnToTable}) 1692 * may populate {@code candidateTables} with the derived tables themselves. 1693 * When Phase 2 resolves the column to a guessed EXACT match (rather than a 1694 * truly AMBIGUOUS result) the scope-aware ambiguous branch that normally 1695 * clears and rebuilds {@code candidateTables} is skipped, leaving those 1696 * stale derived-table candidates in place. Some of them cannot even supply 1697 * the column (e.g. a {@code SELECT x, ds FROM ...} subquery for column 1698 * {@code S311}).</p> 1699 * 1700 * <p>This pass walks each candidate: physical tables are kept as-is, while 1701 * a derived table is replaced by the set of physical base tables reachable 1702 * through its projection that could supply the column. A derived table whose 1703 * closed projection does not expose the column contributes nothing. Results 1704 * are de-duplicated by qualified table name, preserving first-seen order, so 1705 * the same physical table reached via two derived paths (or two aliases of a 1706 * self-join) collapses to a single candidate.</p> 1707 */ 1708 private void reconcileCandidateTablesToBaseTables(TObjectName objName) { 1709 TTableList candidates = objName.getCandidateTables(); 1710 if (candidates == null || candidates.size() == 0) { 1711 return; 1712 } 1713 1714 String columnName = objName.getColumnNameOnly(); 1715 if (columnName == null || columnName.isEmpty()) { 1716 return; 1717 } 1718 1719 List<TTable> expanded = new ArrayList<TTable>(); 1720 Set<String> seenKeys = new LinkedHashSet<String>(); 1721 IdentityHashMap<TSelectSqlStatement, Boolean> visiting = 1722 new IdentityHashMap<TSelectSqlStatement, Boolean>(); 1723 for (int i = 0; i < candidates.size(); i++) { 1724 collectBaseTablesSupplyingColumn(candidates.getTable(i), columnName, 1725 expanded, seenKeys, visiting); 1726 } 1727 1728 // Only replace when expansion produced concrete base tables; otherwise 1729 // keep the existing list rather than wiping resolution information. 1730 if (expanded.isEmpty()) { 1731 return; 1732 } 1733 1734 candidates.clear(); 1735 for (TTable t : expanded) { 1736 candidates.addTable(t); 1737 } 1738 } 1739 1740 /** 1741 * Add, to {@code objName}'s candidate tables, the base tables that supply the 1742 * column for an ambiguous candidate whose {@link ColumnSource#getFinalTable()} 1743 * is {@code null} (typically a set-operation/UNION-backed namespace). The 1744 * namespace's SELECT statement is traced projection-aware by the requested 1745 * column, so a branch whose projection at that position is a constant or 1746 * expression contributes no base table. Falls back to every reachable final 1747 * table only when projection-aware tracing yields nothing. 1748 */ 1749 private void addBranchBaseTablesForColumn(TObjectName objName, ColumnSource candidate) { 1750 INamespace ns = candidate.getSourceNamespace(); 1751 if (ns == null) { 1752 return; 1753 } 1754 1755 List<TTable> base = new ArrayList<TTable>(); 1756 Set<String> seenKeys = new LinkedHashSet<String>(); 1757 IdentityHashMap<TSelectSqlStatement, Boolean> visiting = 1758 new IdentityHashMap<TSelectSqlStatement, Boolean>(); 1759 1760 String columnName = objName.getColumnNameOnly(); 1761 TSelectSqlStatement nsSelect = ns.getSelectStatement(); 1762 boolean traced = false; 1763 if (nsSelect != null && columnName != null && !columnName.isEmpty()) { 1764 collectFromSelectBody(nsSelect, columnName, -1, base, seenKeys, visiting); 1765 traced = true; 1766 } 1767 1768 if (!traced) { 1769 // We could not trace the namespace projection-aware (no SELECT body to 1770 // inspect): report every reachable final table rather than dropping 1771 // the candidate entirely. When tracing DID run, an empty result means 1772 // "no physical table supplies this column" (e.g. all branches project 1773 // a constant), so we must not fall back and re-add those tables. 1774 List<TTable> all = ns.getAllFinalTables(); 1775 if (all != null) { 1776 for (TTable t : all) { 1777 if (t != null) { 1778 base.add(t); 1779 } 1780 } 1781 } 1782 } 1783 1784 for (TTable t : base) { 1785 objName.getCandidateTables().addTable(t); 1786 } 1787 } 1788 1789 /** 1790 * Collect, into {@code out}, the physical base tables reachable from 1791 * {@code table} that could supply {@code columnName}. Derived tables 1792 * (subqueries / CTEs) are expanded through their projection; physical 1793 * tables are added directly. De-duplicated by qualified table name via 1794 * {@code seenKeys}. {@code visiting} guards against cyclic CTE/subquery 1795 * references. 1796 */ 1797 private void collectBaseTablesSupplyingColumn(TTable table, String columnName, 1798 List<TTable> out, Set<String> seenKeys, 1799 IdentityHashMap<TSelectSqlStatement, Boolean> visiting) { 1800 if (table == null) { 1801 return; 1802 } 1803 1804 TSelectSqlStatement body = getDerivedBody(table); 1805 if (body == null) { 1806 // Physical base table (or a reference we cannot trace further). 1807 addBaseTable(table, out, seenKeys); 1808 return; 1809 } 1810 1811 if (visiting.containsKey(body)) { 1812 return; 1813 } 1814 visiting.put(body, Boolean.TRUE); 1815 try { 1816 // A CTE with an explicit column list (e.g. WITH cte(a) AS (SELECT x 1817 // FROM t)) renames its outputs, so the requested name must be mapped 1818 // positionally onto the body's projection rather than matched by the 1819 // body's own display names. 1820 int forcedIndex = -1; 1821 gudusoft.gsqlparser.nodes.TObjectNameList cteColumns = 1822 (table.isCTEName() && table.getCTE() != null) ? table.getCTE().getColumnList() : null; 1823 if (cteColumns != null && cteColumns.size() > 0) { 1824 forcedIndex = indexOfMatchingName(cteColumns, columnName); 1825 if (forcedIndex < 0) { 1826 // The CTE's column list does not expose this name. 1827 return; 1828 } 1829 } 1830 1831 collectFromSelectBody(body, columnName, forcedIndex, out, seenKeys, visiting); 1832 } finally { 1833 visiting.remove(body); 1834 } 1835 } 1836 1837 /** 1838 * Collect base tables from a derived table's SELECT body. Handles set 1839 * operations (UNION / EXCEPT / INTERSECT) by flattening to leaf SELECTs and 1840 * mapping the column onto each leaf positionally, and plain SELECTs via 1841 * {@link #collectFromSimpleSelect}. 1842 * 1843 * @param forcedIndex when {@code >= 0}, the column is identified by position 1844 * (e.g. a CTE explicit column list); when {@code -1}, by output name. 1845 */ 1846 private void collectFromSelectBody(TSelectSqlStatement body, String columnName, 1847 int forcedIndex, List<TTable> out, Set<String> seenKeys, 1848 IdentityHashMap<TSelectSqlStatement, Boolean> visiting) { 1849 if (body == null) { 1850 return; 1851 } 1852 1853 if (body.isCombinedQuery()) { 1854 List<TSelectSqlStatement> leaves = new ArrayList<TSelectSqlStatement>(); 1855 flattenSetOperationLeaves(body, leaves); 1856 if (leaves.isEmpty()) { 1857 return; 1858 } 1859 // All set operands share one output shape by position. Determine the 1860 // position from the first leaf (or the forced/CTE index) and trace 1861 // that ordinal in every operand. 1862 int idx = forcedIndex; 1863 if (idx < 0) { 1864 idx = indexOfNamedProjection(leaves.get(0).getResultColumnList(), columnName); 1865 } 1866 if (idx < 0) { 1867 // The output ordinal is unknown (e.g. the leftmost operand is 1868 // SELECT *), so the column cannot be mapped onto each operand by 1869 // position. Matching later operands by name would silently drop a 1870 // renamed branch, so over-report every FROM table of every operand 1871 // instead. 1872 for (TSelectSqlStatement leaf : leaves) { 1873 expandAllFromTables(leaf, columnName, out, seenKeys, visiting); 1874 } 1875 return; 1876 } 1877 for (TSelectSqlStatement leaf : leaves) { 1878 collectFromSimpleSelect(leaf, columnName, idx, out, seenKeys, visiting); 1879 } 1880 return; 1881 } 1882 1883 collectFromSimpleSelect(body, columnName, forcedIndex, out, seenKeys, visiting); 1884 } 1885 1886 /** 1887 * Collect base tables from a single (non-combined) SELECT. 1888 * 1889 * <p>When {@code forcedIndex >= 0} the column is resolved by position onto 1890 * the projection (used for CTE column lists and set-operation operands); 1891 * otherwise it is matched by output name. A {@code SELECT *} or an 1892 * out-of-range/star position falls back to expanding every FROM table, since 1893 * any of them may supply the column.</p> 1894 */ 1895 private void collectFromSimpleSelect(TSelectSqlStatement select, String columnName, 1896 int forcedIndex, List<TTable> out, Set<String> seenKeys, 1897 IdentityHashMap<TSelectSqlStatement, Boolean> visiting) { 1898 if (select == null) { 1899 return; 1900 } 1901 TResultColumnList rcl = select.getResultColumnList(); 1902 if (rcl == null) { 1903 return; 1904 } 1905 1906 if (forcedIndex >= 0) { 1907 boolean hasStar = false; 1908 for (int i = 0; i < rcl.size(); i++) { 1909 if (starQualifier(rcl.getResultColumn(i)) != null) { 1910 hasStar = true; 1911 break; 1912 } 1913 } 1914 if (hasStar || forcedIndex >= rcl.size()) { 1915 // Positional mapping is unreliable: any FROM table may supply it. 1916 expandAllFromTables(select, columnName, out, seenKeys, visiting); 1917 return; 1918 } 1919 TResultColumn rc = rcl.getResultColumn(forcedIndex); 1920 TObjectName underlying = rc != null ? rc.getColumnFullname() : null; 1921 if (underlying != null) { 1922 traceNamedColumnIntoFrom(select, underlying, out, seenKeys, visiting); 1923 } 1924 // A computed projection at this position has no physical base table. 1925 return; 1926 } 1927 1928 // Match by output name. Collect base tables from EVERY projection that 1929 // could expose the column: each matching named projection (there may be 1930 // several with the same output name, e.g. "SELECT t1.c, t2.c") AND every 1931 // star projection. We do not early-return on the first match, so 1932 // genuinely ambiguous derived tables surface all of their suppliers. 1933 boolean unqualifiedStar = false; 1934 List<String> qualifiedStarPrefixes = new ArrayList<String>(); 1935 for (int i = 0; i < rcl.size(); i++) { 1936 TResultColumn rc = rcl.getResultColumn(i); 1937 if (rc == null) { 1938 continue; 1939 } 1940 String starPrefix = starQualifier(rc); 1941 if (starPrefix != null) { 1942 if (starPrefix.isEmpty()) { 1943 unqualifiedStar = true; 1944 } else { 1945 qualifiedStarPrefixes.add(starPrefix); 1946 } 1947 continue; 1948 } 1949 1950 String outName = rc.getDisplayName(); 1951 if (outName != null && nameMatcher.matches(outName, columnName)) { 1952 TObjectName underlying = rc.getColumnFullname(); 1953 if (underlying != null) { 1954 traceNamedColumnIntoFrom(select, underlying, out, seenKeys, visiting); 1955 } 1956 // A computed/expression column has no physical base table. 1957 } 1958 } 1959 1960 if (unqualifiedStar) { 1961 expandAllFromTables(select, columnName, out, seenKeys, visiting); 1962 } 1963 for (String prefix : qualifiedStarPrefixes) { 1964 TTable matched = findTableByAliasOrName(select, prefix); 1965 if (matched != null) { 1966 collectBaseTablesSupplyingColumn(matched, columnName, out, seenKeys, visiting); 1967 } 1968 } 1969 // No star and no named match: this SELECT cannot supply the column. 1970 } 1971 1972 /** 1973 * Expand every table in {@code select}'s FROM clause as a possible supplier 1974 * of {@code columnName}. 1975 */ 1976 private void expandAllFromTables(TSelectSqlStatement select, String columnName, 1977 List<TTable> out, Set<String> seenKeys, 1978 IdentityHashMap<TSelectSqlStatement, Boolean> visiting) { 1979 if (select == null || select.tables == null) { 1980 return; 1981 } 1982 for (int i = 0; i < select.tables.size(); i++) { 1983 collectBaseTablesSupplyingColumn(select.tables.getTable(i), 1984 columnName, out, seenKeys, visiting); 1985 } 1986 } 1987 1988 /** 1989 * Iteratively flatten a set-operation tree into the leaf SELECTs that 1990 * actually supply output column values, left to right. Uses an explicit 1991 * stack to avoid StackOverflowError on deeply nested set-operation chains. 1992 * 1993 * <p>The operator matters: UNION and INTERSECT take values from both 1994 * operands, but EXCEPT / MINUS take output values from the LEFT operand only 1995 * (the right operand merely filters rows), so its right branch is not a 1996 * source of the selected column and is skipped.</p> 1997 */ 1998 private void flattenSetOperationLeaves(TSelectSqlStatement stmt, List<TSelectSqlStatement> leaves) { 1999 java.util.Deque<TSelectSqlStatement> stack = new java.util.ArrayDeque<TSelectSqlStatement>(); 2000 stack.push(stmt); 2001 while (!stack.isEmpty()) { 2002 TSelectSqlStatement cur = stack.pop(); 2003 if (cur == null) { 2004 continue; 2005 } 2006 if (cur.isCombinedQuery()) { 2007 gudusoft.gsqlparser.ESetOperatorType op = cur.getSetOperatorType(); 2008 boolean rightSuppliesValues = 2009 op != gudusoft.gsqlparser.ESetOperatorType.except 2010 && op != gudusoft.gsqlparser.ESetOperatorType.minus; 2011 // Push right first (when it supplies values) so the left operand 2012 // is processed first. 2013 if (rightSuppliesValues && cur.getRightStmt() != null) { 2014 stack.push(cur.getRightStmt()); 2015 } 2016 if (cur.getLeftStmt() != null) { 2017 stack.push(cur.getLeftStmt()); 2018 } 2019 } else { 2020 leaves.add(cur); 2021 } 2022 } 2023 } 2024 2025 /** 2026 * Index of the first projection in {@code rcl} whose output name matches 2027 * {@code columnName}, or {@code -1} if none matches or a star projection 2028 * makes positional indexing unreliable. 2029 */ 2030 private int indexOfNamedProjection(TResultColumnList rcl, String columnName) { 2031 if (rcl == null) { 2032 return -1; 2033 } 2034 for (int i = 0; i < rcl.size(); i++) { 2035 TResultColumn rc = rcl.getResultColumn(i); 2036 if (rc == null) { 2037 continue; 2038 } 2039 if (starQualifier(rc) != null) { 2040 // A star shifts subsequent positions unpredictably. 2041 return -1; 2042 } 2043 String name = rc.getDisplayName(); 2044 if (name != null && nameMatcher.matches(name, columnName)) { 2045 return i; 2046 } 2047 } 2048 return -1; 2049 } 2050 2051 /** 2052 * Index of the first name in {@code names} that matches {@code columnName}. 2053 */ 2054 private int indexOfMatchingName(gudusoft.gsqlparser.nodes.TObjectNameList names, String columnName) { 2055 if (names == null) { 2056 return -1; 2057 } 2058 for (int i = 0; i < names.size(); i++) { 2059 TObjectName name = names.getObjectName(i); 2060 String n = name != null ? name.getColumnNameOnly() : null; 2061 if (n != null && nameMatcher.matches(n, columnName)) { 2062 return i; 2063 } 2064 } 2065 return -1; 2066 } 2067 2068 /** 2069 * Trace a named projection column (e.g. {@code t.col} or {@code col}) into 2070 * the FROM clause of {@code body} and collect the base tables that supply 2071 * the referenced column. 2072 */ 2073 private void traceNamedColumnIntoFrom(TSelectSqlStatement body, TObjectName underlying, 2074 List<TTable> out, Set<String> seenKeys, 2075 IdentityHashMap<TSelectSqlStatement, Boolean> visiting) { 2076 if (body == null || body.tables == null || underlying == null) { 2077 return; 2078 } 2079 String underlyingName = underlying.getColumnNameOnly(); 2080 if (underlyingName == null || underlyingName.isEmpty()) { 2081 return; 2082 } 2083 String tableQualifier = columnTableQualifier(underlying); 2084 if (tableQualifier != null && !tableQualifier.isEmpty()) { 2085 TTable matched = findTableByAliasOrName(body, tableQualifier); 2086 if (matched != null) { 2087 collectBaseTablesSupplyingColumn(matched, underlyingName, out, 2088 seenKeys, visiting); 2089 return; 2090 } 2091 } 2092 2093 // If resolver2 already resolved this inner projection column to a single 2094 // source (e.g. via DDL/sqlenv metadata), trust that source rather than 2095 // broadening to every FROM table. This keeps candidateTables accurate 2096 // for "SELECT c FROM t1 JOIN t2" when only t1 actually exposes c. 2097 TTable resolvedSource = underlying.getSourceTable(); 2098 if (resolvedSource != null && isInFromClause(body, resolvedSource)) { 2099 collectBaseTablesSupplyingColumn(resolvedSource, underlyingName, out, 2100 seenKeys, visiting); 2101 return; 2102 } 2103 2104 // Unqualified and unresolved: the underlying column may originate from 2105 // any FROM table. 2106 for (int i = 0; i < body.tables.size(); i++) { 2107 collectBaseTablesSupplyingColumn(body.tables.getTable(i), 2108 underlyingName, out, seenKeys, visiting); 2109 } 2110 } 2111 2112 /** 2113 * Whether {@code target} is one of the tables in {@code body}'s FROM clause 2114 * (by object identity). Used to ensure a resolved {@code sourceTable} really 2115 * belongs to this derived table's scope before trusting it. 2116 */ 2117 private boolean isInFromClause(TSelectSqlStatement body, TTable target) { 2118 if (body == null || body.tables == null || target == null) { 2119 return false; 2120 } 2121 for (int i = 0; i < body.tables.size(); i++) { 2122 if (body.tables.getTable(i) == target) { 2123 return true; 2124 } 2125 } 2126 return false; 2127 } 2128 2129 /** 2130 * Return the SELECT body of a derived table (inline subquery or CTE 2131 * reference), or {@code null} if {@code table} is a physical table. 2132 */ 2133 private TSelectSqlStatement getDerivedBody(TTable table) { 2134 if (table == null) { 2135 return null; 2136 } 2137 if (table.getSubquery() != null) { 2138 return table.getSubquery(); 2139 } 2140 if (table.isCTEName() && table.getCTE() != null) { 2141 return table.getCTE().getSubquery(); 2142 } 2143 return null; 2144 } 2145 2146 /** 2147 * Add a physical base table to {@code out}, de-duplicated by qualified name. 2148 */ 2149 private void addBaseTable(TTable table, List<TTable> out, Set<String> seenKeys) { 2150 if (table == null) { 2151 return; 2152 } 2153 String key = baseTableKey(table); 2154 if (seenKeys.add(key)) { 2155 out.add(table); 2156 } 2157 } 2158 2159 private String baseTableKey(TTable table) { 2160 String name = table.getFullName(); 2161 if (name == null || name.isEmpty()) { 2162 name = table.getName(); 2163 } 2164 if (name == null) { 2165 name = table.toString(); 2166 } 2167 return name == null ? "" : name.toLowerCase(java.util.Locale.ROOT); 2168 } 2169 2170 /** 2171 * If {@code rc} is a star result column, return its qualifier: an empty 2172 * string for an unqualified {@code *}, or the table prefix for {@code q.*}. 2173 * Returns {@code null} when {@code rc} is not a star column. 2174 */ 2175 private String starQualifier(TResultColumn rc) { 2176 if (rc == null || rc.getExpr() == null) { 2177 return null; 2178 } 2179 TObjectName operand = rc.getExpr().getObjectOperand(); 2180 String text = operand != null ? operand.toString() : rc.toString(); 2181 if (text == null) { 2182 return null; 2183 } 2184 text = text.trim(); 2185 if ("*".equals(text)) { 2186 return ""; 2187 } 2188 if (text.endsWith(".*")) { 2189 String prefix = text.substring(0, text.length() - 2).trim(); 2190 // Keep only the last segment of a multi-part prefix (e.g. a.b.* -> b). 2191 int dot = prefix.lastIndexOf('.'); 2192 if (dot >= 0) { 2193 prefix = prefix.substring(dot + 1).trim(); 2194 } 2195 return prefix; 2196 } 2197 return null; 2198 } 2199 2200 /** 2201 * Return the table qualifier of a (possibly) qualified column reference, 2202 * e.g. {@code "t"} for {@code t.col} or {@code "t"} for {@code s.t.col}. 2203 * Returns {@code null} for an unqualified column. 2204 */ 2205 private String columnTableQualifier(TObjectName column) { 2206 if (column == null) { 2207 return null; 2208 } 2209 String full = column.toString(); 2210 if (full == null) { 2211 return null; 2212 } 2213 int lastDot = full.lastIndexOf('.'); 2214 if (lastDot < 0) { 2215 return null; 2216 } 2217 String prefix = full.substring(0, lastDot).trim(); 2218 int prevDot = prefix.lastIndexOf('.'); 2219 if (prevDot >= 0) { 2220 prefix = prefix.substring(prevDot + 1).trim(); 2221 } 2222 return prefix.isEmpty() ? null : prefix; 2223 } 2224 2225 /** 2226 * Find a table in {@code body}'s FROM clause by alias or (last segment of) 2227 * name, matching case-insensitively. 2228 */ 2229 private TTable findTableByAliasOrName(TSelectSqlStatement body, String qualifier) { 2230 if (body == null || body.tables == null || qualifier == null) { 2231 return null; 2232 } 2233 for (int i = 0; i < body.tables.size(); i++) { 2234 TTable t = body.tables.getTable(i); 2235 if (t == null) { 2236 continue; 2237 } 2238 String alias = t.getAliasName(); 2239 if (alias != null && tableNameMatches(alias, qualifier)) { 2240 return t; 2241 } 2242 String name = t.getName(); 2243 if (name != null) { 2244 int dot = name.lastIndexOf('.'); 2245 String shortName = dot >= 0 ? name.substring(dot + 1) : name; 2246 if (tableNameMatches(shortName, qualifier)) { 2247 return t; 2248 } 2249 } 2250 } 2251 return null; 2252 } 2253 2254 /** 2255 * Table-name/alias equality routed through the unified façade; falls back to 2256 * case-insensitive comparison when no vendor is configured (synthetic scopes). 2257 */ 2258 private boolean tableNameMatches(String a, String b) { 2259 if (a == null || b == null) { 2260 return false; 2261 } 2262 EDbVendor vendor = config != null ? config.getVendor() : null; 2263 // The resolution's own service (a configured profile, else the vendor default — the same 2264 // relation SQLUtil.sameName uses), so a qualifier matches here exactly as in the scopes. 2265 return vendor == null ? a.equalsIgnoreCase(b) 2266 : config.identifierService().areEqual(a, b, ESQLDataObjectType.dotTable); 2267 } 2268 2269 /** 2270 * Resolve a column within a specific namespace (for direct lookups). 2271 */ 2272 public ResolutionResult resolveInNamespace(String columnName, INamespace namespace) { 2273 if (columnName == null || namespace == null) { 2274 return ResolutionResult.notFound("<null>"); 2275 } 2276 2277 ColumnSource source = namespace.resolveColumn(columnName); 2278 if (source != null) { 2279 return ResolutionResult.exactMatch(source); 2280 } 2281 2282 return ResolutionResult.notFound(columnName); 2283 } 2284 2285 /** 2286 * Find all namespaces that contain a given column. 2287 * Used for implementing full candidate collection in ambiguous scenarios. 2288 */ 2289 /** 2290 * The unaliased UNNEST value tables of unknown element type in the FROM clause of {@code scope} that the 2291 * reference can read (Mantis 4828). A reference inside an UNNEST argument, {@code UNNEST(a)} in 2292 * {@code FROM t, UNNEST(a), UNNEST(b)}, reads only the value tables to its left: the argument cannot name a 2293 * column of the table it feeds, nor of one that comes after it. 2294 */ 2295 private List<UnnestNamespace> unknownValueTablesIn(IScope scope, TObjectName reference) { 2296 List<UnnestNamespace> result = new ArrayList<UnnestNamespace>(); 2297 if (!(scope instanceof SelectScope) || ((SelectScope) scope).getFromScope() == null) { 2298 return result; 2299 } 2300 List<gudusoft.gsqlparser.resolver2.model.ScopeChild> children = ((SelectScope) scope).getFromScope().getChildren(); 2301 UnnestNamespace enclosing = null; 2302 for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : children) { 2303 if (child != null && child.getNamespace() instanceof UnnestNamespace 2304 && insideUnnestArgument(reference, (UnnestNamespace) child.getNamespace())) { 2305 enclosing = (UnnestNamespace) child.getNamespace(); 2306 } 2307 } 2308 for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : children) { 2309 if (child == null || !(child.getNamespace() instanceof UnnestNamespace)) { 2310 continue; 2311 } 2312 UnnestNamespace valueTable = (UnnestNamespace) child.getNamespace(); 2313 if (!valueTable.mayHoldUnknownFields() || valueTable == enclosing) { 2314 continue; 2315 } 2316 if (enclosing != null && !precedes(valueTable, enclosing)) { 2317 continue; 2318 } 2319 result.add(valueTable); 2320 } 2321 return result; 2322 } 2323 2324 /** 2325 * The columns named {@code columnName} that another item of the reference's FROM clause authoritatively declares 2326 * (metadata FOUND; an inferred column of a table without metadata is no competitor). 2327 */ 2328 private static List<ColumnSource> declaredColumnsOfOtherItems(IScope scope, INamespace self, String columnName) { 2329 List<ColumnSource> result = new ArrayList<ColumnSource>(); 2330 if (!(scope instanceof SelectScope) || ((SelectScope) scope).getFromScope() == null) { 2331 return result; 2332 } 2333 for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : ((SelectScope) scope).getFromScope().getChildren()) { 2334 INamespace other = child == null ? null : child.getNamespace(); 2335 if (other == null || other == self 2336 || other.getMetadataState() != gudusoft.gsqlparser.resolver2.namespace.MetadataState.FOUND 2337 || other.hasAuthoritativeOutputColumn(columnName) != ColumnLevel.EXISTS) { 2338 continue; 2339 } 2340 ColumnSource column = other.resolveColumn(columnName); 2341 if (column != null) { 2342 result.add(column); 2343 } 2344 } 2345 return result; 2346 } 2347 2348 /** Whether the UNNEST clause of {@code earlier} ends before the clause of {@code later} starts. */ 2349 private static boolean precedes(UnnestNamespace earlier, UnnestNamespace later) { 2350 TTable a = earlier.getSourceTable(); 2351 TTable b = later.getSourceTable(); 2352 if (a == null || b == null || a.getUnnestClause() == null || b.getUnnestClause() == null 2353 || a.getUnnestClause().getEndToken() == null || b.getUnnestClause().getStartToken() == null) { 2354 return false; 2355 } 2356 return a.getUnnestClause().getEndToken().posinlist < b.getUnnestClause().getStartToken().posinlist; 2357 } 2358 2359 /** One placeholder candidate per value table that may hold the field {@code columnName} (Mantis 4828). */ 2360 private static void addValueTableCandidates(List<ColumnSource> candidates, List<UnnestNamespace> valueTables, 2361 String columnName) { 2362 for (UnnestNamespace valueTable : valueTables) { 2363 candidates.add(new ColumnSource(valueTable, columnName, null, 0.3, "unnest_value_table_candidate")); 2364 } 2365 } 2366 2367 /** Whether the reference is the UNNEST's own argument, UNNEST(items): it cannot read the value table it feeds. */ 2368 private static boolean insideUnnestArgument(TObjectName reference, UnnestNamespace valueTable) { 2369 TTable unnestTable = valueTable.getSourceTable(); 2370 if (reference == null || reference.getStartToken() == null || unnestTable == null 2371 || unnestTable.getUnnestClause() == null || unnestTable.getUnnestClause().getStartToken() == null 2372 || unnestTable.getUnnestClause().getEndToken() == null) { 2373 return false; 2374 } 2375 long at = reference.getStartToken().posinlist; 2376 return at >= unnestTable.getUnnestClause().getStartToken().posinlist 2377 && at <= unnestTable.getUnnestClause().getEndToken().posinlist; 2378 } 2379 2380 /** Whether a candidate rests on inference only (a table without DDL, or low confidence): the reading the 2381 * all-inferred check applies (Mantis 4828). */ 2382 private boolean isInferredCandidate(ColumnSource candidate) { 2383 if (candidate.getEvidenceDetail() != null) { 2384 return !candidate.getEvidenceDetail().isHighConfidence() || candidate.getEvidenceDetail().isInferred(); 2385 } 2386 double minDefiniteConf = config != null ? config.getMinDefiniteConfidence() : 0.9; 2387 String evidence = candidate.getEvidence(); 2388 return evidence == null || "inferred_from_usage".equals(evidence) || candidate.getConfidence() < minDefiniteConf; 2389 } 2390 2391 public List<INamespace> findNamespacesWithColumn(String columnName, IScope scope) { 2392 List<INamespace> result = new ArrayList<>(); 2393 2394 for (INamespace ns : scope.getVisibleNamespaces()) { 2395 if (ns.hasColumn(columnName) == ColumnLevel.EXISTS) { 2396 result.add(ns); 2397 } 2398 } 2399 2400 return result; 2401 } 2402 2403 public INameMatcher getNameMatcher() { 2404 return nameMatcher; 2405 } 2406 2407 public ResolutionContext getContext() { 2408 return context; 2409 } 2410}