1use crate::dialects::{Dialect, DialectType};
8use crate::expressions::*;
9use crate::lineage::{self, LineageNode, SetOperator};
10use crate::schema::Schema;
11use crate::scope::SourceKind;
12use crate::traversal::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#[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#[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#[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#[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
192pub 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
275pub 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
304pub 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}