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::{DdlStatement, Distinct, Expr, JoinType, LogicalPlan, WriteOp};
25
26use crate::config::OpenLineageConfig;
27use crate::extract::dataset_for;
28use crate::naming::DatasetName;
29
30/// A physical source column in a real input dataset.
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
32pub struct SourceColumn {
33    /// The input dataset the column lives in.
34    pub dataset: DatasetName,
35    /// The physical column name within that dataset.
36    pub column: String,
37}
38
39/// How directly a source value feeds an output column. Ordered so that merging
40/// two paths to the same source keeps the strongest claim.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42pub enum DirectKind {
43    /// The source value flows through unchanged.
44    Identity,
45    /// The source value is transformed before reaching the output.
46    Transformation,
47    /// The source value is consumed by an aggregate function.
48    Aggregation,
49}
50
51impl DirectKind {
52    /// Returns the OpenLineage transformation-type subtype string for this kind.
53    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/// How a source influences output rows without its value flowing into them.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
64pub enum IndirectKind {
65    /// The source appears in a filter predicate.
66    Filter,
67    /// The source appears in a join condition.
68    Join,
69    /// The source appears in a group-by key.
70    GroupBy,
71    /// The source appears in a sort key.
72    Sort,
73    /// The source appears in a window partition or order key.
74    Window,
75}
76
77impl IndirectKind {
78    /// Returns the OpenLineage transformation-type subtype string for this kind.
79    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/// The direct sources of one output column.
91#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct ColumnSources {
93    /// Source -> strongest [`DirectKind`] seen on any path.
94    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    /// All kinds upgraded to at least `min` (e.g. a column passing through an
114    /// aggregate expression is an `Aggregation` source even if it entered as
115    /// `Identity`).
116    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
124/// Per-source indirect influences; unions upward through the plan.
125pub type IndirectSources = BTreeMap<SourceColumn, BTreeSet<IndirectKind>>;
126
127/// Resolved column lineage for one plan node's output.
128///
129/// Internal to the bottom-up resolution; the crate-public result is
130/// [`ResolvedColumns`].
131#[derive(Debug, Clone, Default)]
132pub(crate) struct NodeLineage {
133    /// One entry per output schema field, positionally aligned with the node's
134    /// [`DFSchema`](datafusion::common::DFSchema).
135    pub columns: Vec<ColumnSources>,
136    /// Influences gathered at this node and below (filter predicates, join
137    /// keys, group keys, sort keys). At the root they apply to every output.
138    pub indirect: IndirectSources,
139}
140
141/// Column lineage for the dataset a root `Dml`/`Ddl` plan writes: output field
142/// name -> sources, plus the statement-wide indirect influences.
143///
144/// `None` when the plan writes nothing (pure SELECT — per spec the facet lives
145/// on output datasets) or when resolution degraded.
146#[derive(Debug, Clone, Default)]
147pub struct ResolvedColumns {
148    /// Output field name -> its direct sources.
149    pub fields: BTreeMap<String, ColumnSources>,
150    /// Statement-wide indirect influences, applying to every output field.
151    pub indirect: IndirectSources,
152}
153
154/// Resolve the column lineage of the dataset written by `plan`.
155pub(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            // The SQL planner aligns the DML input positionally with the
162            // target table schema (one projection expr per target column);
163            // key the resolved map by the target's field names. Degrade on
164            // arity mismatch — non-SQL plan builders carry no such guarantee.
165            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            // No column values are written.
174            WriteOp::Delete | WriteOp::Truncate => None,
175        },
176        // `CREATE TABLE ... AS SELECT` lowers to CreateMemoryTable; the new
177        // table's fields are exactly the query's output fields.
178        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        // CreateExternalTable has no query input; everything else writes no
187        // dataset, so there is no output to attach the facet to.
188        _ => 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        // Columns with no sources (literals, defaults filled in by the
198        // planner) are omitted: an empty `inputFields` says nothing.
199        .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
218/// Bottom-up positional resolution of a relational plan node.
219fn resolve(plan: &LogicalPlan, config: &OpenLineageConfig) -> Option<NodeLineage> {
220    let node = match plan {
221        LogicalPlan::TableScan(scan) => {
222            // information_schema is DataFusion's metadata surface, not a real
223            // dataset (mirrors the table-level extraction).
224            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            // Map each projected output position back to the *source* schema
234            // position, reporting the physical column name.
235            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            // Filters pushed into the scan may reference unprojected columns,
248            // so resolve them by name against the full source schema.
249            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        // A positional re-qualification of the input: same columns, new
284        // qualifier. The alias name is never consulted, so it cannot
285        // fabricate a dataset.
286        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                // Group keys shape the output rows beyond carrying values.
303                add_indirect(
304                    &mut indirect,
305                    sources.direct.keys().cloned().collect(),
306                    IndirectKind::GroupBy,
307                );
308                columns.push(sources);
309            }
310            // Grouping sets add a synthetic `__grouping_id` field after the
311            // group columns; it derives from no source value.
312            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                // The residual filter references columns from either side.
347                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            // The mark column a mark join appends flags subquery matches; it
366            // carries no source value.
367            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        // Skip/fetch are literals; row selection by position is not a
408        // column-level influence.
409        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                    // Unexpected shape: treat every ref as a transformation
463                    // source — coarser, still sound.
464                    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        // Literal rows soundly have no provenance — this is not a degradation.
503        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        // After optimization a surviving Subquery means decorrelation failed;
513        // Extension nodes are opaque; a recursive CTE's work-table scan would
514        // fabricate a dataset from the CTE's own name. Degrade rather than
515        // guess.
516        other => return degrade(other, "no column lineage resolution for plan node"),
517    };
518
519    // Positional indexing only works if every handler produced exactly one
520    // entry per output field.
521    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
527/// The sources of one projected expression, resolved against the child's map.
528///
529/// A bare column (through any aliases) copies its child sources unchanged, so
530/// identity provenance survives stacked projections; any other expression
531/// upgrades every referenced source to at least `Transformation`. `min` raises
532/// that floor further (`Aggregation` for aggregate/window arguments).
533fn 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
558/// The physical sources behind every column an expression references —
559/// used for indirect influences (predicates, keys), where the expression's
560/// shape doesn't matter, only which sources it touches.
561fn 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
583/// `column_refs` silently misses sources hidden inside subquery expressions;
584/// treat their presence as unresolvable.
585fn 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        // And merging the weaker kind back doesn't downgrade.
619        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}