From c3324c0a076eb541da4a50c4f49177ee69523eca Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sun, 25 Oct 2026 20:31:26 +0100 Subject: [PATCH 1/2] Handle sqlparser table membership and statement variants --- src/impls/expr_helpers.rs | 117 ++++++++++++++++-- src/impls/reverse_translator_impls/expr.rs | 5 + src/impls/translator_impls/expr.rs | 5 + .../translator_impls/plpgsql/translator.rs | 3 +- src/impls/translator_impls/statement.rs | 15 +++ src/initialization.rs | 1 + src/pg2sqlite.rs | 1 + tests/gauntlet/source.rs | 2 +- tests/test_crash_cases.rs | 2 +- tests/test_sqlparser_ast_compatibility.rs | 93 ++++++++++++++ 10 files changed, 230 insertions(+), 14 deletions(-) create mode 100644 tests/test_sqlparser_ast_compatibility.rs diff --git a/src/impls/expr_helpers.rs b/src/impls/expr_helpers.rs index 052cb7eea2..22b131ab8a 100644 --- a/src/impls/expr_helpers.rs +++ b/src/impls/expr_helpers.rs @@ -271,6 +271,19 @@ pub(crate) fn try_map_expr_children( InTarget::Subquery(Box::new(f_query(subquery)?)) } InTarget::Unnest(array_expr) => InTarget::Unnest(Box::new(f(array_expr)?)), + InTarget::Table { name, args } => { + InTarget::Table { + name: name.clone(), + args: args + .as_ref() + .map(|args| { + args.iter() + .map(|arg| try_map_function_arg(arg, f)) + .collect::>() + }) + .transpose()?, + } + } }; Ok(Expr::In { expr: inner, target, negated: *negated, global: *global }) })? @@ -786,7 +799,7 @@ pub(crate) fn for_each_child_expr(expr: &Expr, f: &mut impl FnMut(&Expr)) { } } - // list and unnest children, subquery targets stay untouched + // Subquery targets stay untouched. Expr::In { expr: inner, target, .. } => { f(inner); match target { @@ -797,6 +810,13 @@ pub(crate) fn for_each_child_expr(expr: &Expr, f: &mut impl FnMut(&Expr)) { } InTarget::Subquery(_) => {} InTarget::Unnest(array_expr) => f(array_expr), + InTarget::Table { name: _, args } => { + if let Some(args) = args { + for arg in args { + for_each_function_arg_expr(arg, f); + } + } + } } } Expr::Tuple(items) => { @@ -945,15 +965,7 @@ fn for_each_function_child_expr(function: &sqlparser::ast::Function, f: &mut imp for arguments in [parameters, args] { if let sqlparser::ast::FunctionArguments::List(list) = arguments { for arg in &list.args { - if let sqlparser::ast::FunctionArg::ExprNamed { name, .. } = arg { - f(name); - } - let (sqlparser::ast::FunctionArg::Named { arg, .. } - | sqlparser::ast::FunctionArg::ExprNamed { arg, .. } - | sqlparser::ast::FunctionArg::Unnamed(arg)) = arg; - if let sqlparser::ast::FunctionArgExpr::Expr(expr) = arg { - f(expr); - } + for_each_function_arg_expr(arg, f); } for clause in &list.clauses { for_each_function_argument_clause_expr(clause, f); @@ -987,6 +999,19 @@ fn for_each_function_child_expr(function: &sqlparser::ast::Function, f: &mut imp } } +/// Visits argument values and expression-valued argument names. +fn for_each_function_arg_expr(arg: &sqlparser::ast::FunctionArg, f: &mut impl FnMut(&Expr)) { + if let sqlparser::ast::FunctionArg::ExprNamed { name, .. } = arg { + f(name); + } + let (sqlparser::ast::FunctionArg::Named { arg, .. } + | sqlparser::ast::FunctionArg::ExprNamed { arg, .. } + | sqlparser::ast::FunctionArg::Unnamed(arg)) = arg; + if let sqlparser::ast::FunctionArgExpr::Expr(expr) = arg { + f(expr); + } +} + fn for_each_function_argument_clause_expr( clause: &sqlparser::ast::FunctionArgumentClause, f: &mut impl FnMut(&Expr), @@ -1141,6 +1166,60 @@ mod tests { assert_eq!(expr.to_string(), "(A, B)"); } + #[test] + fn table_membership_walkers_transform_every_argument_expression() { + use sqlparser::ast::{FunctionArg, FunctionArgExpr, FunctionArgOperator, ObjectName}; + + let expr = Expr::In { + expr: Box::new(ident_expr("lhs")), + target: InTarget::Table { + name: ObjectName::from(Ident::new("lookup")), + args: Some(vec![ + FunctionArg::Unnamed(FunctionArgExpr::Expr(ident_expr("positional"))), + FunctionArg::Named { + name: Ident::new("label"), + arg: FunctionArgExpr::Expr(ident_expr("named")), + operator: FunctionArgOperator::RightArrow, + }, + FunctionArg::ExprNamed { + name: ident_expr("key"), + arg: FunctionArgExpr::Expr(ident_expr("value")), + operator: FunctionArgOperator::RightArrow, + }, + FunctionArg::Unnamed(FunctionArgExpr::Wildcard), + ]), + }, + negated: true, + global: false, + }; + let mut children = Vec::new(); + for_each_child_expr(&expr, &mut |child| children.push(child.to_string())); + assert_eq!(children, ["lhs", "positional", "named", "key", "value"]); + + let rewritten = try_map_expr_children::<()>( + &expr, + &mut |child| Ok(Expr::Nested(Box::new(child.clone()))), + &mut |_| panic!("table membership has no query child"), + ) + .unwrap(); + assert_eq!( + rewritten.to_string(), + "(lhs) NOT IN lookup((positional), label => (named), (key) => (value), *)" + ); + let failure = try_map_expr_children( + &expr, + &mut |child| { + if *child == ident_expr("value") { + Err("argument refused") + } else { + Ok(child.clone()) + } + }, + &mut |query| Ok(query.clone()), + ); + assert_eq!(failure, Err("argument refused")); + } + /// Parse a single SQL expression using the PostgreSQL dialect. fn parse_expr(sql: &str) -> Expr { let full = format!("SELECT {sql} FROM dummy"); @@ -1530,6 +1609,24 @@ mod tests { corpus.push(("map_literal".to_string(), map_literal_expr())); corpus.push(("lambda".to_string(), lambda_expr())); corpus.push(("member_of".to_string(), member_of_expr())); + for sql in ["SELECT a IN t", "SELECT a NOT IN f(b, c)"] { + let sqlparser::ast::Statement::Query(query) = + sqlparser::parser::Parser::parse_sql(&sqlparser::dialect::SQLiteDialect {}, sql) + .unwrap() + .remove(0) + else { + panic!("expected query"); + }; + let sqlparser::ast::SetExpr::Select(select) = *query.body else { + panic!("expected select"); + }; + let sqlparser::ast::SelectItem::UnnamedExpr(expr) = + select.projection.into_iter().next().unwrap() + else { + panic!("expected expression"); + }; + corpus.push((sql.to_string(), expr)); + } for (label, expr) in corpus { let mut read_only = Vec::new(); diff --git a/src/impls/reverse_translator_impls/expr.rs b/src/impls/reverse_translator_impls/expr.rs index a54d79a435..9af3ca5983 100644 --- a/src/impls/reverse_translator_impls/expr.rs +++ b/src/impls/reverse_translator_impls/expr.rs @@ -491,6 +491,11 @@ impl ReverseTranslator for Expr { Expr::In { expr: operand, target: InTarget::List(list), negated, global } => { reverse_in_list(operand, list, *negated, *global, schema, options) } + Expr::In { target: InTarget::Table { .. }, .. } => { + Err(Error::reverse_refusal( + "IN table and IN table_function(...) have no PostgreSQL spelling. Use IN (SELECT ...) with explicit output columns.", + )) + } Expr::Between { expr: operand, negated, low, high } => { reverse_between(operand, *negated, low, high, schema, options) diff --git a/src/impls/translator_impls/expr.rs b/src/impls/translator_impls/expr.rs index fe9ecf8c97..191df14a3a 100644 --- a/src/impls/translator_impls/expr.rs +++ b/src/impls/translator_impls/expr.rs @@ -3937,6 +3937,11 @@ impl crate::traits::translator::TranslatorWithContext for Expr { .to_string(), )); } + Expr::In { target: InTarget::Table { .. }, .. } => { + return Err(crate::errors::Error::forward_refusal( + "IN table and IN table_function(...) are SQLite-only syntax. Use IN (SELECT ...) with explicit output columns.", + )); + } Expr::In { target: InTarget::Unnest(_), .. } => { return Err(crate::errors::Error::forward_refusal( "IN UNNEST (array unnesting) is not supported in SQLite. \ diff --git a/src/impls/translator_impls/plpgsql/translator.rs b/src/impls/translator_impls/plpgsql/translator.rs index cf637d4fa7..d1cf029577 100644 --- a/src/impls/translator_impls/plpgsql/translator.rs +++ b/src/impls/translator_impls/plpgsql/translator.rs @@ -263,8 +263,7 @@ impl PlPgSqlTranslator { expr, Expr::Subquery(_) | Expr::Exists { .. } - | Expr::In { target: InTarget::Subquery(_), .. } - | Expr::In { target: InTarget::Unnest(_), .. } + | Expr::In { target: InTarget::Subquery(_) | InTarget::Unnest(_), .. } ) { ControlFlow::Break(()) } else { diff --git a/src/impls/translator_impls/statement.rs b/src/impls/translator_impls/statement.rs index 7a43c07731..12e55f40eb 100644 --- a/src/impls/translator_impls/statement.rs +++ b/src/impls/translator_impls/statement.rs @@ -1971,6 +1971,9 @@ impl crate::traits::translator::TranslatorWithContext for Statement { drop_with_warning("ALTER FUNCTION", REASON_ACCESS_CONTROL, emit) } Self::CreateServer(_) => drop_with_warning("CREATE SERVER", REASON_FOREIGN_DATA, emit), + Self::CreateForeignDataWrapper(_) => { + drop_with_warning("CREATE FOREIGN DATA WRAPPER", REASON_FOREIGN_DATA, emit) + } Self::CreateForeignTable(_) => { drop_with_warning("CREATE FOREIGN TABLE", REASON_FOREIGN_DATA, emit) } @@ -2130,6 +2133,18 @@ impl crate::traits::translator::TranslatorWithContext for Statement { | Self::CreateSchema { .. } | Self::AlterSchema(_) => Vec::new(), // Outcome 2, hard error, grouped by reason. + Self::CreateRule(_) => { + return Err(reject_unsupported_statement( + self, + "SQLite has no query rewrite rules. Rewrite the rule as explicit statements or triggers.", + )); + } + Self::AlterTrigger(_) => { + return Err(reject_unsupported_statement( + self, + "SQLite cannot alter a trigger. Drop and recreate the trigger with its intended definition.", + )); + } data_movement_patterns!() => { return Err(reject_unsupported_statement( self, diff --git a/src/initialization.rs b/src/initialization.rs index f0400d88b0..f5fafe7d9d 100644 --- a/src/initialization.rs +++ b/src/initialization.rs @@ -123,6 +123,7 @@ impl InitializationPlan { } /// Complete initialization in source order. + #[must_use] pub fn iter(&self) -> impl ExactSizeIterator + FusedIterator { self.selected(FreshFiles::Both) } diff --git a/src/pg2sqlite.rs b/src/pg2sqlite.rs index 537dbf2f08..d41ca9ffb8 100644 --- a/src/pg2sqlite.rs +++ b/src/pg2sqlite.rs @@ -1238,6 +1238,7 @@ impl Pg2Sqlite { /// [`crate::errors::Error::UnsupportedReverseStatement`] for non-DML /// statements, or [`crate::errors::Error::RlsTableDetected`] for RLS /// backing table references. + /// Refuses SQLite's `IN table` and `IN table_function(...)` shorthand. /// /// # Example /// diff --git a/tests/gauntlet/source.rs b/tests/gauntlet/source.rs index 89fca8ba19..3165ca66e9 100644 --- a/tests/gauntlet/source.rs +++ b/tests/gauntlet/source.rs @@ -105,7 +105,7 @@ fn corpus_verdict_classification() { (false, true) => translator_refuses += 1, (true, false) => { let pg_err = pg_result.unwrap_err(); - forbidden.push((group.to_string(), (*row).to_string(), pg_err)); + forbidden.push(((*group).to_string(), (*row).to_string(), pg_err)); } } } diff --git a/tests/test_crash_cases.rs b/tests/test_crash_cases.rs index 2ca9df4478..556df88409 100644 --- a/tests/test_crash_cases.rs +++ b/tests/test_crash_cases.rs @@ -104,7 +104,7 @@ fn test_all_crash_cases() { if let Err(panic) = result { let panic_msg = if let Some(s) = panic.downcast_ref::<&str>() { - s.to_string() + (*s).to_string() } else if let Some(s) = panic.downcast_ref::() { s.clone() } else { diff --git a/tests/test_sqlparser_ast_compatibility.rs b/tests/test_sqlparser_ast_compatibility.rs new file mode 100644 index 0000000000..da2cffe3e1 --- /dev/null +++ b/tests/test_sqlparser_ast_compatibility.rs @@ -0,0 +1,93 @@ +//! Translation policies for table membership and PostgreSQL-only statements. + +use pg2sqlite::{ + errors::{Error, RefusalCategory, TranslationDirection}, + prelude::{Pg2Sqlite, Pg2SqliteOptions, Translator}, + warnings::TranslationWarning, +}; +use sqlparser::{ + ast::{Expr, InTarget, SelectItem, SetExpr, Statement}, + dialect::SQLiteDialect, + parser::Parser, +}; + +const SCHEMA: &str = "CREATE TABLE t (id INTEGER PRIMARY KEY);"; + +fn assert_refusal(error: Error, direction: TranslationDirection, construct: &str) { + let Error::TranslationRefusal(refusal) = error else { + panic!("expected translation refusal, got {error}"); + }; + assert_eq!(refusal.direction(), direction); + assert_eq!(refusal.category(), RefusalCategory::UnrepresentableSemantics); + assert!(refusal.detail().contains(construct), "{refusal}"); +} + +#[test] +fn foreign_data_wrapper_is_reported_as_a_lossy_drop() { + let report = Pg2Sqlite::default() + .sql("CREATE FOREIGN DATA WRAPPER remote NO HANDLER NO VALIDATOR;") + .unwrap() + .translate_with_report(&Pg2SqliteOptions::default()) + .unwrap(); + assert!(report.script.into_vec().is_empty()); + assert!(matches!( + report.warnings.as_slice(), + [TranslationWarning::LossyDrop { construct, .. }] if construct == "CREATE FOREIGN DATA WRAPPER" + )); +} + +#[test] +fn rules_and_trigger_alterations_are_refused() { + for (sql, construct) in [ + ("CREATE RULE r AS ON DELETE TO t DO INSTEAD NOTHING;", "CREATE RULE"), + ("ALTER TRIGGER tr ON t RENAME TO renamed;", "ALTER TRIGGER"), + ("ALTER TRIGGER tr ON t DEPENDS ON EXTENSION ext;", "ALTER TRIGGER"), + ] { + let error = Pg2Sqlite::default() + .sql(SCHEMA) + .unwrap() + .sql(sql) + .unwrap() + .translate(&Pg2SqliteOptions::default()) + .unwrap_err(); + assert_refusal(error, TranslationDirection::PostgreSqlToSqlite, construct); + } +} + +#[test] +fn reverse_table_membership_is_refused() { + let translator = Pg2Sqlite::default().sql(SCHEMA).unwrap(); + let schema = translator.build_schema().unwrap(); + for sql in [ + "SELECT 1 IN t", + "SELECT 1 NOT IN main.t", + "SELECT 1 IN json_each('[1,2]')", + "UPDATE t SET id = 2 WHERE id IN t", + "DELETE FROM t WHERE id NOT IN t", + "SELECT 1 IN (SELECT id FROM t WHERE id IN t)", + ] { + let error = translator.reverse_sql(sql, &schema, &Pg2SqliteOptions::default()).unwrap_err(); + assert_refusal(error, TranslationDirection::SqliteToPostgreSql, "IN (SELECT"); + } +} + +#[test] +fn forward_table_membership_ast_is_refused() { + let translator = Pg2Sqlite::default().sql(SCHEMA).unwrap(); + let schema = translator.build_schema().unwrap(); + for sql in ["SELECT 1 IN t", "SELECT 1 NOT IN json_each('[1,2]')"] { + let Statement::Query(query) = Parser::parse_sql(&SQLiteDialect {}, sql).unwrap().remove(0) + else { + panic!("expected query"); + }; + let SetExpr::Select(select) = *query.body else { + panic!("expected select"); + }; + let SelectItem::UnnamedExpr(expr) = &select.projection[0] else { + panic!("expected expression"); + }; + assert!(matches!(expr, Expr::In { target: InTarget::Table { .. }, .. })); + let error = expr.translate(&schema, &Pg2SqliteOptions::default()).unwrap_err(); + assert_refusal(error, TranslationDirection::PostgreSqlToSqlite, "IN (SELECT"); + } +} From 3ebb35ef5f2f3c073189ec160746fbaa09cdd13a Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sun, 25 Oct 2026 20:58:18 +0100 Subject: [PATCH 2/2] Keep iterator and regression lints compatible across Rust versions --- src/initialization.rs | 2 +- tests/test_sqlparser_ast_compatibility.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/initialization.rs b/src/initialization.rs index f5fafe7d9d..e2ab7ff4a4 100644 --- a/src/initialization.rs +++ b/src/initialization.rs @@ -123,7 +123,7 @@ impl InitializationPlan { } /// Complete initialization in source order. - #[must_use] + #[must_use = "initialization statements are only visited when the iterator is consumed"] pub fn iter(&self) -> impl ExactSizeIterator + FusedIterator { self.selected(FreshFiles::Both) } diff --git a/tests/test_sqlparser_ast_compatibility.rs b/tests/test_sqlparser_ast_compatibility.rs index da2cffe3e1..f2ba85aedd 100644 --- a/tests/test_sqlparser_ast_compatibility.rs +++ b/tests/test_sqlparser_ast_compatibility.rs @@ -29,7 +29,7 @@ fn foreign_data_wrapper_is_reported_as_a_lossy_drop() { .unwrap() .translate_with_report(&Pg2SqliteOptions::default()) .unwrap(); - assert!(report.script.into_vec().is_empty()); + assert_eq!(report.script.into_vec(), Vec::::new()); assert!(matches!( report.warnings.as_slice(), [TranslationWarning::LossyDrop { construct, .. }] if construct == "CREATE FOREIGN DATA WRAPPER"