001package gudusoft.gsqlparser.dlineage.dynamicsql;
002
003import gudusoft.gsqlparser.DynamicSqlProofHarness;
004import gudusoft.gsqlparser.EDbVendor;
005import gudusoft.gsqlparser.TSourceToken;
006import gudusoft.gsqlparser.nodes.TParseTreeNode;
007import gudusoft.gsqlparser.resolver2.binding.BindingTrace;
008import gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry;
009
010import java.util.ArrayList;
011import java.util.Collections;
012import java.util.List;
013
014/**
015 * Historical per-edge classifier experiment for dynamic SQL (design:
016 * {@code docs/designs/sp/dynamic-sql-fragment-provenance-design.md}, Layer 3 /
017 * R4 step 4).
018 *
019 * <p>This class is not a publication authority. It checks three useful evidence
020 * conditions, but its P2 approximation is known to accept a JOIN/ON-tail
021 * counterexample. A true publication proof additionally requires the LR
022 * commitment check and staged model construction described in
023 * {@code dynamic-sql-commitment-check-design.md}.
024 * The historical conditions are:
025 *
026 * <ul>
027 *   <li><b>P1 literal evidence</b> — every evidence token's character span maps
028 *       to {@link SqlFragment.Kind#LITERAL} fragments (step 1's fragment IR).
029 *       A hole never contributes identifier text, so a fabricated object cannot
030 *       be published by construction.</li>
031 *   <li><b>P2 stability</b> — every evidence token lies before the first hole,
032 *       and the literal prefix up to that hole is a safe lexical boundary
033 *       ({@link DynamicSqlProofHarness#checkLexBoundary}, step 2), and the
034 *       whole materialized text is proof-grade parseable
035 *       ({@link DynamicSqlProofHarness#proofParse}) — no recovery, no syntax
036 *       errors.</li>
037 *   <li><b>P3 binding-chain independence</b> — every evidence reference carries
038 *       a COMPLETE {@link BindingTrace} (step 3). A missing or incomplete trace
039 *       refuses: an omitted binder never looks complete.</li>
040 * </ul>
041 *
042 * <p><b>Phase status: built, unwired.</b> No analysis path calls this yet;
043 * staged publication and a sound LR commitment check are prerequisites for
044 * any filtered publication mode. Its historical P2 token-adjacency check has
045 * a pinned counterexample and must be treated as SHADOW evidence only.
046 */
047public final class DynamicSqlEdgeProof {
048
049    private DynamicSqlEdgeProof() {
050    }
051
052    /** Outcome of proving one candidate edge. */
053    public static final class Verdict {
054        /** Historical classifier acceptance; not permission to publish. */
055        public final boolean publishable;
056        /** Refusal cause, tagged with the failing condition; {@code null} when publishable. */
057        public final String reason;
058        /** Origin of the hole that blocked the edge, when P1/P2 failed on one. */
059        public final SqlFragment.HoleOrigin blockingHole;
060        /** Name of the variable / call / expression behind {@link #blockingHole}. */
061        public final String blockingHoleName;
062        Verdict(boolean publishable, String reason, SqlFragment.HoleOrigin blockingHole,
063                String blockingHoleName) {
064            this.publishable = publishable;
065            this.reason = reason;
066            this.blockingHole = blockingHole;
067            this.blockingHoleName = blockingHoleName;
068        }
069
070        static Verdict publish() {
071            return new Verdict(true, null, null, null);
072        }
073
074        static Verdict refuse(String reason) {
075            return new Verdict(false, reason, null, null);
076        }
077
078        static Verdict refuse(String reason, SqlFragment hole) {
079            return new Verdict(false, reason, hole == null ? null : hole.origin,
080                    hole == null ? null : hole.originName);
081        }
082
083        @Override
084        public String toString() {
085            return publishable ? "publish" : "refuse: " + reason
086                    + (blockingHoleName != null ? " [" + blockingHole + " " + blockingHoleName + "]" : "");
087        }
088    }
089
090    /**
091     * The evidence set of one candidate edge: the AST nodes whose tokens spell
092     * the source object, the target object, and the column references binding
093     * them. Callers assemble it from the sentinel parse; an edge kind whose
094     * evidence cannot be enumerated must not be offered here (fail closed by
095     * not publishing it at all).
096     */
097    public static final class EdgeEvidence {
098        private TParseTreeNode sourceTable;
099        private TParseTreeNode targetTable;
100        private final List<gudusoft.gsqlparser.nodes.TObjectName> references =
101                new ArrayList<gudusoft.gsqlparser.nodes.TObjectName>();
102        private final List<gudusoft.gsqlparser.nodes.TObjectName> sourceSideReferences =
103                new ArrayList<gudusoft.gsqlparser.nodes.TObjectName>();
104        private final List<gudusoft.gsqlparser.nodes.TObjectName> targetSideReferences =
105                new ArrayList<gudusoft.gsqlparser.nodes.TObjectName>();
106        private final List<TParseTreeNode> extraNodes = new ArrayList<TParseTreeNode>();
107
108        /**
109         * The edge's source relation node (a {@code TTable}, an INTO clause,
110         * a derived-table node). Mandatory. Must NOT be a {@code TObjectName} —
111         * names go through {@link #reference} so they cannot skip P3.
112         */
113        public EdgeEvidence source(TParseTreeNode relation) {
114            this.sourceTable = relation;
115            return this;
116        }
117
118        /** The edge's target relation node. Mandatory. Same rule as {@link #source}. */
119        public EdgeEvidence target(TParseTreeNode relation) {
120            this.targetTable = relation;
121            return this;
122        }
123
124        /**
125         * A column reference on the SOURCE side: it must bind to the source
126         * relation. Span-checked (P1/P2) AND binding-checked (P3).
127         */
128        public EdgeEvidence sourceReference(gudusoft.gsqlparser.nodes.TObjectName reference) {
129            if (reference != null) {
130                references.add(reference);
131                sourceSideReferences.add(reference);
132            }
133            return this;
134        }
135
136        /**
137         * A column reference on the TARGET side: it must bind to the target
138         * relation. Span-checked (P1/P2) AND binding-checked (P3).
139         *
140         * <p>Roles are directional on purpose: "binds to either endpoint" would
141         * certify the reverse edge, e.g. {@code source(d).target(t)} over
142         * {@code INSERT INTO d(x) SELECT t.a FROM t}.
143         */
144        public EdgeEvidence targetReference(gudusoft.gsqlparser.nodes.TObjectName reference) {
145            if (reference != null) {
146                references.add(reference);
147                targetSideReferences.add(reference);
148            }
149            return this;
150        }
151
152        /**
153         * An additional structural node whose span must also be literal and
154         * stable (a clause node, a join condition). Names must NOT come in
155         * this way — {@code TObjectName} passed here is rejected, because it
156         * would skip the P3 binding check.
157         */
158        public EdgeEvidence structure(TParseTreeNode node) {
159            if (node != null) {
160                extraNodes.add(node);
161            }
162            return this;
163        }
164
165        /** Roles that must be present for the evidence set to be admissible. */
166        String missingRole() {
167            if (sourceTable == null) {
168                return "source relation";
169            }
170            if (targetTable == null) {
171                return "target relation";
172            }
173            if (sourceSideReferences.isEmpty()) {
174                return "source-side column reference";
175            }
176            // No name may enter through a span-only role: that would skip P3.
177            if (sourceTable instanceof gudusoft.gsqlparser.nodes.TObjectName
178                    || targetTable instanceof gudusoft.gsqlparser.nodes.TObjectName) {
179                return "a name was supplied as a relation endpoint (would skip the binding check)";
180            }
181            for (TParseTreeNode n : extraNodes) {
182                if (n instanceof gudusoft.gsqlparser.nodes.TObjectName) {
183                    return "a name was supplied as structure (would skip the binding check)";
184                }
185            }
186            return null;
187        }
188
189        /** Every node whose span must be literal and stable. */
190        List<TParseTreeNode> spanNodes() {
191            List<TParseTreeNode> all = new ArrayList<TParseTreeNode>();
192            all.add(sourceTable);
193            all.add(targetTable);
194            all.addAll(references);
195            all.addAll(extraNodes);
196            return all;
197        }
198    }
199
200    /**
201     * Prove one candidate edge against a materialized site.
202     *
203     * @param site      the materialized dynamic SQL, carrying fragment provenance
204     * @param evidence  the edge's evidence set, from the parse of {@code site.sqlText}
205     * @param traces    binding traces from the resolver run over that parse;
206     *                  {@code null} means no trace information ⇒ refuse
207     * @param vendor    dialect, for the lexical boundary checks
208     */
209    public static Verdict prove(DynamicSqlLineageResolver.MaterializedSite site,
210            gudusoft.gsqlparser.TCustomSqlStatement statement,
211            EdgeEvidence evidence, BindingTraceRegistry traces, EDbVendor vendor) {
212        if (statement == null || statement.sourcetokenlist == null) {
213            return Verdict.refuse("P0: no owning statement supplied");
214        }
215        if (site == null || site.sqlText == null) {
216            return Verdict.refuse("P0: no materialized text");
217        }
218        if (evidence == null) {
219            return Verdict.refuse("P0: no evidence");
220        }
221        String missing = evidence.missingRole();
222        if (missing != null) {
223            return Verdict.refuse("P0: evidence set incomplete — " + missing);
224        }
225        if (site.fragments.isEmpty()) {
226            return Verdict.refuse("P0: no fragment provenance (producer channel carries none)");
227        }
228
229        // --- P0b: fragment/text consistency ---------------------------------
230        // The span→fragment mapping is only meaningful if the fragments really
231        // render this text. (SqlStringValue enforces this at construction; a
232        // MaterializedSite built by another channel might not.)
233        StringBuilder rendered = new StringBuilder(site.sqlText.length());
234        for (SqlFragment f : site.fragments) {
235            rendered.append(f.text);
236        }
237        if (!rendered.toString().equals(site.sqlText)) {
238            return Verdict.refuse("P0: fragments do not render the site text");
239        }
240
241        // The statement (and therefore the traces taken from its resolution) must
242        // be THE parse of this site text. Without this, evidence from a parse of
243        // a DIFFERENT but prefix-compatible text passes every later check: the
244        // early spans match and the tokens are that statement's own, yet the
245        // real site may bind those names differently (an extra JOIN makes them
246        // ambiguous). Require the statement's tokens to render the site text.
247        StringBuilder stmtText = new StringBuilder(site.sqlText.length());
248        for (int i = 0; i < statement.sourcetokenlist.size(); i++) {
249            stmtText.append(statement.sourcetokenlist.get(i).toString());
250        }
251        if (!stmtText.toString().equals(site.sqlText)) {
252            return Verdict.refuse("P0: the supplied statement is not the parse of this site text");
253        }
254
255        // --- P1: every evidence span is literal ------------------------------
256        List<int[]> holeSpans = holeSpansOf(site);
257        List<SqlFragment> holes = holesOf(site);
258        int firstHoleStart = holeSpans.isEmpty() ? -1 : holeSpans.get(0)[0];
259
260        List<TParseTreeNode> spanNodes = evidence.spanNodes();
261        for (TParseTreeNode node : spanNodes) {
262            // Ownership is established by TOKEN IDENTITY, on BOTH endpoints of
263            // the span. A node from a different parse (e.g. a separately
264            // re-parsed second statement, whose offsets restart at 0) is
265            // rejected because its tokens are not these tokens — identity
266            // cannot be forged by identical text at identical offsets. Pinning
267            // both ends, rather than only the start, is what makes the span a
268            // provable interval of THIS statement's own token stream.
269            //
270            // Deliberately NOT a comparison against the rendered text: an
271            // earlier version required site.sqlText.substring(span) to equal
272            // node.toString(), which silently deleted provable edges whenever a
273            // node's rendering differs from its source span. An aliased table
274            // spans `src AS s` but renders `src`, so every aliased source table
275            // — one of the commonest shapes in real SQL — lost its edge. Token
276            // identity is both stronger (text equality cannot prove AST
277            // provenance) and rendering-independent.
278            if (!ownsToken(statement, node.getStartToken())
279                    || !ownsToken(statement, node.getEndToken())) {
280                return Verdict.refuse("P1: evidence node does not belong to this statement");
281            }
282            int[] span = spanOf(node);
283            if (span == null) {
284                return Verdict.refuse("P1: evidence node has no source span");
285            }
286            // P0 already proved the statement's tokens render site.sqlText, so
287            // an owned token's offset indexes that text; keep the bound check as
288            // a hard assertion rather than an assumption.
289            if (span[0] < 0 || span[1] > site.sqlText.length()) {
290                return Verdict.refuse("P1: evidence span is outside the site text");
291            }
292            for (int i = 0; i < holeSpans.size(); i++) {
293                int[] h = holeSpans.get(i);
294                if (span[0] < h[1] && h[0] < span[1]) {
295                    return Verdict.refuse("P1: evidence overlaps a hole", holes.get(i));
296                }
297            }
298        }
299
300        // A fully concrete site has no holes: P2's prefix reasoning is vacuous,
301        // but the text must still be proof-grade parseable.
302        if (firstHoleStart < 0) {
303            DynamicSqlProofHarness.ProofParseResult parse =
304                    DynamicSqlProofHarness.proofParse(site.sqlText, vendor);
305            if (!parse.ok) {
306                return Verdict.refuse("P2: " + parse.reason);
307            }
308            return proveBindings(evidence, traces);
309        }
310
311        // --- P2: stability ---------------------------------------------------
312        // Nothing after the first hole is stable: a hole can open a comment or a
313        // string and swallow the literal text that follows it.
314        for (TParseTreeNode node : spanNodes) {
315            int[] span = spanOf(node);
316            if (span[1] > firstHoleStart) {
317                return Verdict.refuse("P2: evidence lies after the first hole",
318                        holes.get(0));
319            }
320        }
321
322        // P2 commitment: preceding the hole is NOT enough. If the hole abuts the
323        // binder region, its runtime text can EXTEND that region and rebind the
324        // evidence — `... SELECT a FROM t` + hole `JOIN u ON 1=1` moves `a` from
325        // t to u without touching a single evidence token. The construct is
326        // committed only when a literal token that terminates the binder region
327        // sits between the source relation and the hole (the `where ` in the
328        // motivating case). Conservative: any solid literal token in between.
329        int[] sourceSpan = spanOf(evidence.sourceTable);
330        if (!hasSolidTokenBetween(statement, sourceSpan[1], firstHoleStart)) {
331            return Verdict.refuse(
332                    "P2: hole abuts the binder region; runtime text could extend it",
333                    holes.get(0));
334        }
335        // P2 containment: the edge's ENTIRE structure must precede the hole.
336        // If any query structure follows it, the edge depends on text a runtime
337        // instantiation can neutralise — e.g.
338        //   INSERT INTO d(x) WITH c AS (SELECT a FROM t WHERE <hole>) SELECT a FROM c
339        // where "<hole> = TRUE) SELECT 0; --" leaves c unused and t -> d gone.
340        // Only a predicate/expression tail may follow the hole.
341        String structural = structuralTokenAfter(statement, firstHoleStart);
342        if (structural != null) {
343            return Verdict.refuse(
344                    "P2: query structure ('" + structural + "') follows the hole", holes.get(0));
345        }
346        DynamicSqlProofHarness.BoundaryVerdict boundary = DynamicSqlProofHarness.checkLexBoundary(
347                site.sqlText.substring(0, firstHoleStart), vendor);
348        if (!boundary.clean) {
349            return Verdict.refuse("P2: " + boundary.reason, holes.get(0));
350        }
351        DynamicSqlProofHarness.ProofParseResult parse =
352                DynamicSqlProofHarness.proofParse(site.sqlText, vendor);
353        if (!parse.ok) {
354            return Verdict.refuse("P2: " + parse.reason);
355        }
356
357        return proveBindings(evidence, traces);
358    }
359
360    /**
361     * P3: every evidence reference must carry a complete binding trace WHOSE
362     * ENDPOINT is one of the edge's own relations. Completeness alone does not
363     * tie a reference to this edge — without the endpoint check, a correctly
364     * bound column of some other relation would certify an edge it has nothing
365     * to do with.
366     */
367    private static Verdict proveBindings(EdgeEvidence evidence, BindingTraceRegistry traces) {
368        if (traces == null) {
369            return Verdict.refuse("P3: no binding traces available");
370        }
371        for (gudusoft.gsqlparser.nodes.TObjectName ref : evidence.references) {
372            BindingTrace t = traces.traceFor(ref);
373            if (t == null) {
374                return Verdict.refuse("P3: no binding trace for " + ref);
375            }
376            if (!t.complete) {
377                return Verdict.refuse("P3: incomplete binding chain for " + ref + " (" + t + ")");
378            }
379            Object required = evidence.sourceSideReferences.contains(ref)
380                    ? evidence.sourceTable : evidence.targetTable;
381            if (!bindsTo(t, required)) {
382                return Verdict.refuse("P3: " + ref + " does not bind to its declared endpoint");
383            }
384        }
385        return Verdict.publish();
386    }
387
388    /** True when the trace's binder is exactly {@code relation}. */
389    private static boolean bindsTo(BindingTrace t, Object relation) {
390        if (relation == null) {
391            return false;
392        }
393        Object[] binders = { t.observedSourceTable, t.definitionNode, t.directTarget };
394        for (Object b : binders) {
395            if (b == relation) {
396                return true;
397            }
398        }
399        return false;
400    }
401
402    /** Identity check: is this token one of the statement's own tokens? */
403    private static boolean ownsToken(gudusoft.gsqlparser.TCustomSqlStatement statement,
404            gudusoft.gsqlparser.TSourceToken token) {
405        if (token == null) {
406            return false;
407        }
408        gudusoft.gsqlparser.TSourceTokenList tokens = statement.sourcetokenlist;
409        for (int i = 0; i < tokens.size(); i++) {
410            if (tokens.get(i) == token) {
411                return true;
412            }
413        }
414        return false;
415    }
416
417    /**
418     * The first query-structure keyword among the statement's own tokens at or
419     * after {@code fromOffset}, or null when only predicate/expression material
420     * follows.
421     *
422     * <p>The scan walks the REAL token stream rather than the raw text: comments,
423     * string literals and delimited identifiers are already classified by the
424     * vendor lexer, so a keyword spelled inside any of them is a value or a name
425     * and cannot be mistaken for structure — and conversely no line-ending,
426     * nested-comment or dollar-quoting subtlety can hide structure from the scan.
427     */
428    private static String structuralTokenAfter(gudusoft.gsqlparser.TCustomSqlStatement statement,
429            int fromOffset) {
430        gudusoft.gsqlparser.TSourceTokenList tokens = statement.sourcetokenlist;
431        for (int i = 0; i < tokens.size(); i++) {
432            gudusoft.gsqlparser.TSourceToken tok = tokens.get(i);
433            if (tok.offset < fromOffset) {
434                continue;
435            }
436            gudusoft.gsqlparser.ETokenType type = tok.tokentype;
437            // Only real keywords can introduce structure. Identifiers (including
438            // every delimited form), literals, comments and punctuation cannot.
439            if (type != gudusoft.gsqlparser.ETokenType.ttkeyword
440                    && type != gudusoft.gsqlparser.ETokenType.ttnonreservedkeyword) {
441                continue;
442            }
443            String text = tok.toString().toLowerCase(); // non-identifier-compare: SQL keyword classification
444            if (STRUCTURAL_KEYWORDS.contains(text)) {
445                return text;
446            }
447        }
448        return null;
449    }
450
451    /** Keywords that can introduce or re-shape query structure. */
452    private static final java.util.Set<String> STRUCTURAL_KEYWORDS =
453            new java.util.HashSet<String>(java.util.Arrays.asList(
454                    "select", "from", "join", "insert", "update", "delete", "merge",
455                    "union", "except", "intersect", "with", "into", "exec", "execute",
456                    "apply", "pivot", "unpivot", "values", "table", "using", "cross",
457                    "outer", "inner", "full", "lateral", "go"));
458
459    /**
460     * True when at least one solid token of the statement sits strictly between
461     * {@code from} and {@code to} — the "a literal token closes the binder
462     * region before the hole" test. Comments and whitespace close nothing, so
463     * the lexer's own classification is used rather than raw text.
464     */
465    private static boolean hasSolidTokenBetween(gudusoft.gsqlparser.TCustomSqlStatement statement,
466            int from, int to) {
467        if (from < 0 || from >= to) {
468            return false;
469        }
470        gudusoft.gsqlparser.TSourceTokenList tokens = statement.sourcetokenlist;
471        for (int i = 0; i < tokens.size(); i++) {
472            gudusoft.gsqlparser.TSourceToken tok = tokens.get(i);
473            if (tok.offset >= from && tok.offset < to && tok.issolidtoken()) {
474                return true;
475            }
476        }
477        return false;
478    }
479
480    /** Character span [start, end) of a node in the parsed text, or null. */
481    private static int[] spanOf(TParseTreeNode node) {
482        TSourceToken start = node.getStartToken();
483        TSourceToken end = node.getEndToken();
484        if (start == null || end == null) {
485            return null;
486        }
487        long from = start.offset;
488        long to = end.offset + end.toString().length();
489        if (from < 0 || to < from) {
490            return null;
491        }
492        return new int[] { (int) from, (int) to };
493    }
494
495    /**
496     * Character spans {@code [start, end)} of every non-literal fragment of {@code site},
497     * in fragment order — index-aligned with {@link #holesOf}. Empty when the site carries
498     * no provenance, or when its fragments do not render its text (in which case the spans
499     * would describe a DIFFERENT string and must not be used to locate anything).
500     *
501     * <p>A zero-length hole still yields a span: arbitrary text can be spliced at that
502     * point, so it bounds the stable region exactly like a non-empty one.
503     */
504    public static List<int[]> holeSpansOf(DynamicSqlLineageResolver.MaterializedSite site) {
505        if (site == null || site.sqlText == null || site.fragments.isEmpty()) {
506            return Collections.emptyList();
507        }
508        StringBuilder rendered = new StringBuilder(site.sqlText.length());
509        for (SqlFragment f : site.fragments) {
510            rendered.append(f.text);
511        }
512        if (!rendered.toString().equals(site.sqlText)) {
513            return Collections.emptyList();
514        }
515        List<int[]> spans = new ArrayList<int[]>();
516        int cursor = 0;
517        for (SqlFragment f : site.fragments) {
518            if (!f.isLiteral()) {
519                spans.add(new int[] { cursor, cursor + f.text.length() });
520            }
521            cursor += f.text.length();
522        }
523        return spans;
524    }
525
526    /** Hole diagnostics for a site, for the "which variable blocked what" report. */
527    public static List<SqlFragment> holesOf(DynamicSqlLineageResolver.MaterializedSite site) {
528        if (site == null || site.fragments.isEmpty()) {
529            return Collections.emptyList();
530        }
531        List<SqlFragment> out = new ArrayList<SqlFragment>();
532        for (SqlFragment f : site.fragments) {
533            if (!f.isLiteral()) {
534                out.add(f);
535            }
536        }
537        return out;
538    }
539}