001package gudusoft.gsqlparser.dlineage.dynamicsql; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.TBaseType; 005import gudusoft.gsqlparser.TCustomSqlStatement; 006import gudusoft.gsqlparser.TSourceToken; 007import gudusoft.gsqlparser.TStatementList; 008import gudusoft.gsqlparser.dlineage.DataFlowAnalyzer; 009import gudusoft.gsqlparser.dlineage.dataflow.model.DynamicSqlSite; 010import gudusoft.gsqlparser.dlineage.dataflow.model.Option; 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.dataflow.model.xml.targetColumn; 016import gudusoft.gsqlparser.nodes.TParameterDeclaration; 017import gudusoft.gsqlparser.nodes.TParameterDeclarationList; 018import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 019import gudusoft.gsqlparser.sqlenv.TSQLEnv; 020import gudusoft.gsqlparser.stmt.mssql.TMssqlCreateProcedure; 021import gudusoft.gsqlparser.util.SQLUtil; 022 023import java.io.UnsupportedEncodingException; 024import java.security.MessageDigest; 025import java.security.NoSuchAlgorithmException; 026import java.util.ArrayList; 027import java.util.HashMap; 028import java.util.LinkedHashMap; 029import java.util.LinkedHashSet; 030import java.util.List; 031import java.util.Map; 032import java.util.Set; 033import java.util.TreeMap; 034 035/** 036 * Orchestrates dynamic-SQL lineage resolution: evaluate the procedure's 037 * dynamic-SQL string building under the given bindings 038 * ({@link TDynamicSqlStringEvaluator}), then for each materialized EXEC site run 039 * ordinary dlineage on the concrete string and tag the resulting edges with 040 * dynamic provenance. 041 * 042 * <p>This is purely additive — it constructs fresh, throwaway 043 * {@link DataFlowAnalyzer} instances for the materialized strings and never 044 * touches the default analysis path, so default dlineage output is unchanged. 045 */ 046public final class DynamicSqlLineageResolver { 047 048 private DynamicSqlLineageResolver() { 049 } 050 051 /** 052 * Materialized dynamic-SQL text for one EXEC / sp_executesql site, produced by the 053 * no-bindings default-path evaluation ({@link #materializeSitesWithoutBindings}). 054 */ 055 public static final class MaterializedSite { 056 /** Usable (possibly placeholder-bearing) SQL text, or {@code null} if the string did not reduce. */ 057 public final String sqlText; 058 /** True when the text still contains placeholder tokens for unbound variables/parameters. */ 059 public final boolean partial; 060 /** 061 * True unless the materialization is usable and every rendered character 062 * has literal provenance. This is intentionally stricter than 063 * {@link #partial}: a legacy-CONCRETE numeric/inexact transform can still 064 * have a provenance hole that SHADOW must report. 065 */ 066 public final boolean provenanceIncomplete; 067 /** Why the string did not reduce; non-null iff {@link #sqlText} is null. */ 068 public final String reason; 069 /** 070 * Ordered provenance fragments. When {@link #sqlText} is non-null their 071 * concatenated text equals it (design: 072 * dynamic-sql-fragment-provenance-design.md); SHADOW completeness checks 073 * and resolved dynamic edges consume that. When the string did not reduce 074 * ({@link #sqlText} null) they are the best-effort provenance of the 075 * argument — every hole met, in order, so a consumer can still name the 076 * value that stopped the fold; their concatenated text is only how far 077 * the fold got. Empty when the producing channel carries no provenance. 078 */ 079 public final java.util.List<SqlFragment> fragments; 080 /** Additive analysis note, such as a constant-relation over-approximation. */ 081 public final String diagnostic; 082 /** Stable value-set row-tuple suffix; empty on the scalar compatibility path. */ 083 public final String variantPath; 084 /** 085 * False when the statement may not run on the evaluated path: it sits in a CATCH block, or behind a 086 * condition or loop whose outcome is not known. Its text is still what it would run (Mantis 4962). 087 */ 088 public final boolean executionCertain; 089 /** Why the statement may not run, when {@link #executionCertain} is false; also part of the diagnostic. */ 090 public final String executionNote; 091 /** 092 * Non-null when the string is SQL NULL for a reason the evaluation proves (a query over a literal relation 093 * returned no row), so the site runs nothing on this variant; says why (Mantis 4962). 094 */ 095 public final String provenNullReason; 096 /** The parameter values this variant was evaluated with: none, or the call or caller that supplied them. */ 097 public final BindingSource bindingSource; 098 099 MaterializedSite(String sqlText, boolean partial, String reason) { 100 this(sqlText, partial, reason, null); 101 } 102 103 MaterializedSite(String sqlText, boolean partial, String reason, 104 java.util.List<SqlFragment> fragments) { 105 this(sqlText, partial, reason, fragments, null, ""); 106 } 107 108 MaterializedSite(String sqlText, boolean partial, String reason, 109 java.util.List<SqlFragment> fragments, String diagnostic, 110 String variantPath) { 111 this(sqlText, partial, reason, fragments, diagnostic, variantPath, true, null); 112 } 113 114 MaterializedSite(String sqlText, boolean partial, String reason, 115 java.util.List<SqlFragment> fragments, String diagnostic, 116 String variantPath, boolean executionCertain, String executionNote) { 117 this(sqlText, partial, reason, fragments, diagnostic, variantPath, executionCertain, executionNote, null, 118 BindingSource.NONE); 119 } 120 121 MaterializedSite(String sqlText, boolean partial, String reason, 122 java.util.List<SqlFragment> fragments, String diagnostic, 123 String variantPath, boolean executionCertain, String executionNote, String provenNullReason, 124 BindingSource bindingSource) { 125 this.provenNullReason = sqlText == null ? provenNullReason : null; 126 this.bindingSource = bindingSource == null ? BindingSource.NONE : bindingSource; 127 this.sqlText = sqlText; 128 this.partial = partial; 129 this.reason = reason; 130 this.fragments = fragments == null || fragments.isEmpty() 131 ? java.util.Collections.<SqlFragment>emptyList() 132 : java.util.Collections.unmodifiableList( 133 new java.util.ArrayList<SqlFragment>(fragments)); 134 this.provenanceIncomplete = !DynamicSqlLineageResolver 135 .hasCompleteLiteralProvenance(sqlText, this.fragments); 136 this.diagnostic = diagnostic; 137 this.variantPath = variantPath == null ? "" : variantPath; 138 this.executionCertain = executionCertain; 139 this.executionNote = executionCertain ? null : executionNote; 140 } 141 142 public boolean hasCompleteLiteralProvenance() { 143 return !provenanceIncomplete; 144 } 145 146 /** The same variant, its diagnostic carrying {@code note} too (sources merged when variants coincide). */ 147 public MaterializedSite withNote(String note) { 148 return new MaterializedSite(sqlText, partial, reason, fragments, joinNotes(diagnostic, note), variantPath, 149 executionCertain, executionNote, provenNullReason, bindingSource); 150 } 151 152 /** 153 * One variant standing for this one and {@code other}, which gives the same text (two bindings with the same 154 * result, Mantis 4962): the weaker evidence of the two, so no hole and no uncertainty is lost. Its provenance 155 * is the one with incomplete provenance or more holes; it runs for certain only when both do; its diagnostic 156 * and binding name both. 157 */ 158 public MaterializedSite mergedWith(MaterializedSite other) { 159 if (other == null || other == this) { 160 return this; 161 } 162 MaterializedSite weaker = weakness(other) > weakness(this) ? other : this; 163 MaterializedSite stronger = weaker == this ? other : this; 164 boolean certain = executionCertain && other.executionCertain; 165 String note = !certain ? (executionCertain ? other.executionNote : executionNote) : null; 166 String merged = weaker.diagnostic; 167 if (stronger.diagnostic != null) { 168 for (String part : stronger.diagnostic.split("; ")) { 169 if (merged == null || !java.util.Arrays.asList(merged.split("; ")).contains(part)) { 170 merged = joinNotes(merged, part); 171 } 172 } 173 } 174 return new MaterializedSite(weaker.sqlText, weaker.partial, weaker.reason, weaker.fragments, merged, 175 weaker.variantPath, certain, note, weaker.provenNullReason, 176 weaker.bindingSource.alsoAt(stronger.bindingSource)); 177 } 178 179 /** Incomplete provenance first, then the number of holes: how little the evidence proves. */ 180 private int weakness(MaterializedSite site) { 181 int holes = 0; 182 for (SqlFragment fragment : site.fragments) { 183 if (!fragment.isLiteral()) { 184 holes++; 185 } 186 } 187 return (site.provenanceIncomplete ? 1000000 : 0) + holes; 188 } 189 } 190 191 /** 192 * Default-path hook for {@link DataFlowAnalyzer}: abstractly evaluate a procedure's 193 * dynamic-SQL string building with NO call-site bindings — only literal parameter 194 * defaults are seeded — and return the materialized string per EXEC/sp_executesql 195 * statement node (identity-keyed against the SAME parse tree that was passed in). 196 * Constant- and template-built strings (REPLACE/QUOTENAME/CONCAT chains over 197 * literals and literal-seeded variables) reduce fully; strings depending on unbound 198 * parameters reduce to placeholder-bearing PARTIAL text; anything else is reported 199 * with a reason. Purely computational — nothing is executed and no model state of 200 * any analyzer is touched. 201 */ 202 public static Map<TCustomSqlStatement, MaterializedSite> materializeSitesWithoutBindings( 203 TCustomSqlStatement procAst, EDbVendor vendor, DynamicLineageOptions options) { 204 return materializeSites(procAst, vendor, null, options); 205 } 206 207 /** 208 * Same as {@link #materializeSitesWithoutBindings}, but with caller-supplied parameter 209 * bindings (e.g. extracted from a literal call site) seeded before the walk. Parameters 210 * absent from the bindings fall back to their literal defaults, then to placeholders. 211 */ 212 public static Map<TCustomSqlStatement, MaterializedSite> materializeSites( 213 TCustomSqlStatement procAst, EDbVendor vendor, Map<String, SqlValue> bindings, 214 DynamicLineageOptions options) { 215 List<Map<TCustomSqlStatement, MaterializedSite>> variants = 216 materializeSiteVariants(procAst, vendor, bindings, options); 217 return variants.isEmpty() 218 ? new java.util.IdentityHashMap<TCustomSqlStatement, MaterializedSite>() 219 : variants.get(0); 220 } 221 222 /** 223 * Materialize all bounded, row-correlated constant-relation variants for a 224 * procedure. Variant order is deterministic: declaration/statement order, 225 * then INSERT VALUES row order. The legacy single-map API above returns the 226 * first entry and is byte-compatible when the value-set feature is unused. 227 */ 228 public static List<Map<TCustomSqlStatement, MaterializedSite>> materializeSiteVariants( 229 TCustomSqlStatement procAst, EDbVendor vendor, Map<String, SqlValue> bindings, 230 DynamicLineageOptions options) { 231 return materializeSiteVariants(procAst, vendor, bindings, options, 232 bindings == null || bindings.isEmpty() ? BindingSource.NONE : BindingSource.explicit(hashBindings(bindings))); 233 } 234 235 /** 236 * {@link #materializeSiteVariants(TCustomSqlStatement, EDbVendor, Map, DynamicLineageOptions)}, each site 237 * stamped with where its bindings came from (Mantis 4962): a site's diagnostic names the binding, and the 238 * default analysis publishes a site some binding reached with the bound variants only. 239 */ 240 public static List<Map<TCustomSqlStatement, MaterializedSite>> materializeSiteVariants( 241 TCustomSqlStatement procAst, EDbVendor vendor, Map<String, SqlValue> bindings, 242 DynamicLineageOptions options, BindingSource source) { 243 if (source == null) { 244 source = BindingSource.NONE; 245 } 246 if (options == null) { 247 options = DynamicLineageOptions.createDefault(); 248 } 249 List<Map<TCustomSqlStatement, MaterializedSite>> variants = 250 new ArrayList<Map<TCustomSqlStatement, MaterializedSite>>(); 251 // Same resource guardrails as resolve(): a binding count over the cap is 252 // rejected outright (best-effort empty result, since this hook must not throw 253 // into the default analysis path). 254 if (bindings != null && bindings.size() > options.getMaxBindings()) { 255 return variants; 256 } 257 TStatementList body = extractBody(procAst); 258 if (body == null) { 259 return variants; 260 } 261 long deadlineNanos = System.nanoTime() + options.getTimeoutMillis() * 1_000_000L; 262 ConstRelationVariantPlanner.EvaluationPlan plan = 263 ConstRelationVariantPlanner.plan(body, vendor, options); 264 List<TDynamicSqlStringEvaluator> runs = evaluations(procAst, body, vendor, bindings, options, plan, 265 deadlineNanos); 266 // a run cut short, or variants left unexplored, make the variants a part of what may run: no NULL among 267 // them is proven for every run, and the sites say so (Mantis 4962) 268 String truncation = null; 269 for (TDynamicSqlStringEvaluator run : runs) { 270 if (run.isSitesTruncated()) { 271 truncation = "evaluation stopped at a limit after " + runs.size() 272 + " variant(s) (maxValueSetSize, maxBindings, maxSites or the timeout): other variants of" 273 + " the procedure are not analyzed"; 274 break; 275 } 276 } 277 for (TDynamicSqlStringEvaluator evaluator : runs) { 278 Map<TCustomSqlStatement, MaterializedSite> result = 279 new java.util.IdentityHashMap<TCustomSqlStatement, MaterializedSite>(); 280 for (TDynamicSqlStringEvaluator.RawSite raw : evaluator.evaluatedSites()) { 281 if (raw.node == null) { 282 continue; 283 } 284 SqlStringValue sql = raw.sql; 285 String diagnostic = joinNotes(joinNotes(joinNotes(raw.diagnostic, raw.executionNote), truncation), 286 source.getDescription()); 287 if (sql != null && sql.usable() && sql.text != null 288 && sql.text.length() > options.getMaxResolvedSqlLength()) { 289 result.put(raw.node, new MaterializedSite(null, false, 290 "resolved SQL length " + sql.text.length() 291 + " exceeds maxResolvedSqlLength", 292 sql.fragments, diagnostic, raw.variantPath, raw.controlFlowCertain, 293 raw.executionNote, null, source)); 294 } else if (sql != null && sql.usable()) { 295 result.put(raw.node, new MaterializedSite(sql.text, 296 sql.state == SqlStringValue.State.PARTIAL, null, sql.fragments, 297 diagnostic, raw.variantPath, raw.controlFlowCertain, 298 raw.executionNote, null, source)); 299 } else { 300 // Unreduced: no text, but the value still carries the holes the 301 // evaluator met (which variable, assigned from what, where) — 302 // that is the site's unresolved-fragment evidence. 303 result.put(raw.node, new MaterializedSite(null, false, 304 sql != null && sql.reason != null ? sql.reason 305 : "dynamic SQL did not reduce to a concrete string", 306 sql == null ? null : sql.fragments, diagnostic, raw.variantPath, 307 raw.controlFlowCertain, raw.executionNote, 308 truncation == null ? raw.provenNullReason : null, source)); 309 } 310 } 311 variants.add(result); 312 } 313 return variants; 314 } 315 316 /** 317 * Every path through the procedure's choice points (a SELECT that reads a literal relation's rows, Mantis 4962), 318 * as finished evaluations in variant order. The first run takes every choice's first alternative; then, depth 319 * first, each further alternative of each choice the earlier runs met, the deepest choice first, which orders 320 * the variants by their choices left to right, as the former plan expansion did. Copying the evaluation state 321 * at each choice and going on with every copy would give the same variants; replaying the procedure with the 322 * choices fixed keeps the evaluator a plain walk. 323 */ 324 private static List<TDynamicSqlStringEvaluator> evaluations(TCustomSqlStatement procAst, TStatementList body, 325 EDbVendor vendor, Map<String, SqlValue> bindings, DynamicLineageOptions options, 326 ConstRelationVariantPlanner.EvaluationPlan plan, long deadlineNanos) { 327 return evaluations(procAst, body, vendor, bindings, options, plan, deadlineNanos, null); 328 } 329 330 /** {@link #evaluations}, each run recording the operands {@code capture} names when it reaches its statement. */ 331 private static List<TDynamicSqlStringEvaluator> evaluations(TCustomSqlStatement procAst, TStatementList body, 332 EDbVendor vendor, Map<String, SqlValue> bindings, DynamicLineageOptions options, 333 ConstRelationVariantPlanner.EvaluationPlan plan, long deadlineNanos, Capture capture) { 334 List<TDynamicSqlStringEvaluator> runs = new ArrayList<TDynamicSqlStringEvaluator>(); 335 // each path's product of alternatives is capped by the evaluator; this bounds paths of differing shape 336 int limit = 4 * Math.max(1, Math.min(options.getMaxValueSetSize(), options.getMaxBindings())); 337 explore(new ArrayList<Integer>(), runs, limit, procAst, body, vendor, bindings, options, plan, deadlineNanos, 338 capture); 339 return runs; 340 } 341 342 /** A statement whose operands every run records when it reaches it: a call and its actuals (Mantis 4962). */ 343 private static final class Capture { 344 final TCustomSqlStatement statement; 345 final List<gudusoft.gsqlparser.nodes.TExpression> expressions; 346 347 Capture(TCustomSqlStatement statement, List<gudusoft.gsqlparser.nodes.TExpression> expressions) { 348 this.statement = statement; 349 this.expressions = expressions; 350 } 351 } 352 353 private static void explore(List<Integer> prefix, List<TDynamicSqlStringEvaluator> runs, int limit, 354 TCustomSqlStatement procAst, TStatementList body, EDbVendor vendor, Map<String, SqlValue> bindings, 355 DynamicLineageOptions options, ConstRelationVariantPlanner.EvaluationPlan plan, long deadlineNanos, 356 Capture capture) { 357 TDynamicSqlStringEvaluator evaluator = 358 new TDynamicSqlStringEvaluator(vendor, options, plan, deadlineNanos, prefix); 359 if (capture != null) { 360 evaluator.captureAt(capture.statement, capture.expressions); 361 } 362 evaluator.seedBindings(bindings); 363 evaluator.seedParameterDefaults(extractParams(procAst)); 364 evaluator.evaluate(body); 365 runs.add(evaluator); 366 List<Integer> taken = evaluator.takenDecisions(); 367 List<Integer> counts = evaluator.choiceCounts(); 368 for (int j = counts.size() - 1; j >= prefix.size(); j--) { 369 for (int k = 1; k < counts.get(j); k++) { 370 if (runs.size() >= limit || System.nanoTime() > deadlineNanos) { 371 evaluator.markSitesTruncated(); 372 return; 373 } 374 List<Integer> next = new ArrayList<Integer>(taken.subList(0, j)); 375 next.add(k); 376 explore(next, runs, limit, procAst, body, vendor, bindings, options, plan, deadlineNanos, capture); 377 } 378 } 379 } 380 381 public static DynamicLineageResult resolve(TCustomSqlStatement procAst, EDbVendor vendor, TSQLEnv sqlEnv, 382 String currentDatabase, String defaultSchema, Map<String, SqlValue> bindings, 383 DynamicLineageOptions options) { 384 385 if (options == null) { 386 options = DynamicLineageOptions.createDefault(); 387 } 388 if (bindings != null && bindings.size() > options.getMaxBindings()) { 389 throw new IllegalArgumentException( 390 "binding count " + bindings.size() + " exceeds maxBindings " + options.getMaxBindings()); 391 } 392 // no bindings given: the values preset in the catalog bind the procedure; bindings given replace them 393 if (bindings == null || bindings.isEmpty()) { 394 Map<String, SqlValue> presets = presetBindings(procAst, sqlEnv, currentDatabase, defaultSchema); 395 if (presets != null) { 396 if (presets.size() > options.getMaxBindings()) { 397 throw new IllegalArgumentException("preset binding count " + presets.size() 398 + " exceeds maxBindings " + options.getMaxBindings()); 399 } 400 bindings = presets; 401 } 402 } 403 404 TStatementList body = extractBody(procAst); 405 List<TParameterDeclaration> params = extractParams(procAst); 406 String sourceProc = extractProcName(procAst); 407 String bindingHash = hashBindings(bindings); 408 409 DynamicLineageResult result = new DynamicLineageResult(sourceProc, bindingHash); 410 if (body == null) { 411 return result; // not a recognized procedure shape — nothing to evaluate 412 } 413 String collision = caseCollision(procAst, sqlEnv); 414 if (collision != null) { 415 result.addSite(new DynamicSiteResult(DynamicSiteResult.Kind.EXEC_STRING, 416 DynamicSiteResult.Status.UNRESOLVED, null, null, sourceProc, 417 dynamicSiteId(sourceProc, "not-evaluated"), bindingHash, 418 "the dynamic SQL of the procedure is not evaluated: " + collision, 419 0L, 0L, new ArrayList<DynamicLineageEdge>(), options.getTrustMode(), 0, -1, false)); 420 return result; 421 } 422 423 long deadlineNanos = System.nanoTime() + options.getTimeoutMillis() * 1_000_000L; 424 ConstRelationVariantPlanner.EvaluationPlan plan = 425 ConstRelationVariantPlanner.plan(body, vendor, options); 426 java.util.IdentityHashMap<TCustomSqlStatement, java.util.Set<String>> seen = 427 new java.util.IdentityHashMap<TCustomSqlStatement, java.util.Set<String>>(); 428 boolean truncated = false; 429 int emitted = 0; 430 NodeNames nodeNames = new NodeNames(); 431 for (TDynamicSqlStringEvaluator evaluator : evaluations(procAst, body, vendor, bindings, options, 432 plan, deadlineNanos)) { 433 for (TDynamicSqlStringEvaluator.RawSite raw : evaluator.evaluatedSites()) { 434 if (raw.node == null) { 435 continue; 436 } 437 String valueKey = raw.sql == null ? "<null>" 438 : raw.sql.state + "\u0000" + raw.sql.text + "\u0000" + raw.sql.reason 439 + "\u0000" + raw.controlFlowCertain; 440 java.util.Set<String> values = seen.get(raw.node); 441 if (values == null) { 442 values = new java.util.LinkedHashSet<String>(); 443 seen.put(raw.node, values); 444 } 445 if (!values.add(valueKey)) { 446 continue; 447 } 448 if (emitted >= options.getMaxSites()) { 449 truncated = true; 450 break; 451 } 452 result.addSite(buildSite(raw, vendor, sqlEnv, currentDatabase, 453 defaultSchema, sourceProc, bindingHash, options, nodeNames)); 454 emitted++; 455 } 456 truncated |= evaluator.isSitesTruncated(); 457 if (truncated) { 458 break; 459 } 460 } 461 // Never let a truncated walk look complete — emit an honest diagnostic. 462 if (truncated) { 463 result.addSite(new DynamicSiteResult(DynamicSiteResult.Kind.EXEC_STRING, 464 DynamicSiteResult.Status.UNRESOLVED, null, null, sourceProc, 465 dynamicSiteId(sourceProc, "truncated"), bindingHash, 466 "site/time limit reached (maxSites=" + options.getMaxSites() 467 + ", timeout=" + options.getTimeoutMillis() + "ms); further sites dropped", 468 0L, 0L, new ArrayList<DynamicLineageEdge>(), options.getTrustMode(), 469 0, -1, false)); 470 } 471 return result; 472 } 473 474 /** 475 * The values preset in {@code sqlEnv} for the parameters of the procedure {@code procAst} defines (Mantis 4962), 476 * as bindings, or null when none is set. The procedure is found under the database its definition is in (its 477 * name's, the USE before it, else {@code currentDatabase}) and its schema ({@link #presetRoutineNames}): a preset 478 * never binds a procedure of the same name in another database. A value supplied but not known is 479 * {@link SqlValue#suppliedUnknown}, never an absent parameter (its default does not apply). 480 */ 481 public static Map<String, SqlValue> presetBindings(TCustomSqlStatement procAst, TSQLEnv sqlEnv, 482 String currentDatabase, String defaultSchema) { 483 // nothing preset anywhere: no lookup at all (a lazy catalog would fetch metadata for it) 484 if (sqlEnv == null || !sqlEnv.hasParameterPresets()) { 485 return null; 486 } 487 String name = presetRoutineName(procAst, sqlEnv, currentDatabase, defaultSchema); 488 if (name == null) { 489 return null; 490 } 491 Map<String, gudusoft.gsqlparser.sqlenv.TSQLRoutine.PresetValue> presets; 492 try { 493 presets = sqlEnv.getProcedureParameterPresets(name); 494 } catch (UnsupportedOperationException notSqlServer) { 495 return null; 496 } 497 if (presets.isEmpty()) { 498 return null; 499 } 500 Map<String, SqlValue> bindings = new java.util.LinkedHashMap<String, SqlValue>(); 501 for (Map.Entry<String, gudusoft.gsqlparser.sqlenv.TSQLRoutine.PresetValue> preset : presets.entrySet()) { 502 bindings.put(preset.getKey(), sqlValueOfPreset(preset.getKey(), preset.getValue())); 503 } 504 return bindings; 505 } 506 507 /** Where the presets of {@link #presetBindings} came from, for a site's diagnostic. */ 508 public static BindingSource presetSource(TCustomSqlStatement procAst, Map<String, SqlValue> presets) { 509 String name = procAst instanceof TMssqlCreateProcedure 510 && ((TMssqlCreateProcedure) procAst).getProcedureName() != null 511 ? ((TMssqlCreateProcedure) procAst).getProcedureName().toString() : "the procedure"; 512 return BindingSource.preset(name, hashBindings(presets)); 513 } 514 515 /** 516 * The one catalog name of the procedure {@code procAst} defines, as presets are found under it: its database 517 * (its name's, the USE before the definition, else {@code currentDatabase}, else the catalog's default) and its 518 * schema (its name's, else {@code defaultSchema}, else the catalog's default schema, else dbo), by 519 * {@link TSQLEnv#qualifyForPresets}. A procedure has one identity: a preset of a procedure of the same name in 520 * another schema or database never binds it. 521 */ 522 static String presetRoutineName(TCustomSqlStatement procAst, TSQLEnv sqlEnv, String currentDatabase, 523 String defaultSchema) { 524 if (sqlEnv == null || !(procAst instanceof TMssqlCreateProcedure)) { 525 return null; 526 } 527 gudusoft.gsqlparser.nodes.TObjectName name = ((TMssqlCreateProcedure) procAst).getProcedureName(); 528 if (name == null || name.getObjectString() == null || name.getObjectString().isEmpty()) { 529 return null; 530 } 531 String db = name.getDatabaseString(); 532 if ((db == null || db.isEmpty()) && procAst.getGsqlparser() != null) { 533 db = effectiveDatabaseOfProc(procAst.getGsqlparser().getSqlstatements(), procAst); 534 } 535 if (db == null || db.isEmpty()) { 536 db = currentDatabase; 537 } 538 String schema = name.getSchemaString(); 539 if (schema == null || schema.isEmpty()) { 540 schema = defaultSchema; 541 } 542 return sqlEnv.qualifyForPresets(name.getObjectString(), db, schema); 543 } 544 545 private static SqlValue sqlValueOfPreset(String parameter, gudusoft.gsqlparser.sqlenv.TSQLRoutine.PresetValue preset) { 546 switch (preset.getKind()) { 547 case STRING: 548 return SqlValue.of(preset.getText()); 549 case NUMBER: { 550 // the number as written, exact: a double would round 100000000000000000000 and long decimals 551 return SqlValue.of(new java.math.BigDecimal(preset.getText())); 552 } 553 case SQL_NULL: 554 return SqlValue.nullValue(); 555 default: 556 return SqlValue.suppliedUnknown(parameter + " is preset as supplied with a value that is not known"); 557 } 558 } 559 560 /** 561 * Discover parameter bindings from literal call sites of {@code procAst} in the same 562 * parsed file: top-level {@code EXEC <proc> 'literal', @p = 'literal', ...} statements 563 * whose (unqualified) module name matches the procedure name. Positional and named 564 * arguments are supported; OUTPUT and non-literal arguments are left unbound (they 565 * become placeholders during evaluation). Returns up to {@code maxBindingSets} distinct 566 * binding sets in file order. 567 */ 568 public static List<Map<String, SqlValue>> discoverCallSiteBindings(TCustomSqlStatement procAst, 569 int maxBindingSets) { 570 return discoverCallSites(procAst, maxBindingSets).getBindingSets(); 571 } 572 573 /** 574 * What a call-site discovery found and what it left: the literal binding sets, each with the call it came 575 * from, and a note for every call that names the procedure but was not taken, saying why (Mantis 4962). A 576 * call is not taken when the database in effect at the call is not the definition's, when its qualified name 577 * names another procedure, or when no argument can be bound. 578 */ 579 public static final class CallSiteDiscovery { 580 private final List<Map<String, SqlValue>> bindingSets = new ArrayList<Map<String, SqlValue>>(); 581 private final List<String> sources = new ArrayList<String>(); 582 private final List<BindingSource> bindingSources = new ArrayList<BindingSource>(); 583 private final List<String> notes = new ArrayList<String>(); 584 private boolean exhaustive = true; 585 586 /** 587 * True when the binding sets stand for every call of the procedure the discovery looked at (Mantis 4962): 588 * no call of it was left unbound (an actual that is not a literal, no bindable argument), none was dropped 589 * by the cap, none sits in a routine body, a block or INSERT ... EXEC, and no call names it without a schema 590 * while it is outside the default schema (the call may name another procedure of that name). 591 */ 592 public boolean isExhaustive() { 593 return exhaustive; 594 } 595 596 /** A call of the procedure the binding sets do not stand for; {@code why} says which and why. */ 597 void incomplete(String why) { 598 exhaustive = false; 599 note(why); 600 } 601 602 /** The binding sets, in the order and under the cap of the discovery. */ 603 public List<Map<String, SqlValue>> getBindingSets() { 604 return bindingSets; 605 } 606 607 /** Where each binding set came from ({@code "line 12"}, {@code "unit 2 line 1"}), parallel to the sets. */ 608 public List<String> getSources() { 609 return sources; 610 } 611 612 /** The call each binding set came from, kind and position, parallel to the sets. */ 613 public List<BindingSource> getBindingSources() { 614 return bindingSources; 615 } 616 617 /** Why each call that names the procedure was not bound. */ 618 public List<String> getNotes() { 619 return notes; 620 } 621 622 void note(String note) { 623 if (notes.size() < MAX_DISCOVERY_NOTES && !notes.contains(note)) { 624 notes.add(note); 625 } 626 } 627 } 628 629 /** Notes beyond this many add nothing a reader needs. */ 630 private static final int MAX_DISCOVERY_NOTES = 8; 631 632 /** 633 * {@link #discoverCallSiteBindings} with its account: the same binding sets in the same order, the call each 634 * came from, and the calls in the file that name the procedure but were not bound. 635 */ 636 public static CallSiteDiscovery discoverCallSites(TCustomSqlStatement procAst, int maxBindingSets) { 637 return discoverCallSites(procAst, maxBindingSets, null, null, null); 638 } 639 640 /** 641 * {@link #discoverCallSites(TCustomSqlStatement, int)} with what a call whose actuals are not literals needs 642 * (Mantis 4962): the catalog ({@code sqlEnv}, for a calling procedure's presets), the other units 643 * ({@code catalog}, for a calling procedure's own calls there) and the evaluation's options. Every call of the 644 * procedure in the file is bound: a call in a batch with its actuals as the batch has them at the call, a call in 645 * another procedure's body with its actuals as that procedure has them, its own parameters bound in turn (its 646 * presets, else its calls, up to {@value #MAX_CALLER_DEPTH} levels, else values nobody knows). 647 */ 648 public static CallSiteDiscovery discoverCallSites(TCustomSqlStatement procAst, int maxBindingSets, TSQLEnv sqlEnv, 649 RoutineCatalog catalog, DynamicLineageOptions options) { 650 return discoverCallSites(procAst, maxBindingSets, new DiscoveryContext(procAst, sqlEnv, catalog, options)); 651 } 652 653 private static CallSiteDiscovery discoverCallSites(TCustomSqlStatement procAst, int maxBindingSets, 654 DiscoveryContext ctx) { 655 CallSiteDiscovery discovery = new CallSiteDiscovery(); 656 if (!(procAst instanceof TMssqlCreateProcedure) || procAst.getGsqlparser() == null) { 657 return discovery; 658 } 659 TMssqlCreateProcedure proc = (TMssqlCreateProcedure) procAst; 660 if (proc.getProcedureName() == null) { 661 return discovery; 662 } 663 String procSimpleName = lastNameSegment(proc.getProcedureName().toString()); 664 List<TParameterDeclaration> params = extractParams(procAst); 665 String procDb = effectiveDatabaseOfProc(procAst.getGsqlparser().getSqlstatements(), procAst); 666 List<BindingCandidate> candidates = sameFileBindingCandidates(procAst, proc, procSimpleName, 667 params, procDb, Integer.MAX_VALUE, discovery, ctx); 668 for (int i = 0; i < candidates.size(); i++) { 669 if (i >= maxBindingSets) { 670 discovery.incomplete("calls of " + proc.getProcedureName() + " give " + candidates.size() 671 + " distinct bindings, more than the " + maxBindingSets + " evaluated"); 672 break; 673 } 674 discovery.bindingSets.add(candidates.get(i).bindings); 675 discovery.sources.add(candidates.get(i).source); 676 discovery.bindingSources.add(candidates.get(i).bindingSource); 677 } 678 return discovery; 679 } 680 681 /** How many levels of calling procedures a call's actuals are followed through. */ 682 static final int MAX_CALLER_DEPTH = 4; 683 684 /** What one call-site discovery shares while it follows actuals through calling procedures. */ 685 private static final class DiscoveryContext { 686 final TSQLEnv sqlEnv; 687 final RoutineCatalog catalog; 688 final DynamicLineageOptions options; 689 final long deadlineNanos; 690 final int depth; 691 /** The procedures whose calls are being followed, the callee first: a call among them is recursive. */ 692 final java.util.Set<TCustomSqlStatement> chain; 693 /** 694 * Each calling procedure's preset binding sets, worked out once. Only presets are kept: a binding found by 695 * following the caller's own calls depends on the depth and the chain it was reached through. 696 */ 697 final Map<TCustomSqlStatement, List<Map<String, SqlValue>>> callerBindings; 698 /** The catalog's identifier rules, or null for SQL Server's own (no catalog). */ 699 final gudusoft.gsqlparser.sqlenv.IdentifierService names; 700 701 DiscoveryContext(TCustomSqlStatement callee, TSQLEnv sqlEnv, RoutineCatalog catalog, 702 DynamicLineageOptions options) { 703 this.names = sqlEnv == null ? null : sqlEnv.getIdentifierService(); 704 this.sqlEnv = sqlEnv; 705 this.catalog = catalog; 706 this.options = options == null ? DynamicLineageOptions.createDefault() : options; 707 this.deadlineNanos = System.nanoTime() + this.options.getTimeoutMillis() * 1_000_000L; 708 this.depth = 0; 709 this.chain = java.util.Collections.newSetFromMap( 710 new java.util.IdentityHashMap<TCustomSqlStatement, Boolean>()); 711 if (callee != null) { 712 this.chain.add(callee); 713 } 714 this.callerBindings = new java.util.IdentityHashMap<TCustomSqlStatement, List<Map<String, SqlValue>>>(); 715 } 716 717 private DiscoveryContext(DiscoveryContext parent, TCustomSqlStatement caller) { 718 this.names = parent.names; 719 this.sqlEnv = parent.sqlEnv; 720 this.catalog = parent.catalog; 721 this.options = parent.options; 722 this.deadlineNanos = parent.deadlineNanos; 723 this.depth = parent.depth + 1; 724 this.chain = java.util.Collections.newSetFromMap( 725 new java.util.IdentityHashMap<TCustomSqlStatement, Boolean>()); 726 this.chain.addAll(parent.chain); 727 this.chain.add(caller); 728 this.callerBindings = parent.callerBindings; 729 } 730 731 DiscoveryContext deeper(TCustomSqlStatement caller) { 732 return new DiscoveryContext(this, caller); 733 } 734 735 boolean timedOut() { 736 return System.nanoTime() > deadlineNanos; 737 } 738 } 739 740 /** A call of some procedure in a unit, with where it sits. */ 741 private static final class CallSite { 742 final gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call; 743 final TStatementList unit; 744 /** The index in {@link #unit} of the top-level statement that is or holds the call. */ 745 final int topIndex; 746 /** The routine whose body holds the call; null for a call in a batch (also in its block, IF or INSERT ... EXEC). */ 747 final TCustomSqlStatement routine; 748 /** The database in effect (USE) at the top-level statement. */ 749 final String db; 750 751 CallSite(gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call, TStatementList unit, int topIndex, 752 TCustomSqlStatement routine, String db) { 753 this.call = call; 754 this.unit = unit; 755 this.topIndex = topIndex; 756 this.routine = routine; 757 this.db = db; 758 } 759 760 boolean topLevel() { 761 return routine == null && unit.get(topIndex) == call; 762 } 763 } 764 765 /** Every EXEC of a module in {@code unit}, top-level or nested, in order. */ 766 private static List<CallSite> callSitesIn(final TStatementList unit) { 767 final List<CallSite> sites = new ArrayList<CallSite>(); 768 if (unit == null) { 769 return sites; 770 } 771 String currentDb = null; 772 for (int i = 0; i < unit.size(); i++) { 773 final TCustomSqlStatement top = unit.get(i); 774 if (top instanceof gudusoft.gsqlparser.stmt.TUseDatabase) { 775 currentDb = String.valueOf(((gudusoft.gsqlparser.stmt.TUseDatabase) top).getDatabaseName()); 776 continue; 777 } 778 if (top instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) { 779 sites.add(new CallSite((gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) top, unit, i, null, currentDb)); 780 continue; 781 } 782 final int index = i; 783 final String db = currentDb; 784 final TCustomSqlStatement routine = top instanceof gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement 785 ? top : null; 786 top.acceptChildren(new gudusoft.gsqlparser.nodes.TParseTreeVisitor() { 787 @Override 788 public void preVisit(gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call) { 789 if (call != top) { 790 sites.add(new CallSite(call, unit, index, routine, db)); 791 } 792 } 793 }); 794 } 795 return sites; 796 } 797 798 /** 799 * The binding sets one call of the procedure gives (Mantis 4962). A top-level call whose actuals are all 800 * literals binds them as written. Otherwise the call's environment is evaluated with every path through its 801 * choice points, and each run that reaches the call records its actuals: a call in a batch runs after the 802 * batch's statements before it (T-SQL variables live in their batch); a call in a procedure's body runs with 803 * that procedure's parameters bound by its presets, else by its own calls when they cover all of them, else to 804 * values nobody knows. An actual is bound to its value when the value is exact (a literal string, an exact 805 * number, a NULL the run vouches for), else to a value supplied but not known. A call in a function or trigger 806 * body, a recursive call, or a run cut by a limit makes the discovery incomplete. 807 */ 808 private static List<BindingCandidate> candidatesForCall(CallSite site, TMssqlCreateProcedure proc, 809 List<TParameterDeclaration> params, String where, DiscoveryContext ctx, CallSiteDiscovery discovery) { 810 List<BindingCandidate> out = new ArrayList<BindingCandidate>(); 811 gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call = site.call; 812 String described = "call at " + where + " (" + call.getModuleName() + ")"; 813 if (site.topLevel() && allActualsLiteral(call)) { 814 String[] whyNot = new String[1]; 815 BindingCandidate candidate = bindingCandidateFromCall(call, params, where, whyNot, ctx.names); 816 if (candidate != null) { 817 out.add(candidate); 818 } else if (discovery != null) { 819 discovery.incomplete(described + " not bound: " + whyNot[0]); 820 } 821 return out; 822 } 823 if (site.routine != null && !(site.routine instanceof TMssqlCreateProcedure)) { 824 if (discovery != null) { 825 discovery.incomplete(described + " not bound: a call in a function or trigger body is not followed"); 826 } 827 return out; 828 } 829 if (site.routine != null && ctx.chain.contains(site.routine)) { 830 if (discovery != null) { 831 discovery.incomplete(described + " not bound: it is a recursive call"); 832 } 833 return out; 834 } 835 List<gudusoft.gsqlparser.nodes.TExpression> actuals = actualExpressions(call); 836 Capture capture = new Capture(call, actuals); 837 List<TDynamicSqlStringEvaluator> runs = new ArrayList<TDynamicSqlStringEvaluator>(); 838 TStatementList evaluated = site.routine == null ? batchOf(site.unit, site.topIndex) 839 : extractBody(site.routine); 840 if (evaluated == null) { 841 return out; 842 } 843 // the walk is linear: a GOTO may skip an assignment it applies, or reach the call again with other values; 844 // a GOTO after the call in its batch can jump back before it, so the whole batch is looked at 845 if (hasGoto(site.routine == null ? wholeBatchOf(site.unit, site.topIndex) : evaluated)) { 846 if (discovery != null) { 847 discovery.incomplete(described + " not bound: its caller has a GOTO, which the evaluation does not" 848 + " follow"); 849 } 850 return out; 851 } 852 String collision = caseCollision(declaredNames(evaluated, 853 site.routine == null ? null : extractParams(site.routine)), ctx.names); 854 if (collision != null) { 855 if (discovery != null) { 856 discovery.incomplete(described + " not bound: " + collision); 857 } 858 return out; 859 } 860 // the caller's choice points are the ones before the call: only its actuals are read from these runs 861 ConstRelationVariantPlanner.EvaluationPlan plan = 862 ConstRelationVariantPlanner.plan(evaluated, EDbVendor.dbvmssql, ctx.options, call); 863 if (site.routine == null) { 864 runs.addAll(evaluations(null, evaluated, EDbVendor.dbvmssql, null, ctx.options, plan, ctx.deadlineNanos, 865 capture)); 866 } else { 867 TMssqlCreateProcedure caller = (TMssqlCreateProcedure) site.routine; 868 List<Map<String, SqlValue>> callerSets = callerBindings(caller, ctx); 869 if (callerSets == null) { 870 if (discovery != null) { 871 discovery.incomplete(described + " not bound: the presets of " + extractProcName(caller) 872 + " give more bindings than maxBindings(" + ctx.options.getMaxBindings() + ")"); 873 } 874 return out; 875 } 876 for (Map<String, SqlValue> callerBindings : callerSets) { 877 runs.addAll(evaluations(caller, evaluated, EDbVendor.dbvmssql, callerBindings, ctx.options, plan, 878 ctx.deadlineNanos, capture)); 879 } 880 } 881 boolean cut = ctx.timedOut(); 882 for (TDynamicSqlStringEvaluator run : runs) { 883 cut |= run.isSitesTruncated(); 884 List<SqlStringValue> values = run.capturedValues(); 885 if (values == null) { 886 // this path does not reach the call 887 continue; 888 } 889 if (run.capturedInLoop() && discovery != null) { 890 // the first iteration's actuals bind; a later iteration may pass others, which nothing here sees 891 discovery.incomplete(described + " not bound for every run of it: it is in a loop body, whose later" 892 + " iterations may pass other values"); 893 } 894 BindingCandidate candidate = bindingCandidateFromValues(call, params, actuals, values, run, where, 895 ctx.names); 896 if (candidate == null) { 897 if (discovery != null) { 898 discovery.incomplete(described + " not bound: no argument binds a parameter"); 899 } 900 continue; 901 } 902 candidate.routine = site.routine == null ? null : extractProcName(site.routine); 903 candidate.mayNotRun = !run.capturedExecutionCertain(); 904 out.add(candidate); 905 } 906 if (cut && discovery != null) { 907 discovery.incomplete(described + " not bound in every way: the evaluation of its caller stopped at a" 908 + " limit"); 909 } 910 return out; 911 } 912 913 /** 914 * The binding sets of a calling procedure's parameters: its presets in the catalog; else the binding sets of its 915 * own calls, when they cover every call of it (followed up to {@value #MAX_CALLER_DEPTH} levels); else one set of 916 * values nobody knows (a caller outside the analysis may pass anything). Null when the presets give more bindings 917 * than maxBindings: the caller is then not evaluated with them, as {@link #resolve} refuses them. 918 */ 919 private static List<Map<String, SqlValue>> callerBindings(TMssqlCreateProcedure caller, DiscoveryContext ctx) { 920 List<Map<String, SqlValue>> known = ctx.callerBindings.get(caller); 921 if (known != null) { 922 return known; 923 } 924 List<Map<String, SqlValue>> sets = new ArrayList<Map<String, SqlValue>>(); 925 Map<String, SqlValue> presets = presetBindings(caller, ctx.sqlEnv, null, null); 926 if (presets != null) { 927 if (presets.size() > ctx.options.getMaxBindings()) { 928 return null; 929 } 930 sets.add(presets); 931 // presets do not depend on how the caller was reached 932 ctx.callerBindings.put(caller, sets); 933 return sets; 934 } 935 if (ctx.depth + 1 < MAX_CALLER_DEPTH && !ctx.timedOut()) { 936 DiscoveryContext deeper = ctx.deeper(caller); 937 CallSiteDiscovery own = discoverCallSites(caller, 4, deeper); 938 boolean exhaustive = own.isExhaustive(); 939 List<Map<String, SqlValue>> found = new ArrayList<Map<String, SqlValue>>(own.getBindingSets()); 940 if (ctx.catalog != null && found.size() < 4) { 941 CallSiteDiscovery cross = discoverCrossFileCallSites(caller, ctx.catalog, 4 - found.size(), deeper); 942 exhaustive &= cross.isExhaustive(); 943 found.addAll(cross.getBindingSets()); 944 } else if (ctx.catalog != null) { 945 exhaustive = false; 946 } 947 if (exhaustive && !found.isEmpty()) { 948 sets.addAll(found); 949 } 950 } 951 if (sets.isEmpty()) { 952 Map<String, SqlValue> unknown = new java.util.LinkedHashMap<String, SqlValue>(); 953 for (TParameterDeclaration param : extractParams(caller)) { 954 if (param.getParameterName() != null) { 955 String name = param.getParameterName().toString(); 956 unknown.put(name, SqlValue.suppliedUnknown(name + " is a parameter of " + extractProcName(caller) 957 + ", which no call in the analysis binds for every caller")); 958 } 959 } 960 sets.add(unknown); 961 } 962 // not kept: what the caller's own calls give depends on the depth and the chain it is reached through 963 return sets; 964 } 965 966 /** Every statement of the batch the top-level statement at {@code index} is in, between the GOs around it. */ 967 private static TStatementList wholeBatchOf(TStatementList unit, int index) { 968 TStatementList batch = batchOf(unit, index); 969 for (int k = index + 1; k < unit.size(); k++) { 970 if (unit.get(k) instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlGo) { 971 break; 972 } 973 batch.add(unit.get(k)); 974 } 975 return batch; 976 } 977 978 /** The statements of the batch the top-level statement at {@code index} is in, up to it (after the last GO). */ 979 private static TStatementList batchOf(TStatementList unit, int index) { 980 int start = 0; 981 for (int k = index - 1; k >= 0; k--) { 982 if (unit.get(k) instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlGo) { 983 start = k + 1; 984 break; 985 } 986 } 987 TStatementList batch = new TStatementList(); 988 for (int k = start; k <= index; k++) { 989 batch.add(unit.get(k)); 990 } 991 return batch; 992 } 993 994 /** 995 * The actual of each argument of {@code call}, in order. An OUTPUT argument is one too: the procedure receives 996 * the caller's value in it (the call's own write-back is the evaluator's, after the call). 997 */ 998 private static List<gudusoft.gsqlparser.nodes.TExpression> actualExpressions( 999 gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call) { 1000 List<gudusoft.gsqlparser.nodes.TExpression> actuals = new ArrayList<gudusoft.gsqlparser.nodes.TExpression>(); 1001 for (int j = 0; call.getParameters() != null && j < call.getParameters().size(); j++) { 1002 gudusoft.gsqlparser.nodes.TExecParameter arg = call.getParameters().getExecParameter(j); 1003 actuals.add(arg == null ? null : arg.getParameterValue()); 1004 } 1005 return actuals; 1006 } 1007 1008 /** True when every argument of {@code call} is a literal or DEFAULT. */ 1009 private static boolean allActualsLiteral(gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call) { 1010 for (gudusoft.gsqlparser.nodes.TExpression actual : actualExpressions(call)) { 1011 if (actual != null && !isDefaultKeyword(actual) && literalToSqlValue(actual) == null) { 1012 return false; 1013 } 1014 } 1015 return true; 1016 } 1017 1018 /** The DEFAULT keyword as an argument: the parameter takes its declared default, as if it were omitted. */ 1019 private static boolean isDefaultKeyword(gudusoft.gsqlparser.nodes.TExpression actual) { 1020 gudusoft.gsqlparser.EExpressionType type = actual.getExpressionType(); 1021 return (type == gudusoft.gsqlparser.EExpressionType.simple_source_token_t 1022 || type == gudusoft.gsqlparser.EExpressionType.simple_object_name_t 1023 || type == gudusoft.gsqlparser.EExpressionType.simple_constant_t) 1024 && "DEFAULT".equalsIgnoreCase(actual.toString().trim()); // non-identifier-compare: the DEFAULT keyword 1025 } 1026 1027 /** 1028 * The binding set a run's values of the call's actuals give: a literal argument binds as written (NULL, signed 1029 * numbers, strings), DEFAULT binds nothing (the default applies), and any other argument binds its value when 1030 * the value is exact, else a value supplied but not known ("call-site actual @f is not a known value"); null when 1031 * no argument binds a parameter. 1032 */ 1033 private static BindingCandidate bindingCandidateFromValues(gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call, 1034 List<TParameterDeclaration> params, List<gudusoft.gsqlparser.nodes.TExpression> actuals, 1035 List<SqlStringValue> values, TDynamicSqlStringEvaluator run, String source, 1036 gudusoft.gsqlparser.sqlenv.IdentifierService names) { 1037 Map<String, SqlValue> bindings = new java.util.LinkedHashMap<String, SqlValue>(); 1038 for (int j = 0; j < actuals.size(); j++) { 1039 gudusoft.gsqlparser.nodes.TExpression actual = actuals.get(j); 1040 if (actual == null || isDefaultKeyword(actual)) { 1041 continue; 1042 } 1043 gudusoft.gsqlparser.nodes.TExecParameter arg = call.getParameters().getExecParameter(j); 1044 String paramName = arg.getParameterName() != null ? arg.getParameterName().toString() 1045 : j < params.size() && params.get(j).getParameterName() != null 1046 ? params.get(j).getParameterName().toString() : null; 1047 if (paramName == null) { 1048 continue; 1049 } 1050 SqlValue literal = literalToSqlValue(actual); 1051 bindings.put(paramName, literal != null ? literal : boundValue(values.get(j), run, actual)); 1052 } 1053 return bindings.isEmpty() ? null : new BindingCandidate(bindings, bindingKey(bindings, params, names), source); 1054 } 1055 1056 /** 1057 * One key for a binding set, the same for a literal call and an evaluated one (Mantis 4962): each formal of the 1058 * procedure in declaration order with the typed value bound to it (names compared under the catalog's rules), 1059 * then the names no formal has, with theirs. Equal keys are equal binding sets. 1060 */ 1061 private static String bindingKey(Map<String, SqlValue> bindings, List<TParameterDeclaration> params, 1062 gudusoft.gsqlparser.sqlenv.IdentifierService names) { 1063 StringBuilder key = new StringBuilder(); 1064 java.util.Set<String> matched = new java.util.HashSet<String>(); 1065 for (int i = 0; i < params.size(); i++) { 1066 String formal = params.get(i).getParameterName() == null ? null 1067 : params.get(i).getParameterName().toString(); 1068 SqlValue bound = null; 1069 for (Map.Entry<String, SqlValue> entry : bindings.entrySet()) { 1070 if (formal != null && !matched.contains(entry.getKey()) 1071 && sameName(names, ESQLDataObjectType.dotColumn, formal, entry.getKey())) { 1072 bound = entry.getValue(); 1073 matched.add(entry.getKey()); 1074 break; 1075 } 1076 } 1077 key.append(i).append('=').append(bound == null ? "-" : valueKey(bound)).append(';'); 1078 } 1079 for (Map.Entry<String, SqlValue> entry : bindings.entrySet()) { 1080 if (!matched.contains(entry.getKey())) { 1081 key.append(entry.getKey().length()).append(':').append(entry.getKey()).append('=') 1082 .append(valueKey(entry.getValue())).append(';'); 1083 } 1084 } 1085 return key.toString(); 1086 } 1087 1088 /** A bound value, typed and length-prefixed: no two different values share it. */ 1089 private static String valueKey(SqlValue value) { 1090 if (value.isSuppliedUnknown()) { 1091 return "?"; 1092 } 1093 if (value.isNull()) { 1094 return "NULL"; 1095 } 1096 String text = value.asText() == null ? "" : value.asText(); 1097 return (value.isNumber() ? "N" : "S") + text.length() + ":" + text; 1098 } 1099 1100 /** Two names of the role {@code type} are one: under the catalog's identifier rules when given, else SQL Server's. */ 1101 private static boolean sameName(gudusoft.gsqlparser.sqlenv.IdentifierService names, ESQLDataObjectType type, 1102 String a, String b) { 1103 return names != null ? names.areEqual(a, b, type) : SQLUtil.sameName(EDbVendor.dbvmssql, type, a, b); 1104 } 1105 1106 /** True when a GOTO is among {@code statements}, nested in blocks, IF, WHILE or TRY/CATCH included. */ 1107 private static boolean hasGoto(TStatementList statements) { 1108 java.util.Deque<TCustomSqlStatement> stack = new java.util.ArrayDeque<TCustomSqlStatement>(); 1109 pushAll(stack, statements); 1110 while (!stack.isEmpty()) { 1111 TCustomSqlStatement s = stack.pop(); 1112 if (s instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlGoTo) { 1113 return true; 1114 } 1115 pushNested(stack, s); 1116 } 1117 return false; 1118 } 1119 1120 /** 1121 * The names {@code statements} declare (DECLARE, nested in blocks, IF, WHILE or TRY/CATCH) and the formals 1122 * {@code params}, in order. 1123 */ 1124 private static List<String> declaredNames(TStatementList statements, List<TParameterDeclaration> params) { 1125 List<String> names = new ArrayList<String>(); 1126 for (int i = 0; params != null && i < params.size(); i++) { 1127 if (params.get(i).getParameterName() != null) { 1128 names.add(params.get(i).getParameterName().toString()); 1129 } 1130 } 1131 java.util.Deque<TCustomSqlStatement> stack = new java.util.ArrayDeque<TCustomSqlStatement>(); 1132 pushAll(stack, statements); 1133 while (!stack.isEmpty()) { 1134 TCustomSqlStatement s = stack.pop(); 1135 if (s instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlDeclare 1136 && ((gudusoft.gsqlparser.stmt.mssql.TMssqlDeclare) s).getVariables() != null) { 1137 gudusoft.gsqlparser.nodes.TDeclareVariableList variables = 1138 ((gudusoft.gsqlparser.stmt.mssql.TMssqlDeclare) s).getVariables(); 1139 for (int i = 0; i < variables.size(); i++) { 1140 if (variables.getDeclareVariable(i) != null 1141 && variables.getDeclareVariable(i).getVariableName() != null) { 1142 names.add(variables.getDeclareVariable(i).getVariableName().toString()); 1143 } 1144 } 1145 } 1146 pushNested(stack, s); 1147 } 1148 return names; 1149 } 1150 1151 /** 1152 * Why the evaluation cannot be trusted with {@code declared} under the catalog's rules {@code names}: the 1153 * evaluation keys variables without their case, so two names the rules keep apart but that differ only in case 1154 * would share a value. Null when there are none (SQL Server's default rules ignore case as the evaluation does). 1155 */ 1156 public static String caseCollision(TCustomSqlStatement procAst, TSQLEnv sqlEnv) { 1157 if (sqlEnv == null || procAst == null) { 1158 return null; 1159 } 1160 return caseCollision(declaredNames(extractBody(procAst), extractParams(procAst)), sqlEnv.getIdentifierService()); 1161 } 1162 1163 static String caseCollision(List<String> declared, gudusoft.gsqlparser.sqlenv.IdentifierService names) { 1164 if (names == null) { 1165 return null; 1166 } 1167 Map<String, String> byKey = new java.util.HashMap<String, String>(); 1168 for (String name : declared) { 1169 String key = TDynamicSqlStringEvaluator.variableKey(name); 1170 String other = byKey.get(key); 1171 if (other == null) { 1172 byKey.put(key, name); 1173 } else if (!names.areEqual(other, name, ESQLDataObjectType.dotColumn)) { 1174 return "variables " + other + " and " + name + " differ only in case, which the dynamic SQL" 1175 + " evaluation does not tell apart"; 1176 } 1177 } 1178 return null; 1179 } 1180 1181 private static void pushAll(java.util.Deque<TCustomSqlStatement> stack, TStatementList statements) { 1182 for (int i = 0; statements != null && i < statements.size(); i++) { 1183 if (statements.get(i) != null) { 1184 stack.push(statements.get(i)); 1185 } 1186 } 1187 } 1188 1189 private static void pushNested(java.util.Deque<TCustomSqlStatement> stack, TCustomSqlStatement s) { 1190 if (s instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlBlock) { 1191 pushAll(stack, ((gudusoft.gsqlparser.stmt.mssql.TMssqlBlock) s).getBodyStatements()); 1192 } else if (s instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlIfElse) { 1193 if (((gudusoft.gsqlparser.stmt.mssql.TMssqlIfElse) s).getStmt() != null) { 1194 stack.push(((gudusoft.gsqlparser.stmt.mssql.TMssqlIfElse) s).getStmt()); 1195 } 1196 if (((gudusoft.gsqlparser.stmt.mssql.TMssqlIfElse) s).getElseStmt() != null) { 1197 stack.push(((gudusoft.gsqlparser.stmt.mssql.TMssqlIfElse) s).getElseStmt()); 1198 } 1199 } else if (s instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlTryCatch) { 1200 pushAll(stack, ((gudusoft.gsqlparser.stmt.mssql.TMssqlTryCatch) s).getTryStatements()); 1201 pushAll(stack, ((gudusoft.gsqlparser.stmt.mssql.TMssqlTryCatch) s).getCatchStatements()); 1202 } else if (s instanceof gudusoft.gsqlparser.stmt.TWhileStmt) { 1203 pushAll(stack, ((gudusoft.gsqlparser.stmt.TWhileStmt) s).getBodyStatements()); 1204 } 1205 } 1206 1207 /** What an actual's value binds: the value when it is exact, else a value supplied but not known. */ 1208 private static SqlValue boundValue(SqlStringValue value, TDynamicSqlStringEvaluator run, 1209 gudusoft.gsqlparser.nodes.TExpression actual) { 1210 String written = actual.toString() == null ? "" : actual.toString().trim(); 1211 SqlValue unknown = SqlValue.suppliedUnknown("call-site actual " + written + " is not a known value"); 1212 if (value == null) { 1213 return unknown; 1214 } 1215 switch (value.state) { 1216 case NULL: 1217 return run.isTrustedNull(value) ? SqlValue.nullValue() : unknown; 1218 case CONCRETE: 1219 if (value.number != null) { 1220 return LiteralRelation.isExactNumber(value) ? numberValue(value.text) : unknown; 1221 } 1222 for (SqlFragment fragment : value.fragments) { 1223 if (!fragment.isLiteral()) { 1224 return unknown; 1225 } 1226 } 1227 return value.text == null ? unknown : SqlValue.of(value.text); 1228 default: 1229 return unknown; 1230 } 1231 } 1232 1233 /** A number written as {@code text}, exact. */ 1234 private static SqlValue numberValue(String text) { 1235 return SqlValue.of(new java.math.BigDecimal(text.trim())); 1236 } 1237 1238 /** 1239 * True when {@code call} names the procedure without a schema while the procedure is outside the default 1240 * schema (dbo): the call may name a procedure of that name in the caller's default schema instead. 1241 */ 1242 private static boolean ambiguousUnqualifiedCall(gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call, 1243 TMssqlCreateProcedure proc, gudusoft.gsqlparser.sqlenv.IdentifierService names) { 1244 if (call.getModuleName() == null || SQLUtil.parseNames(call.getModuleName().toString()).size() != 1) { 1245 return false; 1246 } 1247 String schema = proc.getProcedureName().getSchemaString(); 1248 return schema != null && !schema.isEmpty() && !sameName(names, ESQLDataObjectType.dotSchema, schema, "dbo"); 1249 } 1250 1251 /** 1252 * Matching call sites in the procedure's own file, in file order, deduplicated by 1253 * canonical key, up to {@code cap}. Tracks the effective database (USE statements) 1254 * so a call after USE DbB is never bound to a same-named procedure defined under 1255 * USE DbA. A call that names the procedure but is not taken is noted in 1256 * {@code discovery} when one is given. 1257 */ 1258 private static List<BindingCandidate> sameFileBindingCandidates(TCustomSqlStatement procAst, 1259 TMssqlCreateProcedure proc, String procSimpleName, List<TParameterDeclaration> params, 1260 String procDb, int cap, CallSiteDiscovery discovery, DiscoveryContext ctx) { 1261 List<BindingCandidate> result = new ArrayList<BindingCandidate>(); 1262 Map<String, BindingCandidate> seen = new java.util.HashMap<String, BindingCandidate>(); 1263 for (CallSite site : callSitesIn(procAst.getGsqlparser().getSqlstatements())) { 1264 if (result.size() >= cap) { 1265 break; 1266 } 1267 gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call = site.call; 1268 if (call.getModuleName() == null || call.getSqlText() != null) { 1269 continue; 1270 } 1271 String where = "line " + lineOf(call); 1272 if (!callMatchesProc(call, proc, procSimpleName, procDb, site.db, ctx.names)) { 1273 if (site.topLevel()) { 1274 noteSkippedCall(discovery, call, proc, procSimpleName, procDb, site.db, where, ctx.names); 1275 } 1276 continue; 1277 } 1278 if (discovery != null && ambiguousUnqualifiedCall(call, proc, ctx.names)) { 1279 discovery.incomplete("call at " + where + " (" + call.getModuleName() + ") names " 1280 + proc.getProcedureName() + " without a schema: it may name a procedure of that name in the" 1281 + " caller's default schema"); 1282 } 1283 for (BindingCandidate candidate : candidatesForCall(site, proc, params, where, ctx, discovery)) { 1284 candidate.bindingSource = BindingSource.sameFile(lineOf(call), hashBindings(candidate.bindings), 1285 candidate.routine, candidate.mayNotRun); 1286 BindingCandidate earlier = seen.get(candidate.canonicalKey); 1287 if (earlier == null) { 1288 seen.put(candidate.canonicalKey, candidate); 1289 result.add(candidate); 1290 } else { 1291 // the same values from another call: one binding set that names both calls 1292 earlier.bindingSource = earlier.bindingSource.alsoAt(candidate.bindingSource); 1293 } 1294 } 1295 } 1296 return result; 1297 } 1298 1299 /** 1300 * Notes why a call that names the procedure by its simple name was not matched to it. Calls to other 1301 * procedures say nothing. 1302 */ 1303 private static void noteSkippedCall(CallSiteDiscovery discovery, 1304 gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call, TMssqlCreateProcedure proc, String procSimpleName, 1305 String procDb, String callDb, String where, gudusoft.gsqlparser.sqlenv.IdentifierService names) { 1306 if (discovery == null || call.getModuleName() == null || call.getSqlText() != null) { 1307 return; 1308 } 1309 String callName = call.getModuleName().toString(); 1310 if (!sameName(names, ESQLDataObjectType.dotProcedure, lastNameSegment(callName), procSimpleName)) { 1311 return; 1312 } 1313 List<String> callParts = SQLUtil.parseNames(callName); 1314 List<String> procParts = SQLUtil.parseNames(proc.getProcedureName().toString()); 1315 String why; 1316 if (!sameEffectiveDatabase(procDb, callDb, callName, names)) { 1317 why = "the database in effect at the call (" + describeDatabase(callDb) 1318 + ") is not the one the procedure is defined in (" + describeDatabase(procDb) 1319 + "); a one- or two-part name is bound only within the same database"; 1320 } else if (callParts.size() == 3 && procParts.size() == 2 && procDb == null) { 1321 why = "no USE precedes the definition, so its database is unknown and a three-part name" 1322 + " cannot be shown to name it"; 1323 } else { 1324 why = "the qualified name does not name the definition " + proc.getProcedureName(); 1325 } 1326 discovery.note("call at " + where + " (" + callName + ") not bound: " + why); 1327 } 1328 1329 private static String describeDatabase(String db) { 1330 return db == null ? "none" : db; 1331 } 1332 1333 private static long lineOf(TCustomSqlStatement stmt) { 1334 return stmt == null || stmt.getStartToken() == null ? -1 : stmt.getStartToken().lineNo; 1335 } 1336 1337 /** 1338 * Discover parameter bindings from literal call sites of {@code procAst} located in 1339 * OTHER files of the same multi-file analysis (plan Phase 5: cross-file routine 1340 * references). {@code otherFileSqlTexts} holds the raw SQL text of every analysis 1341 * unit; the procedure's own file is skipped by text identity. A text is parsed at 1342 * most once per analysis via the caller-owned {@code parseCache} (key: the text), 1343 * and only when it mentions the procedure's simple name at all. 1344 * 1345 * <p>Never guesses across ambiguity: if any other file defines a procedure with the 1346 * same simple name in a database-compatible context, there are two candidate owners 1347 * for a matching call and this method returns no bindings at all. Binding sets are 1348 * returned in canonical-key order, so the result is independent of the file order 1349 * of the analysis. Binding sets already surfaced by the same-file discovery above 1350 * are excluded here, so duplicates never consume the caller's shared cap. 1351 */ 1352 public static List<Map<String, SqlValue>> discoverCrossFileCallSiteBindings( 1353 TCustomSqlStatement procAst, RoutineCatalog catalog, int maxBindingSets) { 1354 return discoverCrossFileCallSites(procAst, catalog, maxBindingSets).getBindingSets(); 1355 } 1356 1357 /** 1358 * {@link #discoverCrossFileCallSiteBindings} with its account: the same binding sets in the same order, the 1359 * unit and line each came from, and why calls in other units that name the procedure were not bound. 1360 */ 1361 public static CallSiteDiscovery discoverCrossFileCallSites( 1362 TCustomSqlStatement procAst, RoutineCatalog catalog, int maxBindingSets) { 1363 return discoverCrossFileCallSites(procAst, catalog, maxBindingSets, null, null); 1364 } 1365 1366 /** 1367 * {@link #discoverCrossFileCallSites(TCustomSqlStatement, RoutineCatalog, int)} with the catalog and options a 1368 * call whose actuals are not literals needs ({@link #discoverCallSites(TCustomSqlStatement, int, TSQLEnv, 1369 * RoutineCatalog, DynamicLineageOptions)}). 1370 */ 1371 public static CallSiteDiscovery discoverCrossFileCallSites(TCustomSqlStatement procAst, RoutineCatalog catalog, 1372 int maxBindingSets, TSQLEnv sqlEnv, DynamicLineageOptions options) { 1373 return discoverCrossFileCallSites(procAst, catalog, maxBindingSets, 1374 new DiscoveryContext(procAst, sqlEnv, catalog, options)); 1375 } 1376 1377 private static CallSiteDiscovery discoverCrossFileCallSites(TCustomSqlStatement procAst, RoutineCatalog catalog, 1378 int maxBindingSets, DiscoveryContext ctx) { 1379 CallSiteDiscovery discovery = new CallSiteDiscovery(); 1380 if (maxBindingSets <= 0 || catalog == null 1381 || !(procAst instanceof TMssqlCreateProcedure) 1382 || procAst.getGsqlparser() == null) { 1383 return discovery; 1384 } 1385 TMssqlCreateProcedure proc = (TMssqlCreateProcedure) procAst; 1386 if (proc.getProcedureName() == null) { 1387 return discovery; 1388 } 1389 String procSimpleName = lastNameSegment(proc.getProcedureName().toString()); 1390 String probeName = bareName(procSimpleName); 1391 if (probeName.isEmpty()) { 1392 return discovery; 1393 } 1394 List<TParameterDeclaration> params = extractParams(procAst); 1395 String ownText = procAst.getGsqlparser().sqltext; 1396 // For matching and the ambiguity verdict, an explicit catalog on the target 1397 // definition's own three-part name outranks the USE-derived database (a 1398 // definition CREATE PROC DbA.dbo.p IS provably in DbA). 1399 String sameFileProcDb = effectiveDatabaseOfProc(procAst.getGsqlparser().getSqlstatements(), procAst); 1400 String procDb = definitionDatabase(proc, sameFileProcDb); 1401 1402 // The same-file pass already surfaced these binding sets (cross-file is only 1403 // invoked when its cap was not reached, so an uncapped re-collection sees 1404 // exactly the sets it returned). Excluding their keys keeps duplicates from 1405 // consuming the shared cap and displacing distinct cross-file bindings. 1406 java.util.Set<String> sameFileKeys = new java.util.HashSet<String>(); 1407 for (BindingCandidate sameFile : sameFileBindingCandidates(procAst, proc, procSimpleName, 1408 params, sameFileProcDb, Integer.MAX_VALUE, null, ctx)) { 1409 sameFileKeys.add(sameFile.canonicalKey); 1410 } 1411 1412 // Canonical-key ordering makes the accepted binding sets (and which ones 1413 // survive the cap) independent of the order files were passed in. 1414 java.util.TreeMap<String, BindingCandidate> ordered = 1415 new java.util.TreeMap<String, BindingCandidate>(); 1416 List<String> texts = catalog.unitTexts(); 1417 for (int unitIndex = 0; unitIndex < texts.size(); unitIndex++) { 1418 String text = texts.get(unitIndex); 1419 if (text == null || text.equals(ownText)) { 1420 continue; 1421 } 1422 if (!text.toLowerCase(java.util.Locale.ENGLISH) 1423 .contains(probeName.toLowerCase(java.util.Locale.ENGLISH))) { 1424 continue; 1425 } 1426 RoutineCatalog.UnitIndex unit = catalog.indexOf(text); 1427 if (unit == null) { 1428 continue; 1429 } 1430 for (RoutineCatalog.Definition def : unit.definitions) { 1431 // A same-named definition in another file: unless the two are 1432 // provably in different databases, a matching call has two 1433 // candidate owners - bail out entirely rather than guess. 1434 if (sameName(ctx.names, ESQLDataObjectType.dotProcedure, def.simple, procSimpleName) 1435 && !provablyDifferentDatabase(procDb, def.db, ctx.names)) { 1436 CallSiteDiscovery ambiguous = new CallSiteDiscovery(); 1437 ambiguous.incomplete("calls in other units not bound: unit " + (unitIndex + 1) 1438 + " also defines a procedure named " + def.simple 1439 + " that is not provably in another database"); 1440 return ambiguous; 1441 } 1442 } 1443 String unitName = "unit " + (unitIndex + 1); 1444 for (CallSite site : callSitesIn(catalog.statementsOf(text))) { 1445 gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call = site.call; 1446 if (call.getModuleName() == null || call.getSqlText() != null) { 1447 continue; 1448 } 1449 String where = unitName + " line " + lineOf(call); 1450 if (!callMatchesProc(call, proc, procSimpleName, procDb, site.db, ctx.names)) { 1451 if (site.topLevel()) { 1452 noteSkippedCall(discovery, call, proc, procSimpleName, procDb, site.db, where, ctx.names); 1453 } 1454 continue; 1455 } 1456 if (ambiguousUnqualifiedCall(call, proc, ctx.names)) { 1457 discovery.incomplete("call at " + where + " (" + call.getModuleName() + ") names " 1458 + proc.getProcedureName() + " without a schema: it may name a procedure of that name" 1459 + " in the caller's default schema"); 1460 } 1461 for (BindingCandidate candidate : candidatesForCall(site, proc, params, where, ctx, discovery)) { 1462 candidate.bindingSource = BindingSource.crossFile(unitName, lineOf(call), 1463 hashBindings(candidate.bindings), candidate.routine, candidate.mayNotRun); 1464 BindingCandidate earlier = ordered.get(candidate.canonicalKey); 1465 if (earlier != null) { 1466 earlier.bindingSource = earlier.bindingSource.alsoAt(candidate.bindingSource); 1467 } else if (!sameFileKeys.contains(candidate.canonicalKey)) { 1468 ordered.put(candidate.canonicalKey, candidate); 1469 } 1470 } 1471 } 1472 } 1473 for (BindingCandidate candidate : ordered.values()) { 1474 if (discovery.bindingSets.size() >= maxBindingSets) { 1475 discovery.incomplete("calls of " + proc.getProcedureName() + " in the other units give " 1476 + ordered.size() + " distinct bindings, more than the " + maxBindingSets + " evaluated"); 1477 break; 1478 } 1479 discovery.bindingSets.add(candidate.bindings); 1480 discovery.sources.add(candidate.source); 1481 discovery.bindingSources.add(candidate.bindingSource); 1482 } 1483 return discovery; 1484 } 1485 1486 /** 1487 * Oracle analog of cross-file call-site discovery (plan Phase 5): collect 1488 * top-level statements from OTHER files (anonymous blocks, CALL statements) 1489 * that mention a routine defined in the current file, so the caller can feed 1490 * them to {@code TASTEvaluator} together with the file's own statements and 1491 * fold parameter-dependent EXECUTE IMMEDIATE templates under the callers' 1492 * literal arguments. 1493 * 1494 * <p>Mirrors the MSSQL rules: a text is parsed at most once per analysis via 1495 * {@code parseCache} and only when it mentions one of the candidate routine 1496 * names; a same-simple-name routine DEFINITION in another file makes that 1497 * name ambiguous and drops it from the harvest (never guess between two 1498 * candidate owners); foreign definitions themselves are never appended. 1499 * Returned statements are ordered by their source text, so evaluation order 1500 * is independent of the file order of the analysis. 1501 */ 1502 public static List<TCustomSqlStatement> plsqlInterpretationStatements( 1503 TStatementList ownStatements, String ownText, RoutineCatalog catalog, 1504 int maxForeignCallers) { 1505 List<TCustomSqlStatement> result = new ArrayList<TCustomSqlStatement>(); 1506 if (ownStatements == null) { 1507 return result; 1508 } 1509 // Routine definitions in this file - the possible callees. Only routines whose 1510 // partial dynamic-SQL expressions are provably literal-bindable participate: 1511 // the interpreter folds any dependency it cannot evaluate (SELECT INTO 1512 // locals, function results, ...) to an EMPTY string, which would produce a 1513 // concrete-looking but WRONG statement that the placeholder classifier 1514 // cannot catch. 1515 List<PlsqlRoutineIdent> idents = new ArrayList<PlsqlRoutineIdent>(); 1516 for (int i = 0; i < ownStatements.size(); i++) { 1517 TCustomSqlStatement stmt = ownStatements.get(i); 1518 String name = plsqlRoutineSimpleName(stmt); 1519 if (name == null || name.isEmpty()) { 1520 continue; 1521 } 1522 if (plsqlDynamicSqlLiteralBindable(stmt)) { 1523 idents.add(new PlsqlRoutineIdent(stmt, name)); 1524 } 1525 } 1526 if (idents.isEmpty()) { 1527 return result; 1528 } 1529 // Foreign pass 1: definitions in other files decide ambiguity. A same-simple- 1530 // name definition that is not provably in a different schema means a matching 1531 // call has two candidate owners. 1532 List<RoutineCatalog.UnitIndex> foreignUnits = new ArrayList<RoutineCatalog.UnitIndex>(); 1533 if (catalog != null) { 1534 for (String text : catalog.unitTexts()) { 1535 if (text == null || text.equals(ownText)) { 1536 continue; 1537 } 1538 String lowered = text.toLowerCase(java.util.Locale.ENGLISH); 1539 boolean mentionsAny = false; 1540 for (PlsqlRoutineIdent ident : idents) { 1541 if (lowered.contains(bareName(ident.simple).toLowerCase(java.util.Locale.ENGLISH))) { 1542 mentionsAny = true; 1543 break; 1544 } 1545 } 1546 if (!mentionsAny) { 1547 continue; 1548 } 1549 RoutineCatalog.UnitIndex unit = catalog.indexOf(text); 1550 if (unit == null) { 1551 continue; 1552 } 1553 foreignUnits.add(unit); 1554 for (RoutineCatalog.Definition def : unit.definitions) { 1555 for (PlsqlRoutineIdent ident : idents) { 1556 if (!SQLUtil.sameName(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure, 1557 def.simple, ident.simple)) { 1558 continue; 1559 } 1560 // ANY same-simple-name duplicate disqualifies UNQUALIFIED calls: 1561 // the caller's current schema is unknown, so even a provably 1562 // different schema could be the one the call resolves in. 1563 ident.hasForeignSameName = true; 1564 if (!provablyDifferentSchema(ident.schema, def.schema)) { 1565 // Not provably different: qualified calls cannot separate 1566 // the two candidates either. 1567 ident.ambiguous = true; 1568 } 1569 } 1570 } 1571 } 1572 } 1573 // Same-file callers (file order, no cap - they were always visible to the 1574 // analysis), then foreign callers ordered by their EXACT source text so the 1575 // pick under the cap is independent of file order and distinct quoted 1576 // literals are never conflated. 1577 List<TCustomSqlStatement> callers = new ArrayList<TCustomSqlStatement>(); 1578 for (int i = 0; i < ownStatements.size(); i++) { 1579 TCustomSqlStatement stmt = ownStatements.get(i); 1580 if (plsqlRoutineSimpleName(stmt) != null || isPlsqlDefinitionShape(stmt)) { 1581 continue; 1582 } 1583 if (isVettedLiteralCaller(stmt, idents)) { 1584 callers.add(stmt); 1585 } 1586 } 1587 java.util.TreeMap<String, TCustomSqlStatement> ordered = 1588 new java.util.TreeMap<String, TCustomSqlStatement>(); 1589 for (RoutineCatalog.UnitIndex unit : foreignUnits) { 1590 for (RoutineCatalog.CallCandidate callCandidate : unit.callCandidates) { 1591 TCustomSqlStatement stmt = callCandidate.stmt; 1592 if (isPlsqlDefinitionShape(stmt)) { 1593 continue; 1594 } 1595 if (isVettedLiteralCaller(stmt, idents)) { 1596 String key = String.valueOf(stmt.toString()); 1597 if (!ordered.containsKey(key)) { 1598 ordered.put(key, stmt); 1599 } 1600 } 1601 } 1602 } 1603 int foreignAdded = 0; 1604 List<TCustomSqlStatement> foreignCallers = new ArrayList<TCustomSqlStatement>(); 1605 for (TCustomSqlStatement stmt : ordered.values()) { 1606 if (foreignAdded >= maxForeignCallers) { 1607 break; 1608 } 1609 foreignCallers.add(stmt); 1610 foreignAdded++; 1611 } 1612 callers.addAll(foreignCallers); 1613 if (callers.isEmpty()) { 1614 // No vetted caller anywhere - interpretation would bind nothing. 1615 return result; 1616 } 1617 for (PlsqlRoutineIdent ident : idents) { 1618 result.add(ident.definition); 1619 } 1620 result.addAll(callers); 1621 return result; 1622 } 1623 1624 /** Qualified identity of an own-file routine definition, plus its ambiguity verdicts. */ 1625 private static final class PlsqlRoutineIdent { 1626 final TCustomSqlStatement definition; 1627 final String schema; // null when the definition name is unqualified 1628 final String simple; 1629 /** Some other file defines the same simple name AND it is not provably in a different schema. */ 1630 boolean ambiguous; 1631 /** Some other file defines the same simple name at all (disqualifies unqualified calls). */ 1632 boolean hasForeignSameName; 1633 1634 PlsqlRoutineIdent(TCustomSqlStatement definition, String simple) { 1635 this.definition = definition; 1636 this.schema = plsqlRoutineSchema(definition); 1637 this.simple = simple; 1638 } 1639 } 1640 1641 /** Schema segment of a routine definition name; null when unqualified. */ 1642 private static String plsqlRoutineSchema(TCustomSqlStatement stmt) { 1643 gudusoft.gsqlparser.nodes.TObjectName name = null; 1644 if (stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure) { 1645 name = ((gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure) stmt).getProcedureName(); 1646 } else if (stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction) { 1647 name = ((gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction) stmt).getFunctionName(); 1648 } 1649 if (name == null) { 1650 return null; 1651 } 1652 List<String> parts = SQLUtil.parseNames(name.toString()); 1653 return parts.size() >= 2 ? parts.get(parts.size() - 2) : null; 1654 } 1655 1656 /** 1657 * True only when the two schema contexts are BOTH known and differ; a null on 1658 * either side could be the same schema (the conservative direction for the 1659 * ambiguity verdict). 1660 */ 1661 private static boolean provablyDifferentSchema(String schemaA, String schemaB) { 1662 if (schemaA == null || schemaB == null) { 1663 return false; 1664 } 1665 return !SQLUtil.sameName(EDbVendor.dbvoracle, ESQLDataObjectType.dotSchema, schemaA, schemaB); 1666 } 1667 1668 /** 1669 * Definition shapes that must never enter the interpretation list. Anonymous 1670 * blocks (TCommonBlock) ARE TStoredProcedureSqlStatement subclasses and are the 1671 * primary caller shape, so this names the definition classes explicitly. 1672 */ 1673 private static boolean isPlsqlDefinitionShape(TCustomSqlStatement stmt) { 1674 return stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreatePackage 1675 || stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateTrigger 1676 || stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateType; 1677 } 1678 1679 /** 1680 * True when {@code stmt} contains at least one call that provably targets one of 1681 * {@code idents} and EVERY name-colliding call in the statement (a) resolves to 1682 * exactly one non-ambiguous identity - unqualified calls match by simple name, 1683 * two-part calls require the definition's schema to match, anything the shape 1684 * rules cannot prove is rejected because the interpreter may still bind it by 1685 * simple name - and (b) passes only compile-time literal arguments. A call fed 1686 * by a variable or expression is rejected wholesale: the interpreter would fold 1687 * the unevaluated operand to an EMPTY string - not a placeholder - which can 1688 * silently produce wrong object names (e.g. 'x' || p_suffix folding to just 1689 * "x"). The mssql discovery applies the same literal-only discipline. 1690 */ 1691 private static boolean isVettedLiteralCaller(TCustomSqlStatement stmt, 1692 List<PlsqlRoutineIdent> idents) { 1693 final boolean[] found = { false }; 1694 final boolean[] rejected = { false }; 1695 final List<PlsqlRoutineIdent> targets = idents; 1696 try { 1697 stmt.acceptChildren(new gudusoft.gsqlparser.nodes.TParseTreeVisitor() { 1698 public void preVisit(gudusoft.gsqlparser.nodes.TFunctionCall call) { 1699 vetCall(call); 1700 } 1701 1702 public void preVisit(gudusoft.gsqlparser.stmt.TCallStatement call) { 1703 // TCallStatement.acceptChildren does not surface its routine 1704 // expression as a TFunctionCall - vet it explicitly, else 1705 // CALL p('src') callers are silently never harvested. 1706 if (call.getRoutineExpr() != null) { 1707 vetCall(call.getRoutineExpr().getFunctionCall()); 1708 } 1709 } 1710 1711 private void vetCall(gudusoft.gsqlparser.nodes.TFunctionCall call) { 1712 if (call == null || call.getFunctionName() == null) { 1713 return; 1714 } 1715 List<String> parts = SQLUtil.parseNames(call.getFunctionName().toString()); 1716 if (parts.isEmpty()) { 1717 return; 1718 } 1719 String simple = parts.get(parts.size() - 1); 1720 PlsqlRoutineIdent matched = null; 1721 boolean collides = false; 1722 boolean multi = false; 1723 for (PlsqlRoutineIdent ident : targets) { 1724 if (!SQLUtil.sameName(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure, 1725 simple, ident.simple)) { 1726 continue; 1727 } 1728 collides = true; 1729 boolean resolves; 1730 if (parts.size() == 1) { 1731 // An unqualified call resolves in the CALLER's (unknown) 1732 // current schema: any same-named definition elsewhere - 1733 // even in a provably different schema - leaves two 1734 // candidates. 1735 resolves = !ident.hasForeignSameName && !ident.ambiguous; 1736 } else if (parts.size() == 2) { 1737 resolves = !ident.ambiguous && ident.schema != null 1738 && SQLUtil.sameName(EDbVendor.dbvoracle, ESQLDataObjectType.dotSchema, 1739 parts.get(0), ident.schema); 1740 } else { 1741 resolves = false; 1742 } 1743 if (resolves) { 1744 if (matched != null) { 1745 multi = true; 1746 } 1747 matched = ident; 1748 } 1749 } 1750 if (!collides) { 1751 return; 1752 } 1753 if (matched == null || multi) { 1754 rejected[0] = true; 1755 return; 1756 } 1757 if (call.getArgs() == null || call.getArgs().size() == 0) { 1758 found[0] = true; 1759 return; 1760 } 1761 for (int i = 0; i < call.getArgs().size(); i++) { 1762 if (!isCompileTimeLiteral(call.getArgs().getExpression(i))) { 1763 rejected[0] = true; 1764 return; 1765 } 1766 } 1767 found[0] = true; 1768 } 1769 }); 1770 } catch (RuntimeException ex) { 1771 return false; 1772 } 1773 return found[0] && !rejected[0]; 1774 } 1775 1776 /** 1777 * F1 guard: true when every partial/unfolded EXECUTE IMMEDIATE inside 1778 * {@code routine} builds its SQL exclusively from compile-time literals, 1779 * routine parameters, and local variables whose every assignment (and declared 1780 * default) is itself so composed, connected by || concatenation. Any other 1781 * dependency - SELECT/FETCH/EXECUTE..INTO targets, function results, variables 1782 * passed to other calls (possible OUT), nested routine declarations (scope 1783 * conflation) - disqualifies the whole routine: the interpreter folds what it 1784 * cannot evaluate to an EMPTY string, which would fabricate concrete-looking 1785 * but wrong SQL that the placeholder classifier cannot detect. 1786 */ 1787 private static boolean plsqlDynamicSqlLiteralBindable(TCustomSqlStatement routine) { 1788 PlsqlBindContext ctx = buildPlsqlBindContext(routine); 1789 if (ctx == null || ctx.unbindable) { 1790 return false; 1791 } 1792 for (gudusoft.gsqlparser.nodes.TExpression expr : ctx.partialDynamicExprs) { 1793 if (!isLiteralBindableExpr(expr, ctx.params, ctx.assignments, ctx.disallowed, 1794 new java.util.HashSet<String>())) { 1795 return false; 1796 } 1797 } 1798 return true; 1799 } 1800 1801 /** 1802 * Binding-relevant facts collected from one routine's body, shared between the 1803 * EXECUTE IMMEDIATE bindability gate and the DBMS_SQL text fold: declared 1804 * parameters, tracked single-variable assignments, names disqualified by 1805 * untrackable writes, and the dynamic-string expressions of partial/unfolded 1806 * EXECUTE IMMEDIATE sites. 1807 */ 1808 static final class PlsqlBindContext { 1809 final java.util.Set<String> params = new java.util.HashSet<String>(); 1810 final Map<String, List<BoundSource>> assignments = 1811 new HashMap<String, List<BoundSource>>(); 1812 final java.util.Set<String> disallowed = new java.util.HashSet<String>(); 1813 final List<gudusoft.gsqlparser.nodes.TExpression> partialDynamicExprs = 1814 new ArrayList<gudusoft.gsqlparser.nodes.TExpression>(); 1815 /** Nested routine declarations (scope conflation) or a partial site with no expression. */ 1816 boolean unbindable; 1817 } 1818 1819 /** One tracked assignment source with its source position (for flow-order checks). */ 1820 static final class BoundSource { 1821 final gudusoft.gsqlparser.nodes.TExpression expr; 1822 final long line; 1823 final long col; 1824 1825 BoundSource(gudusoft.gsqlparser.nodes.TExpression expr, long line, long col) { 1826 this.expr = expr; 1827 this.line = line; 1828 this.col = col; 1829 } 1830 } 1831 1832 /** Collector behind {@link PlsqlBindContext}; null when the walk crashed. */ 1833 static PlsqlBindContext buildPlsqlBindContext(TCustomSqlStatement routine) { 1834 try { 1835 final PlsqlBindContext ctx = new PlsqlBindContext(); 1836 final java.util.Set<String> params = ctx.params; 1837 if (routine instanceof gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement) { 1838 TParameterDeclarationList declared = ((gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement) routine) 1839 .getParameterDeclarations(); 1840 if (declared != null) { 1841 for (int i = 0; i < declared.size(); i++) { 1842 TParameterDeclaration pd = declared.getParameterDeclarationItem(i); 1843 if (pd != null && pd.getParameterName() != null) { 1844 params.add(canonVarKey(pd.getParameterName().toString())); 1845 } 1846 } 1847 } 1848 } 1849 final Map<String, List<BoundSource>> assignments = ctx.assignments; 1850 final java.util.Set<String> disallowed = ctx.disallowed; 1851 final List<gudusoft.gsqlparser.nodes.TExpression> dynamicExprs = ctx.partialDynamicExprs; 1852 final boolean[] nestedRoutine = { false }; 1853 final TCustomSqlStatement root = routine; 1854 routine.acceptChildren(new gudusoft.gsqlparser.nodes.TParseTreeVisitor() { 1855 public void preVisit(gudusoft.gsqlparser.stmt.TAssignStmt stmt) { 1856 if (stmt.getLeft() == null || stmt.getExpression() == null) { 1857 return; 1858 } 1859 String left = stmt.getLeft().toString(); 1860 if (left == null || left.indexOf('(') >= 0 1861 || SQLUtil.parseNames(left).size() != 1) { 1862 return; // record fields / collection elements are not tracked 1863 } 1864 recordAssignment(canonVarKey(left), stmt.getExpression(), 1865 stmt.getStartToken() != null ? stmt.getStartToken() 1866 : stmt.getExpression().getStartToken()); 1867 } 1868 1869 public void preVisit(gudusoft.gsqlparser.stmt.TVarDeclStmt stmt) { 1870 if (stmt.getElementName() == null) { 1871 return; 1872 } 1873 if (stmt.getDefaultValue() != null) { 1874 recordAssignment(canonVarKey(stmt.getElementName().toString()), 1875 stmt.getDefaultValue(), stmt.getStartToken()); 1876 } 1877 } 1878 1879 private void recordAssignment(String key, 1880 gudusoft.gsqlparser.nodes.TExpression source, 1881 gudusoft.gsqlparser.TSourceToken position) { 1882 List<BoundSource> list = assignments.get(key); 1883 if (list == null) { 1884 list = new ArrayList<BoundSource>(); 1885 assignments.put(key, list); 1886 } 1887 // Unknown position sorts AFTER everything (Long.MAX_VALUE): a source 1888 // whose location cannot be established never counts as preceding a 1889 // use site - the conservative direction for the flow-order check. 1890 list.add(new BoundSource(source, 1891 position != null ? position.lineNo : Long.MAX_VALUE, 1892 position != null ? position.columnNo : Long.MAX_VALUE)); 1893 } 1894 1895 public void preVisit(gudusoft.gsqlparser.stmt.TSelectSqlStatement stmt) { 1896 if (stmt.getIntoClause() != null && stmt.getIntoClause().getExprList() != null) { 1897 for (int i = 0; i < stmt.getIntoClause().getExprList().size(); i++) { 1898 disallowExpr(stmt.getIntoClause().getExprList().getExpression(i)); 1899 } 1900 } 1901 } 1902 1903 public void preVisit(gudusoft.gsqlparser.stmt.TFetchStmt stmt) { 1904 if (stmt.getVariableNames() != null) { 1905 for (int i = 0; i < stmt.getVariableNames().size(); i++) { 1906 disallowExpr(stmt.getVariableNames().getExpression(i)); 1907 } 1908 } 1909 } 1910 1911 public void preVisit(gudusoft.gsqlparser.stmt.TExecImmeStmt stmt) { 1912 if (stmt.getIntoVariables() != null) { 1913 for (int i = 0; i < stmt.getIntoVariables().size(); i++) { 1914 disallowExpr(stmt.getIntoVariables().getExpression(i)); 1915 } 1916 } 1917 // USING bind values never rewrite the SQL text, but OUT binds 1918 // write variables - disallow every bind target conservatively. 1919 if (stmt.getBindArguments() != null) { 1920 for (int i = 0; i < stmt.getBindArguments().size(); i++) { 1921 gudusoft.gsqlparser.nodes.TBindArgument bind = 1922 stmt.getBindArguments().getBindArgument(i); 1923 if (bind != null && bind.getBindArgumentExpr() != null) { 1924 disallowExpr(bind.getBindArgumentExpr()); 1925 } 1926 } 1927 } 1928 if (stmt.getDynamicSQL() == null || stmt.isDynamicSQLPartial()) { 1929 if (stmt.getDynamicStringExpr() != null) { 1930 dynamicExprs.add(stmt.getDynamicStringExpr()); 1931 } else { 1932 nestedRoutine[0] = true; // unfoldable site with no expression - bail 1933 } 1934 } 1935 } 1936 1937 public void preVisit(gudusoft.gsqlparser.nodes.TFunctionCall call) { 1938 disallowBareArgs(call); 1939 } 1940 1941 public void preVisit(gudusoft.gsqlparser.stmt.TCallStatement stmt) { 1942 // TCallStatement.acceptChildren does not surface its routine 1943 // expression as a TFunctionCall - inspect its args explicitly. 1944 if (stmt.getRoutineExpr() != null) { 1945 disallowBareArgs(stmt.getRoutineExpr().getFunctionCall()); 1946 } 1947 } 1948 1949 public void preVisit(gudusoft.gsqlparser.nodes.TReturningClause clause) { 1950 // RETURNING ... INTO writes locals, and the clause's own 1951 // acceptChildren does not visit its variable list - handle it 1952 // here or a literal-initialized local silently keeps its stale 1953 // literal after being overwritten. 1954 if (clause.getVariableList() != null) { 1955 for (int i = 0; i < clause.getVariableList().size(); i++) { 1956 disallowExpr(clause.getVariableList().getExpression(i)); 1957 } 1958 } 1959 } 1960 1961 private void disallowBareArgs(gudusoft.gsqlparser.nodes.TFunctionCall call) { 1962 // A bare variable handed to any call could be an OUT actual - 1963 // except at argument positions of KNOWN in-only builtins 1964 // (notably DBMS_SQL.PARSE's own statement argument, which 1965 // would otherwise disqualify the very variable it consumes). 1966 if (call == null || call.getArgs() == null) { 1967 return; 1968 } 1969 for (int i = 0; i < call.getArgs().size(); i++) { 1970 if (isKnownInOnlyArg(call, i)) { 1971 continue; 1972 } 1973 gudusoft.gsqlparser.nodes.TExpression arg = call.getArgs().getExpression(i); 1974 if (arg != null && arg.getExpressionType() 1975 == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) { 1976 disallowExpr(arg); 1977 } 1978 } 1979 } 1980 1981 public void preVisit(gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure stmt) { 1982 if (stmt != root) { 1983 nestedRoutine[0] = true; // nested scopes would conflate name keys 1984 } 1985 } 1986 1987 public void preVisit(gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction stmt) { 1988 if (stmt != root) { 1989 nestedRoutine[0] = true; 1990 } 1991 } 1992 1993 private void disallowExpr(gudusoft.gsqlparser.nodes.TExpression expr) { 1994 if (expr == null) { 1995 return; 1996 } 1997 String text = expr.toString(); 1998 if (text != null && SQLUtil.parseNames(text).size() == 1 1999 && text.indexOf('(') < 0) { 2000 disallowed.add(canonVarKey(text)); 2001 } 2002 } 2003 }); 2004 ctx.unbindable = nestedRoutine[0]; 2005 return ctx; 2006 } catch (RuntimeException ex) { 2007 return null; 2008 } 2009 } 2010 2011 /** 2012 * True when argument {@code index} of {@code call} is an IN-only position of a 2013 * known builtin, so a bare variable there is a READ and must not disqualify the 2014 * variable from literal folding. Deliberately tiny: DBMS_SQL.PARSE's statement 2015 * argument (index 1 positional; named association arrives as an arrow 2016 * expression and is never a bare name anyway) and DBMS_OUTPUT.PUT_LINE / PUT 2017 * (pure logging, all-IN) — the calls that most commonly surround dynamic SQL. 2018 */ 2019 private static boolean isKnownInOnlyArg(gudusoft.gsqlparser.nodes.TFunctionCall call, int index) { 2020 if (call.getFunctionName() == null) { 2021 return false; 2022 } 2023 String name = call.getFunctionName().toString(); 2024 if (SQLUtil.compareIdentifier(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure, 2025 name, "DBMS_SQL.PARSE") 2026 || SQLUtil.compareIdentifier(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure, 2027 name, "SYS.DBMS_SQL.PARSE")) { 2028 return index == 1; 2029 } 2030 return SQLUtil.compareIdentifier(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure, 2031 name, "DBMS_OUTPUT.PUT_LINE") 2032 || SQLUtil.compareIdentifier(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure, 2033 name, "DBMS_OUTPUT.PUT"); 2034 } 2035 2036 /** 2037 * DBMS_SQL entry point (plan Phase 5 slice 4): fold the SQL-text argument of a 2038 * {@code DBMS_SQL.PARSE} call into concrete statement texts. Returns the 2039 * no-binding fold first (literal or literal-composed locals), then one fold per 2040 * literal call-site binding set (same-file callers first, then cross-file via 2041 * the catalog), deduplicated, capped at {@code maxVariants}. Empty when nothing 2042 * folds — the site then keeps its honest unresolved diagnostic. The fold shares 2043 * the EXECUTE IMMEDIATE whitelist (literals, parameters bound at vetted call 2044 * sites, locals with exactly one literal-composed assignment, || and 2045 * parentheses), so a fold can never fabricate object names. 2046 */ 2047 public static List<String> foldDbmsSqlTexts(TCustomSqlStatement enclosingRoutine, 2048 gudusoft.gsqlparser.nodes.TExpression sqlArg, RoutineCatalog catalog, int maxVariants) { 2049 List<String> texts = new ArrayList<String>(); 2050 if (sqlArg == null || maxVariants <= 0) { 2051 return texts; 2052 } 2053 PlsqlBindContext ctx = enclosingRoutine == null ? new PlsqlBindContext() 2054 : buildPlsqlBindContext(enclosingRoutine); 2055 if (ctx == null || ctx.unbindable) { 2056 // Context unavailable: only a directly-literal argument can fold. 2057 ctx = new PlsqlBindContext(); 2058 } 2059 // Flow-order bound: only assignments strictly before the PARSE site count. 2060 // An unknown site position folds no variables at all (literals still do). 2061 long siteLine = Long.MIN_VALUE; 2062 long siteCol = Long.MIN_VALUE; 2063 if (sqlArg.getStartToken() != null) { 2064 siteLine = sqlArg.getStartToken().lineNo; 2065 siteCol = sqlArg.getStartToken().columnNo; 2066 } 2067 String direct = foldLiteralBindableExpr(sqlArg, ctx, 2068 new HashMap<String, SqlValue>(), new java.util.HashSet<String>(), 2069 siteLine, siteCol); 2070 if (direct != null && !direct.trim().isEmpty()) { 2071 texts.add(direct); 2072 } 2073 if (texts.size() < maxVariants && enclosingRoutine != null) { 2074 for (Map<String, SqlValue> bindings : discoverPlsqlCallSiteBindings( 2075 enclosingRoutine, catalog, maxVariants)) { 2076 String folded = foldLiteralBindableExpr(sqlArg, ctx, bindings, 2077 new java.util.HashSet<String>(), siteLine, siteCol); 2078 if (folded != null && !folded.trim().isEmpty() && !texts.contains(folded)) { 2079 texts.add(folded); 2080 if (texts.size() >= maxVariants) { 2081 break; 2082 } 2083 } 2084 } 2085 } 2086 return texts; 2087 } 2088 2089 /** 2090 * Fold an expression to its concrete string under the P5-2 whitelist: literals, 2091 * parameters WITH a supplied binding (a NULL binding folds to the empty string, 2092 * Oracle's NULL-concatenation semantics), locals with exactly one 2093 * literal-composed assignment, || and parentheses. Null when anything outside 2094 * the whitelist (or an unbound parameter) is involved — never a partial text. 2095 * 2096 * <p>Flow order: a local's single assignment is usable only when it appears 2097 * strictly BEFORE ({@code boundLine},{@code boundCol}) — the use site on the 2098 * first level, then the consuming assignment's own position as the recursion 2099 * descends — so a value assigned after the site can never fabricate lineage. 2100 */ 2101 private static String foldLiteralBindableExpr(gudusoft.gsqlparser.nodes.TExpression expr, 2102 PlsqlBindContext ctx, Map<String, SqlValue> bindings, java.util.Set<String> visiting, 2103 long boundLine, long boundCol) { 2104 if (expr == null) { 2105 return null; 2106 } 2107 if (isCompileTimeLiteral(expr)) { 2108 SqlValue value = literalToSqlValue(expr); 2109 if (value == null) { 2110 return null; 2111 } 2112 return value.isNull() ? "" : String.valueOf(value.text()); 2113 } 2114 gudusoft.gsqlparser.EExpressionType type = expr.getExpressionType(); 2115 if (type == gudusoft.gsqlparser.EExpressionType.concatenate_t) { 2116 String left = foldLiteralBindableExpr(expr.getLeftOperand(), ctx, bindings, visiting, 2117 boundLine, boundCol); 2118 String right = foldLiteralBindableExpr(expr.getRightOperand(), ctx, bindings, visiting, 2119 boundLine, boundCol); 2120 return left == null || right == null ? null : left + right; 2121 } 2122 if (type == gudusoft.gsqlparser.EExpressionType.parenthesis_t) { 2123 return foldLiteralBindableExpr(expr.getLeftOperand(), ctx, bindings, visiting, 2124 boundLine, boundCol); 2125 } 2126 if (type == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) { 2127 String text = expr.toString(); 2128 if (text == null || SQLUtil.parseNames(text).size() != 1) { 2129 return null; 2130 } 2131 String key = canonVarKey(text); 2132 if (ctx.disallowed.contains(key)) { 2133 return null; 2134 } 2135 if (ctx.params.contains(key)) { 2136 SqlValue bound = bindings.get(key); 2137 if (bound == null) { 2138 return null; // unbound parameter - never fold partially 2139 } 2140 return bound.isNull() ? "" : String.valueOf(bound.text()); 2141 } 2142 List<BoundSource> sources = ctx.assignments.get(key); 2143 if (sources == null || sources.size() != 1 || !visiting.add(key)) { 2144 return null; 2145 } 2146 try { 2147 BoundSource source = sources.get(0); 2148 if (source.line > boundLine 2149 || (source.line == boundLine && source.col >= boundCol)) { 2150 return null; // assigned at or after the use site - value unavailable 2151 } 2152 return foldLiteralBindableExpr(source.expr, ctx, bindings, visiting, 2153 source.line, source.col); 2154 } finally { 2155 visiting.remove(key); 2156 } 2157 } 2158 return null; 2159 } 2160 2161 /** 2162 * Literal call-site binding sets for an Oracle routine, mirroring the T-SQL 2163 * {@link #discoverCallSiteBindings} semantics: same-file callers first (file 2164 * order), then cross-file callers via the catalog (exact-text order); vetted by 2165 * {@link #isVettedLiteralCaller} (so only positional, compile-time-literal 2166 * arguments to a provably-unambiguous target survive); one binding map per 2167 * matching call, deduplicated by canonical key, capped. 2168 */ 2169 static List<Map<String, SqlValue>> discoverPlsqlCallSiteBindings( 2170 TCustomSqlStatement procAst, RoutineCatalog catalog, int maxBindingSets) { 2171 List<Map<String, SqlValue>> bindingSets = new ArrayList<Map<String, SqlValue>>(); 2172 if (maxBindingSets <= 0 || procAst == null || procAst.getGsqlparser() == null) { 2173 return bindingSets; 2174 } 2175 String simple = plsqlRoutineSimpleName(procAst); 2176 if (simple == null || simple.isEmpty()) { 2177 return bindingSets; 2178 } 2179 final PlsqlRoutineIdent ident = new PlsqlRoutineIdent(procAst, simple); 2180 TStatementList ownStatements = procAst.getGsqlparser().getSqlstatements(); 2181 String ownText = procAst.getGsqlparser().sqltext; 2182 // Same-FILE duplicate definitions make the name just as ambiguous as foreign 2183 // ones (the interpretation path catches this via its multi-ident matching; 2184 // this single-ident path must check explicitly). 2185 if (ownStatements != null) { 2186 for (int i = 0; i < ownStatements.size(); i++) { 2187 TCustomSqlStatement stmt = ownStatements.get(i); 2188 if (stmt == procAst) { 2189 continue; 2190 } 2191 String otherSimple = plsqlRoutineSimpleName(stmt); 2192 if (otherSimple == null || !SQLUtil.sameName(EDbVendor.dbvoracle, 2193 ESQLDataObjectType.dotProcedure, otherSimple, ident.simple)) { 2194 continue; 2195 } 2196 ident.hasForeignSameName = true; 2197 if (!provablyDifferentSchema(ident.schema, plsqlRoutineSchema(stmt))) { 2198 ident.ambiguous = true; 2199 } 2200 } 2201 } 2202 // Foreign definitions decide the ambiguity verdicts (same rules as the 2203 // interpretation path). 2204 List<RoutineCatalog.UnitIndex> foreignUnits = new ArrayList<RoutineCatalog.UnitIndex>(); 2205 if (catalog != null) { 2206 String probe = bareName(simple).toLowerCase(java.util.Locale.ENGLISH); 2207 for (String text : catalog.unitTexts()) { 2208 if (text == null || text.equals(ownText) 2209 || !text.toLowerCase(java.util.Locale.ENGLISH).contains(probe)) { 2210 continue; 2211 } 2212 RoutineCatalog.UnitIndex unit = catalog.indexOf(text); 2213 if (unit == null) { 2214 continue; 2215 } 2216 foreignUnits.add(unit); 2217 for (RoutineCatalog.Definition def : unit.definitions) { 2218 if (!SQLUtil.sameName(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure, 2219 def.simple, ident.simple)) { 2220 continue; 2221 } 2222 ident.hasForeignSameName = true; 2223 if (!provablyDifferentSchema(ident.schema, def.schema)) { 2224 ident.ambiguous = true; 2225 } 2226 } 2227 } 2228 } 2229 List<PlsqlRoutineIdent> idents = new ArrayList<PlsqlRoutineIdent>(); 2230 idents.add(ident); 2231 List<TParameterDeclaration> params = new ArrayList<TParameterDeclaration>(); 2232 if (procAst instanceof gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement) { 2233 TParameterDeclarationList declared = ((gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement) procAst) 2234 .getParameterDeclarations(); 2235 if (declared != null) { 2236 for (int i = 0; i < declared.size(); i++) { 2237 params.add(declared.getParameterDeclarationItem(i)); 2238 } 2239 } 2240 } 2241 java.util.Set<String> seen = new java.util.HashSet<String>(); 2242 // Same-file callers in file order. 2243 if (ownStatements != null) { 2244 for (int i = 0; i < ownStatements.size() 2245 && bindingSets.size() < maxBindingSets; i++) { 2246 TCustomSqlStatement stmt = ownStatements.get(i); 2247 if (stmt == procAst || plsqlRoutineSimpleName(stmt) != null 2248 || isPlsqlDefinitionShape(stmt)) { 2249 continue; 2250 } 2251 if (isVettedLiteralCaller(stmt, idents)) { 2252 extractPlsqlBindings(stmt, ident, params, seen, bindingSets, maxBindingSets); 2253 } 2254 } 2255 } 2256 // Cross-file callers ordered by exact statement text. 2257 java.util.TreeMap<String, TCustomSqlStatement> ordered = 2258 new java.util.TreeMap<String, TCustomSqlStatement>(); 2259 for (RoutineCatalog.UnitIndex unit : foreignUnits) { 2260 for (RoutineCatalog.CallCandidate candidate : unit.callCandidates) { 2261 if (isPlsqlDefinitionShape(candidate.stmt)) { 2262 continue; 2263 } 2264 if (isVettedLiteralCaller(candidate.stmt, idents)) { 2265 String key = String.valueOf(candidate.stmt.toString()); 2266 if (!ordered.containsKey(key)) { 2267 ordered.put(key, candidate.stmt); 2268 } 2269 } 2270 } 2271 } 2272 for (TCustomSqlStatement stmt : ordered.values()) { 2273 if (bindingSets.size() >= maxBindingSets) { 2274 break; 2275 } 2276 extractPlsqlBindings(stmt, ident, params, seen, bindingSets, maxBindingSets); 2277 } 2278 return bindingSets; 2279 } 2280 2281 /** 2282 * Extract one binding map per call to {@code ident} inside a VETTED caller 2283 * statement. Vetting already guaranteed every matching call is unambiguous and 2284 * all-literal (positional form — named notation is rejected there), so this 2285 * maps arguments to parameter declarations by position. 2286 */ 2287 private static void extractPlsqlBindings(TCustomSqlStatement callerStmt, 2288 final PlsqlRoutineIdent ident, final List<TParameterDeclaration> params, 2289 final java.util.Set<String> seen, final List<Map<String, SqlValue>> bindingSets, 2290 final int maxBindingSets) { 2291 try { 2292 callerStmt.acceptChildren(new gudusoft.gsqlparser.nodes.TParseTreeVisitor() { 2293 public void preVisit(gudusoft.gsqlparser.nodes.TFunctionCall call) { 2294 collect(call); 2295 } 2296 2297 public void preVisit(gudusoft.gsqlparser.stmt.TCallStatement call) { 2298 if (call.getRoutineExpr() != null) { 2299 collect(call.getRoutineExpr().getFunctionCall()); 2300 } 2301 } 2302 2303 private void collect(gudusoft.gsqlparser.nodes.TFunctionCall call) { 2304 if (call == null || call.getFunctionName() == null 2305 || bindingSets.size() >= maxBindingSets) { 2306 return; 2307 } 2308 List<String> parts = SQLUtil.parseNames(call.getFunctionName().toString()); 2309 if (parts.isEmpty() || !SQLUtil.sameName(EDbVendor.dbvoracle, 2310 ESQLDataObjectType.dotProcedure, parts.get(parts.size() - 1), 2311 ident.simple)) { 2312 return; 2313 } 2314 if (call.getArgs() == null || call.getArgs().size() == 0) { 2315 return; 2316 } 2317 Map<String, SqlValue> bindings = new java.util.LinkedHashMap<String, SqlValue>(); 2318 java.util.TreeMap<String, String> canonical = new java.util.TreeMap<String, String>(); 2319 for (int i = 0; i < call.getArgs().size(); i++) { 2320 if (i >= params.size() || params.get(i) == null 2321 || params.get(i).getParameterName() == null) { 2322 return; // arity beyond declaration - do not guess 2323 } 2324 SqlValue value = literalToSqlValue(call.getArgs().getExpression(i)); 2325 if (value == null) { 2326 return; // vetting should prevent this; stay safe 2327 } 2328 String paramName = params.get(i).getParameterName().toString(); 2329 bindings.put(canonVarKey(paramName), value); 2330 canonical.put(canonVarKey(paramName), 2331 canonicalValueKey(call.getArgs().getExpression(i))); 2332 } 2333 if (!bindings.isEmpty() && seen.add(canonical.toString())) { 2334 bindingSets.add(bindings); 2335 } 2336 } 2337 }); 2338 } catch (RuntimeException ex) { 2339 // best-effort extraction; a crashed walk contributes nothing 2340 } 2341 } 2342 2343 /** 2344 * Variable-tracking key preserving Oracle quote semantics: unquoted names fold 2345 * upper (case-insensitive), quoted names keep their exact case inside markers so 2346 * a quoted lowercase local is never conflated with an unquoted parameter of the 2347 * same spelling. (Unquoted FOO vs quoted "FOO" key differently - a conservative 2348 * miss that can only disqualify, never wrongly qualify.) 2349 */ 2350 private static String canonVarKey(String name) { 2351 String trimmed = name.trim(); 2352 if (trimmed.length() >= 2 && trimmed.charAt(0) == '"' 2353 && trimmed.charAt(trimmed.length() - 1) == '"') { 2354 return "\"" + trimmed.substring(1, trimmed.length() - 1) + "\""; 2355 } 2356 return trimmed.toUpperCase(java.util.Locale.ENGLISH); 2357 } 2358 2359 /** 2360 * Recursive whitelist for {@link #plsqlDynamicSqlLiteralBindable}: literals, 2361 * || concatenation, parentheses, routine parameters, and local variables whose 2362 * every recorded assignment is itself bindable. Cycles and everything else fail. 2363 */ 2364 private static boolean isLiteralBindableExpr(gudusoft.gsqlparser.nodes.TExpression expr, 2365 java.util.Set<String> params, 2366 Map<String, List<BoundSource>> assignments, 2367 java.util.Set<String> disallowed, java.util.Set<String> visiting) { 2368 if (expr == null) { 2369 return false; 2370 } 2371 if (isCompileTimeLiteral(expr)) { 2372 return true; 2373 } 2374 gudusoft.gsqlparser.EExpressionType type = expr.getExpressionType(); 2375 if (type == gudusoft.gsqlparser.EExpressionType.concatenate_t) { 2376 return isLiteralBindableExpr(expr.getLeftOperand(), params, assignments, disallowed, visiting) 2377 && isLiteralBindableExpr(expr.getRightOperand(), params, assignments, disallowed, visiting); 2378 } 2379 if (type == gudusoft.gsqlparser.EExpressionType.parenthesis_t) { 2380 return isLiteralBindableExpr(expr.getLeftOperand(), params, assignments, disallowed, visiting); 2381 } 2382 if (type == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) { 2383 String text = expr.toString(); 2384 if (text == null || SQLUtil.parseNames(text).size() != 1) { 2385 return false; 2386 } 2387 String key = canonVarKey(text); 2388 if (disallowed.contains(key)) { 2389 return false; 2390 } 2391 if (params.contains(key)) { 2392 return true; 2393 } 2394 List<BoundSource> sources = assignments.get(key); 2395 // Exactly ONE recorded source (assignment or declared default): with 2396 // several, control flow (IF/loops) selects which one is live, but the 2397 // interpreter walks structurally and would collapse them to a single 2398 // arbitrary winner - presenting one possible lineage as certain. Real 2399 // branch-sensitive evaluation is Phase 3 CFG work. 2400 if (sources == null || sources.size() != 1 || !visiting.add(key)) { 2401 return false; 2402 } 2403 try { 2404 if (!isLiteralBindableExpr(sources.get(0).expr, params, assignments, 2405 disallowed, visiting)) { 2406 return false; 2407 } 2408 } finally { 2409 visiting.remove(key); 2410 } 2411 return true; 2412 } 2413 return false; 2414 } 2415 2416 /** Compile-time literal: string/numeric constant, signed numeric, or bare NULL. */ 2417 private static boolean isCompileTimeLiteral(gudusoft.gsqlparser.nodes.TExpression expr) { 2418 if (expr == null) { 2419 return false; 2420 } 2421 gudusoft.gsqlparser.EExpressionType type = expr.getExpressionType(); 2422 if (type == gudusoft.gsqlparser.EExpressionType.unary_plus_t 2423 || type == gudusoft.gsqlparser.EExpressionType.unary_minus_t) { 2424 return isCompileTimeLiteral(expr.getRightOperand() != null 2425 ? expr.getRightOperand() : expr.getLeftOperand()); 2426 } 2427 if (type == gudusoft.gsqlparser.EExpressionType.simple_source_token_t 2428 && expr.toString().trim().equalsIgnoreCase("NULL")) { // non-identifier-compare: the NULL keyword literal 2429 return true; 2430 } 2431 return type == gudusoft.gsqlparser.EExpressionType.simple_constant_t 2432 && expr.getConstantOperand() != null; 2433 } 2434 2435 /** Simple name of an Oracle routine definition; null for any other statement. */ 2436 private static String plsqlRoutineSimpleName(TCustomSqlStatement stmt) { 2437 gudusoft.gsqlparser.nodes.TObjectName name = null; 2438 if (stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure) { 2439 name = ((gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure) stmt).getProcedureName(); 2440 } else if (stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction) { 2441 name = ((gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction) stmt).getFunctionName(); 2442 } 2443 if (name == null) { 2444 return null; 2445 } 2446 return lastNameSegment(name.toString()); 2447 } 2448 2449 // Package-visible accessors for RoutineCatalog (plan §5.6 index). Same helpers, 2450 // exposed under index-facing names; no behavior of their own. 2451 static String mssqlDefinitionDatabase(TMssqlCreateProcedure def, String currentDb) { 2452 return definitionDatabase(def, currentDb); 2453 } 2454 2455 static String simpleNameOf(String name) { 2456 return lastNameSegment(name); 2457 } 2458 2459 static String plsqlRoutineSimpleNameOf(TCustomSqlStatement stmt) { 2460 return plsqlRoutineSimpleName(stmt); 2461 } 2462 2463 static String plsqlRoutineSchemaOf(TCustomSqlStatement stmt) { 2464 return plsqlRoutineSchema(stmt); 2465 } 2466 2467 /** Effective database (last USE before the definition) of {@code procAst} in its file. */ 2468 private static String effectiveDatabaseOfProc(TStatementList all, TCustomSqlStatement procAst) { 2469 String currentDb = null; 2470 for (int i = 0; i < all.size(); i++) { 2471 if (all.get(i) instanceof gudusoft.gsqlparser.stmt.TUseDatabase) { 2472 currentDb = String.valueOf(((gudusoft.gsqlparser.stmt.TUseDatabase) all.get(i)).getDatabaseName()); 2473 } 2474 if (all.get(i) == procAst) { 2475 return currentDb; 2476 } 2477 } 2478 return null; 2479 } 2480 2481 /** 2482 * True when {@code call} is a plain (non-dynamic-text) EXEC of {@code proc} under 2483 * the matching rules: unqualified operands match by simple name, qualified operands 2484 * require the full qualified identity (so EXEC audit.load is never taken as a call 2485 * to dbo.load), and one-/two-part names must agree on the effective USE database. 2486 */ 2487 private static boolean callMatchesProc(gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call, 2488 TMssqlCreateProcedure proc, String procSimpleName, String procDb, String callDb, 2489 gudusoft.gsqlparser.sqlenv.IdentifierService names) { 2490 if (call.getModuleName() == null || call.getSqlText() != null) { 2491 return false; 2492 } 2493 String callName = call.getModuleName().toString(); 2494 if (!sameEffectiveDatabase(procDb, callDb, callName, names)) { 2495 return false; 2496 } 2497 List<String> callParts = SQLUtil.parseNames(callName); 2498 if (callParts.size() <= 1) { 2499 return sameName(names, ESQLDataObjectType.dotProcedure, lastNameSegment(callName), procSimpleName); 2500 } 2501 List<String> procParts = SQLUtil.parseNames(proc.getProcedureName().toString()); 2502 if (callParts.size() == procParts.size()) { 2503 if (names == null) { 2504 return SQLUtil.compareIdentifier(EDbVendor.dbvmssql, ESQLDataObjectType.dotProcedure, 2505 callName, proc.getProcedureName().toString()); 2506 } 2507 // segment by segment from the name: procedure, schema, database, server 2508 ESQLDataObjectType[] roles = {ESQLDataObjectType.dotProcedure, ESQLDataObjectType.dotSchema, 2509 ESQLDataObjectType.dotCatalog, ESQLDataObjectType.dotCatalog}; 2510 for (int k = 1; k <= callParts.size(); k++) { 2511 if (!sameName(names, roles[Math.min(k - 1, roles.length - 1)], callParts.get(callParts.size() - k), 2512 procParts.get(procParts.size() - k))) { 2513 return false; 2514 } 2515 } 2516 return true; 2517 } 2518 if (callParts.size() == 2 && procParts.size() == 3) { 2519 // Two-part call to a three-part definition: database agreement was already 2520 // established by sameEffectiveDatabase (call's USE db vs the definition's 2521 // catalog); compare the remaining schema.name segments. 2522 return sameName(names, ESQLDataObjectType.dotSchema, callParts.get(0), procParts.get(1)) 2523 && sameName(names, ESQLDataObjectType.dotProcedure, callParts.get(1), procParts.get(2)); 2524 } 2525 if (callParts.size() == 3 && procParts.size() == 2) { 2526 // Three-part call to a two-part definition: the call's explicit catalog 2527 // must equal the definition's known effective database. 2528 return procDb != null 2529 && sameName(names, ESQLDataObjectType.dotCatalog, callParts.get(0), procDb) 2530 && sameName(names, ESQLDataObjectType.dotSchema, callParts.get(1), procParts.get(0)) 2531 && sameName(names, ESQLDataObjectType.dotProcedure, callParts.get(2), procParts.get(1)); 2532 } 2533 // Other arity combinations (e.g. one-part definition vs qualified call) keep 2534 // the conservative no-match: schema defaulting is environment-dependent. 2535 return false; 2536 } 2537 2538 /** A literal binding set extracted from one call site plus its canonical dedupe key. */ 2539 private static final class BindingCandidate { 2540 final Map<String, SqlValue> bindings; 2541 final String canonicalKey; 2542 /** Where the call is: {@code "line 12"}, {@code "unit 2 line 1"}. */ 2543 final String source; 2544 /** The call, kind and position; set by the discovery that found it. */ 2545 BindingSource bindingSource = BindingSource.NONE; 2546 /** The procedure whose body holds the call; null for a call in a batch. */ 2547 String routine; 2548 /** The call may not run on the path its values come from (an undecidable branch, a loop, CATCH). */ 2549 boolean mayNotRun; 2550 2551 BindingCandidate(Map<String, SqlValue> bindings, String canonicalKey, String source) { 2552 this.bindings = bindings; 2553 this.canonicalKey = canonicalKey; 2554 this.source = source; 2555 } 2556 } 2557 2558 /** 2559 * Convert one EXEC call's literal arguments into a binding set; null when nothing literal was bound 2560 * ({@code whyNot[0]} says so). A call with an argument that is not a literal is bound by evaluating its 2561 * caller ({@link #candidatesForCall}), never here. 2562 */ 2563 private static BindingCandidate bindingCandidateFromCall( 2564 gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call, List<TParameterDeclaration> params, 2565 String source, String[] whyNot, gudusoft.gsqlparser.sqlenv.IdentifierService names) { 2566 Map<String, SqlValue> bindings = new java.util.LinkedHashMap<String, SqlValue>(); 2567 if (call.getParameters() != null) { 2568 for (int j = 0; j < call.getParameters().size(); j++) { 2569 gudusoft.gsqlparser.nodes.TExecParameter arg = call.getParameters().getExecParameter(j); 2570 if (arg == null || arg.getParameterMode() != TBaseType.parameter_mode_default) { 2571 // OUT/OUTPUT actuals stay unbound: the value at the dynamic 2572 // site may differ from the literal passed in. 2573 continue; 2574 } 2575 String paramName = null; 2576 if (arg.getParameterName() != null) { 2577 paramName = arg.getParameterName().toString(); 2578 } else if (j < params.size() && params.get(j).getParameterName() != null) { 2579 paramName = params.get(j).getParameterName().toString(); 2580 } 2581 // DEFAULT is no literal: nothing is bound and the default applies 2582 SqlValue value = literalToSqlValue(arg.getParameterValue()); 2583 if (paramName != null && value != null) { 2584 bindings.put(paramName, value); 2585 } 2586 } 2587 } 2588 if (bindings.isEmpty()) { 2589 whyNot[0] = "no argument is a literal"; 2590 return null; 2591 } 2592 return new BindingCandidate(bindings, bindingKey(bindings, params, names), source); 2593 } 2594 2595 /** 2596 * Database a definition belongs to: the catalog part of a three-part name wins, 2597 * otherwise the effective USE database at the definition site. 2598 */ 2599 private static String definitionDatabase(TMssqlCreateProcedure def, String currentDb) { 2600 List<String> parts = SQLUtil.parseNames(def.getProcedureName().toString()); 2601 if (parts.size() >= 3) { 2602 return parts.get(0); 2603 } 2604 return currentDb; 2605 } 2606 2607 /** 2608 * True only when the two database contexts are BOTH known and differ. A null on 2609 * either side means "analysis default" - possibly the same database - so it is 2610 * NOT provably different (the conservative direction for the ambiguity bail-out). 2611 */ 2612 private static boolean provablyDifferentDatabase(String dbA, String dbB, 2613 gudusoft.gsqlparser.sqlenv.IdentifierService names) { 2614 if (dbA == null || dbB == null) { 2615 return false; 2616 } 2617 return !sameName(names, ESQLDataObjectType.dotCatalog, dbA, dbB); 2618 } 2619 2620 /** Name stripped of [..] / ".." / `..` quoting, for the cheap contains-prefilter only. */ 2621 private static String bareName(String name) { 2622 String trimmed = name.trim(); 2623 if (trimmed.length() >= 2) { 2624 char first = trimmed.charAt(0); 2625 char last = trimmed.charAt(trimmed.length() - 1); 2626 if ((first == '[' && last == ']') || (first == '"' && last == '"') 2627 || (first == '`' && last == '`')) { 2628 return trimmed.substring(1, trimmed.length() - 1).trim(); 2629 } 2630 } 2631 return trimmed; 2632 } 2633 2634 2635 /** 2636 * True when the call's effective database matches the procedure definition's. An 2637 * explicit three-part call name carries its own database and was already compared 2638 * as a full qualified identity; here we only reject cross-USE ambiguity for one- 2639 * and two-part names. 2640 */ 2641 private static boolean sameEffectiveDatabase(String procDb, String callDb, String callName, 2642 gudusoft.gsqlparser.sqlenv.IdentifierService names) { 2643 if (SQLUtil.parseNames(callName).size() >= 3) { 2644 return true; 2645 } 2646 if (procDb == null && callDb == null) { 2647 return true; 2648 } 2649 if (procDb == null || callDb == null) { 2650 return false; 2651 } 2652 return sameName(names, ESQLDataObjectType.dotCatalog, procDb, callDb); 2653 } 2654 2655 private static String lastNameSegment(String name) { 2656 List<String> parts = SQLUtil.parseNames(name); 2657 return parts.isEmpty() ? name : parts.get(parts.size() - 1); 2658 } 2659 2660 /** T-SQL literal -> binding value; null when the expression is not a compile-time literal. */ 2661 private static SqlValue literalToSqlValue(gudusoft.gsqlparser.nodes.TExpression expr) { 2662 if (expr == null) { 2663 return null; 2664 } 2665 gudusoft.gsqlparser.EExpressionType type = expr.getExpressionType(); 2666 // Signed numerics arrive as unary +/- wrapping the constant. 2667 if (type == gudusoft.gsqlparser.EExpressionType.unary_plus_t 2668 || type == gudusoft.gsqlparser.EExpressionType.unary_minus_t) { 2669 SqlValue inner = literalToSqlValue(expr.getRightOperand() != null 2670 ? expr.getRightOperand() : expr.getLeftOperand()); 2671 if (inner == null || type == gudusoft.gsqlparser.EExpressionType.unary_plus_t) { 2672 return inner; 2673 } 2674 try { 2675 String innerText = inner.text() == null ? "" : inner.text(); 2676 if (innerText.indexOf('.') >= 0) { 2677 return SqlValue.of(-Double.parseDouble(innerText)); 2678 } 2679 return SqlValue.of(-Long.parseLong(innerText)); 2680 } catch (NumberFormatException nfe) { 2681 return null; 2682 } 2683 } 2684 // Bare NULL in an EXEC argument surfaces as a plain source token. 2685 if (type == gudusoft.gsqlparser.EExpressionType.simple_source_token_t 2686 && expr.toString().trim().equalsIgnoreCase("NULL")) { // non-identifier-compare: the NULL keyword literal 2687 return SqlValue.nullValue(); 2688 } 2689 if (type != gudusoft.gsqlparser.EExpressionType.simple_constant_t 2690 || expr.getConstantOperand() == null) { 2691 return null; 2692 } 2693 String text = expr.getConstantOperand().toString().trim(); 2694 if (text.equalsIgnoreCase("NULL")) { // non-identifier-compare: the NULL keyword literal 2695 return SqlValue.nullValue(); 2696 } 2697 String unquoted = unquoteTsqlString(text); 2698 if (unquoted != null) { 2699 return SqlValue.of(unquoted); 2700 } 2701 try { 2702 if (text.indexOf('.') >= 0 || text.indexOf('e') >= 0 || text.indexOf('E') >= 0) { 2703 // Bind decimals only when the double round-trips to the exact source 2704 // text - otherwise the evaluated string could differ from the real 2705 // runtime value (precision loss); leave inexact values unbound. 2706 double parsed = Double.parseDouble(text); 2707 SqlValue candidate = SqlValue.of(parsed); 2708 if (text.equals(candidate.text())) { 2709 return candidate; 2710 } 2711 return null; 2712 } 2713 return SqlValue.of(Long.parseLong(text)); 2714 } catch (NumberFormatException nfe) { 2715 return null; 2716 } 2717 } 2718 2719 /** 'abc''d' / N'abc' -> abc'd ; returns null when not a string literal. */ 2720 private static String unquoteTsqlString(String text) { 2721 String t = text; 2722 if (t.length() >= 2 && (t.charAt(0) == 'N' || t.charAt(0) == 'n') && t.charAt(1) == '\'') { 2723 t = t.substring(1); 2724 } 2725 if (t.length() >= 2 && t.charAt(0) == '\'' && t.charAt(t.length() - 1) == '\'') { 2726 return t.substring(1, t.length() - 1).replace("''", "'"); 2727 } 2728 return null; 2729 } 2730 2731 /** Order/spelling-insensitive value key for binding-set dedupe (length-prefixed to defeat collisions). */ 2732 private static String canonicalValueKey(gudusoft.gsqlparser.nodes.TExpression expr) { 2733 String raw = expr == null ? "" : expr.toString().trim(); 2734 return raw.length() + ":" + raw; 2735 } 2736 2737 /* --------------------------------------------------------- site building */ 2738 2739 private static DynamicSiteResult buildSite(TDynamicSqlStringEvaluator.RawSite raw, EDbVendor vendor, 2740 TSQLEnv sqlEnv, String currentDatabase, String defaultSchema, String sourceProc, String bindingHash, 2741 DynamicLineageOptions options, NodeNames nodeNames) { 2742 2743 long[] pos = coordinate(raw.node); 2744 String dynamicSite = dynamicSiteId(sourceProc, 2745 raw.ordinalPath + (raw.variantPath == null ? "" : raw.variantPath)); 2746 SqlStringValue sql = raw.sql; 2747 // The EXEC site is wrapped in an IF whose predicate we cannot decide from 2748 // the bindings (e.g. IF @Debug = 0 EXEC(@sql) with @Debug unbound). 2749 boolean executionGated = !raw.controlFlowCertain; 2750 DynamicSqlTrustMode trustMode = options.getTrustMode(); 2751 HoleFacts holes = holeFacts(sql); 2752 2753 // Honest unresolved reasons — never guess. 2754 // Case (a): the dynamic string itself is not usable — NULL, opaque, or 2755 // assigned under an undecidable branch and collapsed to UNKNOWN. No 2756 // usable inner-SQL candidate exists for this site. 2757 if (sql == null || !sql.usable()) { 2758 String reason = sql != null && sql.reason != null ? sql.reason 2759 : executionGated ? "site " + gateNote(raw, "guarded by an undecidable conditional") 2760 : "dynamic SQL did not reduce to a concrete string"; 2761 return unresolved(raw, dynamicSite, sourceProc, bindingHash, pos, reason, trustMode, holes); 2762 } 2763 // Case (b): the EXEC site is execution-gated, but the string itself is usable 2764 // (CONCRETE or PARTIAL) — only the *did-it-run* fact is uncertain, so we 2765 // degrade-don't-fail rather than dropping the lineage. We fall through to 2766 // analyzeResolvedSql either way: a fully-concrete gated site emits all its 2767 // edges (flagged CONDITIONAL below), and a PARTIAL gated site emits exactly 2768 // the placeholder-free subset a non-gated PARTIAL site would, suppressing only 2769 // the placeholder edges. The downstream status/reason block surfaces both the 2770 // placeholder suppression and the execution gate. (Case (a) — an unusable 2771 // string, NULL/UNKNOWN — was already returned UNRESOLVED above.) 2772 String resolvedSql = sql.text; 2773 if (resolvedSql.length() > options.getMaxResolvedSqlLength()) { 2774 return unresolved(raw, dynamicSite, sourceProc, bindingHash, pos, 2775 "resolved SQL length " + resolvedSql.length() + " exceeds maxResolvedSqlLength", 2776 trustMode, holes); 2777 } 2778 2779 String concreteHash = sha1Hex(resolvedSql); 2780 boolean partialPlaceholders = sql.state == SqlStringValue.State.PARTIAL; 2781 boolean provenanceIncomplete = !hasCompleteLiteralProvenance(sql.text, sql.fragments); 2782 2783 List<DynamicLineageEdge> edges; 2784 boolean parseError; 2785 boolean partialParseError; 2786 boolean nestedIncomplete; 2787 List<String> withheldFiles = java.util.Collections.emptyList(); 2788 try { 2789 Analysis a = analyzeResolvedSql(resolvedSql, vendor, sqlEnv, currentDatabase, defaultSchema, 2790 sourceProc, dynamicSite, bindingHash, options, sql.fragments, nodeNames); 2791 edges = a.edges; 2792 nestedIncomplete = a.nestedIncomplete; 2793 withheldFiles = a.withheldFiles; 2794 // No lineage + recorded errors = parse failure (generateDataFlow() 2795 // does not throw on bad SQL). Errors WITH some lineage = a mixed batch 2796 // where one statement failed; keep the good edges but flag it honestly. 2797 // No lineage because file edges were withheld is not a parse failure. 2798 parseError = a.hadErrors && a.edges.isEmpty() && a.withheldFiles.isEmpty(); 2799 partialParseError = a.hadErrors && !a.edges.isEmpty(); 2800 } catch (RuntimeException ex) { 2801 edges = new ArrayList<DynamicLineageEdge>(); 2802 parseError = true; 2803 partialParseError = false; 2804 nestedIncomplete = false; 2805 } 2806 2807 boolean aggregateIncomplete = provenanceIncomplete || nestedIncomplete 2808 || (partialParseError && trustMode == DynamicSqlTrustMode.SHADOW); 2809 HoleFacts aggregateHoles = nestedIncomplete ? new HoleFacts(-1, false) : holes; 2810 2811 DynamicSiteResult.Status status; 2812 if (parseError) { 2813 status = DynamicSiteResult.Status.PARSE_ERROR; 2814 } else if (executionGated) { 2815 // The undecidable gate dominates the status: whether the string is fully 2816 // concrete or PARTIAL, every emitted edge is reached only if the guarded 2817 // branch runs. CONDITIONAL keeps that "might not execute" caveat visible — 2818 // the strictly-stronger signal to preserve — and the reason below adds the 2819 // placeholder-suppression note when the string is also PARTIAL. 2820 status = DynamicSiteResult.Status.CONDITIONAL; 2821 } else if (partialPlaceholders || !withheldFiles.isEmpty() 2822 || (aggregateIncomplete && trustMode == DynamicSqlTrustMode.SHADOW)) { 2823 status = DynamicSiteResult.Status.PARTIAL; 2824 } else { 2825 status = DynamicSiteResult.Status.RESOLVED; 2826 } 2827 2828 // Compose the reason from every applicable fact rather than picking one — a 2829 // single site can be partial-parse AND placeholder-suppressed AND gated at 2830 // once, and a consumer needs all three to gauge how far to trust the edges. 2831 List<String> reasons = new ArrayList<String>(); 2832 if (parseError) { 2833 reasons.add("materialized SQL failed to parse"); 2834 } else if (partialParseError) { 2835 reasons.add("some statements in the materialized SQL failed to parse; emitted edges are from the parseable statements"); 2836 } 2837 if (partialPlaceholders) { 2838 reasons.add("materialized SQL still contains unbound placeholder(s); placeholder edges suppressed"); 2839 } 2840 int unprovenRelationshipCount = 0; 2841 if (nestedIncomplete) { 2842 reasons.add("nested dynamic SQL is incomplete"); 2843 } 2844 for (String withheld : withheldFiles) { 2845 reasons.add("no edge from " + withheld); 2846 } 2847 if (aggregateIncomplete && trustMode == DynamicSqlTrustMode.SHADOW) { 2848 unprovenRelationshipCount = edges.size(); 2849 reasons.add("SHADOW retained " + unprovenRelationshipCount 2850 + " historical candidate relationship(s) without a per-edge commitment proof; " 2851 + holeDescription(aggregateHoles)); 2852 } 2853 if (executionGated) { 2854 boolean anyEdges = !edges.isEmpty(); 2855 reasons.add("site " + gateNote(raw, "reached through an undecidable conditional") 2856 + (anyEdges ? " — emitted edges assume the guarded statement executed" 2857 : " — no resolvable edges were produced")); 2858 } 2859 String reason = reasons.isEmpty() ? null : String.join("; ", reasons); 2860 2861 return new DynamicSiteResult(raw.kind, status, resolvedSql, concreteHash, sourceProc, dynamicSite, 2862 bindingHash, reason, pos[0], pos[1], edges, trustMode, 2863 unprovenRelationshipCount, aggregateHoles.count, aggregateHoles.exact, 2864 raw.diagnostic); 2865 } 2866 2867 /** A gated site in a CATCH block says so; any other keeps the historical wording it is given. */ 2868 private static String gateNote(TDynamicSqlStringEvaluator.RawSite raw, String undecidedWording) { 2869 return TDynamicSqlStringEvaluator.CATCH_EXECUTION_NOTE.equals(raw.executionNote) 2870 ? raw.executionNote : undecidedWording; 2871 } 2872 2873 private static DynamicSiteResult unresolved(TDynamicSqlStringEvaluator.RawSite raw, String dynamicSite, 2874 String sourceProc, String bindingHash, long[] pos, String reason, 2875 DynamicSqlTrustMode trustMode, HoleFacts holes) { 2876 String text = raw.sql != null && raw.sql.usable() ? raw.sql.text : null; 2877 return new DynamicSiteResult(raw.kind, DynamicSiteResult.Status.UNRESOLVED, text, 2878 text != null ? sha1Hex(text) : null, sourceProc, dynamicSite, bindingHash, reason, 2879 pos[0], pos[1], new ArrayList<DynamicLineageEdge>(), trustMode, 2880 0, holes.count, holes.exact, raw.diagnostic); 2881 } 2882 2883 private static final class HoleFacts { 2884 final int count; 2885 final boolean exact; 2886 2887 HoleFacts(int count, boolean exact) { 2888 this.count = count; 2889 this.exact = exact; 2890 } 2891 } 2892 2893 private static HoleFacts holeFacts(SqlStringValue sql) { 2894 if (sql == null) { 2895 return new HoleFacts(-1, false); 2896 } 2897 int count = 0; 2898 for (SqlFragment fragment : sql.fragments) { 2899 if (!fragment.isLiteral()) { 2900 count++; 2901 } 2902 } 2903 if (sql.usable() && sql.fragments.isEmpty()) { 2904 return new HoleFacts(-1, false); 2905 } 2906 if (!sql.usable() && count == 0) { 2907 // The value did not reduce, yet its provenance shows no hole: whatever 2908 // stopped the fold left no fragment. Not known - never an exact zero. 2909 return new HoleFacts(-1, false); 2910 } 2911 return new HoleFacts(count, true); 2912 } 2913 2914 private static String holeDescription(HoleFacts holes) { 2915 return holes.exact ? holes.count + " unresolved fragment(s)" 2916 : "unresolved fragment count unavailable from the producer"; 2917 } 2918 2919 /** Complete means usable text with non-empty, exclusively literal provenance. */ 2920 private static boolean hasCompleteLiteralProvenance(String text, List<SqlFragment> fragments) { 2921 if (text == null || fragments == null || fragments.isEmpty()) { 2922 return false; 2923 } 2924 StringBuilder rendered = new StringBuilder(); 2925 for (SqlFragment fragment : fragments) { 2926 if (fragment == null || !fragment.isLiteral()) { 2927 return false; 2928 } 2929 rendered.append(fragment.text); 2930 } 2931 return rendered.toString().equals(text); 2932 } 2933 2934 /** 2935 * Shared "analyze a materialized dynamic-SQL string" routine: run ordinary 2936 * dlineage with the proc's database/schema context and lift each column edge 2937 * into a provenance-tagged {@link DynamicLineageEdge}. 2938 */ 2939 /** Edges from analyzing one materialized string, plus whether dlineage recorded parse/analysis errors. */ 2940 private static final class Analysis { 2941 final List<DynamicLineageEdge> edges; 2942 final boolean hadErrors; 2943 final boolean nestedIncomplete; 2944 /** Files whose edges are withheld, each with why (a location relative to an unknown data source). */ 2945 final List<String> withheldFiles; 2946 2947 Analysis(List<DynamicLineageEdge> edges, boolean hadErrors, boolean nestedIncomplete) { 2948 this(edges, hadErrors, nestedIncomplete, java.util.Collections.<String>emptyList()); 2949 } 2950 2951 Analysis(List<DynamicLineageEdge> edges, boolean hadErrors, boolean nestedIncomplete, 2952 List<String> withheldFiles) { 2953 this.edges = edges; 2954 this.hadErrors = hadErrors; 2955 this.nestedIncomplete = nestedIncomplete; 2956 this.withheldFiles = withheldFiles; 2957 } 2958 } 2959 2960 private static Analysis analyzeResolvedSql(String resolvedSql, EDbVendor vendor, 2961 TSQLEnv sqlEnv, String currentDatabase, String defaultSchema, String sourceProc, String dynamicSite, 2962 String bindingHash, DynamicLineageOptions options, 2963 List<SqlFragment> valueProvenance, NodeNames nodeNames) { 2964 2965 Option opt = new Option(); 2966 opt.setVendor(vendor); 2967 opt.setSimpleOutput(false); 2968 opt.setOutput(false); 2969 opt.setShowImplicitSchema(options.isShowImplicitSchema()); 2970 opt.setDynamicSqlTrustMode(options.getTrustMode()); 2971 if (currentDatabase != null && !currentDatabase.isEmpty()) { 2972 opt.setDefaultDatabase(currentDatabase); 2973 } 2974 if (defaultSchema != null && !defaultSchema.isEmpty()) { 2975 opt.setDefaultSchema(defaultSchema); 2976 } 2977 2978 DataFlowAnalyzer nested = new DataFlowAnalyzer(resolvedSql, opt); 2979 if (sqlEnv != null) { 2980 nested.setSqlEnv(sqlEnv); 2981 } 2982 nested.generateDataFlow(); 2983 dataflow df = nested.getDataFlow(); 2984 boolean nestedIncomplete = hasIncompleteNestedDynamicSql(nested, options.getTrustMode()); 2985 2986 List<DynamicLineageEdge> edges = new ArrayList<DynamicLineageEdge>(); 2987 if (df == null) { 2988 return new Analysis(edges, true, nestedIncomplete); 2989 } 2990 boolean hadErrors = df.getErrors() != null && !df.getErrors().isEmpty(); 2991 if (df.getRelationships() == null) { 2992 return new Analysis(edges, hadErrors, nestedIncomplete); 2993 } 2994 2995 // Index the real tables/views by id so we can rebuild fully-qualified 2996 // names from the structured model and fill in the proc's database/schema 2997 // for names the dynamic SQL left unqualified. 2998 Map<String, table> realTables = new HashMap<String, table>(); 2999 indexTables(realTables, df.getTables()); 3000 indexTables(realTables, df.getViews()); 3001 3002 boolean showImplicit = options.isShowImplicitSchema(); 3003 Map<String, String> intermediateNames = nodeNames.assign(df.getRelationships(), realTables, currentDatabase, 3004 defaultSchema, showImplicit); 3005 // files (a BULK INSERT's path, Mantis 4962): named by their URI, never qualified with the proc's database 3006 Map<String, table> files = new HashMap<String, table>(); 3007 indexTables(files, df.getPaths()); 3008 boolean holes = hasValueUnknownHole(valueProvenance); 3009 List<String> withheldFiles = new ArrayList<String>(); 3010 for (relationship rel : df.getRelationships()) { 3011 if (rel == null || rel.getTarget() == null || rel.getSources() == null) { 3012 continue; 3013 } 3014 // a file feeds a table through the table's load column, a system column like RelationRows; its edge is 3015 // lineage all the same 3016 boolean fromFiles = !rel.getSources().isEmpty(); 3017 for (sourceColumn src : rel.getSources()) { 3018 fromFiles &= src != null && files.containsKey(src.getParent_id()); 3019 } 3020 String target = fromFiles 3021 ? joinName(rel.getTarget().getParent_id(), rel.getTarget().getParent_name(), 3022 rel.getTarget().getColumn(), realTables, intermediateNames, currentDatabase, defaultSchema, 3023 showImplicit) 3024 : fullName(rel.getTarget(), realTables, intermediateNames, currentDatabase, defaultSchema, 3025 showImplicit); 3026 // An edge touching an unresolved placeholder (a name still carrying a 3027 // @variable from an unbound binding) must NOT be emitted as resolved 3028 // lineage — it would be a guess. The site stays PARTIAL and its 3029 // resolvedSql still shows the placeholder; only the fully-resolved 3030 // edges flow out. 3031 if (target == null || referencesPlaceholder(target)) { 3032 continue; 3033 } 3034 for (sourceColumn src : rel.getSources()) { 3035 table file = src == null ? null : files.get(src.getParent_id()); 3036 if (file != null) { 3037 // a data source whose LOCATION is not known leaves the file's URI its bare name 3038 if (file.getDataSourceName() != null 3039 && (file.getUri() == null || file.getUri().equals(file.getName()))) { 3040 // relative to a data source whose location is not known: its name alone would make it the 3041 // same file as one of that name under another data source (Mantis 4962) 3042 String why = "file " + file.getName() + " under data source " + file.getDataSourceName() 3043 + ", whose location is not known"; 3044 if (!withheldFiles.contains(why)) { 3045 withheldFiles.add(why); 3046 } 3047 continue; 3048 } 3049 String uri = file.getUri() != null ? file.getUri() : file.getName(); 3050 // a file name may hold an '@' of its own; it is a placeholder only when a hole is left 3051 if (uri == null || "DYNAMIC_TEMPLATE".equals(file.getEndpointKind()) 3052 || (holes && referencesPlaceholder(uri))) { 3053 continue; 3054 } 3055 edges.add(new DynamicLineageEdge(uri, target, rel.getEffectType(), rel.getType(), 3056 sourceProc, dynamicSite, bindingHash, valueProvenance)); 3057 continue; 3058 } 3059 if (isSystem(src)) { 3060 continue; 3061 } 3062 String source = fullName(src, realTables, intermediateNames, currentDatabase, defaultSchema, 3063 showImplicit); 3064 if (source == null || referencesPlaceholder(source)) { 3065 continue; 3066 } 3067 edges.add(new DynamicLineageEdge(source, target, rel.getEffectType(), rel.getType(), 3068 sourceProc, dynamicSite, bindingHash, valueProvenance)); 3069 } 3070 } 3071 return new Analysis(edges, hadErrors, nestedIncomplete, withheldFiles); 3072 } 3073 3074 private static boolean hasIncompleteNestedDynamicSql(DataFlowAnalyzer analyzer, 3075 DynamicSqlTrustMode trustMode) { 3076 if (trustMode == DynamicSqlTrustMode.LEGACY) { 3077 return false; 3078 } 3079 for (DynamicSqlSite site : analyzer.getDynamicSqlSites()) { 3080 // A nested execution is part of the outer program. Surface its 3081 // incompleteness in SHADOW without changing the published model. 3082 if (site.getStatus() != DynamicSqlSite.Status.RESOLVED) { 3083 return true; 3084 } 3085 } 3086 return false; 3087 } 3088 3089 /** Whether {@code fragments} leave a part of the string whose value is not known. */ 3090 private static boolean hasValueUnknownHole(List<SqlFragment> fragments) { 3091 for (int i = 0; fragments != null && i < fragments.size(); i++) { 3092 if (!fragments.get(i).isLiteral() && fragments.get(i).valueUnknown) { 3093 return true; 3094 } 3095 } 3096 return false; 3097 } 3098 3099 /** A resolved object name never legitimately contains '@'; if it does, it still holds an unbound placeholder. */ 3100 private static boolean referencesPlaceholder(String name) { 3101 return name != null && name.indexOf('@') >= 0; 3102 } 3103 3104 /* ------------------------------------------------------------- helpers */ 3105 3106 /** The non-null notes, joined with "; "; null when there are none. */ 3107 public static String joinNotes(String first, String second) { 3108 if (first == null || first.isEmpty()) { 3109 return second == null || second.isEmpty() ? null : second; 3110 } 3111 if (second == null || second.isEmpty() || first.contains(second)) { 3112 return first; 3113 } 3114 return first + "; " + second; 3115 } 3116 3117 private static void indexTables(Map<String, table> index, List<table> tables) { 3118 if (tables == null) { 3119 return; 3120 } 3121 for (table t : tables) { 3122 if (t != null && t.getId() != null) { 3123 index.put(t.getId(), t); 3124 } 3125 } 3126 } 3127 3128 private static String fullName(targetColumn c, Map<String, table> realTables, Map<String, String> nodeNames, 3129 String currentDatabase, String defaultSchema, boolean showImplicit) { 3130 if (c == null || "system".equals(c.getSource())) { 3131 return null; 3132 } 3133 return joinName(c.getParent_id(), c.getParent_name(), c.getColumn(), realTables, nodeNames, currentDatabase, 3134 defaultSchema, showImplicit); 3135 } 3136 3137 private static String fullName(sourceColumn c, Map<String, table> realTables, Map<String, String> nodeNames, 3138 String currentDatabase, String defaultSchema, boolean showImplicit) { 3139 return joinName(c.getParent_id(), c.getParent_name(), c.getColumn(), realTables, nodeNames, currentDatabase, 3140 defaultSchema, showImplicit); 3141 } 3142 3143 /** 3144 * The names the intermediate nodes (result sets, functions and other nodes that are no table of the model) of 3145 * one {@link #resolve} call get, whose projected edges are one list across its dynamic sites and value variants: 3146 * a display name keeps its spelling for the first node that has it alone, and is numbered, {@code COALESCE#1}, 3147 * {@code COALESCE#2}, for every node of the same or a later analysis that shares it, as every call of a function 3148 * shares its name, so that following the edges by name never joins two nodes (Mantis 4848). A number that the 3149 * name of a table of the analysis already has is skipped. 3150 */ 3151 private static final class NodeNames { 3152 /** How many nodes each display name has been given to. */ 3153 private final Map<String, Integer> given = new HashMap<String, Integer>(); 3154 /** Every parent name the edges use: given names and the names of tables. */ 3155 private final Set<String> taken = new java.util.HashSet<String>(); 3156 3157 Map<String, String> assign(List<relationship> relationships, Map<String, table> realTables, 3158 String currentDatabase, String defaultSchema, boolean showImplicit) { 3159 Map<String, Set<String>> idsByName = new LinkedHashMap<String, Set<String>>(); 3160 for (relationship rel : relationships) { 3161 if (rel == null) { 3162 continue; 3163 } 3164 if (rel.getTarget() != null) { 3165 note(idsByName, rel.getTarget().getParent_id(), rel.getTarget().getParent_name(), realTables, 3166 currentDatabase, defaultSchema, showImplicit); 3167 } 3168 if (rel.getSources() != null) { 3169 for (sourceColumn src : rel.getSources()) { 3170 if (src != null) { 3171 note(idsByName, src.getParent_id(), src.getParent_name(), realTables, currentDatabase, 3172 defaultSchema, showImplicit); 3173 } 3174 } 3175 } 3176 } 3177 Map<String, String> names = new HashMap<String, String>(); 3178 for (Map.Entry<String, Set<String>> group : idsByName.entrySet()) { 3179 String name = group.getKey(); 3180 List<String> ids = new ArrayList<String>(group.getValue()); 3181 if (ids.size() == 1 && !given.containsKey(name) && !taken.contains(name)) { 3182 given.put(name, 1); 3183 taken.add(name); 3184 names.put(ids.get(0), name); 3185 continue; 3186 } 3187 if (allNumeric(ids)) { 3188 // ids are made in the order the analysis meets the nodes; otherwise the order they appear in 3189 java.util.Collections.sort(ids, new java.util.Comparator<String>() { 3190 @Override 3191 public int compare(String a, String b) { 3192 return Long.compare(Long.parseLong(a), Long.parseLong(b)); 3193 } 3194 }); 3195 } 3196 for (String id : ids) { 3197 Integer count = given.get(name); 3198 int k = count == null ? 0 : count; 3199 String numbered; 3200 do { 3201 k++; 3202 numbered = name + "#" + k; 3203 } while (taken.contains(numbered)); 3204 given.put(name, k); 3205 taken.add(numbered); 3206 names.put(id, numbered); 3207 } 3208 } 3209 return names; 3210 } 3211 3212 /** Notes a parent of an edge: a table's name is taken, a node's id is grouped under its display name. */ 3213 private void note(Map<String, Set<String>> idsByName, String parentId, String parentName, 3214 Map<String, table> realTables, String currentDatabase, String defaultSchema, boolean showImplicit) { 3215 if (parentId == null || parentName == null) { 3216 return; 3217 } 3218 if (realTables.containsKey(parentId)) { 3219 taken.add(qualifiedParent(parentId, parentName, realTables, currentDatabase, defaultSchema, 3220 showImplicit)); 3221 return; 3222 } 3223 String name = stripBrackets(parentName); 3224 Set<String> ids = idsByName.get(name); 3225 if (ids == null) { 3226 ids = new LinkedHashSet<String>(); 3227 idsByName.put(name, ids); 3228 } 3229 ids.add(parentId); 3230 } 3231 } 3232 3233 private static boolean allNumeric(List<String> ids) { 3234 for (String id : ids) { 3235 if (id.isEmpty() || id.length() > 18) { 3236 return false; 3237 } 3238 for (int i = 0; i < id.length(); i++) { 3239 if (!Character.isDigit(id.charAt(i))) { 3240 return false; 3241 } 3242 } 3243 } 3244 return true; 3245 } 3246 3247 private static boolean isSystem(sourceColumn c) { 3248 return c == null || "system".equals(c.getSource()) || "RelationRows".equals(c.getColumn()); 3249 } 3250 3251 private static String joinName(String parentId, String parentName, String column, 3252 Map<String, table> realTables, Map<String, String> nodeNames, String currentDatabase, 3253 String defaultSchema, boolean showImplicit) { 3254 if (column == null) { 3255 return null; 3256 } 3257 String node = parentId == null ? null : nodeNames.get(parentId); 3258 if (node != null) { 3259 return node + "." + stripBrackets(column); 3260 } 3261 return qualifiedParent(parentId, parentName, realTables, currentDatabase, defaultSchema, showImplicit) 3262 + "." + stripBrackets(column); 3263 } 3264 3265 /** 3266 * Build the qualified parent name from the real-table model. dlineage may 3267 * return the name already qualified (db.schema.table), partly qualified 3268 * (schema.table), bare (table), or with an empty schema slot (db..table). We 3269 * normalize per-segment brackets positionally — last = table, then schema, 3270 * then database — and, only when {@code showImplicit} is on, fill a missing 3271 * schema/database from the proc's {@code defaultSchema}/{@code currentDatabase} 3272 * so an unqualified {@code RECON_JE_02_QSP_…} binds to 3273 * {@code JOURNALENGINE.dbo.RECON_JE_02_QSP_…}. Intermediate result sets 3274 * (no model table) keep their display name unchanged. 3275 */ 3276 private static String qualifiedParent(String parentId, String parentName, Map<String, table> realTables, 3277 String currentDatabase, String defaultSchema, boolean showImplicit) { 3278 table t = parentId == null ? null : realTables.get(parentId); 3279 if (t == null) { 3280 return stripBrackets(parentName); // result set / function / intermediate node 3281 } 3282 String full = t.getFullName() != null ? t.getFullName() : t.getName(); 3283 if (full == null || full.isEmpty()) { 3284 full = parentName; 3285 } 3286 if (full == null) { 3287 return stripBrackets(parentName); 3288 } 3289 3290 // Keep empty segments so positions are preserved (e.g. db..table). 3291 String[] raw = full.split("\\.", -1); 3292 for (int i = 0; i < raw.length; i++) { 3293 raw[i] = stripBrackets(raw[i].trim()); 3294 } 3295 int n = raw.length; 3296 String table = n >= 1 ? raw[n - 1] : stripBrackets(parentName); 3297 String schema = n >= 2 ? raw[n - 2] : ""; 3298 String db = n >= 3 ? raw[n - 3] : ""; 3299 // Anything left of the database segment (e.g. linked-server) is kept as a prefix. 3300 StringBuilder prefix = new StringBuilder(); 3301 for (int i = 0; i < n - 3; i++) { 3302 if (!raw[i].isEmpty()) { 3303 prefix.append(raw[i]).append('.'); 3304 } 3305 } 3306 if (showImplicit) { 3307 if (schema.isEmpty()) { 3308 schema = stripBrackets(defaultSchema); 3309 } 3310 if (db.isEmpty()) { 3311 db = stripBrackets(currentDatabase); 3312 } 3313 } 3314 StringBuilder sb = new StringBuilder(prefix.toString()); 3315 if (db != null && !db.isEmpty()) { 3316 sb.append(db).append('.'); 3317 } 3318 if (schema != null && !schema.isEmpty()) { 3319 sb.append(schema).append('.'); 3320 } 3321 sb.append(table == null ? "" : table); 3322 return sb.length() == 0 ? stripBrackets(parentName) : sb.toString(); 3323 } 3324 3325 private static TStatementList extractBody(TCustomSqlStatement procAst) { 3326 if (procAst instanceof TMssqlCreateProcedure) { 3327 return ((TMssqlCreateProcedure) procAst).getBodyStatements(); 3328 } 3329 return null; 3330 } 3331 3332 private static List<TParameterDeclaration> extractParams(TCustomSqlStatement procAst) { 3333 List<TParameterDeclaration> out = new ArrayList<TParameterDeclaration>(); 3334 if (procAst instanceof TMssqlCreateProcedure) { 3335 TParameterDeclarationList list = ((TMssqlCreateProcedure) procAst).getParameterDeclarations(); 3336 if (list != null) { 3337 for (int i = 0; i < list.size(); i++) { 3338 out.add(list.getParameterDeclarationItem(i)); 3339 } 3340 } 3341 } 3342 return out; 3343 } 3344 3345 private static String extractProcName(TCustomSqlStatement procAst) { 3346 if (procAst instanceof TMssqlCreateProcedure) { 3347 TMssqlCreateProcedure cp = (TMssqlCreateProcedure) procAst; 3348 if (cp.getProcedureName() != null) { 3349 return cp.getProcedureName().toString(); 3350 } 3351 } 3352 return procAst == null ? "" : procAst.getClass().getSimpleName(); 3353 } 3354 3355 private static long[] coordinate(TCustomSqlStatement node) { 3356 try { 3357 TSourceToken t = node.getStartToken(); 3358 if (t != null) { 3359 return new long[] { t.lineNo, t.columnNo }; 3360 } 3361 } catch (Throwable ignore) { 3362 // fall through 3363 } 3364 return new long[] { 0L, 0L }; 3365 } 3366 3367 /** Stable id robust to reformatting: proc identity + structural ordinal path, hashed. */ 3368 private static String dynamicSiteId(String sourceProc, String ordinalPath) { 3369 return "dyn:" + sha1Hex(stripBrackets(sourceProc) + "#" + ordinalPath).substring(0, 12); 3370 } 3371 3372 private static String hashBindings(Map<String, SqlValue> bindings) { 3373 if (bindings == null || bindings.isEmpty()) { 3374 return sha1Hex(""); 3375 } 3376 TreeMap<String, String> sorted = new TreeMap<String, String>(); 3377 for (Map.Entry<String, SqlValue> e : bindings.entrySet()) { 3378 if (e.getKey() == null) { 3379 continue; 3380 } 3381 sorted.put(stripBrackets(e.getKey().trim()).toLowerCase(java.util.Locale.ROOT), 3382 e.getValue() == null ? "NULL" : e.getValue().toString()); 3383 } 3384 StringBuilder sb = new StringBuilder(); 3385 for (Map.Entry<String, String> e : sorted.entrySet()) { 3386 sb.append(e.getKey()).append('=').append(e.getValue()).append(';'); 3387 } 3388 return sha1Hex(sb.toString()); 3389 } 3390 3391 private static String stripBrackets(String s) { 3392 if (s == null) { 3393 return ""; 3394 } 3395 s = s.trim(); 3396 if (s.length() >= 2) { 3397 char a = s.charAt(0); 3398 char z = s.charAt(s.length() - 1); 3399 if ((a == '[' && z == ']') || (a == '"' && z == '"') || (a == '`' && z == '`')) { 3400 return s.substring(1, s.length() - 1); 3401 } 3402 } 3403 return s; 3404 } 3405 3406 private static String sha1Hex(String input) { 3407 try { 3408 MessageDigest md = MessageDigest.getInstance("SHA-1"); 3409 byte[] digest = md.digest(input.getBytes("UTF-8")); 3410 StringBuilder sb = new StringBuilder(digest.length * 2); 3411 for (byte b : digest) { 3412 sb.append(Character.forDigit((b >> 4) & 0xF, 16)); 3413 sb.append(Character.forDigit(b & 0xF, 16)); 3414 } 3415 return sb.toString(); 3416 } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) { 3417 return Integer.toHexString(input.hashCode()); 3418 } 3419 } 3420}