From 2a846910d24a0a7271a3d1c67060883787f9fc18 Mon Sep 17 00:00:00 2001 From: eitsupi Date: Sat, 22 Aug 2026 01:24:37 +0000 Subject: [PATCH 1/2] fix: make column mapping order deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The order of `ColumnLineage.mappings` varied between runs of the same binary whenever a query had duplicate output column names: SELECT a.id, b.id FROM a JOIN b ON a.id = b.bid sometimes [ id <- a.id , id <- b.id ] sometimes [ id <- b.id , id <- a.id ] With three duplicates, four distinct orderings showed up across six runs. Callers that cache or diff results see the same input produce different output. `resolve` collected the output nodes into a `HashSet` and built the mappings by iterating it, so construction order followed hash order with a per-process random seed. The sort afterwards could not undo this: it keyed on a `HashMap` of output names, so duplicate names collided and one index won, leaving same-named mappings in whatever order the set had produced. `ordered_cols` already holds the projection order, so build the mappings straight from it. That makes the sort redundant — it can only reproduce the order the loop now has, and it is the reason duplicates were reordered in the first place — so it goes too. Co-authored-by: Claude Opus 5 --- sqllineage/src/resolve/mod.rs | 23 +++-------------- sqllineage/tests/column_lineage.rs | 40 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/sqllineage/src/resolve/mod.rs b/sqllineage/src/resolve/mod.rs index 7a596c3..8b7e732 100644 --- a/sqllineage/src/resolve/mod.rs +++ b/sqllineage/src/resolve/mod.rs @@ -1,7 +1,7 @@ mod catalog; mod topo; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use crate::graph::RawGraph; use crate::graph::edge::EdgeKind; @@ -53,12 +53,11 @@ pub(crate) fn resolve( let root = ScopeTree::root(); let ordered_cols = graph.scopes.output_columns(root).to_vec(); - let final_ids: HashSet = ordered_cols.iter().map(|c| c.node_id).collect(); - let output_table = graph.tables.output.clone(); let mut mappings = Vec::new(); - for &node_id in &final_ids { + for col in &ordered_cols { + let node_id = col.node_id; match &graph.nodes[node_id] { RawNode::Output { name, .. } => { let mut visited = HashSet::new(); @@ -104,22 +103,6 @@ pub(crate) fn resolve( } } - let name_order: HashMap = ordered_cols - .iter() - .enumerate() - .filter_map(|(i, c)| match &graph.nodes[c.node_id] { - RawNode::Output { name, .. } => Some((name.clone(), i)), - RawNode::Star { .. } => Some(("*".to_string(), i)), - _ => None, - }) - .collect(); - mappings.sort_by_key(|m| { - name_order - .get(&m.target.column) - .copied() - .unwrap_or(usize::MAX) - }); - if let Some(cat) = catalog { catalog::apply_catalog(&mut mappings, cat); } diff --git a/sqllineage/tests/column_lineage.rs b/sqllineage/tests/column_lineage.rs index 3418c24..a183107 100644 --- a/sqllineage/tests/column_lineage.rs +++ b/sqllineage/tests/column_lineage.rs @@ -80,6 +80,46 @@ fn select_multiple_tables_qualified() { assert_eq!(concrete_sources(m_b), vec![("t2".into(), "b".into())]); } +#[test] +fn duplicate_output_names_preserve_projection_order() { + let result = analyze_one("SELECT a.id, b.id FROM a JOIN b ON a.id = b.bid"); + let sources: Vec<_> = result + .columns + .mappings + .iter() + .map(concrete_sources) + .collect(); + + assert_eq!( + sources, + vec![ + vec![("a".into(), "id".into())], + vec![("b".into(), "id".into())] + ] + ); +} + +#[test] +fn three_duplicate_output_names_preserve_projection_order() { + let result = + analyze_one("SELECT a.id, b.id, c.id FROM a JOIN b ON a.id = b.bid JOIN c ON a.id = c.cid"); + let sources: Vec<_> = result + .columns + .mappings + .iter() + .map(concrete_sources) + .collect(); + + assert_eq!( + sources, + vec![ + vec![("a".into(), "id".into())], + vec![("b".into(), "id".into())], + vec![("c".into(), "id".into())], + ] + ); +} + #[test] fn select_case_expression() { let result = analyze_one("SELECT CASE WHEN a > 0 THEN b ELSE c END AS d FROM t"); From eab038f4a948df0cd07fa881233bb26743a58b2c Mon Sep 17 00:00:00 2001 From: funcpp Date: Mon, 21 Sep 2026 14:19:33 +0900 Subject: [PATCH 2/2] test: drop the redundant three-duplicate ordering test `three_duplicate_output_names_preserve_projection_order` covered the same path as the two-column test. It earned its place while the bug existed, when a wrong order could pass by luck; with the `HashSet` gone, mapping order follows `ordered_cols` structurally and a third duplicate exercises nothing new. Co-Authored-By: Claude Opus 5 (1M context) --- sqllineage/tests/column_lineage.rs | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/sqllineage/tests/column_lineage.rs b/sqllineage/tests/column_lineage.rs index a183107..c906f68 100644 --- a/sqllineage/tests/column_lineage.rs +++ b/sqllineage/tests/column_lineage.rs @@ -99,27 +99,6 @@ fn duplicate_output_names_preserve_projection_order() { ); } -#[test] -fn three_duplicate_output_names_preserve_projection_order() { - let result = - analyze_one("SELECT a.id, b.id, c.id FROM a JOIN b ON a.id = b.bid JOIN c ON a.id = c.cid"); - let sources: Vec<_> = result - .columns - .mappings - .iter() - .map(concrete_sources) - .collect(); - - assert_eq!( - sources, - vec![ - vec![("a".into(), "id".into())], - vec![("b".into(), "id".into())], - vec![("c".into(), "id".into())], - ] - ); -} - #[test] fn select_case_expression() { let result = analyze_one("SELECT CASE WHEN a > 0 THEN b ELSE c END AS d FROM t");