Skip to content

Commit 1208d38

Browse files
committed
Fix #247, #248, #249, version bump to 0.5.8
1 parent 44a2086 commit 1208d38

23 files changed

Lines changed: 1204 additions & 163 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ All notable changes to this project are documented in this file.
44

55
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
66

7+
## [0.5.8] - 2026-06-22
8+
9+
### Added
10+
- Regression coverage for nested set-operation derived table lineage, scalar
11+
subqueries inside expression wrappers, same-select alias references,
12+
pivot/unpivot alias columns, and semi/anti join source inference across Rust
13+
core, C FFI, WASM/TypeScript, Python, and Go.
14+
15+
### Fixed
16+
- Lineage now registers aliased query-like derived relations such as nested
17+
`UNION` expressions in `FROM`, so output columns trace back to each physical
18+
source table instead of stopping at the derived relation.
19+
- Scalar subqueries wrapped in expressions such as `CASE`, `COALESCE`, `CAST`,
20+
`BETWEEN`, `IN`, `ANY`, `ALL`, and `EXISTS` now contribute their source-column
21+
lineage.
22+
- Same-select alias references now resolve through earlier projection
23+
expressions before column qualification, avoiding unresolved or incorrect
24+
physical-column attribution.
25+
- Pivot and unpivot lineage now preserves alias column lists and maps renamed or
26+
generated outputs back to implicit source columns and aggregate input columns.
27+
- Star and source inference now ignores semi/anti join right-hand inputs,
28+
preventing non-output join sources from making schema-less star lineage
29+
ambiguous.
30+
731
## [0.5.7] - 2026-06-19
832

933
### Added

Cargo.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ exclude = [
1212
]
1313

1414
[workspace.package]
15-
version = "0.5.7"
15+
version = "0.5.8"
1616
edition = "2021"
1717
license = "MIT"
1818
authors = ["polyglot contributors"]

crates/polyglot-sql-ffi/tests/ffi_tests.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,31 @@ fn test_lineage_schema_less_cte_star_passthrough() {
728728
);
729729
}
730730

731+
#[test]
732+
fn test_lineage_nested_set_operation_inside_derived_table() {
733+
let column = c("v");
734+
let sql = c(
735+
"SELECT v FROM ((SELECT v FROM t1 UNION ALL SELECT v FROM t2) UNION ALL SELECT v FROM t3) u",
736+
);
737+
let dialect = c("duckdb");
738+
let (status, data, error) = consume_result(polyglot_lineage(
739+
column.as_ptr(),
740+
sql.as_ptr(),
741+
dialect.as_ptr(),
742+
));
743+
assert_eq!(status, 0, "error={error:?}");
744+
let node: Value = serde_json::from_str(&data.expect("missing lineage")).expect("invalid json");
745+
746+
let mut names = Vec::new();
747+
collect_lineage_names(&node, &mut names);
748+
assert!(
749+
names.iter().any(|name| name == "t1.v")
750+
&& names.iter().any(|name| name == "t2.v")
751+
&& names.iter().any(|name| name == "t3.v"),
752+
"expected set operation source columns in lineage names, got {names:?}"
753+
);
754+
}
755+
731756
#[test]
732757
fn test_lineage_recursive_cte_terminates() {
733758
let column = c("n");
@@ -889,6 +914,42 @@ fn test_analyze_query_cte_facts_and_star_projections() {
889914
);
890915
}
891916

917+
#[test]
918+
fn test_analyze_query_pivot_alias_columns() {
919+
let sql = c(
920+
"SELECT region2, p1 FROM (SELECT region, q, amt FROM sales) PIVOT(SUM(amt) FOR q IN ('Q1')) AS p(region2, p1)",
921+
);
922+
let options = c(r#"{"dialect":"duckdb"}"#);
923+
let (status, data, error) =
924+
consume_result(polyglot_analyze_query(sql.as_ptr(), options.as_ptr()));
925+
assert_eq!(status, 0, "error={error:?}");
926+
let analysis: Value =
927+
serde_json::from_str(&data.expect("missing analyze_query payload")).expect("invalid json");
928+
929+
let projections = analysis["projections"]
930+
.as_array()
931+
.expect("projections array");
932+
let region = projections
933+
.iter()
934+
.find(|projection| projection["name"] == "region2")
935+
.expect("region2 projection");
936+
assert!(region["upstream"]
937+
.as_array()
938+
.unwrap()
939+
.iter()
940+
.any(|reference| { reference["table"] == "sales" && reference["column"] == "region" }));
941+
942+
let pivot_value = projections
943+
.iter()
944+
.find(|projection| projection["name"] == "p1")
945+
.expect("p1 projection");
946+
assert!(pivot_value["upstream"]
947+
.as_array()
948+
.unwrap()
949+
.iter()
950+
.any(|reference| reference["table"] == "sales" && reference["column"] == "amt"));
951+
}
952+
892953
#[test]
893954
fn test_analyze_query_invalid_options_json() {
894955
let sql = c("SELECT 1");

crates/polyglot-sql-python/tests/test_lineage.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ def test_lineage_window_over_columns():
4646
assert "events.ts" in names
4747

4848

49+
def test_lineage_nested_set_operation_inside_derived_table():
50+
sql = (
51+
"SELECT v FROM ((SELECT v FROM t1 UNION ALL SELECT v FROM t2) "
52+
"UNION ALL SELECT v FROM t3) u"
53+
)
54+
result = polyglot_sql.lineage("v", sql, dialect="duckdb")
55+
56+
names = collect_names(result)
57+
assert "t1.v" in names
58+
assert "t2.v" in names
59+
assert "t3.v" in names
60+
61+
4962
def test_source_tables_returns_orders():
5063
sql = "SELECT o.total FROM orders o JOIN users u ON o.user_id = u.id"
5164
tables = polyglot_sql.source_tables("total", sql, dialect="postgres")

crates/polyglot-sql-python/tests/test_query_analysis.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,32 @@ def test_analyze_query_reports_cte_facts_and_star_projections():
122122
assert result["starProjections"][0]["expandedColumns"] == ["id", "amount"]
123123

124124

125+
def test_analyze_query_resolves_pivot_alias_columns():
126+
result = polyglot_sql.analyze_query(
127+
"SELECT region2, p1 FROM (SELECT region, q, amt FROM sales) "
128+
"PIVOT(SUM(amt) FOR q IN ('Q1')) AS p(region2, p1)",
129+
{"dialect": "duckdb"},
130+
)
131+
132+
region = next(
133+
projection
134+
for projection in result["projections"]
135+
if projection["name"] == "region2"
136+
)
137+
assert any(
138+
reference["table"] == "sales" and reference["column"] == "region"
139+
for reference in region["upstream"]
140+
)
141+
142+
pivot_value = next(
143+
projection for projection in result["projections"] if projection["name"] == "p1"
144+
)
145+
assert any(
146+
reference["table"] == "sales" and reference["column"] == "amt"
147+
for reference in pivot_value["upstream"]
148+
)
149+
150+
125151
def test_analyze_query_unknown_dialect_raises_value_error():
126152
with pytest.raises(ValueError):
127153
polyglot_sql.analyze_query("SELECT 1", dialect="not_a_dialect")

crates/polyglot-sql-wasm/src/lib.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3290,6 +3290,26 @@ mod tests {
32903290
);
32913291
}
32923292

3293+
#[test]
3294+
fn test_lineage_nested_set_operation_inside_derived_table() {
3295+
let result = lineage_sql(
3296+
"SELECT v FROM ((SELECT v FROM t1 UNION ALL SELECT v FROM t2) UNION ALL SELECT v FROM t3) u",
3297+
"v",
3298+
"duckdb",
3299+
false,
3300+
);
3301+
assert!(result.contains("\"success\":true"), "Result: {}", result);
3302+
let result: serde_json::Value = serde_json::from_str(&result).expect("valid wrapper json");
3303+
let lineage = result["lineage"].as_object().expect("lineage object");
3304+
let names = collect_lineage_names(&serde_json::Value::Object(lineage.clone()));
3305+
assert!(
3306+
names.iter().any(|name| name == "t1.v")
3307+
&& names.iter().any(|name| name == "t2.v")
3308+
&& names.iter().any(|name| name == "t3.v"),
3309+
"expected set operation source columns in lineage names, got {names:?}"
3310+
);
3311+
}
3312+
32933313
#[test]
32943314
fn test_lineage_bigquery_unnest_virtual_source_metadata() {
32953315
let result = lineage_sql(
@@ -3536,6 +3556,34 @@ mod tests {
35363556
parsed["analysis"]["starProjections"][0]["expandedColumns"][1],
35373557
"amount"
35383558
);
3559+
3560+
let result = analyze_query(
3561+
"SELECT region2, p1 FROM (SELECT region, q, amt FROM sales) PIVOT(SUM(amt) FOR q IN ('Q1')) AS p(region2, p1)",
3562+
r#"{"dialect":"duckdb"}"#,
3563+
);
3564+
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid json");
3565+
assert_eq!(parsed["success"], true);
3566+
let projections = parsed["analysis"]["projections"]
3567+
.as_array()
3568+
.expect("projections array");
3569+
let region = projections
3570+
.iter()
3571+
.find(|projection| projection["name"] == "region2")
3572+
.expect("region2 projection");
3573+
assert!(region["upstream"]
3574+
.as_array()
3575+
.unwrap()
3576+
.iter()
3577+
.any(|reference| { reference["table"] == "sales" && reference["column"] == "region" }));
3578+
let pivot_value = projections
3579+
.iter()
3580+
.find(|projection| projection["name"] == "p1")
3581+
.expect("p1 projection");
3582+
assert!(pivot_value["upstream"]
3583+
.as_array()
3584+
.unwrap()
3585+
.iter()
3586+
.any(|reference| reference["table"] == "sales" && reference["column"] == "amt"));
35393587
}
35403588

35413589
#[test]

crates/polyglot-sql/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ thiserror = { workspace = true }
105105
unicode-segmentation = { workspace = true }
106106
stacker = { version = "0.1", optional = true }
107107
ts-rs = { version = "12.0", features = ["serde-compat"], optional = true }
108-
polyglot-sql-function-catalogs = { path = "../polyglot-sql-function-catalogs", version = "0.5.7", optional = true, default-features = false }
108+
polyglot-sql-function-catalogs = { path = "../polyglot-sql-function-catalogs", version = "0.5.8", optional = true, default-features = false }
109109

110110
[dev-dependencies]
111111
pretty_assertions = "1.4"

crates/polyglot-sql/src/expressions.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4118,6 +4118,9 @@ pub struct Pivot {
41184118
/// Optional alias
41194119
#[serde(default)]
41204120
pub alias: Option<Identifier>,
4121+
/// Optional output column aliases from `PIVOT(...) AS alias(col1, col2, ...)`
4122+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
4123+
pub alias_columns: Vec<Identifier>,
41214124
/// Include/exclude nulls (for UNPIVOT)
41224125
#[serde(default)]
41234126
pub include_nulls: Option<bool>,
@@ -4138,6 +4141,9 @@ pub struct Unpivot {
41384141
pub name_column: Identifier,
41394142
pub columns: Vec<Expression>,
41404143
pub alias: Option<Identifier>,
4144+
/// Optional output column aliases from `UNPIVOT(...) AS alias(col1, col2, ...)`
4145+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
4146+
pub alias_columns: Vec<Identifier>,
41414147
/// Whether the value_column was parenthesized in the original SQL
41424148
#[serde(default)]
41434149
pub value_column_parenthesized: bool,

crates/polyglot-sql/src/generator.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23391,6 +23391,7 @@ impl Generator {
2339123391
self.write_keyword("AS");
2339223392
self.write_space();
2339323393
self.generate_identifier(alias)?;
23394+
self.generate_alias_column_list(&pivot.alias_columns)?;
2339423395
}
2339523396

2339623397
Ok(())
@@ -23442,10 +23443,27 @@ impl Generator {
2344223443
self.write_keyword("AS");
2344323444
self.write_space();
2344423445
self.generate_identifier(alias)?;
23446+
self.generate_alias_column_list(&unpivot.alias_columns)?;
2344523447
}
2344623448
Ok(())
2344723449
}
2344823450

23451+
fn generate_alias_column_list(&mut self, columns: &[Identifier]) -> Result<()> {
23452+
if columns.is_empty() {
23453+
return Ok(());
23454+
}
23455+
23456+
self.write("(");
23457+
for (i, column) in columns.iter().enumerate() {
23458+
if i > 0 {
23459+
self.write(", ");
23460+
}
23461+
self.generate_identifier(column)?;
23462+
}
23463+
self.write(")");
23464+
Ok(())
23465+
}
23466+
2344923467
fn generate_values(&mut self, values: &Values) -> Result<()> {
2345023468
self.write_keyword("VALUES");
2345123469
for (i, row) in values.expressions.iter().enumerate() {

0 commit comments

Comments
 (0)