use std::collections::{BTreeMap, BTreeSet};
use datafusion::common::tree_node::TreeNode;
use datafusion::logical_expr::utils::grouping_set_to_exprlist;
use datafusion::logical_expr::{
CreateMemoryTable, CreateView, DdlStatement, Distinct, Expr, JoinType, LogicalPlan, WriteOp,
};
use crate::config::OpenLineageConfig;
use crate::extract::dataset_for;
use crate::naming::DatasetName;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct SourceColumn {
pub dataset: DatasetName,
pub column: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DirectKind {
Identity,
Transformation,
Aggregation,
}
impl DirectKind {
pub fn subtype(self) -> &'static str {
match self {
DirectKind::Identity => "IDENTITY",
DirectKind::Transformation => "TRANSFORMATION",
DirectKind::Aggregation => "AGGREGATION",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum IndirectKind {
Filter,
Join,
GroupBy,
Sort,
Window,
}
impl IndirectKind {
pub fn subtype(self) -> &'static str {
match self {
IndirectKind::Filter => "FILTER",
IndirectKind::Join => "JOIN",
IndirectKind::GroupBy => "GROUP_BY",
IndirectKind::Sort => "SORT",
IndirectKind::Window => "WINDOW",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ColumnSources {
pub direct: BTreeMap<SourceColumn, DirectKind>,
}
impl ColumnSources {
fn single(source: SourceColumn, kind: DirectKind) -> Self {
Self {
direct: BTreeMap::from([(source, kind)]),
}
}
fn merge(&mut self, other: &ColumnSources) {
for (source, kind) in &other.direct {
self.direct
.entry(source.clone())
.and_modify(|k| *k = (*k).max(*kind))
.or_insert(*kind);
}
}
fn upgraded(mut self, min: DirectKind) -> Self {
for kind in self.direct.values_mut() {
*kind = (*kind).max(min);
}
self
}
}
pub type IndirectSources = BTreeMap<SourceColumn, BTreeSet<IndirectKind>>;
#[derive(Debug, Clone, Default)]
pub(crate) struct NodeLineage {
pub columns: Vec<ColumnSources>,
pub indirect: IndirectSources,
}
#[derive(Debug, Clone, Default)]
pub struct ResolvedColumns {
pub fields: BTreeMap<String, ColumnSources>,
pub indirect: IndirectSources,
}
pub(crate) fn resolve_output_columns(
plan: &LogicalPlan,
config: &OpenLineageConfig,
) -> Option<ResolvedColumns> {
match plan {
LogicalPlan::Dml(dml) => match dml.op {
WriteOp::Insert(_) | WriteOp::Ctas | WriteOp::Update => {
let resolved = resolve(&dml.input, config)?;
let target = dml.target.schema();
if resolved.columns.len() != target.fields().len() {
return degrade(plan, "DML input not aligned with target schema");
}
Some(keyed_by(resolved, |i| target.field(i).name().clone()))
}
WriteOp::Delete | WriteOp::Truncate => None,
},
LogicalPlan::Ddl(
DdlStatement::CreateMemoryTable(CreateMemoryTable { input, .. })
| DdlStatement::CreateView(CreateView { input, .. }),
) => {
let resolved = resolve(input, config)?;
let schema = input.schema().clone();
if resolved.columns.len() != schema.fields().len() {
return degrade(plan, "CTAS/view input arity mismatch");
}
Some(keyed_by(resolved, |i| schema.field(i).name().clone()))
}
_ => None,
}
}
fn keyed_by(node: NodeLineage, name: impl Fn(usize) -> String) -> ResolvedColumns {
let fields = node
.columns
.into_iter()
.enumerate()
.filter(|(_, sources)| !sources.direct.is_empty())
.map(|(i, sources)| (name(i), sources))
.collect();
ResolvedColumns {
fields,
indirect: node.indirect,
}
}
fn peel_aliases(expr: &Expr) -> &Expr {
let mut inner = expr;
while let Expr::Alias(alias) = inner {
inner = &alias.expr;
}
inner
}
fn degrade<T>(plan: &LogicalPlan, reason: &str) -> Option<T> {
tracing::debug!(
target: "openlineage",
node = %plan.display(),
reason,
"column lineage degraded"
);
None
}
fn resolve(plan: &LogicalPlan, config: &OpenLineageConfig) -> Option<NodeLineage> {
let node = match plan {
LogicalPlan::TableScan(scan) => {
if scan
.table_name
.schema()
.is_some_and(|s| s.eq_ignore_ascii_case("information_schema"))
{
return degrade(plan, "information_schema scan");
}
let dataset = dataset_for(&scan.table_name, config);
let source_schema = scan.source.schema();
let columns = (0..scan.projected_schema.fields().len())
.map(|i| {
let src = scan.projection.as_ref().map_or(i, |p| p[i]);
ColumnSources::single(
SourceColumn {
dataset: dataset.clone(),
column: source_schema.field(src).name().clone(),
},
DirectKind::Identity,
)
})
.collect();
let mut indirect = IndirectSources::new();
for filter in &scan.filters {
if has_hidden_sources(filter) {
return degrade(plan, "subquery in pushed-down filter");
}
for col in filter.column_refs() {
if source_schema.field_with_name(&col.name).is_err() {
return degrade(plan, "pushed-down filter column not in source schema");
}
indirect
.entry(SourceColumn {
dataset: dataset.clone(),
column: col.name.clone(),
})
.or_default()
.insert(IndirectKind::Filter);
}
}
NodeLineage { columns, indirect }
}
LogicalPlan::Projection(proj) => {
let child = resolve(&proj.input, config)?;
let columns = proj
.expr
.iter()
.map(|expr| expr_sources(expr, &proj.input, &child, DirectKind::Identity))
.collect::<Option<_>>()?;
NodeLineage {
columns,
indirect: child.indirect,
}
}
LogicalPlan::SubqueryAlias(alias) => resolve(&alias.input, config)?,
LogicalPlan::Filter(filter) => {
let mut child = resolve(&filter.input, config)?;
let sources = referenced_sources(&filter.predicate, &filter.input, &child.columns)?;
add_indirect(&mut child.indirect, sources, IndirectKind::Filter);
child
}
LogicalPlan::Aggregate(agg) => {
let child = resolve(&agg.input, config)?;
let group_list = grouping_set_to_exprlist(&agg.group_expr).ok()?;
let mut columns = Vec::with_capacity(agg.schema.fields().len());
let mut indirect = child.indirect.clone();
for expr in &group_list {
let sources = expr_sources(expr, &agg.input, &child, DirectKind::Identity)?;
add_indirect(
&mut indirect,
sources.direct.keys().cloned().collect(),
IndirectKind::GroupBy,
);
columns.push(sources);
}
if matches!(agg.group_expr.as_slice(), [Expr::GroupingSet(_)]) {
columns.push(ColumnSources::default());
}
for expr in &agg.aggr_expr {
columns.push(expr_sources(
expr,
&agg.input,
&child,
DirectKind::Aggregation,
)?);
}
NodeLineage { columns, indirect }
}
LogicalPlan::Join(join) => {
let left = resolve(&join.left, config)?;
let right = resolve(&join.right, config)?;
let mut indirect = left.indirect.clone();
for (source, kinds) in &right.indirect {
indirect.entry(source.clone()).or_default().extend(kinds);
}
for (l, r) in &join.on {
add_indirect(
&mut indirect,
referenced_sources(l, &join.left, &left.columns)?,
IndirectKind::Join,
);
add_indirect(
&mut indirect,
referenced_sources(r, &join.right, &right.columns)?,
IndirectKind::Join,
);
}
if let Some(filter) = &join.filter {
if has_hidden_sources(filter) {
return degrade(plan, "subquery in join filter");
}
for col in filter.column_refs() {
let sources = if let Some(i) = join.left.schema().maybe_index_of_column(col) {
&left.columns[i]
} else if let Some(i) = join.right.schema().maybe_index_of_column(col) {
&right.columns[i]
} else {
return degrade(plan, "join filter column not in either side");
};
add_indirect(
&mut indirect,
sources.direct.keys().cloned().collect(),
IndirectKind::Join,
);
}
}
let mark = std::iter::once(ColumnSources::default());
let columns: Vec<ColumnSources> = match join.join_type {
JoinType::Inner | JoinType::Left | JoinType::Right | JoinType::Full => {
left.columns.into_iter().chain(right.columns).collect()
}
JoinType::LeftSemi | JoinType::LeftAnti => left.columns,
JoinType::RightSemi | JoinType::RightAnti => right.columns,
JoinType::LeftMark => left.columns.into_iter().chain(mark).collect(),
JoinType::RightMark => right.columns.into_iter().chain(mark).collect(),
};
NodeLineage { columns, indirect }
}
LogicalPlan::Union(union) => {
let mut columns = vec![ColumnSources::default(); union.schema.fields().len()];
let mut indirect = IndirectSources::new();
for input in &union.inputs {
let child = resolve(input, config)?;
if child.columns.len() != columns.len() {
return degrade(plan, "union input arity mismatch");
}
for (merged, sources) in columns.iter_mut().zip(&child.columns) {
merged.merge(sources);
}
for (source, kinds) in &child.indirect {
indirect.entry(source.clone()).or_default().extend(kinds);
}
}
NodeLineage { columns, indirect }
}
LogicalPlan::Sort(sort) => {
let mut child = resolve(&sort.input, config)?;
for sort_expr in &sort.expr {
let sources = referenced_sources(&sort_expr.expr, &sort.input, &child.columns)?;
add_indirect(&mut child.indirect, sources, IndirectKind::Sort);
}
child
}
LogicalPlan::Limit(limit) => resolve(&limit.input, config)?,
LogicalPlan::Repartition(repartition) => resolve(&repartition.input, config)?,
LogicalPlan::Distinct(Distinct::All(input)) => resolve(input, config)?,
LogicalPlan::Distinct(Distinct::On(distinct)) => {
let child = resolve(&distinct.input, config)?;
let mut indirect = child.indirect.clone();
for expr in &distinct.on_expr {
let sources = referenced_sources(expr, &distinct.input, &child.columns)?;
add_indirect(&mut indirect, sources, IndirectKind::GroupBy);
}
for sort_expr in distinct.sort_expr.iter().flatten() {
let sources = referenced_sources(&sort_expr.expr, &distinct.input, &child.columns)?;
add_indirect(&mut indirect, sources, IndirectKind::Sort);
}
let columns = distinct
.select_expr
.iter()
.map(|expr| expr_sources(expr, &distinct.input, &child, DirectKind::Identity))
.collect::<Option<_>>()?;
NodeLineage { columns, indirect }
}
LogicalPlan::Window(window) => {
let child = resolve(&window.input, config)?;
let mut columns = child.columns.clone();
let mut indirect = child.indirect.clone();
for expr in &window.window_expr {
let inner = peel_aliases(expr);
if let Expr::WindowFunction(wf) = inner {
let mut sources = ColumnSources::default();
for arg in &wf.params.args {
sources.merge(&expr_sources(
arg,
&window.input,
&child,
DirectKind::Aggregation,
)?);
}
for key in wf
.params
.partition_by
.iter()
.chain(wf.params.order_by.iter().map(|s| &s.expr))
{
let refs = referenced_sources(key, &window.input, &child.columns)?;
add_indirect(&mut indirect, refs, IndirectKind::Window);
}
columns.push(sources);
} else {
columns.push(expr_sources(
inner,
&window.input,
&child,
DirectKind::Transformation,
)?);
}
}
NodeLineage { columns, indirect }
}
LogicalPlan::Unnest(unnest) => {
let child = resolve(&unnest.input, config)?;
let unnested: BTreeSet<usize> = unnest
.list_type_columns
.iter()
.map(|(i, _)| *i)
.chain(unnest.struct_type_columns.iter().copied())
.collect();
let columns = unnest
.dependency_indices
.iter()
.map(|&dep| {
let sources = child.columns.get(dep)?.clone();
Some(if unnested.contains(&dep) {
sources.upgraded(DirectKind::Transformation)
} else {
sources
})
})
.collect::<Option<_>>()
.or_else(|| degrade(plan, "unnest dependency index out of range"))?;
NodeLineage {
columns,
indirect: child.indirect,
}
}
LogicalPlan::Values(values) => NodeLineage {
columns: vec![ColumnSources::default(); values.schema.fields().len()],
indirect: IndirectSources::new(),
},
LogicalPlan::EmptyRelation(empty) => NodeLineage {
columns: vec![ColumnSources::default(); empty.schema.fields().len()],
indirect: IndirectSources::new(),
},
other => return degrade(other, "no column lineage resolution for plan node"),
};
if node.columns.len() != plan.schema().fields().len() {
return degrade(plan, "resolved arity does not match node schema");
}
Some(node)
}
fn expr_sources(
expr: &Expr,
child_plan: &LogicalPlan,
child: &NodeLineage,
min: DirectKind,
) -> Option<ColumnSources> {
if has_hidden_sources(expr) {
return degrade(child_plan, "subquery in expression");
}
let inner = peel_aliases(expr);
if let Expr::Column(col) = inner {
let i = child_plan.schema().maybe_index_of_column(col)?;
return Some(child.columns[i].clone().upgraded(min));
}
let mut sources = ColumnSources::default();
for col in inner.column_refs() {
let i = child_plan.schema().maybe_index_of_column(col)?;
sources.merge(&child.columns[i]);
}
Some(sources.upgraded(min.max(DirectKind::Transformation)))
}
fn referenced_sources(
expr: &Expr,
child_plan: &LogicalPlan,
columns: &[ColumnSources],
) -> Option<Vec<SourceColumn>> {
if has_hidden_sources(expr) {
return degrade(child_plan, "subquery in predicate/key expression");
}
let mut out = Vec::new();
for col in expr.column_refs() {
let i = child_plan.schema().maybe_index_of_column(col)?;
out.extend(columns[i].direct.keys().cloned());
}
Some(out)
}
fn add_indirect(indirect: &mut IndirectSources, sources: Vec<SourceColumn>, kind: IndirectKind) {
for source in sources {
indirect.entry(source).or_default().insert(kind);
}
}
fn has_hidden_sources(expr: &Expr) -> bool {
expr.exists(|e| {
Ok(matches!(
e,
Expr::Exists(_)
| Expr::InSubquery(_)
| Expr::ScalarSubquery(_)
| Expr::OuterReferenceColumn(_, _)
))
})
.unwrap_or(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn source(name: &str) -> SourceColumn {
SourceColumn {
dataset: DatasetName {
namespace: "ns".into(),
name: "t".into(),
},
column: name.into(),
}
}
#[test]
fn merge_keeps_strongest_kind() {
let mut a = ColumnSources::single(source("a"), DirectKind::Identity);
let b = ColumnSources::single(source("a"), DirectKind::Aggregation);
a.merge(&b);
assert_eq!(a.direct[&source("a")], DirectKind::Aggregation);
let c = ColumnSources::single(source("a"), DirectKind::Identity);
a.merge(&c);
assert_eq!(a.direct[&source("a")], DirectKind::Aggregation);
}
#[test]
fn upgrade_is_a_floor_not_an_override() {
let s = ColumnSources::single(source("a"), DirectKind::Aggregation)
.upgraded(DirectKind::Transformation);
assert_eq!(s.direct[&source("a")], DirectKind::Aggregation);
let s = ColumnSources::single(source("a"), DirectKind::Identity)
.upgraded(DirectKind::Transformation);
assert_eq!(s.direct[&source("a")], DirectKind::Transformation);
}
}