001package gudusoft.gsqlparser.ir.semantic.diff;
002
003import gudusoft.gsqlparser.EDbVendor;
004import org.w3c.dom.Document;
005import org.w3c.dom.Element;
006import org.w3c.dom.Node;
007import org.w3c.dom.NodeList;
008import org.xml.sax.InputSource;
009
010import javax.xml.parsers.DocumentBuilder;
011import javax.xml.parsers.DocumentBuilderFactory;
012import java.io.StringReader;
013import java.util.ArrayDeque;
014import java.util.ArrayList;
015import java.util.Arrays;
016import java.util.Collections;
017import java.util.Deque;
018import java.util.HashMap;
019import java.util.HashSet;
020import java.util.LinkedHashMap;
021import java.util.LinkedHashSet;
022import java.util.List;
023import java.util.Locale;
024import java.util.Map;
025import java.util.Set;
026
027/**
028 * Project the existing {@code DataFlowAnalyzer} XML output into the same
029 * canonical form as the Semantic IR projector.
030 *
031 * <p>Two projection modes:
032 *
033 * <ul>
034 *   <li>{@link #project(String, EDbVendor)} — Phase-0 single-statement
035 *       mode, consuming only the XML shapes seen in the captured 5-SQL
036 *       corpus baselines (see {@code phase0/golden/*.dlineage.xml}) and
037 *       the slice-1..57 lifts. Pinned by the slice test suite; byte-stable.</li>
038 *   <li>{@link #projectGraph(String, EDbVendor)} — US-011 whole-file mode
039 *       for the full mssql corpus shapes (procedures, variables, cursors,
040 *       temp tables, multi-statement batches, write targets, call
041 *       relationships). See its javadoc for the sink/naming rules.</li>
042 * </ul>
043 *
044 * <p>Any input that doesn't fit produces a {@link ProjectorResult}
045 * carrying an {@link ProjectorResult.UnsupportedReason}; the harness
046 * translates it into a single
047 * {@link DivergenceClass#UNSUPPORTED_BY_DLINEAGE} divergence.
048 *
049 * <p>Aggregate handling — see slice-7 plan §"DlineageXmlProjector". The
050 * COUNT(*) heuristic detects function-resultset fdd sources that include
051 * a {@code column="*"} reference and suppresses SELECT fan-out from that
052 * function. {@code SUM(col)}/{@code AVG(col)}/{@code COUNT(col)} retain
053 * their fan-out.
054 */
055public final class DlineageXmlProjector {
056
057    /**
058     * Lower-cased function names whose presence on the immediate source-side
059     * of an output's fdd marks that output as aggregate. Mirrors the
060     * whitelist in {@code SemanticIRBuilder.AGGREGATE_FUNCTION_NAMES} so that
061     * scalar functions like {@code UPPER}, {@code COALESCE}, or {@code TRIM}
062     * do not cause a false {@link DivergenceClass#AGGREGATION_MISMATCH}
063     * against the IR's {@code OutputColumn.isAggregate()}.
064     *
065     * <p>Slice 30 added {@code mode} (PostgreSQL ordered-set aggregate). See
066     * {@link #ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} for the matching
067     * window-vs-aggregate discriminator override.
068     */
069    private static final Set<String> AGGREGATE_FUNCTION_NAMES;
070    static {
071        Set<String> s = new HashSet<>(Arrays.asList(
072                "count", "sum", "avg", "min", "max",
073                "stddev", "variance", "var_samp", "var_pop",
074                "stddev_samp", "stddev_pop",
075                "listagg", "string_agg", "group_concat", "array_agg",
076                // Slice 30: PostgreSQL ordered-set aggregate. Mirrors
077                // SemanticIRBuilder.AGGREGATE_FUNCTION_NAMES; see also
078                // ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES below.
079                "mode",
080                // Slice 42: hypothetical-set ordered-set aggregates
081                // (Oracle / MSSQL {@code RANK(100) WITHIN GROUP (ORDER
082                // BY x)} family). On Oracle / MSSQL the dlineage XML for
083                // this shape emits NO {@code clauseType="orderby"} fdr —
084                // the slice-13 windowed discriminator
085                // {@link #isWindowFunctionResultset} returns false and
086                // these names mark the output aggregate=true. The OVER
087                // form on Oracle / MSSQL ({@code RANK() OVER (ORDER BY
088                // x)}) emits {@code clauseType="orderby"} and the
089                // discriminator returns true (windowed) — adding these
090                // names to the aggregate set therefore does not affect
091                // OVER-form classification. The PG direct-attachment
092                // hypothetical-set form ({@code rank(0.5) WITHIN GROUP
093                // (...)}) and PG OVER form share the same XML shape;
094                // both are classified as windowed (rank is NOT in
095                // ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES) — slice 42
096                // does NOT lift PG, the IR builder rejects PG
097                // hypothetical-set so the divergence harness never
098                // sees that case. Probe-confirmed
099                // {@code /tmp/probe42/Probe42.java}.
100                "rank", "dense_rank", "percent_rank", "cume_dist"));
101        AGGREGATE_FUNCTION_NAMES = Collections.unmodifiableSet(s);
102    }
103
104    /**
105     * Function names whose {@code clauseType="orderby"} fdr may be the
106     * WITHIN GROUP order-by rather than a window {@code OVER ORDER BY}.
107     *
108     * <p>Slice 30 introduced {@code mode}. Slice 35 added PostgreSQL
109     * {@code listagg}; slice 36 adds PostgreSQL {@code string_agg}. Probes
110     * show dlineage emits the WITHIN GROUP order-by for both as an fdr with
111     * {@code clauseType="orderby"} structurally identical to LISTAGG (probe
112     * /tmp/ProbeStringAggXml.java verified the XML for aliased and unaliased
113     * STRING_AGG WG: a {@code resultset type="function"} STRING_AGG wrapper,
114     * an {@code fdd effectType="function"} target/source pair for the arg,
115     * and a single {@code fdr clauseType="orderby"} for the WG key — no
116     * {@code clauseType="selectList"} fdr is emitted, so the slice-13
117     * projector otherwise mistakes the orderby fdr for a window discriminator
118     * and reports {@link DivergenceClass#AGGREGATION_MISMATCH}). This set
119     * does NOT blindly short-circuit window classification:
120     * {@link #isWindowFunctionResultset} still returns {@code true} when a
121     * {@code selectList} fdr is present (PARTITION BY / analytic dependency),
122     * and only suppresses the order-by-only shape.
123     *
124     * <p><b>Subset constraint</b>: must be a strict subset of
125     * {@link #AGGREGATE_FUNCTION_NAMES} so the IR-side / projector-side
126     * aggregate-flag invariants stay symmetric. The class-init check below
127     * enforces this.
128     *
129     * <p><b>Why not include {@code percentile_cont} / {@code percentile_disc}</b>:
130     * these have windowed forms in BigQuery / Vertica / Redshift / Oracle /
131     * SQL Server (e.g. {@code PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x)
132     * OVER (PARTITION BY y)}). Lifting them requires a stronger structural
133     * discriminator. Similarly, hypothetical-set {@code rank} /
134     * {@code dense_rank} remain excluded because their PG XML is structurally
135     * identical to window form {@code RANK() OVER (ORDER BY ...)}.
136     */
137    private static final Set<String> ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES;
138    static {
139        Set<String> s = new HashSet<>();
140        s.add("mode");
141        s.add("listagg");
142        s.add("string_agg");
143        ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES = Collections.unmodifiableSet(s);
144        // Subset assertion: must be enforced at runtime (not via assert,
145        // which can be disabled). A future contributor adding a name to
146        // ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES without also adding it to
147        // AGGREGATE_FUNCTION_NAMES would produce silently-wrong projector
148        // output (the function would be "non-aggregate but
149        // not-windowed-either" — first-hop check skips it entirely).
150        if (!AGGREGATE_FUNCTION_NAMES.containsAll(ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES)) {
151            Set<String> missing = new HashSet<>(ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES);
152            missing.removeAll(AGGREGATE_FUNCTION_NAMES);
153            throw new IllegalStateException(
154                    "ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES must be a subset of "
155                            + "AGGREGATE_FUNCTION_NAMES; missing=" + missing);
156        }
157    }
158
159    /**
160     * Functions whose {@code (*)} argument is a row-counting marker rather
161     * than a real value dependency, so SELECT fan-out from them should be
162     * suppressed (otherwise the dlineage XML's expansion to every joined
163     * column would produce false {@link DivergenceClass#IR_MISSING_DEPENDENCY}
164     * edges relative to the IR's "aggregate, no sources" representation).
165     * Today the only widely-supported example is {@code COUNT(*)}.
166     */
167    private static final Set<String> NULL_ARG_AGGREGATES;
168    static {
169        Set<String> s = new HashSet<>();
170        s.add("count");
171        NULL_ARG_AGGREGATES = Collections.unmodifiableSet(s);
172    }
173
174    /**
175     * Slice 48 — wrapper-function names through which the projector will
176     * descend ONE fdd hop to look for a known ordered-set aggregate
177     * inner.
178     *
179     * <p>Slice 48 introduced {@code lower}. Slice 50 widened the set to
180     * include {@code upper} — the natural case-folding sibling. Slice 51
181     * widened the set further to include {@code concat} — the canonical
182     * multi-argument scalar concatenation wrapper. Slice 52 widened the
183     * set again to include {@code trim} / {@code ltrim} / {@code rtrim} —
184     * the canonical whitespace-trimming scalar wrappers. Slice 53 widened
185     * it to include {@code substring} / {@code substr} — the
186     * three-argument string-extraction wrappers. Slice 54 widened it once
187     * more to include {@code replace} — the canonical three-argument
188     * pattern-replacement wrapper. Slice 55 widens it again to include
189     * {@code lpad} / {@code rpad} — the canonical three-argument string
190     * padding wrappers. Slice 51 probes ({@code /tmp/Probe51.java},
191     * {@code /tmp/Probe51b.java}), slice 52 probe
192     * ({@code /tmp/Probe52.java}), slice 53 probe
193     * ({@code /tmp/Probe53.java}), slice 54 probe (deleted
194     * post-implementation; see {@code Slice54Test} javadoc) and slice 55
195     * probe (transient {@code Slice55LpadRpadProbeTest} inside the Maven
196     * test JVM, deleted post-confirmation; see {@code Slice55Test}
197     * javadoc) confirmed the dlineage XML for {@code CONCAT(...)} /
198     * {@code TRIM(...)} / {@code LTRIM(...)} / {@code RTRIM(...)} /
199     * {@code SUBSTRING(...)} / {@code SUBSTR(...)} / {@code REPLACE(...)}
200     * / {@code LPAD(...)} / {@code RPAD(...)} differs from
201     * {@code LOWER(...)} / {@code UPPER(...)} only in the wrapper
202     * resultset's {@code name} attribute. The literal-string /
203     * character-class / direction-marker / numeric start+length /
204     * pattern+replacement / pad-length / pad-character arguments do NOT
205     * generate extra {@code fdd} edges on the wrapping column
206     * (constants and BOTH/LEADING/TRAILING/FROM/FOR markers do not
207     * produce fdd edges in the dlineage XML), so the structural shape is
208     * byte-identical to the case-folding wrapper shape: one
209     * {@code fdd effectType="select"} from the SELECT-list resultset to
210     * the wrapper column, then one {@code fdd effectType="function"}
211     * from the wrapper column to the inner ordered-set aggregate's
212     * resultset, then the inner aggregate's own argument fdd and
213     * orderby fdr exactly as in the case-folding wrapper shape. The
214     * IR-side classification (aggregate=true via the slice-31 collector
215     * descending into the wrapping function body) and the dlineage-side
216     * aggregate classification lift symmetrically once the wrapper
217     * allowlist contains the new wrapper name.
218     *
219     * <p>The {@code ||} (string-concatenation) operator is a special
220     * non-case: PG / Snowflake parsers fold {@code expr || ' '} into
221     * the projection root WITHOUT emitting a {@code concat} or
222     * {@code ||}-named function-resultset wrapper. The dlineage XML for
223     * {@code LISTAGG WG || '|'} is byte-identical to the unwrapped
224     * {@code LISTAGG WG} form — no descent is needed because the
225     * existing first-hop aggregate detection
226     * (slice-30 / 35 / 36 carve-outs in
227     * {@link #ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES}) already
228     * classifies that direct path as aggregate=true. Slice 51 / 52 / 53
229     * / 54 therefore do NOT add {@code ||} to this allowlist; the
230     * wrapper widen is only required for explicit named function calls.
231     *
232     * <p>{@code TRIM(BOTH ' ' FROM x)} (PG / Snowflake special grammar)
233     * is a syntactic surface only — slice 52 probe confirmed the
234     * dlineage XML still emits a {@code resultset type="function"}
235     * named {@code TRIM} with the same one-fdd-edge connection chain
236     * as canonical {@code TRIM(x)}. The descent therefore fires for
237     * both forms unchanged. Likewise, the PG SQL-standard
238     * {@code SUBSTRING(x FROM 1 FOR 5)} grammar form (with
239     * {@code FROM}/{@code FOR} keyword markers) emits the same chain
240     * shape as the canonical {@code SUBSTRING(x, 1, 5)} comma form —
241     * slice 53 probe confirmed parity. The two-argument
242     * {@code SUBSTRING(x, 1)} call (no length) emits the same chain
243     * because constants do not produce fdd edges. Slice 54
244     * {@code REPLACE(x, pattern, replacement)} probe confirmed the
245     * pattern and replacement string literals likewise produce no fdd
246     * edges on the wrapping {@code REPLACE.REPLACE} column, so the
247     * descent fires identically regardless of the literal values.
248     * Slice 55 {@code LPAD(x, length, padchar)} /
249     * {@code RPAD(x, length, padchar)} probe confirmed the integer
250     * pad-length and literal-string pad-character arguments likewise
251     * produce no fdd edges on the wrapping {@code LPAD.LPAD} /
252     * {@code RPAD.RPAD} column, so the descent fires identically
253     * regardless of the pad-length / pad-character values.
254     *
255     * <p>Slice 56 {@code LEFT(x, length)} / {@code RIGHT(x, length)}
256     * probe confirmed the integer length argument (2nd arg) likewise
257     * produces no fdd edges on the wrapping {@code LEFT.LEFT} /
258     * {@code RIGHT.RIGHT} column — only the inner ordered-set
259     * aggregate's resultset is the fdd source — so the descent fires
260     * identically regardless of the literal length value.
261     *
262     * <p>Slice 57 {@code REGEXP_REPLACE(x, pattern, replacement [,
263     * flags] [, position, occurrence, parameters])} probe (transient
264     * test inside the Maven test JVM, deleted post-confirmation)
265     * confirmed the regex pattern, replacement string, optional
266     * flag, and Snowflake positional / occurrence / parameter
267     * literals likewise produce no fdd edges on the wrapping
268     * {@code REGEXP_REPLACE.REGEXP_REPLACE} column — only the inner
269     * ordered-set aggregate's resultset is the fdd source — so the
270     * descent fires identically regardless of the constant
271     * pattern / replacement / flag / positional values. The PG
272     * 3-arg form, PG 4-arg flag form, SF 3-arg form, and SF 6-arg
273     * (position / occurrence / parameters) form all produce the
274     * same chain shape as slice-49 {@code LOWER(...)}.
275     *
276     * <p>The wrapper allowlist is intentionally NOT vendor-gated:
277     * PostgreSQL and Snowflake are the validated scope per the
278     * slice-57 probe, but any dialect whose dlineage XML emits a
279     * {@code REGEXP_REPLACE}-named function-resultset wrapper around
280     * a {@code mode} / {@code listagg} / {@code string_agg} inner
281     * with a non-window fdr (typically the WITHIN GROUP order-by
282     * fdr admitted by the slice-30 / 35 / 36 carve-outs in
283     * {@link #ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES}) will lift
284     * transparently. This matches the slice-48 → 56 posture.
285     *
286     * <p>Other scalar wrappers ({@code COALESCE},
287     * {@code INITCAP}, {@code REVERSE}, {@code LENGTH} /
288     * {@code CHAR_LENGTH}, arithmetic) stay out of scope. Each
289     * carries a different argument-shape signature in the dlineage
290     * XML and would need an independent probe pass before being
291     * widened — slice 57 stays narrowly focused on the canonical
292     * case-folding / concat / trim / substring / replace / lpad /
293     * rpad / left / right / regexp_replace family.
294     */
295    private static final Set<String> SLICE48_DESCENT_WRAPPER_NAMES;
296    static {
297        Set<String> s = new HashSet<>();
298        s.add("lower");
299        s.add("upper");
300        s.add("concat");
301        s.add("trim");
302        s.add("ltrim");
303        s.add("rtrim");
304        s.add("substring");
305        s.add("substr");
306        s.add("replace");
307        s.add("lpad");
308        s.add("rpad");
309        s.add("left");
310        s.add("right");
311        s.add("regexp_replace");
312        SLICE48_DESCENT_WRAPPER_NAMES = Collections.unmodifiableSet(s);
313    }
314
315    /**
316     * Slice 48 — inner aggregate names that the {@link
317     * #SLICE48_DESCENT_WRAPPER_NAMES} descent is allowed to lift.
318     *
319     * <p>Slice 48 introduced {@code mode}. Slice 49 widens the set to
320     * also include {@code listagg} / {@code string_agg} — the natural
321     * sibling lift along the same {@code LOWER(...) WITHIN GROUP (...)}
322     * embedded form. Probe {@code /tmp/Probe49.java} confirmed PG /
323     * Snowflake {@code LOWER(LISTAGG(x.id, ',') WG (ORDER BY x.region))}
324     * dlineage XML matches the slice-48 descent shape exactly: a
325     * stacked {@code RS-1.s} → {@code LOWER} → {@code LISTAGG} chain of
326     * {@code resultset type="function"} elements linked by
327     * {@code fdd effectType="function"} edges, plus a single
328     * {@code fdr clauseType="orderby"} on the inner resultset for the
329     * WG ORDER BY column. The slice-30 / 35 / 36 carve-outs in
330     * {@link #ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} include
331     * {@code listagg} and {@code string_agg}, so the defense-in-depth
332     * {@link #isWindowFunctionResultset} check at the bottom of the
333     * descent (which would otherwise mis-classify the orderby fdr as a
334     * window discriminator) returns false for these inner names — the
335     * descent is therefore trivially safe.
336     *
337     * <p>The extra {@code fdd effectType="function"} from
338     * {@code LISTAGG.LISTAGG} → base-table column (the function
339     * argument) is structurally orthogonal to the descent: the descent
340     * only flips {@code result.aggregate = true} and never alters the
341     * SELECT-terminal BFS, so the existing per-output SELECT lineage
342     * edges are unchanged.
343     *
344     * <p>Plain aggregates ({@code SUM} / {@code COUNT} / etc.) inside
345     * scalar wrappers stay outside the descent (slice-48 boundary
346     * preserved): they are NOT in
347     * {@link #ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} so the descent's
348     * {@code isWindowFunctionResultset} carve-out would NOT apply if
349     * they were added here. Lifting them needs a separate slice.
350     * Hypothetical-set / {@code percentile_cont} / {@code percentile_disc}
351     * also stay out for the same reason — slice 47 / 48 probes
352     * confirmed their dlineage XML is structurally indistinguishable
353     * from window-OVER form for the inner resultset, so widening the
354     * descent allowlist would mis-classify the OVER form as aggregate.
355     *
356     * <p>Constraint: must be a strict subset of
357     * {@link #AGGREGATE_FUNCTION_NAMES}; the runtime check below
358     * mirrors the same constraint on
359     * {@link #ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES}.
360     */
361    private static final Set<String> SLICE48_DESCENT_INNER_NAMES;
362    static {
363        Set<String> s = new HashSet<>();
364        s.add("mode");
365        s.add("listagg");
366        s.add("string_agg");
367        SLICE48_DESCENT_INNER_NAMES = Collections.unmodifiableSet(s);
368        if (!AGGREGATE_FUNCTION_NAMES.containsAll(SLICE48_DESCENT_INNER_NAMES)) {
369            Set<String> missing = new HashSet<>(SLICE48_DESCENT_INNER_NAMES);
370            missing.removeAll(AGGREGATE_FUNCTION_NAMES);
371            throw new IllegalStateException(
372                    "SLICE48_DESCENT_INNER_NAMES must be a subset of "
373                            + "AGGREGATE_FUNCTION_NAMES; missing=" + missing);
374        }
375    }
376
377    /** Policy for {@link #resolveBaseSources(String, Document, FanoutPolicy)}. */
378    public enum FanoutPolicy {
379        /** SELECT projection: function fdd whose argument is `*` or constant suppresses fan-out. */
380        SUPPRESS_AGGREGATE_NULL_ARG,
381        /** Row-influence: every fdd terminal kept regardless of intervening function nodes. */
382        INCLUDE_ALL
383    }
384
385    private DlineageXmlProjector() {}
386
387    /**
388     * Legacy entry point — equivalent to {@link #project(String, EDbVendor)}
389     * with {@code vendor=null}. Retained for callers that don't know or
390     * don't care about the source dialect (Phase-0 harness, divergence
391     * goldens, slice-7 comparison harness).
392     */
393    public static ProjectorResult project(String xml) {
394        return project(xml, null);
395    }
396
397    /**
398     * Slice 43 — vendor-scoped projection entry point. The {@code vendor}
399     * parameter pins the per-vendor API contract so a future cautious
400     * projector override can apply per-vendor name-whitelist exceptions
401     * (e.g. PG hypothetical-set {@code rank} / {@code dense_rank} /
402     * {@code percent_rank} / {@code cume_dist} where the WG form's XML
403     * is structurally indistinguishable from the OVER form). Slice 43
404     * deliberately makes <b>no behavior change</b> in the projector —
405     * today the {@link #AGGREGATE_FUNCTION_NAMES} /
406     * {@link #ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} sets are vendor-
407     * agnostic and the {@code vendor} argument is recorded but not
408     * consulted. The byte-for-byte equivalence is enforced by
409     * {@code Slice43Test.projectorVendorScopeOverloadAccepts*Vendor}.
410     *
411     * <p>Future slices that wire vendor-specific overrides MUST keep
412     * this equivalence for callers that pass {@code vendor=null} (the
413     * legacy {@link #project(String)} delegates here with {@code null}).
414     * The follow-up slice for PG hypothetical-set top-level admission
415     * is the first expected consumer of the {@code vendor} parameter.
416     *
417     * @param xml dlineage XML output, must not be null/empty
418     * @param vendor source SQL dialect; may be {@code null} when the
419     *               caller has no vendor information
420     */
421    public static ProjectorResult project(String xml, EDbVendor vendor) {
422        ParseOutcome parsed = parseHardened(xml);
423        if (parsed.error != null) {
424            return parsed.error;
425        }
426        Document doc = parsed.doc;
427
428        // Identify the final resultset: exactly one <resultset> of a
429        // terminal-candidate type that is never referenced as the parent
430        // of any fdd OR fdr <source>. Slice 12 lifted set-op programs, so
431        // {@code select_union}, {@code select_intersect}, {@code select_minus}
432        // are also valid terminal types — dlineage emits one
433        // {@code RESULT_OF_<OP>-N} resultset of the matching set-op type
434        // whose columns each have an fdd edge with N sources (one per
435        // branch's per-position {@code select_list} column). Slice 23
436        // widened the parent-id collection to include fdr sources too,
437        // so the inner EXISTS-body resultset (referenced only via
438        // fdr clause="on") is correctly excluded from terminal candidates.
439        List<Element> terminalCandidates = new ArrayList<>();
440        NodeList resultsets = doc.getElementsByTagName("resultset");
441        for (int i = 0; i < resultsets.getLength(); i++) {
442            Element e = (Element) resultsets.item(i);
443            if (isTerminalResultsetType(e.getAttribute("type"))) {
444                terminalCandidates.add(e);
445            }
446        }
447        if (terminalCandidates.isEmpty()) {
448            return ProjectorResult.unsupported(
449                    ProjectorResult.UnsupportedReason.NO_RELATIONSHIPS,
450                    "no terminal-candidate <resultset> found "
451                            + "(select_list / select_union / select_intersect / "
452                            + "select_minus / select_except)");
453        }
454        Set<String> sourceParentIds = collectFddOrFdrSourceParentIds(doc);
455        List<Element> terminals = new ArrayList<>();
456        for (Element rs : terminalCandidates) {
457            if (!sourceParentIds.contains(rs.getAttribute("id"))) {
458                terminals.add(rs);
459            }
460        }
461        if (terminals.size() != 1) {
462            return ProjectorResult.unsupported(
463                    ProjectorResult.UnsupportedReason.MULTIPLE_TERMINAL_SELECTS,
464                    "expected exactly one terminal resultset, got " + terminals.size());
465        }
466        Element finalResultset = terminals.get(0);
467
468        Set<CanonicalLineageEdge> edges = new LinkedHashSet<>();
469        Set<String> outputNames = new LinkedHashSet<>();
470        Map<String, Boolean> aggregateByOutput = new LinkedHashMap<>();
471
472        // 1) SELECT projection per output column of the final resultset.
473        // Skip system pseudo-columns (e.g. RelationRows). They are the implicit
474        // fdr-target for row-influence, never user-visible projected columns.
475        NodeList finalCols = finalResultset.getElementsByTagName("column");
476        for (int i = 0; i < finalCols.getLength(); i++) {
477            Element col = (Element) finalCols.item(i);
478            // Only direct children of finalResultset count — getElementsByTagName
479            // returns descendants too in theory, but our XML schema has columns
480            // as direct children of resultset/table, so this is fine in practice.
481            if (col.getParentNode() != finalResultset) continue;
482            if ("system".equals(col.getAttribute("source"))) continue;
483
484            String outName = col.getAttribute("name").toLowerCase(Locale.ROOT);
485            outputNames.add(outName);
486            // Default false; flipped to true if BFS visits a function resultset.
487            aggregateByOutput.put(outName, false);
488
489            BfsResult br = projectColumn(col.getAttribute("id"), doc,
490                    FanoutPolicy.SUPPRESS_AGGREGATE_NULL_ARG);
491            if (br.aggregate) {
492                aggregateByOutput.put(outName, true);
493            }
494            for (TableColumn tc : br.terminals) {
495                edges.add(new CanonicalLineageEdge(EdgeRole.SELECT, outName, tc.table, tc.column));
496            }
497        }
498
499        // 2) Row-influence from fdr edges whose target is a column of the final
500        // resultset (typically the system RelationRows column).
501        String finalRsId = finalResultset.getAttribute("id");
502        // Walk the fdr graph: follow chains that target the final resultset's
503        // RelationRows, including indirect ones (e.g. RS-1.RelationRows -> sub.RelationRows
504        // in 04_nested_subquery). For each fdr source with clauseType where/joinCondition
505        // (or clause where/on), resolve to base columns via fdd closure.
506        NodeList fdrs = doc.getElementsByTagName("relationship");
507        // Collect fdr edges keyed by the parent_id of the target so we can
508        // chain RelationRows-targeting fdrs.
509        Map<String, List<Element>> fdrsByTargetParentId = new HashMap<>();
510        for (int i = 0; i < fdrs.getLength(); i++) {
511            Element rel = (Element) fdrs.item(i);
512            if (!"fdr".equals(rel.getAttribute("type"))) continue;
513            Element target = firstChildElement(rel, "target");
514            if (target == null) continue;
515            fdrsByTargetParentId.computeIfAbsent(target.getAttribute("parent_id"), k -> new ArrayList<>())
516                    .add(rel);
517        }
518
519        Deque<String> rowSetParents = new ArrayDeque<>();
520        Set<String> visitedRowSetParents = new HashSet<>();
521        rowSetParents.add(finalRsId);
522        visitedRowSetParents.add(finalRsId);
523        while (!rowSetParents.isEmpty()) {
524            String parentId = rowSetParents.removeFirst();
525            List<Element> rels = fdrsByTargetParentId.get(parentId);
526            if (rels == null) continue;
527            for (Element rel : rels) {
528                Element target = firstChildElement(rel, "target");
529                if (target == null) continue;
530                // Only follow fdrs whose target is a system column (RelationRows).
531                // Other fdrs (e.g. effectType="function" with target on a function
532                // resultset) carry aggregation semantics, not row-influence.
533                if (!"system".equals(target.getAttribute("source"))) continue;
534                // Process each <source> on this fdr.
535                NodeList sources = rel.getElementsByTagName("source");
536                for (int si = 0; si < sources.getLength(); si++) {
537                    Element src = (Element) sources.item(si);
538                    if (src.getParentNode() != rel) continue;
539                    String clauseType = src.getAttribute("clauseType");
540                    String relClause = rel.getAttribute("clause");
541                    EdgeRole role = clauseTypeToRole(clauseType, relClause);
542                    if (role == null) {
543                        // Source is itself a system RelationRows pointing at
544                        // another resultset — chain through it.
545                        if ("system".equals(src.getAttribute("source"))) {
546                            String nextParent = src.getAttribute("parent_id");
547                            if (visitedRowSetParents.add(nextParent)) {
548                                rowSetParents.add(nextParent);
549                            }
550                        }
551                        continue;
552                    }
553                    // Source identifies a column on a base table or a non-base
554                    // resultset. Resolve via fdd closure with INCLUDE_ALL so
555                    // function-arg suppression doesn't skew row influence.
556                    String srcParentId = src.getAttribute("parent_id");
557                    Element parent = elementById(doc, srcParentId);
558                    if (parent == null) continue;
559                    if (isBaseTable(parent)) {
560                        // Skip constantTable for row influence too.
561                        if ("constantTable".equals(parent.getAttribute("type"))) continue;
562                        String col = src.getAttribute("column");
563                        if ("*".equals(col)) continue;
564                        edges.add(new CanonicalLineageEdge(role, null,
565                                parent.getAttribute("name").toLowerCase(Locale.ROOT),
566                                col.toLowerCase(Locale.ROOT)));
567                    } else if (isResultset(parent)) {
568                        String srcId = src.getAttribute("id");
569                        BfsResult inner = resolveBaseSources(srcId, doc, FanoutPolicy.INCLUDE_ALL);
570                        for (TableColumn tc : inner.terminals) {
571                            edges.add(new CanonicalLineageEdge(role, null, tc.table, tc.column));
572                        }
573                    } // else: constantTable / function — already filtered
574                }
575            }
576        }
577
578        return ProjectorResult.ok(
579                new CanonicalLineageModel(edges, outputNames, aggregateByOutput));
580    }
581
582    // ------------------------------------------------------------------
583    // US-011: graph-mode projection over full mssql corpus shapes
584    // ------------------------------------------------------------------
585
586    /**
587     * Graph-mode entry point — equivalent to
588     * {@link #projectGraph(String, EDbVendor)} with {@code vendor=null}.
589     */
590    public static ProjectorResult projectGraph(String xml) {
591        return projectGraph(xml, null);
592    }
593
594    /**
595     * US-011 — whole-file ("graph mode") projection for the full mssql
596     * corpus shapes: procedures, variables (including cursor variables),
597     * temp tables, multi-statement batches, INSERT / UPDATE / DELETE /
598     * MERGE / CTAS / CREATE VIEW write targets, and {@code call}
599     * relationships.
600     *
601     * <p>Unlike {@link #project(String, EDbVendor)} — which is pinned to
602     * the Phase-0 single-terminal-SELECT contract and stays byte-stable —
603     * graph mode projects <b>every</b> lineage sink in the document:
604     *
605     * <ul>
606     *   <li><b>Write sinks</b> — an fdd target column whose parent is a
607     *       {@code <table>} or {@code <view>} (INSERT / UPDATE / MERGE /
608     *       SELECT INTO / CREATE VIEW). Output name:
609     *       {@code <qualified-object-name>.<column>} lower-cased.</li>
610     *   <li><b>Variable sinks</b> — an fdd target column whose parent is a
611     *       {@code <variable>} ({@code SET @v = …}, {@code SELECT @v = …},
612     *       {@code FETCH … INTO @v}, cursor variables). Output name: the
613     *       variable's qualified name (plus {@code .<column>} for
614     *       multi-column record/cursor variables whose column name is not
615     *       the variable's own name).</li>
616     *   <li><b>Terminal SELECTs</b> — resultsets of the terminal-candidate
617     *       types (see {@link #isTerminalResultsetType(String)}) never
618     *       referenced as an fdd/fdr source. Output name:
619     *       {@code <resultset-name>.<column>} lower-cased (e.g.
620     *       {@code rs-2.au_fname}) so multiple statements in one file
621     *       cannot collide.</li>
622     * </ul>
623     *
624     * <p>The SELECT-edge BFS descends through intermediate resultsets
625     * <b>and variables</b> (cross-statement flow: a value written to
626     * {@code @v} in one statement and read in a later INSERT walks back to
627     * the original base column). Base {@code <table>} elements (including
628     * temp tables and {@code type="pseudoTable"} trigger pseudo-tables)
629     * and {@code <view>} elements terminate the walk; {@code constantTable}
630     * sources are dropped as in single mode.
631     *
632     * <p>Row-influence: every fdr source with
633     * {@code clauseType="where"/"joinCondition"} (or
634     * {@code clause="where"/"on"}) anywhere in the document contributes a
635     * FILTER / JOIN null-anchor edge resolved to base columns — graph mode
636     * has no single "final" resultset to chain from, so the per-statement
637     * reachability filter of single mode is intentionally replaced by
638     * whole-document breadth.
639     *
640     * <p>Ignored without error: {@code <procedure>}, {@code <process>},
641     * {@code <error>}, {@code <datasource>} elements and
642     * {@code relationship type="call"} (procedure/function call edges
643     * carry no column lineage). Aggregate flags use the same first-hop
644     * detection as single mode ({@link #AGGREGATE_FUNCTION_NAMES} +
645     * {@link #isWindowFunctionResultset}); the slice-48 scalar-wrapper
646     * descent is not applied in graph mode (PG/Snowflake-only shape,
647     * out of mssql-corpus scope).
648     *
649     * @param xml    dlineage XML output, must not be null/empty
650     * @param vendor source SQL dialect; recorded for API parity with
651     *               {@link #project(String, EDbVendor)}, not consulted
652     */
653    public static ProjectorResult projectGraph(String xml, EDbVendor vendor) {
654        ParseOutcome parsed = parseHardened(xml);
655        if (parsed.error != null) {
656            return parsed.error;
657        }
658        Document doc = parsed.doc;
659
660        GraphIndex index = new GraphIndex(doc);
661
662        Set<CanonicalLineageEdge> edges = new LinkedHashSet<>();
663        Set<String> outputNames = new LinkedHashSet<>();
664        Map<String, Boolean> aggregateByOutput = new LinkedHashMap<>();
665
666        // 1) Write sinks + variable sinks: fdd targets parented on a
667        // table / view / variable element.
668        for (Element fdd : index.fdds) {
669            Element target = firstChildElement(fdd, "target");
670            if (target == null) continue;
671            if ("system".equals(target.getAttribute("source"))) continue;
672            String col = target.getAttribute("column");
673            if (col == null || col.isEmpty() || "*".equals(col)) continue;
674            Element container = index.containerById.get(target.getAttribute("parent_id"));
675            if (container == null) continue;
676            String tag = container.getTagName();
677            String outName;
678            if (("table".equals(tag) || "view".equals(tag))
679                    && !"constantTable".equals(container.getAttribute("type"))) {
680                outName = container.getAttribute("name").toLowerCase(Locale.ROOT)
681                        + "." + col.toLowerCase(Locale.ROOT);
682            } else if ("variable".equals(tag)) {
683                outName = variableOutputName(container, col);
684            } else {
685                continue;
686            }
687            boolean aggregate = isFirstHopAggregate(target.getAttribute("id"), doc, index);
688            recordSink(outName, target.getAttribute("id"), aggregate,
689                    doc, index, edges, outputNames, aggregateByOutput);
690        }
691
692        // 2) Terminal SELECT resultsets (never referenced as fdd/fdr source).
693        for (Element rs : index.resultsets) {
694            if (!isTerminalResultsetType(rs.getAttribute("type"))) continue;
695            if (index.sourceParentIds.contains(rs.getAttribute("id"))) continue;
696            String rsName = rs.getAttribute("name").toLowerCase(Locale.ROOT);
697            NodeList cols = rs.getChildNodes();
698            for (int i = 0; i < cols.getLength(); i++) {
699                Node n = cols.item(i);
700                if (n.getNodeType() != Node.ELEMENT_NODE
701                        || !"column".equals(n.getNodeName())) continue;
702                Element colEl = (Element) n;
703                if ("system".equals(colEl.getAttribute("source"))) continue;
704                String colName = colEl.getAttribute("name");
705                if (colName == null || colName.isEmpty()) continue;
706                String outName = rsName + "." + colName.toLowerCase(Locale.ROOT);
707                boolean aggregate = isFirstHopAggregate(colEl.getAttribute("id"), doc, index);
708                recordSink(outName, colEl.getAttribute("id"), aggregate,
709                        doc, index, edges, outputNames, aggregateByOutput);
710            }
711        }
712
713        // 3) Row-influence: every where/joinCondition fdr source in the
714        // document, resolved to base columns.
715        for (Element fdr : index.fdrs) {
716            String relClause = fdr.getAttribute("clause");
717            NodeList sources = fdr.getElementsByTagName("source");
718            for (int si = 0; si < sources.getLength(); si++) {
719                Element src = (Element) sources.item(si);
720                if (src.getParentNode() != fdr) continue;
721                EdgeRole role = clauseTypeToRole(src.getAttribute("clauseType"), relClause);
722                if (role == null) continue;
723                Element container = index.containerById.get(src.getAttribute("parent_id"));
724                if (container == null) continue;
725                String tag = container.getTagName();
726                String col = src.getAttribute("column");
727                if ("table".equals(tag) || "view".equals(tag)) {
728                    if ("constantTable".equals(container.getAttribute("type"))) continue;
729                    if (col == null || col.isEmpty() || "*".equals(col)) continue;
730                    edges.add(new CanonicalLineageEdge(role, null,
731                            container.getAttribute("name").toLowerCase(Locale.ROOT),
732                            col.toLowerCase(Locale.ROOT)));
733                } else if ("resultset".equals(tag) || "variable".equals(tag)) {
734                    for (TableColumn tc : graphBfs(src.getAttribute("id"), doc, index,
735                            FanoutPolicy.INCLUDE_ALL)) {
736                        edges.add(new CanonicalLineageEdge(role, null, tc.table, tc.column));
737                    }
738                }
739            }
740        }
741
742        if (outputNames.isEmpty() && edges.isEmpty()) {
743            return ProjectorResult.unsupported(
744                    ProjectorResult.UnsupportedReason.NO_RELATIONSHIPS,
745                    "graph projection found no write sinks, variable sinks, "
746                            + "terminal selects, or row-influence edges");
747        }
748        return ProjectorResult.ok(
749                new CanonicalLineageModel(edges, outputNames, aggregateByOutput));
750    }
751
752    /**
753     * Register a graph-mode sink: add the output name, OR-merge its
754     * aggregate flag (the same {@code table.column} may be written by
755     * several statements), and emit its SELECT edges via the graph BFS.
756     */
757    private static void recordSink(String outName, String columnId, boolean aggregate,
758                                   Document doc, GraphIndex index,
759                                   Set<CanonicalLineageEdge> edges,
760                                   Set<String> outputNames,
761                                   Map<String, Boolean> aggregateByOutput) {
762        outputNames.add(outName);
763        Boolean prev = aggregateByOutput.get(outName);
764        aggregateByOutput.put(outName, aggregate || Boolean.TRUE.equals(prev));
765        if (columnId == null || columnId.isEmpty()) return;
766        for (TableColumn tc : graphBfs(columnId, doc, index,
767                FanoutPolicy.SUPPRESS_AGGREGATE_NULL_ARG)) {
768            edges.add(new CanonicalLineageEdge(EdgeRole.SELECT, outName, tc.table, tc.column));
769        }
770    }
771
772    /**
773     * Graph-mode variable sink name. The dlineage {@code <variable>} name
774     * is already qualified ({@code pubs.dbo.@myvar}); plain record
775     * variables carry a single column named like the variable itself —
776     * collapse that to the variable name. Multi-column record/cursor
777     * variables keep {@code <variable>.<column>}.
778     */
779    private static String variableOutputName(Element variable, String column) {
780        String vn = variable.getAttribute("name").toLowerCase(Locale.ROOT);
781        String cn = column.toLowerCase(Locale.ROOT);
782        if (cn.equals(vn) || vn.endsWith("." + cn)) {
783            return vn;
784        }
785        return vn + "." + cn;
786    }
787
788    /**
789     * Graph-mode first-hop aggregate detection — same rule as single mode
790     * (immediate fdd source on a non-windowed function resultset whose
791     * name is in {@link #AGGREGATE_FUNCTION_NAMES}) but driven off the
792     * {@link GraphIndex} instead of whole-document scans.
793     */
794    private static boolean isFirstHopAggregate(String columnId, Document doc, GraphIndex index) {
795        if (columnId == null || columnId.isEmpty()) return false;
796        for (Element fdd : index.fddsByTargetId(columnId)) {
797            for (Element fnRs : immediateFunctionSourceResultsets(fdd, doc)) {
798                String fnName = fnRs.getAttribute("name");
799                if (fnName == null || fnName.isEmpty()) continue;
800                if (!AGGREGATE_FUNCTION_NAMES.contains(fnName.toLowerCase(Locale.ROOT))) continue;
801                if (isWindowFunctionResultset(fnRs, doc)) continue;
802                return true;
803            }
804        }
805        return false;
806    }
807
808    /**
809     * Graph-mode BFS to base terminals. Differences from the single-mode
810     * BFS in {@link #projectColumn}:
811     *
812     * <ul>
813     *   <li>descends through {@code <variable>} columns (cross-statement
814     *       flow through {@code @variables} and cursor variables);</li>
815     *   <li>treats {@code <view>} elements as base terminals alongside
816     *       {@code <table>};</li>
817     *   <li>uses the prebuilt {@link GraphIndex} (the corpus harness runs
818     *       this over hundreds of files — single mode's per-call document
819     *       scans would be quadratic).</li>
820     * </ul>
821     *
822     * <p>COUNT(*) fan-out suppression is identical to single mode.
823     */
824    private static List<TableColumn> graphBfs(String columnId, Document doc,
825                                              GraphIndex index, FanoutPolicy policy) {
826        List<TableColumn> terminals = new ArrayList<>();
827        if (columnId == null || columnId.isEmpty()) return terminals;
828        Set<String> visited = new HashSet<>();
829        Deque<String> q = new ArrayDeque<>();
830        q.add(columnId);
831        visited.add(columnId);
832        while (!q.isEmpty()) {
833            String cur = q.removeFirst();
834            for (Element fdd : index.fddsByTargetId(cur)) {
835                Element targetEl = firstChildElement(fdd, "target");
836                if (targetEl == null) continue;
837                Element targetParent = index.containerById.get(targetEl.getAttribute("parent_id"));
838                boolean targetIsFunction = targetParent != null
839                        && "resultset".equals(targetParent.getTagName())
840                        && "function".equals(targetParent.getAttribute("type"));
841                if (targetIsFunction
842                        && policy == FanoutPolicy.SUPPRESS_AGGREGATE_NULL_ARG
843                        && shouldSuppressFunctionFanout(fdd, targetParent)) {
844                    continue;
845                }
846                NodeList sources = fdd.getElementsByTagName("source");
847                for (int si = 0; si < sources.getLength(); si++) {
848                    Element src = (Element) sources.item(si);
849                    if (src.getParentNode() != fdd) continue;
850                    String col = src.getAttribute("column");
851                    if ("*".equals(col)) continue;
852                    Element parent = index.containerById.get(src.getAttribute("parent_id"));
853                    if (parent == null) continue;
854                    String tag = parent.getTagName();
855                    if ("table".equals(tag) || "view".equals(tag)) {
856                        if ("constantTable".equals(parent.getAttribute("type"))) continue;
857                        terminals.add(new TableColumn(
858                                parent.getAttribute("name").toLowerCase(Locale.ROOT),
859                                col.toLowerCase(Locale.ROOT)));
860                    } else if ("resultset".equals(tag) || "variable".equals(tag)) {
861                        String nextId = src.getAttribute("id");
862                        if (nextId != null && !nextId.isEmpty() && visited.add(nextId)) {
863                            q.add(nextId);
864                        }
865                    }
866                }
867            }
868        }
869        return terminals;
870    }
871
872    /**
873     * One-pass index over the dlineage document for graph-mode projection.
874     * Containers are every element that an fdd/fdr {@code parent_id} can
875     * reference for column lineage: {@code table}, {@code resultset},
876     * {@code variable}, {@code view}. {@code procedure} / {@code process} /
877     * {@code error} / {@code datasource} elements are deliberately not
878     * indexed — sources referencing them resolve to null and are skipped.
879     */
880    private static final class GraphIndex {
881        final Map<String, Element> containerById = new HashMap<>();
882        final List<Element> resultsets = new ArrayList<>();
883        final List<Element> fdds = new ArrayList<>();
884        final List<Element> fdrs = new ArrayList<>();
885        final Set<String> sourceParentIds = new HashSet<>();
886        private final Map<String, List<Element>> fddByTargetId = new HashMap<>();
887
888        GraphIndex(Document doc) {
889            for (String tag : new String[]{"table", "resultset", "variable", "view"}) {
890                NodeList nl = doc.getElementsByTagName(tag);
891                for (int i = 0; i < nl.getLength(); i++) {
892                    Element e = (Element) nl.item(i);
893                    String id = e.getAttribute("id");
894                    if (id != null && !id.isEmpty()) {
895                        containerById.put(id, e);
896                    }
897                    if ("resultset".equals(tag)) {
898                        resultsets.add(e);
899                    }
900                }
901            }
902            NodeList rels = doc.getElementsByTagName("relationship");
903            for (int i = 0; i < rels.getLength(); i++) {
904                Element rel = (Element) rels.item(i);
905                String type = rel.getAttribute("type");
906                boolean isFdd = "fdd".equals(type);
907                boolean isFdr = "fdr".equals(type);
908                if (!isFdd && !isFdr) continue; // call / future types: no column lineage
909                if (isFdd) {
910                    fdds.add(rel);
911                    Element target = firstChildElement(rel, "target");
912                    if (target != null) {
913                        String tid = target.getAttribute("id");
914                        if (tid != null && !tid.isEmpty()) {
915                            fddByTargetId.computeIfAbsent(tid, k -> new ArrayList<>()).add(rel);
916                        }
917                    }
918                } else {
919                    fdrs.add(rel);
920                }
921                NodeList sources = rel.getElementsByTagName("source");
922                for (int si = 0; si < sources.getLength(); si++) {
923                    Element src = (Element) sources.item(si);
924                    if (src.getParentNode() != rel) continue;
925                    String parentId = src.getAttribute("parent_id");
926                    if (parentId != null && !parentId.isEmpty()) {
927                        sourceParentIds.add(parentId);
928                    }
929                }
930            }
931        }
932
933        List<Element> fddsByTargetId(String columnId) {
934            List<Element> out = fddByTargetId.get(columnId);
935            return out != null ? out : Collections.<Element>emptyList();
936        }
937    }
938
939    /** Outcome of {@link #parseHardened(String)}: exactly one of doc/error is set. */
940    private static final class ParseOutcome {
941        final Document doc;
942        final ProjectorResult error;
943        ParseOutcome(Document doc, ProjectorResult error) {
944            this.doc = doc;
945            this.error = error;
946        }
947    }
948
949    /**
950     * Shared hardened XML parse for both projection modes. Defensive XML
951     * hardening: disable DTDs, external entities, and network access by
952     * default. The projector is a public API in src/main and may be called
953     * with untrusted input; matching the OWASP XXE-prevention recipe for
954     * JDK DocumentBuilderFactory.
955     */
956    private static ParseOutcome parseHardened(String xml) {
957        if (xml == null || xml.isEmpty()) {
958            return new ParseOutcome(null, ProjectorResult.unsupported(
959                    ProjectorResult.UnsupportedReason.MALFORMED_XML, "empty xml"));
960        }
961        try {
962            DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
963            dbf.setNamespaceAware(false);
964            applyXmlSecurity(dbf);
965            DocumentBuilder db = dbf.newDocumentBuilder();
966            // Belt-and-braces: even with the features above, refuse to
967            // resolve any external entity that slips through.
968            db.setEntityResolver((publicId, systemId) ->
969                    new InputSource(new java.io.StringReader("")));
970            db.setErrorHandler(new org.xml.sax.ErrorHandler() {
971                @Override public void warning(org.xml.sax.SAXParseException e) {}
972                @Override public void error(org.xml.sax.SAXParseException e) throws org.xml.sax.SAXException { throw e; }
973                @Override public void fatalError(org.xml.sax.SAXParseException e) throws org.xml.sax.SAXException { throw e; }
974            });
975            return new ParseOutcome(db.parse(new InputSource(new StringReader(xml))), null);
976        } catch (Exception ex) {
977            return new ParseOutcome(null, ProjectorResult.unsupported(
978                    ProjectorResult.UnsupportedReason.MALFORMED_XML, ex.getMessage()));
979        }
980    }
981
982    /**
983     * Project one final-resultset output column. The aggregate flag is set
984     * <b>only when an immediate fdd source of this column lives on a
985     * function resultset whose function name is in
986     * {@link #AGGREGATE_FUNCTION_NAMES}</b>. This matches Semantic IR's
987     * per-output (non-transitive) aggregate semantics — see slice-6
988     * design notes on flag locality. The BFS itself walks transitively
989     * to collect base-table SELECT terminals.
990     */
991    private static BfsResult projectColumn(String columnId, Document doc, FanoutPolicy policy) {
992        BfsResult result = new BfsResult();
993
994        // First-hop aggregate detection. Local rule: this output is aggregate
995        // iff *any* immediate fdd source lives on a function resultset whose
996        // function name is in AGGREGATE_FUNCTION_NAMES AND the function
997        // resultset is NOT a window function (slice 13). We must scan all
998        // immediate function sources, not stop at the first one — a fdd whose
999        // sources include both `UPPER(...)` and `SUM(...)` columns must still
1000        // mark the output aggregate.
1001        //
1002        // Slice 13 window-vs-aggregate discriminator: a function resultset is
1003        // windowed iff at least one fdr targeting it carries a source with
1004        // clauseType="selectList" (PARTITION BY) or clauseType="orderby"
1005        // (OVER ORDER BY). Plain aggregates have only the RelationRows fdr;
1006        // window functions add the PARTITION BY / OVER ORDER BY fdrs. Empty
1007        // OVER () is not discriminable in dlineage XML (looks identical to a
1008        // plain aggregate); the IR builder rejects empty OVER () to avoid
1009        // manufacturing AGGREGATION_MISMATCH divergences.
1010        outer:
1011        for (Element fdd : findFddByTarget(doc, columnId)) {
1012            for (Element fnRs : immediateFunctionSourceResultsets(fdd, doc)) {
1013                String fnName = fnRs.getAttribute("name");
1014                if (fnName == null || fnName.isEmpty()) continue;
1015                if (!AGGREGATE_FUNCTION_NAMES.contains(fnName.toLowerCase(Locale.ROOT))) continue;
1016                if (isWindowFunctionResultset(fnRs, doc)) continue;
1017                result.aggregate = true;
1018                break outer;
1019            }
1020        }
1021
1022        // Slice 48 — single-step descent through a known scalar wrapper.
1023        // The first-hop check above mirrors the IR builder's per-output
1024        // (non-transitive) aggregate semantics, but for a small set of
1025        // case-folding scalar wrappers used to format the output of an
1026        // ordered-set aggregate, the IR side classifies the projection
1027        // as aggregate via the descendant ordered-set name. The
1028        // projector must mirror that classification or the divergence
1029        // harness reports a pre-existing
1030        // {@link DivergenceClass#AGGREGATION_MISMATCH}.
1031        //
1032        // Lift surface as of slice 57:
1033        //   wrapper-name ∈ SLICE48_DESCENT_WRAPPER_NAMES
1034        //                = {lower, upper, concat, trim, ltrim, rtrim,
1035        //                   substring, substr, replace, lpad, rpad,
1036        //                   left, right, regexp_replace}
1037        //   ∧ inner-name ∈ SLICE48_DESCENT_INNER_NAMES
1038        //                = {mode, listagg, string_agg}
1039        //   ∧ !isWindowFunctionResultset(inner).
1040        //
1041        // History:
1042        //   - slice 48 introduced the descent with wrapper={lower},
1043        //     inner={mode}.
1044        //   - slice 49 widened inner to {mode, listagg, string_agg}.
1045        //   - slice 50 widened wrapper to {lower, upper}.
1046        //   - slice 51 widened wrapper to {lower, upper, concat}.
1047        //   - slice 52 widened wrapper to
1048        //     {lower, upper, concat, trim, ltrim, rtrim}.
1049        //   - slice 53 widened wrapper to
1050        //     {lower, upper, concat, trim, ltrim, rtrim, substring,
1051        //      substr}.
1052        //   - slice 54 widened wrapper to
1053        //     {lower, upper, concat, trim, ltrim, rtrim, substring,
1054        //      substr, replace}.
1055        //   - slice 55 widened wrapper to
1056        //     {lower, upper, concat, trim, ltrim, rtrim, substring,
1057        //      substr, replace, lpad, rpad}.
1058        //   - slice 56 widened wrapper to
1059        //     {lower, upper, concat, trim, ltrim, rtrim, substring,
1060        //      substr, replace, lpad, rpad, left, right}.
1061        //   - slice 57 widens wrapper to
1062        //     {lower, upper, concat, trim, ltrim, rtrim, substring,
1063        //      substr, replace, lpad, rpad, left, right,
1064        //      regexp_replace}.
1065        // The {@code ||} string-concatenation operator does NOT emit a
1066        // wrapper resultset on PG / Snowflake (the parser folds
1067        // {@code expr || ' '} into the projection root directly), so no
1068        // descent is needed for that shape — the existing first-hop
1069        // aggregate detection already handles it. Other scalar wrappers
1070        // ({@code COALESCE}, {@code INITCAP}, {@code REVERSE},
1071        // {@code LENGTH} / {@code CHAR_LENGTH}, arithmetic) stay
1072        // outside the descent — their argument-shape signatures
1073        // differ and require independent probes.
1074        //
1075        // The descent is also one-level only — {@code LOWER(LOWER(mode WG))}
1076        // remains divergent because the wrapper's immediate function
1077        // source is {@code lower}, not {@code mode}. This keeps the
1078        // descent O(immediate-fdds) and avoids any risk of
1079        // double-counting through arbitrarily deep wrappings.
1080        if (!result.aggregate) {
1081            // The first-hop loop above scans the column's fdds for an
1082            // immediate function-resultset source whose own column was
1083            // the projection's value (e.g. {@code RS-1.s} fdd-source
1084            // {@code LOWER.LOWER}). To descend one level we walk through
1085            // each scalar-wrapper source's <i>column</i> and look at
1086            // that column's own fdd targets — the wrapper-internal fdd
1087            // (e.g. fdd targeting {@code LOWER.LOWER} with source
1088            // {@code mode.mode}). The {@code findFddByTarget} helper
1089            // matches on the target column's {@code id}, NOT the
1090            // resultset id, so we must look up the wrapper's child
1091            // column id (slice-48 descent's distinguishing detail vs
1092            // the existing first-hop check).
1093            outerDescent:
1094            for (Element fdd : findFddByTarget(doc, columnId)) {
1095                NodeList sources = fdd.getElementsByTagName("source");
1096                for (int si = 0; si < sources.getLength(); si++) {
1097                    Element src = (Element) sources.item(si);
1098                    if (src.getParentNode() != fdd) continue;
1099                    String wrapperParentId = src.getAttribute("parent_id");
1100                    Element wrapperRs = elementById(doc, wrapperParentId);
1101                    if (wrapperRs == null
1102                            || !isResultset(wrapperRs)
1103                            || !"function".equals(wrapperRs.getAttribute("type"))) continue;
1104                    String wrapperName = wrapperRs.getAttribute("name");
1105                    if (wrapperName == null) continue;
1106                    if (!SLICE48_DESCENT_WRAPPER_NAMES.contains(
1107                            wrapperName.toLowerCase(Locale.ROOT))) continue;
1108                    String wrapperColumnId = src.getAttribute("id");
1109                    if (wrapperColumnId == null || wrapperColumnId.isEmpty()) continue;
1110                    for (Element wrapperFdd : findFddByTarget(doc, wrapperColumnId)) {
1111                        for (Element innerRs : immediateFunctionSourceResultsets(wrapperFdd, doc)) {
1112                            String innerName = innerRs.getAttribute("name");
1113                            if (innerName == null) continue;
1114                            if (!SLICE48_DESCENT_INNER_NAMES.contains(
1115                                    innerName.toLowerCase(Locale.ROOT))) continue;
1116                            // Defense-in-depth: even though `mode` is in
1117                            // ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES (so
1118                            // an `orderby`-only fdr is not classified as
1119                            // windowed), a `selectList` fdr (PARTITION
1120                            // BY) on the inner resultset still indicates
1121                            // the OVER form. Skip those PARTITION-BY
1122                            // windowed inners so the slice-48 descent does
1123                            // not lift that OVER projection. The known
1124                            // OVER-only-ORDER-BY carve-out remains
1125                            // classified as plain aggregate on the
1126                            // projector side; IR rejects that form, and
1127                            // Slice48Test documents the limitation.
1128                            if (isWindowFunctionResultset(innerRs, doc)) continue;
1129                            result.aggregate = true;
1130                            break outerDescent;
1131                        }
1132                    }
1133                }
1134            }
1135        }
1136
1137        // BFS for SELECT terminals. The aggregate flag is no longer touched
1138        // inside the BFS; the only thing the function nodes still control is
1139        // SELECT-fan-out suppression for COUNT(*)-shaped calls.
1140        Set<String> visited = new HashSet<>();
1141        Deque<String> q = new ArrayDeque<>();
1142        q.add(columnId);
1143        visited.add(columnId);
1144        while (!q.isEmpty()) {
1145            String cur = q.removeFirst();
1146            for (Element fdd : findFddByTarget(doc, cur)) {
1147                Element targetEl = firstChildElement(fdd, "target");
1148                if (targetEl == null) continue;
1149                Element targetParent = elementById(doc, targetEl.getAttribute("parent_id"));
1150                boolean targetIsFunction = targetParent != null
1151                        && isResultset(targetParent)
1152                        && "function".equals(targetParent.getAttribute("type"));
1153                if (targetIsFunction
1154                        && policy == FanoutPolicy.SUPPRESS_AGGREGATE_NULL_ARG
1155                        && shouldSuppressFunctionFanout(fdd, targetParent)) {
1156                    continue;
1157                }
1158                NodeList sources = fdd.getElementsByTagName("source");
1159                for (int si = 0; si < sources.getLength(); si++) {
1160                    Element src = (Element) sources.item(si);
1161                    if (src.getParentNode() != fdd) continue;
1162                    String col = src.getAttribute("column");
1163                    if ("*".equals(col)) continue;
1164                    String parentId = src.getAttribute("parent_id");
1165                    Element parent = elementById(doc, parentId);
1166                    if (parent == null) continue;
1167                    if (isBaseTable(parent)) {
1168                        if ("constantTable".equals(parent.getAttribute("type"))) continue;
1169                        result.terminals.add(new TableColumn(
1170                                parent.getAttribute("name").toLowerCase(Locale.ROOT),
1171                                col.toLowerCase(Locale.ROOT)));
1172                    } else if (isResultset(parent)) {
1173                        String nextId = src.getAttribute("id");
1174                        if (visited.add(nextId)) {
1175                            q.add(nextId);
1176                        }
1177                    }
1178                    // constantTable: skipped already; function resultset
1179                    // descent is handled when nextId is dequeued and its fdd
1180                    // is matched above.
1181                }
1182            }
1183        }
1184        return result;
1185    }
1186
1187    /**
1188     * Public-ish helper used by the row-influence path. Same BFS as
1189     * {@link #projectColumn} but the aggregate flag is meaningless here.
1190     */
1191    static BfsResult resolveBaseSources(String columnId, Document doc, FanoutPolicy policy) {
1192        return projectColumn(columnId, doc, policy);
1193    }
1194
1195    /**
1196     * Return every function-resultset name that appears as the parent of an
1197     * immediate {@code <source>} on this fdd. Multiple are possible when an
1198     * expression projects something like {@code UPPER(name) || SUM(salary)}
1199     * and dlineage produces sources from both function nodes.
1200     */
1201    private static List<String> immediateFunctionSourceNames(Element fdd, Document doc) {
1202        List<String> names = new ArrayList<>();
1203        NodeList sources = fdd.getElementsByTagName("source");
1204        for (int si = 0; si < sources.getLength(); si++) {
1205            Element src = (Element) sources.item(si);
1206            if (src.getParentNode() != fdd) continue;
1207            Element parent = elementById(doc, src.getAttribute("parent_id"));
1208            if (parent != null && isResultset(parent)
1209                    && "function".equals(parent.getAttribute("type"))) {
1210                String name = parent.getAttribute("name");
1211                if (name != null && !name.isEmpty()) names.add(name);
1212            }
1213        }
1214        return names;
1215    }
1216
1217    /**
1218     * Slice 13: parallel to {@link #immediateFunctionSourceNames} but
1219     * returns the function-resultset {@link Element}s themselves so the
1220     * caller can inspect them with {@link #isWindowFunctionResultset}.
1221     */
1222    private static List<Element> immediateFunctionSourceResultsets(Element fdd, Document doc) {
1223        List<Element> els = new ArrayList<>();
1224        NodeList sources = fdd.getElementsByTagName("source");
1225        for (int si = 0; si < sources.getLength(); si++) {
1226            Element src = (Element) sources.item(si);
1227            if (src.getParentNode() != fdd) continue;
1228            Element parent = elementById(doc, src.getAttribute("parent_id"));
1229            if (parent != null && isResultset(parent)
1230                    && "function".equals(parent.getAttribute("type"))) {
1231                els.add(parent);
1232            }
1233        }
1234        return els;
1235    }
1236
1237    /**
1238     * Slice 13: window-vs-aggregate discriminator. A function resultset is
1239     * windowed iff it has at least one {@code fdr} edge whose target's
1240     * {@code parent_id} equals the function resultset's id AND whose source
1241     * carries {@code clauseType="selectList"} (PARTITION BY) or
1242     * {@code clauseType="orderby"} (OVER ORDER BY). Plain (non-windowed)
1243     * aggregates have only the RelationRows fdr; the addition of PARTITION
1244     * BY / OVER ORDER BY fdrs is dlineage's way of recording the analytic
1245     * dependencies of a windowed call.
1246     *
1247     * <p>Empty {@code OVER ()} window functions are NOT discriminable here
1248     * (their XML is byte-identical to a plain aggregate). Slice 13's IR
1249     * builder rejects empty {@code OVER ()} so the divergence harness
1250     * never sees that case in practice.
1251     *
1252     * <p>Probed against Oracle (slice-13 corpus); cross-vendor parity
1253     * (PostgreSQL, BigQuery, SparkSQL) is unproven and deferred.
1254     *
1255     * <p>Slice 35 override: function names in
1256     * {@link #ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES} suppress an
1257     * order-by-only discriminator. PostgreSQL emits a
1258     * {@code clauseType="orderby"} fdr for the WITHIN GROUP ORDER BY of
1259     * {@code LISTAGG(... ) WITHIN GROUP (...)}, which the slice-13
1260     * discriminator would otherwise mis-classify as windowed. A
1261     * {@code selectList} fdr still wins and keeps analytic/window shapes
1262     * classified as windowed.
1263     */
1264    private static boolean isWindowFunctionResultset(Element fnRs, Document doc) {
1265        if (fnRs == null) return false;
1266        String fnName = fnRs.getAttribute("name");
1267        boolean orderByWithinGroupCandidate = fnName != null
1268                && ORDER_BY_WITHIN_GROUP_AGGREGATE_NAMES.contains(
1269                        fnName.toLowerCase(Locale.ROOT));
1270        String fnRsId = fnRs.getAttribute("id");
1271        if (fnRsId == null || fnRsId.isEmpty()) return false;
1272        boolean sawOrderByDiscriminator = false;
1273        NodeList rels = doc.getElementsByTagName("relationship");
1274        for (int i = 0; i < rels.getLength(); i++) {
1275            Element rel = (Element) rels.item(i);
1276            if (!"fdr".equals(rel.getAttribute("type"))) continue;
1277            Element target = firstChildElement(rel, "target");
1278            if (target == null) continue;
1279            if (!fnRsId.equals(target.getAttribute("parent_id"))) continue;
1280            NodeList sources = rel.getElementsByTagName("source");
1281            for (int si = 0; si < sources.getLength(); si++) {
1282                Element src = (Element) sources.item(si);
1283                if (src.getParentNode() != rel) continue;
1284                String ct = src.getAttribute("clauseType");
1285                if ("selectList".equals(ct)) return true;
1286                if ("orderby".equals(ct)) sawOrderByDiscriminator = true;
1287            }
1288        }
1289        return sawOrderByDiscriminator && !orderByWithinGroupCandidate;
1290    }
1291
1292    /**
1293     * Decide whether the SELECT fan-out from a function-targeted fdd should
1294     * be suppressed. Two conditions must hold:
1295     *
1296     * <ol>
1297     *   <li>The function name is in {@link #NULL_ARG_AGGREGATES}. Today
1298     *       only {@code COUNT}; this prevents a hypothetical
1299     *       {@code SUM(t.*)} (some dialects allow it) from silently losing
1300     *       lineage.</li>
1301     *   <li>Either at least one source has {@code column="*"} (the
1302     *       {@code COUNT(*)} shape) or every source is a constant-table
1303     *       column ({@code COUNT(1)} / {@code COUNT(literal)}).</li>
1304     * </ol>
1305     */
1306    private static boolean shouldSuppressFunctionFanout(Element functionFdd, Element functionResultset) {
1307        if (functionResultset == null) return false;
1308        String fnName = functionResultset.getAttribute("name");
1309        if (fnName == null || fnName.isEmpty()) return false;
1310        if (!NULL_ARG_AGGREGATES.contains(fnName.toLowerCase(Locale.ROOT))) return false;
1311
1312        boolean anyStar = false;
1313        boolean anyNonConstant = false;
1314        boolean anySource = false;
1315        NodeList sources = functionFdd.getElementsByTagName("source");
1316        for (int si = 0; si < sources.getLength(); si++) {
1317            Element src = (Element) sources.item(si);
1318            if (src.getParentNode() != functionFdd) continue;
1319            anySource = true;
1320            if ("*".equals(src.getAttribute("column"))) {
1321                anyStar = true;
1322                continue;
1323            }
1324            String parentId = src.getAttribute("parent_id");
1325            Element parent = elementById(functionFdd.getOwnerDocument(), parentId);
1326            if (parent != null && isBaseTable(parent)
1327                    && "constantTable".equals(parent.getAttribute("type"))) {
1328                continue;
1329            }
1330            anyNonConstant = true;
1331        }
1332        if (!anySource) return false;
1333        if (anyStar) return true;
1334        return !anyNonConstant;
1335    }
1336
1337    private static List<Element> findFddByTarget(Document doc, String columnId) {
1338        List<Element> out = new ArrayList<>();
1339        NodeList rels = doc.getElementsByTagName("relationship");
1340        for (int i = 0; i < rels.getLength(); i++) {
1341            Element rel = (Element) rels.item(i);
1342            if (!"fdd".equals(rel.getAttribute("type"))) continue;
1343            Element target = firstChildElement(rel, "target");
1344            if (target == null) continue;
1345            if (columnId.equals(target.getAttribute("id"))) {
1346                out.add(rel);
1347            }
1348        }
1349        return out;
1350    }
1351
1352    /**
1353     * Collect parent IDs of fdd <b>and</b> fdr sources. The terminal-resultset
1354     * detection treats any resultset that is referenced as a source of either
1355     * data-dependency (fdd) <b>or</b> row-influence (fdr) as non-terminal.
1356     *
1357     * <p>Slice 23 motivation: an EXISTS subquery in JOIN ON contributes its
1358     * inner SELECT-list resultset only via an {@code fdr clause="on"} edge
1359     * (the inner result is plumbed into outer's RelationRows as a row-
1360     * influence source, NOT as a data dependency on any outer-projected
1361     * column). Without considering fdr sources, both the outer SELECT-list
1362     * resultset AND the inner EXISTS-body resultset would qualify as
1363     * terminal candidates, producing a false MULTIPLE_TERMINAL_SELECTS.
1364     *
1365     * <p>Pre-slice-23 corpus entries (FROM-subquery, scalar-subquery,
1366     * set-op) all referenced inner resultsets via fdd, so the original
1367     * fdd-only check sufficed for them; widening to fdr is additive and
1368     * cannot mis-classify their inner resultsets as referenced when they
1369     * weren't already.
1370     */
1371    private static Set<String> collectFddOrFdrSourceParentIds(Document doc) {
1372        Set<String> out = new HashSet<>();
1373        NodeList rels = doc.getElementsByTagName("relationship");
1374        for (int i = 0; i < rels.getLength(); i++) {
1375            Element rel = (Element) rels.item(i);
1376            String relType = rel.getAttribute("type");
1377            if (!"fdd".equals(relType) && !"fdr".equals(relType)) continue;
1378            NodeList sources = rel.getElementsByTagName("source");
1379            for (int si = 0; si < sources.getLength(); si++) {
1380                Element src = (Element) sources.item(si);
1381                if (src.getParentNode() != rel) continue;
1382                String parentId = src.getAttribute("parent_id");
1383                if (parentId != null && !parentId.isEmpty()) {
1384                    out.add(parentId);
1385                }
1386            }
1387        }
1388        return out;
1389    }
1390
1391    /**
1392     * Apply OWASP-recommended XXE-prevention features to a
1393     * {@link DocumentBuilderFactory}. Each set is best-effort: a parser
1394     * that does not expose a feature swallows the failure but the others
1395     * still apply.
1396     */
1397    private static void applyXmlSecurity(DocumentBuilderFactory dbf) {
1398        String[] disable = {
1399                // Disallow inline DTDs entirely — strongest mitigation.
1400                "http://apache.org/xml/features/disallow-doctype-decl",
1401        };
1402        String[] enable = {
1403                javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING,
1404        };
1405        String[] disableExt = {
1406                "http://xml.org/sax/features/external-general-entities",
1407                "http://xml.org/sax/features/external-parameter-entities",
1408                "http://apache.org/xml/features/nonvalidating/load-external-dtd",
1409        };
1410        for (String f : disable) {
1411            try { dbf.setFeature(f, true); } catch (Exception ignore) {}
1412        }
1413        for (String f : enable) {
1414            try { dbf.setFeature(f, true); } catch (Exception ignore) {}
1415        }
1416        for (String f : disableExt) {
1417            try { dbf.setFeature(f, false); } catch (Exception ignore) {}
1418        }
1419        try {
1420            dbf.setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD, "");
1421        } catch (Exception ignore) {}
1422        try {
1423            dbf.setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
1424        } catch (Exception ignore) {}
1425        dbf.setXIncludeAware(false);
1426        dbf.setExpandEntityReferences(false);
1427    }
1428
1429    private static Element elementById(Document doc, String id) {
1430        if (id == null || id.isEmpty()) return null;
1431        // The XML uses string ids on table/resultset/column elements; not
1432        // declared as XML IDs, so we lookup by attribute.
1433        for (String tag : new String[]{"table", "resultset", "column"}) {
1434            NodeList nl = doc.getElementsByTagName(tag);
1435            for (int i = 0; i < nl.getLength(); i++) {
1436                Element e = (Element) nl.item(i);
1437                if (id.equals(e.getAttribute("id"))) return e;
1438            }
1439        }
1440        return null;
1441    }
1442
1443    private static Element firstChildElement(Element parent, String tag) {
1444        NodeList nl = parent.getChildNodes();
1445        for (int i = 0; i < nl.getLength(); i++) {
1446            Node n = nl.item(i);
1447            if (n.getNodeType() != Node.ELEMENT_NODE) continue;
1448            if (tag.equals(n.getNodeName())) return (Element) n;
1449        }
1450        return null;
1451    }
1452
1453    private static boolean isBaseTable(Element parent) {
1454        return "table".equals(parent.getTagName());
1455    }
1456
1457    private static boolean isResultset(Element parent) {
1458        return "resultset".equals(parent.getTagName());
1459    }
1460
1461    /**
1462     * True iff the resultset {@code type} is a candidate to be the
1463     * program's terminal resultset. {@code select_list} is the standard
1464     * shape (slice 1+). Slice 12 added the set-op result types
1465     * ({@code select_union}, {@code select_intersect}, {@code select_minus},
1466     * {@code select_except}): dlineage emits one {@code RESULT_OF_<OP>-N}
1467     * resultset of the matching type per top-level set-op program; that
1468     * resultset's columns have one fdd edge each with N sources (one per
1469     * branch's per-position {@code select_list} column), and the existing
1470     * {@code projectColumn} BFS walks those fdd chains transparently to
1471     * reach base-table terminals. {@code select_except} (PostgreSQL /
1472     * SQL Server EXCEPT) is distinct from {@code select_minus} (Oracle /
1473     * Spark / Hive MINUS) at the dlineage layer even though they are
1474     * semantically equivalent.
1475     */
1476    private static boolean isTerminalResultsetType(String type) {
1477        if (type == null) return false;
1478        return "select_list".equals(type)
1479                || "select_union".equals(type)
1480                || "select_intersect".equals(type)
1481                || "select_minus".equals(type)
1482                || "select_except".equals(type);
1483    }
1484
1485    private static EdgeRole clauseTypeToRole(String clauseType, String relClause) {
1486        if ("where".equals(clauseType) || "where".equals(relClause)) return EdgeRole.FILTER;
1487        if ("joinCondition".equals(clauseType) || "on".equals(relClause)) return EdgeRole.JOIN;
1488        return null;
1489    }
1490
1491    /** Result of projecting one column: terminals plus the aggregate flag. */
1492    static final class BfsResult {
1493        final List<TableColumn> terminals = new ArrayList<>();
1494        boolean aggregate = false;
1495    }
1496
1497    /** Lower-cased base (table, column) pair. */
1498    static final class TableColumn {
1499        final String table;
1500        final String column;
1501        TableColumn(String table, String column) {
1502            this.table = table;
1503            this.column = column;
1504        }
1505    }
1506}