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