Skip to main content

polyglot_sql/
openlineage.rs

1//! OpenLineage-compatible payload generation.
2//!
3//! This module only builds OpenLineage JSON-compatible structures from SQL
4//! analysis. It deliberately does not implement transports, clients, retries,
5//! buffering, or runtime lifecycle management.
6
7use 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/// Dataset identity in OpenLineage (`namespace`, `name`).
31#[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/// Options shared by OpenLineage payload generation helpers.
48#[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/// OpenLineage run event type.
66#[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/// Non-fatal issue encountered while generating OpenLineage output.
78#[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
193/// Produce a standalone OpenLineage columnLineage facet plus inferred datasets.
194pub 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    // Built on first use: a star field of a base table needs no lineage.
235    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
279/// Produce an OpenLineage JobEvent as JSON.
280pub 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
308/// Produce an OpenLineage RunEvent as JSON.
309pub 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    // A star over a CTE is not in the schema. Expand it from the CTE output columns.
465    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            // Derived table columns resolve through the lineage of the whole query.
957            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
975/// A FROM or JOIN source that a star can expand.
976enum 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            // A PIVOT or UNPIVOT expands as the derived table `SELECT * FROM <pivot>`.
1025            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            // Snowflake normalizes unquoted schema columns to upper case.
1618            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}