Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion sql-insight/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ path = "src/lib.rs"
serde = ["dep:serde"]

[dependencies]
sqlparser = { version = "0.61.0", features = ["visitor"] }
sqlparser = { version = "0.62.0", features = ["visitor"] }
thiserror = "1.0.56"
serde = { version = "1.0.228", features = ["derive"], optional = true }

Expand Down
33 changes: 24 additions & 9 deletions sql-insight/src/normalizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ use std::ops::{ControlFlow, Deref};

use crate::error::Error;
use sqlparser::ast::{Expr, Insert, Statement, VisitMut, VisitorMut};
use sqlparser::ast::{Query, SetExpr, TopQuantity, Value};
use sqlparser::ast::{Parens, Query, SetExpr, TopQuantity, Value, ValueWithSpan};
use sqlparser::dialect::Dialect;
use sqlparser::parser::Parser;
use std::ops::DerefMut;
Expand Down Expand Up @@ -141,9 +141,12 @@ impl VisitorMut for Normalizer {
row.is_empty() || row.iter().all(|expr| matches!(expr, Expr::Value(_)))
})
{
*rows = vec![vec![Expr::Value(
// `Values::rows` is `Vec<Parens<Vec<Expr>>>` (each row
// tracks its own parentheses tokens); wrap the collapsed
// sentinel row accordingly.
*rows = vec![Parens::with_empty_span(vec![Expr::Value(
Value::Placeholder("...".into()).with_empty_span(),
)]];
)])];
}
}
}
Expand All @@ -164,13 +167,24 @@ impl VisitorMut for Normalizer {
{
if let Some(Query { body, .. }) = source.as_deref() {
if let SetExpr::Values(v) = body.deref() {
// `Parens` equality ignores its parenthesis tokens
// (their `PartialEq` is always-equal), so this compares
// the row content alone — matching the sentinel above.
if v.rows
== vec![vec![Expr::Value(
== vec![Parens::with_empty_span(vec![Expr::Value(
Value::Placeholder("...".into()).with_empty_span(),
)]]
)])]
{
if columns.len() > 1 {
columns.sort_by_key(|s| s.value.to_lowercase());
// `Insert::columns` is now `Vec<ObjectName>`;
// sort by the (unquoted) final identifier part,
// preserving the old `Ident::value` key.
columns.sort_by_key(|s| {
s.0.last()
.and_then(|p| p.as_ident())
.map(|i| i.value.to_lowercase())
.unwrap_or_default()
});
}
if after_columns.len() > 1 {
after_columns.sort_by_key(|s| s.value.to_lowercase());
Expand Down Expand Up @@ -199,13 +213,14 @@ impl VisitorMut for Normalizer {
ControlFlow::Continue(())
}

fn pre_visit_value(&mut self, value: &mut Value) -> ControlFlow<Self::Break> {
fn pre_visit_value(&mut self, value: &mut ValueWithSpan) -> ControlFlow<Self::Break> {
// The base contract: *every* literal `Value` becomes `?`, wherever the
// AST holds it. `pre_visit_expr` only catches an `Expr::Value`; a
// literal kept in a bare `Value` field — `DATE '…'` / `TIMESTAMP '…'`
// (`TypedString`), a `LIKE … ESCAPE '!'` char, a `MATCH … AGAINST '…'`
// search string — is reached only through this hook.
*value = Value::Placeholder("?".into());
// search string — is reached only through this hook. The visitor now
// hands us a `ValueWithSpan`; rewrite the inner `value`, keeping the span.
value.value = Value::Placeholder("?".into());
ControlFlow::Continue(())
}

Expand Down
30 changes: 29 additions & 1 deletion sql-insight/src/reference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,18 @@ impl TableReference {
let name = match &value.table {
TableObject::TableName(object_name) => object_name,
TableObject::TableFunction(function) => &function.name,
// Oracle `INSERT INTO (SELECT …) …`: a subquery target names no
// stored table, so it can't become a `TableReference`.
TableObject::TableQuery(_) => {
return Err(Error::AnalysisError(
"INSERT target is a subquery, not a named table".to_string(),
))
}
};
Ok((Self::try_from_name(name)?, value.table_alias.clone()))
// `Insert::table_alias` is now a `TableAliasWithoutColumns`; the public
// pair still exposes just the alias identifier.
let alias = value.table_alias.as_ref().map(|a| a.alias.clone());
Ok((Self::try_from_name(name)?, alias))
}

/// Parse a `TableFactor::Table` into (identity, alias) pair. Other
Expand Down Expand Up @@ -614,4 +624,22 @@ mod tests {
assert_eq!(reference.schema.as_ref().unwrap().value, "a");
assert_eq!(reference.name.value, "b");
}

#[test]
fn from_insert_with_alias_extracts_the_target_alias_identifier() {
// `INSERT INTO t AS foo …` (PostgreSQL): the target alias is a
// `TableAliasWithoutColumns`. The pair keeps the base table (`t`) plus
// just the alias identifier (`foo`), dropping the `explicit`
// (`AS`-was-written) flag the analysis doesn't need.
use sqlparser::dialect::PostgreSqlDialect;
let mut stmts =
Parser::parse_sql(&PostgreSqlDialect {}, "INSERT INTO t AS foo (a) VALUES (1)")
.unwrap();
let Statement::Insert(insert) = stmts.remove(0) else {
panic!("expected an insert");
};
let (reference, alias) = TableReference::from_insert_with_alias(&insert).unwrap();
assert_eq!(reference.name.value, "t");
assert_eq!(alias.unwrap().value, "foo");
}
}
12 changes: 11 additions & 1 deletion sql-insight/src/resolver/binder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,10 @@ fn alter_table_op_target_columns(op: &AlterTableOperation) -> Vec<Ident> {
| AlterTableOperation::OwnerTo { .. }
| AlterTableOperation::ClusterBy { .. }
| AlterTableOperation::DropClusteringKey
// Redshift `ALTER SORTKEY (cols)` reorders storage; the columns are
// references to existing columns, not columns this op writes/creates —
// so, like `ClusterBy`, it names no target column.
| AlterTableOperation::AlterSortKey { .. }
| AlterTableOperation::SuspendRecluster
| AlterTableOperation::ResumeRecluster
| AlterTableOperation::Refresh { .. }
Expand Down Expand Up @@ -736,7 +740,13 @@ fn join_constraint(op: &JoinOperator) -> Option<&JoinConstraint> {
| JoinOperator::RightAnti(c)
| JoinOperator::StraightJoin(c) => Some(c),
JoinOperator::AsOf { constraint, .. } => Some(constraint),
JoinOperator::CrossApply | JoinOperator::OuterApply => None,
// No `ON`/`USING` predicate: APPLY correlates through the relation
// itself; ClickHouse `ARRAY JOIN` unnests an array expression inline.
JoinOperator::CrossApply
| JoinOperator::OuterApply
| JoinOperator::ArrayJoin
| JoinOperator::LeftArrayJoin
| JoinOperator::InnerArrayJoin => None,
}
}

Expand Down
29 changes: 28 additions & 1 deletion sql-insight/src/resolver/binder/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,31 @@ impl<'a> Binder<'a> {
name: Some(alias.clone()),
expr: self.bind_expr(expr, scope),
}],
// Spark `expr AS (a, b, …)`: one expression projected under several
// output names (e.g. `explode(arr) AS (key, value)`). Because
// `reads` is occurrence-based, the expression's columns must be
// counted exactly once — so only the first alias carries the bound
// expression (with its reads / lineage); the remaining aliases are
// extra output columns that trace to nothing, like a
// `(VALUES …) AS v(x)` derived column. Splitting one expression's
// lineage across N outputs isn't representable without re-reading
// it, so those tail outputs are best-effort.
SelectItem::ExprWithAliases { expr, aliases } => {
let bound = self.bind_expr(expr, scope);
let mut names = aliases.iter();
let head = NamedExpr {
name: names.next().cloned(),
expr: bound,
};
std::iter::once(head)
.chain(names.map(|a| NamedExpr {
name: Some(a.clone()),
// An empty `Call` reads nothing and originates from
// nothing (a `Derived` output).
expr: Expr::Call { args: Vec::new() },
}))
.collect()
}
// A wildcard isn't expanded (the rigor cost is too high for a
// SQL-text-only library); record it so consumers know this
// projection's column lineage is incomplete. A `REPLACE (expr AS
Expand Down Expand Up @@ -277,7 +302,9 @@ impl<'a> Binder<'a> {
// resolves its own columns first, the enclosing query last.
SqlExpr::Lambda(lambda) => self.in_lambda(
scope.relations.clone(),
lambda.params.iter().cloned(),
// A lambda param is now a `LambdaFunctionParameter` (name +
// optional type); the binder only tracks the bound name.
lambda.params.iter().map(|p| p.name.clone()),
|b| b.call([lambda.body.as_ref()], &Scope::default()),
),
SqlExpr::MemberOf(member_of) => {
Expand Down
4 changes: 3 additions & 1 deletion sql-insight/src/resolver/binder/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,9 @@ impl<'a> Binder<'a> {
/// falling through to the correlation stack (a `(VALUES (t.a)) AS v` reads
/// the enclosing / sibling `t.a` like a derived subquery's body).
pub(super) fn bind_values(&mut self, values: &SqlValues) -> (LogicalPlan, Scope) {
let width = values.rows.iter().map(Vec::len).max().unwrap_or(0);
// `rows` is `Vec<Parens<Vec<Expr>>>`; each `Parens` derefs to its inner
// row `Vec`, so `row.len()` is the column count.
let width = values.rows.iter().map(|row| row.len()).max().unwrap_or(0);
let rows: Vec<Vec<Expr>> = values
.rows
.iter()
Expand Down
24 changes: 20 additions & 4 deletions sql-insight/src/resolver/binder/statement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ impl<'a> Binder<'a> {
let name = match &insert.table {
TableObject::TableName(name) => name,
TableObject::TableFunction(function) => &function.name,
// Oracle `INSERT INTO (SELECT …) …`: the target is a subquery, not a
// writable base table — drop + flag (like a CTE / derived target).
TableObject::TableQuery(query) => {
self.record_unsupported_dml_target("INSERT", query.as_ref());
return LogicalPlan::Empty;
}
};
let Some(written) = self.table_ref(name) else {
return LogicalPlan::Empty;
Expand Down Expand Up @@ -153,7 +159,13 @@ impl<'a> Binder<'a> {
// the drop + flag guard below fires rather than mis-truncating to the
// undercounted outputs.
let columns = if !insert.columns.is_empty() {
insert.columns.clone()
// `Insert::columns` is `Vec<ObjectName>`; a target column is named
// by its final identifier part (mirrors the MERGE-insert path).
insert
.columns
.iter()
.filter_map(|n| n.0.last().and_then(|p| p.as_ident().cloned()))
.collect()
} else if source_wildcard {
Vec::new()
} else {
Expand Down Expand Up @@ -575,11 +587,13 @@ impl<'a> Binder<'a> {
} else {
explicit
};
// A MERGE INSERT is a single VALUES row.
// A MERGE INSERT is a single VALUES row. Each row is
// now a `Parens<Vec<Expr>>`; flatten through its
// inner expressions (via `Deref`).
let row: Vec<Expr> = values
.rows
.iter()
.flatten()
.flat_map(|row| row.iter())
.map(|e| self.bind_expr(e, &scope))
.collect();
// Column-list-less and no catalog to fill the target
Expand Down Expand Up @@ -1226,7 +1240,9 @@ fn source_has_wildcard(query: &Query) -> bool {
match body {
SetExpr::Select(select) => select.projection.iter().any(|item| match item {
SelectItem::Wildcard(_) | SelectItem::QualifiedWildcard(..) => true,
SelectItem::UnnamedExpr(_) | SelectItem::ExprWithAlias { .. } => false,
SelectItem::UnnamedExpr(_)
| SelectItem::ExprWithAlias { .. }
| SelectItem::ExprWithAliases { .. } => false,
}),
SetExpr::Query(q) => body_has_wildcard(&q.body),
SetExpr::SetOperation { left, right, .. } => {
Expand Down
32 changes: 28 additions & 4 deletions sql-insight/tests/column_operation_extractor/arm_coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ use crate::support::*;
/// Generic parse is quirky are reshaped (`Interval` via `+`, `Subscript`
/// on a bare identifier) or omitted (`Convert` parses its type arg as a
/// value and drops the real column; `Interpolate` rejects a qualified
/// name) and are left to higher-level tests.
/// name) and are left to higher-level tests. Two arms need a non-Generic
/// parse: `ARRAY[...]` literals (`reads_pg`, PostgreSQL) and lambda
/// `x -> …` (`reads_duck`, DuckDB — `GenericDialect` parses `->` as the
/// JSON-arrow operator, not a lambda).
#[cfg(test)]
mod expr_arm_coverage {
use super::*;
Expand All @@ -38,6 +41,18 @@ mod expr_arm_coverage {
.reads
}

/// Like [`reads`], but parses under DuckDB — the built-in dialect used
/// for lambda `x -> …` syntax (`GenericDialect` parses `->` as the
/// JSON-arrow operator instead).
fn reads_duck(sql: &str) -> Vec<ColumnRead> {
use sql_insight::sqlparser::dialect::DuckDbDialect;
extract_column_operations(&DuckDbDialect {}, sql)
.unwrap()
.remove(0)
.unwrap()
.reads
}

/// `lineage` of the first statement, resolved without a catalog.
fn lineage(sql: &str) -> Vec<ColumnLineageEdge> {
extract_column_operations(&GenericDialect {}, sql)
Expand Down Expand Up @@ -138,21 +153,30 @@ mod expr_arm_coverage {

#[test]
fn lambda() {
// Lambdas parse under DuckDB (`GenericDialect` reads `->` as the
// JSON-arrow operator, which would leak the parameter as a column).
// A real column in the body is read; the lambda parameter is a local,
// not a column.
assert_unordered_eq!(
reads("SELECT transform(t.arr, x -> t.a) FROM t"),
reads_duck("SELECT transform(t.arr, x -> t.a) FROM t"),
vec![c("arr"), c("a")]
);
// A bare reference to the parameter `x` adds no read (a previous
// version wrongly read `t.x`).
assert_unordered_eq!(
reads("SELECT transform(t.arr, x -> x + 1) FROM t"),
reads_duck("SELECT transform(t.arr, x -> x + 1) FROM t"),
vec![c("arr")]
);
// Mixed: the parameter is suppressed, the real column `t.a` stays.
assert_unordered_eq!(
reads("SELECT transform(t.arr, x -> x + t.a) FROM t"),
reads_duck("SELECT transform(t.arr, x -> x + t.a) FROM t"),
vec![c("arr"), c("a")]
);
// A *typed* parameter (`x INT`): sqlparser now carries an optional
// data type on each lambda parameter, but the binder only tracks the
// name — so `x` is still a local and the type `INT` is not a read.
assert_unordered_eq!(
reads_duck("SELECT transform(t.arr, x INT -> x + t.a) FROM t"),
vec![c("arr"), c("a")]
);
}
Expand Down
20 changes: 19 additions & 1 deletion sql-insight/tests/column_operation_extractor/cte_and_dml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,7 @@ mod alter_table {
//! BOTH the old and new names — both ends of the rename are
//! useful for downstream lineage consumers tracking column
//! history. Schema-level operations (constraints, partitions,
//! RENAME TABLE) contribute no column writes.
//! RENAME TABLE, CLUSTER BY, SORTKEY) contribute no column writes.
use super::*;

#[test]
Expand Down Expand Up @@ -1093,6 +1093,24 @@ mod alter_table {
},
);
}

#[test]
fn alter_table_alter_sort_key_emits_no_column_writes() {
// Redshift `ALTER SORTKEY (cols)` reorders storage and only
// references existing columns — like ADD CONSTRAINT / CLUSTER BY
// it's schema-level, so no column writes surface (the table itself
// stays in table_op writes).
assert_column_ops(
"ALTER TABLE t ALTER SORTKEY (a, b)",
ColumnOperation {
statement_kind: StatementKind::AlterTable,
reads: vec![],
writes: vec![],
lineage: vec![],
diagnostics: vec![],
},
);
}
}

mod data_modifying_cte {
Expand Down
Loading