001package gudusoft.gsqlparser.ir.semantic; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.EResolverType; 005import gudusoft.gsqlparser.TCustomSqlStatement; 006import gudusoft.gsqlparser.TGSqlParser; 007import gudusoft.gsqlparser.TStatementList; 008import gudusoft.gsqlparser.ir.semantic.binding.RelationBinding; 009import gudusoft.gsqlparser.ir.semantic.binding.Resolver2NameBindingProvider; 010import gudusoft.gsqlparser.ir.semantic.builder.SemanticIRBuilder; 011import gudusoft.gsqlparser.ir.semantic.builder.SemanticIRBuilder.SemanticIRBuildException; 012import gudusoft.gsqlparser.ir.semantic.catalog.Catalog; 013import gudusoft.gsqlparser.ir.semantic.catalog.CatalogColumn; 014import gudusoft.gsqlparser.ir.semantic.catalog.CatalogTable; 015import gudusoft.gsqlparser.ir.semantic.export.SemanticIRJsonExporter; 016import gudusoft.gsqlparser.sqlenv.TSQLEnv; 017import gudusoft.gsqlparser.sqlenv.TSQLTable; 018import gudusoft.gsqlparser.stmt.TCreateTableSqlStatement; 019import gudusoft.gsqlparser.stmt.TCreateViewSqlStatement; 020import gudusoft.gsqlparser.stmt.TDeleteSqlStatement; 021import gudusoft.gsqlparser.stmt.TInsertSqlStatement; 022import gudusoft.gsqlparser.stmt.TMergeSqlStatement; 023import gudusoft.gsqlparser.stmt.TSelectSqlStatement; 024import gudusoft.gsqlparser.stmt.TUpdateSqlStatement; 025 026import java.util.ArrayList; 027import java.util.Collections; 028import java.util.HashSet; 029import java.util.List; 030import java.util.Locale; 031import java.util.Set; 032 033/** 034 * Public service wrapper for the Semantic IR pipeline. Given a SQL 035 * string and a vendor (and optionally a catalog), returns an 036 * {@link AnalysisResult} carrying the built {@link SemanticProgram}, 037 * its JSON encoding, and any {@link Diagnostic}s emitted during 038 * analysis. 039 * 040 * <p>Slice 75 introduced the packaging surface; slice 76 added the 041 * {@link Catalog} DTO overload so external callers can supply catalog 042 * metadata without depending on the internal 043 * {@link gudusoft.gsqlparser.sqlenv.TSQLEnv} type. Neither slice 044 * changes semantic behaviour — the pipeline is: 045 * <ol> 046 * <li>{@link TGSqlParser} with {@link EResolverType#RESOLVER2};</li> 047 * <li>{@link SemanticIRBuilder#build} with a 048 * {@link Resolver2NameBindingProvider} (wired to the catalog 049 * when one is supplied);</li> 050 * <li>{@link SemanticIRJsonExporter#toJson} for the JSON form.</li> 051 * </ol> 052 * 053 * <p>Slice 75 supports a single {@code SELECT} statement per 054 * {@link #analyze} call. Parse failures, multi-statement input, 055 * non-{@code SELECT} input, and builder rejections all surface as 056 * structured {@link Diagnostic}s on the result (never as exceptions 057 * for the documented failure modes). Unexpected runtime failures 058 * inside the analyzer propagate as {@link RuntimeException}s so 059 * bugs fail loudly rather than being silently bound to a stable 060 * diagnostic message. 061 * 062 * <p>External callers should branch on 063 * {@link AnalysisResult#isSuccessful()} and pattern-match on 064 * {@link Diagnostic#getCode()} ({@link DiagnosticCode} is the 065 * stable contract); message text remains user-visible English and 066 * may change without notice. 067 * 068 * <p><b>Overload-resolution note.</b> The two 3-arg overloads accept 069 * {@link Catalog} or {@link TSQLEnv}; the types are unrelated, so 070 * passing a literal {@code null} third argument is ambiguous at 071 * compile time. Callers who have no catalog should use the 2-arg 072 * {@link #analyze(String, EDbVendor)} overload. 073 * 074 * <p>This class is stateless and its static methods are safe to 075 * call from multiple threads. 076 */ 077public final class SqlSemanticAnalyzer { 078 079 /** 080 * Current JSON schema version emitted by this analyzer. 081 * 082 * <p>Implemented as a method (not a {@code public static final 083 * String} constant) so binary consumers compiled against one 084 * version of this library do NOT silently see the old value 085 * after a drop-in JAR upgrade — Java compile-time inlining of 086 * String constants would otherwise break the version contract 087 * (codex diff-review round-1 Q3). 088 * 089 * <p>The returned value is always equal to 090 * {@link SemanticIRJsonExporter#SCHEMA_VERSION}. 091 */ 092 public static String schemaVersion() { 093 return SemanticIRJsonExporter.SCHEMA_VERSION; 094 } 095 096 private SqlSemanticAnalyzer() { 097 // utility — no instances 098 } 099 100 /** 101 * Convenience overload — equivalent to {@code analyze(sql, vendor, (TSQLEnv) null)}. 102 * 103 * <p>Use this when no catalog metadata is available. Slice 76 104 * extracted the no-catalog delegation through a private helper so 105 * the 2-arg call remains unambiguous after the {@link Catalog} 106 * overload was added. 107 */ 108 public static AnalysisResult analyze(String sql, EDbVendor vendor) { 109 return analyzeInternal(sql, vendor, (TSQLEnv) null); 110 } 111 112 /** 113 * Analyze a single {@code SELECT} statement using catalog metadata 114 * supplied as a {@link TSQLEnv}. 115 * 116 * <p>Slice 75's original catalog entry point. Retained as the 117 * authoritative pipeline input — slice 76's {@link Catalog} overload 118 * bridges to a generated {@code TSQLEnv} internally so identifier 119 * semantics (case folding, qualifier expansion, vendor rules) flow 120 * through one code path only. 121 * 122 * <p>Failure modes that return a structured result (never throw): 123 * <ul> 124 * <li>parse failure ({@code rc != 0}) → 125 * {@link DiagnosticCode#PARSE_FAILED};</li> 126 * <li>empty statement list (parse rc=0 but no statements) → 127 * {@link DiagnosticCode#PARSE_FAILED};</li> 128 * <li>more than one statement → 129 * {@link DiagnosticCode#MULTIPLE_STATEMENTS_NOT_SUPPORTED};</li> 130 * <li>first statement is not a {@link TSelectSqlStatement} → 131 * {@link DiagnosticCode#STATEMENT_KIND_NOT_SUPPORTED};</li> 132 * <li>builder rejection 133 * ({@link SemanticIRBuildException}) → the diagnostic from 134 * the exception.</li> 135 * </ul> 136 * 137 * <p>Genuine programming errors throw 138 * {@link IllegalArgumentException} (null {@code sql} or null 139 * {@code vendor}). Unexpected {@link RuntimeException}s from the 140 * builder propagate to the caller. 141 * 142 * @param sql non-null SQL text (single statement; trailing 143 * statements yield {@link DiagnosticCode#MULTIPLE_STATEMENTS_NOT_SUPPORTED}) 144 * @param vendor non-null vendor enum 145 * @param catalog optional {@link TSQLEnv} for catalog-backed 146 * resolution; pass {@code null} to disable 147 * catalog lookups (star expansion and similar 148 * catalog-required features will be rejected 149 * with their existing slice-58 / 66 diagnostics). 150 * See the overload-resolution note in the class 151 * javadoc — to pass a literal null, use the 2-arg 152 * {@link #analyze(String, EDbVendor)} overload 153 * instead. 154 * @return immutable result with program, json, and diagnostics 155 */ 156 public static AnalysisResult analyze(String sql, EDbVendor vendor, 157 TSQLEnv catalog) { 158 return analyzeInternal(sql, vendor, catalog); 159 } 160 161 /** 162 * Slice 76 — analyze a single {@code SELECT} statement using 163 * catalog metadata supplied as a {@link Catalog} DTO instead of a 164 * {@code TSQLEnv}. 165 * 166 * <p>The DTO is converted to a {@code TSQLEnv} internally; all 167 * resolver / identifier behaviour is identical to the 168 * {@code TSQLEnv}-flavored overload. The point of this overload is 169 * purely to keep the internal {@code TSQLEnv} type off the public 170 * API surface for external callers. 171 * 172 * <p>A {@code null} {@code Catalog} is treated as "no catalog" 173 * (equivalent to the 2-arg {@link #analyze(String, EDbVendor)} 174 * overload). Note that to pass a literal {@code null} you must use 175 * the 2-arg overload — see the overload-resolution note in the 176 * class javadoc. 177 * 178 * @param sql non-null SQL text 179 * @param vendor non-null vendor enum 180 * @param catalog optional DTO catalog (may be {@code null} when the 181 * caller has already typed the reference as 182 * {@code Catalog}) 183 * @return immutable result with program, json, and diagnostics 184 * @since slice 76 185 */ 186 public static AnalysisResult analyze(String sql, EDbVendor vendor, 187 Catalog catalog) { 188 // Validate sql/vendor first so the no-catalog branch matches 189 // the 2-arg overload's contract exactly (null sql/vendor throws 190 // BEFORE we touch the catalog). 191 if (sql == null) { 192 throw new IllegalArgumentException("sql must not be null"); 193 } 194 if (vendor == null) { 195 throw new IllegalArgumentException("vendor must not be null"); 196 } 197 TSQLEnv env = (catalog == null) ? null : bridgeToTSQLEnv(catalog, vendor); 198 AnalysisResult base = analyzeInternal(sql, vendor, env); 199 // Slice 77 — catalog-miss WARN diagnostics. The walk only fires 200 // when (a) a non-null Catalog was supplied (this overload only), 201 // (b) the analyzer otherwise succeeded (program/JSON built; no 202 // ERROR-severity diagnostics). The TSQLEnv-flavored overload 203 // does NOT route through this walk — pre-slice-76 callers do 204 // not observe a behavior change. 205 if (catalog == null || !base.isSuccessful()) { 206 return base; 207 } 208 List<Diagnostic> warnings = collectCatalogMissWarnings( 209 base.getProgram(), catalog); 210 if (warnings.isEmpty()) { 211 return base; 212 } 213 List<Diagnostic> merged = new ArrayList<>( 214 base.getDiagnostics().size() + warnings.size()); 215 merged.addAll(base.getDiagnostics()); 216 merged.addAll(warnings); 217 return new AnalysisResult(base.getSchemaVersion(), base.getProgram(), 218 base.getJson(), merged); 219 } 220 221 /** 222 * Slice 76 internal pipeline. The three public overloads delegate 223 * here with explicit typing to defeat overload-resolution 224 * ambiguity for {@code null} arguments (codex plan-review round 2). 225 */ 226 private static AnalysisResult analyzeInternal(String sql, EDbVendor vendor, 227 TSQLEnv catalog) { 228 // IllegalArgumentException — not NPE — matches the javadoc 229 // contract; codex diff-review (slice 75) round-1 Q4 flagged 230 // Objects.requireNonNull as enshrining the wrong exception 231 // type in the public API. 232 if (sql == null) { 233 throw new IllegalArgumentException("sql must not be null"); 234 } 235 if (vendor == null) { 236 throw new IllegalArgumentException("vendor must not be null"); 237 } 238 239 TGSqlParser parser = new TGSqlParser(vendor); 240 parser.setResolverType(EResolverType.RESOLVER2); 241 if (catalog != null) { 242 parser.setSqlEnv(catalog); 243 } 244 parser.sqltext = sql; 245 246 int rc = parser.parse(); 247 if (rc != 0) { 248 String errorMessage = parser.getErrormessage(); 249 if (errorMessage == null || errorMessage.isEmpty()) { 250 errorMessage = "parser returned non-zero rc=" + rc; 251 } 252 return rejectionResult(Diagnostic.error( 253 DiagnosticCode.PARSE_FAILED, 254 "SQL parse failed: " + errorMessage)); 255 } 256 257 TStatementList stmts = parser.sqlstatements; 258 if (stmts == null || stmts.size() == 0) { 259 return rejectionResult(Diagnostic.error( 260 DiagnosticCode.PARSE_FAILED, 261 "SQL parse returned no statements")); 262 } 263 if (stmts.size() > 1) { 264 return rejectionResult(Diagnostic.error( 265 DiagnosticCode.MULTIPLE_STATEMENTS_NOT_SUPPORTED, 266 "SqlSemanticAnalyzer.analyze supports exactly one " 267 + "statement per call; received " + stmts.size())); 268 } 269 270 TCustomSqlStatement first = stmts.get(0); 271 // Slice 78 — admit single-target INSERT INTO target SELECT in 272 // addition to standalone SELECT. Slice 79 (D3) adds CTAS 273 // (TCreateTableSqlStatement) and CREATE VIEW 274 // (TCreateViewSqlStatement). Slice 80 (D9) adds single-target 275 // UPDATE (TUpdateSqlStatement). Slice 81 (D11) adds single- 276 // target DELETE (TDeleteSqlStatement). Slice 94 (D6) adds 277 // single-target MERGE (TMergeSqlStatement). The builders 278 // themselves reject VALUES / DEFAULT VALUES / Oracle INSERT 279 // ALL / Hive multi-insert with structured INSERT_* codes; 280 // CREATE TABLE / CREATE VIEW without AS SELECT reject with 281 // CREATE_AS_NO_SOURCE_SELECT; joined / cross-table UPDATE and 282 // other UPDATE shapes reject with UPDATE_* codes; joined / 283 // multi-target DELETE and other DELETE shapes reject with 284 // DELETE_* codes; MERGE shapes outside the slice-94 skeleton 285 // reject with MERGE_* codes. Remaining DDL (CREATE INDEX, 286 // CREATE PROCEDURE, etc.) still reject here with 287 // STATEMENT_KIND_NOT_SUPPORTED until a later slice. 288 boolean isSelect = first instanceof TSelectSqlStatement; 289 boolean isInsert = first instanceof TInsertSqlStatement; 290 boolean isCreateTable = first instanceof TCreateTableSqlStatement; 291 boolean isCreateView = first instanceof TCreateViewSqlStatement; 292 boolean isUpdate = first instanceof TUpdateSqlStatement; 293 boolean isDelete = first instanceof TDeleteSqlStatement; 294 boolean isMerge = first instanceof TMergeSqlStatement; 295 if (!isSelect && !isInsert && !isCreateTable && !isCreateView 296 && !isUpdate && !isDelete && !isMerge) { 297 return rejectionResult(Diagnostic.error( 298 DiagnosticCode.STATEMENT_KIND_NOT_SUPPORTED, 299 "SqlSemanticAnalyzer.analyze supports SELECT, INSERT, " 300 + "UPDATE, DELETE, MERGE, CREATE TABLE AS SELECT, " 301 + "and CREATE VIEW AS SELECT; received " 302 + first.getClass().getSimpleName())); 303 } 304 305 Resolver2NameBindingProvider provider = (catalog != null) 306 ? new Resolver2NameBindingProvider(catalog) 307 : new Resolver2NameBindingProvider(); 308 309 SemanticProgram program; 310 try { 311 if (isSelect) { 312 program = SemanticIRBuilder.build((TSelectSqlStatement) first, provider); 313 } else if (isInsert) { 314 program = SemanticIRBuilder.buildInsert((TInsertSqlStatement) first, provider); 315 } else if (isCreateTable) { 316 program = SemanticIRBuilder.buildCreateTable( 317 (TCreateTableSqlStatement) first, provider); 318 } else if (isCreateView) { 319 program = SemanticIRBuilder.buildCreateView( 320 (TCreateViewSqlStatement) first, provider); 321 } else if (isUpdate) { 322 program = SemanticIRBuilder.buildUpdate( 323 (TUpdateSqlStatement) first, provider); 324 } else if (isDelete) { 325 program = SemanticIRBuilder.buildDelete( 326 (TDeleteSqlStatement) first, provider); 327 } else { 328 program = SemanticIRBuilder.buildMerge( 329 (TMergeSqlStatement) first, provider); 330 } 331 } catch (SemanticIRBuildException ex) { 332 return rejectionResult(ex.getDiagnostic()); 333 } 334 335 String json = SemanticIRJsonExporter.toJson(program); 336 return new AnalysisResult(schemaVersion(), program, json, 337 Collections.<Diagnostic>emptyList()); 338 } 339 340 /** 341 * Slice 76 bridge — translate a {@link Catalog} DTO into a fresh 342 * {@link TSQLEnv} so the rest of the pipeline (resolver, 343 * identifier service, star expansion via {@code searchTable}) is 344 * unchanged. 345 * 346 * <p>Allocates a new anonymous {@code TSQLEnv} subclass with an 347 * empty {@code initSQLEnv()} for each call — codex round-1 Q5 348 * verdict: rebuild on every call. The bridge is cheap (one 349 * subclass instantiation + N table registrations), Catalog 350 * instances are small, and memoization would introduce 351 * thread-safety / stale-state risks for no measurable gain. 352 */ 353 private static TSQLEnv bridgeToTSQLEnv(Catalog catalog, EDbVendor vendor) { 354 TSQLEnv env = new TSQLEnv(vendor) { 355 @Override public void initSQLEnv() {} 356 }; 357 for (CatalogTable table : catalog.getTables()) { 358 // fromDDL=false so TSQLEnv.addTable does not gate the 359 // registration behind isEnableGetMetadataFromDDL(). 360 TSQLTable tbl = env.addTable(table.getName(), false); 361 if (tbl == null) { 362 // Defensive: TSQLEnv.addTable returns null only when 363 // fromDDL=true AND enableGetMetadataFromDDL=false; we 364 // pass fromDDL=false so this path should be 365 // unreachable. Skip silently to keep slice 76 a 366 // pure-packaging slice. 367 continue; 368 } 369 for (CatalogColumn column : table.getColumns()) { 370 tbl.addColumn(column.getName()); 371 } 372 } 373 return env; 374 } 375 376 /** 377 * Slice 79 — kind-aware WARN message for a missing-target catalog 378 * miss. Mirrors the slice-77 FROM-relation walk's wording style. 379 * Falls back to a generic "target relation '...'" label when the 380 * statement kind is not one of the known write-side kinds. 381 * Slice 80 adds the {@code "UPDATE"} branch; slice 81 adds the 382 * {@code "DELETE"} branch alongside the existing INSERT / 383 * CREATE_TABLE / CREATE_VIEW / UPDATE kinds. Slice 94 adds the 384 * {@code "MERGE"} branch. 385 */ 386 private static String targetWarnMessage(String stmtKind, String relName) { 387 String label; 388 if ("INSERT".equals(stmtKind)) { 389 label = "INSERT target"; 390 } else if ("CREATE_TABLE".equals(stmtKind)) { 391 label = "CTAS target"; 392 } else if ("CREATE_VIEW".equals(stmtKind)) { 393 label = "CREATE VIEW target"; 394 } else if ("UPDATE".equals(stmtKind)) { 395 label = "UPDATE target"; 396 } else if ("DELETE".equals(stmtKind)) { 397 label = "DELETE target"; 398 } else if ("MERGE".equals(stmtKind)) { 399 label = "MERGE target"; 400 } else { 401 // Defensive — only INSERT / CREATE_TABLE / CREATE_VIEW / 402 // UPDATE / DELETE / MERGE statements set target on 403 // StatementGraph, but future statement kinds may also 404 // carry one. 405 label = "target"; 406 } 407 return label + " relation '" + relName 408 + "' is not declared in the supplied catalog"; 409 } 410 411 private static AnalysisResult rejectionResult(Diagnostic d) { 412 List<Diagnostic> list = new ArrayList<>(1); 413 list.add(d); 414 return new AnalysisResult(schemaVersion(), null, null, list); 415 } 416 417 /** 418 * Slice 77 — walk the built {@link SemanticProgram} and report 419 * FROM relations that the supplied {@link Catalog} DTO does not 420 * declare. The walk satisfies the relation half of §8.1.4 row C3's 421 * "missing table/column can be diagnosed" requirement. 422 * 423 * <p>Column-miss is intentionally NOT a slice-77 WARN: when the 424 * catalog declares the relation but not a referenced column, the 425 * {@code TSQLResolver2} pipeline already rejects with 426 * {@link DiagnosticCode#COLUMN_BINDING_NON_EXACT} (the resolver's 427 * NOT_FOUND status surfaces as that ERROR). Callers can pattern- 428 * match on {@code COLUMN_BINDING_NON_EXACT} with a non-null 429 * {@code Catalog} supplied to detect that case; lifting it to a 430 * WARN would require relaxing the resolver, which is a larger 431 * design change deferred to a future slice. 432 * 433 * <p>Emitted relation-miss diagnostics are {@link Severity#WARN 434 * WARN}-severity so {@link AnalysisResult#isSuccessful()} continues 435 * to return {@code true}; consumers pattern-match on 436 * {@link DiagnosticCode#RELATION_NOT_FOUND_IN_CATALOG}. 437 * 438 * <p>Matching rules: 439 * <ul> 440 * <li>Only {@link RelationKind#TABLE} relations are checked. 441 * CTE / SUBQUERY / UNION / OUTER_REFERENCE bindings have no 442 * catalog presence by construction.</li> 443 * <li>Relation match: case-insensitive bare-name on 444 * {@link RelationBinding#getQualifiedName()}. The bridge in 445 * {@link #bridgeToTSQLEnv} routes through TSQLEnv identifier 446 * folding, and {@code Resolver2NameBindingProvider.bindRelation} 447 * returns the AST spelling — case-insensitive matching keeps 448 * the WARN consistent with the resolver's actual lookup.</li> 449 * <li>Dedup: same missing relation referenced from multiple 450 * statements emits one warning.</li> 451 * </ul> 452 * 453 * <p>Each {@link StatementGraph} in {@link SemanticProgram#getStatements()} 454 * is walked once at the top level; CTE / scalar-subquery / 455 * set-op-branch bodies are emitted as their own statements before 456 * the outer SELECT (see {@code SemanticIRBuilder}'s extraction 457 * helpers), so a single flat iteration here covers every built 458 * statement. 459 */ 460 private static List<Diagnostic> collectCatalogMissWarnings( 461 SemanticProgram program, Catalog catalog) { 462 if (program == null || catalog == null) { 463 return Collections.emptyList(); 464 } 465 // Build a case-insensitive bare-name index of the catalog DTO 466 // once per analyze() call. First-match wins on duplicate names 467 // (matches Catalog.findTable's first-match contract; the DTO 468 // itself does not currently enforce table-name uniqueness). 469 Set<String> tableNameKeys = new HashSet<>(); 470 for (CatalogTable t : catalog.getTables()) { 471 if (t != null) { 472 tableNameKeys.add(t.getName().toLowerCase(Locale.ROOT)); 473 } 474 } 475 List<Diagnostic> out = new ArrayList<>(); 476 // Dedup ACROSS the whole program: a CTE body and the outer 477 // SELECT both referencing the same missing relation should not 478 // double-fire. Keys are case-insensitive on the qualifier. 479 Set<String> emitted = new HashSet<>(); 480 // Slice 83 — two-pass walk to generalise slice 82's 481 // within-statement target-before-relations ordering to 482 // cross-statement ordering. Pass 1 walks every statement's 483 // target; pass 2 walks every statement's TABLE-kind relations. 484 // The flat `emitted` set carries dedup across passes so a 485 // target-side miss always shadows a same-named FROM-side miss, 486 // regardless of which statement carries the FROM-side miss. 487 // 488 // Concretely: MSSQL `UPDATE t SET ... FROM t JOIN (SELECT c FROM t) sub 489 // ON ...` extracts the inner SELECT as a separate statement with 490 // relations=[t] (TABLE-kind). Without the two-pass walk, the 491 // single-pass would process the SELECT first, emit "FROM 492 // relation 't' …" for t, and then skip the UPDATE-target-`t` 493 // because 't' is already in `emitted`. The kind-aware 494 // "UPDATE target relation 't' …" message would never fire. 495 // 496 // Slices 77-82 invariants preserved by the two-pass walk: 497 // - SELECT has target=null → pass 1 is a no-op for SELECT. 498 // - INSERT / CTAS / CREATE VIEW carry SUBQUERY-kind relations 499 // → skipped by the kind filter in pass 2 (no collision). 500 // - Single-target UPDATE / DELETE (slice 80 / 81) carry 501 // empty relations[] → no collision possible. 502 // - Joined UPDATE (slice 82) within-statement: pass 1 walks 503 // target first, pass 2 walks the same statement's relations 504 // and finds the target's name already in `emitted` → skip. 505 // Identical to slice 82's within-statement ordering. 506 507 // Pass 1: walk every statement's target across the whole 508 // program. A target-side miss wins over a same-named 509 // FROM-side miss in any statement. 510 for (StatementGraph stmt : program.getStatements()) { 511 TargetRelation target = stmt.getTarget(); 512 if (target == null) continue; 513 RelationBinding tb = target.getBinding(); 514 String tn = tb.getQualifiedName(); 515 String tkey = tn.toLowerCase(Locale.ROOT); 516 if (!tableNameKeys.contains(tkey) && emitted.add(tkey)) { 517 out.add(Diagnostic.warn( 518 DiagnosticCode.RELATION_NOT_FOUND_IN_CATALOG, 519 targetWarnMessage(stmt.getKind(), tn))); 520 } 521 } 522 // Pass 2: walk every statement's TABLE-kind relations across 523 // the whole program. Targets emitted in pass 1 dedup these 524 // entries via the shared `emitted` set. 525 for (StatementGraph stmt : program.getStatements()) { 526 for (RelationSource rs : stmt.getRelations()) { 527 RelationBinding b = rs.getBinding(); 528 if (b == null || b.getKind() != RelationKind.TABLE) { 529 continue; 530 } 531 String tableName = b.getQualifiedName(); 532 String key = tableName.toLowerCase(Locale.ROOT); 533 if (tableNameKeys.contains(key)) { 534 continue; 535 } 536 if (emitted.add(key)) { 537 out.add(Diagnostic.warn( 538 DiagnosticCode.RELATION_NOT_FOUND_IN_CATALOG, 539 "FROM relation '" + tableName 540 + "' is not declared in the supplied catalog")); 541 } 542 } 543 } 544 return out; 545 } 546}