001package gudusoft.gsqlparser.dlineage.dynamicsql; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.EExpressionType; 005import gudusoft.gsqlparser.TBaseType; 006import gudusoft.gsqlparser.TCustomSqlStatement; 007import gudusoft.gsqlparser.TStatementList; 008import gudusoft.gsqlparser.dlineage.DataFlowAnalyzer; 009import gudusoft.gsqlparser.dlineage.dataflow.model.Option; 010import gudusoft.gsqlparser.dlineage.dataflow.model.xml.column; 011import gudusoft.gsqlparser.dlineage.dataflow.model.xml.dataflow; 012import gudusoft.gsqlparser.dlineage.dataflow.model.xml.relationship; 013import gudusoft.gsqlparser.dlineage.dataflow.model.xml.sourceColumn; 014import gudusoft.gsqlparser.dlineage.dataflow.model.xml.table; 015import gudusoft.gsqlparser.dlineage.dynamicsql.RoutineSummaryEdge.Endpoint; 016import gudusoft.gsqlparser.dlineage.dynamicsql.RoutineSummaryEdge.EndpointKind; 017import gudusoft.gsqlparser.nodes.TExpression; 018import gudusoft.gsqlparser.nodes.TFunctionCall; 019import gudusoft.gsqlparser.nodes.TObjectName; 020import gudusoft.gsqlparser.nodes.TParseTreeVisitor; 021import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 022import gudusoft.gsqlparser.util.SQLUtil; 023 024import java.util.ArrayDeque; 025import java.util.ArrayList; 026import java.util.Collections; 027import java.util.Deque; 028import java.util.HashMap; 029import java.util.HashSet; 030import java.util.LinkedHashMap; 031import java.util.LinkedHashSet; 032import java.util.List; 033import java.util.Locale; 034import java.util.Map; 035import java.util.Set; 036import java.util.TreeSet; 037 038/** 039 * B2 (design {@code routine-summary-scc-design.md} §2.2, §3): intraprocedural 040 * base-summary extraction — the reachability QUOTIENT of one routine's 041 * analyzed relation graph. 042 * 043 * <p><b>No-inline-callee / binding-independence — how it is actually 044 * enforced</b> (corrected per codex-B2-r1 finding 1): a fresh 045 * {@link DataFlowAnalyzer} instance blocks CROSS-FILE leakage only; the 046 * analyzer will still inline a SAME-UNIT callee's body through its 047 * procedure-DDL map. The contract therefore rests on PER-DEFINITION 048 * slicing: {@link #extractAll(String)} splits a unit into single-definition 049 * texts and each slice is analyzed with no sibling present, so callee 050 * bodies are absent by construction and calls surface as raw call sites 051 * (+ CALLS_UNRESOLVED). {@link #extract(String)} requires a 052 * single-definition input for the same reason. Oracle package statements 053 * are the documented exception (members share one statement; B4). 054 * 055 * <p><b>Closure semantics</b> (mirrors the inline engine's fdd/fdr duality, 056 * never OR-merging): per boundary pair, an {@code fdd} summary edge means at 057 * least one interior path made of {@code fdd} hops only; an {@code fdr} 058 * summary edge means at least one interior path containing an {@code fdr} 059 * hop. Both may coexist. Propagation is an iterative worklist (explicit 060 * {@link Deque}; the relation graph is arbitrary — cycles from variable 061 * reassignment converge because the propagated sets are monotone). 062 * 063 * <p>The extractor caches per definition text (analysis-scoped: one 064 * extractor instance per analysis). 065 */ 066public final class RoutineSummaryExtractor { 067 068 private final EDbVendor vendor; 069 private final Map<String, RoutineSummary> cache = new HashMap<String, RoutineSummary>(); 070 071 public RoutineSummaryExtractor(EDbVendor vendor) { 072 this.vendor = vendor; 073 } 074 075 /** 076 * Extract base summaries for EVERY routine defined in a unit, each from a 077 * PER-DEFINITION slice analyzed alone (codex-B2-r1 finding 1: analyzing 078 * the whole unit lets the analyzer inline same-unit callee bodies through 079 * its procedure-DDL map — slicing is what actually enforces 080 * no-inline-callee; a same-unit callee is then simply absent, and the 081 * call surfaces as a raw call site + CALLS_UNRESOLVED). 082 * 083 * <p>Oracle packages: members share one CREATE PACKAGE [BODY] statement 084 * and cannot be sliced apart — the package statement is summarized whole, 085 * and intra-package sibling composition is deferred to B4 (parity slice). 086 */ 087 public List<RoutineSummary> extractAll(String unitText) { 088 List<RoutineSummary> summaries = new ArrayList<RoutineSummary>(); 089 RoutineCatalog catalog = new RoutineCatalog(vendor, 090 Collections.singletonList(unitText), 091 new HashMap<String, TStatementList>()); 092 RoutineCatalog.UnitIndex index = catalog.indexOf(unitText); 093 if (index == null) { 094 summaries.add(new RoutineSummary(null, null, null, 095 RoutineSummary.Completeness.OPAQUE, 096 Collections.singletonList("PARSE_FAILED"))); 097 return summaries; 098 } 099 for (RoutineCatalog.Definition def : index.definitions) { 100 summaries.add(extract(definitionSlice(def))); 101 } 102 // Oracle packages: members live in memberDefinitions, not definitions 103 // (codex-B2-r2 new issue 1). B4a summarizes each member from its OWN 104 // re-wrapped single-member slice (see RoutineCatalog.PackageContext). 105 // A package whose members cannot all be sliced falls back to the B2/B3 106 // behavior — one whole-package summary held PARTIAL by the 107 // PACKAGE_SCOPE_DEFERRED gate — so a slicing failure degrades to the 108 // previously shipped result instead of dropping the package. 109 // Sliceability is decided PER PACKAGE, not per member: a package is 110 // either fully represented by per-member slices or not sliced at all. 111 // Mixing the two would emit per-member summaries AND a whole-package 112 // summary covering those same routines — double coverage. 113 Map<TCustomSqlStatement, List<RoutineCatalog.Definition>> byPackage = 114 new LinkedHashMap<TCustomSqlStatement, List<RoutineCatalog.Definition>>(); 115 for (RoutineCatalog.Definition member : index.memberDefinitions) { 116 TCustomSqlStatement pkg = packageStatementOf(member); 117 if (pkg == null) { 118 continue; 119 } 120 List<RoutineCatalog.Definition> members = byPackage.get(pkg); 121 if (members == null) { 122 members = new ArrayList<RoutineCatalog.Definition>(); 123 byPackage.put(pkg, members); 124 } 125 members.add(member); 126 } 127 for (Map.Entry<TCustomSqlStatement, List<RoutineCatalog.Definition>> e 128 : byPackage.entrySet()) { 129 List<RoutineSummary> sliced = sliceMembers(e.getKey(), e.getValue()); 130 if (sliced != null) { 131 summaries.addAll(sliced); 132 } else { 133 summaries.add(extract(e.getKey().toString())); 134 } 135 } 136 return summaries; 137 } 138 139 /** 140 * Per-member summaries for ONE package, or null when the package must not 141 * be sliced at all (caller then falls back to the B2/B3 whole-package 142 * summary, held PARTIAL by the PACKAGE_SCOPE_DEFERRED gate — i.e. the 143 * previously shipped extraction SHAPE; it does not recover lineage the 144 * analyzer never produced). 145 * 146 * <p>Returning null on ANY problem is what keeps slicing sound: a package 147 * is either fully covered by member slices or not sliced at all. 148 */ 149 private List<RoutineSummary> sliceMembers(TCustomSqlStatement pkg, 150 List<RoutineCatalog.Definition> members) { 151 // A package initialization block (`... BEGIN <stmts> END pkg;`) is 152 // stored on the PACKAGE's own body statements, not on any member, so 153 // no member slice can carry it (codex-B4a-r2 finding 1). 154 // 155 // MEASURED: dlineage emits ZERO relationships for a package 156 // initializer today, so nothing is lost either way — verified against 157 // master with the whole B4a diff stashed. This guard therefore does 158 // not fix a regression; it keeps slicing from claiming coverage of a 159 // construct it structurally cannot carry, so that if the analyzer 160 // ever learns to read initializers the whole-package path picks them 161 // up instead of the slice silently dropping them. 162 if (pkg instanceof gudusoft.gsqlparser.stmt.TBlockSqlStatement) { 163 gudusoft.gsqlparser.stmt.TBlockSqlStatement block = 164 (gudusoft.gsqlparser.stmt.TBlockSqlStatement) pkg; 165 if (block.getBodyStatements() != null 166 && block.getBodyStatements().size() > 0) { 167 return null; 168 } 169 } 170 List<RoutineSummary> sliced = new ArrayList<RoutineSummary>(); 171 for (RoutineCatalog.Definition member : members) { 172 // A SPEC declaration has no body — nothing to summarize. Its 173 // signature already reached the catalog via memberDefinitions. 174 // specOnly is a PACKAGE-wide flag, so it does NOT catch a forward 175 // declaration sitting inside a package BODY; that needs the 176 // per-member body check (codex-B4a-r1 finding 1). 177 if (member.specOnly || !hasBody(member.stmt)) { 178 continue; 179 } 180 String slice = memberSlice(member); 181 if (slice == null) { 182 return null; 183 } 184 RoutineSummary summary = extract(slice); 185 if (summary.getCompleteness() == RoutineSummary.Completeness.OPAQUE) { 186 // Re-wrap did not survive the parser (unusual declaration 187 // section, dialect edge). Fall back rather than publish OPAQUE. 188 return null; 189 } 190 sliced.add(summary); 191 } 192 return sliced; 193 } 194 195 /** 196 * True when a package member actually carries an implementation. 197 * 198 * <p>A forward declaration inside a package BODY ({@code PROCEDURE foo(p 199 * NUMBER);} ahead of its definition) and a call specification (EXTERNAL / 200 * LANGUAGE JAVA) both parse to the same node type as a real member but 201 * hold zero body statements. Summarizing one would publish a SECOND 202 * summary under the implementation's own {@link RoutineIdentity}; the 203 * applier's duplicate-definition handling then discards that identity 204 * outright and the real routine's effects are lost (codex-B4a-r1 205 * finding 1). Body presence is the per-member discriminator — 206 * {@code Definition.specOnly} is package-wide and cannot see this. 207 */ 208 private static boolean hasBody(TCustomSqlStatement stmt) { 209 if (!(stmt instanceof gudusoft.gsqlparser.stmt.TBlockSqlStatement)) { 210 // Unknown member shape — keep the pre-existing behavior of 211 // summarizing it rather than silently dropping a real routine. 212 return true; 213 } 214 gudusoft.gsqlparser.stmt.TBlockSqlStatement block = 215 (gudusoft.gsqlparser.stmt.TBlockSqlStatement) stmt; 216 return block.getBodyStatements() != null 217 && block.getBodyStatements().size() > 0; 218 } 219 220 /** 221 * B4a: rebuild ONE package member as a standalone single-member package — 222 * header + package globals + this member only + {@code END}. Returns null 223 * when the member carries no re-wrap context or its own text is 224 * unavailable. 225 */ 226 private static String memberSlice(RoutineCatalog.Definition member) { 227 RoutineCatalog.PackageContext ctx = member.pkgContext; 228 if (ctx == null || ctx.qualifiedName == null || member.stmt == null) { 229 return null; 230 } 231 String memberText = member.stmt.toString(); 232 if (memberText == null) { 233 return null; 234 } 235 memberText = memberText.trim(); 236 if (memberText.length() == 0) { 237 return null; 238 } 239 // Member nodes end at END <name> with the terminating semicolon owned 240 // by the enclosing declare list, so it must be re-added here. 241 if (!memberText.endsWith(";")) { 242 memberText = memberText + ";"; 243 } 244 StringBuilder slice = new StringBuilder(); 245 slice.append("CREATE OR REPLACE PACKAGE BODY ") 246 .append(ctx.qualifiedName).append(" AS\n"); 247 if (ctx.globalsText != null) { 248 slice.append(ctx.globalsText); 249 } 250 slice.append(" ").append(memberText).append("\n"); 251 slice.append("END"); 252 if (ctx.endName != null && ctx.endName.length() > 0) { 253 slice.append(" ").append(ctx.endName); 254 } 255 slice.append(";\n"); 256 return slice.toString(); 257 } 258 259 /** The enclosing CREATE PACKAGE statement of a member definition. */ 260 private static TCustomSqlStatement packageStatementOf( 261 RoutineCatalog.Definition member) { 262 TCustomSqlStatement stmt = member.stmt; 263 while (stmt != null 264 && !(stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreatePackage)) { 265 stmt = stmt.getParentStmt(); 266 } 267 return stmt; 268 } 269 270 /** The standalone analyzable text of one definition (its own statement, 271 * re-anchored under its effective USE database when one applies). */ 272 private String definitionSlice(RoutineCatalog.Definition def) { 273 String text = def.stmt.toString(); 274 if (vendor == EDbVendor.dbvmssql && def.db != null && def.db.length() > 0) { 275 return "USE " + def.db + "\nGO\n" + text; 276 } 277 return text; 278 } 279 280 /** Extract (or return the cached) base summary of ONE routine definition. 281 * The input must contain a single definition — feeding a multi-routine 282 * unit here reintroduces same-unit callee inlining; use 283 * {@link #extractAll(String)} for whole units. */ 284 public RoutineSummary extract(String routineDefinitionText) { 285 RoutineSummary cached = cache.get(routineDefinitionText); 286 if (cached != null) { 287 return cached; 288 } 289 RoutineSummary summary = doExtract(routineDefinitionText); 290 cache.put(routineDefinitionText, summary); 291 return summary; 292 } 293 294 private RoutineSummary doExtract(String text) { 295 RoutineCatalog catalog = new RoutineCatalog(vendor, 296 Collections.singletonList(text), 297 new HashMap<String, TStatementList>()); 298 RoutineCatalog.UnitIndex index = catalog.indexOf(text); 299 if (index == null) { 300 // Unit failed to parse — dlineage would swallow this into its 301 // error list, but §2.6 reserves OPAQUE for exactly this case. 302 return new RoutineSummary(null, null, null, 303 RoutineSummary.Completeness.OPAQUE, 304 Collections.singletonList("PARSE_FAILED")); 305 } 306 RoutineIdentity identity = identityOf(index); 307 dataflow df = analyze(text); 308 if (df == null) { 309 return new RoutineSummary(identity, null, null, 310 RoutineSummary.Completeness.OPAQUE, 311 Collections.singletonList("ANALYSIS_FAILED")); 312 } 313 RelationGraph graph = RelationGraph.of(df); 314 Closure closure = closeGraph(graph); 315 Set<RoutineSummaryEdge> edges = closure.edges; 316 CollectedBody body = collectCallSiteAsts(text); 317 List<CallSiteRef> callSites = callSites(body.sites, df, graph, closure); 318 List<RoutineSummary.RawCallSite> calls = 319 new ArrayList<RoutineSummary.RawCallSite>(); 320 for (CallSiteRef site : callSites) { 321 calls.add(site.getRaw()); 322 } 323 List<String> reasons = new ArrayList<String>(); 324 if (df.getErrors() != null && !df.getErrors().isEmpty()) { 325 reasons.add("ANALYSIS_ERRORS"); 326 } 327 if (containsDynamicSql(text)) { 328 reasons.add("DYNAMIC_SQL_UNRESOLVED"); 329 } 330 if (!calls.isEmpty()) { 331 // Base summaries never include callee effects (no-inline-callee): 332 // any call site means the summary under-approximates until B3 333 // composes it — a COMPLETE claim here would let B6 replace inline 334 // analysis and silently lose the callee flow (codex-B2-r1 f6). 335 reasons.add("CALLS_UNRESOLVED"); 336 } 337 if (index != null && index.definitions.size() 338 + index.memberDefinitions.size() > 1) { 339 reasons.add("MULTIPLE_DEFINITIONS_IN_UNIT"); 340 } 341 // Deferred-construct gates (codex-B2-r2 finding 2): the B2 endpoint 342 // model has no RETURN / ref-cursor / package-global kinds, so any 343 // routine whose effects can flow through one must be PARTIAL — a 344 // COMPLETE claim would let B6 replace inline analysis and lose them. 345 if (identity != null 346 && (identity.getKind() == RoutineKind.FUNCTION 347 || identity.getKind() == RoutineKind.PACKAGE_MEMBER_FUNCTION)) { 348 reasons.add("RETURN_ENDPOINT_DEFERRED"); 349 } 350 if (identity != null) { 351 for (String typeText : identity.getParamTypeNames()) { 352 // non-identifier-compare: normalized declared-type text screen 353 if (typeText.contains("CURSOR")) { 354 reasons.add("REF_CURSOR_DEFERRED"); 355 break; 356 } 357 } 358 } 359 if (index != null && !index.memberDefinitions.isEmpty()) { 360 reasons.add("PACKAGE_SCOPE_DEFERRED"); 361 } 362 // A body-level RETURN with a value defers the routine for BOTH kinds 363 // (T-SQL procedures return status values too — codex-B3-r2 finding 364 // 4b), and its pseudo-resultset is indistinguishable from a genuine 365 // SELECT result set in the model — suppress positional INSERT…EXEC 366 // composition entirely (sound under-approximation; finding 4c). 367 Map<String, List<String>> shapes = graph.terminalShapes; 368 if (body.returnsValue) { 369 if (!reasons.contains("RETURN_ENDPOINT_DEFERRED")) { 370 reasons.add("RETURN_ENDPOINT_DEFERRED"); 371 } 372 shapes = Collections.emptyMap(); 373 } 374 RoutineSummary.Completeness completeness = reasons.isEmpty() 375 ? RoutineSummary.Completeness.COMPLETE 376 : RoutineSummary.Completeness.PARTIAL; 377 return new RoutineSummary(identity, edges, calls, callSites, 378 shapes, completeness, reasons); 379 } 380 381 // ------------------------------------------------------------------ 382 // Graph model shared by the worklist closure and (in tests) the naive 383 // differential implementation. 384 // ------------------------------------------------------------------ 385 386 /** The analyzed relation graph with boundary classification. */ 387 static final class RelationGraph { 388 /** node key -> outgoing (targetKey, "1" when the hop is fdr) pairs. */ 389 final Map<String, List<String[]>> out = new HashMap<String, List<String[]>>(); 390 /** node key -> boundary endpoint (absent for interior nodes). */ 391 final Map<String, Endpoint> boundary = new HashMap<String, Endpoint>(); 392 /** interior function-hop display name per node (absent when none). */ 393 final Map<String, String> functionName = new HashMap<String, String>(); 394 final Set<String> nodes = new LinkedHashSet<String>(); 395 /** B3: terminal result-set qualified display name -> its columns in 396 * projection order (for positional INSERT…EXEC composition). */ 397 final Map<String, List<String>> terminalShapes = 398 new LinkedHashMap<String, List<String>>(); 399 400 static RelationGraph of(dataflow df) { 401 RelationGraph g = new RelationGraph(); 402 Map<String, table> parentById = new HashMap<String, table>(); 403 Map<String, EndpointKind> kindByParent = new HashMap<String, EndpointKind>(); 404 Set<String> resultsetIds = new HashSet<String>(); 405 Map<String, List<String>> terminalNameOccurrences = 406 new HashMap<String, List<String>>(); 407 if (df.getTables() != null) { 408 for (table t : df.getTables()) { 409 parentById.put(t.getId(), t); 410 String name = t.getName() == null ? "" : t.getName(); 411 kindByParent.put(t.getId(), name.startsWith("#") 412 ? EndpointKind.TEMP_COLUMN : EndpointKind.TABLE_COLUMN); 413 } 414 } 415 if (df.getVariables() != null) { 416 for (table v : df.getVariables()) { 417 parentById.put(v.getId(), v); 418 String subType = v.getSubType() == null ? "" 419 : v.getSubType().toLowerCase(Locale.ROOT); 420 // non-identifier-compare: subType is a fixed mode-token vocabulary 421 boolean formal = subType.equals("in") || subType.equals("out") 422 || subType.equals("output") || subType.equals("inout") 423 || subType.equals("argument"); 424 if (formal) { 425 kindByParent.put(v.getId(), EndpointKind.FORMAL); 426 } 427 // locals stay interior (absent from kindByParent) 428 } 429 } 430 List<String> rsIdOrder = new ArrayList<String>(); 431 if (df.getResultsets() != null) { 432 for (table rs : df.getResultsets()) { 433 parentById.put(rs.getId(), rs); 434 resultsetIds.add(rs.getId()); 435 rsIdOrder.add(rs.getId()); 436 } 437 } 438 // Edges + node registration. 439 Set<String> parentsWithOutgoing = new HashSet<String>(); 440 if (df.getRelationships() != null) { 441 for (relationship rel : df.getRelationships()) { 442 String type = rel.getType(); 443 // non-identifier-compare: relationship type token 444 boolean fdd = "fdd".equals(type); 445 boolean fdr = "fdr".equals(type); 446 if (!fdd && !fdr) { 447 continue; 448 } 449 if (rel.getTarget() == null || rel.getSources() == null) { 450 continue; 451 } 452 String targetKey = key(rel.getTarget().getParent_id(), 453 rel.getTarget().getColumn()); 454 for (sourceColumn src : rel.getSources()) { 455 String sourceKey = key(src.getParent_id(), src.getColumn()); 456 g.nodes.add(sourceKey); 457 g.nodes.add(targetKey); 458 List<String[]> hops = g.out.get(sourceKey); 459 if (hops == null) { 460 hops = new ArrayList<String[]>(); 461 g.out.put(sourceKey, hops); 462 } 463 hops.add(new String[] { targetKey, fdr ? "1" : "0" }); 464 parentsWithOutgoing.add(src.getParent_id()); 465 } 466 } 467 } 468 // §4.1 ROW-dominates across the quotient: an fdr edge into a 469 // RESULTSET's synthetic RelationRows column is a row gate on that 470 // statement occurrence — fan it out as fdr hops to the sibling 471 // columns so a filter composes into downstream fdr edges (the 472 // design's base.f → t rows → c example). RESULTSET parents only: 473 // resultset ids are occurrence-scoped (one per statement), while 474 // physical-table models are REUSED across statements, so fanning 475 // out on a table parent would smear one statement's condition 476 // onto unrelated statements' flows (codex-B2-r1 finding 4). A 477 // table's row gate stays visible as a boundary edge targeting 478 // the table's own RelationRows node instead. 479 List<String> relationRowsNodes = new ArrayList<String>(); 480 for (String node : g.nodes) { 481 int sep = node.indexOf(' '); 482 // non-identifier-compare: RelationRows is dlineage's fixed synthetic column name 483 if ("RelationRows".equals(node.substring(sep + 1)) 484 && resultsetIds.contains(node.substring(0, sep))) { 485 relationRowsNodes.add(node); 486 } 487 } 488 for (String rowsNode : relationRowsNodes) { 489 String parentId = rowsNode.substring(0, rowsNode.indexOf(' ')); 490 for (String sibling : new ArrayList<String>(g.nodes)) { 491 if (sibling.equals(rowsNode) 492 || !sibling.startsWith(parentId + " ")) { 493 continue; 494 } 495 List<String[]> hops = g.out.get(rowsNode); 496 if (hops == null) { 497 hops = new ArrayList<String[]>(); 498 g.out.put(rowsNode, hops); 499 } 500 hops.add(new String[] { sibling, "1" }); 501 } 502 } 503 // Terminal-resultset duplicate-name ordinals in OCCURRENCE order 504 // (df.getResultsets() order — codex-B2-r2: lexicographic id sort 505 // is not occurrence order and both differential sides would have 506 // repeated the mistake). 507 for (String id : rsIdOrder) { 508 if (parentsWithOutgoing.contains(id)) { 509 continue; 510 } 511 table rs = parentById.get(id); 512 if (rs == null || rs.isFunction()) { 513 continue; 514 } 515 String name = rs.getName() == null ? id : rs.getName(); 516 List<String> ids = terminalNameOccurrences.get(name); 517 if (ids == null) { 518 ids = new ArrayList<String>(); 519 terminalNameOccurrences.put(name, ids); 520 } 521 ids.add(id); 522 } 523 // Boundary classification per node. 524 for (String node : g.nodes) { 525 int sep = node.indexOf(' '); 526 String parentId = node.substring(0, sep); 527 String column = node.substring(sep + 1); 528 EndpointKind kind = kindByParent.get(parentId); 529 table parent = parentById.get(parentId); 530 String parentName = parent == null || parent.getName() == null 531 ? parentId : parent.getName(); 532 if (kind != null) { 533 g.boundary.put(node, new Endpoint(kind, parentName, column)); 534 } else if (resultsetIds.contains(parentId)) { 535 boolean isFunction = parent != null && parent.isFunction(); 536 if (isFunction) { 537 g.functionName.put(node, parentName); 538 } else if (!parentsWithOutgoing.contains(parentId)) { 539 // Terminal resultset: no column of it feeds anything — 540 // this is a routine OUTPUT result set (§2.2 boundary). 541 // Duplicate display names get "#k" by occurrence order. 542 List<String> dup = terminalNameOccurrences.get(parentName); 543 String qualified = dup != null && dup.size() > 1 544 ? parentName + "#" + dup.indexOf(parentId) 545 : parentName; 546 g.boundary.put(node, new Endpoint( 547 EndpointKind.RESULT_SET_COLUMN, qualified, column)); 548 if (!g.terminalShapes.containsKey(qualified)) { 549 List<String> shape = new ArrayList<String>(); 550 if (parent != null && parent.getColumns() != null) { 551 for (column c : parent.getColumns()) { 552 // non-identifier-compare: RelationRows is dlineage's fixed synthetic column name 553 if ("RelationRows".equals(c.getName()) 554 || "system".equals(c.getSource())) { 555 continue; 556 } 557 shape.add(c.getName()); 558 } 559 } 560 g.terminalShapes.put(qualified, shape); 561 } 562 } 563 // Non-terminal, non-function resultsets stay interior. 564 } 565 } 566 return g; 567 } 568 569 static String key(String parentId, String column) { 570 return (parentId == null ? "" : parentId) + ' ' 571 + (column == null ? "" : column); 572 } 573 } 574 575 /** Result of {@link #closeGraph}: summary edges plus the full per-node 576 * reach map (B3 consumes the latter for call-site actual reach). */ 577 static final class Closure { 578 final Map<String, Map<String, PathAttr>> reach; 579 final Set<RoutineSummaryEdge> edges; 580 581 Closure(Map<String, Map<String, PathAttr>> reach, 582 Set<RoutineSummaryEdge> edges) { 583 this.reach = reach; 584 this.edges = edges; 585 } 586 } 587 588 /** B2-compatible view of {@link #closeGraph}: the summary edges only. */ 589 static Set<RoutineSummaryEdge> close(RelationGraph g) { 590 return closeGraph(g).edges; 591 } 592 593 /** 594 * Worklist closure: per node, the map boundary-source-key → 595 * {reachable via fdd-only path, reachable via fdr-bearing path, 596 * functions on value paths}. Monotone joins ⇒ convergence. 597 * 598 * <p>Propagation continues THROUGH boundary nodes: a path formal → table 599 * → formal is two summary edges AND the transitive pair, matching the 600 * inline engine's shared-model transitivity. 601 */ 602 static Closure closeGraph(RelationGraph g) { 603 Map<String, Map<String, PathAttr>> reach = 604 new HashMap<String, Map<String, PathAttr>>(); 605 Deque<String> worklist = new ArrayDeque<String>(); 606 for (String node : g.nodes) { 607 if (g.boundary.containsKey(node)) { 608 Map<String, PathAttr> self = new HashMap<String, PathAttr>(); 609 PathAttr attr = new PathAttr(); 610 attr.valueOnly = true; 611 self.put(node, attr); 612 reach.put(node, self); 613 worklist.push(node); 614 } 615 } 616 while (!worklist.isEmpty()) { 617 String node = worklist.pop(); 618 Map<String, PathAttr> nodeReach = reach.get(node); 619 if (nodeReach == null) { 620 continue; 621 } 622 List<String[]> hops = g.out.get(node); 623 if (hops == null) { 624 continue; 625 } 626 for (String[] hop : hops) { 627 String target = hop[0]; 628 boolean hopFdr = "1".equals(hop[1]); 629 String targetFunction = g.functionName.get(target); 630 Map<String, PathAttr> targetReach = reach.get(target); 631 if (targetReach == null) { 632 targetReach = new HashMap<String, PathAttr>(); 633 reach.put(target, targetReach); 634 } 635 boolean changed = false; 636 for (Map.Entry<String, PathAttr> e : nodeReach.entrySet()) { 637 PathAttr in = e.getValue(); 638 PathAttr outAttr = targetReach.get(e.getKey()); 639 if (outAttr == null) { 640 outAttr = new PathAttr(); 641 targetReach.put(e.getKey(), outAttr); 642 } 643 boolean nvo = in.valueOnly && !hopFdr; 644 boolean nfr = in.withFdr || hopFdr; 645 if (nvo && !outAttr.valueOnly) { 646 outAttr.valueOnly = true; 647 changed = true; 648 } 649 if (nfr && !outAttr.withFdr) { 650 outAttr.withFdr = true; 651 changed = true; 652 } 653 if (nvo) { 654 Set<String> fns = new TreeSet<String>(in.functions); 655 if (targetFunction != null) { 656 fns.add(targetFunction); 657 } 658 if (!outAttr.functions.containsAll(fns)) { 659 outAttr.functions.addAll(fns); 660 changed = true; 661 } 662 } 663 } 664 if (changed) { 665 worklist.push(target); 666 } 667 } 668 } 669 Set<RoutineSummaryEdge> edges = new LinkedHashSet<RoutineSummaryEdge>(); 670 for (Map.Entry<String, Map<String, PathAttr>> e : reach.entrySet()) { 671 Endpoint target = g.boundary.get(e.getKey()); 672 if (target == null) { 673 continue; 674 } 675 for (Map.Entry<String, PathAttr> s : e.getValue().entrySet()) { 676 if (s.getKey().equals(e.getKey())) { 677 continue; // self-seed 678 } 679 Endpoint source = g.boundary.get(s.getKey()); 680 if (source == null) { 681 continue; 682 } 683 PathAttr attr = s.getValue(); 684 if (attr.valueOnly) { 685 edges.add(new RoutineSummaryEdge(source, target, "fdd", 686 attr.functions)); 687 } 688 if (attr.withFdr) { 689 edges.add(new RoutineSummaryEdge(source, target, "fdr", null)); 690 } 691 } 692 } 693 return new Closure(reach, edges); 694 } 695 696 /** 697 * B3: forward reach from ONE node (a call-site OUT actual) to the graph's 698 * boundary endpoints. Same monotone worklist as {@link #closeGraph}, but 699 * seeded at an arbitrary (typically interior) node — the closure's own 700 * reach map is seeded only at boundaries, so this direction needs its own 701 * pass. Returns boundary endpoint → path attributes. 702 */ 703 static Map<Endpoint, PathAttr> forwardReach(RelationGraph g, String seed) { 704 Map<Endpoint, PathAttr> result = new LinkedHashMap<Endpoint, PathAttr>(); 705 for (Map.Entry<String, PathAttr> e : forwardReachAll(g, seed).entrySet()) { 706 Endpoint boundary = g.boundary.get(e.getKey()); 707 if (boundary != null) { 708 result.put(boundary, e.getValue()); 709 } 710 } 711 return result; 712 } 713 714 /** Forward reach over ALL nodes (interior included) — the intra-caller 715 * bridge for call-to-call composition needs interior locals 716 * (codex-B3-r1 finding 5). */ 717 static Map<String, PathAttr> forwardReachAll(RelationGraph g, String seed) { 718 Map<String, PathAttr> attrs = new HashMap<String, PathAttr>(); 719 PathAttr self = new PathAttr(); 720 self.valueOnly = true; 721 attrs.put(seed, self); 722 Deque<String> worklist = new ArrayDeque<String>(); 723 worklist.push(seed); 724 while (!worklist.isEmpty()) { 725 String node = worklist.pop(); 726 PathAttr in = attrs.get(node); 727 List<String[]> hops = g.out.get(node); 728 if (hops == null || in == null) { 729 continue; 730 } 731 for (String[] hop : hops) { 732 String target = hop[0]; 733 boolean hopFdr = "1".equals(hop[1]); 734 PathAttr out = attrs.get(target); 735 if (out == null) { 736 out = new PathAttr(); 737 attrs.put(target, out); 738 } 739 boolean changed = false; 740 boolean nvo = in.valueOnly && !hopFdr; 741 boolean nfr = in.withFdr || hopFdr; 742 if (nvo && !out.valueOnly) { 743 out.valueOnly = true; 744 changed = true; 745 } 746 if (nfr && !out.withFdr) { 747 out.withFdr = true; 748 changed = true; 749 } 750 if (changed) { 751 worklist.push(target); 752 } 753 } 754 } 755 return attrs; 756 } 757 758 /** Monotone per-(source,node) path attributes. */ 759 static final class PathAttr { 760 boolean valueOnly; 761 boolean withFdr; 762 final Set<String> functions = new TreeSet<String>(); 763 } 764 765 // ------------------------------------------------------------------ 766 // Support: isolated analysis, identity, raw call sites. 767 // ------------------------------------------------------------------ 768 769 private dataflow analyze(String text) { 770 try { 771 Option opt = new Option(); 772 opt.setVendor(vendor); 773 opt.setOutput(false); 774 opt.setShowJoin(true); 775 // SHADOW analyzers run identity-first pools: package overloads in 776 // a whole-package slice keep separated formal/cursor pools 777 // (codex-B3-r1 finding 7 — the mode must actually be exercised). 778 opt.setIdentityFirstVariablePools(true); 779 // Base-summary edges currently have no per-edge dynamic-site 780 // provenance. Never flatten an inner dynamic-SQL relationship 781 // into that static edge set: an incomplete LEGACY fold can name 782 // the wrong source/target yet would otherwise look identical to 783 // a proven static relationship during call-site composition. 784 opt.setAnalyzeDynamicSql(false); 785 DataFlowAnalyzer analyzer = new DataFlowAnalyzer(text, opt); 786 analyzer.generateDataFlow(); 787 return analyzer.getDataFlow(); 788 } catch (RuntimeException ex) { 789 return null; 790 } catch (StackOverflowError err) { 791 return null; 792 } 793 } 794 795 private static RoutineIdentity identityOf(RoutineCatalog.UnitIndex index) { 796 if (!index.definitions.isEmpty()) { 797 return index.definitions.get(0).identity; 798 } 799 if (!index.memberDefinitions.isEmpty()) { 800 return index.memberDefinitions.get(0).identity; 801 } 802 return null; 803 } 804 805 // ------------------------------------------------------------------ 806 // B3: call sites with AST actuals + caller-side boundary reach. 807 // ------------------------------------------------------------------ 808 809 /** AST-side view of one call site before model reach is attached. */ 810 private static final class CallSiteAst { 811 final String rawName; 812 final TCustomSqlStatement stmt; 813 final List<CallSiteRef.Actual> actuals; 814 final gudusoft.gsqlparser.stmt.TInsertSqlStatement enclosingInsert; 815 816 CallSiteAst(String rawName, TCustomSqlStatement stmt, 817 List<CallSiteRef.Actual> actuals, 818 gudusoft.gsqlparser.stmt.TInsertSqlStatement enclosingInsert) { 819 this.rawName = rawName; 820 this.stmt = stmt; 821 this.actuals = actuals; 822 this.enclosingInsert = enclosingInsert; 823 } 824 } 825 826 /** Everything one body walk collects: call sites + RETURN-with-value. */ 827 private static final class CollectedBody { 828 final List<CallSiteAst> sites = new ArrayList<CallSiteAst>(); 829 boolean returnsValue; 830 } 831 832 /** AST-level facts shared by BOTH CallSiteRef construction paths (the 833 * reach-enriched extractor path and the AST-only B0 contract path — 834 * codex-B3-r2 finding 4a): INSERT…EXEC column list / missing-list flag 835 * and the {@code @ret = EXEC} status target. */ 836 private void enrichAstFacts(CallSiteRef ref, CallSiteAst ast) { 837 if (ast.enclosingInsert != null) { 838 if (ast.enclosingInsert.getColumnList() != null 839 && ast.enclosingInsert.getColumnList().size() > 0) { 840 List<TObjectName> columnNames = new ArrayList<TObjectName>(); 841 for (int i = 0; i < ast.enclosingInsert.getColumnList().size(); i++) { 842 columnNames.add(ast.enclosingInsert.getColumnList() 843 .getObjectName(i)); 844 } 845 ref.setInsertColumnNames(columnNames); 846 } else { 847 // No explicit column list: ordinals are unknowable 848 // without a catalog — the binder reports PARTIAL. 849 ref.setInsertWithoutColumnList(true); 850 } 851 } 852 if (ast.stmt instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) { 853 ref.setReturnStatusTarget( 854 ((gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) ast.stmt) 855 .getReturnStatus()); 856 } 857 } 858 859 /** Collect the slice's call sites and attach the caller-side reach the 860 * SHADOW composition step needs (design §2.3): per IN actual, the caller 861 * boundary endpoints feeding it; per OUT actual, the caller boundary 862 * endpoints its value reaches downstream. */ 863 private List<CallSiteRef> callSites(List<CallSiteAst> asts, dataflow df, 864 RelationGraph g, Closure closure) { 865 List<CallSiteRef> result = new ArrayList<CallSiteRef>(); 866 List<table> variables = df.getVariables() == null 867 ? Collections.<table>emptyList() : df.getVariables(); 868 List<table> tables = df.getTables() == null 869 ? Collections.<table>emptyList() : df.getTables(); 870 for (CallSiteAst ast : asts) { 871 Map<Integer, Set<Endpoint>> inFdd = new LinkedHashMap<Integer, Set<Endpoint>>(); 872 Map<Integer, Set<Endpoint>> inFdr = new LinkedHashMap<Integer, Set<Endpoint>>(); 873 Map<Integer, Set<Endpoint>> outFdd = new LinkedHashMap<Integer, Set<Endpoint>>(); 874 Map<Integer, Set<Endpoint>> outFdr = new LinkedHashMap<Integer, Set<Endpoint>>(); 875 Map<Integer, Set<String>> inNodes = new LinkedHashMap<Integer, Set<String>>(); 876 Map<Integer, Map<String, Integer>> outReachNodes = 877 new LinkedHashMap<Integer, Map<String, Integer>>(); 878 for (CallSiteRef.Actual actual : ast.actuals) { 879 if (actual.isLiteral() || actual.isDefaultToken()) { 880 continue; // no source binds (inline parity / declared default) 881 } 882 List<TObjectName> leafs = actual.getBareReference() != null 883 ? Collections.singletonList(actual.getBareReference()) 884 : actual.getLeafReferences(); 885 for (TObjectName leaf : leafs) { 886 String node = nodeKeyForLeaf(leaf, variables, tables); 887 if (node == null) { 888 continue; 889 } 890 Set<String> ownNodes = inNodes.get(actual.getPosition()); 891 if (ownNodes == null) { 892 ownNodes = new LinkedHashSet<String>(); 893 inNodes.put(actual.getPosition(), ownNodes); 894 } 895 ownNodes.add(node); 896 Endpoint selfBoundary = g.boundary.get(node); 897 if (selfBoundary != null) { 898 addEndpoint(inFdd, actual.getPosition(), selfBoundary); 899 } 900 Map<String, PathAttr> reach = closure.reach.get(node); 901 if (reach != null) { 902 for (Map.Entry<String, PathAttr> e : reach.entrySet()) { 903 if (e.getKey().equals(node)) { 904 continue; 905 } 906 Endpoint source = g.boundary.get(e.getKey()); 907 if (source == null) { 908 continue; 909 } 910 if (e.getValue().valueOnly) { 911 addEndpoint(inFdd, actual.getPosition(), source); 912 } 913 if (e.getValue().withFdr) { 914 addEndpoint(inFdr, actual.getPosition(), source); 915 } 916 } 917 } 918 // Forward reach for potential OUT targets: only bare 919 // actuals qualify (an expression is never a writable 920 // lvalue — the binder drops those with a diagnostic). 921 // Interior nodes are kept too: they anchor the 922 // intra-caller bridge for call-to-call composition. 923 if (actual.getBareReference() != null) { 924 Map<String, Integer> nodeFlavors = 925 outReachNodes.get(actual.getPosition()); 926 if (nodeFlavors == null) { 927 nodeFlavors = new LinkedHashMap<String, Integer>(); 928 outReachNodes.put(actual.getPosition(), nodeFlavors); 929 } 930 for (Map.Entry<String, PathAttr> e 931 : forwardReachAll(g, node).entrySet()) { 932 int bits = (e.getValue().valueOnly ? 1 : 0) 933 | (e.getValue().withFdr ? 2 : 0); 934 Integer prior = nodeFlavors.get(e.getKey()); 935 nodeFlavors.put(e.getKey(), 936 prior == null ? bits : (prior | bits)); 937 Endpoint boundary = g.boundary.get(e.getKey()); 938 if (boundary == null) { 939 continue; 940 } 941 if (e.getValue().valueOnly) { 942 addEndpoint(outFdd, actual.getPosition(), boundary); 943 } 944 if (e.getValue().withFdr) { 945 addEndpoint(outFdr, actual.getPosition(), boundary); 946 } 947 } 948 } 949 } 950 } 951 List<Endpoint> insertTargets = insertTargetsOf(ast, tables); 952 int argCount = ast.actuals.size(); 953 CallSiteRef ref = new CallSiteRef( 954 new RoutineSummary.RawCallSite(ast.rawName, argCount), 955 ast.stmt, ast.actuals, insertTargets, 956 inFdd, inFdr, outFdd, outFdr); 957 ref.setInNodes(inNodes); 958 ref.setOutReachNodes(outReachNodes); 959 enrichAstFacts(ref, ast); 960 result.add(ref); 961 } 962 return result; 963 } 964 965 private static void addEndpoint(Map<Integer, Set<Endpoint>> map, int position, 966 Endpoint endpoint) { 967 Set<Endpoint> set = map.get(position); 968 if (set == null) { 969 set = new LinkedHashSet<Endpoint>(); 970 map.put(position, set); 971 } 972 set.add(endpoint); 973 } 974 975 /** INSERT…EXEC positional targets: the INSERT's explicit column list 976 * resolved against the analyzed target table. Null when the call is not 977 * INSERT-consumed or the column list is absent (the binder reports the 978 * latter as PARTIAL — ordinals are never guessed). */ 979 private List<Endpoint> insertTargetsOf(CallSiteAst ast, List<table> tables) { 980 if (ast.enclosingInsert == null 981 || ast.enclosingInsert.getColumnList() == null 982 || ast.enclosingInsert.getColumnList().size() == 0 983 || ast.enclosingInsert.getTargetTable() == null) { 984 return null; 985 } 986 String targetName = ast.enclosingInsert.getTargetTable().toString(); 987 String display = targetName; 988 for (table t : tables) { 989 if (t.getName() != null && sameQualifiedTable(targetName, t.getName())) { 990 display = t.getName(); 991 break; 992 } 993 } 994 List<Endpoint> targets = new ArrayList<Endpoint>(); 995 // #tmp targets are TEMP endpoints — the same classification the 996 // graph builder applies to '#'-prefixed tables (codex-B3-r3 f5). 997 List<String> displayParts = SQLUtil.parseNames(display); 998 String displaySimple = displayParts.isEmpty() 999 ? display : displayParts.get(displayParts.size() - 1); 1000 EndpointKind kind = displaySimple.startsWith("#") 1001 ? EndpointKind.TEMP_COLUMN : EndpointKind.TABLE_COLUMN; 1002 for (int i = 0; i < ast.enclosingInsert.getColumnList().size(); i++) { 1003 targets.add(new Endpoint(kind, display, 1004 ast.enclosingInsert.getColumnList().getObjectName(i).toString())); 1005 } 1006 return targets; 1007 } 1008 1009 /** Suffix-tolerant qualified table-name comparison (call text vs model 1010 * display name), segment-by-segment through the canonical facade. */ 1011 private boolean sameQualifiedTable(String a, String b) { 1012 List<String> pa = SQLUtil.parseNames(a); 1013 List<String> pb = SQLUtil.parseNames(b); 1014 int n = Math.min(pa.size(), pb.size()); 1015 if (n == 0) { 1016 return false; 1017 } 1018 for (int i = 1; i <= n; i++) { 1019 if (!SQLUtil.sameName(vendor, ESQLDataObjectType.dotTable, 1020 pa.get(pa.size() - i), pb.get(pb.size() - i))) { 1021 return false; 1022 } 1023 } 1024 return true; 1025 } 1026 1027 /** Model node key for one actual leaf reference: a declared variable 1028 * (formal or local) matched by name, else a qualified table column. 1029 * Null when the leaf matches nothing the analyzed model knows. */ 1030 private String nodeKeyForLeaf(TObjectName leaf, List<table> variables, 1031 List<table> tables) { 1032 String text = leaf.toString(); 1033 List<String> parts = SQLUtil.parseNames(text); 1034 String simple = parts.isEmpty() ? text : parts.get(parts.size() - 1); 1035 for (table v : variables) { 1036 if (v.getName() == null) { 1037 continue; 1038 } 1039 // A USE-re-anchored slice qualifies variable display names 1040 // (e.g. "Db.dbo.@v") — match on the LAST segment, not the whole 1041 // name (codex-B3-r1 finding 2). 1042 List<String> varParts = SQLUtil.parseNames(v.getName()); 1043 String varSimple = varParts.isEmpty() 1044 ? v.getName() : varParts.get(varParts.size() - 1); 1045 if (SQLUtil.sameName(vendor, ESQLDataObjectType.dotColumn, 1046 simple, varSimple)) { 1047 String columnName = v.getColumns() != null && !v.getColumns().isEmpty() 1048 ? v.getColumns().get(0).getName() : v.getName(); 1049 return RelationGraph.key(v.getId(), columnName); 1050 } 1051 } 1052 if (parts.size() >= 2) { 1053 String tablePart = text.substring(0, text.lastIndexOf('.')); 1054 for (table t : tables) { 1055 if (t.getName() != null && sameQualifiedTable(tablePart, t.getName())) { 1056 return RelationGraph.key(t.getId(), simple); 1057 } 1058 } 1059 } 1060 return null; 1061 } 1062 1063 /** AST-only call sites of a statement text (no model-side reach) — the 1064 * B0 contract entry of {@link CatalogRoutineCallBinder} binds these. 1065 * Carries the same AST-level facts as the enriched path (INSERT…EXEC 1066 * columns, return-status target — codex-B3-r2 finding 4a). */ 1067 List<CallSiteRef> callSitesOfStatementText(String text) { 1068 List<CallSiteRef> result = new ArrayList<CallSiteRef>(); 1069 for (CallSiteAst ast : collectCallSiteAsts(text).sites) { 1070 CallSiteRef ref = new CallSiteRef( 1071 new RoutineSummary.RawCallSite(ast.rawName, ast.actuals.size()), 1072 ast.stmt, ast.actuals, null, null, null, null, null); 1073 enrichAstFacts(ref, ast); 1074 result.add(ref); 1075 } 1076 return result; 1077 } 1078 1079 /** Parse the slice and collect its call-site statements with actuals, 1080 * plus body-level RETURN-with-value detection. Best-effort: a parse 1081 * crash leaves the result empty (summaries stay usable; completeness 1082 * gating happens on the analysis side). */ 1083 private CollectedBody collectCallSiteAsts(String text) { 1084 CollectedBody body = new CollectedBody(); 1085 List<CallSiteAst> sites = body.sites; 1086 try { 1087 gudusoft.gsqlparser.TGSqlParser parser = 1088 new gudusoft.gsqlparser.TGSqlParser(vendor); 1089 parser.sqltext = text; 1090 if (parser.parse() != 0) { 1091 return body; 1092 } 1093 Deque<TCustomSqlStatement> stack = new ArrayDeque<TCustomSqlStatement>(); 1094 Set<TCustomSqlStatement> visited = Collections.newSetFromMap( 1095 new java.util.IdentityHashMap<TCustomSqlStatement, Boolean>()); 1096 Map<TCustomSqlStatement, gudusoft.gsqlparser.stmt.TInsertSqlStatement> 1097 insertOf = new java.util.IdentityHashMap<TCustomSqlStatement, 1098 gudusoft.gsqlparser.stmt.TInsertSqlStatement>(); 1099 for (int i = 0; i < parser.sqlstatements.size(); i++) { 1100 stack.push(parser.sqlstatements.get(i)); 1101 } 1102 while (!stack.isEmpty()) { 1103 TCustomSqlStatement stmt = stack.pop(); 1104 if (!visited.add(stmt)) { 1105 continue; // getStatements/getBodyStatements can overlap 1106 } 1107 if (stmt instanceof gudusoft.gsqlparser.stmt.TInsertSqlStatement) { 1108 gudusoft.gsqlparser.stmt.TInsertSqlStatement insert = 1109 (gudusoft.gsqlparser.stmt.TInsertSqlStatement) stmt; 1110 if (insert.getExecuteStmt() != null) { 1111 insertOf.put(insert.getExecuteStmt(), insert); 1112 stack.push(insert.getExecuteStmt()); 1113 } 1114 } else if (stmt instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) { 1115 gudusoft.gsqlparser.stmt.mssql.TMssqlExecute exec = 1116 (gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) stmt; 1117 // getSqlText() != null is dynamic EXEC('...') — that stays 1118 // with the dynamic-SQL machinery, not routine binding. 1119 if (exec.getModuleName() != null && exec.getSqlText() == null) { 1120 String rawName = exec.getModuleName().toString(); 1121 // sp_executesql/sp_execute_external_script are dynamic 1122 // executors, not static callees. EXEC @module_var is 1123 // indirect and cannot bind from the variable spelling. 1124 if (!isDynamicExecutorName(rawName) 1125 && !isIndirectMssqlExecute(rawName)) { 1126 // Recover the ;N group the parser drops from the 1127 // AST name (numbered procedures — codex-B3-r1 f6). 1128 String group = RoutineCatalog.numberedGroupAfter( 1129 exec.getModuleName()); 1130 if (group != null) { 1131 rawName = rawName + ";" + group; 1132 } 1133 sites.add(new CallSiteAst(rawName, 1134 exec, mssqlActuals(exec), insertOf.get(exec))); 1135 } 1136 } 1137 } else if (stmt instanceof gudusoft.gsqlparser.stmt.TCallStatement) { 1138 gudusoft.gsqlparser.stmt.TCallStatement call = 1139 (gudusoft.gsqlparser.stmt.TCallStatement) stmt; 1140 if (call.getRoutineName() != null 1141 && !isDynamicExecutorName( 1142 call.getRoutineName().toString())) { 1143 sites.add(new CallSiteAst( 1144 call.getRoutineName().toString(), call, 1145 expressionActuals(call.getArgs()), null)); 1146 } 1147 } else if (stmt instanceof gudusoft.gsqlparser.stmt.oracle.TBasicStmt) { 1148 // Oracle bare procedure calls inside PL/SQL bodies parse as 1149 // expression statements, NOT TCallStatement — missing them 1150 // let a calling summary claim COMPLETE (B2 gap, fixed here). 1151 gudusoft.gsqlparser.stmt.oracle.TBasicStmt basic = 1152 (gudusoft.gsqlparser.stmt.oracle.TBasicStmt) stmt; 1153 TExpression expr = basic.getExpr(); 1154 if (expr != null 1155 && expr.getExpressionType() == EExpressionType.function_t 1156 && expr.getFunctionCall() != null 1157 && expr.getFunctionCall().getFunctionName() != null) { 1158 TFunctionCall fc = expr.getFunctionCall(); 1159 String rawName = fc.getFunctionName().toString(); 1160 if (!isDynamicExecutorName(rawName)) { 1161 sites.add(new CallSiteAst(rawName, basic, 1162 expressionActuals(fc.getArgs()), null)); 1163 } 1164 } else if (expr != null 1165 && expr.getExpressionType() == EExpressionType.simple_object_name_t 1166 && expr.getObjectOperand() != null) { 1167 sites.add(new CallSiteAst(expr.getObjectOperand().toString(), 1168 basic, new ArrayList<CallSiteRef.Actual>(), null)); 1169 } 1170 } else if (stmt instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlReturn) { 1171 if (((gudusoft.gsqlparser.stmt.mssql.TMssqlReturn) stmt) 1172 .getReturnExpr() != null) { 1173 body.returnsValue = true; 1174 } 1175 } else if (stmt instanceof gudusoft.gsqlparser.stmt.TReturnStmt) { 1176 if (((gudusoft.gsqlparser.stmt.TReturnStmt) stmt) 1177 .getExpression() != null) { 1178 body.returnsValue = true; 1179 } 1180 } 1181 TStatementList children = stmt.getStatements(); 1182 if (children != null) { 1183 for (int i = 0; i < children.size(); i++) { 1184 stack.push(children.get(i)); 1185 } 1186 } 1187 if (stmt instanceof gudusoft.gsqlparser.stmt.TBlockSqlStatement) { 1188 TStatementList bodyStatements = 1189 ((gudusoft.gsqlparser.stmt.TBlockSqlStatement) stmt) 1190 .getBodyStatements(); 1191 if (bodyStatements != null) { 1192 for (int i = 0; i < bodyStatements.size(); i++) { 1193 stack.push(bodyStatements.get(i)); 1194 } 1195 } 1196 } 1197 } 1198 } catch (RuntimeException ex) { 1199 // call collection is best-effort; summaries stay usable 1200 } 1201 return body; 1202 } 1203 1204 /** Dynamic executor APIs carry generated SQL, not a statically named 1205 * routine call that the summary binder may resolve against user/catalog 1206 * definitions. Comparison goes through the vendor identifier facade so 1207 * quoting/case cannot reopen the frozen-call inventory. */ 1208 private boolean isDynamicExecutorName(String rawName) { 1209 if (rawName == null) { 1210 return false; 1211 } 1212 if (vendor == EDbVendor.dbvmssql) { 1213 List<String> parts = SQLUtil.parseNames(rawName); 1214 String simple = parts.isEmpty() 1215 ? rawName : parts.get(parts.size() - 1); 1216 return SQLUtil.sameName(vendor, ESQLDataObjectType.dotProcedure, 1217 simple, "sp_executesql") 1218 || SQLUtil.sameName(vendor, 1219 ESQLDataObjectType.dotProcedure, simple, 1220 "sp_execute_external_script"); 1221 } 1222 if (vendor == EDbVendor.dbvoracle) { 1223 return SQLUtil.compareIdentifier(vendor, 1224 ESQLDataObjectType.dotProcedure, 1225 rawName, "DBMS_SQL.PARSE") 1226 || SQLUtil.compareIdentifier(vendor, 1227 ESQLDataObjectType.dotProcedure, 1228 rawName, "SYS.DBMS_SQL.PARSE"); 1229 } 1230 return false; 1231 } 1232 1233 /** SQL Server's unquoted {@code EXEC @name} is an indirect procedure 1234 * invocation. The variable spelling is not the callee identity (while 1235 * {@code EXEC [@name]} may legitimately call a quoted static routine). */ 1236 private static boolean isIndirectMssqlExecute(String rawName) { 1237 return rawName != null && rawName.trim().startsWith("@"); 1238 } 1239 1240 /** Actuals of a T-SQL EXEC: named/positional, OUTPUT flag, value shape. */ 1241 private List<CallSiteRef.Actual> mssqlActuals( 1242 gudusoft.gsqlparser.stmt.mssql.TMssqlExecute exec) { 1243 List<CallSiteRef.Actual> actuals = new ArrayList<CallSiteRef.Actual>(); 1244 if (exec.getParameters() == null) { 1245 return actuals; 1246 } 1247 for (int i = 0; i < exec.getParameters().size(); i++) { 1248 gudusoft.gsqlparser.nodes.TExecParameter param = 1249 exec.getParameters().getExecParameter(i); 1250 if (param == null) { 1251 continue; 1252 } 1253 String named = param.getParameterName() == null 1254 ? null : param.getParameterName().toString(); 1255 boolean out = param.getParameterMode() == TBaseType.parameter_mode_out 1256 || param.getParameterMode() == TBaseType.parameter_mode_output; 1257 actuals.add(actualOf(i, named, out, param.getParameterValue())); 1258 } 1259 return actuals; 1260 } 1261 1262 /** Actuals of an expression-list call (Oracle CALL / bare PL/SQL call): 1263 * positional, with {@code formal => value} named notation unwrapped. */ 1264 private List<CallSiteRef.Actual> expressionActuals( 1265 gudusoft.gsqlparser.nodes.TExpressionList args) { 1266 List<CallSiteRef.Actual> actuals = new ArrayList<CallSiteRef.Actual>(); 1267 if (args == null) { 1268 return actuals; 1269 } 1270 for (int i = 0; i < args.size(); i++) { 1271 TExpression arg = args.getExpression(i); 1272 if (arg == null) { 1273 continue; 1274 } 1275 String named = null; 1276 TExpression value = arg; 1277 if (arg.getExpressionType() == EExpressionType.ref_arrow_t 1278 && arg.getLeftOperand() != null && arg.getRightOperand() != null) { 1279 named = arg.getLeftOperand().toString(); 1280 value = arg.getRightOperand(); 1281 } 1282 // PL/SQL has no call-site OUT marker; the declared mode decides. 1283 actuals.add(actualOf(i, named, false, value)); 1284 } 1285 return actuals; 1286 } 1287 1288 /** Classify one actual value expression: literal / bare reference / 1289 * expression with leaf fan-in. */ 1290 private CallSiteRef.Actual actualOf(int position, String named, boolean out, 1291 TExpression value) { 1292 if (value == null) { 1293 return new CallSiteRef.Actual(position, named, out, null, null, null, false); 1294 } 1295 EExpressionType type = value.getExpressionType(); 1296 // Bare keyword actuals parse as source tokens: DEFAULT means "use 1297 // the declared default" (distinct kind — compatibility requires 1298 // one); NULL is a plain literal (codex-B3-r2 finding 11). 1299 if (type == EExpressionType.simple_source_token_t) { 1300 String token = value.toString().trim(); 1301 if ("DEFAULT".equalsIgnoreCase(token)) { // non-identifier-compare: DEFAULT keyword token 1302 return new CallSiteRef.Actual(position, named, out, null, null, null, 1303 false, true); 1304 } 1305 if ("NULL".equalsIgnoreCase(token)) { // non-identifier-compare: NULL keyword token 1306 return new CallSiteRef.Actual(position, named, out, null, null, null, 1307 true); 1308 } 1309 } 1310 if (isCompileTimeLiteral(value)) { 1311 return new CallSiteRef.Actual(position, named, out, null, null, null, true); 1312 } 1313 if (type == EExpressionType.simple_object_name_t 1314 && value.getObjectOperand() != null) { 1315 return new CallSiteRef.Actual(position, named, out, 1316 value.getObjectOperand(), null, null, false); 1317 } 1318 final List<TObjectName> leafs = new ArrayList<TObjectName>(); 1319 final Set<TObjectName> functionNames = Collections.newSetFromMap( 1320 new java.util.IdentityHashMap<TObjectName, Boolean>()); 1321 value.acceptChildren(new TParseTreeVisitor() { 1322 @Override 1323 public void preVisit(TFunctionCall fc) { 1324 if (fc.getFunctionName() != null) { 1325 functionNames.add(fc.getFunctionName()); 1326 } 1327 } 1328 1329 @Override 1330 public void preVisit(TObjectName name) { 1331 leafs.add(name); 1332 } 1333 }); 1334 List<TObjectName> filtered = new ArrayList<TObjectName>(); 1335 for (TObjectName leaf : leafs) { 1336 if (!functionNames.contains(leaf)) { 1337 filtered.add(leaf); 1338 } 1339 } 1340 return new CallSiteRef.Actual(position, named, out, null, filtered, 1341 value.toString(), false); 1342 } 1343 1344 /** Compile-time literal shapes: plain constants and sign-prefixed 1345 * constants. Deliberately NOT {@code null_t} — that is the IS NULL 1346 * PREDICATE type, whose operand fan-in must survive (codex-B3-r2 1347 * finding 11); a bare NULL actual is a source token handled above. */ 1348 private static boolean isCompileTimeLiteral(TExpression value) { 1349 EExpressionType type = value.getExpressionType(); 1350 if (type == EExpressionType.simple_constant_t) { 1351 return true; 1352 } 1353 if (type == EExpressionType.unary_minus_t 1354 || type == EExpressionType.unary_plus_t) { 1355 TExpression operand = value.getRightOperand() != null 1356 ? value.getRightOperand() : value.getLeftOperand(); 1357 return operand != null && operand.getExpressionType() 1358 == EExpressionType.simple_constant_t; 1359 } 1360 return false; 1361 } 1362 1363 private boolean containsDynamicSql(String text) { 1364 // Cheap lexical screen for the PARTIAL flag only (never used for 1365 // binding): EXEC(<expr>) / sp_executesql / EXECUTE IMMEDIATE. 1366 String lower = text.toLowerCase(Locale.ROOT); 1367 return lower.contains("sp_executesql") 1368 || lower.contains("execute immediate") 1369 || lower.contains("dbms_sql.parse") 1370 || lower.matches("(?s).*\\bexec(ute)?\\s*\\(.*"); 1371 } 1372}