001package gudusoft.gsqlparser.resolver2.binding; 002 003import gudusoft.gsqlparser.EExpressionType; 004import gudusoft.gsqlparser.EJoinType; 005import gudusoft.gsqlparser.ESqlClause; 006import gudusoft.gsqlparser.ETableSource; 007import gudusoft.gsqlparser.TCustomSqlStatement; 008import gudusoft.gsqlparser.TStatementList; 009import gudusoft.gsqlparser.nodes.TColumnDefinition; 010import gudusoft.gsqlparser.nodes.TColumnDefinitionList; 011import gudusoft.gsqlparser.nodes.TJoinExpr; 012import gudusoft.gsqlparser.nodes.TObjectName; 013import gudusoft.gsqlparser.nodes.TParseTreeNode; 014import gudusoft.gsqlparser.nodes.TParseTreeVisitor; 015import gudusoft.gsqlparser.nodes.TResultColumn; 016import gudusoft.gsqlparser.nodes.TResultColumnList; 017import gudusoft.gsqlparser.nodes.TTable; 018import gudusoft.gsqlparser.resolver2.ColumnLevel; 019import gudusoft.gsqlparser.resolver2.ScopeBuildResult; 020import gudusoft.gsqlparser.resolver2.TSQLResolver2; 021import gudusoft.gsqlparser.resolver2.TSQLResolverConfig; 022import gudusoft.gsqlparser.resolver2.model.AmbiguousColumnSource; 023import gudusoft.gsqlparser.resolver2.model.ColumnSource; 024import gudusoft.gsqlparser.resolver2.model.ResolutionContext; 025import gudusoft.gsqlparser.resolver2.model.ResolutionResult; 026import gudusoft.gsqlparser.resolver2.namespace.CTENamespace; 027import gudusoft.gsqlparser.resolver2.namespace.INamespace; 028import gudusoft.gsqlparser.resolver2.namespace.MetadataState; 029import gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace; 030import gudusoft.gsqlparser.resolver2.namespace.UnionNamespace; 031import gudusoft.gsqlparser.resolver2.scope.IScope; 032import gudusoft.gsqlparser.stmt.TCreateTableSqlStatement; 033import gudusoft.gsqlparser.stmt.TSelectSqlStatement; 034 035import java.util.ArrayDeque; 036import java.util.ArrayList; 037import java.util.Collections; 038import java.util.Deque; 039import java.util.HashSet; 040import java.util.IdentityHashMap; 041import java.util.List; 042import java.util.Locale; 043import java.util.Set; 044 045/** 046 * Slice S5: orchestrator that walks the recorded binding trace 047 * ({@link ResolutionContext#getColumnResolutionResult}) once after 048 * iterative resolution converges and converts {@link ResolutionResult}s 049 * into a public {@link BindingResult}. 050 * 051 * <p>The class is {@code public} because {@link TSQLResolver2} (in the 052 * parent {@code resolver2} package) instantiates it directly. The static 053 * invocation counter accessors remain package-private — they are 054 * test-only.</p> 055 * 056 * <p>Plan §5.6.1 critical clarification — the post-pass MUST NOT perform 057 * name binding. It only reads resolver2's final state and decides which 058 * stable, public {@link BindingDiagnostic} to surface. In particular it 059 * never re-invokes {@code NameResolver.resolve(...)} or mutates AST 060 * state.</p> 061 * 062 * <p>This slice ships the first three diagnostic codes ({@code 063 * UNKNOWN_COLUMN}, {@code AMBIGUOUS_COLUMN}, {@code 064 * UNBOUND_COLUMN_REFERENCE}). Strict-mode codes ({@code UNKNOWN_TABLE}, 065 * {@code UNKNOWN_ALIAS}, {@code CATALOG_METADATA_UNAVAILABLE}) and clause 066 * attribution land in S6; deeper codes (CTE / subquery output, set-op 067 * arity, USING / NATURAL, DML) land in S8–S13.</p> 068 */ 069public final class BindingDiagnosticPostPass { 070 071 /** 072 * Static invocation counter used by {@code BindingPostPassInvocationTest} 073 * to confirm the post-pass runs at most once per resolver call regardless 074 * of how many iterations the resolver loop executed (plan §13 risk row). 075 * Test-only — not part of the public surface. 076 */ 077 private static volatile int invocationCount = 0; 078 079 /** 080 * How many times the binding diagnostic post-pass has run in this JVM. 081 * 082 * <p>PUBLIC ON PURPOSE, for the same reason as 083 * {@link #resetInvocationCounter()}: package-private members are renamed in 084 * every shipped (ProGuard-obfuscated) jar, so a test asserting on this 085 * counter cannot reach it when run against the real released bytes.</p> 086 * 087 * @return the invocation count since the last reset 088 * @since 4.2.6 089 */ 090 public static int getInvocationCount() { 091 return invocationCount; 092 } 093 094 /** 095 * Resets the post-pass invocation counter. Test support only. 096 * 097 * <p>PUBLIC ON PURPOSE: every shipped jar is ProGuard-obfuscated under a 098 * compatibility-first policy that preserves public and protected members 099 * and renames everything else. A package-private method is therefore 100 * unreachable from the released jar, which is what the test suite runs 101 * against in the nightly obfuscated-jar gate. Keeping this public is what 102 * lets that gate execute the real shipped bytes.</p> 103 * 104 * @since 4.2.6 105 */ 106 public static void resetInvocationCounter() { 107 invocationCount = 0; 108 } 109 110 private final TSQLResolver2 resolver; 111 private final TSQLResolverConfig config; 112 private BindingClauseMapper mapper = BindingClauseMapper.empty(); 113 114 public BindingDiagnosticPostPass(TSQLResolver2 resolver, TSQLResolverConfig config) { 115 this.resolver = resolver; 116 this.config = config; 117 } 118 119 /** 120 * Run the post-pass exactly once and return the populated 121 * {@link BindingResult}. 122 * 123 * <p>Always non-null. Returns {@link BindingResult#empty()} when the 124 * inputs do not yet satisfy the minimum invariants (no scope build 125 * result, no resolution context, trace not enabled) — never throws.</p> 126 */ 127 public BindingResult run() { 128 invocationCount++; 129 130 ScopeBuildResult sbr = resolver != null ? resolver.getScopeBuildResult() : null; 131 ResolutionContext ctx = resolver != null ? resolver.getContext() : null; 132 if (sbr == null || ctx == null || !ctx.isBindingTraceEnabled()) { 133 return BindingResult.empty(); 134 } 135 136 mapper = BindingClauseMapper.of(resolver.getStatements()); 137 138 boolean includeSuccessful = config != null 139 && config.isBindingIncludeSuccessfulReferences(); 140 141 List<BindingDiagnostic> diagnostics = new ArrayList<BindingDiagnostic>(); 142 List<BindingReference> references = new ArrayList<BindingReference>(); 143 144 collectSetOperationDiagnostics(diagnostics); 145 collectJoinDiagnostics(sbr, diagnostics); 146 147 List<TObjectName> refs = sbr.getAllColumnReferences(); 148 if (refs == null || refs.isEmpty()) { 149 return diagnostics.isEmpty() ? BindingResult.empty() 150 : BindingResult.of(diagnostics, references); 151 } 152 153 // Identity-keyed dedup so re-emitted diagnostics for the same 154 // TObjectName do not stack (e.g., two passes touching the same 155 // reference). The trace itself is identity-keyed for the same 156 // reason — see ResolutionContext#columnResolutionResults. 157 IdentityHashMap<TObjectName, BindingDiagnostic> seenDiagnostics = 158 new IdentityHashMap<TObjectName, BindingDiagnostic>(); 159 IdentityHashMap<TObjectName, BindingReference> seenReferences = 160 new IdentityHashMap<TObjectName, BindingReference>(); 161 162 for (TObjectName ref : refs) { 163 if (ref == null) continue; 164 165 BindingSkipReason skip = ctx.getColumnSkipReason(ref); 166 if (skip != null) { 167 continue; 168 } 169 170 // S13: DML target/definition columns are NOT user references — they 171 // declare WHERE the write goes, not what it reads. Skip silently and 172 // tag the trace so consumers reading ResolutionContext directly can 173 // see intent (plan §10.5 / §13 failure-handling row). 174 if (isDmlTargetColumn(ref)) { 175 ctx.recordColumnSkipReason(ref, BindingSkipReason.TARGET_COLUMN); 176 continue; 177 } 178 179 // CREATE TABLE column names remain in ScopeBuildResult for the 180 // long-standing get-table-column compatibility surface, but they 181 // are definitions rather than read-side references. Keep the 182 // compatibility data and suppress only binding diagnostics. 183 if (isCreateTableColumnDefinition(ref)) { 184 ctx.recordColumnSkipReason(ref, 185 BindingSkipReason.TARGET_COLUMN); 186 continue; 187 } 188 189 ResolutionResult result = ctx.getColumnResolutionResult(ref); 190 if (result == null) { 191 // No recorded resolution. This is normal for synthetic clones 192 // (already filtered above via SYNTHETIC_STAR_CLONE) and for 193 // references the resolver intentionally skipped. The S3 194 // coverage gate ensures every other reference is recorded; 195 // we trust that gate here. 196 continue; 197 } 198 199 if (!seenDiagnostics.containsKey(ref)) { 200 BindingDiagnostic setOpOutputDiagnostic = buildSetOperationOutputDiagnostic(ref, sbr); 201 if (setOpOutputDiagnostic != null) { 202 seenDiagnostics.put(ref, setOpOutputDiagnostic); 203 diagnostics.add(setOpOutputDiagnostic); 204 205 if (includeSuccessful && !seenReferences.containsKey(ref)) { 206 BindingReference br = buildReference(ref, sbr, /* bound */ false); 207 if (br != null) { 208 seenReferences.put(ref, br); 209 references.add(br); 210 } 211 } 212 continue; 213 } 214 BindingDiagnostic cteOutputDiagnostic = buildCteOutputDiagnostic(ref, sbr); 215 if (cteOutputDiagnostic != null) { 216 seenDiagnostics.put(ref, cteOutputDiagnostic); 217 diagnostics.add(cteOutputDiagnostic); 218 219 if (includeSuccessful && !seenReferences.containsKey(ref)) { 220 BindingReference br = buildReference(ref, sbr, /* bound */ false); 221 if (br != null) { 222 seenReferences.put(ref, br); 223 references.add(br); 224 } 225 } 226 continue; 227 } 228 BindingDiagnostic subqueryOutputDiagnostic = buildSubqueryOutputDiagnostic(ref, sbr); 229 if (subqueryOutputDiagnostic != null) { 230 seenDiagnostics.put(ref, subqueryOutputDiagnostic); 231 diagnostics.add(subqueryOutputDiagnostic); 232 233 if (includeSuccessful && !seenReferences.containsKey(ref)) { 234 BindingReference br = buildReference(ref, sbr, /* bound */ false); 235 if (br != null) { 236 seenReferences.put(ref, br); 237 references.add(br); 238 } 239 } 240 continue; 241 } 242 } 243 244 if (result.isExactMatch()) { 245 // S16c (plan §5.6.2): EXACT_MATCH means resolver2 picked one 246 // SQL source for this reference, but it does NOT mean catalog 247 // metadata has confirmed the column actually lives on the 248 // selected source. Read the resolver2-selected namespace from 249 // the recorded ResolutionResult and ask the catalog only. 250 // We never call NameResolver.resolve(...) again, never search 251 // the SQL scope, and never mutate AST state. 252 if (!seenDiagnostics.containsKey(ref)) { 253 BindingDiagnostic exactMatchDiag = 254 verifyExactMatchAgainstCatalog(ref, result, sbr); 255 if (exactMatchDiag != null) { 256 seenDiagnostics.put(ref, exactMatchDiag); 257 diagnostics.add(exactMatchDiag); 258 if (includeSuccessful && !seenReferences.containsKey(ref)) { 259 BindingReference br = buildReference(ref, sbr, /* bound */ false); 260 if (br != null) { 261 seenReferences.put(ref, br); 262 references.add(br); 263 } 264 } 265 continue; 266 } 267 } 268 // In strict mode, even an exact-match resolution may be 269 // suspect when the backing namespace has unreliable metadata 270 // (e.g. resolver inferred a 0.80-confidence match for a table 271 // that is NOT_FOUND_IN_CATALOG or METADATA_UNAVAILABLE). 272 if (config != null && config.isBindingStrictCatalogValidation() 273 && !seenDiagnostics.containsKey(ref)) { 274 BindingDiagnostic strictDiag = buildStrictModeNamespaceDiagnostic(ref, sbr); 275 if (strictDiag != null) { 276 seenDiagnostics.put(ref, strictDiag); 277 diagnostics.add(strictDiag); 278 continue; 279 } 280 } 281 if (includeSuccessful && !seenReferences.containsKey(ref)) { 282 BindingReference br = buildReference(ref, sbr, /* bound */ true); 283 if (br != null) { 284 seenReferences.put(ref, br); 285 references.add(br); 286 } 287 } 288 continue; 289 } 290 291 if (seenDiagnostics.containsKey(ref)) { 292 continue; 293 } 294 295 BindingDiagnostic diagnostic = null; 296 if (result.isAmbiguous()) { 297 diagnostic = buildAmbiguousDiagnostic(ref, result, sbr); 298 } else if (result.isNotFound()) { 299 diagnostic = buildNotFoundDiagnostic(ref, ctx, sbr); 300 } 301 302 if (diagnostic != null) { 303 seenDiagnostics.put(ref, diagnostic); 304 diagnostics.add(diagnostic); 305 306 if (includeSuccessful && !seenReferences.containsKey(ref)) { 307 BindingReference br = buildReference(ref, sbr, /* bound */ false); 308 if (br != null) { 309 seenReferences.put(ref, br); 310 references.add(br); 311 } 312 } 313 } 314 } 315 316 if (diagnostics.isEmpty() && references.isEmpty()) { 317 return BindingResult.empty(); 318 } 319 return BindingResult.of(diagnostics, references); 320 } 321 322 // ===== AMBIGUOUS ===== 323 324 private BindingDiagnostic buildAmbiguousDiagnostic(TObjectName ref, 325 ResolutionResult result, 326 ScopeBuildResult sbr) { 327 AmbiguousColumnSource ambiguous = result.getAmbiguousSource(); 328 List<String> candidates = candidatesOf(ambiguous); 329 String text = ref.toString(); 330 331 BindingDiagnosticSeverity severity = severityFor(BindingDiagnosticCode.AMBIGUOUS_COLUMN); 332 BindingDiagnostic.Builder b = BindingDiagnosticBuilder 333 .forCode(BindingDiagnosticCode.AMBIGUOUS_COLUMN) 334 .severity(severity) 335 .message("Column " + text + " is ambiguous between " 336 + (candidates.isEmpty() ? "multiple sources" : candidates) + ".") 337 .objectNameText(text) 338 .candidates(candidates) 339 .site(siteFor(ref)) 340 .statement(statementFor(ref, sbr)); 341 return b.build(); 342 } 343 344 private static List<String> candidatesOf(AmbiguousColumnSource ambiguous) { 345 if (ambiguous == null) { 346 return Collections.emptyList(); 347 } 348 List<String> out = new ArrayList<String>(); 349 for (ColumnSource cs : ambiguous.getCandidates()) { 350 if (cs == null) continue; 351 String label = describeCandidate(cs, ambiguous.getColumnName()); 352 if (label != null) { 353 out.add(label); 354 } 355 } 356 return out; 357 } 358 359 private static String describeCandidate(ColumnSource cs, String columnName) { 360 if (cs == null) return null; 361 String tableLabel = null; 362 if (cs.getFinalTable() != null && cs.getFinalTable().getName() != null) { 363 tableLabel = cs.getFinalTable().getName().toString(); 364 } else if (cs.getSourceNamespace() != null) { 365 tableLabel = cs.getSourceNamespace().getDisplayName(); 366 } 367 String exposed = cs.getExposedName(); 368 String name = (exposed != null && !exposed.isEmpty()) ? exposed : columnName; 369 if (tableLabel == null || tableLabel.isEmpty()) { 370 return name; 371 } 372 return tableLabel + "." + name; 373 } 374 375 // ===== SET operations ===== 376 377 private void collectSetOperationDiagnostics(final List<BindingDiagnostic> diagnostics) { 378 if (diagnostics == null) return; 379 TStatementList statements = resolver != null ? resolver.getStatements() : null; 380 if (statements == null) return; 381 382 final Set<TSelectSqlStatement> covered = 383 Collections.newSetFromMap(new IdentityHashMap<TSelectSqlStatement, Boolean>()); 384 for (int i = 0; i < statements.size(); i++) { 385 TCustomSqlStatement stmt = statements.get(i); 386 if (stmt == null) continue; 387 try { 388 stmt.acceptChildren(new TParseTreeVisitor() { 389 @Override 390 public void preVisit(TSelectSqlStatement node) { 391 collectSetOperationDiagnostic(node, diagnostics, covered); 392 } 393 }); 394 } catch (Throwable ignore) { 395 // Defensive: set-operation diagnostics must never fail the post-pass. 396 } 397 } 398 } 399 400 private void collectSetOperationDiagnostic(TSelectSqlStatement stmt, 401 List<BindingDiagnostic> diagnostics, 402 Set<TSelectSqlStatement> covered) { 403 if (stmt == null || !stmt.isCombinedQuery() || covered.contains(stmt)) { 404 return; 405 } 406 407 SetOperationShape shape = analyzeSetOperationShape(stmt); 408 if (!shape.authoritative || !shape.hasMismatch()) { 409 return; 410 } 411 412 diagnostics.add(buildSetOperationArityDiagnostic(stmt, shape)); 413 covered.addAll(collectCombinedSetOperationNodes(stmt)); 414 } 415 416 private SetOperationShape analyzeSetOperationShape(TSelectSqlStatement stmt) { 417 List<TSelectSqlStatement> branches = flattenSetOperationBranches(stmt); 418 List<Integer> counts = new ArrayList<Integer>(); 419 if (branches.size() < 2) { 420 return SetOperationShape.nonAuthoritative(counts); 421 } 422 423 for (TSelectSqlStatement branch : branches) { 424 if (branch == null || branch.isCombinedQuery()) { 425 return SetOperationShape.nonAuthoritative(counts); 426 } 427 TResultColumnList selectList = branch.getResultColumnList(); 428 if (selectList == null || containsStarResultColumn(selectList)) { 429 return SetOperationShape.nonAuthoritative(counts); 430 } 431 counts.add(Integer.valueOf(selectList.size())); 432 } 433 434 return SetOperationShape.authoritative(counts); 435 } 436 437 private static List<TSelectSqlStatement> flattenSetOperationBranches(TSelectSqlStatement stmt) { 438 List<TSelectSqlStatement> branches = new ArrayList<TSelectSqlStatement>(); 439 if (stmt == null) { 440 return branches; 441 } 442 443 Deque<TSelectSqlStatement> stack = new ArrayDeque<TSelectSqlStatement>(); 444 stack.push(stmt); 445 while (!stack.isEmpty()) { 446 TSelectSqlStatement current = stack.pop(); 447 if (current == null) continue; 448 if (current.isCombinedQuery()) { 449 if (current.getRightStmt() != null) { 450 stack.push(current.getRightStmt()); 451 } 452 if (current.getLeftStmt() != null) { 453 stack.push(current.getLeftStmt()); 454 } 455 } else { 456 branches.add(current); 457 } 458 } 459 return branches; 460 } 461 462 private static Set<TSelectSqlStatement> collectCombinedSetOperationNodes(TSelectSqlStatement stmt) { 463 Set<TSelectSqlStatement> nodes = 464 Collections.newSetFromMap(new IdentityHashMap<TSelectSqlStatement, Boolean>()); 465 if (stmt == null) { 466 return nodes; 467 } 468 469 Deque<TSelectSqlStatement> stack = new ArrayDeque<TSelectSqlStatement>(); 470 stack.push(stmt); 471 while (!stack.isEmpty()) { 472 TSelectSqlStatement current = stack.pop(); 473 if (current == null || !current.isCombinedQuery() || !nodes.add(current)) { 474 continue; 475 } 476 if (current.getRightStmt() != null) { 477 stack.push(current.getRightStmt()); 478 } 479 if (current.getLeftStmt() != null) { 480 stack.push(current.getLeftStmt()); 481 } 482 } 483 return nodes; 484 } 485 486 private static boolean containsStarResultColumn(TResultColumnList selectList) { 487 if (selectList == null) { 488 return true; 489 } 490 for (int i = 0; i < selectList.size(); i++) { 491 TResultColumn resultColumn = selectList.getResultColumn(i); 492 if (isStarResultColumn(resultColumn)) { 493 return true; 494 } 495 } 496 return false; 497 } 498 499 private static boolean isStarResultColumn(TResultColumn resultColumn) { 500 if (resultColumn == null) { 501 return false; 502 } 503 504 // Star modifiers such as BigQuery SELECT * EXCEPT/REPLACE still have 505 // non-authoritative expanded output for S11 arity and missing-output 506 // diagnostics. Treat those as star-derived even when the full result 507 // column text no longer equals "*" or "t.*". 508 if (resultColumn.getExceptColumnList() != null 509 || (resultColumn.getReplaceExprAsIdentifiers() != null 510 && !resultColumn.getReplaceExprAsIdentifiers().isEmpty()) 511 || (resultColumn.getExprAsIdentifiers() != null 512 && !resultColumn.getExprAsIdentifiers().isEmpty())) { 513 return true; 514 } 515 516 if (resultColumn.getExpr() != null 517 && resultColumn.getExpr().getExpressionType() == EExpressionType.simple_object_name_t 518 && resultColumn.getExpr().getObjectOperand() != null) { 519 String starText = resultColumn.getExpr().getObjectOperand().toString(); 520 if (starText != null) { 521 starText = starText.trim(); 522 if ("*".equals(starText) || starText.endsWith(".*")) { 523 return true; 524 } 525 } 526 } 527 528 String text = resultColumn.toString(); 529 if (text == null) { 530 return false; 531 } 532 text = text.trim(); 533 return "*".equals(text) || text.endsWith(".*"); 534 } 535 536 private BindingDiagnostic buildSetOperationArityDiagnostic(TSelectSqlStatement stmt, 537 SetOperationShape shape) { 538 String label = setOperationLabel(stmt); 539 int expected = shape.counts.get(0).intValue(); 540 int mismatchIndex = shape.firstMismatchIndex(); 541 int actual = shape.counts.get(mismatchIndex).intValue(); 542 543 return BindingDiagnosticBuilder 544 .forCode(BindingDiagnosticCode.SET_OPERATION_ARITY_MISMATCH) 545 .severity(severityFor(BindingDiagnosticCode.SET_OPERATION_ARITY_MISMATCH)) 546 .message("Set operation " + label 547 + " has branch column count mismatch: branch 1 has " 548 + expected + " column(s), branch " + (mismatchIndex + 1) 549 + " has " + actual + " column(s).") 550 .objectNameText(label) 551 .candidates(branchCountCandidates(shape.counts)) 552 .site(setOperationSite(label)) 553 .statement(stmt) 554 .build(); 555 } 556 557 private static List<String> branchCountCandidates(List<Integer> counts) { 558 if (counts == null || counts.isEmpty()) { 559 return Collections.emptyList(); 560 } 561 List<String> out = new ArrayList<String>(); 562 for (int i = 0; i < counts.size(); i++) { 563 out.add("branch " + (i + 1) + ": " + counts.get(i)); 564 } 565 return out; 566 } 567 568 private static BindingReferenceSite setOperationSite(String label) { 569 return BindingReferenceSite.builder() 570 .clause(BindingClause.OTHER) 571 .referenceText(label == null || label.isEmpty() ? "SET OPERATION" : label) 572 .build(); 573 } 574 575 private static String setOperationLabel(TSelectSqlStatement stmt) { 576 if (stmt == null || stmt.getSetOperatorType() == null) { 577 return "SET OPERATION"; 578 } 579 String type = stmt.getSetOperatorType().name(); 580 if (type == null || "none".equals(type)) { 581 return "SET OPERATION"; 582 } 583 String label = type.toUpperCase(Locale.ROOT); 584 if (stmt.isAll()) { 585 label += " ALL"; 586 } else if (stmt.isSetOpDistinct()) { 587 label += " DISTINCT"; 588 } 589 return label; 590 } 591 592 private static final class SetOperationShape { 593 private final boolean authoritative; 594 private final List<Integer> counts; 595 596 private SetOperationShape(boolean authoritative, List<Integer> counts) { 597 this.authoritative = authoritative; 598 this.counts = counts != null 599 ? Collections.unmodifiableList(new ArrayList<Integer>(counts)) 600 : Collections.<Integer>emptyList(); 601 } 602 603 static SetOperationShape authoritative(List<Integer> counts) { 604 return new SetOperationShape(true, counts); 605 } 606 607 static SetOperationShape nonAuthoritative(List<Integer> counts) { 608 return new SetOperationShape(false, counts); 609 } 610 611 boolean hasMismatch() { 612 return firstMismatchIndex() >= 0; 613 } 614 615 int firstMismatchIndex() { 616 if (!authoritative || counts.size() < 2) { 617 return -1; 618 } 619 int expected = counts.get(0).intValue(); 620 for (int i = 1; i < counts.size(); i++) { 621 if (counts.get(i).intValue() != expected) { 622 return i; 623 } 624 } 625 return -1; 626 } 627 } 628 629 // ===== JOIN diagnostics ===== 630 631 private void collectJoinDiagnostics(ScopeBuildResult sbr, 632 List<BindingDiagnostic> diagnostics) { 633 if (sbr == null || diagnostics == null) return; 634 collectUsingColumnDiagnostics(sbr, diagnostics); 635 collectNaturalJoinDiagnostics(diagnostics); 636 } 637 638 private void collectUsingColumnDiagnostics(final ScopeBuildResult sbr, 639 final List<BindingDiagnostic> diagnostics) { 640 TStatementList statements = resolver != null ? resolver.getStatements() : null; 641 if (statements == null) return; 642 final Set<TJoinExpr> seen = Collections.newSetFromMap(new IdentityHashMap<TJoinExpr, Boolean>()); 643 for (int i = 0; i < statements.size(); i++) { 644 TCustomSqlStatement stmt = statements.get(i); 645 if (stmt == null) continue; 646 try { 647 stmt.acceptChildren(new TParseTreeVisitor() { 648 @Override 649 public void preVisit(TJoinExpr node) { 650 if (node == null || !seen.add(node) 651 || node.getUsingColumns() == null 652 || node.getUsingColumns().size() == 0) { 653 return; 654 } 655 Set<TTable> leftTables = collectInputTables(node.getLeftTable()); 656 Set<TTable> rightTables = collectInputTables(node.getRightTable()); 657 if (leftTables.isEmpty() || rightTables.isEmpty()) return; 658 for (int c = 0; c < node.getUsingColumns().size(); c++) { 659 TObjectName col = node.getUsingColumns().getObjectName(c); 660 String name = usingColumnName(col); 661 if (name == null) continue; 662 Boolean leftPresent = inputExposesColumn(sbr, leftTables, name); 663 Boolean rightPresent = inputExposesColumn(sbr, rightTables, name); 664 // Catalog-honesty rule: emit only when both JOIN 665 // inputs have authoritative metadata. For a 666 // composite left input, one table exposing the 667 // column is sufficient: SQL visibility is over the 668 // join input, not every physical table beneath it. 669 if (leftPresent == null || rightPresent == null) continue; 670 if (leftPresent.booleanValue() && rightPresent.booleanValue()) continue; 671 diagnostics.add(BindingDiagnosticBuilder 672 .forCode(BindingDiagnosticCode.USING_COLUMN_NOT_COMMON) 673 .severity(severityFor(BindingDiagnosticCode.USING_COLUMN_NOT_COMMON)) 674 .message("USING column " + name 675 + " is not common to both JOIN inputs.") 676 .objectNameText(col != null ? col.toString() : name) 677 .site(joinSite(col != null ? col.toString() : name)) 678 .statement(null) 679 .build()); 680 } 681 } 682 }); 683 } catch (Throwable ignore) { 684 // Defensive: join diagnostics must never fail the post-pass. 685 } 686 } 687 } 688 689 /** 690 * @return TRUE when at least one table in the input authoritatively exposes 691 * the column, FALSE when all authoritative tables lack it, null when the 692 * input has only unavailable/ambiguous metadata or mixed unavailable misses. 693 */ 694 private static Boolean inputExposesColumn(ScopeBuildResult sbr, 695 Set<TTable> tables, 696 String columnName) { 697 boolean sawAuthoritativeAbsent = false; 698 boolean sawUnavailable = false; 699 for (TTable table : tables) { 700 if (table == null) { 701 sawUnavailable = true; 702 continue; 703 } 704 INamespace ns = sbr.getNamespaceForTable(table); 705 ColumnAuthority authority = BindingMetadataAuthority.lookup(ns, columnName); 706 if (authority == ColumnAuthority.AUTHORITATIVE_PRESENT) return Boolean.TRUE; 707 if (authority == ColumnAuthority.AUTHORITATIVE_ABSENT) { 708 sawAuthoritativeAbsent = true; 709 } else { 710 sawUnavailable = true; 711 } 712 } 713 if (sawUnavailable) return null; 714 return sawAuthoritativeAbsent ? Boolean.FALSE : null; 715 } 716 717 private static Set<TTable> collectInputTables(TTable table) { 718 Set<TTable> out = new HashSet<TTable>(); 719 collectInputTables(table, out); 720 return out; 721 } 722 723 private static void collectInputTables(TTable table, Set<TTable> out) { 724 if (table == null || out == null) return; 725 if (table.getTableType() == ETableSource.join && table.getJoinExpr() != null) { 726 TJoinExpr join = table.getJoinExpr(); 727 collectInputTables(join.getLeftTable(), out); 728 collectInputTables(join.getRightTable(), out); 729 return; 730 } 731 out.add(table); 732 } 733 734 private void collectNaturalJoinDiagnostics(final List<BindingDiagnostic> diagnostics) { 735 if (config == null || !config.isBindingStrictCatalogValidation()) return; 736 TStatementList statements = resolver != null ? resolver.getStatements() : null; 737 if (statements == null) return; 738 final Set<TJoinExpr> seen = Collections.newSetFromMap(new IdentityHashMap<TJoinExpr, Boolean>()); 739 for (int i = 0; i < statements.size(); i++) { 740 TCustomSqlStatement stmt = statements.get(i); 741 if (stmt == null) continue; 742 try { 743 stmt.acceptChildren(new TParseTreeVisitor() { 744 @Override 745 public void preVisit(TJoinExpr node) { 746 if (node == null || !seen.add(node) || !isNaturalJoin(node.getJointype())) return; 747 String text = node.toString(); 748 diagnostics.add(BindingDiagnosticBuilder 749 .forCode(BindingDiagnosticCode.UNSUPPORTED_BINDING_SCOPE) 750 .severity(severityFor(BindingDiagnosticCode.UNSUPPORTED_BINDING_SCOPE)) 751 .message("NATURAL JOIN binding semantics are not yet modeled.") 752 .objectNameText(text != null && !text.isEmpty() ? text : "NATURAL JOIN") 753 .site(joinSite("NATURAL JOIN")) 754 .statement(null) 755 .build()); 756 } 757 }); 758 } catch (Throwable ignore) { 759 // Defensive: join diagnostics must never fail the post-pass. 760 } 761 } 762 } 763 764 private static boolean isNaturalJoin(EJoinType type) { 765 return type == EJoinType.natural 766 || type == EJoinType.natural_inner 767 || type == EJoinType.natural_left 768 || type == EJoinType.natural_right 769 || type == EJoinType.natural_full 770 || type == EJoinType.natural_leftouter 771 || type == EJoinType.natural_rightouter 772 || type == EJoinType.natural_fullouter; 773 } 774 775 private static String usingColumnName(TObjectName col) { 776 if (col == null) return null; 777 String name = col.getColumnNameOnly(); 778 if (name == null || name.isEmpty()) return null; 779 // S14: keep the column name in its original form (with quotes if any). 780 // {@link BindingMetadataAuthority#lookup} passes the name to 781 // {@code namespace.hasColumn(...)} which routes through {@code 782 // INameMatcher} — VendorNameMatcher honors per-dialect quoted vs 783 // unquoted compare rules. Stripping quotes here would lose that 784 // distinction (e.g. Oracle USING("Id") would have falsely matched a 785 // catalog column folded to ID). 786 return name; 787 } 788 789 private static BindingReferenceSite joinSite(String text) { 790 return BindingReferenceSite.builder() 791 .clause(BindingClause.OTHER) 792 .referenceText(text == null || text.isEmpty() ? "JOIN" : text) 793 .build(); 794 } 795 796 // ===== NOT_FOUND ===== 797 798 private BindingDiagnostic buildNotFoundDiagnostic(TObjectName ref, 799 ResolutionContext ctx, 800 ScopeBuildResult sbr) { 801 String columnName = ref.getColumnNameOnly(); 802 if (columnName == null || columnName.isEmpty()) { 803 return null; 804 } 805 806 String qualifier = qualifierOf(ref); 807 IScope scope = sbr.getScopeForColumn(ref); 808 809 // Star reference (`t.*` or bare `*`): wildcard expansion is not a column 810 // lookup. We MUST NOT emit UNKNOWN_COLUMN regardless of namespace state. 811 // Plan §7.3 S7: user-authored `unknown_alias.*` emits 812 // INVALID_STAR_QUALIFIER; everything else is silent. 813 if (isStarReference(ref, columnName)) { 814 if (qualifier == null || qualifier.isEmpty()) { 815 return null; 816 } 817 INamespace starNs = (scope != null) ? scope.resolveTable(qualifier) : null; 818 if (starNs == null) { 819 return BindingDiagnosticBuilder 820 .forCode(BindingDiagnosticCode.INVALID_STAR_QUALIFIER) 821 .severity(severityFor(BindingDiagnosticCode.INVALID_STAR_QUALIFIER)) 822 .message("Star qualifier " + qualifier 823 + " does not match any table or alias in scope.") 824 .objectNameText(ref.toString()) 825 .site(siteFor(ref)) 826 .statement(statementFor(ref, sbr)) 827 .build(); 828 } 829 return null; 830 } 831 832 if (qualifier != null && !qualifier.isEmpty()) { 833 INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null; 834 if (ns == null) { 835 if (config != null && config.isBindingStrictCatalogValidation()) { 836 return BindingDiagnosticBuilder 837 .forCode(BindingDiagnosticCode.UNKNOWN_ALIAS) 838 .severity(severityFor(BindingDiagnosticCode.UNKNOWN_ALIAS)) 839 .message("Qualifier " + qualifier 840 + " does not match any table or alias in scope.") 841 .objectNameText(ref.toString()) 842 .site(siteFor(ref)) 843 .statement(statementFor(ref, sbr)) 844 .build(); 845 } 846 return null; 847 } 848 MetadataState state = ns.getMetadataState(); 849 if (state == MetadataState.NOT_FOUND_IN_CATALOG) { 850 if (config != null && config.isBindingStrictCatalogValidation()) { 851 return BindingDiagnosticBuilder 852 .forCode(BindingDiagnosticCode.UNKNOWN_TABLE) 853 .severity(severityFor(BindingDiagnosticCode.UNKNOWN_TABLE)) 854 .message("Table " + qualifier + " was not found in the catalog.") 855 .objectNameText(ref.toString()) 856 .site(siteFor(ref)) 857 .statement(statementFor(ref, sbr)) 858 .build(); 859 } 860 return null; 861 } 862 if (state == MetadataState.METADATA_UNAVAILABLE) { 863 if (config != null && config.isBindingStrictCatalogValidation()) { 864 return BindingDiagnosticBuilder 865 .forCode(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE) 866 .severity(severityFor(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE)) 867 .message("Catalog metadata is unavailable for table " + qualifier + ".") 868 .objectNameText(ref.toString()) 869 .site(siteFor(ref)) 870 .statement(statementFor(ref, sbr)) 871 .build(); 872 } 873 return null; 874 } 875 ColumnAuthority authority = BindingMetadataAuthority.lookup(ns, columnName); 876 if (authority != ColumnAuthority.AUTHORITATIVE_ABSENT) { 877 return null; 878 } 879 880 String tableLabel = describeTable(ns); 881 return BindingDiagnosticBuilder 882 .forCode(BindingDiagnosticCode.UNKNOWN_COLUMN) 883 .severity(severityFor(BindingDiagnosticCode.UNKNOWN_COLUMN)) 884 .message("Column " + ref.toString() 885 + " was not found in catalog table " + tableLabel + ".") 886 .objectNameText(ref.toString()) 887 .site(siteFor(ref)) 888 .statement(statementFor(ref, sbr)) 889 .build(); 890 } 891 892 // Unqualified miss. Plan §7.3 S5: UNBOUND_COLUMN_REFERENCE fires when 893 // status NOT_FOUND AND no qualifier AND no in-scope table. Otherwise 894 // wait for S6+ to add ambiguous/contextual handling. 895 if (!hasInScopeTable(scope)) { 896 return BindingDiagnosticBuilder 897 .forCode(BindingDiagnosticCode.UNBOUND_COLUMN_REFERENCE) 898 .severity(severityFor(BindingDiagnosticCode.UNBOUND_COLUMN_REFERENCE)) 899 .message("Column " + columnName 900 + " could not be bound: no tables are in scope.") 901 .objectNameText(ref.toString()) 902 .site(siteFor(ref)) 903 .statement(statementFor(ref, sbr)) 904 .build(); 905 } 906 return null; 907 } 908 909 private BindingDiagnostic buildSetOperationOutputDiagnostic(TObjectName ref, 910 ScopeBuildResult sbr) { 911 if (ref == null || sbr == null) return null; 912 913 String columnName = ref.getColumnNameOnly(); 914 if (columnName == null || columnName.isEmpty() 915 || isStarReference(ref, columnName)) { 916 return null; 917 } 918 919 String qualifier = qualifierOf(ref); 920 if (qualifier == null || qualifier.isEmpty()) return null; 921 922 IScope scope = sbr.getScopeForColumn(ref); 923 INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null; 924 if (!(ns instanceof UnionNamespace)) return null; 925 926 UnionNamespace union = (UnionNamespace) ns; 927 ColumnLevel level = union.hasAuthoritativeOutputColumn(columnName); 928 if (level != ColumnLevel.NOT_EXISTS) return null; 929 930 String displayName = union.getDisplayName(); 931 if (displayName == null || displayName.isEmpty()) { 932 displayName = qualifier; 933 } 934 return BindingDiagnosticBuilder 935 .forCode(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING) 936 .severity(severityFor(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING)) 937 .message("Column " + ref.toString() 938 + " is not exposed by set-operation derived table " 939 + displayName + ".") 940 .objectNameText(ref.toString()) 941 .site(siteFor(ref)) 942 .statement(statementFor(ref, sbr)) 943 .build(); 944 } 945 946 private BindingDiagnostic buildCteOutputDiagnostic(TObjectName ref, 947 ScopeBuildResult sbr) { 948 if (ref == null || sbr == null) return null; 949 950 String columnName = ref.getColumnNameOnly(); 951 if (columnName == null || columnName.isEmpty() 952 || isStarReference(ref, columnName)) { 953 return null; 954 } 955 956 String qualifier = qualifierOf(ref); 957 if (qualifier == null || qualifier.isEmpty()) return null; 958 959 IScope scope = sbr.getScopeForColumn(ref); 960 INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null; 961 if (!(ns instanceof CTENamespace)) return null; 962 963 CTENamespace cte = (CTENamespace) ns; 964 ColumnLevel level = cte.hasAuthoritativeOutputColumn(columnName); 965 if (level != ColumnLevel.NOT_EXISTS) return null; 966 967 String cteName = cte.getDisplayName(); 968 if (cteName == null || cteName.isEmpty()) { 969 cteName = qualifier; 970 } 971 return BindingDiagnosticBuilder 972 .forCode(BindingDiagnosticCode.CTE_OUTPUT_COLUMN_MISSING) 973 .severity(severityFor(BindingDiagnosticCode.CTE_OUTPUT_COLUMN_MISSING)) 974 .message("Column " + ref.toString() 975 + " is not exposed by CTE " + cteName + ".") 976 .objectNameText(ref.toString()) 977 .site(siteFor(ref)) 978 .statement(statementFor(ref, sbr)) 979 .build(); 980 } 981 982 private BindingDiagnostic buildSubqueryOutputDiagnostic(TObjectName ref, 983 ScopeBuildResult sbr) { 984 if (ref == null || sbr == null) return null; 985 986 String columnName = ref.getColumnNameOnly(); 987 if (columnName == null || columnName.isEmpty() 988 || isStarReference(ref, columnName)) { 989 return null; 990 } 991 992 String qualifier = qualifierOf(ref); 993 if (qualifier == null || qualifier.isEmpty()) return null; 994 995 IScope scope = sbr.getScopeForColumn(ref); 996 INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null; 997 if (!(ns instanceof SubqueryNamespace)) return null; 998 999 SubqueryNamespace subquery = (SubqueryNamespace) ns; 1000 ColumnLevel level = subquery.hasAuthoritativeOutputColumn(columnName); 1001 if (level != ColumnLevel.NOT_EXISTS) return null; 1002 1003 String displayName = subquery.getDisplayName(); 1004 if (displayName == null || displayName.isEmpty()) { 1005 displayName = qualifier; 1006 } 1007 return BindingDiagnosticBuilder 1008 .forCode(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING) 1009 .severity(severityFor(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING)) 1010 .message("Column " + ref.toString() 1011 + " is not exposed by derived table " + displayName + ".") 1012 .objectNameText(ref.toString()) 1013 .site(siteFor(ref)) 1014 .statement(statementFor(ref, sbr)) 1015 .build(); 1016 } 1017 1018 /** 1019 * For strict mode: checks a qualified reference's namespace state even when 1020 * the resolver produced an exact-match result (inferred at low confidence 1021 * against a NOT_FOUND_IN_CATALOG or METADATA_UNAVAILABLE table). 1022 * Returns a diagnostic when the namespace warrants one, null otherwise. 1023 */ 1024 private BindingDiagnostic buildStrictModeNamespaceDiagnostic(TObjectName ref, 1025 ScopeBuildResult sbr) { 1026 String qualifier = qualifierOf(ref); 1027 if (qualifier == null || qualifier.isEmpty()) return null; 1028 IScope scope = sbr.getScopeForColumn(ref); 1029 INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null; 1030 if (ns == null) return null; 1031 MetadataState state = ns.getMetadataState(); 1032 if (state == MetadataState.NOT_FOUND_IN_CATALOG) { 1033 return BindingDiagnosticBuilder 1034 .forCode(BindingDiagnosticCode.UNKNOWN_TABLE) 1035 .severity(severityFor(BindingDiagnosticCode.UNKNOWN_TABLE)) 1036 .message("Table " + qualifier + " was not found in the catalog.") 1037 .objectNameText(ref.toString()) 1038 .site(siteFor(ref)) 1039 .statement(statementFor(ref, sbr)) 1040 .build(); 1041 } 1042 if (state == MetadataState.METADATA_UNAVAILABLE) { 1043 return BindingDiagnosticBuilder 1044 .forCode(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE) 1045 .severity(severityFor(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE)) 1046 .message("Catalog metadata is unavailable for table " + qualifier + ".") 1047 .objectNameText(ref.toString()) 1048 .site(siteFor(ref)) 1049 .statement(statementFor(ref, sbr)) 1050 .build(); 1051 } 1052 return null; 1053 } 1054 1055 /** 1056 * Slice S16c (plan §5.6.2): verify a resolver2 {@link 1057 * gudusoft.gsqlparser.resolver2.ResolutionStatus#EXACT_MATCH} against 1058 * authoritative catalog metadata. 1059 * 1060 * <p>Resolver2's exact match proves only that SQL scope binding selected 1061 * one source; the column may still be authoritatively absent from that 1062 * source's catalog metadata. The post-pass therefore reads the resolver2- 1063 * selected {@link INamespace} out of the recorded {@link ResolutionResult} 1064 * and asks {@link BindingMetadataAuthority#lookup(INamespace, String)} 1065 * only — it never re-binds names, searches SQL scope, or mutates AST 1066 * state.</p> 1067 * 1068 * <ul> 1069 * <li>{@link ColumnAuthority#AUTHORITATIVE_PRESENT} — keep the resolver2 1070 * binding; emit no diagnostic. Successful references picked up 1071 * upstream still flow.</li> 1072 * <li>{@link ColumnAuthority#AUTHORITATIVE_ABSENT} — emit 1073 * {@link BindingDiagnosticCode#UNKNOWN_COLUMN} against the 1074 * resolver2-selected source. Resolver2's binding state is NOT 1075 * mutated.</li> 1076 * <li>{@link ColumnAuthority#METADATA_UNAVAILABLE} — return null. Strict 1077 * mode's {@link #buildStrictModeNamespaceDiagnostic} may then emit 1078 * {@link BindingDiagnosticCode#CATALOG_METADATA_UNAVAILABLE} based on 1079 * namespace state; non-strict mode stays silent.</li> 1080 * </ul> 1081 * 1082 * <p>Star references (bare {@code *} or {@code t.*}) are wildcard 1083 * expansion sites, not column lookups, and are skipped here even if the 1084 * resolver tagged them as {@code EXACT_MATCH}.</p> 1085 * 1086 * <p>The column-name compare flows through 1087 * {@link BindingMetadataAuthority#lookup} → {@link INamespace#hasColumn} 1088 * → vendor {@link gudusoft.gsqlparser.resolver2.matcher.INameMatcher} 1089 * (typically {@code VendorNameMatcher} when the parser auto-wires the 1090 * resolver), so per-dialect quoted-vs-unquoted rules are honored without 1091 * any bespoke string compare here. Quotes on the column token are 1092 * preserved (we never call {@code stripQuotes}).</p> 1093 */ 1094 private BindingDiagnostic verifyExactMatchAgainstCatalog(TObjectName ref, 1095 ResolutionResult result, 1096 ScopeBuildResult sbr) { 1097 if (ref == null || result == null) return null; 1098 1099 String columnName = ref.getColumnNameOnly(); 1100 if (columnName == null || columnName.isEmpty()) return null; 1101 1102 // Star references are not column lookups — skip catalog verification. 1103 if (isStarReference(ref, columnName)) return null; 1104 1105 ColumnSource cs = result.getColumnSource(); 1106 if (cs == null) return null; 1107 INamespace ns = cs.getSourceNamespace(); 1108 if (ns == null) return null; 1109 1110 ColumnAuthority verdict = BindingMetadataAuthority.lookup(ns, columnName); 1111 if (verdict != ColumnAuthority.AUTHORITATIVE_ABSENT) { 1112 // PRESENT → resolver2 binding catalog-verified; no diagnostic. 1113 // METADATA_UNAVAILABLE → defer to strict-mode namespace handler. 1114 return null; 1115 } 1116 1117 String tableLabel = describeTable(ns); 1118 return BindingDiagnosticBuilder 1119 .forCode(BindingDiagnosticCode.UNKNOWN_COLUMN) 1120 .severity(severityFor(BindingDiagnosticCode.UNKNOWN_COLUMN)) 1121 .message("Column " + ref.toString() 1122 + " was not found in catalog table " + tableLabel + ".") 1123 .objectNameText(ref.toString()) 1124 .site(siteFor(ref)) 1125 .statement(statementFor(ref, sbr)) 1126 .build(); 1127 } 1128 1129 /** 1130 * S13 — DML target column entries are column DECLARATIONS, not column 1131 * references. They must never trigger {@code UNKNOWN_COLUMN}, 1132 * {@code UNBOUND_COLUMN_REFERENCE}, or any other binding diagnostic 1133 * regardless of whether the column exists in the catalog (plan §7.3 S13: 1134 * "target columns produce zero diagnostics"). 1135 * 1136 * <p>Detection is via {@link ESqlClause} location stamped by the 1137 * statement's {@code doParseStatement} when populating the target column 1138 * list:</p> 1139 * 1140 * <ul> 1141 * <li>{@link ESqlClause#insertColumn} — {@code INSERT INTO t (a,b,c)} target 1142 * columns.</li> 1143 * <li>{@link ESqlClause#mergeInsert} — {@code MERGE … WHEN NOT MATCHED THEN 1144 * INSERT (a,b,c)} target columns.</li> 1145 * <li>{@link ESqlClause#set} — {@code UPDATE … SET col = …} LHS targets 1146 * (and the equivalent {@code MERGE … WHEN MATCHED THEN UPDATE SET} 1147 * LHS in dialects where it inherits the {@code set} location). 1148 * The legacy linker pre-resolves these against the target table so 1149 * they are usually silent already, but tagging here records 1150 * {@link BindingSkipReason#TARGET_COLUMN} explicitly so consumers 1151 * reading {@code ResolutionContext} directly see intent rather 1152 * than inferring it from absence.</li> 1153 * </ul> 1154 * 1155 * <p>RHS expressions ({@link ESqlClause#setValue}) are NOT skipped — they 1156 * are bona-fide value references and continue to emit when missing.</p> 1157 */ 1158 private static boolean isDmlTargetColumn(TObjectName ref) { 1159 if (ref == null) return false; 1160 ESqlClause loc = ref.getLocation(); 1161 return loc == ESqlClause.insertColumn 1162 || loc == ESqlClause.mergeInsert 1163 || loc == ESqlClause.set; 1164 } 1165 1166 private boolean isCreateTableColumnDefinition(TObjectName ref) { 1167 if (ref == null || resolver == null 1168 || resolver.getStatements() == null) { 1169 return false; 1170 } 1171 TStatementList statements = resolver.getStatements(); 1172 for (int statementIndex = 0; 1173 statementIndex < statements.size(); statementIndex++) { 1174 TCustomSqlStatement statement = 1175 statements.get(statementIndex); 1176 if (!(statement instanceof TCreateTableSqlStatement)) { 1177 continue; 1178 } 1179 TColumnDefinitionList columns = 1180 ((TCreateTableSqlStatement) statement).getColumnList(); 1181 if (columns == null) { 1182 continue; 1183 } 1184 for (int columnIndex = 0; 1185 columnIndex < columns.size(); columnIndex++) { 1186 TColumnDefinition column = columns.getColumn(columnIndex); 1187 if (column != null && column.getColumnName() == ref) { 1188 return true; 1189 } 1190 } 1191 } 1192 return false; 1193 } 1194 1195 private static boolean isStarReference(TObjectName ref, String columnName) { 1196 if ("*".equals(columnName)) { 1197 return true; 1198 } 1199 if (ref == null) return false; 1200 String text = ref.toString(); 1201 return text != null && text.endsWith(".*"); 1202 } 1203 1204 private static String qualifierOf(TObjectName ref) { 1205 if (ref == null) return null; 1206 String tbl = ref.getTableString(); 1207 if (tbl != null && !tbl.isEmpty()) { 1208 // Slice S15: preserve quotes so downstream {@link 1209 // IScope#resolveTable(String)} (now matcher-routed in 1210 // ListBasedScope) can apply per-dialect quoted-vs-unquoted alias 1211 // rules (e.g. Oracle quoted alias is case-sensitive, unquoted 1212 // folds to upper). Stripping quotes here drops that distinction. 1213 return tbl; 1214 } 1215 return null; 1216 } 1217 1218 private static boolean hasInScopeTable(IScope scope) { 1219 if (scope == null) return false; 1220 try { 1221 List<INamespace> visible = scope.getVisibleNamespaces(); 1222 return visible != null && !visible.isEmpty(); 1223 } catch (Throwable ignore) { 1224 return false; 1225 } 1226 } 1227 1228 private static String describeTable(INamespace ns) { 1229 if (ns == null) return "<unknown>"; 1230 if (ns.getSourceTable() != null && ns.getSourceTable().getName() != null) { 1231 return ns.getSourceTable().getName().toString(); 1232 } 1233 if (ns.getFinalTable() != null && ns.getFinalTable().getName() != null) { 1234 return ns.getFinalTable().getName().toString(); 1235 } 1236 String display = ns.getDisplayName(); 1237 return (display == null || display.isEmpty()) ? "<unknown>" : display; 1238 } 1239 1240 // ===== shared ===== 1241 1242 private BindingDiagnosticSeverity severityFor(BindingDiagnosticCode code) { 1243 if (config == null) { 1244 return code.defaultSeverity(); 1245 } 1246 BindingDiagnosticSeverity sev = config.getBindingSeverityFor(code); 1247 return sev != null ? sev : code.defaultSeverity(); 1248 } 1249 1250 private BindingReferenceSite siteFor(TObjectName ref) { 1251 String text = ref.toString(); 1252 if (text == null || text.isEmpty()) { 1253 text = "<unknown>"; 1254 } 1255 BindingClause clause = mapper.map(ref); 1256 return BindingReferenceSite.builder() 1257 .clause(clause) 1258 .referenceText(text) 1259 .build(); 1260 } 1261 1262 private static TCustomSqlStatement statementFor(TObjectName ref, ScopeBuildResult sbr) { 1263 if (sbr == null || ref == null) return null; 1264 IScope scope = sbr.getScopeForColumn(ref); 1265 while (scope != null) { 1266 TParseTreeNode node = scope.getNode(); 1267 if (node instanceof TCustomSqlStatement) { 1268 return (TCustomSqlStatement) node; 1269 } 1270 if (node instanceof TSelectSqlStatement) { 1271 return (TCustomSqlStatement) node; 1272 } 1273 IScope parent = scope.getParent(); 1274 if (parent == scope) break; 1275 scope = parent; 1276 } 1277 return null; 1278 } 1279 1280 private BindingReference buildReference(TObjectName ref, 1281 ScopeBuildResult sbr, 1282 boolean bound) { 1283 if (ref == null) return null; 1284 String text = ref.toString(); 1285 if (text == null || text.isEmpty()) return null; 1286 BindingReferenceKind kind = ref.toString().endsWith("*") 1287 ? BindingReferenceKind.STAR 1288 : BindingReferenceKind.COLUMN; 1289 return BindingReference.builder() 1290 .kind(kind) 1291 .objectNameText(text) 1292 .site(siteFor(ref)) 1293 .bound(bound) 1294 .build(); 1295 } 1296}