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}