524 lines
18 KiB
Rust
524 lines
18 KiB
Rust
use dbx_core::sql_analysis::analyze_sql_references;
|
|
|
|
#[test]
|
|
fn extracts_tables_aliases_and_qualified_columns() {
|
|
let analysis = analyze_sql_references("select u.missing from users u where u.id = 1", Some("postgres")).unwrap();
|
|
|
|
assert_eq!(analysis.tables.len(), 1);
|
|
assert_eq!(analysis.tables[0].name, "users");
|
|
assert_eq!(analysis.tables[0].alias.as_deref(), Some("u"));
|
|
|
|
let columns: Vec<_> =
|
|
analysis.columns.iter().map(|column| (column.qualifier.as_deref(), column.name.as_str())).collect();
|
|
assert_eq!(columns, vec![(Some("u"), "missing"), (Some("u"), "id")]);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_nested_query_scopes_for_correlated_subqueries() {
|
|
let sql = "select aa.house_id from mds_base_house aa where exists (select 1 from mds_base_owner where HOUSE_ID = aa.HOUSE_ID)";
|
|
let analysis = analyze_sql_references(sql, Some("mysql")).unwrap();
|
|
|
|
let tables: Vec<_> =
|
|
analysis.tables.iter().map(|table| (table.name.as_str(), table.alias.as_deref(), table.scope_id)).collect();
|
|
assert_eq!(tables, vec![("mds_base_house", Some("aa"), 0), ("mds_base_owner", None, 1)]);
|
|
|
|
let scopes: Vec<_> = analysis.scopes.iter().map(|scope| (scope.id, scope.parent_id)).collect();
|
|
assert_eq!(scopes, vec![(0, None), (1, Some(0))]);
|
|
|
|
let columns: Vec<_> = analysis
|
|
.columns
|
|
.iter()
|
|
.map(|column| (column.qualifier.as_deref(), column.name.as_str(), column.scope_id))
|
|
.collect();
|
|
assert_eq!(columns, vec![(Some("aa"), "house_id", 0), (None, "HOUSE_ID", 1), (Some("aa"), "HOUSE_ID", 1)]);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_in_subquery_in_a_child_scope() {
|
|
let sql = "select u.id from users u where u.id in (select o.user_id from orders o)";
|
|
let analysis = analyze_sql_references(sql, Some("sqlserver")).unwrap();
|
|
|
|
let tables: Vec<_> = analysis.tables.iter().map(|table| (table.name.as_str(), table.scope_id)).collect();
|
|
assert_eq!(tables, vec![("users", 0), ("orders", 1)]);
|
|
|
|
let scopes: Vec<_> = analysis.scopes.iter().map(|scope| (scope.id, scope.parent_id)).collect();
|
|
assert_eq!(scopes, vec![(0, None), (1, Some(0))]);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_single_cte_is_not_reported_as_a_physical_table() {
|
|
let sql = "WITH SalesCte AS (SELECT * FROM dbo.sales) SELECT * FROM salescte";
|
|
let analysis = analyze_sql_references(sql, Some("sqlserver")).unwrap();
|
|
|
|
let tables: Vec<_> = analysis.tables.iter().map(|table| (table.schema.as_deref(), table.name.as_str())).collect();
|
|
assert_eq!(tables, vec![(Some("dbo"), "sales")]);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_recursive_cte_can_reference_itself() {
|
|
let sql = "WITH numbers AS (SELECT 1 AS value UNION ALL SELECT value + 1 FROM numbers WHERE value < 10) SELECT * FROM numbers";
|
|
let analysis = analyze_sql_references(sql, Some("sqlserver")).unwrap();
|
|
|
|
assert!(analysis.tables.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_ctes_only_hide_names_after_they_are_declared() {
|
|
let sql =
|
|
"WITH first_cte AS (SELECT * FROM later_cte), later_cte AS (SELECT * FROM first_cte) SELECT * FROM later_cte";
|
|
let analysis = analyze_sql_references(sql, Some("sqlserver")).unwrap();
|
|
|
|
let tables: Vec<_> = analysis.tables.iter().map(|table| table.name.as_str()).collect();
|
|
assert_eq!(tables, vec!["later_cte"]);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_qualified_table_is_not_hidden_by_same_named_cte() {
|
|
let sql = "WITH employees AS (SELECT * FROM dbo.employees) SELECT * FROM employees";
|
|
let analysis = analyze_sql_references(sql, Some("sqlserver")).unwrap();
|
|
|
|
assert_eq!(analysis.tables.len(), 1);
|
|
assert_eq!(analysis.tables[0].schema.as_deref(), Some("dbo"));
|
|
assert_eq!(analysis.tables[0].name, "employees");
|
|
}
|
|
|
|
#[test]
|
|
fn nested_queries_inherit_and_shadow_cte_names() {
|
|
let sql = "WITH source AS (SELECT * FROM dbo.outer_source) SELECT * FROM source WHERE EXISTS (WITH source AS (SELECT * FROM dbo.inner_source) SELECT * FROM source) AND EXISTS (SELECT * FROM source)";
|
|
let analysis = analyze_sql_references(sql, Some("sqlserver")).unwrap();
|
|
|
|
let tables: Vec<_> =
|
|
analysis.tables.iter().map(|table| (table.schema.as_deref(), table.name.as_str(), table.scope_id)).collect();
|
|
assert_eq!(tables, vec![(Some("dbo"), "outer_source", 1), (Some("dbo"), "inner_source", 3)]);
|
|
|
|
let scopes: Vec<_> = analysis.scopes.iter().map(|scope| (scope.id, scope.parent_id)).collect();
|
|
assert_eq!(scopes, vec![(0, None), (1, Some(0)), (2, Some(0)), (3, Some(2)), (4, Some(0))]);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_unqualified_columns_from_single_table_select() {
|
|
let analysis = analyze_sql_references("select missing, id from users", Some("postgres")).unwrap();
|
|
|
|
let columns: Vec<_> =
|
|
analysis.columns.iter().map(|column| (column.qualifier.as_deref(), column.name.as_str())).collect();
|
|
assert_eq!(columns, vec![(None, "missing"), (None, "id")]);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_mysql_quoted_table_references() {
|
|
let analysis = analyze_sql_references("SELECT * FROM `t_19991` LIMIT 100", Some("mysql")).unwrap();
|
|
|
|
assert_eq!(analysis.tables.len(), 1);
|
|
assert_eq!(analysis.tables[0].name, "t_19991");
|
|
assert_eq!(analysis.tables[0].schema, None);
|
|
assert_eq!(analysis.tables[0].span.start_line, 1);
|
|
assert_eq!(analysis.tables[0].span.start_column, 15);
|
|
assert_eq!(analysis.tables[0].span.end_line, 1);
|
|
assert_eq!(analysis.tables[0].span.end_column, 24);
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_mysql_qualified_backtick_table_references() {
|
|
let analysis = analyze_sql_references("SELECT * FROM `core`.`products` LIMIT 100;", Some("mysql")).unwrap();
|
|
|
|
assert_eq!(analysis.tables.len(), 1);
|
|
assert_eq!(analysis.tables[0].schema.as_deref(), Some("core"));
|
|
assert_eq!(analysis.tables[0].name, "products");
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_mysql_single_quoted_table_references() {
|
|
let analysis = analyze_sql_references("SELECT * FROM 't_10001' LIMIT 100", Some("mysql")).unwrap();
|
|
|
|
assert_eq!(analysis.tables.len(), 1);
|
|
assert_eq!(analysis.tables[0].name, "t_10001");
|
|
assert_eq!(analysis.tables[0].schema, None);
|
|
}
|
|
|
|
#[test]
|
|
fn postgres_default_privileges_statements_do_not_raise_syntax_errors() {
|
|
let sql = "\
|
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
|
GRANT SELECT,INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER ON TABLES TO app_user;";
|
|
|
|
let analysis = analyze_sql_references(sql, Some("postgres"))
|
|
.unwrap_or_else(|error| panic!("PostgreSQL ALTER DEFAULT PRIVILEGES should analyze: {error}"));
|
|
|
|
assert!(analysis.tables.is_empty());
|
|
assert!(analysis.columns.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_unqualified_order_by_columns_for_sqlserver_queries() {
|
|
let analysis =
|
|
analyze_sql_references("SELECT * FROM Evt_GCM_Qop_Info ORDER BY PDReceiveDatePartInfo DESC", Some("sqlserver"))
|
|
.unwrap();
|
|
|
|
assert_eq!(analysis.tables.len(), 1);
|
|
assert_eq!(analysis.tables[0].name, "Evt_GCM_Qop_Info");
|
|
|
|
let columns: Vec<_> =
|
|
analysis.columns.iter().map(|column| (column.qualifier.as_deref(), column.name.as_str())).collect();
|
|
assert_eq!(columns, vec![(None, "PDReceiveDatePartInfo")]);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_date_functions_do_not_treat_legal_dateparts_as_columns() {
|
|
let dateadd_and_datediff = [
|
|
"year",
|
|
"yy",
|
|
"yyyy",
|
|
"quarter",
|
|
"qq",
|
|
"q",
|
|
"month",
|
|
"mm",
|
|
"m",
|
|
"dayofyear",
|
|
"dy",
|
|
"y",
|
|
"day",
|
|
"dd",
|
|
"d",
|
|
"week",
|
|
"wk",
|
|
"ww",
|
|
"weekday",
|
|
"dw",
|
|
"w",
|
|
"hour",
|
|
"hh",
|
|
"minute",
|
|
"mi",
|
|
"n",
|
|
"second",
|
|
"ss",
|
|
"s",
|
|
"millisecond",
|
|
"ms",
|
|
"microsecond",
|
|
"mcs",
|
|
"nanosecond",
|
|
"ns",
|
|
];
|
|
let datediff_big = [
|
|
"year",
|
|
"yy",
|
|
"yyyy",
|
|
"quarter",
|
|
"qq",
|
|
"q",
|
|
"month",
|
|
"mm",
|
|
"m",
|
|
"dayofyear",
|
|
"dy",
|
|
"y",
|
|
"day",
|
|
"dd",
|
|
"d",
|
|
"week",
|
|
"wk",
|
|
"ww",
|
|
"weekday",
|
|
"dw",
|
|
"w",
|
|
"hour",
|
|
"hh",
|
|
"minute",
|
|
"mi",
|
|
"n",
|
|
"second",
|
|
"ss",
|
|
"s",
|
|
"millisecond",
|
|
"ms",
|
|
"microsecond",
|
|
"mcs",
|
|
"nanosecond",
|
|
"ns",
|
|
];
|
|
let datepart = [
|
|
"year",
|
|
"yy",
|
|
"yyyy",
|
|
"quarter",
|
|
"qq",
|
|
"q",
|
|
"month",
|
|
"mm",
|
|
"m",
|
|
"dayofyear",
|
|
"dy",
|
|
"y",
|
|
"day",
|
|
"dd",
|
|
"d",
|
|
"week",
|
|
"wk",
|
|
"ww",
|
|
"weekday",
|
|
"dw",
|
|
"w",
|
|
"hour",
|
|
"hh",
|
|
"minute",
|
|
"mi",
|
|
"n",
|
|
"second",
|
|
"ss",
|
|
"s",
|
|
"millisecond",
|
|
"ms",
|
|
"microsecond",
|
|
"mcs",
|
|
"nanosecond",
|
|
"ns",
|
|
"tzoffset",
|
|
"tz",
|
|
"iso_week",
|
|
"isowk",
|
|
"isoww",
|
|
];
|
|
let datename = [
|
|
"year",
|
|
"yy",
|
|
"yyyy",
|
|
"quarter",
|
|
"qq",
|
|
"q",
|
|
"month",
|
|
"mm",
|
|
"m",
|
|
"dayofyear",
|
|
"dy",
|
|
"y",
|
|
"day",
|
|
"dd",
|
|
"d",
|
|
"week",
|
|
"wk",
|
|
"ww",
|
|
"weekday",
|
|
"dw",
|
|
"w",
|
|
"hour",
|
|
"hh",
|
|
"minute",
|
|
"mi",
|
|
"n",
|
|
"second",
|
|
"ss",
|
|
"s",
|
|
"millisecond",
|
|
"ms",
|
|
"microsecond",
|
|
"mcs",
|
|
"nanosecond",
|
|
"ns",
|
|
"tzoffset",
|
|
"tz",
|
|
"iso_week",
|
|
"isowk",
|
|
"isoww",
|
|
];
|
|
|
|
for (function, dateparts) in [
|
|
("DATEADD", dateadd_and_datediff.as_slice()),
|
|
("DATEDIFF", dateadd_and_datediff.as_slice()),
|
|
("DATEDIFF_BIG", datediff_big.as_slice()),
|
|
("DATEPART", datepart.as_slice()),
|
|
("DATENAME", datename.as_slice()),
|
|
] {
|
|
for (index, datepart) in dateparts.iter().enumerate() {
|
|
let datepart = if index % 2 == 0 { datepart.to_ascii_uppercase() } else { datepart.to_string() };
|
|
let sql = match function {
|
|
"DATEADD" => format!("SELECT DATEADD({datepart}, amount, occurred_at) FROM events"),
|
|
"DATEDIFF" | "DATEDIFF_BIG" => {
|
|
format!("SELECT {function}({datepart}, started_at, ended_at) FROM events")
|
|
}
|
|
"DATEPART" | "DATENAME" => format!("SELECT {function}({datepart}, occurred_at) FROM events"),
|
|
_ => unreachable!(),
|
|
};
|
|
let analysis = analyze_sql_references(&sql, Some("sqlserver"))
|
|
.unwrap_or_else(|error| panic!("{function}({datepart}, ...) should analyze: {error}"));
|
|
let columns: Vec<_> = analysis.columns.iter().map(|column| column.name.as_str()).collect();
|
|
let expected = match function {
|
|
"DATEADD" => vec!["amount", "occurred_at"],
|
|
"DATEDIFF" | "DATEDIFF_BIG" => vec!["started_at", "ended_at"],
|
|
"DATEPART" | "DATENAME" => vec!["occurred_at"],
|
|
_ => unreachable!(),
|
|
};
|
|
assert_eq!(columns, expected, "{function} must ignore the legal {datepart} datepart only");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_datepart_suppression_is_limited_to_unqualified_builtins() {
|
|
let sql = "SELECT dAtEaDd(SeCoNd, amount, occurred_at), dbo.DATEADD(SECOND, amount, occurred_at), custom_fn(MONTH, occurred_at), DATEADD(datepart_column, amount, occurred_at), SECOND FROM events";
|
|
let analysis = analyze_sql_references(sql, Some("sqlserver")).unwrap();
|
|
|
|
let columns: Vec<_> = analysis.columns.iter().map(|column| column.name.as_str()).collect();
|
|
assert_eq!(
|
|
columns,
|
|
vec![
|
|
"amount",
|
|
"occurred_at",
|
|
"SECOND",
|
|
"amount",
|
|
"occurred_at",
|
|
"MONTH",
|
|
"occurred_at",
|
|
"datepart_column",
|
|
"amount",
|
|
"occurred_at",
|
|
"SECOND",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_create_proc_and_procedure_are_equivalent() {
|
|
for sql in ["CREATE PROC test\nAS\n", "CREATE PROCEDURE test\nAS\n", "CREATE PROC test AS SELECT 1;"] {
|
|
let analysis = analyze_sql_references(sql, Some("sqlserver"))
|
|
.unwrap_or_else(|error| panic!("SQL Server procedure declaration should analyze: {error}"));
|
|
assert!(analysis.tables.is_empty());
|
|
assert!(analysis.columns.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_create_or_alter_proc_is_supported() {
|
|
analyze_sql_references("CREATE OR ALTER PROC test AS SELECT 1;", Some("sqlserver"))
|
|
.unwrap_or_else(|error| panic!("SQL Server CREATE OR ALTER PROC should analyze: {error}"));
|
|
}
|
|
|
|
#[test]
|
|
fn create_proc_remains_invalid_outside_sqlserver() {
|
|
let error = analyze_sql_references("CREATE PROC test AS SELECT 1", Some("postgres"))
|
|
.expect_err("PostgreSQL must not inherit SQL Server's PROC synonym");
|
|
|
|
assert!(error.contains("an object type after CREATE"));
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_proc_identifiers_remain_identifiers_outside_create() {
|
|
let analysis = analyze_sql_references("SELECT proc FROM jobs", Some("sqlserver")).unwrap();
|
|
|
|
assert_eq!(analysis.tables[0].name, "jobs");
|
|
assert_eq!(analysis.columns[0].name, "proc");
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_alter_table_single_add_supports_multiple_columns() {
|
|
for sql in [
|
|
"ALTER TABLE dbo.demo\nADD isOldWell BIT NULL,\n isNewWell BIT NULL;",
|
|
"ALTER TABLE [dbo].[demo] ADD amount DECIMAL(10, 2) DEFAULT (0), [display_name] NVARCHAR(50) NULL;",
|
|
"ALTER TABLE dbo.demo ADD enabled BIT NULL, CHECK (enabled IN (0, 1));",
|
|
] {
|
|
analyze_sql_references(sql, Some("sqlserver"))
|
|
.unwrap_or_else(|error| panic!("SQL Server single-ADD multi-column ALTER TABLE should analyze: {error}"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_alter_table_add_normalization_preserves_boundaries() {
|
|
analyze_sql_references("ALTER TABLE dbo.demo ADD isOldWell BIT NULL, ADD isNewWell BIT NULL;", Some("sqlserver"))
|
|
.expect("existing repeated-ADD parser behavior should remain valid");
|
|
|
|
let missing_comma =
|
|
analyze_sql_references("ALTER TABLE dbo.demo ADD isOldWell BIT NULL isNewWell BIT NULL;", Some("sqlserver"))
|
|
.expect_err("missing column separators must remain invalid");
|
|
assert!(missing_comma.contains("isNewWell"));
|
|
|
|
analyze_sql_references(
|
|
"ALTER TABLE dbo.demo ADD amount DECIMAL(10, 2) NULL, label NVARCHAR(20) NULL; SELECT label FROM dbo.demo;",
|
|
Some("sqlserver"),
|
|
)
|
|
.expect("data-type commas and multiple statements should remain parseable");
|
|
|
|
analyze_sql_references("ALTER TABLE dbo.demo ADD first_flag BIT NULL, second_flag BIT NULL;", Some("postgres"))
|
|
.expect_err("other dialects must not inherit SQL Server ALTER TABLE normalization");
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_query_hints_do_not_raise_parser_errors() {
|
|
let analysis = analyze_sql_references(
|
|
"SELECT o.name FROM sys.objects o WHERE o.type = 'U' OPTION (RECOMPILE);",
|
|
Some("sqlserver"),
|
|
)
|
|
.expect("SQL Server OPTION query hint should analyze");
|
|
|
|
assert_eq!(analysis.tables.len(), 1);
|
|
assert_eq!(analysis.tables[0].schema.as_deref(), Some("sys"));
|
|
assert_eq!(analysis.tables[0].name, "objects");
|
|
assert_eq!(analysis.tables[0].alias.as_deref(), Some("o"));
|
|
|
|
let columns: Vec<_> =
|
|
analysis.columns.iter().map(|column| (column.qualifier.as_deref(), column.name.as_str())).collect();
|
|
assert_eq!(columns, vec![(Some("o"), "name"), (Some("o"), "type")]);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_query_hints_support_arguments_ctes_and_multiple_statements() {
|
|
let sql = "WITH nodes AS (\
|
|
SELECT 1 AS depth \
|
|
UNION ALL \
|
|
SELECT depth + 1 FROM nodes WHERE depth < 3\
|
|
) SELECT depth FROM nodes OPTION (MAXRECURSION 100, MAXDOP 2);\
|
|
SELECT name FROM sys.tables WHERE is_ms_shipped = 0 OPTION (HASH JOIN, USE HINT('DISABLE_OPTIMIZER_ROWGOAL'));";
|
|
let analysis =
|
|
analyze_sql_references(sql, Some("sqlserver")).expect("SQL Server query hints with arguments should analyze");
|
|
|
|
let tables: Vec<_> = analysis.tables.iter().map(|table| (table.schema.as_deref(), table.name.as_str())).collect();
|
|
assert_eq!(tables, vec![(Some("sys"), "tables")]);
|
|
}
|
|
|
|
#[test]
|
|
fn sqlserver_option_functions_and_invalid_hints_are_not_suppressed() {
|
|
for argument in ["value", "recompile"] {
|
|
let sql = format!("SELECT option({argument}) FROM settings;");
|
|
let analysis =
|
|
analyze_sql_references(&sql, Some("sqlserver")).expect("ordinary OPTION function should remain parseable");
|
|
assert_eq!(analysis.tables[0].name, "settings");
|
|
assert_eq!(analysis.columns[0].name, argument);
|
|
}
|
|
|
|
let analysis = analyze_sql_references(
|
|
"SELECT option(recompile); SELECT name FROM sys.objects OPTION (RECOMPILE);",
|
|
Some("sqlserver"),
|
|
)
|
|
.expect("an OPTION function in an earlier statement must remain parseable");
|
|
assert_eq!(analysis.tables[0].name, "objects");
|
|
assert_eq!(analysis.columns[0].name, "recompile");
|
|
assert_eq!(analysis.columns[1].name, "name");
|
|
|
|
let error =
|
|
analyze_sql_references("SELECT * FROM sys.objects WHERE type = 'U' OPTION (CUSTOM_HINT 1);", Some("sqlserver"))
|
|
.expect_err("unknown OPTION clauses must still surface parser errors");
|
|
assert!(error.contains("OPTION"));
|
|
}
|
|
|
|
#[test]
|
|
fn duckdb_parser_gap_queries_do_not_raise_syntax_errors() {
|
|
for sql in ["FROM users;", "SUMMARIZE users;", "SUMMARISE users;"] {
|
|
let analysis = analyze_sql_references(sql, Some("duckdb")).expect("duckdb parser gap query should analyze");
|
|
assert!(analysis.tables.is_empty());
|
|
assert!(analysis.columns.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clickhouse_strictness_first_left_joins_do_not_raise_syntax_errors() {
|
|
for strictness in ["ANY", "ALL", "SEMI", "ANTI"] {
|
|
let sql = format!("SELECT a.id FROM events a {strictness} LEFT JOIN wallets b ON a.wallet_id = b.id");
|
|
let analysis = analyze_sql_references(&sql, Some("clickhouse"))
|
|
.unwrap_or_else(|error| panic!("ClickHouse {strictness} LEFT JOIN should analyze: {error}"));
|
|
|
|
let tables: Vec<_> =
|
|
analysis.tables.iter().map(|table| (table.name.as_str(), table.alias.as_deref())).collect();
|
|
assert_eq!(tables, vec![("events", Some("a")), ("wallets", Some("b"))]);
|
|
}
|
|
}
|