001package gudusoft.gsqlparser.lineage2.engine;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.EExpressionType;
005import gudusoft.gsqlparser.ESetOperatorType;
006import gudusoft.gsqlparser.TSourceToken;
007import gudusoft.gsqlparser.ir.semantic.AnalysisResult;
008import gudusoft.gsqlparser.ir.semantic.RelationKind;
009import gudusoft.gsqlparser.ir.semantic.RelationSource;
010import gudusoft.gsqlparser.ir.semantic.SqlSemanticAnalyzer;
011import gudusoft.gsqlparser.ir.semantic.StatementGraph;
012import gudusoft.gsqlparser.ir.semantic.catalog.Catalog;
013import gudusoft.gsqlparser.ir.semantic.catalog.CatalogColumn;
014import gudusoft.gsqlparser.ir.semantic.catalog.CatalogTable;
015import gudusoft.gsqlparser.lineage2.api.LineageConfig;
016import gudusoft.gsqlparser.lineage2.contract.CaseBranch;
017import gudusoft.gsqlparser.lineage2.contract.ContractEdge;
018import gudusoft.gsqlparser.lineage2.contract.EdgeIdentity;
019import gudusoft.gsqlparser.lineage2.contract.Endpoint;
020import gudusoft.gsqlparser.lineage2.contract.GuidEncoder;
021import gudusoft.gsqlparser.lineage2.contract.LineageAxis;
022import gudusoft.gsqlparser.lineage2.contract.LineageRole;
023import gudusoft.gsqlparser.lineage2.contract.ObjectKind;
024import gudusoft.gsqlparser.lineage2.contract.ResolutionStatus;
025import gudusoft.gsqlparser.lineage2.contract.SourceLocation;
026import gudusoft.gsqlparser.lineage2.contract.Transformation;
027import gudusoft.gsqlparser.nodes.TAliasClause;
028import gudusoft.gsqlparser.nodes.TCaseExpression;
029import gudusoft.gsqlparser.nodes.TExpression;
030import gudusoft.gsqlparser.nodes.TExpressionList;
031import gudusoft.gsqlparser.nodes.TFunctionCall;
032import gudusoft.gsqlparser.nodes.TJoin;
033import gudusoft.gsqlparser.nodes.TJoinItem;
034import gudusoft.gsqlparser.nodes.TObjectName;
035import gudusoft.gsqlparser.nodes.TOrderBy;
036import gudusoft.gsqlparser.nodes.TOrderByItem;
037import gudusoft.gsqlparser.nodes.TParseTreeNode;
038import gudusoft.gsqlparser.nodes.TPartitionClause;
039import gudusoft.gsqlparser.nodes.TResultColumn;
040import gudusoft.gsqlparser.nodes.TSortBy;
041import gudusoft.gsqlparser.nodes.TTable;
042import gudusoft.gsqlparser.nodes.TWhenClauseItem;
043import gudusoft.gsqlparser.nodes.TWindowDef;
044import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
045
046import java.util.ArrayDeque;
047import java.util.ArrayList;
048import java.util.Arrays;
049import java.util.Collections;
050import java.util.Deque;
051import java.util.HashMap;
052import java.util.HashSet;
053import java.util.LinkedHashMap;
054import java.util.LinkedHashSet;
055import java.util.List;
056import java.util.Locale;
057import java.util.Map;
058import java.util.Set;
059
060/**
061 * US-021 — role-tagged edge extraction for a single {@code SELECT}
062 * statement (contract §5 two-pass decision procedure).
063 *
064 * <p>The extractor wraps {@link SqlSemanticAnalyzer#analyze} as the
065 * semantic gate and relation-binding source (alias → base table), and
066 * walks the already-parsed resolver2-annotated AST for everything the
067 * Semantic IR does not yet carry (expression text, alias/expression
068 * source spans, CASE branch attribution, per-conjunct predicate
069 * decomposition — the IR side arrives with US-021a).
070 *
071 * <p><b>Pass 1 — axis.</b> VALUE contributions come from the SELECT list
072 * (one edge per distinct source column per output), ROW contributions
073 * from WHERE / HAVING conjuncts (R1) and JOIN ON conjuncts (R2), each
074 * targeting the statement's result set as a whole (null target column).
075 * A source appearing on both axes yields two edges (identity layer
076 * requirement).
077 *
078 * <p><b>Pass 2 — role within axis.</b> ROW: FILTER (R1) beats JOIN (R2);
079 * a source in both WHERE and ON yields the FILTER edge only. VALUE:
080 * AGGREGATE (V1, incl. reducing window functions, excl. ranking) beats
081 * CONDITION (V2, CASE/IIF condition branch) beats TRANSFORM (V3); a
082 * bare-column projection is DIRECT (V5); an output with no column
083 * sources at all is DERIVED with a null source endpoint (V6). LOOKUP
084 * (V4) requires catalog key metadata and is never guessed — it always
085 * falls through until the BindingGate story (US-025) supplies key facts.
086 *
087 * <p>Out-of-scope statement shapes (set operators, star projections,
088 * multi-block queries with CTEs/subqueries, non-TABLE relations) yield
089 * no edges — their extraction stories are US-022+.
090 *
091 * <p>Internal API: not part of the public gsqlparser surface.
092 */
093public final class StatementLineageExtractor {
094
095    /** Contract §4 record type for engine-emitted occurrences. */
096    private static final String RECORD_TYPE = "INCOMING";
097
098    /** Result relation object name of a top-level SELECT (gold convention). */
099    private static final String RESULTSET = "RESULTSET";
100
101    /**
102     * Reducing aggregate function names (contract §5 V1). Window
103     * functions count only when reducing — ranking/navigation functions
104     * (ROW_NUMBER, RANK, LAG, …) are deliberately absent so they
105     * classify as TRANSFORM.
106     */
107    private static final Set<String> AGGREGATE_FUNCTIONS =
108            Collections.unmodifiableSet(new HashSet<String>(Arrays.asList(
109                    "sum", "avg", "count", "count_big", "min", "max",
110                    "string_agg", "stdev", "stdevp", "var", "varp",
111                    "variance", "stddev", "checksum_agg", "grouping",
112                    "grouping_id", "approx_count_distinct")));
113
114    private StatementLineageExtractor() {
115        // utility — no instances
116    }
117
118    /**
119     * Extract contract edges for one parsed SELECT statement.
120     *
121     * @param stmt             resolver2-annotated AST from the unit parse
122     * @param statementText    verbatim statement slice (trailing separator
123     *                         excluded — the FINGERPRINT-V1 slice)
124     * @param statementIndex   1-based source-order index
125     * @param queryFingerprint FINGERPRINT-V1 of {@code statementText}
126     * @param unitText         full unit text (token offsets index into it)
127     * @return contract edges in deterministic construction order
128     *         (final ordering is the exporter's documented sort); empty
129     *         for out-of-scope shapes or semantic-gate rejections
130     */
131    public static List<ContractEdge> extract(TSelectSqlStatement stmt,
132                                             String statementText,
133                                             int statementIndex,
134                                             String queryFingerprint,
135                                             EDbVendor vendor,
136                                             Catalog catalog,
137                                             LineageConfig config,
138                                             String artifactLocator,
139                                             String unitText) {
140        if (stmt == null || statementText == null) {
141            return Collections.emptyList();
142        }
143        if (stmt.getSetOperatorType() != ESetOperatorType.none) {
144            return Collections.emptyList(); // set operators: R3 story (US-02x)
145        }
146        if (stmt.getResultColumnList() == null
147                || stmt.getResultColumnList().size() == 0) {
148            return Collections.emptyList();
149        }
150        for (int i = 0; i < stmt.getResultColumnList().size(); i++) {
151            TExpression expr = stmt.getResultColumnList()
152                    .getResultColumn(i).getExpr();
153            if (expr != null && isStar(expr)) {
154                return Collections.emptyList(); // star expansion story
155            }
156        }
157
158        // Semantic gate (the wrapped analyzer): rejects shapes the IR does
159        // not support and supplies the alias → base-table binding map.
160        AnalysisResult analysis =
161                SqlSemanticAnalyzer.analyze(statementText, vendor, catalog);
162        if (!analysis.isSuccessful() || analysis.getProgram() == null) {
163            return Collections.emptyList();
164        }
165        List<StatementGraph> graphs = analysis.getProgram().getStatements();
166        if (graphs.size() != 1) {
167            return Collections.emptyList(); // CTE / subquery stories (US-02x)
168        }
169        Map<String, String> aliasToTable = new HashMap<String, String>();
170        for (RelationSource relation : graphs.get(0).getRelations()) {
171            if (relation.getBinding().getKind() != RelationKind.TABLE) {
172                return Collections.emptyList();
173            }
174            aliasToTable.put(relation.getAlias().toLowerCase(Locale.ROOT),
175                    relation.getBinding().getQualifiedName());
176        }
177
178        Extraction extraction = new Extraction(statementIndex,
179                queryFingerprint, catalog, config, artifactLocator, unitText,
180                aliasToTable);
181        extraction.valueEdges(stmt);
182        extraction.rowEdges(stmt);
183        return extraction.edges;
184    }
185
186    /** Per-statement extraction state. */
187    private static final class Extraction {
188        final List<ContractEdge> edges = new ArrayList<ContractEdge>();
189        final int statementIndex;
190        final String queryFingerprint;
191        final Catalog catalog;
192        final LineageConfig config;
193        final String artifactLocator;
194        final String unitText;
195        final Map<String, String> aliasToTable;
196
197        Extraction(int statementIndex, String queryFingerprint,
198                   Catalog catalog, LineageConfig config,
199                   String artifactLocator, String unitText,
200                   Map<String, String> aliasToTable) {
201            this.statementIndex = statementIndex;
202            this.queryFingerprint = queryFingerprint;
203            this.catalog = catalog;
204            this.config = config;
205            this.artifactLocator = artifactLocator;
206            this.unitText = unitText;
207            this.aliasToTable = aliasToTable;
208        }
209
210        // -------------------------------------------------------------
211        // VALUE axis (contract §5 pass 2, V1–V7).
212        // -------------------------------------------------------------
213
214        void valueEdges(TSelectSqlStatement stmt) {
215            for (int i = 0; i < stmt.getResultColumnList().size(); i++) {
216                TResultColumn rc =
217                        stmt.getResultColumnList().getResultColumn(i);
218                TExpression expr = rc.getExpr();
219                if (expr == null) {
220                    continue;
221                }
222                Endpoint target = resultsetEndpoint(targetName(rc, expr),
223                        targetLocation(rc, expr));
224                if (expr.getExpressionType()
225                        == EExpressionType.simple_object_name_t) {
226                    // V5 — pure column copy: no transformation object.
227                    Endpoint source =
228                            sourceEndpoint(expr.getObjectOperand());
229                    if (source != null) {
230                        addEdge(LineageRole.DIRECT, LineageAxis.VALUE,
231                                source, target, null, null);
232                    }
233                    continue;
234                }
235                Transformation tf = transformationOf(expr);
236                List<Ref> refs = collectRefs(expr);
237                if (refs.isEmpty()) {
238                    // V6 — constant-only / synthesized target: null source.
239                    addEdge(LineageRole.DERIVED, LineageAxis.VALUE, null,
240                            target, tf, null);
241                    continue;
242                }
243                Set<String> seen = new LinkedHashSet<String>();
244                for (Ref ref : refs) {
245                    Endpoint source = sourceEndpoint(ref.node);
246                    if (source == null) {
247                        continue;
248                    }
249                    LineageRole role = ref.aggregate ? LineageRole.AGGREGATE
250                            : (ref.branch == CaseBranch.CONDITION
251                                    ? LineageRole.CONDITION
252                                    : LineageRole.TRANSFORM);
253                    String key = endpointKey(source) + "|" + role.name()
254                            + "|" + (ref.branch == null ? ""
255                                    : ref.branch.name());
256                    if (!seen.add(key)) {
257                        continue;
258                    }
259                    addEdge(role, LineageAxis.VALUE, source, target, tf,
260                            ref.branch);
261                }
262            }
263        }
264
265        // -------------------------------------------------------------
266        // ROW axis (contract §5 pass 2, R1–R4).
267        // -------------------------------------------------------------
268
269        void rowEdges(TSelectSqlStatement stmt) {
270            // R1 — WHERE / HAVING conjuncts, in clause order.
271            List<TExpression> filterConjuncts = new ArrayList<TExpression>();
272            if (stmt.getWhereClause() != null
273                    && stmt.getWhereClause().getCondition() != null) {
274                filterConjuncts.addAll(
275                        conjunctsOf(stmt.getWhereClause().getCondition()));
276            }
277            if (stmt.getGroupByClause() != null
278                    && stmt.getGroupByClause().getHavingClause() != null) {
279                filterConjuncts.addAll(conjunctsOf(
280                        stmt.getGroupByClause().getHavingClause()));
281            }
282            // Sources with an R1 edge suppress their R2 edges (R1 beats R2:
283            // one ROW edge per source for the same target, role FILTER).
284            Set<String> filtered = new HashSet<String>();
285            for (TExpression conjunct : filterConjuncts) {
286                for (Map.Entry<String, Endpoint> entry
287                        : uniqueSources(conjunct).entrySet()) {
288                    filtered.add(entry.getKey());
289                    addEdge(LineageRole.FILTER, LineageAxis.ROW,
290                            entry.getValue(), resultsetEndpoint(null, null),
291                            transformationOf(conjunct), null);
292                }
293            }
294            // R2 — JOIN ON conjuncts.
295            if (stmt.getJoins() != null) {
296                for (int i = 0; i < stmt.getJoins().size(); i++) {
297                    TJoin join = stmt.getJoins().getJoin(i);
298                    if (join.getJoinItems() == null) {
299                        continue;
300                    }
301                    for (int j = 0; j < join.getJoinItems().size(); j++) {
302                        TJoinItem item = join.getJoinItems().getJoinItem(j);
303                        if (item.getOnCondition() == null) {
304                            continue;
305                        }
306                        for (TExpression conjunct
307                                : conjunctsOf(item.getOnCondition())) {
308                            for (Map.Entry<String, Endpoint> entry
309                                    : uniqueSources(conjunct).entrySet()) {
310                                if (filtered.contains(entry.getKey())) {
311                                    continue;
312                                }
313                                addEdge(LineageRole.JOIN, LineageAxis.ROW,
314                                        entry.getValue(),
315                                        resultsetEndpoint(null, null),
316                                        transformationOf(conjunct), null);
317                            }
318                        }
319                    }
320                }
321            }
322        }
323
324        /** Distinct source endpoints of one predicate, in source order. */
325        Map<String, Endpoint> uniqueSources(TExpression predicate) {
326            Map<String, Endpoint> out = new LinkedHashMap<String, Endpoint>();
327            for (Ref ref : collectRefs(predicate)) {
328                Endpoint source = sourceEndpoint(ref.node);
329                if (source == null) {
330                    continue;
331                }
332                String key = endpointKey(source);
333                if (!out.containsKey(key)) {
334                    out.put(key, source);
335                }
336            }
337            return out;
338        }
339
340        // -------------------------------------------------------------
341        // Edge / endpoint assembly.
342        // -------------------------------------------------------------
343
344        void addEdge(LineageRole role, LineageAxis axis, Endpoint source,
345                     Endpoint target, Transformation tf,
346                     CaseBranch caseBranch) {
347            String factGuid = GuidEncoder.factGuid(config.getWorkspaceId(),
348                    source == null ? null : source.getDatabase(),
349                    source == null ? null : source.getSchema(),
350                    source == null ? null : source.getObject(),
351                    source == null ? null : source.getColumn(),
352                    target.getDatabase(), target.getSchema(),
353                    target.getObject(), target.getColumn(), role.name());
354            // Draft variantGuid convention (gen_fixtures.py): VALUE-axis
355            // edges hash the normalized expression into the transformation
356            // component, ROW-axis edges into the predicate component.
357            String exprHash = (tf == null) ? ""
358                    : GuidEncoder.textHash(tf.getNormalizedExpression());
359            String transformationHash =
360                    (axis == LineageAxis.VALUE) ? exprHash : "";
361            String predicateHash = (axis == LineageAxis.ROW) ? exprHash : "";
362            String variantGuid = GuidEncoder.variantGuid(
363                    config.getWorkspaceId(), factGuid, role.name(),
364                    transformationHash, predicateHash, queryFingerprint);
365            String occurrenceGuid = GuidEncoder.occurrenceGuid(
366                    config.getWorkspaceId(), factGuid, variantGuid,
367                    RECORD_TYPE, "", "", queryFingerprint, artifactLocator,
368                    statementIndex);
369            edges.add(ContractEdge.builder()
370                    .source(source)
371                    .target(target)
372                    .role(role)
373                    .axis(axis)
374                    .caseBranch(caseBranch)
375                    .statementIndex(statementIndex)
376                    .transformation(tf)
377                    .identity(new EdgeIdentity(factGuid, variantGuid,
378                            occurrenceGuid))
379                    .build());
380        }
381
382        /**
383         * Physical source endpoint for a column reference, fully expanded
384         * per the D-15 qualification promise. Returns {@code null} when
385         * the reference cannot be bound to a base relation (the binding
386         * stories US-025+ replace this with non-RESOLVED endpoints).
387         */
388        Endpoint sourceEndpoint(TObjectName ref) {
389            if (ref == null) {
390                return null;
391            }
392            String column = ref.getColumnNameOnly();
393            if (column == null || column.isEmpty() || "*".equals(column)) {
394                return null;
395            }
396            String tableName = null;
397            TTable sourceTable = ref.getSourceTable();
398            if (sourceTable != null) {
399                tableName = sourceTable.getFullName();
400            }
401            if (tableName == null || tableName.isEmpty()) {
402                String qualifier = ref.getObjectString();
403                if (qualifier != null && !qualifier.isEmpty()) {
404                    tableName = aliasToTable.get(
405                            qualifier.toLowerCase(Locale.ROOT));
406                }
407            }
408            if ((tableName == null || tableName.isEmpty())
409                    && aliasToTable.size() == 1) {
410                // Unqualified reference in a single-relation scope.
411                tableName = aliasToTable.values().iterator().next();
412            }
413            if (tableName == null || tableName.isEmpty()) {
414                return null;
415            }
416            String[] parts = tableName.split("\\.");
417            String database = null;
418            String schema = null;
419            String object;
420            if (parts.length >= 3) {
421                database = parts[parts.length - 3];
422                schema = parts[parts.length - 2];
423                object = parts[parts.length - 1];
424            } else if (parts.length == 2) {
425                schema = parts[0];
426                object = parts[1];
427            } else {
428                object = parts[0];
429            }
430            if (schema == null && !config.getDefaultSchema().isEmpty()) {
431                schema = config.getDefaultSchema();
432            }
433            if (database == null && !config.getDefaultDatabase().isEmpty()) {
434                database = config.getDefaultDatabase();
435            }
436            return Endpoint.builder(object)
437                    .database(database)
438                    .schema(schema)
439                    .column(column)
440                    .objectKind(ObjectKind.TABLE)
441                    .resolutionStatus(resolutionStatus(database, schema,
442                            object, column))
443                    .sourceLocation(locationOf(ref))
444                    .build();
445        }
446
447        /**
448         * Resolution status against the catalog snapshot (contract §4.1
449         * precedence; the BindingGate story US-025 extends this with
450         * AMBIGUOUS / PARSER_UNSUPPORTED facts).
451         */
452        ResolutionStatus resolutionStatus(String database, String schema,
453                                          String object, String column) {
454            if (catalog == null) {
455                return ResolutionStatus.GUESSED;
456            }
457            String bare = object.toLowerCase(Locale.ROOT);
458            String qualified = ((database == null ? "" : database + ".")
459                    + (schema == null ? "" : schema + ".")
460                    + object).toLowerCase(Locale.ROOT);
461            for (CatalogTable table : catalog.getTables()) {
462                String name = table.getName() == null ? ""
463                        : table.getName().toLowerCase(Locale.ROOT);
464                if (!name.equals(qualified) && !name.equals(bare)
465                        && !name.endsWith("." + bare)) {
466                    continue;
467                }
468                for (CatalogColumn catalogColumn : table.getColumns()) {
469                    if (column.equalsIgnoreCase(catalogColumn.getName())) {
470                        return ResolutionStatus.RESOLVED;
471                    }
472                }
473                return ResolutionStatus.PARTIAL;
474            }
475            return ResolutionStatus.UNRESOLVED_SOURCE;
476        }
477
478        Endpoint resultsetEndpoint(String column, SourceLocation location) {
479            return Endpoint.builder(RESULTSET)
480                    .column(column)
481                    .objectKind(ObjectKind.UNKNOWN)
482                    .resolutionStatus(ResolutionStatus.RESOLVED)
483                    .sourceLocation(location)
484                    .build();
485        }
486
487        String targetName(TResultColumn rc, TExpression expr) {
488            TAliasClause alias = rc.getAliasClause();
489            if (alias != null && alias.getAliasName() != null) {
490                return alias.getAliasName().toString();
491            }
492            if (expr.getExpressionType()
493                    == EExpressionType.simple_object_name_t
494                    && expr.getObjectOperand() != null) {
495                return expr.getObjectOperand().getColumnNameOnly();
496            }
497            return sliceText(expr);
498        }
499
500        SourceLocation targetLocation(TResultColumn rc, TExpression expr) {
501            TAliasClause alias = rc.getAliasClause();
502            if (alias != null && alias.getAliasName() != null) {
503                return locationOf(alias.getAliasName());
504            }
505            return locationOf(expr);
506        }
507
508        Transformation transformationOf(TParseTreeNode node) {
509            String original = sliceText(node);
510            if (original == null || original.isEmpty()) {
511                return null;
512            }
513            return Transformation.of(original, locationOf(node), null);
514        }
515
516        /** Verbatim unit-text slice spanned by the node's tokens. */
517        String sliceText(TParseTreeNode node) {
518            TSourceToken startToken = node.getStartToken();
519            TSourceToken endToken = node.getEndToken();
520            if (startToken == null || endToken == null) {
521                return null;
522            }
523            int start = (int) startToken.offset;
524            String endText = endToken.astext == null ? "" : endToken.astext;
525            int end = (int) endToken.offset + endText.length();
526            if (start < 0 || end < start || end > unitText.length()) {
527                return null;
528            }
529            return unitText.substring(start, end);
530        }
531
532        /**
533         * Half-open 1-based contract location from the node's boundary
534         * tokens (unit coordinates — the AST comes from the unit parse).
535         */
536        SourceLocation locationOf(TParseTreeNode node) {
537            TSourceToken startToken = node.getStartToken();
538            TSourceToken endToken = node.getEndToken();
539            if (startToken == null || endToken == null) {
540                return null;
541            }
542            String endText = endToken.astext == null ? "" : endToken.astext;
543            int newlines = 0;
544            for (int i = 0; i < endText.length(); i++) {
545                if (endText.charAt(i) == '\n') {
546                    newlines++;
547                }
548            }
549            int endLine = (int) endToken.lineNo + newlines;
550            int endCol = (newlines == 0)
551                    ? (int) endToken.columnNo + endText.length()
552                    : endText.length() - endText.lastIndexOf('\n');
553            return new SourceLocation(artifactLocator, statementIndex,
554                    (int) startToken.lineNo, (int) startToken.columnNo,
555                    endLine, endCol);
556        }
557
558        String endpointKey(Endpoint endpoint) {
559            return (lower(endpoint.getDatabase()) + "|"
560                    + lower(endpoint.getSchema()) + "|"
561                    + lower(endpoint.getObject()) + "|"
562                    + lower(endpoint.getColumn()));
563        }
564
565        private String lower(String s) {
566            return s == null ? "" : s.toLowerCase(Locale.ROOT);
567        }
568    }
569
570    // -----------------------------------------------------------------
571    // Expression analysis (shared, stateless).
572    // -----------------------------------------------------------------
573
574    /** A column reference with its V1/V2 classification context. */
575    private static final class Ref {
576        final TObjectName node;
577        final boolean aggregate;
578        final CaseBranch branch;
579
580        Ref(TObjectName node, boolean aggregate, CaseBranch branch) {
581            this.node = node;
582            this.aggregate = aggregate;
583            this.branch = branch;
584        }
585    }
586
587    /** Work item for the iterative expression walk. */
588    private static final class Frame {
589        final TExpression expr;
590        final boolean aggregate;
591        final CaseBranch branch;
592
593        Frame(TExpression expr, boolean aggregate, CaseBranch branch) {
594            this.expr = expr;
595            this.aggregate = aggregate;
596            this.branch = branch;
597        }
598    }
599
600    /**
601     * Collect column references with aggregate-path / CASE-branch flags.
602     * Iterative (explicit stack) per the repo's no-recursion rule for
603     * expression trees; children are pushed in reverse so references pop
604     * in source order. Subqueries are skipped (their lineage belongs to
605     * their own scope — multi-block stories).
606     */
607    private static List<Ref> collectRefs(TExpression root) {
608        List<Ref> out = new ArrayList<Ref>();
609        Deque<Frame> stack = new ArrayDeque<Frame>();
610        stack.push(new Frame(root, false, null));
611        while (!stack.isEmpty()) {
612            Frame frame = stack.pop();
613            TExpression e = frame.expr;
614            if (e == null) {
615                continue;
616            }
617            EExpressionType type = e.getExpressionType();
618            if (type == EExpressionType.simple_object_name_t) {
619                TObjectName name = e.getObjectOperand();
620                if (name != null && name.getColumnNameOnly() != null
621                        && !"*".equals(name.getColumnNameOnly())) {
622                    out.add(new Ref(name, frame.aggregate, frame.branch));
623                }
624            } else if (type == EExpressionType.simple_constant_t) {
625                // literal — no source
626            } else if (type == EExpressionType.subquery_t) {
627                // inner scope — out of single-block extraction scope
628            } else if (type == EExpressionType.function_t
629                    && e.getFunctionCall() != null) {
630                pushFunction(stack, e.getFunctionCall(), frame);
631            } else if (type == EExpressionType.case_t
632                    && e.getCaseExpression() != null) {
633                pushCase(stack, e.getCaseExpression(), frame);
634            } else {
635                // Generic operator/list node: reverse-push so the leftmost
636                // child is processed first.
637                List<TExpression> children = new ArrayList<TExpression>();
638                if (e.getLeftOperand() != null) {
639                    children.add(e.getLeftOperand());
640                }
641                if (e.getBetweenOperand() != null) {
642                    children.add(e.getBetweenOperand());
643                }
644                if (e.getRightOperand() != null) {
645                    children.add(e.getRightOperand());
646                }
647                if (e.getExprList() != null) {
648                    for (int i = 0; i < e.getExprList().size(); i++) {
649                        children.add(e.getExprList().getExpression(i));
650                    }
651                }
652                for (int i = children.size() - 1; i >= 0; i--) {
653                    stack.push(new Frame(children.get(i), frame.aggregate,
654                            frame.branch));
655                }
656            }
657        }
658        return out;
659    }
660
661    /**
662     * Function call: arguments inherit the aggregate flag when the
663     * function reduces (plain aggregate, or a reducing window function —
664     * contract §5 V1 counts windows "only when reducing, not ranking");
665     * OVER-clause partition/order keys are value-shaping, never
666     * aggregating (V3 TRANSFORM path).
667     */
668    private static void pushFunction(Deque<Frame> stack, TFunctionCall call,
669                                     Frame frame) {
670        String name = functionName(call);
671        boolean reducing = AGGREGATE_FUNCTIONS.contains(name);
672        boolean argAggregate = frame.aggregate || reducing;
673        List<Frame> children = new ArrayList<Frame>();
674        if (call.getArgs() != null) {
675            for (int i = 0; i < call.getArgs().size(); i++) {
676                children.add(new Frame(call.getArgs().getExpression(i),
677                        argAggregate, frame.branch));
678            }
679        }
680        if (call.getCastOperand() != null) {
681            children.add(new Frame(call.getCastOperand(), argAggregate,
682                    frame.branch));
683        }
684        TWindowDef window = call.getWindowDef();
685        if (window != null) {
686            TPartitionClause partition = window.getPartitionClause();
687            if (partition != null && partition.getExpressionList() != null) {
688                TExpressionList list = partition.getExpressionList();
689                for (int i = 0; i < list.size(); i++) {
690                    children.add(new Frame(list.getExpression(i),
691                            frame.aggregate, frame.branch));
692                }
693            }
694            TSortBy sortBy = window.getSortBy();
695            if (sortBy != null && sortBy.getItems() != null) {
696                for (int i = 0; i < sortBy.getItems().size(); i++) {
697                    TOrderByItem item = (TOrderByItem)
698                            sortBy.getItems().getElement(i);
699                    children.add(new Frame(item.getSortKey(),
700                            frame.aggregate, frame.branch));
701                }
702            }
703            TOrderBy orderBy = window.getOrderBy();
704            if (orderBy != null && orderBy.getItems() != null) {
705                for (int i = 0; i < orderBy.getItems().size(); i++) {
706                    children.add(new Frame(
707                            orderBy.getItems().getOrderByItem(i)
708                                    .getSortKey(),
709                            frame.aggregate, frame.branch));
710                }
711            }
712        }
713        for (int i = children.size() - 1; i >= 0; i--) {
714            stack.push(children.get(i));
715        }
716    }
717
718    /**
719     * CASE/IIF: WHEN conditions (and the comparand of a simple CASE)
720     * flow through the condition branch (V2); THEN/ELSE results flow
721     * through the value branch (V3). The innermost CASE decides the
722     * branch of a nested reference.
723     */
724    private static void pushCase(Deque<Frame> stack, TCaseExpression ce,
725                                 Frame frame) {
726        List<Frame> children = new ArrayList<Frame>();
727        if (ce.getInput_expr() != null) {
728            children.add(new Frame(ce.getInput_expr(), frame.aggregate,
729                    CaseBranch.CONDITION));
730        }
731        if (ce.getWhenClauseItemList() != null) {
732            for (int i = 0; i < ce.getWhenClauseItemList().size(); i++) {
733                TWhenClauseItem item = (TWhenClauseItem)
734                        ce.getWhenClauseItemList().getElement(i);
735                if (item.getComparison_expr() != null) {
736                    children.add(new Frame(item.getComparison_expr(),
737                            frame.aggregate, CaseBranch.CONDITION));
738                }
739                if (item.getReturn_expr() != null) {
740                    children.add(new Frame(item.getReturn_expr(),
741                            frame.aggregate, CaseBranch.VALUE));
742                }
743            }
744        }
745        if (ce.getElse_expr() != null) {
746            children.add(new Frame(ce.getElse_expr(), frame.aggregate,
747                    CaseBranch.VALUE));
748        }
749        for (int i = children.size() - 1; i >= 0; i--) {
750            stack.push(children.get(i));
751        }
752    }
753
754    /** Bare lower-cased function name (qualifier stripped). */
755    private static String functionName(TFunctionCall call) {
756        if (call.getFunctionName() == null) {
757            return "";
758        }
759        String name = call.getFunctionName().toString();
760        int dot = name.lastIndexOf('.');
761        if (dot >= 0) {
762            name = name.substring(dot + 1);
763        }
764        return name.toLowerCase(Locale.ROOT);
765    }
766
767    /**
768     * Top-level AND conjuncts of a predicate, in source order. Iterative
769     * left-chain descent (left-leaning tree from the left-recursive
770     * grammar); parentheses around a conjunction are transparent.
771     */
772    private static List<TExpression> conjunctsOf(TExpression condition) {
773        List<TExpression> out = new ArrayList<TExpression>();
774        Deque<TExpression> stack = new ArrayDeque<TExpression>();
775        stack.push(condition);
776        while (!stack.isEmpty()) {
777            TExpression e = stack.pop();
778            if (e == null) {
779                continue;
780            }
781            if (e.getExpressionType() == EExpressionType.logical_and_t) {
782                if (e.getRightOperand() != null) {
783                    stack.push(e.getRightOperand());
784                }
785                if (e.getLeftOperand() != null) {
786                    stack.push(e.getLeftOperand());
787                }
788                continue;
789            }
790            if (e.getExpressionType() == EExpressionType.parenthesis_t
791                    && e.getLeftOperand() != null
792                    && e.getLeftOperand().getExpressionType()
793                            == EExpressionType.logical_and_t) {
794                stack.push(e.getLeftOperand());
795                continue;
796            }
797            out.add(e);
798        }
799        return out;
800    }
801
802    /** {@code *} or {@code t.*} projection? */
803    private static boolean isStar(TExpression expr) {
804        if (expr.getExpressionType() != EExpressionType.simple_object_name_t
805                || expr.getObjectOperand() == null) {
806            return false;
807        }
808        String column = expr.getObjectOperand().getColumnNameOnly();
809        return "*".equals(column);
810    }
811}