1use crate::dialects::{Dialect, DialectType};
8use crate::expressions::*;
9use crate::lineage::{self, LineageNode, SetOperator};
10use crate::schema::Schema;
11use crate::scope::SourceKind;
12use crate::traversal::{contains_aggregate, ExpressionWalk};
13use crate::{mapping_schema_from_validation_schema_with_dialect, Error, Result, ValidationSchema};
14use serde::de::{self, Deserializer};
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17use std::borrow::Cow;
18use std::collections::{BTreeMap, BTreeSet, HashSet};
19
20pub const OPENLINEAGE_SCHEMA_URL: &str = "https://openlineage.io/spec/2-0-2/OpenLineage.json";
21pub const COLUMN_LINEAGE_FACET_SCHEMA_URL: &str =
22 "https://openlineage.io/spec/facets/1-2-0/ColumnLineageDatasetFacet.json";
23pub const SQL_JOB_FACET_SCHEMA_URL: &str =
24 "https://openlineage.io/spec/facets/1-1-0/SQLJobFacet.json";
25pub const JOB_TYPE_JOB_FACET_SCHEMA_URL: &str =
26 "https://openlineage.io/spec/facets/2-0-3/JobTypeJobFacet.json";
27pub const SCHEMA_DATASET_FACET_SCHEMA_URL: &str =
28 "https://openlineage.io/spec/facets/1-2-0/SchemaDatasetFacet.json";
29
30#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct OpenLineageDatasetId {
34 pub namespace: String,
35 pub name: String,
36}
37
38impl OpenLineageDatasetId {
39 pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
40 Self {
41 namespace: namespace.into(),
42 name: name.into(),
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49#[serde(rename_all = "camelCase", default)]
50pub struct OpenLineageOptions {
51 #[serde(deserialize_with = "deserialize_dialect_type")]
52 pub dialect: DialectType,
53 pub producer: String,
54 pub dataset_namespace: Option<String>,
55 pub dataset_mappings: BTreeMap<String, OpenLineageDatasetId>,
56 pub output_dataset: Option<OpenLineageDatasetId>,
57 pub schema: Option<ValidationSchema>,
58 pub job_namespace: Option<String>,
59 pub job_name: Option<String>,
60 pub event_time: Option<String>,
61 pub run_id: Option<String>,
62 pub event_type: Option<OpenLineageRunEventType>,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
68pub enum OpenLineageRunEventType {
69 Start,
70 Running,
71 Complete,
72 Abort,
73 Fail,
74 Other,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct OpenLineageWarning {
81 pub code: String,
82 pub message: String,
83}
84
85impl OpenLineageWarning {
86 fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
87 Self {
88 code: code.into(),
89 message: message.into(),
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct OpenLineageColumnLineageResult {
97 pub facet: ColumnLineageDatasetFacet,
98 pub inputs: Vec<OpenLineageDataset>,
99 pub outputs: Vec<OpenLineageDataset>,
100 pub warnings: Vec<OpenLineageWarning>,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct OpenLineageEventResult {
106 pub event: Value,
107 pub warnings: Vec<OpenLineageWarning>,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct OpenLineageDataset {
112 pub namespace: String,
113 pub name: String,
114 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
115 pub facets: BTreeMap<String, Value>,
116}
117
118impl std::convert::From<OpenLineageDatasetId> for OpenLineageDataset {
119 fn from(id: OpenLineageDatasetId) -> Self {
120 Self {
121 namespace: id.namespace,
122 name: id.name,
123 facets: BTreeMap::new(),
124 }
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct ColumnLineageDatasetFacet {
130 #[serde(rename = "_producer")]
131 pub producer: String,
132 #[serde(rename = "_schemaURL")]
133 pub schema_url: String,
134 pub fields: BTreeMap<String, ColumnLineageField>,
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct ColumnLineageField {
140 pub input_fields: Vec<OpenLineageInputField>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct OpenLineageInputField {
146 pub namespace: String,
147 pub name: String,
148 pub field: String,
149 #[serde(skip_serializing_if = "Vec::is_empty", default)]
150 pub transformations: Vec<OpenLineageTransformation>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
154pub struct OpenLineageTransformation {
155 #[serde(rename = "type")]
156 pub type_: String,
157 pub subtype: String,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub description: Option<String>,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub masking: Option<bool>,
162}
163
164#[derive(Debug, Clone)]
165struct StatementAnalysis {
166 query: Expression,
167 inputs: Vec<OpenLineageDatasetId>,
168 output: OpenLineageDatasetId,
169 output_column_names: Vec<String>,
170}
171
172#[derive(Debug, Clone)]
173struct OutputField {
174 name: String,
175 lineage_name: String,
176 expression: Option<Expression>,
177 star_source_table: Option<String>,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
181struct TerminalField {
182 table: String,
183 field: String,
184 dependency: TerminalDependency,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
188enum TerminalDependency {
189 Direct,
190 Filter,
191}
192
193pub fn openlineage_column_lineage(
195 sql: &str,
196 options: &OpenLineageOptions,
197) -> Result<OpenLineageColumnLineageResult> {
198 validate_common_options(options)?;
199
200 let mut warnings = Vec::new();
201 let schema_mapping = options
202 .schema
203 .as_ref()
204 .map(|schema| mapping_schema_from_validation_schema_with_dialect(schema, options.dialect));
205 let dialect = Dialect::get(options.dialect);
206 let mut expressions = dialect.parse(sql)?;
207 if expressions.len() != 1 {
208 return Err(Error::parse(
209 format!(
210 "OpenLineage generation expects exactly one statement, found {}",
211 expressions.len()
212 ),
213 0,
214 0,
215 0,
216 0,
217 ));
218 }
219
220 let expr = expressions.remove(0);
221 let analysis = analyze_statement(&expr, options, &mut warnings)?;
222 let mut output_fields = output_fields_for_query(
223 &analysis.query,
224 schema_mapping.as_ref().map(|s| s as &dyn Schema),
225 options.dialect,
226 &mut warnings,
227 )?;
228 apply_output_column_names(
229 &mut output_fields,
230 &analysis.output_column_names,
231 &mut warnings,
232 );
233
234 let mut lineage = None;
236 let mut fields = BTreeMap::new();
237 for output_field in output_fields {
238 if fields.contains_key(&output_field.name) {
239 warnings.push(OpenLineageWarning::new(
240 "W_DUPLICATE_OUTPUT_FIELD",
241 format!(
242 "Duplicate output field '{}' was merged in the OpenLineage fields map",
243 output_field.name
244 ),
245 ));
246 }
247
248 let input_fields = input_fields_for_output(
249 &analysis.query,
250 &mut lineage,
251 &output_field,
252 options,
253 schema_mapping.as_ref().map(|s| s as &dyn Schema),
254 &mut warnings,
255 )?;
256
257 fields.insert(output_field.name, ColumnLineageField { input_fields });
258 }
259
260 let mut outputs = vec![OpenLineageDataset::from(analysis.output.clone())];
261 attach_output_facets(&mut outputs[0], &analysis.output, options, &fields)?;
262
263 Ok(OpenLineageColumnLineageResult {
264 facet: ColumnLineageDatasetFacet {
265 producer: options.producer.clone(),
266 schema_url: COLUMN_LINEAGE_FACET_SCHEMA_URL.to_string(),
267 fields,
268 },
269 inputs: analysis
270 .inputs
271 .into_iter()
272 .map(OpenLineageDataset::from)
273 .collect(),
274 outputs,
275 warnings,
276 })
277}
278
279pub fn openlineage_job_event(
281 sql: &str,
282 options: &OpenLineageOptions,
283) -> Result<OpenLineageEventResult> {
284 let job_namespace = required_option(&options.job_namespace, "jobNamespace")?;
285 let job_name = required_option(&options.job_name, "jobName")?;
286 let event_time = required_option(&options.event_time, "eventTime")?;
287
288 let result = openlineage_column_lineage(sql, options)?;
289 let event = json!({
290 "eventTime": event_time,
291 "producer": options.producer,
292 "schemaURL": OPENLINEAGE_SCHEMA_URL,
293 "job": {
294 "namespace": job_namespace,
295 "name": job_name,
296 "facets": job_facets(sql, options),
297 },
298 "inputs": result.inputs,
299 "outputs": result.outputs,
300 });
301
302 Ok(OpenLineageEventResult {
303 event,
304 warnings: result.warnings,
305 })
306}
307
308pub fn openlineage_run_event(
310 sql: &str,
311 options: &OpenLineageOptions,
312) -> Result<OpenLineageEventResult> {
313 let job_namespace = required_option(&options.job_namespace, "jobNamespace")?;
314 let job_name = required_option(&options.job_name, "jobName")?;
315 let event_time = required_option(&options.event_time, "eventTime")?;
316 let run_id = required_option(&options.run_id, "runId")?;
317 let event_type = options
318 .event_type
319 .ok_or_else(|| Error::parse("Missing required option: eventType", 0, 0, 0, 0))?;
320
321 let result = openlineage_column_lineage(sql, options)?;
322 let event = json!({
323 "eventTime": event_time,
324 "eventType": event_type,
325 "producer": options.producer,
326 "schemaURL": OPENLINEAGE_SCHEMA_URL,
327 "run": {
328 "runId": run_id,
329 "facets": {},
330 },
331 "job": {
332 "namespace": job_namespace,
333 "name": job_name,
334 "facets": job_facets(sql, options),
335 },
336 "inputs": result.inputs,
337 "outputs": result.outputs,
338 });
339
340 Ok(OpenLineageEventResult {
341 event,
342 warnings: result.warnings,
343 })
344}
345
346fn validate_common_options(options: &OpenLineageOptions) -> Result<()> {
347 if options.producer.trim().is_empty() {
348 return Err(Error::parse(
349 "Missing required option: producer",
350 0,
351 0,
352 0,
353 0,
354 ));
355 }
356 Ok(())
357}
358
359fn required_option(value: &Option<String>, name: &str) -> Result<String> {
360 match value.as_ref().filter(|v| !v.trim().is_empty()) {
361 Some(value) => Ok(value.clone()),
362 None => Err(Error::parse(
363 format!("Missing required option: {name}"),
364 0,
365 0,
366 0,
367 0,
368 )),
369 }
370}
371
372fn analyze_statement(
373 expr: &Expression,
374 options: &OpenLineageOptions,
375 warnings: &mut Vec<OpenLineageWarning>,
376) -> Result<StatementAnalysis> {
377 match expr {
378 Expression::Prepare(prepare) => analyze_statement(&prepare.statement, options, warnings),
379 Expression::Select(select) => {
380 let output = if let Some(into) = &select.into {
381 dataset_from_expression(&into.this, options)?
382 } else {
383 options.output_dataset.clone().ok_or_else(|| {
384 Error::parse(
385 "OpenLineage outputDataset is required for SELECT statements without SELECT INTO",
386 0,
387 0,
388 0,
389 0,
390 )
391 })?
392 };
393 Ok(StatementAnalysis {
394 query: expr.clone(),
395 inputs: collect_input_datasets(expr, options, Some(&output), warnings)?,
396 output,
397 output_column_names: Vec::new(),
398 })
399 }
400 Expression::Union(_) | Expression::Intersect(_) | Expression::Except(_) => {
401 let output = options.output_dataset.clone().ok_or_else(|| {
402 Error::parse(
403 "OpenLineage outputDataset is required for set-operation queries",
404 0,
405 0,
406 0,
407 0,
408 )
409 })?;
410 Ok(StatementAnalysis {
411 query: expr.clone(),
412 inputs: collect_input_datasets(expr, options, Some(&output), warnings)?,
413 output,
414 output_column_names: Vec::new(),
415 })
416 }
417 Expression::Insert(insert) => {
418 let output = dataset_from_table_ref(&insert.table, options)?;
419 let query = insert.query.clone().ok_or_else(|| {
420 Error::unsupported(
421 "OpenLineage column lineage for INSERT without query",
422 options.dialect.to_string(),
423 )
424 })?;
425 Ok(StatementAnalysis {
426 inputs: collect_input_datasets(&query, options, Some(&output), warnings)?,
427 query,
428 output,
429 output_column_names: insert.columns.iter().map(|col| col.name.clone()).collect(),
430 })
431 }
432 Expression::CreateTable(create) => {
433 let output = dataset_from_table_ref(&create.name, options)?;
434 let query = create.as_select.clone().ok_or_else(|| {
435 Error::unsupported(
436 "OpenLineage column lineage for CREATE TABLE without AS SELECT",
437 options.dialect.to_string(),
438 )
439 })?;
440 Ok(StatementAnalysis {
441 inputs: collect_input_datasets(&query, options, Some(&output), warnings)?,
442 query,
443 output,
444 output_column_names: create
445 .columns
446 .iter()
447 .map(|col| col.name.name.clone())
448 .collect(),
449 })
450 }
451 _ => Err(Error::unsupported(
452 format!("OpenLineage generation for {}", expr.variant_name()),
453 options.dialect.to_string(),
454 )),
455 }
456}
457
458fn output_fields_for_query(
459 query: &Expression,
460 schema: Option<&dyn Schema>,
461 dialect: DialectType,
462 warnings: &mut Vec<OpenLineageWarning>,
463) -> Result<Vec<OutputField>> {
464 let mut query = query.clone();
466 lineage::expand_cte_stars(&mut query, schema);
467 let select = leftmost_select(&query).ok_or_else(|| {
468 Error::unsupported(
469 "OpenLineage output field extraction for non-SELECT query",
470 dialect.to_string(),
471 )
472 })?;
473
474 let mut fields = Vec::new();
475 for (idx, expr) in select.expressions.iter().enumerate() {
476 if is_star_expr(expr) {
477 expand_star_output_fields(select, expr, schema, dialect, warnings, &mut fields);
478 continue;
479 }
480
481 let name = output_name(expr).unwrap_or_else(|| format!("_{idx}"));
482 fields.push(OutputField {
483 lineage_name: name.clone(),
484 name,
485 expression: Some(expr.clone()),
486 star_source_table: None,
487 });
488 }
489 Ok(fields)
490}
491
492fn apply_output_column_names(
493 fields: &mut [OutputField],
494 output_column_names: &[String],
495 warnings: &mut Vec<OpenLineageWarning>,
496) {
497 if output_column_names.is_empty() {
498 return;
499 }
500 if output_column_names.len() != fields.len() {
501 warnings.push(OpenLineageWarning::new(
502 "W_OUTPUT_COLUMN_COUNT_MISMATCH",
503 format!(
504 "Target column count ({}) does not match projected column count ({})",
505 output_column_names.len(),
506 fields.len()
507 ),
508 ));
509 return;
510 }
511 for (field, output_name) in fields.iter_mut().zip(output_column_names) {
512 field.name = output_name.clone();
513 }
514}
515
516fn input_fields_for_output(
517 query: &Expression,
518 lineage: &mut Option<Result<lineage::ScopedLineage>>,
519 output_field: &OutputField,
520 options: &OpenLineageOptions,
521 schema: Option<&dyn Schema>,
522 warnings: &mut Vec<OpenLineageWarning>,
523) -> Result<Vec<OpenLineageInputField>> {
524 if let Some(table) = &output_field.star_source_table {
525 return terminal_fields_to_openlineage(
526 vec![TerminalField {
527 table: table.clone(),
528 field: output_field.lineage_name.clone(),
529 dependency: TerminalDependency::Direct,
530 }],
531 "IDENTITY",
532 Some(format!("SELECT {}", output_field.lineage_name)),
533 options,
534 warnings,
535 );
536 }
537
538 let lineage = lineage.get_or_insert_with(|| {
539 lineage::ScopedLineage::for_query(query, schema, Some(options.dialect))
540 });
541 let lineage_result = match lineage {
542 Ok(lineage) => lineage
543 .named(&output_field.lineage_name)
544 .map_err(|err| err.to_string()),
545 Err(err) => Err(err.to_string()),
546 };
547
548 let node = match lineage_result {
549 Ok(node) => node,
550 Err(err) => {
551 warnings.push(OpenLineageWarning::new(
552 "W_UNRESOLVED_OUTPUT_FIELD",
553 format!(
554 "Could not resolve lineage for output field '{}': {}",
555 output_field.name, err
556 ),
557 ));
558 return Ok(Vec::new());
559 }
560 };
561
562 let mut terminals = BTreeSet::new();
563 collect_terminal_fields(&node, TerminalDependency::Direct, &mut terminals);
564 let terminals: Vec<TerminalField> = terminals.into_iter().collect();
565
566 if terminals.is_empty() {
567 if has_virtual_terminal(&node) {
568 return Ok(Vec::new());
569 }
570 warnings.push(OpenLineageWarning::new(
571 "W_EMPTY_FIELD_LINEAGE",
572 format!(
573 "No input fields were found for output field '{}'",
574 output_field.name
575 ),
576 ));
577 return Ok(Vec::new());
578 }
579
580 let subtype = transformation_subtype(output_field.expression.as_ref(), &terminals);
581 let description = output_field
582 .expression
583 .as_ref()
584 .and_then(|expr| transformation_description(expr, options.dialect));
585
586 terminal_fields_to_openlineage(terminals, subtype, description, options, warnings)
587}
588
589fn transformation_description(expr: &Expression, dialect: DialectType) -> Option<String> {
590 #[cfg(feature = "generate")]
591 {
592 Some(expr.sql_for(dialect))
593 }
594
595 #[cfg(not(feature = "generate"))]
596 {
597 let _ = (expr, dialect);
598 None
599 }
600}
601
602fn terminal_fields_to_openlineage(
603 terminals: Vec<TerminalField>,
604 subtype: &str,
605 description: Option<String>,
606 options: &OpenLineageOptions,
607 warnings: &mut Vec<OpenLineageWarning>,
608) -> Result<Vec<OpenLineageInputField>> {
609 let mut grouped =
610 BTreeMap::<(String, String, String), BTreeSet<OpenLineageTransformation>>::new();
611 for terminal in terminals {
612 let dataset = dataset_from_table_name(&terminal.table, options).map_err(|err| {
613 warnings.push(OpenLineageWarning::new(
614 "W_UNRESOLVED_DATASET",
615 format!(
616 "Could not map table '{}' to an OpenLineage dataset: {}",
617 terminal.table, err
618 ),
619 ));
620 err
621 })?;
622 let (type_, transformation_subtype) = match terminal.dependency {
623 TerminalDependency::Direct => ("DIRECT", subtype),
624 TerminalDependency::Filter => ("INDIRECT", "FILTER"),
625 };
626 grouped
627 .entry((dataset.namespace, dataset.name, terminal.field))
628 .or_default()
629 .insert(OpenLineageTransformation {
630 type_: type_.to_string(),
631 subtype: transformation_subtype.to_string(),
632 description: description.clone(),
633 masking: Some(false),
634 });
635 }
636 Ok(grouped
637 .into_iter()
638 .map(
639 |((namespace, name, field), transformations)| OpenLineageInputField {
640 namespace,
641 name,
642 field,
643 transformations: transformations.into_iter().collect(),
644 },
645 )
646 .collect())
647}
648
649fn transformation_subtype(expr: Option<&Expression>, terminals: &[TerminalField]) -> &'static str {
650 let Some(expr) = expr else {
651 return "TRANSFORMATION";
652 };
653 let unaliased = unalias(expr);
654 if expression_contains_aggregate(unaliased) {
655 return "AGGREGATION";
656 }
657 let distinct_fields = terminals
658 .iter()
659 .map(|terminal| (&terminal.table, &terminal.field))
660 .collect::<BTreeSet<_>>();
661 if distinct_fields.len() == 1 {
662 if let Expression::Column(col) = unaliased {
663 if col.name.name == terminals[0].field {
664 return "IDENTITY";
665 }
666 }
667 }
668 "TRANSFORMATION"
669}
670
671fn collect_terminal_fields(
672 node: &LineageNode,
673 inherited_dependency: TerminalDependency,
674 terminals: &mut BTreeSet<TerminalField>,
675) {
676 let dependency = if inherited_dependency == TerminalDependency::Filter
677 || matches!(
678 node.set_branch,
679 Some(branch)
680 if branch.ordinal == 1
681 && matches!(branch.operator, SetOperator::Intersect | SetOperator::Except)
682 ) {
683 TerminalDependency::Filter
684 } else {
685 TerminalDependency::Direct
686 };
687
688 if node.downstream.is_empty() {
689 if node.source_kind == SourceKind::Virtual {
690 return;
691 }
692 if let Expression::Column(column) = &node.expression {
693 let table = if !node.source_name.is_empty() {
694 Some(node.source_name.clone())
695 } else if let Expression::Table(table) = &node.source {
696 Some(table_ref_qualified_name(table))
697 } else {
698 column.table.as_ref().map(|t| t.name.clone())
699 };
700 if let Some(table) = table.filter(|t| !t.is_empty()) {
701 terminals.insert(TerminalField {
702 table,
703 field: column.name.name.clone(),
704 dependency,
705 });
706 }
707 }
708 return;
709 }
710
711 for child in &node.downstream {
712 collect_terminal_fields(child, dependency, terminals);
713 }
714}
715
716fn has_virtual_terminal(node: &LineageNode) -> bool {
717 if node.downstream.is_empty() {
718 return node.source_kind == SourceKind::Virtual;
719 }
720 node.downstream.iter().any(has_virtual_terminal)
721}
722
723fn expression_contains_aggregate(expr: &Expression) -> bool {
724 contains_aggregate(expr)
725}
726
727fn collect_input_datasets(
728 expr: &Expression,
729 options: &OpenLineageOptions,
730 output: Option<&OpenLineageDatasetId>,
731 warnings: &mut Vec<OpenLineageWarning>,
732) -> Result<Vec<OpenLineageDatasetId>> {
733 let cte_aliases = collect_cte_aliases(expr, options.dialect);
734 let mut seen = BTreeSet::new();
735 let mut result = Vec::new();
736
737 for table in expr.dfs().filter_map(|node| match node {
738 Expression::Table(table) => Some(table),
739 _ => None,
740 }) {
741 let qname = table_ref_qualified_name(table);
742 let normalized = normalize_identifier(&table.name.name, options.dialect, true);
743 if cte_aliases.contains(&normalized) {
744 continue;
745 }
746 if output
747 .map(|out| out.name == qname || out.name == table.name.name)
748 .unwrap_or(false)
749 {
750 continue;
751 }
752 match dataset_from_table_name(&qname, options) {
753 Ok(dataset) => {
754 if seen.insert((dataset.namespace.clone(), dataset.name.clone())) {
755 result.push(dataset);
756 }
757 }
758 Err(err) => warnings.push(OpenLineageWarning::new(
759 "W_UNRESOLVED_DATASET",
760 format!("Could not map input table '{qname}': {err}"),
761 )),
762 }
763 }
764
765 Ok(result)
766}
767
768fn attach_output_facets(
769 output: &mut OpenLineageDataset,
770 output_id: &OpenLineageDatasetId,
771 options: &OpenLineageOptions,
772 fields: &BTreeMap<String, ColumnLineageField>,
773) -> Result<()> {
774 let column_lineage = ColumnLineageDatasetFacet {
775 producer: options.producer.clone(),
776 schema_url: COLUMN_LINEAGE_FACET_SCHEMA_URL.to_string(),
777 fields: fields.clone(),
778 };
779 output.facets.insert(
780 "columnLineage".to_string(),
781 serde_json::to_value(column_lineage).map_err(openlineage_serialization_error)?,
782 );
783
784 if let Some(schema_facet) = schema_facet_for_dataset(output_id, options) {
785 output.facets.insert(
786 "schema".to_string(),
787 serde_json::to_value(schema_facet).map_err(openlineage_serialization_error)?,
788 );
789 }
790
791 Ok(())
792}
793
794fn job_facets(sql: &str, options: &OpenLineageOptions) -> Value {
795 json!({
796 "sql": {
797 "_producer": options.producer,
798 "_schemaURL": SQL_JOB_FACET_SCHEMA_URL,
799 "query": sql,
800 "dialect": options.dialect.to_string(),
801 },
802 "jobType": {
803 "_producer": options.producer,
804 "_schemaURL": JOB_TYPE_JOB_FACET_SCHEMA_URL,
805 "processingType": "BATCH",
806 "integration": "POLYGLOT_SQL",
807 "jobType": "QUERY",
808 }
809 })
810}
811
812#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
813struct SchemaDatasetFacet {
814 #[serde(rename = "_producer")]
815 producer: String,
816 #[serde(rename = "_schemaURL")]
817 schema_url: String,
818 fields: Vec<SchemaDatasetFacetField>,
819}
820
821#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
822struct SchemaDatasetFacetField {
823 name: String,
824 #[serde(skip_serializing_if = "String::is_empty", default)]
825 #[serde(rename = "type")]
826 data_type: String,
827 #[serde(skip_serializing_if = "Option::is_none")]
828 ordinal_position: Option<usize>,
829}
830
831fn schema_facet_for_dataset(
832 output: &OpenLineageDatasetId,
833 options: &OpenLineageOptions,
834) -> Option<SchemaDatasetFacet> {
835 let schema = options.schema.as_ref()?;
836 let table = schema.tables.iter().find(|table| {
837 let qname = if let Some(schema_name) = &table.schema {
838 format!("{}.{}", schema_name, table.name)
839 } else {
840 table.name.clone()
841 };
842 output.name == table.name || output.name == qname
843 })?;
844
845 Some(SchemaDatasetFacet {
846 producer: options.producer.clone(),
847 schema_url: SCHEMA_DATASET_FACET_SCHEMA_URL.to_string(),
848 fields: table
849 .columns
850 .iter()
851 .enumerate()
852 .map(|(idx, col)| SchemaDatasetFacetField {
853 name: col.name.clone(),
854 data_type: col.data_type.clone(),
855 ordinal_position: Some(idx + 1),
856 })
857 .collect(),
858 })
859}
860
861fn expand_star_output_fields(
862 select: &Select,
863 star_expr: &Expression,
864 schema: Option<&dyn Schema>,
865 dialect: DialectType,
866 warnings: &mut Vec<OpenLineageWarning>,
867 fields: &mut Vec<OutputField>,
868) {
869 let Some(schema) = schema else {
870 warnings.push(OpenLineageWarning::new(
871 "W_STAR_WITHOUT_SCHEMA",
872 "SELECT * cannot be expanded into OpenLineage column lineage without schema metadata",
873 ));
874 return;
875 };
876
877 let qualifier = star_qualifier(star_expr);
878 let star = match star_expr {
879 Expression::Star(star) => Some(star),
880 _ => None,
881 };
882 if star.is_some_and(|star| star.has_except_pattern()) {
883 warnings.push(OpenLineageWarning::new(
884 "W_STAR_EXCEPT_PATTERN",
885 "SELECT * EXCEPT with a pattern cannot be expanded into OpenLineage column lineage",
886 ));
887 return;
888 }
889 let sources = select_source_tables(select);
890 for (alias, source) in sources {
891 let (columns, star_source_table) = match source {
892 StarSource::Table(qname) => {
893 if qualifier
894 .as_ref()
895 .map(|q| q != &alias && q != &qname)
896 .unwrap_or(false)
897 {
898 continue;
899 }
900 match schema.column_names(&qname) {
901 Ok(columns) => (columns, Some(qname)),
902 Err(err) => {
903 warnings.push(OpenLineageWarning::new(
904 "W_STAR_SCHEMA_LOOKUP_FAILED",
905 format!("Could not expand SELECT * for table '{qname}': {err}"),
906 ));
907 continue;
908 }
909 }
910 }
911 StarSource::Derived(query) => {
912 if qualifier.as_ref().is_some_and(|q| q != &alias) {
913 continue;
914 }
915 let columns =
916 lineage::output_columns_with_schema(&query, Some(schema), Some(dialect))
917 .ok()
918 .and_then(|output| {
919 output
920 .columns
921 .into_iter()
922 .map(|column| match column {
923 lineage::OutputColumn::Named { name, .. } => Some(name),
924 _ => None,
925 })
926 .collect::<Option<Vec<_>>>()
927 });
928 let Some(columns) = columns else {
929 warnings.push(OpenLineageWarning::new(
930 "W_STAR_SCHEMA_LOOKUP_FAILED",
931 format!("Could not expand SELECT * for source '{alias}'"),
932 ));
933 continue;
934 };
935 (columns, None)
936 }
937 };
938
939 for name in columns {
940 if star.is_some_and(|star| star.excludes(&name, Some(dialect))) {
941 continue;
942 }
943 let output_name = star
944 .and_then(|star| star.renamed(&name, Some(dialect)))
945 .map_or_else(|| name.clone(), |new| new.name);
946 if let Some(replacement) = star.and_then(|star| star.replacement(&name, Some(dialect)))
947 {
948 fields.push(OutputField {
949 lineage_name: output_name.clone(),
950 name: output_name,
951 expression: Some(Expression::Alias(Box::new(replacement.clone()))),
952 star_source_table: None,
953 });
954 continue;
955 }
956 let (lineage_name, expression) = match &star_source_table {
958 Some(_) => (name, None),
959 None if alias.is_empty() => (output_name.clone(), Some(Expression::column(name))),
960 None => (
961 output_name.clone(),
962 Some(Expression::qualified_column(alias.as_str(), name)),
963 ),
964 };
965 fields.push(OutputField {
966 name: output_name,
967 lineage_name,
968 expression,
969 star_source_table: star_source_table.clone(),
970 });
971 }
972 }
973}
974
975enum StarSource<'a> {
977 Table(String),
978 Derived(Box<Cow<'a, Expression>>),
979}
980
981fn select_source_tables(select: &Select) -> Vec<(String, StarSource<'_>)> {
982 let mut result = Vec::new();
983 let with = select.with.as_ref();
984 if let Some(from) = &select.from {
985 for expr in &from.expressions {
986 collect_source_table(expr, with, &mut result);
987 }
988 }
989 for join in &select.joins {
990 collect_source_table(&join.this, with, &mut result);
991 }
992 result
993}
994
995fn collect_source_table<'a>(
996 expr: &'a Expression,
997 with: Option<&With>,
998 result: &mut Vec<(String, StarSource<'a>)>,
999) {
1000 match expr {
1001 Expression::Table(table) => {
1002 let qname = table_ref_qualified_name(table);
1003 let alias = table
1004 .alias
1005 .as_ref()
1006 .map(|a| a.name.clone())
1007 .unwrap_or_else(|| table.name.name.clone());
1008 result.push((alias, StarSource::Table(qname)));
1009 }
1010 Expression::Subquery(subquery) => {
1011 if let Some(alias) = &subquery.alias {
1012 result.push((
1013 alias.name.clone(),
1014 StarSource::Derived(Box::new(Cow::Borrowed(&subquery.this))),
1015 ));
1016 }
1017 }
1018 Expression::Pivot(_) | Expression::Unpivot(_) => {
1019 let alias = match expr {
1020 Expression::Pivot(pivot) => pivot.alias.as_ref(),
1021 Expression::Unpivot(unpivot) => unpivot.alias.as_ref(),
1022 _ => None,
1023 };
1024 let mut query = Select::new();
1026 query.with = with.cloned();
1027 query.expressions = vec![Expression::star()];
1028 query.from = Some(From {
1029 expressions: vec![expr.clone()],
1030 });
1031 result.push((
1032 alias.map(|a| a.name.clone()).unwrap_or_default(),
1033 StarSource::Derived(Box::new(Cow::Owned(Expression::Select(Box::new(query))))),
1034 ));
1035 }
1036 Expression::Alias(alias) => collect_source_table(&alias.this, with, result),
1037 Expression::Paren(paren) => collect_source_table(&paren.this, with, result),
1038 _ => {}
1039 }
1040}
1041
1042fn leftmost_select(expr: &Expression) -> Option<&Select> {
1043 match expr {
1044 Expression::Prepare(prepare) => leftmost_select(&prepare.statement),
1045 Expression::Select(select) => Some(select),
1046 Expression::Union(union) => leftmost_select(&union.left),
1047 Expression::Intersect(intersect) => leftmost_select(&intersect.left),
1048 Expression::Except(except) => leftmost_select(&except.left),
1049 Expression::Subquery(subquery) => leftmost_select(&subquery.this),
1050 _ => None,
1051 }
1052}
1053
1054fn output_name(expr: &Expression) -> Option<String> {
1055 match expr {
1056 Expression::Alias(alias) => Some(alias.alias.name.clone()),
1057 Expression::Column(col) => Some(col.name.name.clone()),
1058 Expression::Identifier(id) => Some(id.name.clone()),
1059 Expression::Annotated(a) => output_name(&a.this),
1060 _ => None,
1061 }
1062}
1063
1064fn unalias(expr: &Expression) -> &Expression {
1065 match expr {
1066 Expression::Alias(alias) => &alias.this,
1067 Expression::Annotated(a) => unalias(&a.this),
1068 _ => expr,
1069 }
1070}
1071
1072fn is_star_expr(expr: &Expression) -> bool {
1073 matches!(expr, Expression::Star(_))
1074 || matches!(expr, Expression::Column(col) if col.name.name == "*")
1075}
1076
1077fn star_qualifier(expr: &Expression) -> Option<String> {
1078 match expr {
1079 Expression::Star(star) => star.table.as_ref().map(|t| t.name.clone()),
1080 Expression::Column(col) if col.name.name == "*" => {
1081 col.table.as_ref().map(|t| t.name.clone())
1082 }
1083 _ => None,
1084 }
1085}
1086
1087fn dataset_from_expression(
1088 expr: &Expression,
1089 options: &OpenLineageOptions,
1090) -> Result<OpenLineageDatasetId> {
1091 match expr {
1092 Expression::Table(table) => dataset_from_table_ref(table, options),
1093 Expression::Identifier(id) => dataset_from_table_name(&id.name, options),
1094 _ => Err(Error::unsupported(
1095 "OpenLineage dataset extraction from non-table expression",
1096 options.dialect.to_string(),
1097 )),
1098 }
1099}
1100
1101fn dataset_from_table_ref(
1102 table: &TableRef,
1103 options: &OpenLineageOptions,
1104) -> Result<OpenLineageDatasetId> {
1105 dataset_from_table_name(&table_ref_qualified_name(table), options)
1106}
1107
1108fn dataset_from_table_name(
1109 table_name: &str,
1110 options: &OpenLineageOptions,
1111) -> Result<OpenLineageDatasetId> {
1112 if let Some(mapped) = options.dataset_mappings.get(table_name) {
1113 return Ok(mapped.clone());
1114 }
1115 let namespace = options.dataset_namespace.as_ref().ok_or_else(|| {
1116 Error::parse(
1117 format!(
1118 "Missing datasetNamespace or explicit dataset mapping for table '{}'",
1119 table_name
1120 ),
1121 0,
1122 0,
1123 0,
1124 0,
1125 )
1126 })?;
1127 Ok(OpenLineageDatasetId::new(namespace, table_name))
1128}
1129
1130fn table_ref_qualified_name(table: &TableRef) -> String {
1131 let mut parts = Vec::new();
1132 if let Some(catalog) = &table.catalog {
1133 parts.push(catalog.name.clone());
1134 }
1135 if let Some(schema) = &table.schema {
1136 parts.push(schema.name.clone());
1137 }
1138 parts.push(table.name.name.clone());
1139 parts.join(".")
1140}
1141
1142fn collect_cte_aliases(expr: &Expression, dialect: DialectType) -> HashSet<String> {
1143 let mut aliases = HashSet::new();
1144 for node in expr.dfs() {
1145 match node {
1146 Expression::Select(select) => {
1147 if let Some(with) = &select.with {
1148 collect_with_aliases(with, dialect, &mut aliases);
1149 }
1150 }
1151 Expression::Union(union) => {
1152 if let Some(with) = &union.with {
1153 collect_with_aliases(with, dialect, &mut aliases);
1154 }
1155 }
1156 Expression::Intersect(intersect) => {
1157 if let Some(with) = &intersect.with {
1158 collect_with_aliases(with, dialect, &mut aliases);
1159 }
1160 }
1161 Expression::Except(except) => {
1162 if let Some(with) = &except.with {
1163 collect_with_aliases(with, dialect, &mut aliases);
1164 }
1165 }
1166 _ => {}
1167 }
1168 }
1169 aliases
1170}
1171
1172fn collect_with_aliases(with: &With, dialect: DialectType, aliases: &mut HashSet<String>) {
1173 for cte in &with.ctes {
1174 aliases.insert(normalize_identifier(&cte.alias.name, dialect, true));
1175 }
1176}
1177
1178fn normalize_identifier(name: &str, dialect: DialectType, is_table: bool) -> String {
1179 crate::schema::normalize_name(name, Some(dialect), is_table, true)
1180}
1181
1182fn openlineage_serialization_error(err: serde_json::Error) -> Error {
1183 Error::internal(format!("OpenLineage serialization failed: {err}"))
1184}
1185
1186fn deserialize_dialect_type<'de, D>(deserializer: D) -> std::result::Result<DialectType, D::Error>
1187where
1188 D: Deserializer<'de>,
1189{
1190 let value = String::deserialize(deserializer)?;
1191 value.parse::<DialectType>().map_err(de::Error::custom)
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::*;
1197
1198 fn options() -> OpenLineageOptions {
1199 OpenLineageOptions {
1200 dialect: DialectType::PostgreSQL,
1201 producer: "https://github.com/tobilg/polyglot".to_string(),
1202 dataset_namespace: Some("postgres://warehouse".to_string()),
1203 output_dataset: Some(OpenLineageDatasetId::new(
1204 "postgres://warehouse",
1205 "analytics.out",
1206 )),
1207 job_namespace: Some("polyglot-tests".to_string()),
1208 job_name: Some("lineage-test".to_string()),
1209 event_time: Some("2026-05-18T00:00:00Z".to_string()),
1210 run_id: Some("3b452093-782c-4ef2-9c0c-aafe2aa6f34d".to_string()),
1211 event_type: Some(OpenLineageRunEventType::Complete),
1212 ..Default::default()
1213 }
1214 }
1215
1216 #[test]
1217 fn resolves_each_field_of_a_wide_query_on_one_scope() {
1218 let columns: Vec<String> = (0..40).map(|i| format!("c{i}")).collect();
1219 let sql = format!(
1220 "WITH w AS (SELECT {cols} FROM t) SELECT {cols}, c0 + c1 AS total FROM w",
1221 cols = columns.join(", ")
1222 );
1223 let result = openlineage_column_lineage(&sql, &options()).expect("lineage");
1224 assert_eq!(result.facet.fields.len(), columns.len() + 1);
1225 for column in &columns {
1226 let field = result.facet.fields.get(column).expect("field");
1227 assert_eq!(field.input_fields.len(), 1, "{column}");
1228 assert_eq!(field.input_fields[0].field, *column);
1229 }
1230 let total = result.facet.fields.get("total").expect("total");
1231 let inputs: Vec<&str> = total
1232 .input_fields
1233 .iter()
1234 .map(|f| f.field.as_str())
1235 .collect();
1236 assert_eq!(inputs, ["c0", "c1"]);
1237 }
1238
1239 #[test]
1240 fn deserializes_dialect_aliases_in_options() {
1241 let options: OpenLineageOptions =
1242 serde_json::from_str(r#"{"producer":"polyglot","dialect":"postgres"}"#)
1243 .expect("options");
1244 assert_eq!(options.dialect, DialectType::PostgreSQL);
1245 }
1246
1247 #[test]
1248 fn emits_identity_column_lineage_for_select() {
1249 let result = openlineage_column_lineage("SELECT a FROM t", &options()).expect("lineage");
1250 let field = result.facet.fields.get("a").expect("field a");
1251 assert_eq!(field.input_fields.len(), 1);
1252 assert_eq!(field.input_fields[0].name, "t");
1253 assert_eq!(field.input_fields[0].field, "a");
1254 assert_eq!(field.input_fields[0].transformations[0].subtype, "IDENTITY");
1255 }
1256
1257 #[test]
1258 fn emits_set_operation_value_and_filter_dependencies() {
1259 let union = openlineage_column_lineage(
1260 "SELECT a FROM left_table UNION ALL SELECT b FROM right_table",
1261 &options(),
1262 )
1263 .expect("union lineage");
1264 let union_field = union.facet.fields.get("a").expect("union field");
1265 assert_eq!(union_field.input_fields.len(), 2);
1266 assert!(union_field.input_fields.iter().all(|input| input
1267 .transformations
1268 .iter()
1269 .all(|transformation| transformation.type_ == "DIRECT")));
1270
1271 for operator in ["EXCEPT", "INTERSECT"] {
1272 let result = openlineage_column_lineage(
1273 &format!("SELECT a FROM left_table {operator} SELECT b FROM right_table"),
1274 &options(),
1275 )
1276 .unwrap_or_else(|error| panic!("{operator} lineage failed: {error}"));
1277 let field = result.facet.fields.get("a").expect("output field");
1278 let left = field
1279 .input_fields
1280 .iter()
1281 .find(|input| input.name == "left_table")
1282 .expect("left input");
1283 let right = field
1284 .input_fields
1285 .iter()
1286 .find(|input| input.name == "right_table")
1287 .expect("right input");
1288 assert!(left
1289 .transformations
1290 .iter()
1291 .all(|transformation| transformation.type_ == "DIRECT"));
1292 assert!(right.transformations.iter().any(|transformation| {
1293 transformation.type_ == "INDIRECT" && transformation.subtype == "FILTER"
1294 }));
1295 }
1296
1297 let merged = openlineage_column_lineage(
1298 "SELECT a FROM shared_table EXCEPT SELECT a FROM shared_table",
1299 &options(),
1300 )
1301 .expect("merged dependency lineage");
1302 let merged_field = merged.facet.fields.get("a").expect("merged field");
1303 assert_eq!(merged_field.input_fields.len(), 1);
1304 assert!(merged_field.input_fields[0]
1305 .transformations
1306 .iter()
1307 .any(|transformation| transformation.type_ == "DIRECT"));
1308 assert!(merged_field.input_fields[0]
1309 .transformations
1310 .iter()
1311 .any(|transformation| {
1312 transformation.type_ == "INDIRECT" && transformation.subtype == "FILTER"
1313 }));
1314
1315 let nested = openlineage_column_lineage(
1316 "SELECT a FROM left_table EXCEPT \
1317 (SELECT b FROM right_table UNION ALL SELECT c FROM third_table)",
1318 &options(),
1319 )
1320 .expect("nested set-operation lineage");
1321 let nested_field = nested.facet.fields.get("a").expect("nested field");
1322 for table in ["right_table", "third_table"] {
1323 let input = nested_field
1324 .input_fields
1325 .iter()
1326 .find(|input| input.name == table)
1327 .unwrap_or_else(|| panic!("missing nested input {table}"));
1328 assert!(input.transformations.iter().any(|transformation| {
1329 transformation.type_ == "INDIRECT" && transformation.subtype == "FILTER"
1330 }));
1331 }
1332 }
1333
1334 #[test]
1335 fn emits_column_lineage_for_prepared_statement_body() {
1336 let result = openlineage_column_lineage(
1337 "PREPARE leak AS SELECT id FROM sensitive_table WHERE id = $1",
1338 &options(),
1339 )
1340 .expect("lineage");
1341 let field = result.facet.fields.get("id").expect("field id");
1342 assert_eq!(field.input_fields.len(), 1);
1343 assert_eq!(field.input_fields[0].name, "sensitive_table");
1344 assert_eq!(field.input_fields[0].field, "id");
1345 }
1346
1347 #[test]
1348 fn resolves_input_dataset_behind_table_alias() {
1349 let result = openlineage_column_lineage("SELECT o.total FROM orders o", &options())
1350 .expect("lineage");
1351 let field = result.facet.fields.get("total").expect("field total");
1352 assert_eq!(field.input_fields[0].name, "orders");
1353 assert_eq!(field.input_fields[0].field, "total");
1354 }
1355
1356 #[test]
1357 fn emits_transformation_column_lineage_for_expression() {
1358 let result =
1359 openlineage_column_lineage("SELECT a + b AS c FROM t", &options()).expect("lineage");
1360 let field = result.facet.fields.get("c").expect("field c");
1361 assert_eq!(field.input_fields.len(), 2);
1362 assert!(field.input_fields.iter().any(|f| f.field == "a"));
1363 assert!(field.input_fields.iter().any(|f| f.field == "b"));
1364 assert!(field
1365 .input_fields
1366 .iter()
1367 .all(|f| f.transformations[0].subtype == "TRANSFORMATION"));
1368 }
1369
1370 #[test]
1371 fn omits_bigquery_safe_namespace_from_column_lineage_issue207() {
1372 let mut opts = options();
1373 opts.dialect = DialectType::BigQuery;
1374
1375 let result = openlineage_column_lineage(
1376 r#"
1377WITH import_cte AS (
1378 SELECT timestamp, data, operation
1379 FROM `project`.`dataset`.`source_table`
1380),
1381transform_cte AS (
1382 SELECT
1383 timestamp,
1384 SAFE.PARSE_JSON(data) AS json_data
1385 FROM import_cte
1386)
1387SELECT json_data FROM transform_cte
1388"#,
1389 &opts,
1390 )
1391 .expect("lineage");
1392 let field = result.facet.fields.get("json_data").expect("json_data");
1393
1394 assert!(
1395 field.input_fields.iter().any(|input| input.field == "data"),
1396 "expected data input field, got {:?}",
1397 field.input_fields
1398 );
1399 assert!(
1400 !field
1401 .input_fields
1402 .iter()
1403 .any(|input| input.field.eq_ignore_ascii_case("safe")),
1404 "did not expect SAFE namespace as input field, got {:?}",
1405 field.input_fields
1406 );
1407 }
1408
1409 #[test]
1410 fn emits_bigquery_unnest_alias_column_lineage_issue209() {
1411 let mut opts = options();
1412 opts.dialect = DialectType::BigQuery;
1413 opts.dataset_namespace = Some("bigquery://warehouse".to_string());
1414 opts.output_dataset = Some(OpenLineageDatasetId::new(
1415 "bigquery://warehouse",
1416 "calendar",
1417 ));
1418
1419 let result = openlineage_column_lineage(
1420 r#"
1421SELECT date_val AS week_start
1422FROM UNNEST(GENERATE_DATE_ARRAY('2024-01-01', '2024-12-31', INTERVAL 1 WEEK)) AS date_val
1423"#,
1424 &opts,
1425 )
1426 .expect("lineage");
1427 let field = result.facet.fields.get("week_start").expect("week_start");
1428
1429 assert!(field.input_fields.is_empty());
1430 assert!(
1431 result
1432 .warnings
1433 .iter()
1434 .all(|warning| warning.code != "W_EMPTY_FIELD_LINEAGE"),
1435 "did not expect empty-lineage warning, got {:?}",
1436 result.warnings
1437 );
1438 }
1439
1440 #[test]
1441 fn emits_bigquery_table_backed_unnest_column_lineage() {
1442 let mut opts = options();
1443 opts.dialect = DialectType::BigQuery;
1444 opts.dataset_namespace = Some("bigquery://warehouse".to_string());
1445 opts.output_dataset = Some(OpenLineageDatasetId::new("bigquery://warehouse", "items"));
1446
1447 let result = openlineage_column_lineage(
1448 r#"
1449SELECT item.item AS item
1450FROM t JOIN UNNEST(t.items) AS item ON TRUE
1451"#,
1452 &opts,
1453 )
1454 .expect("lineage");
1455 let field = result.facet.fields.get("item").expect("item");
1456
1457 assert_eq!(field.input_fields.len(), 1);
1458 assert_eq!(field.input_fields[0].name, "t");
1459 assert_eq!(field.input_fields[0].field, "items");
1460 }
1461
1462 #[test]
1463 fn emits_aggregation_column_lineage() {
1464 let result =
1465 openlineage_column_lineage("SELECT SUM(amount) AS total FROM orders", &options())
1466 .expect("lineage");
1467 let field = result.facet.fields.get("total").expect("field total");
1468 assert_eq!(field.input_fields[0].field, "amount");
1469 assert_eq!(
1470 field.input_fields[0].transformations[0].subtype,
1471 "AGGREGATION"
1472 );
1473 }
1474
1475 #[test]
1476 fn infers_insert_output_dataset() {
1477 let mut opts = options();
1478 opts.output_dataset = None;
1479 let result =
1480 openlineage_column_lineage("INSERT INTO analytics.out SELECT a FROM raw.input", &opts)
1481 .expect("lineage");
1482 assert_eq!(result.outputs[0].name, "analytics.out");
1483 assert_eq!(result.inputs[0].name, "raw.input");
1484 }
1485
1486 #[test]
1487 fn maps_insert_target_columns_to_output_fields() {
1488 let mut opts = options();
1489 opts.output_dataset = None;
1490 let result = openlineage_column_lineage(
1491 "INSERT INTO analytics.out (target_a) SELECT source_a FROM raw.input",
1492 &opts,
1493 )
1494 .expect("lineage");
1495 let field = result.facet.fields.get("target_a").expect("target field");
1496 assert_eq!(field.input_fields[0].field, "source_a");
1497 assert!(!result.facet.fields.contains_key("source_a"));
1498 }
1499
1500 #[test]
1501 fn pure_select_requires_output_dataset() {
1502 let mut opts = options();
1503 opts.output_dataset = None;
1504 let err = openlineage_column_lineage("SELECT a FROM t", &opts).unwrap_err();
1505 assert!(err.to_string().contains("outputDataset is required"));
1506 }
1507
1508 #[test]
1509 fn emits_job_event_payload() {
1510 let result = openlineage_job_event("SELECT a FROM t", &options()).expect("event");
1511 assert_eq!(result.event["job"]["namespace"], "polyglot-tests");
1512 assert_eq!(
1513 result.event["job"]["facets"]["sql"]["_schemaURL"],
1514 SQL_JOB_FACET_SCHEMA_URL
1515 );
1516 assert_eq!(
1517 result.event["outputs"][0]["facets"]["columnLineage"]["fields"]["a"]["inputFields"][0]
1518 ["field"],
1519 "a"
1520 );
1521 }
1522
1523 #[test]
1524 fn emits_run_event_payload() {
1525 let result = openlineage_run_event("SELECT a FROM t", &options()).expect("event");
1526 assert_eq!(result.event["eventType"], "COMPLETE");
1527 assert_eq!(
1528 result.event["run"]["runId"],
1529 "3b452093-782c-4ef2-9c0c-aafe2aa6f34d"
1530 );
1531 }
1532
1533 #[test]
1534 fn select_star_without_schema_warns() {
1535 let result = openlineage_column_lineage("SELECT * FROM t", &options()).expect("lineage");
1536 assert!(result.facet.fields.is_empty());
1537 assert!(result
1538 .warnings
1539 .iter()
1540 .any(|w| w.code == "W_STAR_WITHOUT_SCHEMA"));
1541 }
1542
1543 #[test]
1544 fn select_star_with_schema_expands_fields() {
1545 let mut opts = options();
1546 opts.schema = Some(ValidationSchema {
1547 strict: None,
1548 tables: vec![crate::validation::SchemaTable {
1549 name: "t".to_string(),
1550 schema: None,
1551 columns: vec![
1552 crate::validation::SchemaColumn {
1553 name: "a".to_string(),
1554 data_type: "INT".to_string(),
1555 nullable: None,
1556 primary_key: false,
1557 unique: false,
1558 references: None,
1559 },
1560 crate::validation::SchemaColumn {
1561 name: "b".to_string(),
1562 data_type: "TEXT".to_string(),
1563 nullable: None,
1564 primary_key: false,
1565 unique: false,
1566 references: None,
1567 },
1568 ],
1569 aliases: vec![],
1570 primary_key: vec![],
1571 unique_keys: vec![],
1572 foreign_keys: vec![],
1573 }],
1574 });
1575
1576 let result = openlineage_column_lineage("SELECT * FROM t", &opts).expect("lineage");
1577 assert!(result.facet.fields.contains_key("a"));
1578 assert!(result.facet.fields.contains_key("b"));
1579 }
1580
1581 #[test]
1582 fn select_star_applies_except_replace_rename() {
1583 let schema: ValidationSchema = serde_json::from_value(json!({
1584 "tables": [{
1585 "name": "orders",
1586 "schema": "s",
1587 "columns": [
1588 {"name": "order_id", "type": "INT"},
1589 {"name": "amount", "type": "INT"},
1590 {"name": "payload", "type": "TEXT"}
1591 ]
1592 }]
1593 }))
1594 .expect("schema");
1595 let lineage = |dialect, sql| {
1596 let mut opts = options();
1597 opts.dialect = dialect;
1598 opts.schema = Some(schema.clone());
1599 openlineage_column_lineage(sql, &opts).expect("lineage")
1600 };
1601
1602 for (dialect, sql) in [
1603 (
1604 DialectType::Snowflake,
1605 "SELECT * EXCLUDE (payload) FROM s.orders",
1606 ),
1607 (
1608 DialectType::BigQuery,
1609 "SELECT * EXCEPT (payload) FROM s.orders",
1610 ),
1611 (
1612 DialectType::ClickHouse,
1613 "SELECT * EXCEPT (payload) FROM s.orders",
1614 ),
1615 ] {
1616 let result = lineage(dialect, sql);
1617 let fields: Vec<_> = result
1619 .facet
1620 .fields
1621 .keys()
1622 .map(|f| f.to_lowercase())
1623 .collect();
1624 assert_eq!(fields, vec!["amount", "order_id"], "{sql}");
1625 }
1626
1627 let result = lineage(
1628 DialectType::BigQuery,
1629 "SELECT * REPLACE (amount * 2 AS amount) FROM s.orders",
1630 );
1631 let inputs = &result.facet.fields["amount"].input_fields;
1632 assert_eq!(inputs.len(), 1);
1633 assert_eq!(inputs[0].name, "s.orders");
1634 assert_eq!(inputs[0].field, "amount");
1635 assert_eq!(inputs[0].transformations[0].subtype, "TRANSFORMATION");
1636
1637 let result = lineage(
1638 DialectType::Snowflake,
1639 "SELECT * RENAME (amount AS order_amount) FROM s.orders",
1640 );
1641 assert!(!result.facet.fields.contains_key("AMOUNT"));
1642 let inputs = &result.facet.fields["ORDER_AMOUNT"].input_fields;
1643 assert_eq!(inputs.len(), 1);
1644 assert_eq!(inputs[0].name, "s.orders");
1645 assert_eq!(inputs[0].field, "AMOUNT");
1646 assert_eq!(inputs[0].transformations[0].subtype, "IDENTITY");
1647
1648 let result = lineage(
1649 DialectType::Snowflake,
1650 r#"SELECT * RENAME (amount AS "order_amount") FROM s.orders"#,
1651 );
1652 assert!(result.facet.fields.contains_key("order_amount"));
1653
1654 let result = lineage(
1655 DialectType::ClickHouse,
1656 "SELECT * EXCEPT ('^pay.*') FROM s.orders",
1657 );
1658 assert!(result.facet.fields.is_empty());
1659 assert!(result
1660 .warnings
1661 .iter()
1662 .any(|w| w.code == "W_STAR_EXCEPT_PATTERN"));
1663 }
1664
1665 #[test]
1666 fn select_star_expands_pivot_and_unpivot_fields() {
1667 let mut opts = options();
1668 opts.dialect = DialectType::Snowflake;
1669 opts.schema = Some(
1670 serde_json::from_value(json!({"tables": [
1671 {"name": "payments", "schema": "s", "columns": [
1672 {"name": "order_id", "type": "INT"},
1673 {"name": "method", "type": "TEXT"},
1674 {"name": "amount", "type": "INT"}
1675 ]},
1676 {"name": "customers", "schema": "s", "columns": [
1677 {"name": "customer_id", "type": "INT"},
1678 {"name": "first_name", "type": "TEXT"},
1679 {"name": "last_name", "type": "TEXT"}
1680 ]}
1681 ]}))
1682 .expect("schema"),
1683 );
1684
1685 for (sql, expected) in [
1686 (
1687 "SELECT * FROM (SELECT order_id, method, amount FROM s.payments) \
1688 PIVOT (SUM(amount) FOR method IN ('card', 'coupon')) AS p",
1689 vec![
1690 ("card", vec!["amount", "method"]),
1691 ("coupon", vec!["amount", "method"]),
1692 ("order_id", vec!["order_id"]),
1693 ],
1694 ),
1695 (
1696 "SELECT * FROM (SELECT customer_id, first_name, last_name FROM s.customers) \
1697 UNPIVOT (name_value FOR name_kind IN (first_name, last_name))",
1698 vec![
1699 ("customer_id", vec!["customer_id"]),
1700 ("name_kind", vec!["first_name", "last_name"]),
1701 ("name_value", vec!["first_name", "last_name"]),
1702 ],
1703 ),
1704 ] {
1705 let result = openlineage_column_lineage(sql, &opts).expect("lineage");
1706 assert!(result.warnings.is_empty(), "{sql}: {:?}", result.warnings);
1707 let fields: Vec<_> = result
1708 .facet
1709 .fields
1710 .iter()
1711 .map(|(name, field)| {
1712 let inputs: Vec<_> = field
1713 .input_fields
1714 .iter()
1715 .map(|f| f.field.as_str())
1716 .collect();
1717 (name.as_str(), inputs)
1718 })
1719 .collect();
1720 assert_eq!(fields, expected, "{sql}");
1721 }
1722 }
1723
1724 #[test]
1725 fn select_star_expands_cte_fields() {
1726 let mut opts = options();
1727 opts.schema = Some(
1728 serde_json::from_value(json!({
1729 "tables": [{
1730 "name": "orders",
1731 "schema": "s",
1732 "columns": [
1733 {"name": "order_id", "type": "INT"},
1734 {"name": "amount", "type": "INT"}
1735 ]
1736 }]
1737 }))
1738 .expect("schema"),
1739 );
1740
1741 for (sql, fields) in [
1742 (
1743 "WITH c AS (SELECT * FROM s.orders) SELECT * FROM c",
1744 vec!["amount", "order_id"],
1745 ),
1746 (
1747 "WITH a AS (SELECT * FROM s.orders), c AS (SELECT * FROM a) SELECT c.* FROM c",
1748 vec!["amount", "order_id"],
1749 ),
1750 (
1751 "WITH c AS (SELECT order_id, amount FROM s.orders) SELECT * FROM c WHERE amount > 0",
1752 vec!["amount", "order_id"],
1753 ),
1754 ] {
1755 let result = openlineage_column_lineage(sql, &opts).expect("lineage");
1756 assert!(result.warnings.is_empty(), "{sql}: {:?}", result.warnings);
1757 assert_eq!(
1758 result.facet.fields.keys().collect::<Vec<_>>(),
1759 fields,
1760 "{sql}"
1761 );
1762 for field in fields {
1763 let inputs = &result.facet.fields[field].input_fields;
1764 assert_eq!(inputs.len(), 1, "{sql}: {field}");
1765 assert_eq!(inputs[0].name, "s.orders", "{sql}: {field}");
1766 assert_eq!(inputs[0].field, field, "{sql}: {field}");
1767 assert_eq!(
1768 inputs[0].transformations[0].subtype, "IDENTITY",
1769 "{sql}: {field}"
1770 );
1771 }
1772 }
1773 }
1774
1775 #[test]
1776 fn select_star_expands_derived_table_fields() {
1777 let mut opts = options();
1778 opts.schema = Some(
1779 serde_json::from_value(json!({
1780 "tables": [{
1781 "name": "customers",
1782 "schema": "s",
1783 "columns": [
1784 {"name": "customer_id", "type": "INT"},
1785 {"name": "created_at", "type": "TIMESTAMP"}
1786 ]
1787 }]
1788 }))
1789 .expect("schema"),
1790 );
1791
1792 for sql in [
1793 "SELECT * FROM (SELECT * FROM s.customers) sbq",
1794 "SELECT sbq.* FROM (SELECT * FROM s.customers) sbq",
1795 "SELECT *, created_at AS dbt_updated_at FROM (SELECT * FROM s.customers) sbq",
1796 ] {
1797 let result = openlineage_column_lineage(sql, &opts).expect("lineage");
1798 for field in ["customer_id", "created_at"] {
1799 let inputs = &result.facet.fields[field].input_fields;
1800 assert_eq!(inputs.len(), 1, "{sql}: {field}");
1801 assert_eq!(inputs[0].name, "s.customers", "{sql}: {field}");
1802 assert_eq!(inputs[0].field, field, "{sql}: {field}");
1803 assert_eq!(
1804 inputs[0].transformations[0].subtype, "IDENTITY",
1805 "{sql}: {field}"
1806 );
1807 }
1808 }
1809 }
1810}