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"))]); } }