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