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