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