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}