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}