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}