Skip to main content

datafusion_openlineage/
extract.rs

1//! Extract table-level lineage (input/output datasets + schema) from a
2//! DataFusion [`LogicalPlan`].
3//!
4//! Follows the same `TreeNodeVisitor`-over-`LogicalPlan` shape the Cedar policy
5//! integration uses. Run this on the *optimized* plan so projections/filters are
6//! pushed down to the scans.
7//!
8//! Column-level lineage is resolved separately by [`crate::column`] (a
9//! positional bottom-up walk) and attached to the output datasets here.
10
11use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion, TreeNodeVisitor};
12use datafusion::error::Result;
13use datafusion::logical_expr::dml::InsertOp;
14use datafusion::logical_expr::{DdlStatement, LogicalPlan, WriteOp};
15use datafusion::sql::TableReference;
16
17use crate::column::{ResolvedColumns, resolve_output_columns};
18use crate::config::OpenLineageConfig;
19use crate::facets::{
20    BaseFacet, ColumnLineageDatasetFacet, DataSourceDatasetFacet, DatasetFacets, FieldLineage,
21    InputField, LifecycleStateChangeDatasetFacet, SchemaDatasetFacet, SchemaField, Transformation,
22    TransformationType,
23};
24use crate::naming::DatasetName;
25
26const SCHEMA_FACET: &str = "1-2-0/SchemaDatasetFacet.json";
27const COLUMN_LINEAGE_FACET: &str = "1-2-0/ColumnLineageDatasetFacet.json";
28const DATA_SOURCE_FACET: &str = "1-0-1/DatasourceDatasetFacet.json";
29const LIFECYCLE_FACET: &str = "1-0-1/LifecycleStateChangeDatasetFacet.json";
30
31/// What a query reads and writes.
32#[derive(Debug, Default)]
33pub struct QueryLineage {
34    /// Datasets the query reads from.
35    pub inputs: Vec<InputTable>,
36    /// Datasets the query writes to.
37    pub outputs: Vec<OutputTable>,
38    /// The query's SQL text, when the host supplies it (the plan walk alone
39    /// cannot recover it).
40    pub sql: Option<String>,
41}
42
43/// A dataset a query reads, with its schema.
44#[derive(Debug)]
45pub struct InputTable {
46    /// The OpenLineage dataset identifier.
47    pub name: DatasetName,
48    /// The dataset's full table schema (not the projected scan schema).
49    pub fields: Vec<SchemaField>,
50}
51
52/// A dataset a query writes, with its schema and optional column lineage.
53#[derive(Debug)]
54pub struct OutputTable {
55    /// The OpenLineage dataset identifier.
56    pub name: DatasetName,
57    /// The output table's columns, emitted as a `schema` dataset facet so the
58    /// written table shows its columns in the lineage graph. Empty when the
59    /// writer can't resolve a schema.
60    pub fields: Vec<SchemaField>,
61    /// Output field name -> source columns, when soundly resolvable.
62    pub column_lineage: Option<ResolvedColumns>,
63    /// The `lifecycleStateChange` this write applied (CREATE / OVERWRITE), when
64    /// the write op maps cleanly to a spec enum value. `None` for plain appends,
65    /// updates, and deletes, which have no corresponding state-change value.
66    pub lifecycle: Option<&'static str>,
67}
68
69/// Extract [`QueryLineage`] from an (ideally optimized) logical plan.
70pub fn extract(plan: &LogicalPlan, config: &OpenLineageConfig) -> QueryLineage {
71    let mut visitor = LineageVisitor {
72        config,
73        inputs: Vec::new(),
74        outputs: Vec::new(),
75    };
76    // The visitor never returns an error; ignore the traversal Result.
77    let _ = plan.visit(&mut visitor);
78
79    let mut outputs = visitor.outputs;
80    if !outputs.is_empty()
81        && let Some(resolved) = resolve_output_columns(plan, config)
82    {
83        // A statement writes (at most) one dataset; the resolved root map
84        // describes exactly its fields.
85        for output in &mut outputs {
86            output.column_lineage = Some(resolved.clone());
87        }
88    }
89
90    QueryLineage {
91        inputs: visitor.inputs,
92        outputs,
93        sql: None,
94    }
95}
96
97/// Map a table reference to its OpenLineage dataset name.
98///
99/// A bare TableScan carries only the qualified reference, not a storage
100/// location. Use the qualified name under the configured namespace; the host
101/// integration can enrich with a physical location + symlinks facet. Shared by
102/// the table-level visitor and the column resolver so the two can never
103/// disagree on dataset identity.
104pub(crate) fn dataset_for(table_ref: &TableReference, config: &OpenLineageConfig) -> DatasetName {
105    DatasetName::from_table_ref(&config.job_namespace, &table_ref.to_string())
106}
107
108/// Map a write op to its `lifecycleStateChange` value, or `None` when the op has
109/// no clean spec-enum equivalent (plain append, update, delete).
110///
111/// `CTAS` creates; an overwriting/replacing insert overwrites; a plain
112/// `Insert(Append)` adds rows without a state change, so it carries no facet.
113fn lifecycle_for(op: &WriteOp) -> Option<&'static str> {
114    match op {
115        WriteOp::Ctas => Some("CREATE"),
116        WriteOp::Insert(InsertOp::Overwrite | InsertOp::Replace) => Some("OVERWRITE"),
117        WriteOp::Insert(InsertOp::Append) | WriteOp::Update | WriteOp::Delete => None,
118        WriteOp::Truncate => Some("TRUNCATE"),
119    }
120}
121
122/// The `dataSource` dataset facet for a dataset.
123///
124/// A DataFusion table reference is a logical `catalog.schema.table` identity, not
125/// a storage URL, so the data source is named by the qualified table name with a
126/// synthetic `datafusion:` URI (namespace + name). This is informational — it
127/// records which engine/instance the dataset lives in, mirroring how the DuckDB
128/// integration derives its dataSource from the catalog path.
129fn data_source_facet(name: &DatasetName, config: &OpenLineageConfig) -> DataSourceDatasetFacet {
130    DataSourceDatasetFacet {
131        base: BaseFacet::new(&config.producer, DATA_SOURCE_FACET),
132        name: name.name.clone(),
133        uri: format!("datafusion:{}/{}", name.namespace, name.name),
134    }
135}
136
137/// Map Arrow fields to OpenLineage [`SchemaField`]s (name + type string). Shared
138/// by input scans and output writers so a dataset's schema facet is consistent
139/// however it's produced.
140pub(crate) fn schema_fields(fields: &datafusion::arrow::datatypes::Fields) -> Vec<SchemaField> {
141    fields
142        .iter()
143        .map(|f| SchemaField {
144            name: f.name().to_string(),
145            type_: f.data_type().to_string(),
146            description: None,
147        })
148        .collect()
149}
150
151struct LineageVisitor<'a> {
152    config: &'a OpenLineageConfig,
153    inputs: Vec<InputTable>,
154    outputs: Vec<OutputTable>,
155}
156
157impl LineageVisitor<'_> {
158    fn dataset_for(&self, table_ref: &TableReference) -> DatasetName {
159        dataset_for(table_ref, self.config)
160    }
161}
162
163impl TreeNodeVisitor<'_> for LineageVisitor<'_> {
164    type Node = LogicalPlan;
165
166    fn f_down(&mut self, node: &Self::Node) -> Result<TreeNodeRecursion> {
167        match node {
168            // Skip scans of the `information_schema` virtual catalog: it is
169            // DataFusion's metadata surface, not a real dataset, so reporting it
170            // as a lineage input only adds noise. Treating these scans as
171            // non-inputs is also what lets the planner suppress pure-metadata
172            // queries (no inputs + no outputs => no events).
173            LogicalPlan::TableScan(scan)
174                if scan
175                    .table_name
176                    .schema()
177                    .is_some_and(|s| s.eq_ignore_ascii_case("information_schema")) => {}
178            LogicalPlan::TableScan(scan) => {
179                let dataset = self.dataset_for(&scan.table_name);
180                // Report the *full* table schema, not the projected scan schema:
181                // after projection pushdown `SELECT a FROM t` would otherwise
182                // report `t` as having only column `a`, causing the dataset's
183                // schema version to flap between queries.
184                let fields: Vec<SchemaField> = scan
185                    .source
186                    .schema()
187                    .fields()
188                    .iter()
189                    .map(|f| SchemaField {
190                        name: f.name().to_string(),
191                        type_: f.data_type().to_string(),
192                        description: None,
193                    })
194                    .collect();
195                // Dedupe by `(namespace, name)`: a self-join scans the same
196                // table twice but it is a single input dataset.
197                if !self
198                    .inputs
199                    .iter()
200                    .any(|i| i.name.namespace == dataset.namespace && i.name.name == dataset.name)
201                {
202                    self.inputs.push(InputTable {
203                        name: dataset,
204                        fields,
205                    });
206                }
207            }
208            LogicalPlan::Dml(dml) => match dml.op {
209                WriteOp::Insert(_) | WriteOp::Update | WriteOp::Delete | WriteOp::Ctas => {
210                    self.outputs.push(OutputTable {
211                        name: self.dataset_for(&dml.table_name),
212                        // The write target's full table schema -> the output's columns.
213                        fields: schema_fields(dml.target.schema().fields()),
214                        column_lineage: None,
215                        lifecycle: lifecycle_for(&dml.op),
216                    });
217                }
218                WriteOp::Truncate => {}
219            },
220            LogicalPlan::Ddl(ddl) => match ddl {
221                DdlStatement::CreateExternalTable(cmd) => {
222                    self.outputs.push(OutputTable {
223                        name: self.dataset_for(&cmd.name),
224                        fields: schema_fields(cmd.schema.as_arrow().fields()),
225                        column_lineage: None,
226                        lifecycle: Some("CREATE"),
227                    });
228                }
229                // `CREATE TABLE ... AS SELECT` lowers to CreateMemoryTable; the
230                // new table is the output dataset (the SELECT's scans are its
231                // inputs, picked up by the TableScan arm).
232                DdlStatement::CreateMemoryTable(cmd) => {
233                    self.outputs.push(OutputTable {
234                        name: self.dataset_for(&cmd.name),
235                        fields: schema_fields(cmd.input.schema().as_arrow().fields()),
236                        column_lineage: None,
237                        lifecycle: Some("CREATE"),
238                    });
239                }
240                _ => {}
241            },
242            // Nodes we don't yet derive lineage from. Warn so coverage gaps are
243            // visible rather than silently dropped.
244            other => {
245                tracing::trace!(
246                    target: "openlineage",
247                    node = other.display().to_string(),
248                    "no lineage extraction for plan node"
249                );
250            }
251        }
252        Ok(TreeNodeRecursion::Continue)
253    }
254}
255
256/// Build the [`DatasetFacets`] for an input table: its schema facet.
257///
258/// Column lineage never appears on inputs — the spec defines the facet on
259/// output datasets, keyed by output field.
260pub(crate) fn input_dataset_facets(
261    input: &InputTable,
262    config: &OpenLineageConfig,
263) -> DatasetFacets {
264    let schema = SchemaDatasetFacet {
265        base: BaseFacet::new(&config.producer, SCHEMA_FACET),
266        fields: input.fields.clone(),
267    };
268
269    DatasetFacets {
270        schema: Some(schema),
271        data_source: Some(data_source_facet(&input.name, config)),
272        ..Default::default()
273    }
274}
275
276/// Build the [`DatasetFacets`] for an output table: its column-lineage facet,
277/// when the resolution produced one.
278///
279/// Each output field lists its direct sources; the statement-wide indirect
280/// influences (filter/join/group/sort keys) are appended to every field's
281/// `inputFields`, matching how the OpenLineage Spark integration emits them.
282pub(crate) fn output_dataset_facets(
283    output: &OutputTable,
284    config: &OpenLineageConfig,
285) -> DatasetFacets {
286    // Schema facet: emit the output table's columns (when known) so the written
287    // dataset shows its columns in the graph — independent of whether column
288    // lineage resolved.
289    let schema = (!output.fields.is_empty()).then(|| SchemaDatasetFacet {
290        base: BaseFacet::new(&config.producer, SCHEMA_FACET),
291        fields: output.fields.clone(),
292    });
293
294    // dataSource + lifecycleStateChange ride along on every output regardless of
295    // whether column lineage resolved, so build them once.
296    let data_source = Some(data_source_facet(&output.name, config));
297    let lifecycle_state_change = output
298        .lifecycle
299        .map(|state| LifecycleStateChangeDatasetFacet {
300            base: BaseFacet::new(&config.producer, LIFECYCLE_FACET),
301            lifecycle_state_change: state.to_string(),
302        });
303
304    let Some(resolved) = &output.column_lineage else {
305        return DatasetFacets {
306            schema,
307            data_source,
308            lifecycle_state_change,
309            ..Default::default()
310        };
311    };
312    if resolved.fields.is_empty() {
313        // No column lineage resolvable (e.g. all-literal INSERT / bulk ingest):
314        // still emit the schema facet so the dataset carries its columns.
315        return DatasetFacets {
316            schema,
317            data_source,
318            lifecycle_state_change,
319            ..Default::default()
320        };
321    }
322
323    let direct = |subtype: &str| Transformation {
324        type_: TransformationType::Direct,
325        subtype: Some(subtype.to_string()),
326        description: String::new(),
327        masking: false,
328    };
329    let indirect = |subtype: &str| Transformation {
330        type_: TransformationType::Indirect,
331        ..direct(subtype)
332    };
333
334    let fields = resolved
335        .fields
336        .iter()
337        .map(|(field, sources)| {
338            // One InputField per source, carrying its direct transformation
339            // plus any statement-wide indirect influences on the same source.
340            let mut input_fields: Vec<InputField> = sources
341                .direct
342                .iter()
343                .map(|(source, kind)| {
344                    let mut transformations = vec![direct(kind.subtype())];
345                    if let Some(kinds) = resolved.indirect.get(source) {
346                        transformations.extend(kinds.iter().map(|k| indirect(k.subtype())));
347                    }
348                    InputField {
349                        namespace: source.dataset.namespace.clone(),
350                        name: source.dataset.name.clone(),
351                        field: Some(source.column.clone()),
352                        transformations,
353                    }
354                })
355                .collect();
356            // Indirect-only sources (e.g. a filter column the output never
357            // carries) still influence every output field.
358            input_fields.extend(
359                resolved
360                    .indirect
361                    .iter()
362                    .filter(|(source, _)| !sources.direct.contains_key(source))
363                    .map(|(source, kinds)| InputField {
364                        namespace: source.dataset.namespace.clone(),
365                        name: source.dataset.name.clone(),
366                        field: Some(source.column.clone()),
367                        transformations: kinds.iter().map(|k| indirect(k.subtype())).collect(),
368                    }),
369            );
370            (field.clone(), FieldLineage { input_fields })
371        })
372        .collect();
373
374    DatasetFacets {
375        schema,
376        data_source,
377        lifecycle_state_change,
378        column_lineage: Some(ColumnLineageDatasetFacet {
379            base: BaseFacet::new(&config.producer, COLUMN_LINEAGE_FACET),
380            fields,
381            dataset: Vec::new(),
382        }),
383        ..Default::default()
384    }
385}