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