Skip to main content

datafusion_openlineage/
column.rs

1//! Sound column-level lineage extraction from a DataFusion [`LogicalPlan`].
2//!
3//! Walks the *optimized* plan bottom-up, maintaining for every node a map of
4//! *output schema position -> set of physical `(dataset, column)` sources*.
5//! Indexing by position (never by name) is what makes the resolution
6//! scope-sound: column refs in expressions resolve to child positions via
7//! `DFSchema::maybe_index_of_column`, so qualifiers participate exactly as
8//! DataFusion's own scoping does and aliases/CTEs/self-joins can neither
9//! collide nor fabricate datasets. Only the root node's map is published, and
10//! the facet is attached to the **output** datasets, keyed by output field
11//! (see `docs/open-lineage-design.md`).
12//!
13//! Degradation policy: any unhandled node, arity mismatch, unresolvable column
14//! ref, or expression embedding a subquery drops column lineage for the whole
15//! statement (`None`). A partially-correct per-column facet is
16//! indistinguishable from a complete one to consumers, so whole-facet drop is
17//! the only honest partial failure. There is deliberately no name-based
18//! fallback. Table-level lineage is unaffected.
19
20use std::collections::{BTreeMap, BTreeSet};
21
22use datafusion::common::tree_node::TreeNode;
23use datafusion::logical_expr::utils::grouping_set_to_exprlist;
24use datafusion::logical_expr::{
25    CreateMemoryTable, CreateView, DdlStatement, Distinct, Expr, JoinType, LogicalPlan, WriteOp,
26};
27
28use crate::config::OpenLineageConfig;
29use crate::extract::dataset_for;
30use crate::naming::DatasetName;
31
32/// A physical source column in a real input dataset.
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
34pub struct SourceColumn {
35    /// The input dataset the column lives in.
36    pub dataset: DatasetName,
37    /// The physical column name within that dataset.
38    pub column: String,
39}
40
41/// How directly a source value feeds an output column. Ordered so that merging
42/// two paths to the same source keeps the strongest claim.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
44pub enum DirectKind {
45    /// The source value flows through unchanged.
46    Identity,
47    /// The source value is transformed before reaching the output.
48    Transformation,
49    /// The source value is consumed by an aggregate function.
50    Aggregation,
51}
52
53impl DirectKind {
54    /// Returns the OpenLineage transformation-type subtype string for this kind.
55    pub fn subtype(self) -> &'static str {
56        match self {
57            DirectKind::Identity => "IDENTITY",
58            DirectKind::Transformation => "TRANSFORMATION",
59            DirectKind::Aggregation => "AGGREGATION",
60        }
61    }
62}
63
64/// How a source influences output rows without its value flowing into them.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
66pub enum IndirectKind {
67    /// The source appears in a filter predicate.
68    Filter,
69    /// The source appears in a join condition.
70    Join,
71    /// The source appears in a group-by key.
72    GroupBy,
73    /// The source appears in a sort key.
74    Sort,
75    /// The source appears in a window partition or order key.
76    Window,
77}
78
79impl IndirectKind {
80    /// Returns the OpenLineage transformation-type subtype string for this kind.
81    pub fn subtype(self) -> &'static str {
82        match self {
83            IndirectKind::Filter => "FILTER",
84            IndirectKind::Join => "JOIN",
85            IndirectKind::GroupBy => "GROUP_BY",
86            IndirectKind::Sort => "SORT",
87            IndirectKind::Window => "WINDOW",
88        }
89    }
90}
91
92/// The direct sources of one output column.
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct ColumnSources {
95    /// Source -> strongest [`DirectKind`] seen on any path.
96    pub direct: BTreeMap<SourceColumn, DirectKind>,
97}
98
99impl ColumnSources {
100    fn single(source: SourceColumn, kind: DirectKind) -> Self {
101        Self {
102            direct: BTreeMap::from([(source, kind)]),
103        }
104    }
105
106    fn merge(&mut self, other: &ColumnSources) {
107        for (source, kind) in &other.direct {
108            self.direct
109                .entry(source.clone())
110                .and_modify(|k| *k = (*k).max(*kind))
111                .or_insert(*kind);
112        }
113    }
114
115    /// All kinds upgraded to at least `min` (e.g. a column passing through an
116    /// aggregate expression is an `Aggregation` source even if it entered as
117    /// `Identity`).
118    fn upgraded(mut self, min: DirectKind) -> Self {
119        for kind in self.direct.values_mut() {
120            *kind = (*kind).max(min);
121        }
122        self
123    }
124}
125
126/// Per-source indirect influences; unions upward through the plan.
127pub type IndirectSources = BTreeMap<SourceColumn, BTreeSet<IndirectKind>>;
128
129/// Resolved column lineage for one plan node's output.
130///
131/// Internal to the bottom-up resolution; the crate-public result is
132/// [`ResolvedColumns`].
133#[derive(Debug, Clone, Default)]
134pub(crate) struct NodeLineage {
135    /// One entry per output schema field, positionally aligned with the node's
136    /// [`DFSchema`](datafusion::common::DFSchema).
137    pub columns: Vec<ColumnSources>,
138    /// Influences gathered at this node and below (filter predicates, join
139    /// keys, group keys, sort keys). At the root they apply to every output.
140    pub indirect: IndirectSources,
141}
142
143/// Column lineage for the dataset a root `Dml`/`Ddl` plan writes: output field
144/// name -> sources, plus the statement-wide indirect influences.
145///
146/// `None` when the plan writes nothing (pure SELECT — per spec the facet lives
147/// on output datasets) or when resolution degraded.
148#[derive(Debug, Clone, Default)]
149pub struct ResolvedColumns {
150    /// Output field name -> its direct sources.
151    pub fields: BTreeMap<String, ColumnSources>,
152    /// Statement-wide indirect influences, applying to every output field.
153    pub indirect: IndirectSources,
154}
155
156/// Resolve the column lineage of the dataset written by `plan`.
157pub(crate) fn resolve_output_columns(
158    plan: &LogicalPlan,
159    config: &OpenLineageConfig,
160) -> Option<ResolvedColumns> {
161    match plan {
162        LogicalPlan::Dml(dml) => match dml.op {
163            // The SQL planner aligns the DML input positionally with the
164            // target table schema (one projection expr per target column);
165            // key the resolved map by the target's field names. Degrade on
166            // arity mismatch — non-SQL plan builders carry no such guarantee.
167            WriteOp::Insert(_) | WriteOp::Ctas | WriteOp::Update => {
168                let resolved = resolve(&dml.input, config)?;
169                let target = dml.target.schema();
170                if resolved.columns.len() != target.fields().len() {
171                    return degrade(plan, "DML input not aligned with target schema");
172                }
173                Some(keyed_by(resolved, |i| target.field(i).name().clone()))
174            }
175            // No column values are written.
176            WriteOp::Delete | WriteOp::Truncate => None,
177        },
178        // `CREATE TABLE ... AS SELECT` lowers to CreateMemoryTable; the new
179        // table's fields are exactly the query's output fields. `CREATE VIEW`
180        // is structurally identical (a named plan over a SELECT input).
181        LogicalPlan::Ddl(
182            DdlStatement::CreateMemoryTable(CreateMemoryTable { input, .. })
183            | DdlStatement::CreateView(CreateView { input, .. }),
184        ) => {
185            let resolved = resolve(input, config)?;
186            let schema = input.schema().clone();
187            if resolved.columns.len() != schema.fields().len() {
188                return degrade(plan, "CTAS/view input arity mismatch");
189            }
190            Some(keyed_by(resolved, |i| schema.field(i).name().clone()))
191        }
192        // CreateExternalTable has no query input; everything else writes no
193        // dataset, so there is no output to attach the facet to.
194        _ => None,
195    }
196}
197
198fn keyed_by(node: NodeLineage, name: impl Fn(usize) -> String) -> ResolvedColumns {
199    let fields = node
200        .columns
201        .into_iter()
202        .enumerate()
203        // Columns with no sources (literals, defaults filled in by the
204        // planner) are omitted: an empty `inputFields` says nothing.
205        .filter(|(_, sources)| !sources.direct.is_empty())
206        .map(|(i, sources)| (name(i), sources))
207        .collect();
208    ResolvedColumns {
209        fields,
210        indirect: node.indirect,
211    }
212}
213
214/// Strip any layers of [`Expr::Alias`] to reach the underlying expression.
215///
216/// `Expr::unalias` consumes `self` and clones, so this borrowing form is used
217/// where only a reference to the inner expression is needed.
218fn peel_aliases(expr: &Expr) -> &Expr {
219    let mut inner = expr;
220    while let Expr::Alias(alias) = inner {
221        inner = &alias.expr;
222    }
223    inner
224}
225
226fn degrade<T>(plan: &LogicalPlan, reason: &str) -> Option<T> {
227    tracing::debug!(
228        target: "openlineage",
229        node = %plan.display(),
230        reason,
231        "column lineage degraded"
232    );
233    None
234}
235
236/// Bottom-up positional resolution of a relational plan node.
237fn resolve(plan: &LogicalPlan, config: &OpenLineageConfig) -> Option<NodeLineage> {
238    let node = match plan {
239        LogicalPlan::TableScan(scan) => {
240            // information_schema is DataFusion's metadata surface, not a real
241            // dataset (mirrors the table-level extraction).
242            if scan
243                .table_name
244                .schema()
245                .is_some_and(|s| s.eq_ignore_ascii_case("information_schema"))
246            {
247                return degrade(plan, "information_schema scan");
248            }
249            let dataset = dataset_for(&scan.table_name, config);
250            let source_schema = scan.source.schema();
251            // Map each projected output position back to the *source* schema
252            // position, reporting the physical column name.
253            let columns = (0..scan.projected_schema.fields().len())
254                .map(|i| {
255                    let src = scan.projection.as_ref().map_or(i, |p| p[i]);
256                    ColumnSources::single(
257                        SourceColumn {
258                            dataset: dataset.clone(),
259                            column: source_schema.field(src).name().clone(),
260                        },
261                        DirectKind::Identity,
262                    )
263                })
264                .collect();
265            // Filters pushed into the scan may reference unprojected columns,
266            // so resolve them by name against the full source schema.
267            let mut indirect = IndirectSources::new();
268            for filter in &scan.filters {
269                if has_hidden_sources(filter) {
270                    return degrade(plan, "subquery in pushed-down filter");
271                }
272                for col in filter.column_refs() {
273                    if source_schema.field_with_name(&col.name).is_err() {
274                        return degrade(plan, "pushed-down filter column not in source schema");
275                    }
276                    indirect
277                        .entry(SourceColumn {
278                            dataset: dataset.clone(),
279                            column: col.name.clone(),
280                        })
281                        .or_default()
282                        .insert(IndirectKind::Filter);
283                }
284            }
285            NodeLineage { columns, indirect }
286        }
287
288        LogicalPlan::Projection(proj) => {
289            let child = resolve(&proj.input, config)?;
290            let columns = proj
291                .expr
292                .iter()
293                .map(|expr| expr_sources(expr, &proj.input, &child, DirectKind::Identity))
294                .collect::<Option<_>>()?;
295            NodeLineage {
296                columns,
297                indirect: child.indirect,
298            }
299        }
300
301        // A positional re-qualification of the input: same columns, new
302        // qualifier. The alias name is never consulted, so it cannot
303        // fabricate a dataset.
304        LogicalPlan::SubqueryAlias(alias) => resolve(&alias.input, config)?,
305
306        LogicalPlan::Filter(filter) => {
307            let mut child = resolve(&filter.input, config)?;
308            let sources = referenced_sources(&filter.predicate, &filter.input, &child.columns)?;
309            add_indirect(&mut child.indirect, sources, IndirectKind::Filter);
310            child
311        }
312
313        LogicalPlan::Aggregate(agg) => {
314            let child = resolve(&agg.input, config)?;
315            let group_list = grouping_set_to_exprlist(&agg.group_expr).ok()?;
316            let mut columns = Vec::with_capacity(agg.schema.fields().len());
317            let mut indirect = child.indirect.clone();
318            for expr in &group_list {
319                let sources = expr_sources(expr, &agg.input, &child, DirectKind::Identity)?;
320                // Group keys shape the output rows beyond carrying values.
321                add_indirect(
322                    &mut indirect,
323                    sources.direct.keys().cloned().collect(),
324                    IndirectKind::GroupBy,
325                );
326                columns.push(sources);
327            }
328            // Grouping sets add a synthetic `__grouping_id` field after the
329            // group columns; it derives from no source value.
330            if matches!(agg.group_expr.as_slice(), [Expr::GroupingSet(_)]) {
331                columns.push(ColumnSources::default());
332            }
333            for expr in &agg.aggr_expr {
334                columns.push(expr_sources(
335                    expr,
336                    &agg.input,
337                    &child,
338                    DirectKind::Aggregation,
339                )?);
340            }
341            NodeLineage { columns, indirect }
342        }
343
344        LogicalPlan::Join(join) => {
345            let left = resolve(&join.left, config)?;
346            let right = resolve(&join.right, config)?;
347            let mut indirect = left.indirect.clone();
348            for (source, kinds) in &right.indirect {
349                indirect.entry(source.clone()).or_default().extend(kinds);
350            }
351            for (l, r) in &join.on {
352                add_indirect(
353                    &mut indirect,
354                    referenced_sources(l, &join.left, &left.columns)?,
355                    IndirectKind::Join,
356                );
357                add_indirect(
358                    &mut indirect,
359                    referenced_sources(r, &join.right, &right.columns)?,
360                    IndirectKind::Join,
361                );
362            }
363            if let Some(filter) = &join.filter {
364                // The residual filter references columns from either side.
365                if has_hidden_sources(filter) {
366                    return degrade(plan, "subquery in join filter");
367                }
368                for col in filter.column_refs() {
369                    let sources = if let Some(i) = join.left.schema().maybe_index_of_column(col) {
370                        &left.columns[i]
371                    } else if let Some(i) = join.right.schema().maybe_index_of_column(col) {
372                        &right.columns[i]
373                    } else {
374                        return degrade(plan, "join filter column not in either side");
375                    };
376                    add_indirect(
377                        &mut indirect,
378                        sources.direct.keys().cloned().collect(),
379                        IndirectKind::Join,
380                    );
381                }
382            }
383            // The mark column a mark join appends flags subquery matches; it
384            // carries no source value.
385            let mark = std::iter::once(ColumnSources::default());
386            let columns: Vec<ColumnSources> = match join.join_type {
387                JoinType::Inner | JoinType::Left | JoinType::Right | JoinType::Full => {
388                    left.columns.into_iter().chain(right.columns).collect()
389                }
390                JoinType::LeftSemi | JoinType::LeftAnti => left.columns,
391                JoinType::RightSemi | JoinType::RightAnti => right.columns,
392                JoinType::LeftMark => left.columns.into_iter().chain(mark).collect(),
393                JoinType::RightMark => right.columns.into_iter().chain(mark).collect(),
394            };
395            NodeLineage { columns, indirect }
396        }
397
398        LogicalPlan::Union(union) => {
399            let mut columns = vec![ColumnSources::default(); union.schema.fields().len()];
400            let mut indirect = IndirectSources::new();
401            for input in &union.inputs {
402                let child = resolve(input, config)?;
403                if child.columns.len() != columns.len() {
404                    return degrade(plan, "union input arity mismatch");
405                }
406                for (merged, sources) in columns.iter_mut().zip(&child.columns) {
407                    merged.merge(sources);
408                }
409                for (source, kinds) in &child.indirect {
410                    indirect.entry(source.clone()).or_default().extend(kinds);
411                }
412            }
413            NodeLineage { columns, indirect }
414        }
415
416        LogicalPlan::Sort(sort) => {
417            let mut child = resolve(&sort.input, config)?;
418            for sort_expr in &sort.expr {
419                let sources = referenced_sources(&sort_expr.expr, &sort.input, &child.columns)?;
420                add_indirect(&mut child.indirect, sources, IndirectKind::Sort);
421            }
422            child
423        }
424
425        // Skip/fetch are literals; row selection by position is not a
426        // column-level influence.
427        LogicalPlan::Limit(limit) => resolve(&limit.input, config)?,
428        LogicalPlan::Repartition(repartition) => resolve(&repartition.input, config)?,
429
430        LogicalPlan::Distinct(Distinct::All(input)) => resolve(input, config)?,
431        LogicalPlan::Distinct(Distinct::On(distinct)) => {
432            let child = resolve(&distinct.input, config)?;
433            let mut indirect = child.indirect.clone();
434            for expr in &distinct.on_expr {
435                let sources = referenced_sources(expr, &distinct.input, &child.columns)?;
436                add_indirect(&mut indirect, sources, IndirectKind::GroupBy);
437            }
438            for sort_expr in distinct.sort_expr.iter().flatten() {
439                let sources = referenced_sources(&sort_expr.expr, &distinct.input, &child.columns)?;
440                add_indirect(&mut indirect, sources, IndirectKind::Sort);
441            }
442            let columns = distinct
443                .select_expr
444                .iter()
445                .map(|expr| expr_sources(expr, &distinct.input, &child, DirectKind::Identity))
446                .collect::<Option<_>>()?;
447            NodeLineage { columns, indirect }
448        }
449
450        LogicalPlan::Window(window) => {
451            let child = resolve(&window.input, config)?;
452            let mut columns = child.columns.clone();
453            let mut indirect = child.indirect.clone();
454            for expr in &window.window_expr {
455                let inner = peel_aliases(expr);
456                if let Expr::WindowFunction(wf) = inner {
457                    let mut sources = ColumnSources::default();
458                    for arg in &wf.params.args {
459                        sources.merge(&expr_sources(
460                            arg,
461                            &window.input,
462                            &child,
463                            DirectKind::Aggregation,
464                        )?);
465                    }
466                    for key in wf
467                        .params
468                        .partition_by
469                        .iter()
470                        .chain(wf.params.order_by.iter().map(|s| &s.expr))
471                    {
472                        let refs = referenced_sources(key, &window.input, &child.columns)?;
473                        add_indirect(&mut indirect, refs, IndirectKind::Window);
474                    }
475                    columns.push(sources);
476                } else {
477                    // Unexpected shape: treat every ref as a transformation
478                    // source — coarser, still sound.
479                    columns.push(expr_sources(
480                        inner,
481                        &window.input,
482                        &child,
483                        DirectKind::Transformation,
484                    )?);
485                }
486            }
487            NodeLineage { columns, indirect }
488        }
489
490        LogicalPlan::Unnest(unnest) => {
491            let child = resolve(&unnest.input, config)?;
492            let unnested: BTreeSet<usize> = unnest
493                .list_type_columns
494                .iter()
495                .map(|(i, _)| *i)
496                .chain(unnest.struct_type_columns.iter().copied())
497                .collect();
498            let columns = unnest
499                .dependency_indices
500                .iter()
501                .map(|&dep| {
502                    let sources = child.columns.get(dep)?.clone();
503                    Some(if unnested.contains(&dep) {
504                        sources.upgraded(DirectKind::Transformation)
505                    } else {
506                        sources
507                    })
508                })
509                .collect::<Option<_>>()
510                .or_else(|| degrade(plan, "unnest dependency index out of range"))?;
511            NodeLineage {
512                columns,
513                indirect: child.indirect,
514            }
515        }
516
517        // Literal rows soundly have no provenance — this is not a degradation.
518        LogicalPlan::Values(values) => NodeLineage {
519            columns: vec![ColumnSources::default(); values.schema.fields().len()],
520            indirect: IndirectSources::new(),
521        },
522        LogicalPlan::EmptyRelation(empty) => NodeLineage {
523            columns: vec![ColumnSources::default(); empty.schema.fields().len()],
524            indirect: IndirectSources::new(),
525        },
526
527        // After optimization a surviving Subquery means decorrelation failed;
528        // Extension nodes are opaque; a recursive CTE's work-table scan would
529        // fabricate a dataset from the CTE's own name. Degrade rather than
530        // guess.
531        other => return degrade(other, "no column lineage resolution for plan node"),
532    };
533
534    // Positional indexing only works if every handler produced exactly one
535    // entry per output field.
536    if node.columns.len() != plan.schema().fields().len() {
537        return degrade(plan, "resolved arity does not match node schema");
538    }
539    Some(node)
540}
541
542/// The sources of one projected expression, resolved against the child's map.
543///
544/// A bare column (through any aliases) copies its child sources unchanged, so
545/// identity provenance survives stacked projections; any other expression
546/// upgrades every referenced source to at least `Transformation`. `min` raises
547/// that floor further (`Aggregation` for aggregate/window arguments).
548fn expr_sources(
549    expr: &Expr,
550    child_plan: &LogicalPlan,
551    child: &NodeLineage,
552    min: DirectKind,
553) -> Option<ColumnSources> {
554    if has_hidden_sources(expr) {
555        return degrade(child_plan, "subquery in expression");
556    }
557    let inner = peel_aliases(expr);
558    if let Expr::Column(col) = inner {
559        let i = child_plan.schema().maybe_index_of_column(col)?;
560        return Some(child.columns[i].clone().upgraded(min));
561    }
562    let mut sources = ColumnSources::default();
563    for col in inner.column_refs() {
564        let i = child_plan.schema().maybe_index_of_column(col)?;
565        sources.merge(&child.columns[i]);
566    }
567    Some(sources.upgraded(min.max(DirectKind::Transformation)))
568}
569
570/// The physical sources behind every column an expression references —
571/// used for indirect influences (predicates, keys), where the expression's
572/// shape doesn't matter, only which sources it touches.
573fn referenced_sources(
574    expr: &Expr,
575    child_plan: &LogicalPlan,
576    columns: &[ColumnSources],
577) -> Option<Vec<SourceColumn>> {
578    if has_hidden_sources(expr) {
579        return degrade(child_plan, "subquery in predicate/key expression");
580    }
581    let mut out = Vec::new();
582    for col in expr.column_refs() {
583        let i = child_plan.schema().maybe_index_of_column(col)?;
584        out.extend(columns[i].direct.keys().cloned());
585    }
586    Some(out)
587}
588
589fn add_indirect(indirect: &mut IndirectSources, sources: Vec<SourceColumn>, kind: IndirectKind) {
590    for source in sources {
591        indirect.entry(source).or_default().insert(kind);
592    }
593}
594
595/// `column_refs` silently misses sources hidden inside subquery expressions;
596/// treat their presence as unresolvable.
597fn has_hidden_sources(expr: &Expr) -> bool {
598    expr.exists(|e| {
599        Ok(matches!(
600            e,
601            Expr::Exists(_)
602                | Expr::InSubquery(_)
603                | Expr::ScalarSubquery(_)
604                | Expr::OuterReferenceColumn(_, _)
605        ))
606    })
607    .unwrap_or(true)
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    fn source(name: &str) -> SourceColumn {
615        SourceColumn {
616            dataset: DatasetName {
617                namespace: "ns".into(),
618                name: "t".into(),
619            },
620            column: name.into(),
621        }
622    }
623
624    #[test]
625    fn merge_keeps_strongest_kind() {
626        let mut a = ColumnSources::single(source("a"), DirectKind::Identity);
627        let b = ColumnSources::single(source("a"), DirectKind::Aggregation);
628        a.merge(&b);
629        assert_eq!(a.direct[&source("a")], DirectKind::Aggregation);
630        // And merging the weaker kind back doesn't downgrade.
631        let c = ColumnSources::single(source("a"), DirectKind::Identity);
632        a.merge(&c);
633        assert_eq!(a.direct[&source("a")], DirectKind::Aggregation);
634    }
635
636    #[test]
637    fn upgrade_is_a_floor_not_an_override() {
638        let s = ColumnSources::single(source("a"), DirectKind::Aggregation)
639            .upgraded(DirectKind::Transformation);
640        assert_eq!(s.direct[&source("a")], DirectKind::Aggregation);
641        let s = ColumnSources::single(source("a"), DirectKind::Identity)
642            .upgraded(DirectKind::Transformation);
643        assert_eq!(s.direct[&source("a")], DirectKind::Transformation);
644    }
645}