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}