1use std::collections::{BTreeMap, BTreeSet};
21
22use datafusion::common::tree_node::TreeNode;
23use datafusion::logical_expr::utils::grouping_set_to_exprlist;
24use datafusion::logical_expr::{DdlStatement, Distinct, Expr, JoinType, LogicalPlan, WriteOp};
25
26use crate::config::OpenLineageConfig;
27use crate::extract::dataset_for;
28use crate::naming::DatasetName;
29
30#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
32pub struct SourceColumn {
33 pub dataset: DatasetName,
35 pub column: String,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42pub enum DirectKind {
43 Identity,
45 Transformation,
47 Aggregation,
49}
50
51impl DirectKind {
52 pub fn subtype(self) -> &'static str {
54 match self {
55 DirectKind::Identity => "IDENTITY",
56 DirectKind::Transformation => "TRANSFORMATION",
57 DirectKind::Aggregation => "AGGREGATION",
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
64pub enum IndirectKind {
65 Filter,
67 Join,
69 GroupBy,
71 Sort,
73 Window,
75}
76
77impl IndirectKind {
78 pub fn subtype(self) -> &'static str {
80 match self {
81 IndirectKind::Filter => "FILTER",
82 IndirectKind::Join => "JOIN",
83 IndirectKind::GroupBy => "GROUP_BY",
84 IndirectKind::Sort => "SORT",
85 IndirectKind::Window => "WINDOW",
86 }
87 }
88}
89
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct ColumnSources {
93 pub direct: BTreeMap<SourceColumn, DirectKind>,
95}
96
97impl ColumnSources {
98 fn single(source: SourceColumn, kind: DirectKind) -> Self {
99 Self {
100 direct: BTreeMap::from([(source, kind)]),
101 }
102 }
103
104 fn merge(&mut self, other: &ColumnSources) {
105 for (source, kind) in &other.direct {
106 self.direct
107 .entry(source.clone())
108 .and_modify(|k| *k = (*k).max(*kind))
109 .or_insert(*kind);
110 }
111 }
112
113 fn upgraded(mut self, min: DirectKind) -> Self {
117 for kind in self.direct.values_mut() {
118 *kind = (*kind).max(min);
119 }
120 self
121 }
122}
123
124pub type IndirectSources = BTreeMap<SourceColumn, BTreeSet<IndirectKind>>;
126
127#[derive(Debug, Clone, Default)]
132pub(crate) struct NodeLineage {
133 pub columns: Vec<ColumnSources>,
136 pub indirect: IndirectSources,
139}
140
141#[derive(Debug, Clone, Default)]
147pub struct ResolvedColumns {
148 pub fields: BTreeMap<String, ColumnSources>,
150 pub indirect: IndirectSources,
152}
153
154pub(crate) fn resolve_output_columns(
156 plan: &LogicalPlan,
157 config: &OpenLineageConfig,
158) -> Option<ResolvedColumns> {
159 match plan {
160 LogicalPlan::Dml(dml) => match dml.op {
161 WriteOp::Insert(_) | WriteOp::Ctas | WriteOp::Update => {
166 let resolved = resolve(&dml.input, config)?;
167 let target = dml.target.schema();
168 if resolved.columns.len() != target.fields().len() {
169 return degrade(plan, "DML input not aligned with target schema");
170 }
171 Some(keyed_by(resolved, |i| target.field(i).name().clone()))
172 }
173 WriteOp::Delete | WriteOp::Truncate => None,
175 },
176 LogicalPlan::Ddl(DdlStatement::CreateMemoryTable(cmd)) => {
179 let resolved = resolve(&cmd.input, config)?;
180 let schema = cmd.input.schema().clone();
181 if resolved.columns.len() != schema.fields().len() {
182 return degrade(plan, "CTAS input arity mismatch");
183 }
184 Some(keyed_by(resolved, |i| schema.field(i).name().clone()))
185 }
186 _ => None,
189 }
190}
191
192fn keyed_by(node: NodeLineage, name: impl Fn(usize) -> String) -> ResolvedColumns {
193 let fields = node
194 .columns
195 .into_iter()
196 .enumerate()
197 .filter(|(_, sources)| !sources.direct.is_empty())
200 .map(|(i, sources)| (name(i), sources))
201 .collect();
202 ResolvedColumns {
203 fields,
204 indirect: node.indirect,
205 }
206}
207
208fn degrade<T>(plan: &LogicalPlan, reason: &str) -> Option<T> {
209 tracing::debug!(
210 target: "openlineage",
211 node = %plan.display(),
212 reason,
213 "column lineage degraded"
214 );
215 None
216}
217
218fn resolve(plan: &LogicalPlan, config: &OpenLineageConfig) -> Option<NodeLineage> {
220 let node = match plan {
221 LogicalPlan::TableScan(scan) => {
222 if scan
225 .table_name
226 .schema()
227 .is_some_and(|s| s.eq_ignore_ascii_case("information_schema"))
228 {
229 return degrade(plan, "information_schema scan");
230 }
231 let dataset = dataset_for(&scan.table_name, config);
232 let source_schema = scan.source.schema();
233 let columns = (0..scan.projected_schema.fields().len())
236 .map(|i| {
237 let src = scan.projection.as_ref().map_or(i, |p| p[i]);
238 ColumnSources::single(
239 SourceColumn {
240 dataset: dataset.clone(),
241 column: source_schema.field(src).name().clone(),
242 },
243 DirectKind::Identity,
244 )
245 })
246 .collect();
247 let mut indirect = IndirectSources::new();
250 for filter in &scan.filters {
251 if has_hidden_sources(filter) {
252 return degrade(plan, "subquery in pushed-down filter");
253 }
254 for col in filter.column_refs() {
255 if source_schema.field_with_name(&col.name).is_err() {
256 return degrade(plan, "pushed-down filter column not in source schema");
257 }
258 indirect
259 .entry(SourceColumn {
260 dataset: dataset.clone(),
261 column: col.name.clone(),
262 })
263 .or_default()
264 .insert(IndirectKind::Filter);
265 }
266 }
267 NodeLineage { columns, indirect }
268 }
269
270 LogicalPlan::Projection(proj) => {
271 let child = resolve(&proj.input, config)?;
272 let columns = proj
273 .expr
274 .iter()
275 .map(|expr| expr_sources(expr, &proj.input, &child, DirectKind::Identity))
276 .collect::<Option<_>>()?;
277 NodeLineage {
278 columns,
279 indirect: child.indirect,
280 }
281 }
282
283 LogicalPlan::SubqueryAlias(alias) => resolve(&alias.input, config)?,
287
288 LogicalPlan::Filter(filter) => {
289 let mut child = resolve(&filter.input, config)?;
290 let sources = referenced_sources(&filter.predicate, &filter.input, &child.columns)?;
291 add_indirect(&mut child.indirect, sources, IndirectKind::Filter);
292 child
293 }
294
295 LogicalPlan::Aggregate(agg) => {
296 let child = resolve(&agg.input, config)?;
297 let group_list = grouping_set_to_exprlist(&agg.group_expr).ok()?;
298 let mut columns = Vec::with_capacity(agg.schema.fields().len());
299 let mut indirect = child.indirect.clone();
300 for expr in &group_list {
301 let sources = expr_sources(expr, &agg.input, &child, DirectKind::Identity)?;
302 add_indirect(
304 &mut indirect,
305 sources.direct.keys().cloned().collect(),
306 IndirectKind::GroupBy,
307 );
308 columns.push(sources);
309 }
310 if matches!(agg.group_expr.as_slice(), [Expr::GroupingSet(_)]) {
313 columns.push(ColumnSources::default());
314 }
315 for expr in &agg.aggr_expr {
316 columns.push(expr_sources(
317 expr,
318 &agg.input,
319 &child,
320 DirectKind::Aggregation,
321 )?);
322 }
323 NodeLineage { columns, indirect }
324 }
325
326 LogicalPlan::Join(join) => {
327 let left = resolve(&join.left, config)?;
328 let right = resolve(&join.right, config)?;
329 let mut indirect = left.indirect.clone();
330 for (source, kinds) in &right.indirect {
331 indirect.entry(source.clone()).or_default().extend(kinds);
332 }
333 for (l, r) in &join.on {
334 add_indirect(
335 &mut indirect,
336 referenced_sources(l, &join.left, &left.columns)?,
337 IndirectKind::Join,
338 );
339 add_indirect(
340 &mut indirect,
341 referenced_sources(r, &join.right, &right.columns)?,
342 IndirectKind::Join,
343 );
344 }
345 if let Some(filter) = &join.filter {
346 if has_hidden_sources(filter) {
348 return degrade(plan, "subquery in join filter");
349 }
350 for col in filter.column_refs() {
351 let sources = if let Some(i) = join.left.schema().maybe_index_of_column(col) {
352 &left.columns[i]
353 } else if let Some(i) = join.right.schema().maybe_index_of_column(col) {
354 &right.columns[i]
355 } else {
356 return degrade(plan, "join filter column not in either side");
357 };
358 add_indirect(
359 &mut indirect,
360 sources.direct.keys().cloned().collect(),
361 IndirectKind::Join,
362 );
363 }
364 }
365 let mark = std::iter::once(ColumnSources::default());
368 let columns: Vec<ColumnSources> = match join.join_type {
369 JoinType::Inner | JoinType::Left | JoinType::Right | JoinType::Full => {
370 left.columns.into_iter().chain(right.columns).collect()
371 }
372 JoinType::LeftSemi | JoinType::LeftAnti => left.columns,
373 JoinType::RightSemi | JoinType::RightAnti => right.columns,
374 JoinType::LeftMark => left.columns.into_iter().chain(mark).collect(),
375 JoinType::RightMark => right.columns.into_iter().chain(mark).collect(),
376 };
377 NodeLineage { columns, indirect }
378 }
379
380 LogicalPlan::Union(union) => {
381 let mut columns = vec![ColumnSources::default(); union.schema.fields().len()];
382 let mut indirect = IndirectSources::new();
383 for input in &union.inputs {
384 let child = resolve(input, config)?;
385 if child.columns.len() != columns.len() {
386 return degrade(plan, "union input arity mismatch");
387 }
388 for (merged, sources) in columns.iter_mut().zip(&child.columns) {
389 merged.merge(sources);
390 }
391 for (source, kinds) in &child.indirect {
392 indirect.entry(source.clone()).or_default().extend(kinds);
393 }
394 }
395 NodeLineage { columns, indirect }
396 }
397
398 LogicalPlan::Sort(sort) => {
399 let mut child = resolve(&sort.input, config)?;
400 for sort_expr in &sort.expr {
401 let sources = referenced_sources(&sort_expr.expr, &sort.input, &child.columns)?;
402 add_indirect(&mut child.indirect, sources, IndirectKind::Sort);
403 }
404 child
405 }
406
407 LogicalPlan::Limit(limit) => resolve(&limit.input, config)?,
410 LogicalPlan::Repartition(repartition) => resolve(&repartition.input, config)?,
411
412 LogicalPlan::Distinct(Distinct::All(input)) => resolve(input, config)?,
413 LogicalPlan::Distinct(Distinct::On(distinct)) => {
414 let child = resolve(&distinct.input, config)?;
415 let mut indirect = child.indirect.clone();
416 for expr in &distinct.on_expr {
417 let sources = referenced_sources(expr, &distinct.input, &child.columns)?;
418 add_indirect(&mut indirect, sources, IndirectKind::GroupBy);
419 }
420 for sort_expr in distinct.sort_expr.iter().flatten() {
421 let sources = referenced_sources(&sort_expr.expr, &distinct.input, &child.columns)?;
422 add_indirect(&mut indirect, sources, IndirectKind::Sort);
423 }
424 let columns = distinct
425 .select_expr
426 .iter()
427 .map(|expr| expr_sources(expr, &distinct.input, &child, DirectKind::Identity))
428 .collect::<Option<_>>()?;
429 NodeLineage { columns, indirect }
430 }
431
432 LogicalPlan::Window(window) => {
433 let child = resolve(&window.input, config)?;
434 let mut columns = child.columns.clone();
435 let mut indirect = child.indirect.clone();
436 for expr in &window.window_expr {
437 let mut inner = expr;
438 while let Expr::Alias(alias) = inner {
439 inner = &alias.expr;
440 }
441 if let Expr::WindowFunction(wf) = inner {
442 let mut sources = ColumnSources::default();
443 for arg in &wf.params.args {
444 sources.merge(&expr_sources(
445 arg,
446 &window.input,
447 &child,
448 DirectKind::Aggregation,
449 )?);
450 }
451 for key in wf
452 .params
453 .partition_by
454 .iter()
455 .chain(wf.params.order_by.iter().map(|s| &s.expr))
456 {
457 let refs = referenced_sources(key, &window.input, &child.columns)?;
458 add_indirect(&mut indirect, refs, IndirectKind::Window);
459 }
460 columns.push(sources);
461 } else {
462 columns.push(expr_sources(
465 inner,
466 &window.input,
467 &child,
468 DirectKind::Transformation,
469 )?);
470 }
471 }
472 NodeLineage { columns, indirect }
473 }
474
475 LogicalPlan::Unnest(unnest) => {
476 let child = resolve(&unnest.input, config)?;
477 let unnested: BTreeSet<usize> = unnest
478 .list_type_columns
479 .iter()
480 .map(|(i, _)| *i)
481 .chain(unnest.struct_type_columns.iter().copied())
482 .collect();
483 let columns = unnest
484 .dependency_indices
485 .iter()
486 .map(|&dep| {
487 let sources = child.columns.get(dep)?.clone();
488 Some(if unnested.contains(&dep) {
489 sources.upgraded(DirectKind::Transformation)
490 } else {
491 sources
492 })
493 })
494 .collect::<Option<_>>()
495 .or_else(|| degrade(plan, "unnest dependency index out of range"))?;
496 NodeLineage {
497 columns,
498 indirect: child.indirect,
499 }
500 }
501
502 LogicalPlan::Values(values) => NodeLineage {
504 columns: vec![ColumnSources::default(); values.schema.fields().len()],
505 indirect: IndirectSources::new(),
506 },
507 LogicalPlan::EmptyRelation(empty) => NodeLineage {
508 columns: vec![ColumnSources::default(); empty.schema.fields().len()],
509 indirect: IndirectSources::new(),
510 },
511
512 other => return degrade(other, "no column lineage resolution for plan node"),
517 };
518
519 if node.columns.len() != plan.schema().fields().len() {
522 return degrade(plan, "resolved arity does not match node schema");
523 }
524 Some(node)
525}
526
527fn expr_sources(
534 expr: &Expr,
535 child_plan: &LogicalPlan,
536 child: &NodeLineage,
537 min: DirectKind,
538) -> Option<ColumnSources> {
539 if has_hidden_sources(expr) {
540 return degrade(child_plan, "subquery in expression");
541 }
542 let mut inner = expr;
543 while let Expr::Alias(alias) = inner {
544 inner = &alias.expr;
545 }
546 if let Expr::Column(col) = inner {
547 let i = child_plan.schema().maybe_index_of_column(col)?;
548 return Some(child.columns[i].clone().upgraded(min));
549 }
550 let mut sources = ColumnSources::default();
551 for col in inner.column_refs() {
552 let i = child_plan.schema().maybe_index_of_column(col)?;
553 sources.merge(&child.columns[i]);
554 }
555 Some(sources.upgraded(min.max(DirectKind::Transformation)))
556}
557
558fn referenced_sources(
562 expr: &Expr,
563 child_plan: &LogicalPlan,
564 columns: &[ColumnSources],
565) -> Option<Vec<SourceColumn>> {
566 if has_hidden_sources(expr) {
567 return degrade(child_plan, "subquery in predicate/key expression");
568 }
569 let mut out = Vec::new();
570 for col in expr.column_refs() {
571 let i = child_plan.schema().maybe_index_of_column(col)?;
572 out.extend(columns[i].direct.keys().cloned());
573 }
574 Some(out)
575}
576
577fn add_indirect(indirect: &mut IndirectSources, sources: Vec<SourceColumn>, kind: IndirectKind) {
578 for source in sources {
579 indirect.entry(source).or_default().insert(kind);
580 }
581}
582
583fn has_hidden_sources(expr: &Expr) -> bool {
586 expr.exists(|e| {
587 Ok(matches!(
588 e,
589 Expr::Exists(_)
590 | Expr::InSubquery(_)
591 | Expr::ScalarSubquery(_)
592 | Expr::OuterReferenceColumn(_, _)
593 ))
594 })
595 .unwrap_or(true)
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 fn source(name: &str) -> SourceColumn {
603 SourceColumn {
604 dataset: DatasetName {
605 namespace: "ns".into(),
606 name: "t".into(),
607 },
608 column: name.into(),
609 }
610 }
611
612 #[test]
613 fn merge_keeps_strongest_kind() {
614 let mut a = ColumnSources::single(source("a"), DirectKind::Identity);
615 let b = ColumnSources::single(source("a"), DirectKind::Aggregation);
616 a.merge(&b);
617 assert_eq!(a.direct[&source("a")], DirectKind::Aggregation);
618 let c = ColumnSources::single(source("a"), DirectKind::Identity);
620 a.merge(&c);
621 assert_eq!(a.direct[&source("a")], DirectKind::Aggregation);
622 }
623
624 #[test]
625 fn upgrade_is_a_floor_not_an_override() {
626 let s = ColumnSources::single(source("a"), DirectKind::Aggregation)
627 .upgraded(DirectKind::Transformation);
628 assert_eq!(s.direct[&source("a")], DirectKind::Aggregation);
629 let s = ColumnSources::single(source("a"), DirectKind::Identity)
630 .upgraded(DirectKind::Transformation);
631 assert_eq!(s.direct[&source("a")], DirectKind::Transformation);
632 }
633}