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.List;
029import java.util.Map;
030import java.util.TreeMap;
031
032/**
033 * Orchestrates dynamic-SQL lineage resolution: evaluate the procedure's
034 * dynamic-SQL string building under the given bindings
035 * ({@link TDynamicSqlStringEvaluator}), then for each materialized EXEC site run
036 * ordinary dlineage on the concrete string and tag the resulting edges with
037 * dynamic provenance.
038 *
039 * <p>This is purely additive — it constructs fresh, throwaway
040 * {@link DataFlowAnalyzer} instances for the materialized strings and never
041 * touches the default analysis path, so default dlineage output is unchanged.
042 */
043public final class DynamicSqlLineageResolver {
044
045    private DynamicSqlLineageResolver() {
046    }
047
048    /**
049     * Materialized dynamic-SQL text for one EXEC / sp_executesql site, produced by the
050     * no-bindings default-path evaluation ({@link #materializeSitesWithoutBindings}).
051     */
052    public static final class MaterializedSite {
053        /** Usable (possibly placeholder-bearing) SQL text, or {@code null} if the string did not reduce. */
054        public final String sqlText;
055        /** True when the text still contains placeholder tokens for unbound variables/parameters. */
056        public final boolean partial;
057        /**
058         * True unless the materialization is usable and every rendered character
059         * has literal provenance. This is intentionally stricter than
060         * {@link #partial}: a legacy-CONCRETE numeric/inexact transform can still
061         * have a provenance hole that SHADOW must report.
062         */
063        public final boolean provenanceIncomplete;
064        /** Why the string did not reduce; non-null iff {@link #sqlText} is null. */
065        public final String reason;
066        /**
067         * Ordered provenance fragments. When {@link #sqlText} is non-null their
068         * concatenated text equals it (design:
069         * dynamic-sql-fragment-provenance-design.md); SHADOW completeness checks
070         * and resolved dynamic edges consume that. When the string did not reduce
071         * ({@link #sqlText} null) they are the best-effort provenance of the
072         * argument — every hole met, in order, so a consumer can still name the
073         * value that stopped the fold; their concatenated text is only how far
074         * the fold got. Empty when the producing channel carries no provenance.
075         */
076        public final java.util.List<SqlFragment> fragments;
077        /** Additive analysis note, such as a constant-relation over-approximation. */
078        public final String diagnostic;
079        /** Stable value-set row-tuple suffix; empty on the scalar compatibility path. */
080        public final String variantPath;
081
082        MaterializedSite(String sqlText, boolean partial, String reason) {
083            this(sqlText, partial, reason, null);
084        }
085
086        MaterializedSite(String sqlText, boolean partial, String reason,
087                java.util.List<SqlFragment> fragments) {
088            this(sqlText, partial, reason, fragments, null, "");
089        }
090
091        MaterializedSite(String sqlText, boolean partial, String reason,
092                java.util.List<SqlFragment> fragments, String diagnostic,
093                String variantPath) {
094            this.sqlText = sqlText;
095            this.partial = partial;
096            this.reason = reason;
097            this.fragments = fragments == null || fragments.isEmpty()
098                    ? java.util.Collections.<SqlFragment>emptyList()
099                    : java.util.Collections.unmodifiableList(
100                            new java.util.ArrayList<SqlFragment>(fragments));
101            this.provenanceIncomplete = !DynamicSqlLineageResolver
102                    .hasCompleteLiteralProvenance(sqlText, this.fragments);
103            this.diagnostic = diagnostic;
104            this.variantPath = variantPath == null ? "" : variantPath;
105        }
106
107        public boolean hasCompleteLiteralProvenance() {
108            return !provenanceIncomplete;
109        }
110    }
111
112    /**
113     * Default-path hook for {@link DataFlowAnalyzer}: abstractly evaluate a procedure's
114     * dynamic-SQL string building with NO call-site bindings — only literal parameter
115     * defaults are seeded — and return the materialized string per EXEC/sp_executesql
116     * statement node (identity-keyed against the SAME parse tree that was passed in).
117     * Constant- and template-built strings (REPLACE/QUOTENAME/CONCAT chains over
118     * literals and literal-seeded variables) reduce fully; strings depending on unbound
119     * parameters reduce to placeholder-bearing PARTIAL text; anything else is reported
120     * with a reason. Purely computational — nothing is executed and no model state of
121     * any analyzer is touched.
122     */
123    public static Map<TCustomSqlStatement, MaterializedSite> materializeSitesWithoutBindings(
124            TCustomSqlStatement procAst, EDbVendor vendor, DynamicLineageOptions options) {
125        return materializeSites(procAst, vendor, null, options);
126    }
127
128    /**
129     * Same as {@link #materializeSitesWithoutBindings}, but with caller-supplied parameter
130     * bindings (e.g. extracted from a literal call site) seeded before the walk. Parameters
131     * absent from the bindings fall back to their literal defaults, then to placeholders.
132     */
133    public static Map<TCustomSqlStatement, MaterializedSite> materializeSites(
134            TCustomSqlStatement procAst, EDbVendor vendor, Map<String, SqlValue> bindings,
135            DynamicLineageOptions options) {
136        List<Map<TCustomSqlStatement, MaterializedSite>> variants =
137                materializeSiteVariants(procAst, vendor, bindings, options);
138        return variants.isEmpty()
139                ? new java.util.IdentityHashMap<TCustomSqlStatement, MaterializedSite>()
140                : variants.get(0);
141    }
142
143    /**
144     * Materialize all bounded, row-correlated constant-relation variants for a
145     * procedure. Variant order is deterministic: declaration/statement order,
146     * then INSERT VALUES row order. The legacy single-map API above returns the
147     * first entry and is byte-compatible when the value-set feature is unused.
148     */
149    public static List<Map<TCustomSqlStatement, MaterializedSite>> materializeSiteVariants(
150            TCustomSqlStatement procAst, EDbVendor vendor, Map<String, SqlValue> bindings,
151            DynamicLineageOptions options) {
152        if (options == null) {
153            options = DynamicLineageOptions.createDefault();
154        }
155        List<Map<TCustomSqlStatement, MaterializedSite>> variants =
156                new ArrayList<Map<TCustomSqlStatement, MaterializedSite>>();
157        // Same resource guardrails as resolve(): a binding count over the cap is
158        // rejected outright (best-effort empty result, since this hook must not throw
159        // into the default analysis path).
160        if (bindings != null && bindings.size() > options.getMaxBindings()) {
161            return variants;
162        }
163        TStatementList body = extractBody(procAst);
164        if (body == null) {
165            return variants;
166        }
167        long deadlineNanos = System.nanoTime() + options.getTimeoutMillis() * 1_000_000L;
168        List<ConstRelationVariantPlanner.EvaluationPlan> plans =
169                ConstRelationVariantPlanner.plan(body, vendor, options);
170        for (ConstRelationVariantPlanner.EvaluationPlan plan : plans) {
171            Map<TCustomSqlStatement, MaterializedSite> result =
172                    new java.util.IdentityHashMap<TCustomSqlStatement, MaterializedSite>();
173            TDynamicSqlStringEvaluator evaluator =
174                    new TDynamicSqlStringEvaluator(vendor, options, plan, deadlineNanos);
175            evaluator.seedBindings(bindings);
176            evaluator.seedParameterDefaults(extractParams(procAst));
177            for (TDynamicSqlStringEvaluator.RawSite raw : evaluator.evaluate(body)) {
178                if (raw.node == null) {
179                    continue;
180                }
181                SqlStringValue sql = raw.sql;
182                if (sql != null && sql.usable() && sql.text != null
183                        && sql.text.length() > options.getMaxResolvedSqlLength()) {
184                    result.put(raw.node, new MaterializedSite(null, false,
185                            "resolved SQL length " + sql.text.length()
186                                    + " exceeds maxResolvedSqlLength",
187                            sql.fragments, raw.diagnostic, raw.variantPath));
188                } else if (sql != null && sql.usable()) {
189                    result.put(raw.node, new MaterializedSite(sql.text,
190                            sql.state == SqlStringValue.State.PARTIAL, null, sql.fragments,
191                            raw.diagnostic, raw.variantPath));
192                } else {
193                    // Unreduced: no text, but the value still carries the holes the
194                    // evaluator met (which variable, assigned from what, where) —
195                    // that is the site's unresolved-fragment evidence.
196                    result.put(raw.node, new MaterializedSite(null, false,
197                            sql != null && sql.reason != null ? sql.reason
198                                    : "dynamic SQL did not reduce to a concrete string",
199                            sql == null ? null : sql.fragments, raw.diagnostic, raw.variantPath));
200                }
201            }
202            variants.add(result);
203        }
204        return variants;
205    }
206
207    public static DynamicLineageResult resolve(TCustomSqlStatement procAst, EDbVendor vendor, TSQLEnv sqlEnv,
208            String currentDatabase, String defaultSchema, Map<String, SqlValue> bindings,
209            DynamicLineageOptions options) {
210
211        if (options == null) {
212            options = DynamicLineageOptions.createDefault();
213        }
214        if (bindings != null && bindings.size() > options.getMaxBindings()) {
215            throw new IllegalArgumentException(
216                    "binding count " + bindings.size() + " exceeds maxBindings " + options.getMaxBindings());
217        }
218
219        TStatementList body = extractBody(procAst);
220        List<TParameterDeclaration> params = extractParams(procAst);
221        String sourceProc = extractProcName(procAst);
222        String bindingHash = hashBindings(bindings);
223
224        DynamicLineageResult result = new DynamicLineageResult(sourceProc, bindingHash);
225        if (body == null) {
226            return result; // not a recognized procedure shape — nothing to evaluate
227        }
228
229        long deadlineNanos = System.nanoTime() + options.getTimeoutMillis() * 1_000_000L;
230        List<ConstRelationVariantPlanner.EvaluationPlan> plans =
231                ConstRelationVariantPlanner.plan(body, vendor, options);
232        java.util.IdentityHashMap<TCustomSqlStatement, java.util.Set<String>> seen =
233                new java.util.IdentityHashMap<TCustomSqlStatement, java.util.Set<String>>();
234        boolean truncated = false;
235        int emitted = 0;
236        for (ConstRelationVariantPlanner.EvaluationPlan plan : plans) {
237            TDynamicSqlStringEvaluator evaluator =
238                    new TDynamicSqlStringEvaluator(vendor, options, plan, deadlineNanos);
239            evaluator.seedBindings(bindings);
240            evaluator.seedParameterDefaults(params);
241            for (TDynamicSqlStringEvaluator.RawSite raw : evaluator.evaluate(body)) {
242                if (raw.node == null) {
243                    continue;
244                }
245                String valueKey = raw.sql == null ? "<null>"
246                        : raw.sql.state + "\u0000" + raw.sql.text + "\u0000" + raw.sql.reason
247                                + "\u0000" + raw.controlFlowCertain;
248                java.util.Set<String> values = seen.get(raw.node);
249                if (values == null) {
250                    values = new java.util.LinkedHashSet<String>();
251                    seen.put(raw.node, values);
252                }
253                if (!values.add(valueKey)) {
254                    continue;
255                }
256                if (emitted >= options.getMaxSites()) {
257                    truncated = true;
258                    break;
259                }
260                result.addSite(buildSite(raw, vendor, sqlEnv, currentDatabase,
261                        defaultSchema, sourceProc, bindingHash, options));
262                emitted++;
263            }
264            truncated |= evaluator.isSitesTruncated();
265            if (truncated) {
266                break;
267            }
268        }
269        // Never let a truncated walk look complete — emit an honest diagnostic.
270        if (truncated) {
271            result.addSite(new DynamicSiteResult(DynamicSiteResult.Kind.EXEC_STRING,
272                    DynamicSiteResult.Status.UNRESOLVED, null, null, sourceProc,
273                    dynamicSiteId(sourceProc, "truncated"), bindingHash,
274                    "site/time limit reached (maxSites=" + options.getMaxSites()
275                            + ", timeout=" + options.getTimeoutMillis() + "ms); further sites dropped",
276                    0L, 0L, new ArrayList<DynamicLineageEdge>(), options.getTrustMode(),
277                    0, -1, false));
278        }
279        return result;
280    }
281
282    /**
283     * Discover parameter bindings from literal call sites of {@code procAst} in the same
284     * parsed file: top-level {@code EXEC <proc> 'literal', @p = 'literal', ...} statements
285     * whose (unqualified) module name matches the procedure name. Positional and named
286     * arguments are supported; OUTPUT and non-literal arguments are left unbound (they
287     * become placeholders during evaluation). Returns up to {@code maxBindingSets} distinct
288     * binding sets in file order.
289     */
290    public static List<Map<String, SqlValue>> discoverCallSiteBindings(TCustomSqlStatement procAst,
291            int maxBindingSets) {
292        List<Map<String, SqlValue>> bindingSets = new ArrayList<Map<String, SqlValue>>();
293        if (!(procAst instanceof TMssqlCreateProcedure) || procAst.getGsqlparser() == null) {
294            return bindingSets;
295        }
296        TMssqlCreateProcedure proc = (TMssqlCreateProcedure) procAst;
297        if (proc.getProcedureName() == null) {
298            return bindingSets;
299        }
300        String procSimpleName = lastNameSegment(proc.getProcedureName().toString());
301        List<TParameterDeclaration> params = extractParams(procAst);
302        String procDb = effectiveDatabaseOfProc(procAst.getGsqlparser().getSqlstatements(), procAst);
303        for (BindingCandidate candidate : sameFileBindingCandidates(procAst, proc, procSimpleName,
304                params, procDb, maxBindingSets)) {
305            bindingSets.add(candidate.bindings);
306        }
307        return bindingSets;
308    }
309
310    /**
311     * Matching call sites in the procedure's own file, in file order, deduplicated by
312     * canonical key, up to {@code cap}. Tracks the effective database (USE statements)
313     * so a call after USE DbB is never bound to a same-named procedure defined under
314     * USE DbA.
315     */
316    private static List<BindingCandidate> sameFileBindingCandidates(TCustomSqlStatement procAst,
317            TMssqlCreateProcedure proc, String procSimpleName, List<TParameterDeclaration> params,
318            String procDb, int cap) {
319        List<BindingCandidate> result = new ArrayList<BindingCandidate>();
320        java.util.Set<String> seen = new java.util.HashSet<String>();
321        TStatementList all = procAst.getGsqlparser().getSqlstatements();
322        String currentDb = null;
323        for (int i = 0; i < all.size() && result.size() < cap; i++) {
324            if (all.get(i) instanceof gudusoft.gsqlparser.stmt.TUseDatabase) {
325                currentDb = String.valueOf(((gudusoft.gsqlparser.stmt.TUseDatabase) all.get(i)).getDatabaseName());
326                continue;
327            }
328            if (!(all.get(i) instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlExecute)) {
329                continue;
330            }
331            gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call =
332                    (gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) all.get(i);
333            if (!callMatchesProc(call, proc, procSimpleName, procDb, currentDb)) {
334                continue;
335            }
336            BindingCandidate candidate = bindingCandidateFromCall(call, params);
337            if (candidate != null && seen.add(candidate.canonicalKey)) {
338                result.add(candidate);
339            }
340        }
341        return result;
342    }
343
344    /**
345     * Discover parameter bindings from literal call sites of {@code procAst} located in
346     * OTHER files of the same multi-file analysis (plan Phase 5: cross-file routine
347     * references). {@code otherFileSqlTexts} holds the raw SQL text of every analysis
348     * unit; the procedure's own file is skipped by text identity. A text is parsed at
349     * most once per analysis via the caller-owned {@code parseCache} (key: the text),
350     * and only when it mentions the procedure's simple name at all.
351     *
352     * <p>Never guesses across ambiguity: if any other file defines a procedure with the
353     * same simple name in a database-compatible context, there are two candidate owners
354     * for a matching call and this method returns no bindings at all. Binding sets are
355     * returned in canonical-key order, so the result is independent of the file order
356     * of the analysis. Binding sets already surfaced by the same-file discovery above
357     * are excluded here, so duplicates never consume the caller's shared cap.
358     */
359    public static List<Map<String, SqlValue>> discoverCrossFileCallSiteBindings(
360            TCustomSqlStatement procAst, RoutineCatalog catalog, int maxBindingSets) {
361        List<Map<String, SqlValue>> bindingSets = new ArrayList<Map<String, SqlValue>>();
362        if (maxBindingSets <= 0 || catalog == null
363                || !(procAst instanceof TMssqlCreateProcedure)
364                || procAst.getGsqlparser() == null) {
365            return bindingSets;
366        }
367        TMssqlCreateProcedure proc = (TMssqlCreateProcedure) procAst;
368        if (proc.getProcedureName() == null) {
369            return bindingSets;
370        }
371        String procSimpleName = lastNameSegment(proc.getProcedureName().toString());
372        String probeName = bareName(procSimpleName);
373        if (probeName.isEmpty()) {
374            return bindingSets;
375        }
376        List<TParameterDeclaration> params = extractParams(procAst);
377        String ownText = procAst.getGsqlparser().sqltext;
378        // For matching and the ambiguity verdict, an explicit catalog on the target
379        // definition's own three-part name outranks the USE-derived database (a
380        // definition CREATE PROC DbA.dbo.p IS provably in DbA).
381        String sameFileProcDb = effectiveDatabaseOfProc(procAst.getGsqlparser().getSqlstatements(), procAst);
382        String procDb = definitionDatabase(proc, sameFileProcDb);
383
384        // The same-file pass already surfaced these binding sets (cross-file is only
385        // invoked when its cap was not reached, so an uncapped re-collection sees
386        // exactly the sets it returned). Excluding their keys keeps duplicates from
387        // consuming the shared cap and displacing distinct cross-file bindings.
388        java.util.Set<String> sameFileKeys = new java.util.HashSet<String>();
389        for (BindingCandidate sameFile : sameFileBindingCandidates(procAst, proc, procSimpleName,
390                params, sameFileProcDb, Integer.MAX_VALUE)) {
391            sameFileKeys.add(sameFile.canonicalKey);
392        }
393
394        // Canonical-key ordering makes the accepted binding sets (and which ones
395        // survive the cap) independent of the order files were passed in.
396        java.util.TreeMap<String, Map<String, SqlValue>> ordered =
397                new java.util.TreeMap<String, Map<String, SqlValue>>();
398        for (String text : catalog.unitTexts()) {
399            if (text == null || text.equals(ownText)) {
400                continue;
401            }
402            if (!text.toLowerCase(java.util.Locale.ENGLISH)
403                    .contains(probeName.toLowerCase(java.util.Locale.ENGLISH))) {
404                continue;
405            }
406            RoutineCatalog.UnitIndex unit = catalog.indexOf(text);
407            if (unit == null) {
408                continue;
409            }
410            for (RoutineCatalog.Definition def : unit.definitions) {
411                // A same-named definition in another file: unless the two are
412                // provably in different databases, a matching call has two
413                // candidate owners - bail out entirely rather than guess.
414                if (SQLUtil.sameName(EDbVendor.dbvmssql, ESQLDataObjectType.dotProcedure,
415                        def.simple, procSimpleName)
416                        && !provablyDifferentDatabase(procDb, def.db)) {
417                    return new ArrayList<Map<String, SqlValue>>();
418                }
419            }
420            for (RoutineCatalog.CallCandidate callCandidate : unit.callCandidates) {
421                if (!(callCandidate.stmt instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlExecute)) {
422                    continue;
423                }
424                gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call =
425                        (gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) callCandidate.stmt;
426                if (!callMatchesProc(call, proc, procSimpleName, procDb, callCandidate.db)) {
427                    continue;
428                }
429                BindingCandidate candidate = bindingCandidateFromCall(call, params);
430                if (candidate != null && !sameFileKeys.contains(candidate.canonicalKey)
431                        && !ordered.containsKey(candidate.canonicalKey)) {
432                    ordered.put(candidate.canonicalKey, candidate.bindings);
433                }
434            }
435        }
436        for (Map<String, SqlValue> bindings : ordered.values()) {
437            if (bindingSets.size() >= maxBindingSets) {
438                break;
439            }
440            bindingSets.add(bindings);
441        }
442        return bindingSets;
443    }
444
445    /**
446     * Oracle analog of cross-file call-site discovery (plan Phase 5): collect
447     * top-level statements from OTHER files (anonymous blocks, CALL statements)
448     * that mention a routine defined in the current file, so the caller can feed
449     * them to {@code TASTEvaluator} together with the file's own statements and
450     * fold parameter-dependent EXECUTE IMMEDIATE templates under the callers'
451     * literal arguments.
452     *
453     * <p>Mirrors the MSSQL rules: a text is parsed at most once per analysis via
454     * {@code parseCache} and only when it mentions one of the candidate routine
455     * names; a same-simple-name routine DEFINITION in another file makes that
456     * name ambiguous and drops it from the harvest (never guess between two
457     * candidate owners); foreign definitions themselves are never appended.
458     * Returned statements are ordered by their source text, so evaluation order
459     * is independent of the file order of the analysis.
460     */
461    public static List<TCustomSqlStatement> plsqlInterpretationStatements(
462            TStatementList ownStatements, String ownText, RoutineCatalog catalog,
463            int maxForeignCallers) {
464        List<TCustomSqlStatement> result = new ArrayList<TCustomSqlStatement>();
465        if (ownStatements == null) {
466            return result;
467        }
468        // Routine definitions in this file - the possible callees. Only routines whose
469        // partial dynamic-SQL expressions are provably literal-bindable participate:
470        // the interpreter folds any dependency it cannot evaluate (SELECT INTO
471        // locals, function results, ...) to an EMPTY string, which would produce a
472        // concrete-looking but WRONG statement that the placeholder classifier
473        // cannot catch.
474        List<PlsqlRoutineIdent> idents = new ArrayList<PlsqlRoutineIdent>();
475        for (int i = 0; i < ownStatements.size(); i++) {
476            TCustomSqlStatement stmt = ownStatements.get(i);
477            String name = plsqlRoutineSimpleName(stmt);
478            if (name == null || name.isEmpty()) {
479                continue;
480            }
481            if (plsqlDynamicSqlLiteralBindable(stmt)) {
482                idents.add(new PlsqlRoutineIdent(stmt, name));
483            }
484        }
485        if (idents.isEmpty()) {
486            return result;
487        }
488        // Foreign pass 1: definitions in other files decide ambiguity. A same-simple-
489        // name definition that is not provably in a different schema means a matching
490        // call has two candidate owners.
491        List<RoutineCatalog.UnitIndex> foreignUnits = new ArrayList<RoutineCatalog.UnitIndex>();
492        if (catalog != null) {
493            for (String text : catalog.unitTexts()) {
494                if (text == null || text.equals(ownText)) {
495                    continue;
496                }
497                String lowered = text.toLowerCase(java.util.Locale.ENGLISH);
498                boolean mentionsAny = false;
499                for (PlsqlRoutineIdent ident : idents) {
500                    if (lowered.contains(bareName(ident.simple).toLowerCase(java.util.Locale.ENGLISH))) {
501                        mentionsAny = true;
502                        break;
503                    }
504                }
505                if (!mentionsAny) {
506                    continue;
507                }
508                RoutineCatalog.UnitIndex unit = catalog.indexOf(text);
509                if (unit == null) {
510                    continue;
511                }
512                foreignUnits.add(unit);
513                for (RoutineCatalog.Definition def : unit.definitions) {
514                    for (PlsqlRoutineIdent ident : idents) {
515                        if (!SQLUtil.sameName(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure,
516                                def.simple, ident.simple)) {
517                            continue;
518                        }
519                        // ANY same-simple-name duplicate disqualifies UNQUALIFIED calls:
520                        // the caller's current schema is unknown, so even a provably
521                        // different schema could be the one the call resolves in.
522                        ident.hasForeignSameName = true;
523                        if (!provablyDifferentSchema(ident.schema, def.schema)) {
524                            // Not provably different: qualified calls cannot separate
525                            // the two candidates either.
526                            ident.ambiguous = true;
527                        }
528                    }
529                }
530            }
531        }
532        // Same-file callers (file order, no cap - they were always visible to the
533        // analysis), then foreign callers ordered by their EXACT source text so the
534        // pick under the cap is independent of file order and distinct quoted
535        // literals are never conflated.
536        List<TCustomSqlStatement> callers = new ArrayList<TCustomSqlStatement>();
537        for (int i = 0; i < ownStatements.size(); i++) {
538            TCustomSqlStatement stmt = ownStatements.get(i);
539            if (plsqlRoutineSimpleName(stmt) != null || isPlsqlDefinitionShape(stmt)) {
540                continue;
541            }
542            if (isVettedLiteralCaller(stmt, idents)) {
543                callers.add(stmt);
544            }
545        }
546        java.util.TreeMap<String, TCustomSqlStatement> ordered =
547                new java.util.TreeMap<String, TCustomSqlStatement>();
548        for (RoutineCatalog.UnitIndex unit : foreignUnits) {
549            for (RoutineCatalog.CallCandidate callCandidate : unit.callCandidates) {
550                TCustomSqlStatement stmt = callCandidate.stmt;
551                if (isPlsqlDefinitionShape(stmt)) {
552                    continue;
553                }
554                if (isVettedLiteralCaller(stmt, idents)) {
555                    String key = String.valueOf(stmt.toString());
556                    if (!ordered.containsKey(key)) {
557                        ordered.put(key, stmt);
558                    }
559                }
560            }
561        }
562        int foreignAdded = 0;
563        List<TCustomSqlStatement> foreignCallers = new ArrayList<TCustomSqlStatement>();
564        for (TCustomSqlStatement stmt : ordered.values()) {
565            if (foreignAdded >= maxForeignCallers) {
566                break;
567            }
568            foreignCallers.add(stmt);
569            foreignAdded++;
570        }
571        callers.addAll(foreignCallers);
572        if (callers.isEmpty()) {
573            // No vetted caller anywhere - interpretation would bind nothing.
574            return result;
575        }
576        for (PlsqlRoutineIdent ident : idents) {
577            result.add(ident.definition);
578        }
579        result.addAll(callers);
580        return result;
581    }
582
583    /** Qualified identity of an own-file routine definition, plus its ambiguity verdicts. */
584    private static final class PlsqlRoutineIdent {
585        final TCustomSqlStatement definition;
586        final String schema; // null when the definition name is unqualified
587        final String simple;
588        /** Some other file defines the same simple name AND it is not provably in a different schema. */
589        boolean ambiguous;
590        /** Some other file defines the same simple name at all (disqualifies unqualified calls). */
591        boolean hasForeignSameName;
592
593        PlsqlRoutineIdent(TCustomSqlStatement definition, String simple) {
594            this.definition = definition;
595            this.schema = plsqlRoutineSchema(definition);
596            this.simple = simple;
597        }
598    }
599
600    /** Schema segment of a routine definition name; null when unqualified. */
601    private static String plsqlRoutineSchema(TCustomSqlStatement stmt) {
602        gudusoft.gsqlparser.nodes.TObjectName name = null;
603        if (stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure) {
604            name = ((gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure) stmt).getProcedureName();
605        } else if (stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction) {
606            name = ((gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction) stmt).getFunctionName();
607        }
608        if (name == null) {
609            return null;
610        }
611        List<String> parts = SQLUtil.parseNames(name.toString());
612        return parts.size() >= 2 ? parts.get(parts.size() - 2) : null;
613    }
614
615    /**
616     * True only when the two schema contexts are BOTH known and differ; a null on
617     * either side could be the same schema (the conservative direction for the
618     * ambiguity verdict).
619     */
620    private static boolean provablyDifferentSchema(String schemaA, String schemaB) {
621        if (schemaA == null || schemaB == null) {
622            return false;
623        }
624        return !SQLUtil.sameName(EDbVendor.dbvoracle, ESQLDataObjectType.dotSchema, schemaA, schemaB);
625    }
626
627    /**
628     * Definition shapes that must never enter the interpretation list. Anonymous
629     * blocks (TCommonBlock) ARE TStoredProcedureSqlStatement subclasses and are the
630     * primary caller shape, so this names the definition classes explicitly.
631     */
632    private static boolean isPlsqlDefinitionShape(TCustomSqlStatement stmt) {
633        return stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreatePackage
634                || stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateTrigger
635                || stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateType;
636    }
637
638    /**
639     * True when {@code stmt} contains at least one call that provably targets one of
640     * {@code idents} and EVERY name-colliding call in the statement (a) resolves to
641     * exactly one non-ambiguous identity - unqualified calls match by simple name,
642     * two-part calls require the definition's schema to match, anything the shape
643     * rules cannot prove is rejected because the interpreter may still bind it by
644     * simple name - and (b) passes only compile-time literal arguments. A call fed
645     * by a variable or expression is rejected wholesale: the interpreter would fold
646     * the unevaluated operand to an EMPTY string - not a placeholder - which can
647     * silently produce wrong object names (e.g. 'x' || p_suffix folding to just
648     * "x"). The mssql discovery applies the same literal-only discipline.
649     */
650    private static boolean isVettedLiteralCaller(TCustomSqlStatement stmt,
651            List<PlsqlRoutineIdent> idents) {
652        final boolean[] found = { false };
653        final boolean[] rejected = { false };
654        final List<PlsqlRoutineIdent> targets = idents;
655        try {
656            stmt.acceptChildren(new gudusoft.gsqlparser.nodes.TParseTreeVisitor() {
657                public void preVisit(gudusoft.gsqlparser.nodes.TFunctionCall call) {
658                    vetCall(call);
659                }
660
661                public void preVisit(gudusoft.gsqlparser.stmt.TCallStatement call) {
662                    // TCallStatement.acceptChildren does not surface its routine
663                    // expression as a TFunctionCall - vet it explicitly, else
664                    // CALL p('src') callers are silently never harvested.
665                    if (call.getRoutineExpr() != null) {
666                        vetCall(call.getRoutineExpr().getFunctionCall());
667                    }
668                }
669
670                private void vetCall(gudusoft.gsqlparser.nodes.TFunctionCall call) {
671                    if (call == null || call.getFunctionName() == null) {
672                        return;
673                    }
674                    List<String> parts = SQLUtil.parseNames(call.getFunctionName().toString());
675                    if (parts.isEmpty()) {
676                        return;
677                    }
678                    String simple = parts.get(parts.size() - 1);
679                    PlsqlRoutineIdent matched = null;
680                    boolean collides = false;
681                    boolean multi = false;
682                    for (PlsqlRoutineIdent ident : targets) {
683                        if (!SQLUtil.sameName(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure,
684                                simple, ident.simple)) {
685                            continue;
686                        }
687                        collides = true;
688                        boolean resolves;
689                        if (parts.size() == 1) {
690                            // An unqualified call resolves in the CALLER's (unknown)
691                            // current schema: any same-named definition elsewhere -
692                            // even in a provably different schema - leaves two
693                            // candidates.
694                            resolves = !ident.hasForeignSameName && !ident.ambiguous;
695                        } else if (parts.size() == 2) {
696                            resolves = !ident.ambiguous && ident.schema != null
697                                    && SQLUtil.sameName(EDbVendor.dbvoracle, ESQLDataObjectType.dotSchema,
698                                            parts.get(0), ident.schema);
699                        } else {
700                            resolves = false;
701                        }
702                        if (resolves) {
703                            if (matched != null) {
704                                multi = true;
705                            }
706                            matched = ident;
707                        }
708                    }
709                    if (!collides) {
710                        return;
711                    }
712                    if (matched == null || multi) {
713                        rejected[0] = true;
714                        return;
715                    }
716                    if (call.getArgs() == null || call.getArgs().size() == 0) {
717                        found[0] = true;
718                        return;
719                    }
720                    for (int i = 0; i < call.getArgs().size(); i++) {
721                        if (!isCompileTimeLiteral(call.getArgs().getExpression(i))) {
722                            rejected[0] = true;
723                            return;
724                        }
725                    }
726                    found[0] = true;
727                }
728            });
729        } catch (RuntimeException ex) {
730            return false;
731        }
732        return found[0] && !rejected[0];
733    }
734
735    /**
736     * F1 guard: true when every partial/unfolded EXECUTE IMMEDIATE inside
737     * {@code routine} builds its SQL exclusively from compile-time literals,
738     * routine parameters, and local variables whose every assignment (and declared
739     * default) is itself so composed, connected by || concatenation. Any other
740     * dependency - SELECT/FETCH/EXECUTE..INTO targets, function results, variables
741     * passed to other calls (possible OUT), nested routine declarations (scope
742     * conflation) - disqualifies the whole routine: the interpreter folds what it
743     * cannot evaluate to an EMPTY string, which would fabricate concrete-looking
744     * but wrong SQL that the placeholder classifier cannot detect.
745     */
746    private static boolean plsqlDynamicSqlLiteralBindable(TCustomSqlStatement routine) {
747        PlsqlBindContext ctx = buildPlsqlBindContext(routine);
748        if (ctx == null || ctx.unbindable) {
749            return false;
750        }
751        for (gudusoft.gsqlparser.nodes.TExpression expr : ctx.partialDynamicExprs) {
752            if (!isLiteralBindableExpr(expr, ctx.params, ctx.assignments, ctx.disallowed,
753                    new java.util.HashSet<String>())) {
754                return false;
755            }
756        }
757        return true;
758    }
759
760    /**
761     * Binding-relevant facts collected from one routine's body, shared between the
762     * EXECUTE IMMEDIATE bindability gate and the DBMS_SQL text fold: declared
763     * parameters, tracked single-variable assignments, names disqualified by
764     * untrackable writes, and the dynamic-string expressions of partial/unfolded
765     * EXECUTE IMMEDIATE sites.
766     */
767    static final class PlsqlBindContext {
768        final java.util.Set<String> params = new java.util.HashSet<String>();
769        final Map<String, List<BoundSource>> assignments =
770                new HashMap<String, List<BoundSource>>();
771        final java.util.Set<String> disallowed = new java.util.HashSet<String>();
772        final List<gudusoft.gsqlparser.nodes.TExpression> partialDynamicExprs =
773                new ArrayList<gudusoft.gsqlparser.nodes.TExpression>();
774        /** Nested routine declarations (scope conflation) or a partial site with no expression. */
775        boolean unbindable;
776    }
777
778    /** One tracked assignment source with its source position (for flow-order checks). */
779    static final class BoundSource {
780        final gudusoft.gsqlparser.nodes.TExpression expr;
781        final long line;
782        final long col;
783
784        BoundSource(gudusoft.gsqlparser.nodes.TExpression expr, long line, long col) {
785            this.expr = expr;
786            this.line = line;
787            this.col = col;
788        }
789    }
790
791    /** Collector behind {@link PlsqlBindContext}; null when the walk crashed. */
792    static PlsqlBindContext buildPlsqlBindContext(TCustomSqlStatement routine) {
793        try {
794            final PlsqlBindContext ctx = new PlsqlBindContext();
795            final java.util.Set<String> params = ctx.params;
796            if (routine instanceof gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement) {
797                TParameterDeclarationList declared = ((gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement) routine)
798                        .getParameterDeclarations();
799                if (declared != null) {
800                    for (int i = 0; i < declared.size(); i++) {
801                        TParameterDeclaration pd = declared.getParameterDeclarationItem(i);
802                        if (pd != null && pd.getParameterName() != null) {
803                            params.add(canonVarKey(pd.getParameterName().toString()));
804                        }
805                    }
806                }
807            }
808            final Map<String, List<BoundSource>> assignments = ctx.assignments;
809            final java.util.Set<String> disallowed = ctx.disallowed;
810            final List<gudusoft.gsqlparser.nodes.TExpression> dynamicExprs = ctx.partialDynamicExprs;
811            final boolean[] nestedRoutine = { false };
812            final TCustomSqlStatement root = routine;
813            routine.acceptChildren(new gudusoft.gsqlparser.nodes.TParseTreeVisitor() {
814                public void preVisit(gudusoft.gsqlparser.stmt.TAssignStmt stmt) {
815                    if (stmt.getLeft() == null || stmt.getExpression() == null) {
816                        return;
817                    }
818                    String left = stmt.getLeft().toString();
819                    if (left == null || left.indexOf('(') >= 0
820                            || SQLUtil.parseNames(left).size() != 1) {
821                        return; // record fields / collection elements are not tracked
822                    }
823                    recordAssignment(canonVarKey(left), stmt.getExpression(),
824                            stmt.getStartToken() != null ? stmt.getStartToken()
825                                    : stmt.getExpression().getStartToken());
826                }
827
828                public void preVisit(gudusoft.gsqlparser.stmt.TVarDeclStmt stmt) {
829                    if (stmt.getElementName() == null) {
830                        return;
831                    }
832                    if (stmt.getDefaultValue() != null) {
833                        recordAssignment(canonVarKey(stmt.getElementName().toString()),
834                                stmt.getDefaultValue(), stmt.getStartToken());
835                    }
836                }
837
838                private void recordAssignment(String key,
839                        gudusoft.gsqlparser.nodes.TExpression source,
840                        gudusoft.gsqlparser.TSourceToken position) {
841                    List<BoundSource> list = assignments.get(key);
842                    if (list == null) {
843                        list = new ArrayList<BoundSource>();
844                        assignments.put(key, list);
845                    }
846                    // Unknown position sorts AFTER everything (Long.MAX_VALUE): a source
847                    // whose location cannot be established never counts as preceding a
848                    // use site - the conservative direction for the flow-order check.
849                    list.add(new BoundSource(source,
850                            position != null ? position.lineNo : Long.MAX_VALUE,
851                            position != null ? position.columnNo : Long.MAX_VALUE));
852                }
853
854                public void preVisit(gudusoft.gsqlparser.stmt.TSelectSqlStatement stmt) {
855                    if (stmt.getIntoClause() != null && stmt.getIntoClause().getExprList() != null) {
856                        for (int i = 0; i < stmt.getIntoClause().getExprList().size(); i++) {
857                            disallowExpr(stmt.getIntoClause().getExprList().getExpression(i));
858                        }
859                    }
860                }
861
862                public void preVisit(gudusoft.gsqlparser.stmt.TFetchStmt stmt) {
863                    if (stmt.getVariableNames() != null) {
864                        for (int i = 0; i < stmt.getVariableNames().size(); i++) {
865                            disallowExpr(stmt.getVariableNames().getExpression(i));
866                        }
867                    }
868                }
869
870                public void preVisit(gudusoft.gsqlparser.stmt.TExecImmeStmt stmt) {
871                    if (stmt.getIntoVariables() != null) {
872                        for (int i = 0; i < stmt.getIntoVariables().size(); i++) {
873                            disallowExpr(stmt.getIntoVariables().getExpression(i));
874                        }
875                    }
876                    // USING bind values never rewrite the SQL text, but OUT binds
877                    // write variables - disallow every bind target conservatively.
878                    if (stmt.getBindArguments() != null) {
879                        for (int i = 0; i < stmt.getBindArguments().size(); i++) {
880                            gudusoft.gsqlparser.nodes.TBindArgument bind =
881                                    stmt.getBindArguments().getBindArgument(i);
882                            if (bind != null && bind.getBindArgumentExpr() != null) {
883                                disallowExpr(bind.getBindArgumentExpr());
884                            }
885                        }
886                    }
887                    if (stmt.getDynamicSQL() == null || stmt.isDynamicSQLPartial()) {
888                        if (stmt.getDynamicStringExpr() != null) {
889                            dynamicExprs.add(stmt.getDynamicStringExpr());
890                        } else {
891                            nestedRoutine[0] = true; // unfoldable site with no expression - bail
892                        }
893                    }
894                }
895
896                public void preVisit(gudusoft.gsqlparser.nodes.TFunctionCall call) {
897                    disallowBareArgs(call);
898                }
899
900                public void preVisit(gudusoft.gsqlparser.stmt.TCallStatement stmt) {
901                    // TCallStatement.acceptChildren does not surface its routine
902                    // expression as a TFunctionCall - inspect its args explicitly.
903                    if (stmt.getRoutineExpr() != null) {
904                        disallowBareArgs(stmt.getRoutineExpr().getFunctionCall());
905                    }
906                }
907
908                public void preVisit(gudusoft.gsqlparser.nodes.TReturningClause clause) {
909                    // RETURNING ... INTO writes locals, and the clause's own
910                    // acceptChildren does not visit its variable list - handle it
911                    // here or a literal-initialized local silently keeps its stale
912                    // literal after being overwritten.
913                    if (clause.getVariableList() != null) {
914                        for (int i = 0; i < clause.getVariableList().size(); i++) {
915                            disallowExpr(clause.getVariableList().getExpression(i));
916                        }
917                    }
918                }
919
920                private void disallowBareArgs(gudusoft.gsqlparser.nodes.TFunctionCall call) {
921                    // A bare variable handed to any call could be an OUT actual -
922                    // except at argument positions of KNOWN in-only builtins
923                    // (notably DBMS_SQL.PARSE's own statement argument, which
924                    // would otherwise disqualify the very variable it consumes).
925                    if (call == null || call.getArgs() == null) {
926                        return;
927                    }
928                    for (int i = 0; i < call.getArgs().size(); i++) {
929                        if (isKnownInOnlyArg(call, i)) {
930                            continue;
931                        }
932                        gudusoft.gsqlparser.nodes.TExpression arg = call.getArgs().getExpression(i);
933                        if (arg != null && arg.getExpressionType()
934                                == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) {
935                            disallowExpr(arg);
936                        }
937                    }
938                }
939
940                public void preVisit(gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure stmt) {
941                    if (stmt != root) {
942                        nestedRoutine[0] = true; // nested scopes would conflate name keys
943                    }
944                }
945
946                public void preVisit(gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction stmt) {
947                    if (stmt != root) {
948                        nestedRoutine[0] = true;
949                    }
950                }
951
952                private void disallowExpr(gudusoft.gsqlparser.nodes.TExpression expr) {
953                    if (expr == null) {
954                        return;
955                    }
956                    String text = expr.toString();
957                    if (text != null && SQLUtil.parseNames(text).size() == 1
958                            && text.indexOf('(') < 0) {
959                        disallowed.add(canonVarKey(text));
960                    }
961                }
962            });
963            ctx.unbindable = nestedRoutine[0];
964            return ctx;
965        } catch (RuntimeException ex) {
966            return null;
967        }
968    }
969
970    /**
971     * True when argument {@code index} of {@code call} is an IN-only position of a
972     * known builtin, so a bare variable there is a READ and must not disqualify the
973     * variable from literal folding. Deliberately tiny: DBMS_SQL.PARSE's statement
974     * argument (index 1 positional; named association arrives as an arrow
975     * expression and is never a bare name anyway) and DBMS_OUTPUT.PUT_LINE / PUT
976     * (pure logging, all-IN) — the calls that most commonly surround dynamic SQL.
977     */
978    private static boolean isKnownInOnlyArg(gudusoft.gsqlparser.nodes.TFunctionCall call, int index) {
979        if (call.getFunctionName() == null) {
980            return false;
981        }
982        String name = call.getFunctionName().toString();
983        if (SQLUtil.compareIdentifier(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure,
984                name, "DBMS_SQL.PARSE")
985                || SQLUtil.compareIdentifier(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure,
986                        name, "SYS.DBMS_SQL.PARSE")) {
987            return index == 1;
988        }
989        return SQLUtil.compareIdentifier(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure,
990                name, "DBMS_OUTPUT.PUT_LINE")
991                || SQLUtil.compareIdentifier(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure,
992                        name, "DBMS_OUTPUT.PUT");
993    }
994
995    /**
996     * DBMS_SQL entry point (plan Phase 5 slice 4): fold the SQL-text argument of a
997     * {@code DBMS_SQL.PARSE} call into concrete statement texts. Returns the
998     * no-binding fold first (literal or literal-composed locals), then one fold per
999     * literal call-site binding set (same-file callers first, then cross-file via
1000     * the catalog), deduplicated, capped at {@code maxVariants}. Empty when nothing
1001     * folds — the site then keeps its honest unresolved diagnostic. The fold shares
1002     * the EXECUTE IMMEDIATE whitelist (literals, parameters bound at vetted call
1003     * sites, locals with exactly one literal-composed assignment, || and
1004     * parentheses), so a fold can never fabricate object names.
1005     */
1006    public static List<String> foldDbmsSqlTexts(TCustomSqlStatement enclosingRoutine,
1007            gudusoft.gsqlparser.nodes.TExpression sqlArg, RoutineCatalog catalog, int maxVariants) {
1008        List<String> texts = new ArrayList<String>();
1009        if (sqlArg == null || maxVariants <= 0) {
1010            return texts;
1011        }
1012        PlsqlBindContext ctx = enclosingRoutine == null ? new PlsqlBindContext()
1013                : buildPlsqlBindContext(enclosingRoutine);
1014        if (ctx == null || ctx.unbindable) {
1015            // Context unavailable: only a directly-literal argument can fold.
1016            ctx = new PlsqlBindContext();
1017        }
1018        // Flow-order bound: only assignments strictly before the PARSE site count.
1019        // An unknown site position folds no variables at all (literals still do).
1020        long siteLine = Long.MIN_VALUE;
1021        long siteCol = Long.MIN_VALUE;
1022        if (sqlArg.getStartToken() != null) {
1023            siteLine = sqlArg.getStartToken().lineNo;
1024            siteCol = sqlArg.getStartToken().columnNo;
1025        }
1026        String direct = foldLiteralBindableExpr(sqlArg, ctx,
1027                new HashMap<String, SqlValue>(), new java.util.HashSet<String>(),
1028                siteLine, siteCol);
1029        if (direct != null && !direct.trim().isEmpty()) {
1030            texts.add(direct);
1031        }
1032        if (texts.size() < maxVariants && enclosingRoutine != null) {
1033            for (Map<String, SqlValue> bindings : discoverPlsqlCallSiteBindings(
1034                    enclosingRoutine, catalog, maxVariants)) {
1035                String folded = foldLiteralBindableExpr(sqlArg, ctx, bindings,
1036                        new java.util.HashSet<String>(), siteLine, siteCol);
1037                if (folded != null && !folded.trim().isEmpty() && !texts.contains(folded)) {
1038                    texts.add(folded);
1039                    if (texts.size() >= maxVariants) {
1040                        break;
1041                    }
1042                }
1043            }
1044        }
1045        return texts;
1046    }
1047
1048    /**
1049     * Fold an expression to its concrete string under the P5-2 whitelist: literals,
1050     * parameters WITH a supplied binding (a NULL binding folds to the empty string,
1051     * Oracle's NULL-concatenation semantics), locals with exactly one
1052     * literal-composed assignment, || and parentheses. Null when anything outside
1053     * the whitelist (or an unbound parameter) is involved — never a partial text.
1054     *
1055     * <p>Flow order: a local's single assignment is usable only when it appears
1056     * strictly BEFORE ({@code boundLine},{@code boundCol}) — the use site on the
1057     * first level, then the consuming assignment's own position as the recursion
1058     * descends — so a value assigned after the site can never fabricate lineage.
1059     */
1060    private static String foldLiteralBindableExpr(gudusoft.gsqlparser.nodes.TExpression expr,
1061            PlsqlBindContext ctx, Map<String, SqlValue> bindings, java.util.Set<String> visiting,
1062            long boundLine, long boundCol) {
1063        if (expr == null) {
1064            return null;
1065        }
1066        if (isCompileTimeLiteral(expr)) {
1067            SqlValue value = literalToSqlValue(expr);
1068            if (value == null) {
1069                return null;
1070            }
1071            return value.isNull() ? "" : String.valueOf(value.text());
1072        }
1073        gudusoft.gsqlparser.EExpressionType type = expr.getExpressionType();
1074        if (type == gudusoft.gsqlparser.EExpressionType.concatenate_t) {
1075            String left = foldLiteralBindableExpr(expr.getLeftOperand(), ctx, bindings, visiting,
1076                    boundLine, boundCol);
1077            String right = foldLiteralBindableExpr(expr.getRightOperand(), ctx, bindings, visiting,
1078                    boundLine, boundCol);
1079            return left == null || right == null ? null : left + right;
1080        }
1081        if (type == gudusoft.gsqlparser.EExpressionType.parenthesis_t) {
1082            return foldLiteralBindableExpr(expr.getLeftOperand(), ctx, bindings, visiting,
1083                    boundLine, boundCol);
1084        }
1085        if (type == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) {
1086            String text = expr.toString();
1087            if (text == null || SQLUtil.parseNames(text).size() != 1) {
1088                return null;
1089            }
1090            String key = canonVarKey(text);
1091            if (ctx.disallowed.contains(key)) {
1092                return null;
1093            }
1094            if (ctx.params.contains(key)) {
1095                SqlValue bound = bindings.get(key);
1096                if (bound == null) {
1097                    return null; // unbound parameter - never fold partially
1098                }
1099                return bound.isNull() ? "" : String.valueOf(bound.text());
1100            }
1101            List<BoundSource> sources = ctx.assignments.get(key);
1102            if (sources == null || sources.size() != 1 || !visiting.add(key)) {
1103                return null;
1104            }
1105            try {
1106                BoundSource source = sources.get(0);
1107                if (source.line > boundLine
1108                        || (source.line == boundLine && source.col >= boundCol)) {
1109                    return null; // assigned at or after the use site - value unavailable
1110                }
1111                return foldLiteralBindableExpr(source.expr, ctx, bindings, visiting,
1112                        source.line, source.col);
1113            } finally {
1114                visiting.remove(key);
1115            }
1116        }
1117        return null;
1118    }
1119
1120    /**
1121     * Literal call-site binding sets for an Oracle routine, mirroring the T-SQL
1122     * {@link #discoverCallSiteBindings} semantics: same-file callers first (file
1123     * order), then cross-file callers via the catalog (exact-text order); vetted by
1124     * {@link #isVettedLiteralCaller} (so only positional, compile-time-literal
1125     * arguments to a provably-unambiguous target survive); one binding map per
1126     * matching call, deduplicated by canonical key, capped.
1127     */
1128    static List<Map<String, SqlValue>> discoverPlsqlCallSiteBindings(
1129            TCustomSqlStatement procAst, RoutineCatalog catalog, int maxBindingSets) {
1130        List<Map<String, SqlValue>> bindingSets = new ArrayList<Map<String, SqlValue>>();
1131        if (maxBindingSets <= 0 || procAst == null || procAst.getGsqlparser() == null) {
1132            return bindingSets;
1133        }
1134        String simple = plsqlRoutineSimpleName(procAst);
1135        if (simple == null || simple.isEmpty()) {
1136            return bindingSets;
1137        }
1138        final PlsqlRoutineIdent ident = new PlsqlRoutineIdent(procAst, simple);
1139        TStatementList ownStatements = procAst.getGsqlparser().getSqlstatements();
1140        String ownText = procAst.getGsqlparser().sqltext;
1141        // Same-FILE duplicate definitions make the name just as ambiguous as foreign
1142        // ones (the interpretation path catches this via its multi-ident matching;
1143        // this single-ident path must check explicitly).
1144        if (ownStatements != null) {
1145            for (int i = 0; i < ownStatements.size(); i++) {
1146                TCustomSqlStatement stmt = ownStatements.get(i);
1147                if (stmt == procAst) {
1148                    continue;
1149                }
1150                String otherSimple = plsqlRoutineSimpleName(stmt);
1151                if (otherSimple == null || !SQLUtil.sameName(EDbVendor.dbvoracle,
1152                        ESQLDataObjectType.dotProcedure, otherSimple, ident.simple)) {
1153                    continue;
1154                }
1155                ident.hasForeignSameName = true;
1156                if (!provablyDifferentSchema(ident.schema, plsqlRoutineSchema(stmt))) {
1157                    ident.ambiguous = true;
1158                }
1159            }
1160        }
1161        // Foreign definitions decide the ambiguity verdicts (same rules as the
1162        // interpretation path).
1163        List<RoutineCatalog.UnitIndex> foreignUnits = new ArrayList<RoutineCatalog.UnitIndex>();
1164        if (catalog != null) {
1165            String probe = bareName(simple).toLowerCase(java.util.Locale.ENGLISH);
1166            for (String text : catalog.unitTexts()) {
1167                if (text == null || text.equals(ownText)
1168                        || !text.toLowerCase(java.util.Locale.ENGLISH).contains(probe)) {
1169                    continue;
1170                }
1171                RoutineCatalog.UnitIndex unit = catalog.indexOf(text);
1172                if (unit == null) {
1173                    continue;
1174                }
1175                foreignUnits.add(unit);
1176                for (RoutineCatalog.Definition def : unit.definitions) {
1177                    if (!SQLUtil.sameName(EDbVendor.dbvoracle, ESQLDataObjectType.dotProcedure,
1178                            def.simple, ident.simple)) {
1179                        continue;
1180                    }
1181                    ident.hasForeignSameName = true;
1182                    if (!provablyDifferentSchema(ident.schema, def.schema)) {
1183                        ident.ambiguous = true;
1184                    }
1185                }
1186            }
1187        }
1188        List<PlsqlRoutineIdent> idents = new ArrayList<PlsqlRoutineIdent>();
1189        idents.add(ident);
1190        List<TParameterDeclaration> params = new ArrayList<TParameterDeclaration>();
1191        if (procAst instanceof gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement) {
1192            TParameterDeclarationList declared = ((gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement) procAst)
1193                    .getParameterDeclarations();
1194            if (declared != null) {
1195                for (int i = 0; i < declared.size(); i++) {
1196                    params.add(declared.getParameterDeclarationItem(i));
1197                }
1198            }
1199        }
1200        java.util.Set<String> seen = new java.util.HashSet<String>();
1201        // Same-file callers in file order.
1202        if (ownStatements != null) {
1203            for (int i = 0; i < ownStatements.size()
1204                    && bindingSets.size() < maxBindingSets; i++) {
1205                TCustomSqlStatement stmt = ownStatements.get(i);
1206                if (stmt == procAst || plsqlRoutineSimpleName(stmt) != null
1207                        || isPlsqlDefinitionShape(stmt)) {
1208                    continue;
1209                }
1210                if (isVettedLiteralCaller(stmt, idents)) {
1211                    extractPlsqlBindings(stmt, ident, params, seen, bindingSets, maxBindingSets);
1212                }
1213            }
1214        }
1215        // Cross-file callers ordered by exact statement text.
1216        java.util.TreeMap<String, TCustomSqlStatement> ordered =
1217                new java.util.TreeMap<String, TCustomSqlStatement>();
1218        for (RoutineCatalog.UnitIndex unit : foreignUnits) {
1219            for (RoutineCatalog.CallCandidate candidate : unit.callCandidates) {
1220                if (isPlsqlDefinitionShape(candidate.stmt)) {
1221                    continue;
1222                }
1223                if (isVettedLiteralCaller(candidate.stmt, idents)) {
1224                    String key = String.valueOf(candidate.stmt.toString());
1225                    if (!ordered.containsKey(key)) {
1226                        ordered.put(key, candidate.stmt);
1227                    }
1228                }
1229            }
1230        }
1231        for (TCustomSqlStatement stmt : ordered.values()) {
1232            if (bindingSets.size() >= maxBindingSets) {
1233                break;
1234            }
1235            extractPlsqlBindings(stmt, ident, params, seen, bindingSets, maxBindingSets);
1236        }
1237        return bindingSets;
1238    }
1239
1240    /**
1241     * Extract one binding map per call to {@code ident} inside a VETTED caller
1242     * statement. Vetting already guaranteed every matching call is unambiguous and
1243     * all-literal (positional form — named notation is rejected there), so this
1244     * maps arguments to parameter declarations by position.
1245     */
1246    private static void extractPlsqlBindings(TCustomSqlStatement callerStmt,
1247            final PlsqlRoutineIdent ident, final List<TParameterDeclaration> params,
1248            final java.util.Set<String> seen, final List<Map<String, SqlValue>> bindingSets,
1249            final int maxBindingSets) {
1250        try {
1251            callerStmt.acceptChildren(new gudusoft.gsqlparser.nodes.TParseTreeVisitor() {
1252                public void preVisit(gudusoft.gsqlparser.nodes.TFunctionCall call) {
1253                    collect(call);
1254                }
1255
1256                public void preVisit(gudusoft.gsqlparser.stmt.TCallStatement call) {
1257                    if (call.getRoutineExpr() != null) {
1258                        collect(call.getRoutineExpr().getFunctionCall());
1259                    }
1260                }
1261
1262                private void collect(gudusoft.gsqlparser.nodes.TFunctionCall call) {
1263                    if (call == null || call.getFunctionName() == null
1264                            || bindingSets.size() >= maxBindingSets) {
1265                        return;
1266                    }
1267                    List<String> parts = SQLUtil.parseNames(call.getFunctionName().toString());
1268                    if (parts.isEmpty() || !SQLUtil.sameName(EDbVendor.dbvoracle,
1269                            ESQLDataObjectType.dotProcedure, parts.get(parts.size() - 1),
1270                            ident.simple)) {
1271                        return;
1272                    }
1273                    if (call.getArgs() == null || call.getArgs().size() == 0) {
1274                        return;
1275                    }
1276                    Map<String, SqlValue> bindings = new java.util.LinkedHashMap<String, SqlValue>();
1277                    java.util.TreeMap<String, String> canonical = new java.util.TreeMap<String, String>();
1278                    for (int i = 0; i < call.getArgs().size(); i++) {
1279                        if (i >= params.size() || params.get(i) == null
1280                                || params.get(i).getParameterName() == null) {
1281                            return; // arity beyond declaration - do not guess
1282                        }
1283                        SqlValue value = literalToSqlValue(call.getArgs().getExpression(i));
1284                        if (value == null) {
1285                            return; // vetting should prevent this; stay safe
1286                        }
1287                        String paramName = params.get(i).getParameterName().toString();
1288                        bindings.put(canonVarKey(paramName), value);
1289                        canonical.put(canonVarKey(paramName),
1290                                canonicalValueKey(call.getArgs().getExpression(i)));
1291                    }
1292                    if (!bindings.isEmpty() && seen.add(canonical.toString())) {
1293                        bindingSets.add(bindings);
1294                    }
1295                }
1296            });
1297        } catch (RuntimeException ex) {
1298            // best-effort extraction; a crashed walk contributes nothing
1299        }
1300    }
1301
1302    /**
1303     * Variable-tracking key preserving Oracle quote semantics: unquoted names fold
1304     * upper (case-insensitive), quoted names keep their exact case inside markers so
1305     * a quoted lowercase local is never conflated with an unquoted parameter of the
1306     * same spelling. (Unquoted FOO vs quoted "FOO" key differently - a conservative
1307     * miss that can only disqualify, never wrongly qualify.)
1308     */
1309    private static String canonVarKey(String name) {
1310        String trimmed = name.trim();
1311        if (trimmed.length() >= 2 && trimmed.charAt(0) == '"'
1312                && trimmed.charAt(trimmed.length() - 1) == '"') {
1313            return "\"" + trimmed.substring(1, trimmed.length() - 1) + "\"";
1314        }
1315        return trimmed.toUpperCase(java.util.Locale.ENGLISH);
1316    }
1317
1318    /**
1319     * Recursive whitelist for {@link #plsqlDynamicSqlLiteralBindable}: literals,
1320     * || concatenation, parentheses, routine parameters, and local variables whose
1321     * every recorded assignment is itself bindable. Cycles and everything else fail.
1322     */
1323    private static boolean isLiteralBindableExpr(gudusoft.gsqlparser.nodes.TExpression expr,
1324            java.util.Set<String> params,
1325            Map<String, List<BoundSource>> assignments,
1326            java.util.Set<String> disallowed, java.util.Set<String> visiting) {
1327        if (expr == null) {
1328            return false;
1329        }
1330        if (isCompileTimeLiteral(expr)) {
1331            return true;
1332        }
1333        gudusoft.gsqlparser.EExpressionType type = expr.getExpressionType();
1334        if (type == gudusoft.gsqlparser.EExpressionType.concatenate_t) {
1335            return isLiteralBindableExpr(expr.getLeftOperand(), params, assignments, disallowed, visiting)
1336                    && isLiteralBindableExpr(expr.getRightOperand(), params, assignments, disallowed, visiting);
1337        }
1338        if (type == gudusoft.gsqlparser.EExpressionType.parenthesis_t) {
1339            return isLiteralBindableExpr(expr.getLeftOperand(), params, assignments, disallowed, visiting);
1340        }
1341        if (type == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) {
1342            String text = expr.toString();
1343            if (text == null || SQLUtil.parseNames(text).size() != 1) {
1344                return false;
1345            }
1346            String key = canonVarKey(text);
1347            if (disallowed.contains(key)) {
1348                return false;
1349            }
1350            if (params.contains(key)) {
1351                return true;
1352            }
1353            List<BoundSource> sources = assignments.get(key);
1354            // Exactly ONE recorded source (assignment or declared default): with
1355            // several, control flow (IF/loops) selects which one is live, but the
1356            // interpreter walks structurally and would collapse them to a single
1357            // arbitrary winner - presenting one possible lineage as certain. Real
1358            // branch-sensitive evaluation is Phase 3 CFG work.
1359            if (sources == null || sources.size() != 1 || !visiting.add(key)) {
1360                return false;
1361            }
1362            try {
1363                if (!isLiteralBindableExpr(sources.get(0).expr, params, assignments,
1364                        disallowed, visiting)) {
1365                    return false;
1366                }
1367            } finally {
1368                visiting.remove(key);
1369            }
1370            return true;
1371        }
1372        return false;
1373    }
1374
1375    /** Compile-time literal: string/numeric constant, signed numeric, or bare NULL. */
1376    private static boolean isCompileTimeLiteral(gudusoft.gsqlparser.nodes.TExpression expr) {
1377        if (expr == null) {
1378            return false;
1379        }
1380        gudusoft.gsqlparser.EExpressionType type = expr.getExpressionType();
1381        if (type == gudusoft.gsqlparser.EExpressionType.unary_plus_t
1382                || type == gudusoft.gsqlparser.EExpressionType.unary_minus_t) {
1383            return isCompileTimeLiteral(expr.getRightOperand() != null
1384                    ? expr.getRightOperand() : expr.getLeftOperand());
1385        }
1386        if (type == gudusoft.gsqlparser.EExpressionType.simple_source_token_t
1387                && expr.toString().trim().equalsIgnoreCase("NULL")) { // non-identifier-compare: the NULL keyword literal
1388            return true;
1389        }
1390        return type == gudusoft.gsqlparser.EExpressionType.simple_constant_t
1391                && expr.getConstantOperand() != null;
1392    }
1393
1394    /** Simple name of an Oracle routine definition; null for any other statement. */
1395    private static String plsqlRoutineSimpleName(TCustomSqlStatement stmt) {
1396        gudusoft.gsqlparser.nodes.TObjectName name = null;
1397        if (stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure) {
1398            name = ((gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure) stmt).getProcedureName();
1399        } else if (stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction) {
1400            name = ((gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction) stmt).getFunctionName();
1401        }
1402        if (name == null) {
1403            return null;
1404        }
1405        return lastNameSegment(name.toString());
1406    }
1407
1408    // Package-visible accessors for RoutineCatalog (plan §5.6 index). Same helpers,
1409    // exposed under index-facing names; no behavior of their own.
1410    static String mssqlDefinitionDatabase(TMssqlCreateProcedure def, String currentDb) {
1411        return definitionDatabase(def, currentDb);
1412    }
1413
1414    static String simpleNameOf(String name) {
1415        return lastNameSegment(name);
1416    }
1417
1418    static String plsqlRoutineSimpleNameOf(TCustomSqlStatement stmt) {
1419        return plsqlRoutineSimpleName(stmt);
1420    }
1421
1422    static String plsqlRoutineSchemaOf(TCustomSqlStatement stmt) {
1423        return plsqlRoutineSchema(stmt);
1424    }
1425
1426    /** Effective database (last USE before the definition) of {@code procAst} in its file. */
1427    private static String effectiveDatabaseOfProc(TStatementList all, TCustomSqlStatement procAst) {
1428        String currentDb = null;
1429        for (int i = 0; i < all.size(); i++) {
1430            if (all.get(i) instanceof gudusoft.gsqlparser.stmt.TUseDatabase) {
1431                currentDb = String.valueOf(((gudusoft.gsqlparser.stmt.TUseDatabase) all.get(i)).getDatabaseName());
1432            }
1433            if (all.get(i) == procAst) {
1434                return currentDb;
1435            }
1436        }
1437        return null;
1438    }
1439
1440    /**
1441     * True when {@code call} is a plain (non-dynamic-text) EXEC of {@code proc} under
1442     * the matching rules: unqualified operands match by simple name, qualified operands
1443     * require the full qualified identity (so EXEC audit.load is never taken as a call
1444     * to dbo.load), and one-/two-part names must agree on the effective USE database.
1445     */
1446    private static boolean callMatchesProc(gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call,
1447            TMssqlCreateProcedure proc, String procSimpleName, String procDb, String callDb) {
1448        if (call.getModuleName() == null || call.getSqlText() != null) {
1449            return false;
1450        }
1451        String callName = call.getModuleName().toString();
1452        if (!sameEffectiveDatabase(procDb, callDb, callName)) {
1453            return false;
1454        }
1455        List<String> callParts = SQLUtil.parseNames(callName);
1456        if (callParts.size() <= 1) {
1457            return SQLUtil.sameName(EDbVendor.dbvmssql, ESQLDataObjectType.dotProcedure,
1458                    lastNameSegment(callName), procSimpleName);
1459        }
1460        List<String> procParts = SQLUtil.parseNames(proc.getProcedureName().toString());
1461        if (callParts.size() == procParts.size()) {
1462            return SQLUtil.compareIdentifier(EDbVendor.dbvmssql, ESQLDataObjectType.dotProcedure,
1463                    callName, proc.getProcedureName().toString());
1464        }
1465        if (callParts.size() == 2 && procParts.size() == 3) {
1466            // Two-part call to a three-part definition: database agreement was already
1467            // established by sameEffectiveDatabase (call's USE db vs the definition's
1468            // catalog); compare the remaining schema.name segments.
1469            return SQLUtil.sameName(EDbVendor.dbvmssql, ESQLDataObjectType.dotSchema,
1470                    callParts.get(0), procParts.get(1))
1471                    && SQLUtil.sameName(EDbVendor.dbvmssql, ESQLDataObjectType.dotProcedure,
1472                            callParts.get(1), procParts.get(2));
1473        }
1474        if (callParts.size() == 3 && procParts.size() == 2) {
1475            // Three-part call to a two-part definition: the call's explicit catalog
1476            // must equal the definition's known effective database.
1477            return procDb != null
1478                    && SQLUtil.sameName(EDbVendor.dbvmssql, ESQLDataObjectType.dotCatalog,
1479                            callParts.get(0), procDb)
1480                    && SQLUtil.sameName(EDbVendor.dbvmssql, ESQLDataObjectType.dotSchema,
1481                            callParts.get(1), procParts.get(0))
1482                    && SQLUtil.sameName(EDbVendor.dbvmssql, ESQLDataObjectType.dotProcedure,
1483                            callParts.get(2), procParts.get(1));
1484        }
1485        // Other arity combinations (e.g. one-part definition vs qualified call) keep
1486        // the conservative no-match: schema defaulting is environment-dependent.
1487        return false;
1488    }
1489
1490    /** A literal binding set extracted from one call site plus its canonical dedupe key. */
1491    private static final class BindingCandidate {
1492        final Map<String, SqlValue> bindings;
1493        final String canonicalKey;
1494
1495        BindingCandidate(Map<String, SqlValue> bindings, String canonicalKey) {
1496            this.bindings = bindings;
1497            this.canonicalKey = canonicalKey;
1498        }
1499    }
1500
1501    /**
1502     * Convert one EXEC call's arguments into a binding set; null when nothing literal
1503     * was bound or the call passes a runtime value to a DEFAULTED formal (the declared
1504     * default would otherwise mask the unknown runtime value).
1505     */
1506    private static BindingCandidate bindingCandidateFromCall(
1507            gudusoft.gsqlparser.stmt.mssql.TMssqlExecute call, List<TParameterDeclaration> params) {
1508        Map<String, SqlValue> bindings = new java.util.LinkedHashMap<String, SqlValue>();
1509        java.util.TreeMap<String, String> canonical = new java.util.TreeMap<String, String>();
1510        if (call.getParameters() != null) {
1511            for (int j = 0; j < call.getParameters().size(); j++) {
1512                gudusoft.gsqlparser.nodes.TExecParameter arg = call.getParameters().getExecParameter(j);
1513                if (arg == null || arg.getParameterMode() != TBaseType.parameter_mode_default) {
1514                    // OUT/OUTPUT actuals stay unbound: the value at the dynamic
1515                    // site may differ from the literal passed in.
1516                    continue;
1517                }
1518                String paramName = null;
1519                if (arg.getParameterName() != null) {
1520                    paramName = arg.getParameterName().toString();
1521                } else if (j < params.size() && params.get(j).getParameterName() != null) {
1522                    paramName = params.get(j).getParameterName().toString();
1523                }
1524                SqlValue value = literalToSqlValue(arg.getParameterValue());
1525                if (paramName != null && value == null && hasLiteralDefault(params, paramName)) {
1526                    // Explicitly-supplied runtime value for a DEFAULTED formal: the
1527                    // evaluator would wrongly seed the declared default for it.
1528                    // This call site cannot be represented soundly - drop it.
1529                    return null;
1530                }
1531                if (paramName != null && value != null) {
1532                    bindings.put(paramName, value);
1533                    // Canonical dedupe key: normalized name -> typed escaped value,
1534                    // insensitive to argument order, @-prefix and case.
1535                    String normalized = paramName.startsWith("@") ? paramName.substring(1) : paramName;
1536                    canonical.put(normalized.toUpperCase(java.util.Locale.ENGLISH),
1537                            canonicalValueKey(arg.getParameterValue()));
1538                }
1539            }
1540        }
1541        if (bindings.isEmpty()) {
1542            return null;
1543        }
1544        return new BindingCandidate(bindings, canonical.toString());
1545    }
1546
1547    /**
1548     * Database a definition belongs to: the catalog part of a three-part name wins,
1549     * otherwise the effective USE database at the definition site.
1550     */
1551    private static String definitionDatabase(TMssqlCreateProcedure def, String currentDb) {
1552        List<String> parts = SQLUtil.parseNames(def.getProcedureName().toString());
1553        if (parts.size() >= 3) {
1554            return parts.get(0);
1555        }
1556        return currentDb;
1557    }
1558
1559    /**
1560     * True only when the two database contexts are BOTH known and differ. A null on
1561     * either side means "analysis default" - possibly the same database - so it is
1562     * NOT provably different (the conservative direction for the ambiguity bail-out).
1563     */
1564    private static boolean provablyDifferentDatabase(String dbA, String dbB) {
1565        if (dbA == null || dbB == null) {
1566            return false;
1567        }
1568        return !SQLUtil.sameName(EDbVendor.dbvmssql, ESQLDataObjectType.dotCatalog, dbA, dbB);
1569    }
1570
1571    /** Name stripped of [..] / ".." / `..` quoting, for the cheap contains-prefilter only. */
1572    private static String bareName(String name) {
1573        String trimmed = name.trim();
1574        if (trimmed.length() >= 2) {
1575            char first = trimmed.charAt(0);
1576            char last = trimmed.charAt(trimmed.length() - 1);
1577            if ((first == '[' && last == ']') || (first == '"' && last == '"')
1578                    || (first == '`' && last == '`')) {
1579                return trimmed.substring(1, trimmed.length() - 1).trim();
1580            }
1581        }
1582        return trimmed;
1583    }
1584
1585
1586    /**
1587     * True when the call's effective database matches the procedure definition's. An
1588     * explicit three-part call name carries its own database and was already compared
1589     * as a full qualified identity; here we only reject cross-USE ambiguity for one-
1590     * and two-part names.
1591     */
1592    private static boolean sameEffectiveDatabase(String procDb, String callDb, String callName) {
1593        if (SQLUtil.parseNames(callName).size() >= 3) {
1594            return true;
1595        }
1596        if (procDb == null && callDb == null) {
1597            return true;
1598        }
1599        if (procDb == null || callDb == null) {
1600            return false;
1601        }
1602        return SQLUtil.sameName(EDbVendor.dbvmssql, ESQLDataObjectType.dotCatalog, procDb, callDb);
1603    }
1604
1605    private static boolean hasLiteralDefault(List<TParameterDeclaration> params, String paramName) {
1606        for (TParameterDeclaration param : params) {
1607            if (param.getParameterName() != null
1608                    && SQLUtil.sameName(EDbVendor.dbvmssql, ESQLDataObjectType.dotColumn,
1609                            param.getParameterName().toString(), paramName)) {
1610                return param.getDefaultValue() != null;
1611            }
1612        }
1613        return false;
1614    }
1615
1616    private static String lastNameSegment(String name) {
1617        List<String> parts = SQLUtil.parseNames(name);
1618        return parts.isEmpty() ? name : parts.get(parts.size() - 1);
1619    }
1620
1621    /** T-SQL literal -> binding value; null when the expression is not a compile-time literal. */
1622    private static SqlValue literalToSqlValue(gudusoft.gsqlparser.nodes.TExpression expr) {
1623        if (expr == null) {
1624            return null;
1625        }
1626        gudusoft.gsqlparser.EExpressionType type = expr.getExpressionType();
1627        // Signed numerics arrive as unary +/- wrapping the constant.
1628        if (type == gudusoft.gsqlparser.EExpressionType.unary_plus_t
1629                || type == gudusoft.gsqlparser.EExpressionType.unary_minus_t) {
1630            SqlValue inner = literalToSqlValue(expr.getRightOperand() != null
1631                    ? expr.getRightOperand() : expr.getLeftOperand());
1632            if (inner == null || type == gudusoft.gsqlparser.EExpressionType.unary_plus_t) {
1633                return inner;
1634            }
1635            try {
1636                String innerText = inner.text() == null ? "" : inner.text();
1637                if (innerText.indexOf('.') >= 0) {
1638                    return SqlValue.of(-Double.parseDouble(innerText));
1639                }
1640                return SqlValue.of(-Long.parseLong(innerText));
1641            } catch (NumberFormatException nfe) {
1642                return null;
1643            }
1644        }
1645        // Bare NULL in an EXEC argument surfaces as a plain source token.
1646        if (type == gudusoft.gsqlparser.EExpressionType.simple_source_token_t
1647                && expr.toString().trim().equalsIgnoreCase("NULL")) { // non-identifier-compare: the NULL keyword literal
1648            return SqlValue.nullValue();
1649        }
1650        if (type != gudusoft.gsqlparser.EExpressionType.simple_constant_t
1651                || expr.getConstantOperand() == null) {
1652            return null;
1653        }
1654        String text = expr.getConstantOperand().toString().trim();
1655        if (text.equalsIgnoreCase("NULL")) { // non-identifier-compare: the NULL keyword literal
1656            return SqlValue.nullValue();
1657        }
1658        String unquoted = unquoteTsqlString(text);
1659        if (unquoted != null) {
1660            return SqlValue.of(unquoted);
1661        }
1662        try {
1663            if (text.indexOf('.') >= 0 || text.indexOf('e') >= 0 || text.indexOf('E') >= 0) {
1664                // Bind decimals only when the double round-trips to the exact source
1665                // text - otherwise the evaluated string could differ from the real
1666                // runtime value (precision loss); leave inexact values unbound.
1667                double parsed = Double.parseDouble(text);
1668                SqlValue candidate = SqlValue.of(parsed);
1669                if (text.equals(candidate.text())) {
1670                    return candidate;
1671                }
1672                return null;
1673            }
1674            return SqlValue.of(Long.parseLong(text));
1675        } catch (NumberFormatException nfe) {
1676            return null;
1677        }
1678    }
1679
1680    /** 'abc''d' / N'abc' -> abc'd ; returns null when not a string literal. */
1681    private static String unquoteTsqlString(String text) {
1682        String t = text;
1683        if (t.length() >= 2 && (t.charAt(0) == 'N' || t.charAt(0) == 'n') && t.charAt(1) == '\'') {
1684            t = t.substring(1);
1685        }
1686        if (t.length() >= 2 && t.charAt(0) == '\'' && t.charAt(t.length() - 1) == '\'') {
1687            return t.substring(1, t.length() - 1).replace("''", "'");
1688        }
1689        return null;
1690    }
1691
1692    /** Order/spelling-insensitive value key for binding-set dedupe (length-prefixed to defeat collisions). */
1693    private static String canonicalValueKey(gudusoft.gsqlparser.nodes.TExpression expr) {
1694        String raw = expr == null ? "" : expr.toString().trim();
1695        return raw.length() + ":" + raw;
1696    }
1697
1698    /* --------------------------------------------------------- site building */
1699
1700    private static DynamicSiteResult buildSite(TDynamicSqlStringEvaluator.RawSite raw, EDbVendor vendor,
1701            TSQLEnv sqlEnv, String currentDatabase, String defaultSchema, String sourceProc, String bindingHash,
1702            DynamicLineageOptions options) {
1703
1704        long[] pos = coordinate(raw.node);
1705        String dynamicSite = dynamicSiteId(sourceProc,
1706                raw.ordinalPath + (raw.variantPath == null ? "" : raw.variantPath));
1707        SqlStringValue sql = raw.sql;
1708        // The EXEC site is wrapped in an IF whose predicate we cannot decide from
1709        // the bindings (e.g. IF @Debug = 0 EXEC(@sql) with @Debug unbound).
1710        boolean executionGated = !raw.controlFlowCertain;
1711        DynamicSqlTrustMode trustMode = options.getTrustMode();
1712        HoleFacts holes = holeFacts(sql);
1713
1714        // Honest unresolved reasons — never guess.
1715        // Case (a): the dynamic string itself is not usable — NULL, opaque, or
1716        // assigned under an undecidable branch and collapsed to UNKNOWN. No
1717        // usable inner-SQL candidate exists for this site.
1718        if (sql == null || !sql.usable()) {
1719            String reason = sql != null && sql.reason != null ? sql.reason
1720                    : executionGated ? "site guarded by an undecidable conditional"
1721                            : "dynamic SQL did not reduce to a concrete string";
1722            return unresolved(raw, dynamicSite, sourceProc, bindingHash, pos, reason, trustMode, holes);
1723        }
1724        // Case (b): the EXEC site is execution-gated, but the string itself is usable
1725        // (CONCRETE or PARTIAL) — only the *did-it-run* fact is uncertain, so we
1726        // degrade-don't-fail rather than dropping the lineage. We fall through to
1727        // analyzeResolvedSql either way: a fully-concrete gated site emits all its
1728        // edges (flagged CONDITIONAL below), and a PARTIAL gated site emits exactly
1729        // the placeholder-free subset a non-gated PARTIAL site would, suppressing only
1730        // the placeholder edges. The downstream status/reason block surfaces both the
1731        // placeholder suppression and the execution gate. (Case (a) — an unusable
1732        // string, NULL/UNKNOWN — was already returned UNRESOLVED above.)
1733        String resolvedSql = sql.text;
1734        if (resolvedSql.length() > options.getMaxResolvedSqlLength()) {
1735            return unresolved(raw, dynamicSite, sourceProc, bindingHash, pos,
1736                    "resolved SQL length " + resolvedSql.length() + " exceeds maxResolvedSqlLength",
1737                    trustMode, holes);
1738        }
1739
1740        String concreteHash = sha1Hex(resolvedSql);
1741        boolean partialPlaceholders = sql.state == SqlStringValue.State.PARTIAL;
1742        boolean provenanceIncomplete = !hasCompleteLiteralProvenance(sql.text, sql.fragments);
1743
1744        List<DynamicLineageEdge> edges;
1745        boolean parseError;
1746        boolean partialParseError;
1747        boolean nestedIncomplete;
1748        try {
1749            Analysis a = analyzeResolvedSql(resolvedSql, vendor, sqlEnv, currentDatabase, defaultSchema,
1750                    sourceProc, dynamicSite, bindingHash, options, sql.fragments);
1751            edges = a.edges;
1752            nestedIncomplete = a.nestedIncomplete;
1753            // No lineage + recorded errors = parse failure (generateDataFlow()
1754            // does not throw on bad SQL). Errors WITH some lineage = a mixed batch
1755            // where one statement failed; keep the good edges but flag it honestly.
1756            parseError = a.hadErrors && a.edges.isEmpty();
1757            partialParseError = a.hadErrors && !a.edges.isEmpty();
1758        } catch (RuntimeException ex) {
1759            edges = new ArrayList<DynamicLineageEdge>();
1760            parseError = true;
1761            partialParseError = false;
1762            nestedIncomplete = false;
1763        }
1764
1765        boolean aggregateIncomplete = provenanceIncomplete || nestedIncomplete
1766                || (partialParseError && trustMode == DynamicSqlTrustMode.SHADOW);
1767        HoleFacts aggregateHoles = nestedIncomplete ? new HoleFacts(-1, false) : holes;
1768
1769        DynamicSiteResult.Status status;
1770        if (parseError) {
1771            status = DynamicSiteResult.Status.PARSE_ERROR;
1772        } else if (executionGated) {
1773            // The undecidable gate dominates the status: whether the string is fully
1774            // concrete or PARTIAL, every emitted edge is reached only if the guarded
1775            // branch runs. CONDITIONAL keeps that "might not execute" caveat visible —
1776            // the strictly-stronger signal to preserve — and the reason below adds the
1777            // placeholder-suppression note when the string is also PARTIAL.
1778            status = DynamicSiteResult.Status.CONDITIONAL;
1779        } else if (partialPlaceholders
1780                || (aggregateIncomplete && trustMode == DynamicSqlTrustMode.SHADOW)) {
1781            status = DynamicSiteResult.Status.PARTIAL;
1782        } else {
1783            status = DynamicSiteResult.Status.RESOLVED;
1784        }
1785
1786        // Compose the reason from every applicable fact rather than picking one — a
1787        // single site can be partial-parse AND placeholder-suppressed AND gated at
1788        // once, and a consumer needs all three to gauge how far to trust the edges.
1789        List<String> reasons = new ArrayList<String>();
1790        if (parseError) {
1791            reasons.add("materialized SQL failed to parse");
1792        } else if (partialParseError) {
1793            reasons.add("some statements in the materialized SQL failed to parse; emitted edges are from the parseable statements");
1794        }
1795        if (partialPlaceholders) {
1796            reasons.add("materialized SQL still contains unbound placeholder(s); placeholder edges suppressed");
1797        }
1798        int unprovenRelationshipCount = 0;
1799        if (nestedIncomplete) {
1800            reasons.add("nested dynamic SQL is incomplete");
1801        }
1802        if (aggregateIncomplete && trustMode == DynamicSqlTrustMode.SHADOW) {
1803            unprovenRelationshipCount = edges.size();
1804            reasons.add("SHADOW retained " + unprovenRelationshipCount
1805                    + " historical candidate relationship(s) without a per-edge commitment proof; "
1806                    + holeDescription(aggregateHoles));
1807        }
1808        if (executionGated) {
1809            boolean anyEdges = !edges.isEmpty();
1810            reasons.add("site reached through an undecidable conditional"
1811                    + (anyEdges ? " — emitted edges assume the guarded statement executed"
1812                            : " — no resolvable edges were produced"));
1813        }
1814        String reason = reasons.isEmpty() ? null : String.join("; ", reasons);
1815
1816        return new DynamicSiteResult(raw.kind, status, resolvedSql, concreteHash, sourceProc, dynamicSite,
1817                bindingHash, reason, pos[0], pos[1], edges, trustMode,
1818                unprovenRelationshipCount, aggregateHoles.count, aggregateHoles.exact,
1819                raw.diagnostic);
1820    }
1821
1822    private static DynamicSiteResult unresolved(TDynamicSqlStringEvaluator.RawSite raw, String dynamicSite,
1823            String sourceProc, String bindingHash, long[] pos, String reason,
1824            DynamicSqlTrustMode trustMode, HoleFacts holes) {
1825        String text = raw.sql != null && raw.sql.usable() ? raw.sql.text : null;
1826        return new DynamicSiteResult(raw.kind, DynamicSiteResult.Status.UNRESOLVED, text,
1827                text != null ? sha1Hex(text) : null, sourceProc, dynamicSite, bindingHash, reason,
1828                pos[0], pos[1], new ArrayList<DynamicLineageEdge>(), trustMode,
1829                0, holes.count, holes.exact, raw.diagnostic);
1830    }
1831
1832    private static final class HoleFacts {
1833        final int count;
1834        final boolean exact;
1835
1836        HoleFacts(int count, boolean exact) {
1837            this.count = count;
1838            this.exact = exact;
1839        }
1840    }
1841
1842    private static HoleFacts holeFacts(SqlStringValue sql) {
1843        if (sql == null) {
1844            return new HoleFacts(-1, false);
1845        }
1846        int count = 0;
1847        for (SqlFragment fragment : sql.fragments) {
1848            if (!fragment.isLiteral()) {
1849                count++;
1850            }
1851        }
1852        if (sql.usable() && sql.fragments.isEmpty()) {
1853            return new HoleFacts(-1, false);
1854        }
1855        if (!sql.usable() && count == 0) {
1856            // The value did not reduce, yet its provenance shows no hole: whatever
1857            // stopped the fold left no fragment. Not known - never an exact zero.
1858            return new HoleFacts(-1, false);
1859        }
1860        return new HoleFacts(count, true);
1861    }
1862
1863    private static String holeDescription(HoleFacts holes) {
1864        return holes.exact ? holes.count + " unresolved fragment(s)"
1865                : "unresolved fragment count unavailable from the producer";
1866    }
1867
1868    /** Complete means usable text with non-empty, exclusively literal provenance. */
1869    private static boolean hasCompleteLiteralProvenance(String text, List<SqlFragment> fragments) {
1870        if (text == null || fragments == null || fragments.isEmpty()) {
1871            return false;
1872        }
1873        StringBuilder rendered = new StringBuilder();
1874        for (SqlFragment fragment : fragments) {
1875            if (fragment == null || !fragment.isLiteral()) {
1876                return false;
1877            }
1878            rendered.append(fragment.text);
1879        }
1880        return rendered.toString().equals(text);
1881    }
1882
1883    /**
1884     * Shared "analyze a materialized dynamic-SQL string" routine: run ordinary
1885     * dlineage with the proc's database/schema context and lift each column edge
1886     * into a provenance-tagged {@link DynamicLineageEdge}.
1887     */
1888    /** Edges from analyzing one materialized string, plus whether dlineage recorded parse/analysis errors. */
1889    private static final class Analysis {
1890        final List<DynamicLineageEdge> edges;
1891        final boolean hadErrors;
1892        final boolean nestedIncomplete;
1893
1894        Analysis(List<DynamicLineageEdge> edges, boolean hadErrors, boolean nestedIncomplete) {
1895            this.edges = edges;
1896            this.hadErrors = hadErrors;
1897            this.nestedIncomplete = nestedIncomplete;
1898        }
1899    }
1900
1901    private static Analysis analyzeResolvedSql(String resolvedSql, EDbVendor vendor,
1902            TSQLEnv sqlEnv, String currentDatabase, String defaultSchema, String sourceProc, String dynamicSite,
1903            String bindingHash, DynamicLineageOptions options,
1904            List<SqlFragment> valueProvenance) {
1905
1906        Option opt = new Option();
1907        opt.setVendor(vendor);
1908        opt.setSimpleOutput(false);
1909        opt.setOutput(false);
1910        opt.setShowImplicitSchema(options.isShowImplicitSchema());
1911        opt.setDynamicSqlTrustMode(options.getTrustMode());
1912        if (currentDatabase != null && !currentDatabase.isEmpty()) {
1913            opt.setDefaultDatabase(currentDatabase);
1914        }
1915        if (defaultSchema != null && !defaultSchema.isEmpty()) {
1916            opt.setDefaultSchema(defaultSchema);
1917        }
1918
1919        DataFlowAnalyzer nested = new DataFlowAnalyzer(resolvedSql, opt);
1920        if (sqlEnv != null) {
1921            nested.setSqlEnv(sqlEnv);
1922        }
1923        nested.generateDataFlow();
1924        dataflow df = nested.getDataFlow();
1925        boolean nestedIncomplete = hasIncompleteNestedDynamicSql(nested, options.getTrustMode());
1926
1927        List<DynamicLineageEdge> edges = new ArrayList<DynamicLineageEdge>();
1928        if (df == null) {
1929            return new Analysis(edges, true, nestedIncomplete);
1930        }
1931        boolean hadErrors = df.getErrors() != null && !df.getErrors().isEmpty();
1932        if (df.getRelationships() == null) {
1933            return new Analysis(edges, hadErrors, nestedIncomplete);
1934        }
1935
1936        // Index the real tables/views by id so we can rebuild fully-qualified
1937        // names from the structured model and fill in the proc's database/schema
1938        // for names the dynamic SQL left unqualified.
1939        Map<String, table> realTables = new HashMap<String, table>();
1940        indexTables(realTables, df.getTables());
1941        indexTables(realTables, df.getViews());
1942
1943        boolean showImplicit = options.isShowImplicitSchema();
1944        for (relationship rel : df.getRelationships()) {
1945            if (rel == null || rel.getTarget() == null || rel.getSources() == null) {
1946                continue;
1947            }
1948            String target = fullName(rel.getTarget(), realTables, currentDatabase, defaultSchema, showImplicit);
1949            // An edge touching an unresolved placeholder (a name still carrying a
1950            // @variable from an unbound binding) must NOT be emitted as resolved
1951            // lineage — it would be a guess. The site stays PARTIAL and its
1952            // resolvedSql still shows the placeholder; only the fully-resolved
1953            // edges flow out.
1954            if (target == null || referencesPlaceholder(target)) {
1955                continue;
1956            }
1957            for (sourceColumn src : rel.getSources()) {
1958                if (isSystem(src)) {
1959                    continue;
1960                }
1961                String source = fullName(src, realTables, currentDatabase, defaultSchema, showImplicit);
1962                if (source == null || referencesPlaceholder(source)) {
1963                    continue;
1964                }
1965                edges.add(new DynamicLineageEdge(source, target, rel.getEffectType(), rel.getType(),
1966                        sourceProc, dynamicSite, bindingHash, valueProvenance));
1967            }
1968        }
1969        return new Analysis(edges, hadErrors, nestedIncomplete);
1970    }
1971
1972    private static boolean hasIncompleteNestedDynamicSql(DataFlowAnalyzer analyzer,
1973            DynamicSqlTrustMode trustMode) {
1974        if (trustMode == DynamicSqlTrustMode.LEGACY) {
1975            return false;
1976        }
1977        for (DynamicSqlSite site : analyzer.getDynamicSqlSites()) {
1978            // A nested execution is part of the outer program. Surface its
1979            // incompleteness in SHADOW without changing the published model.
1980            if (site.getStatus() != DynamicSqlSite.Status.RESOLVED) {
1981                return true;
1982            }
1983        }
1984        return false;
1985    }
1986
1987    /** A resolved object name never legitimately contains '@'; if it does, it still holds an unbound placeholder. */
1988    private static boolean referencesPlaceholder(String name) {
1989        return name != null && name.indexOf('@') >= 0;
1990    }
1991
1992    /* ------------------------------------------------------------- helpers */
1993
1994    private static void indexTables(Map<String, table> index, List<table> tables) {
1995        if (tables == null) {
1996            return;
1997        }
1998        for (table t : tables) {
1999            if (t != null && t.getId() != null) {
2000                index.put(t.getId(), t);
2001            }
2002        }
2003    }
2004
2005    private static String fullName(targetColumn c, Map<String, table> realTables, String currentDatabase,
2006            String defaultSchema, boolean showImplicit) {
2007        if (c == null || "system".equals(c.getSource())) {
2008            return null;
2009        }
2010        return joinName(c.getParent_id(), c.getParent_name(), c.getColumn(), realTables, currentDatabase,
2011                defaultSchema, showImplicit);
2012    }
2013
2014    private static String fullName(sourceColumn c, Map<String, table> realTables, String currentDatabase,
2015            String defaultSchema, boolean showImplicit) {
2016        return joinName(c.getParent_id(), c.getParent_name(), c.getColumn(), realTables, currentDatabase,
2017                defaultSchema, showImplicit);
2018    }
2019
2020    private static boolean isSystem(sourceColumn c) {
2021        return c == null || "system".equals(c.getSource()) || "RelationRows".equals(c.getColumn());
2022    }
2023
2024    private static String joinName(String parentId, String parentName, String column,
2025            Map<String, table> realTables, String currentDatabase, String defaultSchema, boolean showImplicit) {
2026        if (column == null) {
2027            return null;
2028        }
2029        return qualifiedParent(parentId, parentName, realTables, currentDatabase, defaultSchema, showImplicit)
2030                + "." + stripBrackets(column);
2031    }
2032
2033    /**
2034     * Build the qualified parent name from the real-table model. dlineage may
2035     * return the name already qualified (db.schema.table), partly qualified
2036     * (schema.table), bare (table), or with an empty schema slot (db..table). We
2037     * normalize per-segment brackets positionally — last = table, then schema,
2038     * then database — and, only when {@code showImplicit} is on, fill a missing
2039     * schema/database from the proc's {@code defaultSchema}/{@code currentDatabase}
2040     * so an unqualified {@code RECON_JE_02_QSP_…} binds to
2041     * {@code JOURNALENGINE.dbo.RECON_JE_02_QSP_…}. Intermediate result sets
2042     * (no model table) keep their display name unchanged.
2043     */
2044    private static String qualifiedParent(String parentId, String parentName, Map<String, table> realTables,
2045            String currentDatabase, String defaultSchema, boolean showImplicit) {
2046        table t = parentId == null ? null : realTables.get(parentId);
2047        if (t == null) {
2048            return stripBrackets(parentName); // result set / function / intermediate node
2049        }
2050        String full = t.getFullName() != null ? t.getFullName() : t.getName();
2051        if (full == null || full.isEmpty()) {
2052            full = parentName;
2053        }
2054        if (full == null) {
2055            return stripBrackets(parentName);
2056        }
2057
2058        // Keep empty segments so positions are preserved (e.g. db..table).
2059        String[] raw = full.split("\\.", -1);
2060        for (int i = 0; i < raw.length; i++) {
2061            raw[i] = stripBrackets(raw[i].trim());
2062        }
2063        int n = raw.length;
2064        String table = n >= 1 ? raw[n - 1] : stripBrackets(parentName);
2065        String schema = n >= 2 ? raw[n - 2] : "";
2066        String db = n >= 3 ? raw[n - 3] : "";
2067        // Anything left of the database segment (e.g. linked-server) is kept as a prefix.
2068        StringBuilder prefix = new StringBuilder();
2069        for (int i = 0; i < n - 3; i++) {
2070            if (!raw[i].isEmpty()) {
2071                prefix.append(raw[i]).append('.');
2072            }
2073        }
2074        if (showImplicit) {
2075            if (schema.isEmpty()) {
2076                schema = stripBrackets(defaultSchema);
2077            }
2078            if (db.isEmpty()) {
2079                db = stripBrackets(currentDatabase);
2080            }
2081        }
2082        StringBuilder sb = new StringBuilder(prefix.toString());
2083        if (db != null && !db.isEmpty()) {
2084            sb.append(db).append('.');
2085        }
2086        if (schema != null && !schema.isEmpty()) {
2087            sb.append(schema).append('.');
2088        }
2089        sb.append(table == null ? "" : table);
2090        return sb.length() == 0 ? stripBrackets(parentName) : sb.toString();
2091    }
2092
2093    private static TStatementList extractBody(TCustomSqlStatement procAst) {
2094        if (procAst instanceof TMssqlCreateProcedure) {
2095            return ((TMssqlCreateProcedure) procAst).getBodyStatements();
2096        }
2097        return null;
2098    }
2099
2100    private static List<TParameterDeclaration> extractParams(TCustomSqlStatement procAst) {
2101        List<TParameterDeclaration> out = new ArrayList<TParameterDeclaration>();
2102        if (procAst instanceof TMssqlCreateProcedure) {
2103            TParameterDeclarationList list = ((TMssqlCreateProcedure) procAst).getParameterDeclarations();
2104            if (list != null) {
2105                for (int i = 0; i < list.size(); i++) {
2106                    out.add(list.getParameterDeclarationItem(i));
2107                }
2108            }
2109        }
2110        return out;
2111    }
2112
2113    private static String extractProcName(TCustomSqlStatement procAst) {
2114        if (procAst instanceof TMssqlCreateProcedure) {
2115            TMssqlCreateProcedure cp = (TMssqlCreateProcedure) procAst;
2116            if (cp.getProcedureName() != null) {
2117                return cp.getProcedureName().toString();
2118            }
2119        }
2120        return procAst == null ? "" : procAst.getClass().getSimpleName();
2121    }
2122
2123    private static long[] coordinate(TCustomSqlStatement node) {
2124        try {
2125            TSourceToken t = node.getStartToken();
2126            if (t != null) {
2127                return new long[] { t.lineNo, t.columnNo };
2128            }
2129        } catch (Throwable ignore) {
2130            // fall through
2131        }
2132        return new long[] { 0L, 0L };
2133    }
2134
2135    /** Stable id robust to reformatting: proc identity + structural ordinal path, hashed. */
2136    private static String dynamicSiteId(String sourceProc, String ordinalPath) {
2137        return "dyn:" + sha1Hex(stripBrackets(sourceProc) + "#" + ordinalPath).substring(0, 12);
2138    }
2139
2140    private static String hashBindings(Map<String, SqlValue> bindings) {
2141        if (bindings == null || bindings.isEmpty()) {
2142            return sha1Hex("");
2143        }
2144        TreeMap<String, String> sorted = new TreeMap<String, String>();
2145        for (Map.Entry<String, SqlValue> e : bindings.entrySet()) {
2146            if (e.getKey() == null) {
2147                continue;
2148            }
2149            sorted.put(stripBrackets(e.getKey().trim()).toLowerCase(),
2150                    e.getValue() == null ? "NULL" : e.getValue().toString());
2151        }
2152        StringBuilder sb = new StringBuilder();
2153        for (Map.Entry<String, String> e : sorted.entrySet()) {
2154            sb.append(e.getKey()).append('=').append(e.getValue()).append(';');
2155        }
2156        return sha1Hex(sb.toString());
2157    }
2158
2159    private static String stripBrackets(String s) {
2160        if (s == null) {
2161            return "";
2162        }
2163        s = s.trim();
2164        if (s.length() >= 2) {
2165            char a = s.charAt(0);
2166            char z = s.charAt(s.length() - 1);
2167            if ((a == '[' && z == ']') || (a == '"' && z == '"') || (a == '`' && z == '`')) {
2168                return s.substring(1, s.length() - 1);
2169            }
2170        }
2171        return s;
2172    }
2173
2174    private static String sha1Hex(String input) {
2175        try {
2176            MessageDigest md = MessageDigest.getInstance("SHA-1");
2177            byte[] digest = md.digest(input.getBytes("UTF-8"));
2178            StringBuilder sb = new StringBuilder(digest.length * 2);
2179            for (byte b : digest) {
2180                sb.append(Character.forDigit((b >> 4) & 0xF, 16));
2181                sb.append(Character.forDigit(b & 0xF, 16));
2182            }
2183            return sb.toString();
2184        } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
2185            return Integer.toHexString(input.hashCode());
2186        }
2187    }
2188}