001package gudusoft.gsqlparser.ir.semantic.diff;
002
003import gudusoft.gsqlparser.ir.semantic.ColumnRef;
004import gudusoft.gsqlparser.ir.semantic.LineageEdge;
005import gudusoft.gsqlparser.ir.semantic.LineageRef;
006import gudusoft.gsqlparser.ir.semantic.OutputColumn;
007import gudusoft.gsqlparser.ir.semantic.RelationKind;
008import gudusoft.gsqlparser.ir.semantic.RelationSource;
009import gudusoft.gsqlparser.ir.semantic.SemanticProgram;
010import gudusoft.gsqlparser.ir.semantic.StatementGraph;
011import gudusoft.gsqlparser.ir.semantic.builder.SemanticIRBuilder;
012
013import java.util.ArrayDeque;
014import java.util.ArrayList;
015import java.util.Deque;
016import java.util.HashMap;
017import java.util.HashSet;
018import java.util.LinkedHashMap;
019import java.util.LinkedHashSet;
020import java.util.List;
021import java.util.Locale;
022import java.util.Map;
023import java.util.Set;
024
025/**
026 * Project a {@link SemanticProgram} into a {@link CanonicalLineageModel}.
027 *
028 * <p>SELECT lineage: for each outer-statement output, BFS the
029 * program-level lineage edges (target → sources) until we hit
030 * {@code TABLE_COLUMN} terminals. Each terminal becomes one canonical
031 * SELECT edge.
032 *
033 * <p>Row influence: for each statement reachable from the outer (via
034 * CTE/SUBQUERY relations), every column ref in {@code filterColumnRefs}
035 * and {@code joinColumnRefs} is resolved to a base table by walking the
036 * lineage chain that starts at the relation it references.
037 */
038public final class SemanticIRProjector {
039
040    private SemanticIRProjector() {}
041
042    public static ProjectorResult project(SemanticProgram program) {
043        if (program == null) {
044            throw new IllegalArgumentException("program must not be null");
045        }
046        if (program.getStatements().isEmpty()) {
047            return ProjectorResult.unsupported(
048                    ProjectorResult.UnsupportedReason.NO_RELATIONSHIPS,
049                    "program has no statements");
050        }
051        // A degrade placeholder means some block was never analyzed. Its empty
052        // OutputColumns would contribute zero edges below and the resulting
053        // model would look complete while silently missing them, so refuse
054        // rather than project an incomplete canonical model.
055        for (StatementGraph s : program.getStatements()) {
056            if (s.isUnanalyzed()) {
057                return ProjectorResult.unsupported(
058                        ProjectorResult.UnsupportedReason.UNANALYZED_BLOCK,
059                        "block '" + s.getName() + "' was not analyzed: "
060                                + s.getUnanalyzedReason().getMessage());
061            }
062        }
063        // Outer statement: last unnamed statement. Named statements are CTE
064        // bodies, FROM-subquery bodies, and (slice 11) scalar-subquery
065        // bodies (synthetic angle-bracketed names — see
066        // SemanticIRBuilder.SCALAR_BODY_PREFIX). The outer is the one that
067        // reads from them and is always unnamed.
068        int outerIndex = -1;
069        for (int i = program.getStatements().size() - 1; i >= 0; i--) {
070            if (program.getStatements().get(i).getName() == null) {
071                outerIndex = i;
072                break;
073            }
074        }
075        if (outerIndex < 0) {
076            return ProjectorResult.unsupported(
077                    ProjectorResult.UnsupportedReason.NO_RELATIONSHIPS,
078                    "no outer (unnamed) statement found");
079        }
080
081        // Index lineage edges by their from-key so the BFS is O(N) per query.
082        Map<String, List<LineageEdge>> outgoingByFrom = indexLineage(program.getLineage());
083
084        // Kind-aware lookups for row-influence resolution. A `CTE`-bound
085        // relation refers to a body whose name is the CTE name; a
086        // `SUBQUERY`-bound relation refers to a body whose name is the
087        // FROM-clause alias. Keeping the maps separate avoids collision when
088        // a CTE name and a subquery alias happen to match (e.g.
089        // {@code WITH x AS (...) SELECT FROM (SELECT ...) x}).
090        BodyIndexes bodies = new BodyIndexes(program);
091
092        StatementGraph outer = program.getStatements().get(outerIndex);
093
094        Set<String> outputNames = new LinkedHashSet<>();
095        Map<String, Boolean> aggregateByOutput = new LinkedHashMap<>();
096        Set<CanonicalLineageEdge> edges = new LinkedHashSet<>();
097
098        // 1) SELECT edges per outer output.
099        for (OutputColumn out : outer.getOutputColumns()) {
100            String outName = out.getName().toLowerCase(Locale.ROOT);
101            outputNames.add(outName);
102            // Last write wins if the SQL has duplicate output names; the
103            // builder doesn't reject them today and the canonical model
104            // can't either, so we treat it as the OutputColumn list does.
105            aggregateByOutput.put(outName, out.isAggregate());
106
107            String startKey = stmtOutputKey(outerIndex, out.getName());
108            for (TableColumn tc : bfsToBaseColumns(startKey, outgoingByFrom)) {
109                edges.add(new CanonicalLineageEdge(
110                        EdgeRole.SELECT, outName, tc.table, tc.column));
111            }
112        }
113
114        // 2) Reachable statements from the outer. Single fixpoint BFS that
115        // combines (a) CTE / SUBQUERY relation edges and (b) program-level
116        // STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage edges. The lineage
117        // walk picks up scalar-subquery bodies (slice 11) — they are
118        // referenced only via lineage, never via relations — and any
119        // statement those scalar bodies reach via their own relations
120        // gets visited in the same pass.
121        Set<Integer> reachable = computeReachable(program, outerIndex, outgoingByFrom);
122
123        // 3) Row-influence edges from every reachable statement's filter/join refs.
124        for (int idx : reachable) {
125            StatementGraph s = program.getStatements().get(idx);
126            for (ColumnRef ref : s.getFilterColumnRefs()) {
127                addRowInfluenceEdges(EdgeRole.FILTER, idx, s, ref,
128                        outgoingByFrom, bodies, edges);
129            }
130            for (ColumnRef ref : s.getJoinColumnRefs()) {
131                addRowInfluenceEdges(EdgeRole.JOIN, idx, s, ref,
132                        outgoingByFrom, bodies, edges);
133            }
134        }
135
136        // 4) Slice 24: predicate-body JOIN edges. Emit one JOIN canonical
137        // edge per base-column terminal of each predicate body's
138        // OutputColumn(s). The pass is intentionally OUTSIDE the
139        // `reachable` BFS:
140        //
141        // (a) Predicate bodies are unreachable from outer by slice-23
142        //     design — outer holds no relation pointing at them and no
143        //     STATEMENT_OUTPUT lineage edge into them. Adding them to
144        //     `reachable` would let `addRowInfluenceEdges` walk their
145        //     filter/join refs, which would manufacture FILTER / JOIN
146        //     edges from inner WHERE / inner JOIN refs — dlineage's BFS
147        //     does not chain into RS-2's RelationRows from RS-1
148        //     (slice-24 probe finding 3), so adding those edges would
149        //     break the slice-7 zero-divergence guarantee.
150        // (b) Iterating ONLY OutputColumns (not filter/join refs) means
151        //     inner WHERE / inner JOIN refs do NOT contribute to outer's
152        //     canonical model. This matches dlineage's behaviour: the
153        //     fdr clause="on" source `clauseType="selectList"
154        //     parent_id=<inner-RS>` resolves to the inner-projected
155        //     column's base-column terminal via fdd chains, but the
156        //     non-system source attribute prevents BFS from chaining
157        //     into the inner RS's RelationRows.
158        // (c) Slice-23 constant-only bodies have OutputColumns with
159        //     empty `sources` → bfsToBaseColumns finds no terminals →
160        //     zero JOIN edges added (preserves slice-23 zero-divergence
161        //     on corpus 21). Slice-24 column-ref bodies have one
162        //     ColumnRef source whose lineage chain leads to the inner
163        //     base column → exactly one JOIN edge per terminal.
164        // (d) De-duplication via the `Set<CanonicalLineageEdge>`
165        //     semantics: a `(JOIN, null, departments, id)` edge from
166        //     outer's `d.id = e.id` and from a column-bearing EXISTS
167        //     `(SELECT d.id FROM departments d)` collapse to one.
168        for (int idx = 0; idx < program.getStatements().size(); idx++) {
169            StatementGraph s = program.getStatements().get(idx);
170            if (s.getName() == null) continue;
171            if (!SemanticIRBuilder.isPredicateSubquerySyntheticName(s.getName())) continue;
172            for (OutputColumn out : s.getOutputColumns()) {
173                String startKey = stmtOutputKey(idx, out.getName());
174                for (TableColumn tc : bfsToBaseColumns(startKey, outgoingByFrom)) {
175                    edges.add(new CanonicalLineageEdge(
176                            EdgeRole.JOIN, null, tc.table, tc.column));
177                }
178            }
179        }
180
181        return ProjectorResult.ok(
182                new CanonicalLineageModel(edges, outputNames, aggregateByOutput));
183    }
184
185    /**
186     * Resolve a {@code ColumnRef} that appears in {@code stmt}'s filter/join
187     * clause down to base-table columns and emit one row-influence edge per
188     * terminal.
189     */
190    private static void addRowInfluenceEdges(EdgeRole role,
191                                             int consumerIdx,
192                                             StatementGraph stmt,
193                                             ColumnRef ref,
194                                             Map<String, List<LineageEdge>> outgoingByFrom,
195                                             BodyIndexes bodies,
196                                             Set<CanonicalLineageEdge> sink) {
197        RelationSource matched = null;
198        for (RelationSource r : stmt.getRelations()) {
199            if (r.getAlias().equals(ref.getRelationAlias())) {
200                matched = r;
201                break;
202            }
203        }
204        if (matched == null) {
205            // Builder guarantees an alias is in scope, but be defensive — a
206            // dropped row-influence edge would silently mask divergence.
207            return;
208        }
209        // Slice 15: resolved-kind dispatch. OUTER_REFERENCE bindings
210        // delegate to the underlying outerKind. TABLE emits a
211        // base-column edge; CTE / SUBQUERY BFS through the outer body
212        // to reach base columns (mirroring dlineage's behaviour).
213        RelationKind kind = matched.getBinding().getKind();
214        RelationKind resolvedKind = (kind == RelationKind.OUTER_REFERENCE)
215                ? matched.getBinding().getOuterKind()
216                : kind;
217        if (resolvedKind == RelationKind.TABLE
218                || resolvedKind == RelationKind.FUNCTION) {
219            // FUNCTION (table-valued function) is an opaque terminal source,
220            // projected like a base table column.
221            sink.add(new CanonicalLineageEdge(role, null,
222                    matched.getBinding().getQualifiedName().toLowerCase(Locale.ROOT),
223                    ref.getColumnName().toLowerCase(Locale.ROOT)));
224            return;
225        }
226        if (resolvedKind == RelationKind.CTE || resolvedKind == RelationKind.SUBQUERY) {
227            Integer downstream = bodies.lookup(consumerIdx, resolvedKind, matched);
228            if (downstream == null) return;
229            String startKey = stmtOutputKey(downstream, ref.getColumnName());
230            for (TableColumn tc : bfsToBaseColumns(startKey, outgoingByFrom)) {
231                sink.add(new CanonicalLineageEdge(role, null, tc.table, tc.column));
232            }
233            return;
234        }
235        // UNION / UNKNOWN / null outerKind — should not appear in
236        // row-influence today. A future slice introducing them must
237        // extend this dispatch.
238        throw new IllegalStateException(
239                "unhandled RelationKind in addRowInfluenceEdges: " + kind
240                        + (kind == RelationKind.OUTER_REFERENCE
241                            ? " (outerKind=" + matched.getBinding().getOuterKind() + ")"
242                            : ""));
243    }
244
245    /**
246     * Single-fixpoint BFS over both (a) CTE/SUBQUERY relation edges
247     * and (b) program-level STATEMENT_OUTPUT → STATEMENT_OUTPUT
248     * lineage edges (slice 11). Each pop visits both edge kinds in
249     * the same pass, so a scalar-subquery body reached via lineage
250     * has its own CTE/SUBQUERY relations traversed before the BFS
251     * terminates. The lineage edges are pre-indexed by the caller
252     * to avoid rebuilding the index inside this method.
253     */
254    private static Set<Integer> computeReachable(SemanticProgram program,
255                                                 int outerIndex,
256                                                 Map<String, List<LineageEdge>> outgoingByFrom) {
257        BodyIndexes bodies = new BodyIndexes(program);
258        Set<Integer> reachable = new LinkedHashSet<>();
259        Deque<Integer> q = new ArrayDeque<>();
260        q.add(outerIndex);
261        reachable.add(outerIndex);
262        while (!q.isEmpty()) {
263            int idx = q.removeFirst();
264            StatementGraph s = program.getStatements().get(idx);
265            // (a) CTE/SUBQUERY relations.
266            for (RelationSource r : s.getRelations()) {
267                RelationKind k = r.getBinding().getKind();
268                if (k != RelationKind.CTE && k != RelationKind.SUBQUERY) continue;
269                Integer downstream = bodies.lookup(idx, k, r);
270                if (downstream != null && reachable.add(downstream)) {
271                    q.add(downstream);
272                }
273            }
274            // (b) STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage edges
275            // originating from this statement's outputs (slice 11
276            // scalar-subquery body reachability).
277            for (OutputColumn out : s.getOutputColumns()) {
278                String key = stmtOutputKey(idx, out.getName());
279                List<LineageEdge> outgoing = outgoingByFrom.get(key);
280                if (outgoing == null) continue;
281                for (LineageEdge e : outgoing) {
282                    LineageRef to = e.getTo();
283                    if (to.getKind() != LineageRef.Kind.STATEMENT_OUTPUT) continue;
284                    int downstream = to.getStatementIndex();
285                    if (reachable.add(downstream)) {
286                        q.add(downstream);
287                    }
288                }
289            }
290        }
291        return reachable;
292    }
293
294    /** Pure helper: BFS from a statement-output key over lineage edges. */
295    private static List<TableColumn> bfsToBaseColumns(String startKey,
296                                                       Map<String, List<LineageEdge>> outgoingByFrom) {
297        List<TableColumn> out = new ArrayList<>();
298        Set<String> visited = new HashSet<>();
299        Deque<String> q = new ArrayDeque<>();
300        q.add(startKey);
301        visited.add(startKey);
302        while (!q.isEmpty()) {
303            String cur = q.removeFirst();
304            List<LineageEdge> outgoing = outgoingByFrom.get(cur);
305            if (outgoing == null) continue;
306            for (LineageEdge e : outgoing) {
307                LineageRef to = e.getTo();
308                if (to.getKind() == LineageRef.Kind.TABLE_COLUMN) {
309                    out.add(new TableColumn(
310                            to.getQualifiedName().toLowerCase(Locale.ROOT),
311                            to.getColumnName().toLowerCase(Locale.ROOT)));
312                } else {
313                    String nextKey = stmtOutputKey(to.getStatementIndex(), to.getOutputName());
314                    if (visited.add(nextKey)) {
315                        q.add(nextKey);
316                    }
317                }
318            }
319        }
320        return out;
321    }
322
323    private static Map<String, List<LineageEdge>> indexLineage(List<LineageEdge> all) {
324        Map<String, List<LineageEdge>> out = new HashMap<>();
325        for (LineageEdge e : all) {
326            LineageRef from = e.getFrom();
327            if (from.getKind() != LineageRef.Kind.STATEMENT_OUTPUT) continue;
328            String key = stmtOutputKey(from.getStatementIndex(), from.getOutputName());
329            out.computeIfAbsent(key, k -> new ArrayList<>()).add(e);
330        }
331        return out;
332    }
333
334    private static String stmtOutputKey(int idx, String name) {
335        return idx + "/" + name;
336    }
337
338    private static final class TableColumn {
339        final String table;
340        final String column;
341        TableColumn(String table, String column) {
342            this.table = table;
343            this.column = column;
344        }
345    }
346
347    /**
348     * Kind-aware lookup from a CTE name or subquery alias to the index of
349     * its body statement, scoped per consumer statement. Maps are keyed by
350     * {@code consumerStmtIdx + "|" + name_lower}.
351     *
352     * <p>Slice 18 makes the lookup consumer-scoped (was: global alias-keyed)
353     * to handle the new shape "two CTE bodies each containing a FROM-subquery
354     * aliased {@code s}". With consumer-scoping each consumer sees its own
355     * body, instead of the last-write-wins value of a global map.
356     *
357     * <p>Construction (two passes):
358     * <ol>
359     * <li>Walk consumers in statement order over their direct CTE / SUBQUERY
360     *     relations. {@code SUBQUERY} consumers exclusively claim a body
361     *     (FROM-subquery bodies are extracted by exactly one consumer);
362     *     {@code CTE} consumers do <i>not</i> claim — CTEs are reusable, so
363     *     multiple consumers can record their own per-consumer entry pointing
364     *     at the same CTE body. Subquery-claimed bodies are skipped by CTE
365     *     pickers so a CTE name colliding with a FROM alias does not
366     *     mis-bind.</li>
367     * <li>Walk STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage edges to derive
368     *     each child statement's immediate parent. For each child with an
369     *     {@code OUTER_REFERENCE-of-CTE} or {@code OUTER_REFERENCE-of-SUBQUERY}
370     *     relation, walk the immediate-parent chain (innermost → outermost)
371     *     until an ancestor's Pass-1 entry is found, and copy that entry
372     *     under {@code (childIdx, name)}. This is what makes the consumer-
373     *     keyed lookup self-sufficient for slice-14/15 OUTER_REFERENCE row-
374     *     influence — without it, a scalar body's lookup at its own index
375     *     would return {@code null} because Pass 1 only stores entries for
376     *     direct CTE/SUBQUERY relations (slice-15 invariant: OUTER_REFERENCE
377     *     does not claim). Slice 20 generalises one-step propagation to a
378     *     transitive chain walk so doubly-nested (and deeper) correlated
379     *     scalars resolve their grandparent body via the same lookup.</li>
380     * </ol>
381     *
382     * <p>Collision behaviour: when both a CTE named {@code x} and a subquery
383     * aliased {@code x} exist, the SUBQUERY consumer's exclusive claim takes
384     * the FROM-subquery body (latest unclaimed prior); the CTE consumer's
385     * non-claiming pick takes the CTE body (earliest non-subquery-claimed
386     * prior). Pinned by {@code subqueryAliasResolvesPastUnusedCteWithSameName}
387     * and {@code outerReferenceRelationDoesNotClaimBodies} in
388     * {@link gudusoft.gsqlparser.ir.semantic.slice7.SemanticIRProjectorBodyIndexesTest}.
389     *
390     * <p>Unused CTEs (declared but never referenced) have no entry in any
391     * map. Reachability BFS does not visit them, so this is correct — there
392     * is no caller that could ask for them. Slice 18 removed the legacy
393     * default-population pass for the same reason.
394     */
395    private static final class BodyIndexes {
396        // Per-consumer claim. Key: "<consumerStmtIdx>|<name_lower>".
397        private final Map<String, Integer> cteByConsumerAndName = new HashMap<>();
398        private final Map<String, Integer> subqueryByConsumerAndAlias = new HashMap<>();
399
400        BodyIndexes(SemanticProgram program) {
401            // Build the candidate-bodies-by-name index. Synthetic-named
402            // bodies (slice-11 scalar bodies, slice-12 set-op branches) are
403            // skipped — they are not CTE/FROM-subquery candidates and are
404            // reached only via lineage edges in computeReachable.
405            Map<String, java.util.List<Integer>> bodyIndicesByName = new HashMap<>();
406            for (int i = 0; i < program.getStatements().size(); i++) {
407                StatementGraph s = program.getStatements().get(i);
408                if (s.getName() == null) continue;
409                if (SemanticIRBuilder.isScalarSyntheticName(s.getName())) continue;
410                if (SemanticIRBuilder.isSetOpBranchSyntheticName(s.getName())) continue;
411                if (SemanticIRBuilder.isPredicateSubquerySyntheticName(s.getName())) continue;
412                bodyIndicesByName
413                        .computeIfAbsent(s.getName().toLowerCase(Locale.ROOT),
414                                k -> new java.util.ArrayList<>())
415                        .add(i);
416            }
417
418            // Pass 1: walk consumer relations in statement order. Body
419            // claiming is kind-aware:
420            //   - CTE consumer: pick the EARLIEST candidate matching the
421            //     name with index < ci that is not already subquery-claimed.
422            //     Do NOT claim — CTE bodies are reusable across consumers.
423            //   - SUBQUERY consumer: pick the LATEST unclaimed candidate
424            //     matching the alias with index < ci. Exclusive claim.
425            //
426            // Slice-15 invariant: OUTER_REFERENCE relations do NOT claim
427            // bodies in this pass — the filter at line ~XXX excludes them.
428            // Pass 2 below derives their per-consumer entries from the
429            // parent's entries via lineage.
430            Set<Integer> subqueryClaimed = new HashSet<>();
431            for (int ci = 0; ci < program.getStatements().size(); ci++) {
432                StatementGraph cs = program.getStatements().get(ci);
433                for (RelationSource r : cs.getRelations()) {
434                    RelationKind k = r.getBinding().getKind();
435                    if (k != RelationKind.CTE && k != RelationKind.SUBQUERY) continue;
436                    String name = (k == RelationKind.SUBQUERY)
437                            ? r.getAlias().toLowerCase(Locale.ROOT)
438                            : r.getBinding().getQualifiedName().toLowerCase(Locale.ROOT);
439                    java.util.List<Integer> candidates = bodyIndicesByName.get(name);
440                    if (candidates == null) continue;
441                    Integer chosen = null;
442                    if (k == RelationKind.CTE) {
443                        for (Integer idx : candidates) {
444                            if (idx >= ci) break;
445                            if (subqueryClaimed.contains(idx)) continue;
446                            chosen = idx;
447                            break;
448                        }
449                    } else { // SUBQUERY
450                        for (int j = candidates.size() - 1; j >= 0; j--) {
451                            int idx = candidates.get(j);
452                            if (idx >= ci) continue;
453                            if (subqueryClaimed.contains(idx)) continue;
454                            chosen = idx;
455                            break;
456                        }
457                        if (chosen != null) subqueryClaimed.add(chosen);
458                    }
459                    if (chosen == null) continue;
460                    String key = ci + "|" + name;
461                    if (k == RelationKind.CTE) cteByConsumerAndName.put(key, chosen);
462                    else                       subqueryByConsumerAndAlias.put(key, chosen);
463                }
464            }
465
466            // Pass 2: derive each child statement's immediate parent from
467            // STATEMENT_OUTPUT → STATEMENT_OUTPUT lineage. For each child
468            // with an OUTER_REFERENCE-of-CTE or OUTER_REFERENCE-of-SUBQUERY
469            // relation, walk the IMMEDIATE-PARENT CHAIN (innermost →
470            // outermost) until an ancestor's per-consumer Pass-1 entry is
471            // found, and copy that entry under the child's index. This
472            // makes the consumer-keyed lookup self-sufficient for
473            // OUTER_REFERENCE row-influence at any nesting depth (slice 18
474            // codex round-2 MUST 2; slice 20 generalises one-step → chain).
475            //
476            // Slice-14/15 carry only the immediate-parent scope and one
477            // STATEMENT_OUTPUT edge per child output is typical, so
478            // first-write-wins is correct. Slice-20 chain walks support
479            // doubly-nested (and deeper) correlated scalars where the
480            // inner-inner's OUTER_REFERENCE is anchored at the
481            // grandparent (or further-ancestor) statement.
482            //
483            // Cycle guard via `visited`: STATEMENT_OUTPUT → STATEMENT_OUTPUT
484            // is a DAG by construction (a child's output never flows back
485            // to an ancestor's output), but the guard makes the
486            // non-termination invariant explicit.
487            Map<Integer, Integer> immediateParent = new HashMap<>();
488            for (LineageEdge e : program.getLineage()) {
489                if (e.getFrom().getKind() != LineageRef.Kind.STATEMENT_OUTPUT) continue;
490                if (e.getTo().getKind() != LineageRef.Kind.STATEMENT_OUTPUT) continue;
491                // Slice 24: predicate-body edges do NOT anchor immediate-
492                // parent chains. The predicate body is unreachable from
493                // outer (slice-23 invariant); recording it as the
494                // "immediate parent" of a CTE / SUBQUERY body would
495                // mis-route OUTER_REFERENCE chain walks below — a slice-15
496                // / slice-20 child looking for the CTE owner would land
497                // at the predicate body's index, find no Pass-1 entry,
498                // walk to the predicate's parent (none recorded), and
499                // give up. Slice-23 constant predicate bodies emitted no
500                // STATEMENT_OUTPUT → STATEMENT_OUTPUT edges (their
501                // synthetic OutputColumn had empty sources), so the side
502                // effect surfaces only when slice-24 column-bearing
503                // predicate bodies have CTE-bound inner relations.
504                int fromIdx = e.getFrom().getStatementIndex();
505                StatementGraph fromStmt = program.getStatements().get(fromIdx);
506                if (fromStmt.getName() != null
507                        && SemanticIRBuilder.isPredicateSubquerySyntheticName(fromStmt.getName())) {
508                    continue;
509                }
510                immediateParent.putIfAbsent(
511                        e.getTo().getStatementIndex(),
512                        e.getFrom().getStatementIndex());
513            }
514            for (Map.Entry<Integer, Integer> entry : immediateParent.entrySet()) {
515                int childIdx = entry.getKey();
516                StatementGraph cs = program.getStatements().get(childIdx);
517                for (RelationSource r : cs.getRelations()) {
518                    if (r.getBinding().getKind() != RelationKind.OUTER_REFERENCE) continue;
519                    RelationKind ok = r.getBinding().getOuterKind();
520                    if (ok != RelationKind.CTE && ok != RelationKind.SUBQUERY) continue;
521                    String name = (ok == RelationKind.SUBQUERY)
522                            ? r.getAlias().toLowerCase(Locale.ROOT)
523                            : r.getBinding().getQualifiedName().toLowerCase(Locale.ROOT);
524                    Map<String, Integer> sourceMap = (ok == RelationKind.CTE)
525                            ? cteByConsumerAndName : subqueryByConsumerAndAlias;
526                    Integer body = null;
527                    Integer cur = entry.getValue();
528                    Set<Integer> visited = new HashSet<>();
529                    visited.add(childIdx); // never claim self
530                    while (cur != null && visited.add(cur)) {
531                        body = sourceMap.get(cur + "|" + name);
532                        if (body != null) break;
533                        cur = immediateParent.get(cur);
534                    }
535                    if (body == null) continue;
536                    String childKey = childIdx + "|" + name;
537                    if (ok == RelationKind.CTE)
538                        cteByConsumerAndName.putIfAbsent(childKey, body);
539                    else
540                        subqueryByConsumerAndAlias.putIfAbsent(childKey, body);
541                }
542            }
543        }
544
545        Integer lookup(int consumerIdx, RelationKind kind, RelationSource consumer) {
546            String name = (kind == RelationKind.CTE)
547                    ? consumer.getBinding().getQualifiedName().toLowerCase(Locale.ROOT)
548                    : consumer.getAlias().toLowerCase(Locale.ROOT);
549            String key = consumerIdx + "|" + name;
550            if (kind == RelationKind.CTE) return cteByConsumerAndName.get(key);
551            if (kind == RelationKind.SUBQUERY) return subqueryByConsumerAndAlias.get(key);
552            return null;
553        }
554    }
555}