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