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}