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 = schema_fields(scan.source.schema().fields());
185                // Dedupe by dataset identity: a self-join scans the same table
186                // twice but it is a single input dataset.
187                if !self.inputs.iter().any(|i| i.name == dataset) {
188                    self.inputs.push(InputTable {
189                        name: dataset,
190                        fields,
191                    });
192                }
193            }
194            LogicalPlan::Dml(dml) => match dml.op {
195                WriteOp::Insert(_) | WriteOp::Update | WriteOp::Delete | WriteOp::Ctas => {
196                    self.outputs.push(OutputTable {
197                        name: self.dataset_for(&dml.table_name),
198                        // The write target's full table schema -> the output's columns.
199                        fields: schema_fields(dml.target.schema().fields()),
200                        column_lineage: None,
201                        lifecycle: lifecycle_for(&dml.op),
202                    });
203                }
204                WriteOp::Truncate => {}
205            },
206            LogicalPlan::Ddl(ddl) => match ddl {
207                DdlStatement::CreateExternalTable(cmd) => {
208                    self.outputs.push(OutputTable {
209                        name: self.dataset_for(&cmd.name),
210                        fields: schema_fields(cmd.schema.as_arrow().fields()),
211                        column_lineage: None,
212                        lifecycle: Some("CREATE"),
213                    });
214                }
215                // `CREATE TABLE ... AS SELECT` lowers to CreateMemoryTable; the
216                // new table is the output dataset (the SELECT's scans are its
217                // inputs, picked up by the TableScan arm).
218                DdlStatement::CreateMemoryTable(cmd) => {
219                    self.outputs.push(OutputTable {
220                        name: self.dataset_for(&cmd.name),
221                        fields: schema_fields(cmd.input.schema().as_arrow().fields()),
222                        column_lineage: None,
223                        lifecycle: Some("CREATE"),
224                    });
225                }
226                // `CREATE VIEW v AS SELECT ...` defines a derived dataset: the
227                // view is the output, its SELECT's scans are inputs (picked up
228                // by the TableScan arm). `OR REPLACE` overwrites an existing
229                // definition; a plain create is a CREATE.
230                DdlStatement::CreateView(cmd) => {
231                    self.outputs.push(OutputTable {
232                        name: self.dataset_for(&cmd.name),
233                        fields: schema_fields(cmd.input.schema().as_arrow().fields()),
234                        column_lineage: None,
235                        lifecycle: Some(if cmd.or_replace {
236                            "OVERWRITE"
237                        } else {
238                            "CREATE"
239                        }),
240                    });
241                }
242                _ => {}
243            },
244            // Nodes we don't yet derive lineage from. Warn so coverage gaps are
245            // visible rather than silently dropped.
246            other => {
247                tracing::trace!(
248                    target: "openlineage",
249                    node = other.display().to_string(),
250                    "no lineage extraction for plan node"
251                );
252            }
253        }
254        Ok(TreeNodeRecursion::Continue)
255    }
256}
257
258/// Build the [`DatasetFacets`] for an input table: its schema facet.
259///
260/// Column lineage never appears on inputs — the spec defines the facet on
261/// output datasets, keyed by output field.
262pub(crate) fn input_dataset_facets(
263    input: &InputTable,
264    config: &OpenLineageConfig,
265) -> DatasetFacets {
266    let schema = SchemaDatasetFacet {
267        base: BaseFacet::new(&config.producer, SCHEMA_FACET),
268        fields: input.fields.clone(),
269    };
270
271    DatasetFacets {
272        schema: Some(schema),
273        data_source: Some(data_source_facet(&input.name, config)),
274        ..Default::default()
275    }
276}
277
278/// Build the [`DatasetFacets`] for an output table: its column-lineage facet,
279/// when the resolution produced one.
280///
281/// Each output field lists its direct sources; the statement-wide indirect
282/// influences (filter/join/group/sort keys) are appended to every field's
283/// `inputFields`, matching how the OpenLineage Spark integration emits them.
284pub(crate) fn output_dataset_facets(
285    output: &OutputTable,
286    config: &OpenLineageConfig,
287) -> DatasetFacets {
288    // Schema facet: emit the output table's columns (when known) so the written
289    // dataset shows its columns in the graph — independent of whether column
290    // lineage resolved.
291    let schema = (!output.fields.is_empty()).then(|| SchemaDatasetFacet {
292        base: BaseFacet::new(&config.producer, SCHEMA_FACET),
293        fields: output.fields.clone(),
294    });
295
296    // dataSource + lifecycleStateChange ride along on every output regardless of
297    // whether column lineage resolved, so build them once.
298    let data_source = Some(data_source_facet(&output.name, config));
299    let lifecycle_state_change = output
300        .lifecycle
301        .map(|state| LifecycleStateChangeDatasetFacet {
302            base: BaseFacet::new(&config.producer, LIFECYCLE_FACET),
303            lifecycle_state_change: state.to_string(),
304        });
305
306    // No column lineage to attach — either none resolved, or it resolved empty
307    // (e.g. an all-literal INSERT / bulk ingest). Still emit the schema +
308    // dataSource + lifecycle facets so the dataset carries its columns.
309    let resolved = match &output.column_lineage {
310        Some(resolved) if !resolved.fields.is_empty() => resolved,
311        _ => {
312            return DatasetFacets {
313                schema,
314                data_source,
315                lifecycle_state_change,
316                ..Default::default()
317            };
318        }
319    };
320
321    let direct = |subtype: &str| Transformation {
322        type_: TransformationType::Direct,
323        subtype: Some(subtype.to_string()),
324        description: String::new(),
325        masking: false,
326    };
327    let indirect = |subtype: &str| Transformation {
328        type_: TransformationType::Indirect,
329        ..direct(subtype)
330    };
331
332    let fields = resolved
333        .fields
334        .iter()
335        .map(|(field, sources)| {
336            // One InputField per source, carrying its direct transformation
337            // plus any statement-wide indirect influences on the same source.
338            let mut input_fields: Vec<InputField> = sources
339                .direct
340                .iter()
341                .map(|(source, kind)| {
342                    let mut transformations = vec![direct(kind.subtype())];
343                    if let Some(kinds) = resolved.indirect.get(source) {
344                        transformations.extend(kinds.iter().map(|k| indirect(k.subtype())));
345                    }
346                    InputField {
347                        namespace: source.dataset.namespace.clone(),
348                        name: source.dataset.name.clone(),
349                        field: Some(source.column.clone()),
350                        transformations,
351                    }
352                })
353                .collect();
354            // Indirect-only sources (e.g. a filter column the output never
355            // carries) still influence every output field.
356            input_fields.extend(
357                resolved
358                    .indirect
359                    .iter()
360                    .filter(|(source, _)| !sources.direct.contains_key(source))
361                    .map(|(source, kinds)| InputField {
362                        namespace: source.dataset.namespace.clone(),
363                        name: source.dataset.name.clone(),
364                        field: Some(source.column.clone()),
365                        transformations: kinds.iter().map(|k| indirect(k.subtype())).collect(),
366                    }),
367            );
368            (field.clone(), FieldLineage { input_fields })
369        })
370        .collect();
371
372    DatasetFacets {
373        schema,
374        data_source,
375        lifecycle_state_change,
376        column_lineage: Some(ColumnLineageDatasetFacet {
377            base: BaseFacet::new(&config.producer, COLUMN_LINEAGE_FACET),
378            fields,
379            dataset: Vec::new(),
380        }),
381        ..Default::default()
382    }
383}