diff --git a/benches/translation.rs b/benches/translation.rs index 70fb6e969..0325d1644 100644 --- a/benches/translation.rs +++ b/benches/translation.rs @@ -159,20 +159,23 @@ fn bench_rls_translation(c: &mut Criterion) { group.finish(); } -fn bench_select_statements(c: &mut Criterion) { +/// Benchmarks parsing and translating each of `statements` against the +/// statement schema, each id under `function` when given. +fn bench_statements( + c: &mut Criterion, + group_name: &str, + statements: &[(&str, &str)], + function: Option<&str>, +) { let options = Pg2SqliteOptions::default(); let schema = statement_schema(); - let mut group = c.benchmark_group("statement/select"); - - let statements: &[(&str, &str)] = &[ - ("simple", SELECT_SIMPLE), - ("join", SELECT_JOIN), - ("subquery", SELECT_SUBQUERY), - ("cte", SELECT_CTE), - ]; - + let mut group = c.benchmark_group(group_name); for (name, sql) in statements { - group.bench_with_input(BenchmarkId::from_parameter(name), *sql, |b, sql| { + let id = match function { + Some(function) => BenchmarkId::new(function, name), + None => BenchmarkId::from_parameter(name), + }; + group.bench_with_input(id, *sql, |b, sql| { b.iter(|| { let parsed = Pg2Sqlite::default().sql(black_box(sql)).unwrap(); black_box(parsed.translate_with_schema(&schema, &options).unwrap()) @@ -182,71 +185,38 @@ fn bench_select_statements(c: &mut Criterion) { group.finish(); } -fn bench_insert_statements(c: &mut Criterion) { - let options = Pg2SqliteOptions::default(); - let schema = statement_schema(); - let mut group = c.benchmark_group("statement/insert"); +fn bench_select_statements(c: &mut Criterion) { + let statements: &[(&str, &str)] = &[ + ("simple", SELECT_SIMPLE), + ("join", SELECT_JOIN), + ("subquery", SELECT_SUBQUERY), + ("cte", SELECT_CTE), + ]; + bench_statements(c, "statement/select", statements, None); +} +fn bench_insert_statements(c: &mut Criterion) { let statements: &[(&str, &str)] = &[ ("simple", INSERT_SIMPLE), ("multi_row", INSERT_MULTI_ROW), ("select", INSERT_SELECT), ("on_conflict", INSERT_ON_CONFLICT), ]; - - for (name, sql) in statements { - group.bench_with_input(BenchmarkId::from_parameter(name), *sql, |b, sql| { - b.iter(|| { - let parsed = Pg2Sqlite::default().sql(black_box(sql)).unwrap(); - black_box(parsed.translate_with_schema(&schema, &options).unwrap()) - }); - }); - } - group.finish(); + bench_statements(c, "statement/insert", statements, None); } fn bench_update_statements(c: &mut Criterion) { - let options = Pg2SqliteOptions::default(); - let schema = statement_schema(); - let mut group = c.benchmark_group("statement/update"); - let statements: &[(&str, &str)] = &[("simple", UPDATE_SIMPLE), ("multi_column", UPDATE_MULTI_COLUMN)]; - - for (name, sql) in statements { - group.bench_with_input(BenchmarkId::from_parameter(name), *sql, |b, sql| { - b.iter(|| { - let parsed = Pg2Sqlite::default().sql(black_box(sql)).unwrap(); - black_box(parsed.translate_with_schema(&schema, &options).unwrap()) - }); - }); - } - group.finish(); + bench_statements(c, "statement/update", statements, None); } fn bench_delete_statements(c: &mut Criterion) { - let options = Pg2SqliteOptions::default(); - let schema = statement_schema(); - let mut group = c.benchmark_group("statement/delete"); - let statements: &[(&str, &str)] = &[("simple", DELETE_SIMPLE), ("subquery", DELETE_SUBQUERY)]; - - for (name, sql) in statements { - group.bench_with_input(BenchmarkId::from_parameter(name), *sql, |b, sql| { - b.iter(|| { - let parsed = Pg2Sqlite::default().sql(black_box(sql)).unwrap(); - black_box(parsed.translate_with_schema(&schema, &options).unwrap()) - }); - }); - } - group.finish(); + bench_statements(c, "statement/delete", statements, None); } fn bench_translation_samples(c: &mut Criterion) { - let options = Pg2SqliteOptions::default(); - let schema = statement_schema(); - let mut group = c.benchmark_group("translation_samples"); - let statements: &[(&str, &str)] = &[ ("select_simple", SELECT_SIMPLE), ("select_join", SELECT_JOIN), @@ -261,16 +231,7 @@ fn bench_translation_samples(c: &mut Criterion) { "CREATE TABLE t (id SERIAL PRIMARY KEY, name TEXT NOT NULL, active BOOLEAN, created_at TIMESTAMPTZ);", ), ]; - - for (name, sql) in statements { - group.bench_with_input(BenchmarkId::new("pg2sqlite", name), *sql, |b, sql| { - b.iter(|| { - let parsed = Pg2Sqlite::default().sql(black_box(sql)).unwrap(); - black_box(parsed.translate_with_schema(&schema, &options).unwrap()) - }); - }); - } - group.finish(); + bench_statements(c, "translation_samples", statements, Some("pg2sqlite")); } const QUERY_SCHEMA: &str = "CREATE TABLE nums (n INTEGER NOT NULL);"; diff --git a/src/history/rebind/query.rs b/src/history/rebind/query.rs index 88f8104f3..68e1e9c2b 100644 --- a/src/history/rebind/query.rs +++ b/src/history/rebind/query.rs @@ -61,15 +61,10 @@ impl Planner<'_> { && let sqlparser::ast::OrderByKind::Expressions(keys) = &order_by.kind && let Some(outputs) = scope.scope_for_query(query) { - let mut listed = Vec::new(); - if let SetExpr::Select(select) = query.body.as_ref() { - for table in &select.from { - self.list(&table.relation, &mut listed)?; - for join in &table.joins { - self.list(&join.relation, &mut listed)?; - } - } - } + let listed = match query.body.as_ref() { + SetExpr::Select(select) => self.list_from(select)?, + _ => Vec::new(), + }; self.frames.push(listed); let mut result = Ok(()); for key in keys { @@ -144,13 +139,7 @@ impl Planner<'_> { header: Header, ) -> Result<(), ConnectionError> { let Some(inner) = scope.scope_for_select(select) else { return Ok(()) }; - let mut listed = Vec::new(); - for table in &select.from { - self.list(&table.relation, &mut listed)?; - for join in &table.joins { - self.list(&join.relation, &mut listed)?; - } - } + let listed = self.list_from(select)?; self.frames.push(listed); let result = self.select_clauses(scope, &inner, select, header); self.frames.pop(); diff --git a/src/history/rebind/scope.rs b/src/history/rebind/scope.rs index f21b4d6c8..9664a9c86 100644 --- a/src/history/rebind/scope.rs +++ b/src/history/rebind/scope.rs @@ -8,7 +8,9 @@ use sql_traits::{ structs::{IdentifierCase, ParserDB, TargetName}, traits::{ColumnLike, DatabaseLike, TableLike, ViewLike}, }; -use sqlparser::ast::{Expr, Ident, ObjectName, ObjectNamePart, SelectItem, SetExpr, TableFactor}; +use sqlparser::ast::{ + Expr, Ident, ObjectName, ObjectNamePart, Select, SelectItem, SetExpr, TableFactor, +}; use super::Planner; use crate::script::ConnectionError; @@ -62,6 +64,18 @@ impl Planner<'_> { self.list_as(factor, listed, false) } + /// The relations every `FROM` item of `select` names, joins included. + pub(super) fn list_from(&self, select: &Select) -> Result, ConnectionError> { + let mut listed = Vec::new(); + for table in &select.from { + self.list(&table.relation, &mut listed)?; + for join in &table.joins { + self.list(&join.relation, &mut listed)?; + } + } + Ok(listed) + } + /// Records a written table, which no common table expression can name. pub(super) fn list_target( &self, diff --git a/src/impls/reverse_translator_impls/insert.rs b/src/impls/reverse_translator_impls/insert.rs index 3477330c0..9ab386baf 100644 --- a/src/impls/reverse_translator_impls/insert.rs +++ b/src/impls/reverse_translator_impls/insert.rs @@ -35,7 +35,9 @@ use crate::{ numeric_minor_unit_scales_of_table, translate_conflict_predicate, translate_on_conflict_do_update, translate_returning, }, - translator_impls::insert::{for_each_insert_position, insert_target_scope}, + translator_impls::insert::{ + for_each_insert_position, insert_column_names, insert_target_scope, + }, }, prelude::ReverseTranslator, }; @@ -249,19 +251,6 @@ fn build_upsert_on_conflict( type ParserTable = ::Table; -/// Column names the INSERT targets, in value order. -fn reverse_insert_column_names( - insert: &Insert, - table: &ParserTable, - schema: &ParserDB, -) -> Result, Error> { - if insert.columns.is_empty() { - Ok(table.columns(schema)?.map(|c| c.column_name().to_owned()).collect()) - } else { - Ok(insert.columns.iter().filter_map(|n| last_ident(n).map(|i| i.value.clone())).collect()) - } -} - /// Unscales integer literals at NUMERIC column positions back to decimal form. /// /// Mirrors the forward `scale_numeric_literals` but divides by 10^s rather than @@ -275,7 +264,7 @@ fn unscale_numeric_literals( if scales.is_empty() { return Ok(()); } - let column_names = reverse_insert_column_names(insert, table, schema)?; + let column_names = insert_column_names(insert, table, schema)?; let Some(source) = insert.source.as_deref_mut() else { return Ok(()) }; for_each_insert_position(source.body.as_mut(), &column_names, &mut |idx, mut expr| { if let Some(column) = column_names.get(idx) diff --git a/src/impls/shared_helpers.rs b/src/impls/shared_helpers.rs index 15d9d6f72..5c376f09d 100644 --- a/src/impls/shared_helpers.rs +++ b/src/impls/shared_helpers.rs @@ -2376,16 +2376,7 @@ fn translate_table_function_args( .iter() .map(|arg| translate_function_arg::(arg, schema, options, emit)) .collect::, _>>()?, - settings: args - .settings - .as_ref() - .map(|settings| { - settings - .iter() - .map(|setting| translate_setting::(setting, schema, options, emit)) - .collect::, _>>() - }) - .transpose()?, + settings: translate_query_settings::(args.settings.as_ref(), schema, options, emit)?, }) } diff --git a/src/impls/translator_impls/expr.rs b/src/impls/translator_impls/expr.rs index 191df14a3..8efd48e3b 100644 --- a/src/impls/translator_impls/expr.rs +++ b/src/impls/translator_impls/expr.rs @@ -2467,6 +2467,15 @@ fn warn_ascii_only_ilike_folding( }); } +/// `datetime(target, modifier..., 'subsec')` with its trailing fractional zeros +/// trimmed. +fn shifted_datetime(target: Expr, modifiers: &[String]) -> Expr { + let mut args = vec![target]; + args.extend(modifiers.iter().map(|modifier| string_literal(modifier))); + args.push(string_literal("subsec")); + trim_trailing_zeros(simple_function_expr("datetime", args, None)) +} + /// Translate a binary operation expression. #[allow(clippy::too_many_lines)] fn translate_binary_op( @@ -3186,14 +3195,7 @@ fn translate_binary_op( match interval_date_modifiers_scaled(interval, negating, n)? { Some(modifiers) => { let target = left.translate_with_warnings(schema, options, emit)?; - let mut args = vec![target]; - for modifier in modifiers { - args.push(string_literal(&modifier)); - } - args.push(string_literal("subsec")); - return Ok(trim_trailing_zeros(simple_function_expr( - "datetime", args, None, - ))); + return Ok(shifted_datetime(target, &modifiers)); } None => { return Err(crate::errors::Error::forward_refusal(format!( @@ -3217,12 +3219,7 @@ fn translate_binary_op( match interval_date_modifiers(interval, negating)? { Some(modifiers) => { let target = left.translate_with_warnings(schema, options, emit)?; - let mut args = vec![target]; - for modifier in modifiers { - args.push(string_literal(&modifier)); - } - args.push(string_literal("subsec")); - return Ok(trim_trailing_zeros(simple_function_expr("datetime", args, None))); + return Ok(shifted_datetime(target, &modifiers)); } None => { // The notation was not decoded (HH:MM:SS, ISO P-form, diff --git a/src/impls/translator_impls/insert.rs b/src/impls/translator_impls/insert.rs index d9d525062..d9e054caa 100644 --- a/src/impls/translator_impls/insert.rs +++ b/src/impls/translator_impls/insert.rs @@ -483,7 +483,7 @@ fn substitute_do_update_defaults( /// order, which is what PostgreSQL fills an omitted list from. Comparison /// against these names is case-insensitive at every caller, to match /// PostgreSQL's identifier folding. -fn insert_column_names( +pub(crate) fn insert_column_names( insert: &Insert, table: &ParserTable, schema: &ParserDB, diff --git a/src/impls/translator_impls/rls.rs b/src/impls/translator_impls/rls.rs index c201e754a..5294e3b4a 100644 --- a/src/impls/translator_impls/rls.rs +++ b/src/impls/translator_impls/rls.rs @@ -1257,25 +1257,28 @@ fn transform_expr_generic( } Expr::Function(func) => { + let transform_list = |list: &FunctionArgumentList| { + FunctionArgumentList { + duplicate_treatment: list.duplicate_treatment, + args: list + .args + .iter() + .map(|arg| transform_function_arg_with_rls(arg, &recurse)) + .collect(), + clauses: list + .clauses + .iter() + .map(|clause| { + transform_function_argument_clause_rls( + clause, options, table, schema, strategy, + ) + }) + .collect(), + } + }; let transformed_args = match &func.args { FunctionArguments::List(arg_list) => { - FunctionArguments::List(FunctionArgumentList { - duplicate_treatment: arg_list.duplicate_treatment, - args: arg_list - .args - .iter() - .map(|arg| transform_function_arg_with_rls(arg, &recurse)) - .collect(), - clauses: arg_list - .clauses - .iter() - .map(|clause| { - transform_function_argument_clause_rls( - clause, options, table, schema, strategy, - ) - }) - .collect(), - }) + FunctionArguments::List(transform_list(arg_list)) } FunctionArguments::Subquery(query) => { FunctionArguments::Subquery(Box::new(transform_query( @@ -1345,23 +1348,7 @@ fn transform_expr_generic( let transformed_parameters = match &func.parameters { FunctionArguments::List(param_list) => { - FunctionArguments::List(FunctionArgumentList { - duplicate_treatment: param_list.duplicate_treatment, - args: param_list - .args - .iter() - .map(|arg| transform_function_arg_with_rls(arg, &recurse)) - .collect(), - clauses: param_list - .clauses - .iter() - .map(|clause| { - transform_function_argument_clause_rls( - clause, options, table, schema, strategy, - ) - }) - .collect(), - }) + FunctionArguments::List(transform_list(param_list)) } other => other.clone(), }; @@ -3232,6 +3219,46 @@ mod tests { assert_eq!(prefixed_identifier.to_string(), "NEW.owner_id"); } + #[test] + fn transform_expr_transforms_function_parameters_like_args() { + let schema = schema_from_sql( + "CREATE TABLE reports(id INTEGER PRIMARY KEY, tenant TEXT, amount INTEGER, weight INTEGER);", + ); + let table = schema + .table_by_target(TargetName::new("reports", false), IdentifierCase::AsWritten) + .expect("table lookup should succeed") + .expect("table should exist"); + let options = crate::options::TranslationContext::from_owned(Pg2SqliteOptions::default()); + let lowercased_columns = resolved_sets(table, &schema); + let facts = ResolvedSchemaFacts { lowercased_columns: &lowercased_columns }; + + let call = Expr::Function(Function { + name: ObjectName(vec![ObjectNamePart::Identifier(Ident::new("quantile"))]), + uses_odbc_syntax: false, + parameters: FunctionArguments::List(FunctionArgumentList { + duplicate_treatment: None, + args: vec![FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Identifier( + Ident::new("weight"), + )))], + clauses: vec![], + }), + args: FunctionArguments::List(FunctionArgumentList { + duplicate_treatment: None, + args: vec![FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Identifier( + Ident::new("amount"), + )))], + clauses: vec![], + }), + filter: None, + null_treatment: None, + over: None, + within_group: vec![], + }); + + let transformed = transform_expr(&call, &options, table, &schema, Some("NEW"), None, facts); + assert_eq!(transformed.to_string(), "quantile(NEW.weight)(NEW.amount)"); + } + #[test] fn transform_query_and_subquery_helpers_cover_projection_and_join_paths() { let schema = schema_from_sql( diff --git a/tests/gauntlet/parity_rls.rs b/tests/gauntlet/parity_rls.rs index bb984a123..bea7a8639 100644 --- a/tests/gauntlet/parity_rls.rs +++ b/tests/gauntlet/parity_rls.rs @@ -58,6 +58,56 @@ fn arm_triggers(conn: &mut SqliteConnection, triggers: &[String]) { } } +/// Vendor-specific session commands shared by the Pg runners. `SET` session +/// variables have no typed DSL form. +fn pg_set_session_user(connection: &mut PgConnection, current_role: &mut String, user: Uuid) { + diesel::sql_query(format!("SET app.user_id = '{user}'")) + .execute(connection) + .expect("set session user"); + if current_role.is_empty() { + diesel::sql_query("SET ROLE app").execute(connection).expect("set role app"); + *current_role = "app".to_string(); + } +} + +/// The column values of one `tenant_users` membership row. +type TenantUserRow<'a> = ( + diesel::dsl::Eq, + diesel::dsl::Eq, + diesel::dsl::Eq, + diesel::dsl::Eq, +); + +/// The insert of one `tenant_users` membership row. +type TenantUserInsert<'a> = diesel::dsl::Values< + diesel::dsl::insert_into, + TenantUserRow<'a>, +>; + +/// Seeds a tenant membership row. The seed shape is the same on both engines, +/// so the insert is shared between them. +fn seed_tenant_membership<'r, C>( + connection: &mut C, + id: Uuid, + tenant: Uuid, + user: Uuid, + role: &'r str, +) where + C: diesel::Connection, + TenantUserInsert<'r>: diesel::query_dsl::methods::ExecuteDsl, +{ + use tenant_schema::tenant_users; + diesel::insert_into(tenant_users::table) + .values(( + tenant_users::id.eq(id), + tenant_users::tenant_id.eq(tenant), + tenant_users::user_id.eq(user), + tenant_users::role.eq(role), + )) + .execute(connection) + .expect("seed tenant_user"); +} + // ════════════════════════════════════════════════════════════════ // rls_basic: documents owned by a single user, four-policy table. // ════════════════════════════════════════════════════════════════ @@ -156,15 +206,7 @@ impl_basic_step!(BasicSqliteRunner); impl BasicPgRunner { fn become_user(&mut self, user: Uuid) { self.acting = user; - // Vendor-specific session commands. - diesel::sql_query(format!("SET app.user_id = '{user}'")) - .execute(&mut self.connection) - .expect("set session user"); - if self.current_role.is_empty() { - // Vendor-specific role switch so RLS applies. - diesel::sql_query("SET ROLE app").execute(&mut self.connection).expect("set role app"); - self.current_role = "app".to_string(); - } + pg_set_session_user(&mut self.connection, &mut self.current_role, user); } fn stored_count(&mut self) -> i64 { @@ -407,16 +449,8 @@ impl_multi_step!(MultiSqliteRunner); impl MultiPgRunner { fn become_user(&mut self, user: Uuid) { self.acting = user; - // Vendor-specific session commands. - diesel::sql_query(format!("SET app.user_id = '{user}'")) - .execute(&mut self.connection) - .expect("set session user"); - if self.current_role.is_empty() { - diesel::sql_query("SET ROLE app").execute(&mut self.connection).expect("set role app"); - self.current_role = "app".to_string(); - } + pg_set_session_user(&mut self.connection, &mut self.current_role, user); } - fn set_department(&mut self, opt_dept: Option<&str>) { let sql = match opt_dept { Some(d) => format!("SET app.user_department = '{d}'"), @@ -710,15 +744,8 @@ impl_tenant_step!(TenantSqliteRunner); impl TenantPgRunner { fn become_user(&mut self, user: Uuid) { self.acting = user; - diesel::sql_query(format!("SET app.user_id = '{user}'")) - .execute(&mut self.connection) - .expect("set session user"); - if self.current_role.is_empty() { - diesel::sql_query("SET ROLE app").execute(&mut self.connection).expect("set role app"); - self.current_role = "app".to_string(); - } + pg_set_session_user(&mut self.connection, &mut self.current_role, user); } - fn stored_count(&mut self) -> i64 { use tenant_schema::projects; let role = self.current_role.clone(); @@ -742,16 +769,7 @@ impl TenantPgRunner { } fn seed_membership(&mut self, id: Uuid, tenant: Uuid, user: Uuid, role: &str) { - use tenant_schema::tenant_users; - diesel::insert_into(tenant_users::table) - .values(( - tenant_users::id.eq(id), - tenant_users::tenant_id.eq(tenant), - tenant_users::user_id.eq(user), - tenant_users::role.eq(role), - )) - .execute(&mut self.connection) - .expect("seed tenant_user"); + seed_tenant_membership(&mut self.connection, id, tenant, user, role); } fn seed_project(&mut self, id: Uuid, tenant: Uuid, name: &str, creator: Uuid) { @@ -788,16 +806,7 @@ impl TenantSqliteRunner { } fn seed_membership(&mut self, id: Uuid, tenant: Uuid, user: Uuid, role: &str) { - use tenant_schema::tenant_users; - diesel::insert_into(tenant_users::table) - .values(( - tenant_users::id.eq(id), - tenant_users::tenant_id.eq(tenant), - tenant_users::user_id.eq(user), - tenant_users::role.eq(role), - )) - .execute(&mut self.connection) - .expect("seed tenant_user"); + seed_tenant_membership(&mut self.connection, id, tenant, user, role); } fn seed_project(&mut self, id: Uuid, tenant: Uuid, name: &str, creator: Uuid) { diff --git a/tests/gauntlet/parity_text.rs b/tests/gauntlet/parity_text.rs index 6742c6be7..9bf7b13b1 100644 --- a/tests/gauntlet/parity_text.rs +++ b/tests/gauntlet/parity_text.rs @@ -89,7 +89,12 @@ struct Val { val: Option, } -fn pg_rows(conn: &mut PgConnection, sql: &str) -> Outcome { +/// Loads `sql` against either engine and maps the result to an `Outcome`. +/// The query is the translator's output text, which no typed DSL can carry. +fn rows_outcome(conn: &mut C, sql: &str) -> Outcome +where + diesel::query_builder::SqlQuery: diesel::query_dsl::LoadQuery<'static, C, Val>, +{ match diesel::sql_query(sql).load::(conn) { Ok(rows) => { Outcome::Rows( @@ -100,15 +105,12 @@ fn pg_rows(conn: &mut PgConnection, sql: &str) -> Outcome { } } +fn pg_rows(conn: &mut PgConnection, sql: &str) -> Outcome { + rows_outcome(conn, sql) +} + fn sqlite_rows(conn: &mut SqliteConnection, sql: &str) -> Outcome { - match diesel::sql_query(sql).load::(conn) { - Ok(rows) => { - Outcome::Rows( - rows.into_iter().map(|r| r.val.unwrap_or_else(|| "NULL".to_string())).collect(), - ) - } - Err(_) => Outcome::Refused, - } + rows_outcome(conn, sql) } /// Translates a PG SELECT with the test table as schema context. diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs index 78cbb5658..aea519bfb 100644 --- a/tests/helpers/mod.rs +++ b/tests/helpers/mod.rs @@ -437,3 +437,245 @@ pub fn vec_connection() -> rusqlite::Connection { register_sqlite_vec_once(); rusqlite::Connection::open_in_memory().expect("in-memory SQLite") } + +// ── Shared translation helpers ───────────────────────────────────────────── + +/// Translates `pg` with default options and returns the emitted statements +/// as strings. +pub fn translate_default_statements(pg: &str) -> Result, String> { + Pg2Sqlite::default() + .sql(pg) + .map_err(|e| e.to_string())? + .translate(&Pg2SqliteOptions::default()) + .map(|script| script.into_vec().iter().map(ToString::to_string).collect()) + .map_err(|e| e.to_string()) +} + +/// Translates `pg` with default options and returns the emitted statements +/// joined with newlines. +pub fn translate_default_joined(pg: &str) -> Result { + translate_default_statements(pg).map(|stmts| stmts.join("\n")) +} + +/// Translates `pg` with default options and returns the emitted statements +/// joined with newlines, or the translation error message when translation +/// is refused. +pub fn translate_default_or_err(pg: &str) -> String { + match Pg2Sqlite::default().sql(pg).unwrap().translate(&Pg2SqliteOptions::default()) { + Err(e) => e.to_string(), + Ok(script) => { + script.into_vec().iter().map(ToString::to_string).collect::>().join("\n") + } + } +} + +/// Translates `{prefix}\n{pg}` with default options and returns the emitted +/// statements. +pub fn translate_prefixed(prefix: &str, pg: &str) -> Result, String> { + Pg2Sqlite::default() + .sql(&format!("{prefix}\n{pg}")) + .map_err(|e| e.to_string())? + .translate_to_sql(&Pg2SqliteOptions::default()) + .map(pg2sqlite::script::Script::into_vec) + .map_err(|e| e.to_string()) +} + +/// Translates `pg` with default options and returns the emitted statements +/// alongside the collected translation warnings. +pub fn translate_with_default_warnings( + pg: &str, +) -> Result<(Vec, Vec), pg2sqlite::errors::Error> { + let report = + Pg2Sqlite::default().sql(pg)?.translate_with_report(&Pg2SqliteOptions::default())?; + let stmts = report.script.iter().map(ToString::to_string).collect(); + Ok((stmts, report.warnings)) +} + +/// Translates `pg` with `options` and applies every emitted statement to a +/// fresh in-memory connection. The emitted DDL is the artifact under test, +/// which no typed DSL can stand in for. +pub fn apply_with_options( + pg: &str, + options: &Pg2SqliteOptions, +) -> Result> { + let statements = Pg2Sqlite::default().sql(pg)?.translate(options)?.into_vec(); + let mut conn = SqliteConnection::establish(":memory:")?; + for statement in &statements { + // Emitted DDL is not expressible via the typed DSL. + diesel::sql_query(statement.to_string()).execute(&mut conn)?; + } + Ok(conn) +} + +/// Translates `pg` with default options and applies every emitted statement +/// to a fresh in-memory connection. +pub fn apply_default(pg: &str) -> Result> { + apply_with_options(pg, &Pg2SqliteOptions::default()) +} + +/// Translates `pg` with the `rls_audit` audit table name and returns the +/// emitted statements joined with newlines. +pub fn translate_rls_sql(pg: &str) -> Result> { + let options = Pg2SqliteOptions::default().with_rls_audit_table_name("rls_audit"); + Ok(translate_pg(pg, &options)?.join("\n")) +} + +/// Translates `pg` with the `rls_audit` audit table name and applies every +/// emitted statement to a fresh in-memory connection. +pub fn apply_rls_sql(pg: &str) -> Result> { + let options = Pg2SqliteOptions::default().with_rls_audit_table_name("rls_audit"); + let stmts = translate_pg(pg, &options)?; + let mut conn = SqliteConnection::establish(":memory:")?; + for stmt in &stmts { + // Emitted DDL is not expressible via the typed DSL. + diesel::sql_query(stmt.as_str()).execute(&mut conn)?; + } + Ok(conn) +} + +/// Translates `pg` with `opts` and applies every emitted statement to a +/// fresh in-memory connection with recursive triggers on. +pub fn apply_translated_ddl(pg: &str, opts: &Pg2SqliteOptions) -> SqliteConnection { + let translated = + Pg2Sqlite::default().sql(pg).expect("parse").translate(opts).expect("translate").into_vec(); + let mut conn = establish_connection(); + for statement in &translated { + // DDL emitted by the translator cannot be expressed with the typed DSL. + diesel::sql_query(statement.to_string()) + .execute(&mut conn) + .unwrap_or_else(|e| panic!("emitted DDL failed: {e}\n{statement}")); + } + conn +} + +/// Runs every statement of `stmts` on a fresh in-memory connection and +/// returns it, panicking on the first failure. +pub fn apply_statements(stmts: &[String]) -> SqliteConnection { + let mut conn = SqliteConnection::establish(":memory:").expect("connect"); + for statement in stmts { + // Emitted DDL is not expressible via the typed DSL. + diesel::sql_query(statement) + .execute(&mut conn) + .unwrap_or_else(|error| panic!("emitted statement failed: {statement}: {error}")); + } + conn +} + +/// Executes each non-empty newline-separated statement of `output` against +/// an in-memory SQLite, proving the joined output runs. +pub fn execute_joined_statements(output: &str) { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + for s in output.split('\n').filter(|s| !s.trim().is_empty()) { + // Translated output is a runtime string that no typed DSL can carry. + conn.execute_batch(&format!("{s};")).unwrap(); + } +} + +/// Reverse-translates `sqlite_sql` against the schema built from `pg_ddl` +/// and returns the error message. Panics when the translation succeeds. +pub fn reverse_sql_err(pg_ddl: &str, sqlite_sql: &str) -> String { + let translator = Pg2Sqlite::default().sql(pg_ddl).unwrap(); + let schema = translator.build_schema().unwrap(); + translator + .reverse_sql(sqlite_sql, &schema, &Pg2SqliteOptions::default()) + .unwrap_err() + .to_string() +} + +/// Reverse-translates `sqlite_sql` against `schema` and returns the joined +/// statements, or the translation error. +pub fn reverse_sql_joined( + sqlite_sql: &str, + schema: &sql_traits::structs::ParserDB, +) -> Result { + Pg2Sqlite::default() + .reverse_sql(sqlite_sql, schema, &Pg2SqliteOptions::default()) + .map(|stmts| stmts.iter().map(ToString::to_string).collect::>().join("; ")) +} + +/// Asserts that `rev(sqlite_sql)` succeeds, the output contains `want`, and +/// the output parses as valid PostgreSQL. +pub fn assert_reverse_emits(rev: F, sqlite_sql: &str, want: &str) +where + F: Fn(&str) -> Result, +{ + let out = rev(sqlite_sql).unwrap_or_else(|e| { + panic!("{sqlite_sql}\n expected output containing {want:?}, got Err: {e}") + }); + assert!(out.contains(want), "{sqlite_sql}\n expected {want:?} in: {out}"); + sqlparser::parser::Parser::parse_sql(&sqlparser::dialect::PostgreSqlDialect {}, &out) + .unwrap_or_else(|e| { + panic!("{sqlite_sql}\n reverse output is not valid PostgreSQL: {e}\n{out}") + }); +} + +/// Reverse-translates `sqlite_sql` against `schema`, proves the output +/// parses as PostgreSQL, and returns it. +pub fn reverse_checked(sqlite_sql: &str, schema: &sql_traits::structs::ParserDB) -> String { + let statements = Pg2Sqlite::default() + .reverse_sql(sqlite_sql, schema, &Pg2SqliteOptions::default()) + .expect("reverse translation"); + let sql = statements.iter().map(ToString::to_string).collect::>().join("; "); + sqlparser::parser::Parser::parse_sql(&sqlparser::dialect::PostgreSqlDialect {}, &sql) + .expect("output parses as PostgreSQL"); + sql +} + +/// Forward-translates `pg_sql` against `schema` and joins the emitted +/// statements with `; `. +pub fn forward_translate_against(pg_sql: &str, schema: &str) -> String { + let translator = Pg2Sqlite::default().sql(schema).unwrap(); + let built = translator.build_schema().unwrap(); + let stmts = + sqlparser::parser::Parser::parse_sql(&sqlparser::dialect::PostgreSqlDialect {}, pg_sql) + .unwrap(); + stmts + .iter() + .flat_map(|s| { + pg2sqlite::prelude::Translator::translate(s, &built, &Pg2SqliteOptions::default()) + .unwrap() + }) + .map(|s| s.to_string()) + .collect::>() + .join("; ") +} + +/// Reverse-translates `sqlite_sql` against the schema built from `schema` +/// and joins the emitted statements with `; `. +pub fn reverse_translate_against(sqlite_sql: &str, schema: &str) -> String { + let translator = Pg2Sqlite::default().sql(schema).unwrap(); + let built = translator.build_schema().unwrap(); + translator + .reverse_sql(sqlite_sql, &built, &Pg2SqliteOptions::default()) + .unwrap() + .into_iter() + .map(|s| s.to_string()) + .collect::>() + .join("; ") +} + +/// Evaluates `probe` in a fresh in-memory SQLite and returns the first +/// column of the first row. +pub fn evaluate_probe(probe: &str) -> T { + let connection = rusqlite::Connection::open_in_memory().expect("in-memory SQLite"); + connection + .query_row(probe, [], |row| row.get::<_, T>(0)) + .unwrap_or_else(|error| panic!("emitted probe failed: {error}\n{probe}")) +} + +/// Translates `pg_sql` with default options and proves every emitted +/// statement runs in SQLite. +pub fn assert_translated_statements_run(pg_sql: &str) { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + for stmt in Pg2Sqlite::default() + .sql(pg_sql) + .expect("parse") + .translate(&Pg2SqliteOptions::default()) + .expect("translate") + .into_vec() + { + // Translated output is a runtime string that no typed DSL can carry. + conn.execute_batch(&format!("{stmt};")) + .unwrap_or_else(|e| panic!("translated statement must run in SQLite: {e}\n{stmt}")); + } +} diff --git a/tests/helpers/translate.rs b/tests/helpers/translate.rs index 61715504e..d3a733343 100644 --- a/tests/helpers/translate.rs +++ b/tests/helpers/translate.rs @@ -50,3 +50,21 @@ pub fn translate_default_err(sql: &str) -> String { .unwrap_err() .to_string() } + +/// Translates `pg` SQL with default options and returns the first emitted +/// query as a string. Panics when parsing or translation fails or when no +/// query is emitted. +#[allow(dead_code)] +pub fn translate_default_first_query(sql: &str) -> String { + use sqlparser::ast::Statement; + Pg2Sqlite::default() + .sql(sql) + .unwrap() + .translate(&Pg2SqliteOptions::default()) + .unwrap() + .into_vec() + .into_iter() + .find(|s| matches!(s, Statement::Query(_))) + .unwrap() + .to_string() +} diff --git a/tests/test_aggregate_ordered_set.rs b/tests/test_aggregate_ordered_set.rs index bb926d443..bc08d903f 100644 --- a/tests/test_aggregate_ordered_set.rs +++ b/tests/test_aggregate_ordered_set.rs @@ -14,25 +14,13 @@ use run_translated_helper::run_translated_with; #[path = "helpers/translate.rs"] mod translate_helpers; -use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; -use sqlparser::ast::Statement; -use translate_helpers::translate_default_err as translate_err; +use pg2sqlite::prelude::Pg2SqliteOptions; +use translate_helpers::{ + translate_default_err as translate_err, translate_default_first_query as translate_ok, +}; const SCHEMA: &str = "CREATE TABLE t (id INT PRIMARY KEY, val FLOAT, label TEXT, flag BOOLEAN);"; -fn translate_ok(sql: &str) -> String { - Pg2Sqlite::default() - .sql(sql) - .unwrap() - .translate(&Pg2SqliteOptions::default()) - .unwrap() - .into_vec() - .into_iter() - .find(|s| matches!(s, Statement::Query(_))) - .unwrap() - .to_string() -} - /// percentile_cont uses WITHIN GROUP syntax unsupported by SQLite. #[test] fn percentile_cont_within_group_causes_error() { diff --git a/tests/test_alter_table_columns.rs b/tests/test_alter_table_columns.rs index 2ad83c628..ee362c43b 100644 --- a/tests/test_alter_table_columns.rs +++ b/tests/test_alter_table_columns.rs @@ -53,17 +53,7 @@ fn table_info(conn: &mut SqliteConnection) -> Result, Box Result> { - let translated = - Pg2Sqlite::default().sql(pg)?.translate(&Pg2SqliteOptions::default())?.into_vec(); - let mut conn = SqliteConnection::establish(":memory:")?; - for statement in &translated { - diesel::sql_query(statement.to_string()).execute(&mut conn)?; - } - Ok(conn) -} +use helpers::apply_default as apply; const BASE: &str = "CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT NOT NULL);"; diff --git a/tests/test_ast_recursion.rs b/tests/test_ast_recursion.rs index ac8ae6c38..beb8dc9aa 100644 --- a/tests/test_ast_recursion.rs +++ b/tests/test_ast_recursion.rs @@ -10,7 +10,11 @@ use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use rusqlite::Connection; -use sqlparser::ast::Statement; +#[path = "helpers/translate.rs"] +mod translate_helpers; +use translate_helpers::{ + translate_default as translate_all, translate_default_first_query as translate_ok, +}; const SCHEMA: &str = " CREATE TABLE t ( @@ -20,34 +24,6 @@ CREATE TABLE t ( ts TIMESTAMP );"; -/// Translate and return the first `SELECT` statement in the output. -fn translate_ok(sql: &str) -> String { - Pg2Sqlite::default() - .sql(sql) - .unwrap() - .translate(&Pg2SqliteOptions::default()) - .unwrap() - .into_vec() - .into_iter() - .find(|s| matches!(s, Statement::Query(_))) - .unwrap() - .to_string() -} - -/// Translate and return all statements joined with newlines. -fn translate_all(sql: &str) -> String { - Pg2Sqlite::default() - .sql(sql) - .unwrap() - .translate(&Pg2SqliteOptions::default()) - .unwrap() - .into_vec() - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n") -} - /// An expression inside a window-frame `PRECEDING` bound must be translated. /// Here `EXTRACT(EPOCH FROM NOW())` is a PG-specific expression; after /// translation `NOW()` becomes `strftime('%Y-%m-%d %H:%M:%f000+00:00', 'now')` diff --git a/tests/test_attached_tables.rs b/tests/test_attached_tables.rs index 905827997..e10b228d3 100644 --- a/tests/test_attached_tables.rs +++ b/tests/test_attached_tables.rs @@ -210,6 +210,31 @@ fn seed_papers(connection: &mut SqliteConnection) { set_write_exempt(false); } +/// Seeds the `notes` backing table directly, the way a server sync would. +fn seed_notes(connection: &mut SqliteConnection) { + set_write_exempt(true); + diesel::insert_into(notes::table) + .values(&[ + (notes::id.eq(1), notes::owner.eq("alice")), + (notes::id.eq(2), notes::owner.eq("bob")), + ]) + .execute(connection) + .expect("seed notes"); + set_write_exempt(false); +} + +/// Creates the store set `label`, initializes both files with `script`, and +/// seeds the `notes` backing table as user alice. +fn seed_in_fresh_store(label: &str, script: &Script) -> Files { + let files = Files::new(label); + { + let mut alice = files.connect("alice"); + run(&mut alice, script.initialize(FreshFiles::Both).expect("initialize")); + seed_notes(&mut alice); + } + files +} + fn visible_papers(connection: &mut SqliteConnection) -> Vec<(i32, String)> { papers::table.order(papers::id).load(connection).expect("read papers") } @@ -270,20 +295,7 @@ fn a_second_main_file_reads_an_existing_shared_file_through_its_policy() { "; let script = translate(NOTES, &options().with_attached_tables("shared", ["notes"])).expect("translate"); - let files = Files::new("second-main"); - { - let mut alice = files.connect("alice"); - run(&mut alice, script.initialize(FreshFiles::Both).expect("initialize")); - set_write_exempt(true); - diesel::insert_into(notes::table) - .values(&[ - (notes::id.eq(1), notes::owner.eq("alice")), - (notes::id.eq(2), notes::owner.eq("bob")), - ]) - .execute(&mut alice) - .expect("seed notes"); - set_write_exempt(false); - } + let files = seed_in_fresh_store("second-main", &script); let mut bob = files.connect_second("bob"); run(&mut bob, script.initialize(FreshFiles::Main).expect("main-only initialization")); @@ -336,20 +348,7 @@ fn a_fresh_attached_store_keeps_the_policy_of_an_unrelated_main_table() { CREATE TABLE papers (id INT PRIMARY KEY, title TEXT NOT NULL); "; let script = translate(NOTES, &placed_options()).expect("translate"); - let files = Files::new("fresh-attached-unrelated"); - { - let mut alice = files.connect("alice"); - run(&mut alice, script.initialize(FreshFiles::Both).expect("initialize")); - set_write_exempt(true); - diesel::insert_into(notes::table) - .values(&[ - (notes::id.eq(1), notes::owner.eq("alice")), - (notes::id.eq(2), notes::owner.eq("bob")), - ]) - .execute(&mut alice) - .expect("seed notes"); - set_write_exempt(false); - } + let files = seed_in_fresh_store("fresh-attached-unrelated", &script); std::fs::remove_file(&files.shared).expect("remove the closed store"); let mut bob = files.connect("bob"); diff --git a/tests/test_bound_param_conversion.rs b/tests/test_bound_param_conversion.rs index d15158c00..519d51448 100644 --- a/tests/test_bound_param_conversion.rs +++ b/tests/test_bound_param_conversion.rs @@ -21,6 +21,22 @@ fn uuid_text_opts() -> Pg2SqliteOptions { Pg2SqliteOptions::default().with_uuid_representation(UuidRepresentation::Text) } +/// Opens a vector-enabled connection with `schema` and `seed` applied. +fn vec_setup(opt: &Pg2SqliteOptions, schema: &str, seed: &str) -> rusqlite::Connection { + let stmts = Pg2Sqlite::default() + .sql(&format!("{schema}{seed}")) + .expect("parse") + .translate_to_sql(opt) + .expect("translate") + .into_vec(); + // rusqlite: sqlite_vec extension is not exposed through diesel's API. + let conn = helpers::vec_connection(); + for s in &stmts { + conn.execute_batch(&format!("{s};")).unwrap_or_else(|e| panic!("setup: {e}\n{s}")); + } + conn +} + /// Translates `query_sql` (the last statement after `schema_sql`) into a /// SQLite string and returns it. fn translate_query(schema_sql: &str, query_sql: &str, opt: &Pg2SqliteOptions) -> String { @@ -300,20 +316,7 @@ fn vector_param_comparison_finds_row() { let schema = "CREATE TABLE vecs (id INT PRIMARY KEY, emb vector(3));"; let seed = "INSERT INTO vecs VALUES (1, '[1.0,2.0,3.0]');"; - let conn = { - let stmts = Pg2Sqlite::default() - .sql(&format!("{schema}{seed}")) - .expect("parse") - .translate_to_sql(&opt) - .expect("translate") - .into_vec(); - // rusqlite: sqlite_vec extension is not exposed through diesel's API. - let conn = helpers::vec_connection(); - for s in &stmts { - conn.execute_batch(&format!("{s};")).unwrap_or_else(|e| panic!("setup: {e}\n{s}")); - } - conn - }; + let conn = vec_setup(&opt, schema, seed); let q = translate_query(schema, "SELECT id FROM vecs WHERE emb = $1;", &opt); // $1 carries the PostgreSQL vector text; the translator wraps it with @@ -442,19 +445,7 @@ fn vector_param_update_wraps_with_vec_f32() { let schema = "CREATE TABLE vecs (id INT PRIMARY KEY, emb vector(3));"; let seed = "INSERT INTO vecs VALUES (1, '[1.0,2.0,3.0]');"; - let conn = { - let stmts = Pg2Sqlite::default() - .sql(&format!("{schema}{seed}")) - .expect("parse") - .translate_to_sql(&opt) - .expect("translate") - .into_vec(); - let conn = helpers::vec_connection(); - for s in &stmts { - conn.execute_batch(&format!("{s};")).unwrap_or_else(|e| panic!("setup: {e}\n{s}")); - } - conn - }; + let conn = vec_setup(&opt, schema, seed); // finish_value wraps the param with vec_f32 for UPDATE; verify by WHERE // comparison. diff --git a/tests/test_case_folding.rs b/tests/test_case_folding.rs index b3e6c9ebd..74749dc40 100644 --- a/tests/test_case_folding.rs +++ b/tests/test_case_folding.rs @@ -16,6 +16,8 @@ use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use rusqlite::Connection; +mod helpers; + /// The last statement of a translation, which is the probe. fn translate(pg: &str) -> String { Pg2Sqlite::default() @@ -30,11 +32,7 @@ fn translate(pg: &str) -> String { /// The first column of the first row of the emitted probe. fn evaluate(pg: &str) -> T { - let probe = translate(pg); - let connection = Connection::open_in_memory().expect("in-memory SQLite"); - connection - .query_row(&probe, [], |row| row.get::<_, T>(0)) - .unwrap_or_else(|error| panic!("emitted probe failed: {error}\n{probe}")) + helpers::evaluate_probe(&translate(pg)) } /// The shape the README shows, on an ASCII pattern. Both halves are lowered diff --git a/tests/test_create_view.rs b/tests/test_create_view.rs index da8e2a239..0ed73dfe7 100644 --- a/tests/test_create_view.rs +++ b/tests/test_create_view.rs @@ -3,19 +3,11 @@ #[path = "helpers/run_translated.rs"] mod run_translated_helper; -use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; +mod helpers; +use helpers::translate_default_statements as translate; +use pg2sqlite::prelude::Pg2SqliteOptions; use run_translated_helper::run_translated_with; -fn translate(sql: &str) -> Result, String> { - Pg2Sqlite::default() - .sql(sql) - .map_err(|e| e.to_string())? - .translate(&Pg2SqliteOptions::default()) - .map(pg2sqlite::script::Script::into_vec) - .map(|stmts| stmts.iter().map(ToString::to_string).collect()) - .map_err(|e| e.to_string()) -} - fn translate_joined(sql: &str) -> Result { translate(sql).map(|v| v.join("\n")) } diff --git a/tests/test_cte_replay_bindings.rs b/tests/test_cte_replay_bindings.rs index f7fbd1ca8..226411d88 100644 --- a/tests/test_cte_replay_bindings.rs +++ b/tests/test_cte_replay_bindings.rs @@ -34,6 +34,28 @@ fn run<'a>(connection: &mut SqliteConnection, statements: impl Iterator) { + let mut creating = open(files); + run(&mut creating, script.iter()); + let row = schema::result::table + .select(schema::result::id) + .first::(&mut creating) + .expect("initial view row"); + assert_eq!(row, 42); +} + +/// Proves the reopened store still answers 42 after the connection program. +fn expect_replayed_row(files: &tempfile::TempDir, script: &Script) { + let mut reopened = open(files); + run(&mut reopened, script.connect().expect("connection program")); + let row = schema::result::table + .select(schema::result::id) + .first::(&mut reopened) + .expect("replayed view row"); + assert_eq!(row, 42); +} + #[test] fn replayed_cte_wildcard_keeps_its_lexical_value() { let script = script( @@ -45,22 +67,8 @@ fn replayed_cte_wildcard_keeps_its_lexical_value() { ALTER TABLE papers RENAME COLUMN id TO renamed;", ); let files = tempfile::TempDir::new().expect("files"); - { - let mut creating = open(&files); - run(&mut creating, script.iter()); - let row = schema::result::table - .select(schema::result::id) - .first::(&mut creating) - .expect("initial view row"); - assert_eq!(row, 42); - } - let mut reopened = open(&files); - run(&mut reopened, script.connect().expect("connection program")); - let row = schema::result::table - .select(schema::result::id) - .first::(&mut reopened) - .expect("replayed view row"); - assert_eq!(row, 42); + expect_initial_row(&files, &script); + expect_replayed_row(&files, &script); } #[test] @@ -72,22 +80,8 @@ fn replayed_qualified_wildcard_stays_lexical() { ALTER TABLE papers RENAME TO archive;", ); let files = tempfile::TempDir::new().expect("files"); - { - let mut creating = open(&files); - run(&mut creating, script.iter()); - let row = schema::result::table - .select(schema::result::id) - .first::(&mut creating) - .expect("initial view row"); - assert_eq!(row, 42); - } - let mut reopened = open(&files); - run(&mut reopened, script.connect().expect("connection program")); - let row = schema::result::table - .select(schema::result::id) - .first::(&mut reopened) - .expect("replayed view row"); - assert_eq!(row, 42); + expect_initial_row(&files, &script); + expect_replayed_row(&files, &script); } #[test] @@ -99,22 +93,8 @@ fn replayed_connect_ignores_an_unused_physical_column_drop() { ALTER TABLE papers DROP COLUMN n;", ); let files = tempfile::TempDir::new().expect("files"); - { - let mut creating = open(&files); - run(&mut creating, script.iter()); - let row = schema::result::table - .select(schema::result::id) - .first::(&mut creating) - .expect("initial view row"); - assert_eq!(row, 42); - } - let mut reopened = open(&files); - run(&mut reopened, script.connect().expect("connection program")); - let row = schema::result::table - .select(schema::result::id) - .first::(&mut reopened) - .expect("replayed view row"); - assert_eq!(row, 42); + expect_initial_row(&files, &script); + expect_replayed_row(&files, &script); } #[test] diff --git a/tests/test_datetime_semantics.rs b/tests/test_datetime_semantics.rs index 4db3fb464..687308d20 100644 --- a/tests/test_datetime_semantics.rs +++ b/tests/test_datetime_semantics.rs @@ -54,78 +54,48 @@ fn translate_err(pg: &str) -> String { .to_string() } -/// Run the translated form of `select_list` against the date/time table. -/// Returns the first column of the single result row as a string. -fn eval_dt(select_list: &str) -> Option { - let script = translate_sql(&format!("{DT_DDL}\nSELECT {select_list} FROM t WHERE id = 1;")); +/// Applies the translated DDL of `ddl`, seeds `seed`, and probes the last +/// emitted statement, returning its first column as `Option`. +fn eval_row(ddl: &str, seed: &str, select_list: &str) -> Option { + let script = translate_sql(&format!("{ddl}\nSELECT {select_list} FROM t WHERE id = 1;")); let conn = Connection::open_in_memory().expect("in-memory SQLite"); for stmt in &script[..script.len() - 1] { conn.execute_batch(&format!("{stmt};")) .unwrap_or_else(|e| panic!("DDL failed: {e}\n{stmt}")); } - conn.execute_batch(DT_ROW).expect("seed row"); + conn.execute_batch(seed).expect("seed row"); let probe = script.last().expect("a query"); - conn.query_row(probe, [], |r| r.get::<_, Option>(0)) + conn.query_row(probe, [], |r| r.get::<_, Option>(0)) .unwrap_or_else(|e| panic!("query failed: {e}\n{probe}")) } +/// Run the translated form of `select_list` against the date/time table. +/// Returns the first column of the single result row as a string. +fn eval_dt(select_list: &str) -> Option { + eval_row::(DT_DDL, DT_ROW, select_list) +} + /// Like `eval_dt` but returns the first column as a 64-bit integer. fn eval_dt_i64(select_list: &str) -> Option { - let script = translate_sql(&format!("{DT_DDL}\nSELECT {select_list} FROM t WHERE id = 1;")); - let conn = Connection::open_in_memory().expect("in-memory SQLite"); - for stmt in &script[..script.len() - 1] { - conn.execute_batch(&format!("{stmt};")) - .unwrap_or_else(|e| panic!("DDL failed: {e}\n{stmt}")); - } - conn.execute_batch(DT_ROW).expect("seed row"); - let probe = script.last().expect("a query"); - conn.query_row(probe, [], |r| r.get::<_, Option>(0)) - .unwrap_or_else(|e| panic!("query failed: {e}\n{probe}")) + eval_row::(DT_DDL, DT_ROW, select_list) } /// Like `eval_dt` but returns the first column as an f64. fn eval_dt_f64(select_list: &str) -> Option { - let script = translate_sql(&format!("{DT_DDL}\nSELECT {select_list} FROM t WHERE id = 1;")); - let conn = Connection::open_in_memory().expect("in-memory SQLite"); - for stmt in &script[..script.len() - 1] { - conn.execute_batch(&format!("{stmt};")) - .unwrap_or_else(|e| panic!("DDL failed: {e}\n{stmt}")); - } - conn.execute_batch(DT_ROW).expect("seed row"); - let probe = script.last().expect("a query"); - conn.query_row(probe, [], |r| r.get::<_, Option>(0)) - .unwrap_or_else(|e| panic!("query failed: {e}\n{probe}")) + eval_row::(DT_DDL, DT_ROW, select_list) } /// Run the translated form of `select_list` against the string table, seeded /// with `row` (a raw SQLite INSERT statement). Returns the first column as a /// string. fn eval_str(row: &str, select_list: &str) -> Option { - let script = translate_sql(&format!("{STR_DDL}\nSELECT {select_list} FROM t WHERE id = 1;")); - let conn = Connection::open_in_memory().expect("in-memory SQLite"); - for stmt in &script[..script.len() - 1] { - conn.execute_batch(&format!("{stmt};")) - .unwrap_or_else(|e| panic!("DDL failed: {e}\n{stmt}")); - } - conn.execute_batch(row).expect("seed row"); - let probe = script.last().expect("a query"); - conn.query_row(probe, [], |r| r.get::<_, Option>(0)) - .unwrap_or_else(|e| panic!("query failed: {e}\n{probe}")) + eval_row::(STR_DDL, row, select_list) } /// Like `eval_str` but returns the first column as a 64-bit integer. Used for /// functions that return INTEGER in SQLite (e.g. INSTR, length). fn eval_str_i64(row: &str, select_list: &str) -> Option { - let script = translate_sql(&format!("{STR_DDL}\nSELECT {select_list} FROM t WHERE id = 1;")); - let conn = Connection::open_in_memory().expect("in-memory SQLite"); - for stmt in &script[..script.len() - 1] { - conn.execute_batch(&format!("{stmt};")) - .unwrap_or_else(|e| panic!("DDL failed: {e}\n{stmt}")); - } - conn.execute_batch(row).expect("seed row"); - let probe = script.last().expect("a query"); - conn.query_row(probe, [], |r| r.get::<_, Option>(0)) - .unwrap_or_else(|e| panic!("query failed: {e}\n{probe}")) + eval_row::(STR_DDL, row, select_list) } // ── EXTRACT diff --git a/tests/test_ddl_clause_gaps.rs b/tests/test_ddl_clause_gaps.rs index 41114f18c..ec8818aff 100644 --- a/tests/test_ddl_clause_gaps.rs +++ b/tests/test_ddl_clause_gaps.rs @@ -26,14 +26,7 @@ fn translate(sql: &str) -> Result, pg2sqlite::errors::Error> { .map(pg2sqlite::script::Script::into_vec) } -/// Translates `sql` and collects warnings alongside the string vec. -fn translate_with_warnings( - sql: &str, -) -> Result<(Vec, Vec), pg2sqlite::errors::Error> { - let report = Pg2Sqlite::default().sql(sql)?.translate_with_report(&opts())?; - let stmts = report.script.iter().map(|s| s.to_string()).collect(); - Ok((stmts, report.warnings)) -} +use helpers::translate_with_default_warnings as translate_with_warnings; /// Executes every statement in `stmts` through rusqlite to confirm they parse /// and run without error. diff --git a/tests/test_ddl_constraint_clauses.rs b/tests/test_ddl_constraint_clauses.rs index 1a921b550..d7823ead7 100644 --- a/tests/test_ddl_constraint_clauses.rs +++ b/tests/test_ddl_constraint_clauses.rs @@ -8,12 +8,11 @@ //! 4. GENERATED ALWAYS AS IDENTITY (START WITH …) //! 5. PRIMARY KEY / UNIQUE DEFERRABLE at column level -use pg2sqlite::{ - prelude::{Pg2Sqlite, Pg2SqliteOptions}, - warnings::TranslationWarning, -}; +use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use rusqlite::Connection; +mod helpers; + fn opts() -> Pg2SqliteOptions { Pg2SqliteOptions::default() } @@ -25,13 +24,7 @@ fn translate(sql: &str) -> Result, pg2sqlite::errors::Error> { .map(pg2sqlite::script::Script::into_vec) } -fn translate_with_warnings( - sql: &str, -) -> Result<(Vec, Vec), pg2sqlite::errors::Error> { - let report = Pg2Sqlite::default().sql(sql)?.translate_with_report(&opts())?; - let stmts = report.script.iter().map(|s| s.to_string()).collect(); - Ok((stmts, report.warnings)) -} +use helpers::translate_with_default_warnings as translate_with_warnings; /// Executes every statement through rusqlite and returns the first error. fn sqlite_exec(stmts: &[String]) -> Result<(), rusqlite::Error> { diff --git a/tests/test_forward_delete.rs b/tests/test_forward_delete.rs index 51dc5ad74..ab54fc33c 100644 --- a/tests/test_forward_delete.rs +++ b/tests/test_forward_delete.rs @@ -1,8 +1,10 @@ //! Tests for forward DELETE translation covering USING clause conversion. //! in `src/impls/translator_impls/delete.rs`. +mod helpers; #[path = "helpers/translate.rs"] mod translate_helpers; +use helpers::execute_joined_statements as execute_all; use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use rusqlite::Connection; use sqlparser::{ @@ -12,15 +14,6 @@ use sqlparser::{ }; use translate_helpers::translate_default as translate; -/// Execute each non-empty statement in `output` (newline-joined) against an -/// in-memory SQLite to prove the translator emits valid SQL. -fn execute_all(output: &str) { - let conn = Connection::open_in_memory().unwrap(); - for s in output.split('\n').filter(|s| !s.trim().is_empty()) { - conn.execute_batch(&format!("{s};")).unwrap(); - } -} - fn parse_expr(sql: &str) -> Expr { Parser::new(&PostgreSqlDialect {}).try_with_sql(sql).unwrap().parse_expr().unwrap() } diff --git a/tests/test_forward_indexes.rs b/tests/test_forward_indexes.rs index 4da1cc749..adc126264 100644 --- a/tests/test_forward_indexes.rs +++ b/tests/test_forward_indexes.rs @@ -6,20 +6,11 @@ mod helpers; mod run_translated_helper; #[path = "helpers/translate.rs"] mod translate_helpers; +use helpers::translate_default_statements as translate_result; use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use run_translated_helper::run_translated_with; use translate_helpers::translate_default as translate; -fn translate_result(sql: &str) -> Result, String> { - Pg2Sqlite::default() - .sql(sql) - .map_err(|e| e.to_string())? - .translate(&Pg2SqliteOptions::default()) - .map(pg2sqlite::script::Script::into_vec) - .map(|stmts| stmts.iter().map(ToString::to_string).collect()) - .map_err(|e| e.to_string()) -} - /// Executes all statements emitted by translating `sql` against real in-memory /// SQLite. Used after substring asserts to prove the translated output runs /// without error. diff --git a/tests/test_forward_misc.rs b/tests/test_forward_misc.rs index ca7ef9036..694a7c925 100644 --- a/tests/test_forward_misc.rs +++ b/tests/test_forward_misc.rs @@ -9,24 +9,10 @@ mod helpers; #[path = "helpers/translate.rs"] mod translate_helpers; +use helpers::translate_default_or_err as translate_err; use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use translate_helpers::translate_default as translate; -fn translate_err(sql: &str) -> String { - let result = Pg2Sqlite::default() - .sql(sql) - .unwrap() - .translate(&Pg2SqliteOptions::default()) - .map(pg2sqlite::script::Script::into_vec); - match result { - Err(e) => e.to_string(), - Ok(stmts) => { - // Some statements may be empty (filtered) - stmts.iter().map(ToString::to_string).collect::>().join("\n") - } - } -} - #[test] fn fts_gin_with_compound_identifier() { let sql = " diff --git a/tests/test_forward_placeholders.rs b/tests/test_forward_placeholders.rs index 1f8336aed..becf1c33b 100644 --- a/tests/test_forward_placeholders.rs +++ b/tests/test_forward_placeholders.rs @@ -4,25 +4,14 @@ //! the same bind vector drives both the PostgreSQL original and the SQLite //! translation, and so a later reverse translation round trips exactly. -use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions, Translator}; -use sqlparser::{ - dialect::{PostgreSqlDialect, SQLiteDialect}, - parser::Parser, -}; +mod helpers; + +use sqlparser::{dialect::SQLiteDialect, parser::Parser}; const SCHEMA: &str = "CREATE TABLE t (a INT, b INT, c INT, d INT);"; fn forward(pg_sql: &str) -> String { - let translator = Pg2Sqlite::default().sql(SCHEMA).unwrap(); - let schema = translator.build_schema().unwrap(); - let options = Pg2SqliteOptions::default(); - let stmts = Parser::parse_sql(&PostgreSqlDialect {}, pg_sql).unwrap(); - stmts - .iter() - .flat_map(|s| s.translate(&schema, &options).unwrap()) - .map(|s| s.to_string()) - .collect::>() - .join("; ") + helpers::forward_translate_against(pg_sql, SCHEMA) } fn reparse_sqlite(sql: &str) { diff --git a/tests/test_forward_update.rs b/tests/test_forward_update.rs index f28d02c95..7541728cd 100644 --- a/tests/test_forward_update.rs +++ b/tests/test_forward_update.rs @@ -4,6 +4,7 @@ mod helpers; #[path = "helpers/translate.rs"] mod translate_helpers; +use helpers::execute_joined_statements as execute_all; use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use rusqlite::Connection; use sqlparser::{ @@ -16,15 +17,6 @@ use sqlparser::{ }; use translate_helpers::translate_default as translate; -/// Execute each non-empty statement in `output` (newline-joined) against an -/// in-memory SQLite to prove the translator emits valid SQL. -fn execute_all(output: &str) { - let conn = Connection::open_in_memory().unwrap(); - for s in output.split('\n').filter(|s| !s.trim().is_empty()) { - conn.execute_batch(&format!("{s};")).unwrap(); - } -} - fn parse_expr(sql: &str) -> Expr { Parser::new(&PostgreSqlDialect {}).try_with_sql(sql).unwrap().parse_expr().unwrap() } diff --git a/tests/test_function_null_semantics.rs b/tests/test_function_null_semantics.rs index 027c9e952..563e64ef2 100644 --- a/tests/test_function_null_semantics.rs +++ b/tests/test_function_null_semantics.rs @@ -5,7 +5,9 @@ //! argument with `COALESCE(arg, '')` to preserve PostgreSQL semantics. mod helpers; -use helpers::translate_sql; +use helpers::{ + assert_translated_statements_run as assert_all_stmts_parse_as_sqlite, translate_sql, +}; use pg2sqlite::prelude::Pg2SqliteOptions; fn translate(sql: &str) -> Result { @@ -65,17 +67,3 @@ fn concat_single_arg_produces_coalesced_result() { ); assert_all_stmts_parse_as_sqlite(sql); } - -fn assert_all_stmts_parse_as_sqlite(pg_sql: &str) { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - for stmt in pg2sqlite::prelude::Pg2Sqlite::default() - .sql(pg_sql) - .expect("parse") - .translate(&pg2sqlite::prelude::Pg2SqliteOptions::default()) - .expect("translate") - .into_vec() - { - conn.execute_batch(&format!("{stmt};")) - .unwrap_or_else(|e| panic!("translated statement must run in SQLite: {e}\n{stmt}")); - } -} diff --git a/tests/test_join_translation.rs b/tests/test_join_translation.rs index 90c1dfbca..875eb7944 100644 --- a/tests/test_join_translation.rs +++ b/tests/test_join_translation.rs @@ -194,6 +194,16 @@ struct Customer { name: String, } +/// Proves the emitted non-query statements run and the emitted `select_stmt` +/// prepares in SQLite. The statements are translator output, a runtime +/// string no typed DSL can carry. +fn prove_script(translated: &[sqlparser::ast::Statement], select_stmt: &str) { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + for stmt in translated.iter().filter(|s| !matches!(s, sqlparser::ast::Statement::Query(_))) { + conn.execute_batch(&format!("{stmt};")).unwrap(); + } + conn.prepare(select_stmt).unwrap(); +} #[test] fn test_join_ilike_translation() -> Result<(), Box> { let sql = " @@ -229,14 +239,7 @@ fn test_join_ilike_translation() -> Result<(), Box> { !select_stmt.to_uppercase().contains("ILIKE"), "Should not contain ILIKE, got: {select_stmt}" ); - { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - for stmt in translated.iter().filter(|s| !matches!(s, sqlparser::ast::Statement::Query(_))) - { - conn.execute_batch(&format!("{stmt};")).unwrap(); - } - conn.prepare(&select_stmt).unwrap(); - } + prove_script(&translated, &select_stmt); Ok(()) } @@ -277,14 +280,7 @@ fn test_join_now_translation() -> Result<(), Box> { !select_stmt.to_uppercase().contains("NOW()"), "Should not contain NOW(), got: {select_stmt}" ); - { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - for stmt in translated.iter().filter(|s| !matches!(s, sqlparser::ast::Statement::Query(_))) - { - conn.execute_batch(&format!("{stmt};")).unwrap(); - } - conn.prepare(&select_stmt).unwrap(); - } + prove_script(&translated, &select_stmt); Ok(()) } @@ -333,14 +329,7 @@ fn test_join_least_greatest_translation() -> Result<(), Box Result<(), Box Result<(), Box> { !select_stmt.to_uppercase().contains("ILIKE"), "Should not contain ILIKE, got: {select_stmt}" ); - { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - for stmt in translated.iter().filter(|s| !matches!(s, sqlparser::ast::Statement::Query(_))) - { - conn.execute_batch(&format!("{stmt};")).unwrap(); - } - conn.prepare(&select_stmt).unwrap(); - } + prove_script(&translated, &select_stmt); Ok(()) } @@ -488,14 +463,7 @@ fn test_left_join_translation() -> Result<(), Box> { select_stmt.to_uppercase().contains(" LIKE "), "ILIKE should translate to LIKE, got: {select_stmt}" ); - { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - for stmt in translated.iter().filter(|s| !matches!(s, sqlparser::ast::Statement::Query(_))) - { - conn.execute_batch(&format!("{stmt};")).unwrap(); - } - conn.prepare(&select_stmt).unwrap(); - } + prove_script(&translated, &select_stmt); Ok(()) } diff --git a/tests/test_literal_scale_folding.rs b/tests/test_literal_scale_folding.rs index a27eca4c8..36c139ef9 100644 --- a/tests/test_literal_scale_folding.rs +++ b/tests/test_literal_scale_folding.rs @@ -11,18 +11,13 @@ //! folded factor that does not run is worth nothing, and check that the two //! spellings produce the same answer rather than merely both producing one. -use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; +mod helpers; use rusqlite::Connection; const FIXTURE: &str = "CREATE TABLE t (id INT PRIMARY KEY, r REAL, amount NUMERIC(10,2));"; fn translate(dml: &str) -> Result, String> { - Pg2Sqlite::default() - .sql(&format!("{FIXTURE}\n{dml}")) - .map_err(|e| e.to_string())? - .translate_to_sql(&Pg2SqliteOptions::default()) - .map(pg2sqlite::script::Script::into_vec) - .map_err(|e| e.to_string()) + helpers::translate_prefixed(FIXTURE, dml) } /// Runs the emitted script over one row and returns the last statement's first diff --git a/tests/test_native_sqlite_statements.rs b/tests/test_native_sqlite_statements.rs index cdb041a35..d93d8556f 100644 --- a/tests/test_native_sqlite_statements.rs +++ b/tests/test_native_sqlite_statements.rs @@ -21,7 +21,9 @@ //! (verified), so it needs translating to `ALTER TABLE ... RENAME TO`, which is //! plan item R13. -use diesel::{prelude::*, sql_query}; +use diesel::prelude::*; + +mod helpers; use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; const BASE: &str = "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);"; @@ -45,19 +47,7 @@ fn emitted(pg: &str) -> Vec { all[fixture + 1..].to_vec() } -/// Applies every emitted statement to a fresh database. -/// -/// The generated SQL is the artifact under test, so it is executed as text. No -/// typed query can stand in for "run exactly what the translator produced". -fn apply(pg: &str) -> Result> { - let statements = - Pg2Sqlite::default().sql(pg)?.translate(&Pg2SqliteOptions::default())?.into_vec(); - let mut conn = SqliteConnection::establish(":memory:")?; - for statement in &statements { - sql_query(statement.to_string()).execute(&mut conn)?; - } - Ok(conn) -} +use helpers::apply_default as apply; /// `ANALYZE` is SQLite's own statement and must survive. #[test] diff --git a/tests/test_on_conflict_constraint.rs b/tests/test_on_conflict_constraint.rs index bc7a46af1..0652e8c63 100644 --- a/tests/test_on_conflict_constraint.rs +++ b/tests/test_on_conflict_constraint.rs @@ -22,6 +22,8 @@ use diesel::prelude::*; use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; +mod helpers; + mod schema { diesel::table! { t (id) { @@ -51,13 +53,7 @@ fn translate(pg: &str) -> Vec { } fn apply(pg: &str) -> SqliteConnection { - let mut conn = SqliteConnection::establish(":memory:").expect("connect"); - for statement in translate(pg) { - diesel::sql_query(&statement) - .execute(&mut conn) - .unwrap_or_else(|error| panic!("emitted statement failed: {statement}: {error}")); - } - conn + helpers::apply_statements(&translate(pg)) } fn translate_err(pg: &str) -> String { diff --git a/tests/test_on_conflict_do_nothing.rs b/tests/test_on_conflict_do_nothing.rs index c5d11e2fa..0bac3605e 100644 --- a/tests/test_on_conflict_do_nothing.rs +++ b/tests/test_on_conflict_do_nothing.rs @@ -15,6 +15,8 @@ use diesel::prelude::*; use pg2sqlite::prelude::{Error, Pg2Sqlite, Pg2SqliteOptions}; +mod helpers; + mod schema { diesel::table! { oc (id) { @@ -61,13 +63,7 @@ fn translate(dml: &str) -> Vec { /// Runs every emitted statement, and panics if any of them fails. fn apply(dml: &str) -> SqliteConnection { - let mut connection = SqliteConnection::establish(":memory:").expect("connect"); - for statement in translate(dml) { - diesel::sql_query(&statement) - .execute(&mut connection) - .unwrap_or_else(|error| panic!("emitted statement failed: {statement}: {error}")); - } - connection + helpers::apply_statements(&translate(dml)) } /// The first execution failure, for the cases where PostgreSQL raises one. diff --git a/tests/test_placeholder_roundtrip.rs b/tests/test_placeholder_roundtrip.rs index a2e321144..70bd08c20 100644 --- a/tests/test_placeholder_roundtrip.rs +++ b/tests/test_placeholder_roundtrip.rs @@ -5,32 +5,18 @@ //! each other byte for byte; a bare `?` canonicalizes to `?N` on its first loop //! (the index SQLite itself would assign it) and then stays fixed. -use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions, Translator}; -use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; +mod helpers; const SCHEMA: &str = "CREATE TABLE t (a INT, b INT, c INT, d INT);"; /// SQLite SQL text to PostgreSQL SQL text. fn reverse(sqlite_sql: &str) -> String { - let translator = Pg2Sqlite::default().sql(SCHEMA).unwrap(); - let schema = translator.build_schema().unwrap(); - let options = Pg2SqliteOptions::default(); - let stmts = translator.reverse_sql(sqlite_sql, &schema, &options).unwrap(); - stmts.iter().map(ToString::to_string).collect::>().join("; ") + helpers::reverse_translate_against(sqlite_sql, SCHEMA) } /// PostgreSQL SQL text to SQLite SQL text. fn forward(pg_sql: &str) -> String { - let translator = Pg2Sqlite::default().sql(SCHEMA).unwrap(); - let schema = translator.build_schema().unwrap(); - let options = Pg2SqliteOptions::default(); - let stmts = Parser::parse_sql(&PostgreSqlDialect {}, pg_sql).unwrap(); - stmts - .iter() - .flat_map(|s| s.translate(&schema, &options).unwrap()) - .map(|s| s.to_string()) - .collect::>() - .join("; ") + helpers::forward_translate_against(pg_sql, SCHEMA) } #[test] diff --git a/tests/test_reverse_coverage_gaps.rs b/tests/test_reverse_coverage_gaps.rs index 06fd2c7d7..132b4fc1a 100644 --- a/tests/test_reverse_coverage_gaps.rs +++ b/tests/test_reverse_coverage_gaps.rs @@ -10,6 +10,9 @@ use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; +mod helpers; + +use helpers::reverse_sql_err as reverse_err; fn reverse(pg_ddl: &str, sqlite_sql: &str) -> String { let translator = Pg2Sqlite::default().sql(pg_ddl).unwrap(); @@ -23,15 +26,6 @@ fn reverse(pg_ddl: &str, sqlite_sql: &str) -> String { pg } -fn reverse_err(pg_ddl: &str, sqlite_sql: &str) -> String { - let translator = Pg2Sqlite::default().sql(pg_ddl).unwrap(); - let schema = translator.build_schema().unwrap(); - translator - .reverse_sql(sqlite_sql, &schema, &Pg2SqliteOptions::default()) - .unwrap_err() - .to_string() -} - fn forward(pg_sql: &str) -> Vec { Pg2Sqlite::default() .sql(pg_sql) diff --git a/tests/test_reverse_dml.rs b/tests/test_reverse_dml.rs index 17c3e04f2..34d0f5d86 100644 --- a/tests/test_reverse_dml.rs +++ b/tests/test_reverse_dml.rs @@ -9,6 +9,9 @@ //! CONFLICT DO UPDATE + WHERE, RETURNING) use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; +mod helpers; + +use helpers::reverse_sql_err as reverse_err; fn reverse(pg_ddl: &str, sqlite_sql: &str) -> String { let translator = Pg2Sqlite::default().sql(pg_ddl).unwrap(); @@ -22,13 +25,6 @@ fn reverse(pg_ddl: &str, sqlite_sql: &str) -> String { pg } -fn reverse_err(pg_ddl: &str, sqlite_sql: &str) -> String { - let translator = Pg2Sqlite::default().sql(pg_ddl).unwrap(); - let schema = translator.build_schema().unwrap(); - let options = Pg2SqliteOptions::default(); - translator.reverse_sql(sqlite_sql, &schema, &options).unwrap_err().to_string() -} - const SCHEMA: &str = "CREATE TABLE users (id INT PRIMARY KEY, name TEXT, age INT);"; const TWO_TABLES: &str = " CREATE TABLE users (id INT PRIMARY KEY, name TEXT, age INT); diff --git a/tests/test_reverse_epoch_now.rs b/tests/test_reverse_epoch_now.rs index d9b6ea63c..55920ff20 100644 --- a/tests/test_reverse_epoch_now.rs +++ b/tests/test_reverse_epoch_now.rs @@ -16,7 +16,8 @@ use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use rusqlite::Connection; use sql_traits::structs::ParserDB; -use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; + +mod helpers; const SCHEMA: &str = "CREATE TABLE t (id INT PRIMARY KEY, ts TIMESTAMP, tz TIMESTAMPTZ);"; @@ -25,12 +26,7 @@ fn schema() -> ParserDB { } fn reverse(sqlite: &str) -> String { - let statements = Pg2Sqlite::default() - .reverse_sql(sqlite, &schema(), &Pg2SqliteOptions::default()) - .expect("reverse translation"); - let sql = statements.iter().map(ToString::to_string).collect::>().join("; "); - Parser::parse_sql(&PostgreSqlDialect {}, &sql).expect("output parses as PostgreSQL"); - sql + helpers::reverse_checked(sqlite, &schema()) } // --- unixepoch() with no arguments ----------------------------------------- diff --git a/tests/test_reverse_ident_quoting.rs b/tests/test_reverse_ident_quoting.rs index d392e0a57..2316650fb 100644 --- a/tests/test_reverse_ident_quoting.rs +++ b/tests/test_reverse_ident_quoting.rs @@ -6,14 +6,12 @@ use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; +mod helpers; + const SCHEMA: &str = "CREATE TABLE t (c INT, d INT); CREATE TABLE t2 (c INT);"; fn reverse(sqlite_sql: &str) -> String { - let translator = Pg2Sqlite::default().sql(SCHEMA).unwrap(); - let schema = translator.build_schema().unwrap(); - let options = Pg2SqliteOptions::default(); - let stmts = translator.reverse_sql(sqlite_sql, &schema, &options).unwrap(); - stmts.iter().map(ToString::to_string).collect::>().join("; ") + helpers::reverse_translate_against(sqlite_sql, SCHEMA) } #[test] diff --git a/tests/test_reverse_json_functions.rs b/tests/test_reverse_json_functions.rs index 505035179..fa670c48a 100644 --- a/tests/test_reverse_json_functions.rs +++ b/tests/test_reverse_json_functions.rs @@ -6,9 +6,10 @@ //! in the task specification plus the two rejection conditions (non-literal //! path and array-index path). -use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; +use pg2sqlite::prelude::Pg2Sqlite; use sql_traits::structs::ParserDB; -use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; + +mod helpers; fn schema() -> ParserDB { Pg2Sqlite::default() @@ -20,23 +21,13 @@ fn schema() -> ParserDB { /// Reverse-translate `sqlite_sql` and return the joined output string. fn rev(sqlite_sql: &str) -> Result { - let schema = schema(); - let options = Pg2SqliteOptions::default(); - Pg2Sqlite::default() - .reverse_sql(sqlite_sql, &schema, &options) - .map(|stmts| stmts.iter().map(ToString::to_string).collect::>().join("; ")) + helpers::reverse_sql_joined(sqlite_sql, &schema()) } /// Assert that `rev(sqlite_sql)` succeeds and the output contains `want`, /// then verify the output parses as valid PostgreSQL. fn assert_emits(sqlite_sql: &str, want: &str) { - let out = rev(sqlite_sql).unwrap_or_else(|e| { - panic!("{sqlite_sql}\n expected output containing {want:?}, got Err: {e}") - }); - assert!(out.contains(want), "{sqlite_sql}\n expected {want:?} in: {out}"); - Parser::parse_sql(&PostgreSqlDialect {}, &out).unwrap_or_else(|e| { - panic!("{sqlite_sql}\n reverse output is not valid PostgreSQL: {e}\n{out}") - }); + helpers::assert_reverse_emits(rev, sqlite_sql, want); } /// Assert that `rev(sqlite_sql)` fails and the error message contains `want`. diff --git a/tests/test_reverse_json_op_fixes.rs b/tests/test_reverse_json_op_fixes.rs index d7cbe5ffe..84efb99d1 100644 --- a/tests/test_reverse_json_op_fixes.rs +++ b/tests/test_reverse_json_op_fixes.rs @@ -9,6 +9,8 @@ use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use sql_traits::structs::ParserDB; use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; +mod helpers; + const SCHEMA: &str = "CREATE TABLE t (id INT PRIMARY KEY, ts TIMESTAMP, payload JSONB);"; fn schema() -> ParserDB { @@ -16,21 +18,11 @@ fn schema() -> ParserDB { } fn rev(sqlite_sql: &str) -> Result { - let schema = schema(); - let options = Pg2SqliteOptions::default(); - Pg2Sqlite::default() - .reverse_sql(sqlite_sql, &schema, &options) - .map(|stmts| stmts.iter().map(ToString::to_string).collect::>().join("; ")) + helpers::reverse_sql_joined(sqlite_sql, &schema()) } fn assert_emits(sqlite_sql: &str, want: &str) { - let out = rev(sqlite_sql).unwrap_or_else(|e| { - panic!("{sqlite_sql}\n expected output containing {want:?}, got Err: {e}") - }); - assert!(out.contains(want), "{sqlite_sql}\n expected {want:?} in: {out}"); - Parser::parse_sql(&PostgreSqlDialect {}, &out).unwrap_or_else(|e| { - panic!("{sqlite_sql}\n reverse output is not valid PostgreSQL: {e}\n{out}") - }); + helpers::assert_reverse_emits(rev, sqlite_sql, want); } fn assert_rejected_with(sqlite_sql: &str, want: &str) { diff --git a/tests/test_reverse_reserved_idents.rs b/tests/test_reverse_reserved_idents.rs index 962e79c97..dc15c7791 100644 --- a/tests/test_reverse_reserved_idents.rs +++ b/tests/test_reverse_reserved_idents.rs @@ -11,6 +11,9 @@ //! SQLite answers 'colval' for both spellings; the bare form is the defect. use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; +mod helpers; + +use helpers::reverse_sql_err as reverse_err; fn reverse(pg_ddl: &str, sqlite_sql: &str) -> String { let translator = Pg2Sqlite::default().sql(pg_ddl).unwrap(); @@ -23,13 +26,6 @@ fn reverse(pg_ddl: &str, sqlite_sql: &str) -> String { pg } -fn reverse_err(pg_ddl: &str, sqlite_sql: &str) -> String { - let translator = Pg2Sqlite::default().sql(pg_ddl).unwrap(); - let schema = translator.build_schema().unwrap(); - let options = Pg2SqliteOptions::default(); - translator.reverse_sql(sqlite_sql, &schema, &options).unwrap_err().to_string() -} - // Schema with a column named `user` — the prime pseudo-expression collision. const USER_SCHEMA: &str = r#"CREATE TABLE tu (id INTEGER PRIMARY KEY, "user" TEXT NOT NULL);"#; diff --git a/tests/test_reverse_sqlite_specific.rs b/tests/test_reverse_sqlite_specific.rs index 06a4c4950..6535cfcd3 100644 --- a/tests/test_reverse_sqlite_specific.rs +++ b/tests/test_reverse_sqlite_specific.rs @@ -13,14 +13,9 @@ //! with a misleading message; SELECT/UPDATE/DELETE passed through silently. //! This test covers both the consistent refusal and the improved messages. -use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; +mod helpers; -fn reverse_err(pg_ddl: &str, sqlite_sql: &str) -> String { - let translator = Pg2Sqlite::default().sql(pg_ddl).unwrap(); - let schema = translator.build_schema().unwrap(); - let options = Pg2SqliteOptions::default(); - translator.reverse_sql(sqlite_sql, &schema, &options).unwrap_err().to_string() -} +use helpers::reverse_sql_err as reverse_err; const SCHEMA: &str = "CREATE TABLE t (id INT PRIMARY KEY, v TEXT);"; diff --git a/tests/test_reverse_strftime.rs b/tests/test_reverse_strftime.rs index 991ecfc3b..88fd21812 100644 --- a/tests/test_reverse_strftime.rs +++ b/tests/test_reverse_strftime.rs @@ -16,7 +16,8 @@ use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use sql_traits::structs::ParserDB; -use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; + +mod helpers; const FIXTURE: &str = "CREATE TABLE t (id INT PRIMARY KEY, ts TIMESTAMP);"; @@ -25,12 +26,7 @@ fn schema() -> ParserDB { } fn reverse(sqlite: &str) -> String { - let statements = Pg2Sqlite::default() - .reverse_sql(sqlite, &schema(), &Pg2SqliteOptions::default()) - .expect("reverse translation"); - let sql = statements.iter().map(ToString::to_string).collect::>().join("; "); - Parser::parse_sql(&PostgreSqlDialect {}, &sql).expect("output parses as PostgreSQL"); - sql + helpers::reverse_checked(sqlite, &schema()) } fn reverse_err(sqlite: &str) -> String { diff --git a/tests/test_reverse_string_agg.rs b/tests/test_reverse_string_agg.rs index f29708e8d..7da4c7675 100644 --- a/tests/test_reverse_string_agg.rs +++ b/tests/test_reverse_string_agg.rs @@ -16,7 +16,8 @@ use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use sql_traits::structs::ParserDB; -use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; + +mod helpers; const FIXTURE: &str = "CREATE TABLE tags (id INT PRIMARY KEY, name TEXT, category TEXT);"; @@ -27,12 +28,7 @@ fn schema() -> ParserDB { /// Reverse-translates `sqlite` and returns the PostgreSQL text, having checked /// it parses as PostgreSQL. fn reverse(sqlite: &str) -> String { - let statements = Pg2Sqlite::default() - .reverse_sql(sqlite, &schema(), &Pg2SqliteOptions::default()) - .expect("reverse translation"); - let sql = statements.iter().map(ToString::to_string).collect::>().join("; "); - Parser::parse_sql(&PostgreSqlDialect {}, &sql).expect("output parses as PostgreSQL"); - sql + helpers::reverse_checked(sqlite, &schema()) } /// Forward-translates `pg` and returns the emitted SQLite text. diff --git a/tests/test_rls_backing_guards.rs b/tests/test_rls_backing_guards.rs index d282db6bb..e0bfeac78 100644 --- a/tests/test_rls_backing_guards.rs +++ b/tests/test_rls_backing_guards.rs @@ -27,7 +27,7 @@ use std::cell::Cell; mod helpers; use diesel::prelude::*; -use helpers::{establish_connection, set_session_username}; +use helpers::{apply_translated_ddl as apply, set_session_username}; use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions, SessionVariableMapping}; diesel::define_sql_function! { @@ -323,21 +323,6 @@ fn apply_with_exemption(pg: &str) -> SqliteConnection { conn } -/// Applies translated DDL to a fresh in-memory connection. The emitted SQL is -/// the artifact under test; every subsequent data-path call uses the typed DSL. -fn apply(pg: &str, opts: &Pg2SqliteOptions) -> SqliteConnection { - let translated = - Pg2Sqlite::default().sql(pg).expect("parse").translate(opts).expect("translate").into_vec(); - let mut conn = establish_connection(); - for statement in &translated { - // DDL emitted by the translator cannot be expressed with the typed DSL. - diesel::sql_query(statement.to_string()) - .execute(&mut conn) - .unwrap_or_else(|e| panic!("emitted DDL failed: {e}\n{statement}")); - } - conn -} - fn branch_schema(policies: &str) -> String { format!( "CREATE TABLE branch_items ( diff --git a/tests/test_rls_expr_transform.rs b/tests/test_rls_expr_transform.rs index 464900d8d..9889d0a5a 100644 --- a/tests/test_rls_expr_transform.rs +++ b/tests/test_rls_expr_transform.rs @@ -4,25 +4,11 @@ //! C2: SetExpr::SetOperation in `transform_query` //! C3: Missing Expr arms in `transform_outer_table_refs` -use diesel::{Connection, RunQueryDsl, SqliteConnection}; -use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; +use diesel::RunQueryDsl; -fn translate(sql: &str) -> Result> { - let options = Pg2SqliteOptions::default().with_rls_audit_table_name("rls_audit"); - let stmts = Pg2Sqlite::default().sql(sql)?.translate(&options)?.into_vec(); - Ok(stmts.iter().map(ToString::to_string).collect::>().join("\n")) -} - -fn apply_all(sql: &str) -> Result> { - let options = Pg2SqliteOptions::default().with_rls_audit_table_name("rls_audit"); - let stmts = Pg2Sqlite::default().sql(sql)?.translate(&options)?.into_vec(); - let mut conn = SqliteConnection::establish(":memory:")?; - for stmt in &stmts { - diesel::sql_query(stmt.to_string()).execute(&mut conn)?; - } - Ok(conn) -} +mod helpers; +use helpers::{apply_rls_sql as apply_all, translate_rls_sql as translate}; #[test] fn rls_policy_case_when_transforms_column_refs() -> Result<(), Box> { let sql = r#" @@ -166,3 +152,50 @@ fn rls_outer_table_ref_in_function_arg() -> Result<(), Box Result<(), Box> { + use diesel::{ExpressionMethods, QueryDsl}; + + diesel::table! { + reports (id) { + id -> Integer, + tenant -> Text, + } + } + + let sql = r#" + CREATE TABLE ordering_keys (rank_key INTEGER, label TEXT); + INSERT INTO ordering_keys VALUES (1, 'a'), (2, 'b'); + CREATE TABLE reports ( + id INTEGER PRIMARY KEY, + tenant TEXT NOT NULL + ); + ALTER TABLE reports ENABLE ROW LEVEL SECURITY; + CREATE POLICY psel ON reports FOR SELECT USING (true); + CREATE POLICY pins ON reports FOR INSERT WITH CHECK ( + (SELECT string_agg(label, ',' ORDER BY + CASE WHEN tenant = 'forward' THEN rank_key ELSE -rank_key END + ) FROM ordering_keys) = 'a,b' + ); + "#; + + let mut conn = apply_all(sql)?; + diesel::insert_into(reports::table) + .values((reports::id.eq(1), reports::tenant.eq("forward"))) + .execute(&mut conn)?; + let denied = diesel::insert_into(reports::table) + .values((reports::id.eq(2), reports::tenant.eq("backward"))) + .execute(&mut conn) + .expect_err("reversed aggregate order must deny the insert"); + assert!(denied.to_string().contains("row-level security policy"), "{denied}"); + diesel::insert_into(reports::table) + .values((reports::id.eq(3), reports::tenant.eq("forward"))) + .execute(&mut conn)?; + assert_eq!( + reports::table.select(reports::id).order(reports::id).load::(&mut conn)?, + vec![1, 3] + ); + Ok(()) +} diff --git a/tests/test_rls_field_translation.rs b/tests/test_rls_field_translation.rs index 6b2aa92fa..52e9851d0 100644 --- a/tests/test_rls_field_translation.rs +++ b/tests/test_rls_field_translation.rs @@ -4,25 +4,11 @@ //! I1b: WindowFrame bounds not transformed in RLS //! I1c: Function parameters not transformed in RLS -use diesel::{Connection, RunQueryDsl, SqliteConnection}; -use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; +use diesel::RunQueryDsl; -fn translate(sql: &str) -> Result> { - let options = Pg2SqliteOptions::default().with_rls_audit_table_name("rls_audit"); - let stmts = Pg2Sqlite::default().sql(sql)?.translate(&options)?.into_vec(); - Ok(stmts.iter().map(ToString::to_string).collect::>().join("\n")) -} - -fn apply_all(sql: &str) -> Result> { - let options = Pg2SqliteOptions::default().with_rls_audit_table_name("rls_audit"); - let stmts = Pg2Sqlite::default().sql(sql)?.translate(&options)?.into_vec(); - let mut conn = SqliteConnection::establish(":memory:")?; - for stmt in &stmts { - diesel::sql_query(stmt.to_string()).execute(&mut conn)?; - } - Ok(conn) -} +mod helpers; +use helpers::{apply_rls_sql as apply_all, translate_rls_sql as translate}; #[test] fn rls_window_frame_bound_appears_in_output() -> Result<(), Box> { // Window function with ROWS BETWEEN in RLS subquery — the bound expressions diff --git a/tests/test_rls_insert_trigger.rs b/tests/test_rls_insert_trigger.rs index 530a1f94a..d99411e21 100644 --- a/tests/test_rls_insert_trigger.rs +++ b/tests/test_rls_insert_trigger.rs @@ -164,24 +164,6 @@ const ITEMS_SCHEMA: &str = " FOR EACH ROW EXECUTE FUNCTION items_maintain(); "; -fn apply_items(pg: &str) -> SqliteConnection { - let opts = Pg2SqliteOptions::default().with_rls_audit_table_name("rls_audit"); - let stmts = Pg2Sqlite::default() - .sql(pg) - .expect("parse") - .translate_to_sql(&opts) - .expect("translate") - .into_vec(); - let mut conn = SqliteConnection::establish(":memory:").expect("connect"); - diesel::sql_query("PRAGMA recursive_triggers = ON").execute(&mut conn).expect("pragma"); - for stmt in &stmts { - diesel::sql_query(stmt.as_str()) - .execute(&mut conn) - .unwrap_or_else(|e| panic!("DDL: {e}\n{stmt}")); - } - conn -} - /// Two maintained columns produce an OR-joined exemption WHEN clause with two /// IS DISTINCT FROM conditions (rls.rs lines 2266-2284). The INSERT must /// succeed because the maintenance update passes the WHEN clause. @@ -208,7 +190,7 @@ fn rls_two_column_maintenance_trigger_exemption_fires() { assert!(update_check.contains(" OR "), "conditions must be joined by OR: {update_check}"); // Execute: INSERT through the view must succeed. - let mut conn = apply_items(ITEMS_SCHEMA); + let mut conn = apply(ITEMS_SCHEMA); diesel::insert_into(items::table) .values((items::id.eq(1_i32), items::tag.eq("hello"), items::slug.eq("world"))) .execute(&mut conn) diff --git a/tests/test_rls_on_conflict.rs b/tests/test_rls_on_conflict.rs index 6e35dfe89..703b2fa02 100644 --- a/tests/test_rls_on_conflict.rs +++ b/tests/test_rls_on_conflict.rs @@ -13,7 +13,7 @@ mod helpers; use diesel::prelude::*; -use helpers::{establish_connection, set_session_username}; +use helpers::{apply_translated_ddl as apply, set_session_username}; use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions, SessionVariableMapping}; mod schema { @@ -71,21 +71,6 @@ fn default_opts() -> Pg2SqliteOptions { .with_session_variable(SessionVariableMapping::current_user("current_app_username")) } -/// Applies translated DDL to a fresh in-memory connection. The emitted SQL is -/// the artifact under test, so it runs as generated text; every other -/// statement uses the typed DSL. -fn apply(pg: &str, opts: &Pg2SqliteOptions) -> SqliteConnection { - let translated = - Pg2Sqlite::default().sql(pg).expect("parse").translate(opts).expect("translate").into_vec(); - let mut conn = establish_connection(); - for statement in &translated { - diesel::sql_query(statement.to_string()) - .execute(&mut conn) - .unwrap_or_else(|e| panic!("emitted DDL failed: {e}\n{statement}")); - } - conn -} - /// Translates `pg_dml` in the context of `schema` and executes the result. /// /// The emitted upsert SQL is the artifact under test; diesel's typed DSL diff --git a/tests/test_rls_read_deny_by_default.rs b/tests/test_rls_read_deny_by_default.rs index a07119da0..37b25a7a2 100644 --- a/tests/test_rls_read_deny_by_default.rs +++ b/tests/test_rls_read_deny_by_default.rs @@ -25,6 +25,7 @@ mod helpers; use diesel::prelude::*; +use helpers::apply_with_options as apply; use pg2sqlite::{ prelude::{Pg2Sqlite, Pg2SqliteOptions}, warnings::TranslationWarning, @@ -108,18 +109,6 @@ fn setup_with_options( Ok(conn) } -fn apply( - pg: &str, - options: &Pg2SqliteOptions, -) -> Result> { - let translated = Pg2Sqlite::default().sql(pg)?.translate(options)?.into_vec(); - let mut conn = SqliteConnection::establish(":memory:")?; - for statement in &translated { - diesel::sql_query(statement.to_string()).execute(&mut conn)?; - } - Ok(conn) -} - fn seed(conn: &mut SqliteConnection) -> diesel::QueryResult { diesel::insert_into(docs_rls::table) .values(vec![BackingRow { id: 1, owner_id: 7 }, BackingRow { id: 2, owner_id: 9 }]) diff --git a/tests/test_subsecond_precision.rs b/tests/test_subsecond_precision.rs index f2f5d0a9d..dc17ad1c3 100644 --- a/tests/test_subsecond_precision.rs +++ b/tests/test_subsecond_precision.rs @@ -14,7 +14,9 @@ //! Every expected value below was read off PostgreSQL 17 before the change. use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; -use rusqlite::{Connection, types::FromSql}; +use rusqlite::types::FromSql; + +mod helpers; const TABLE: &str = "CREATE TABLE t (ts TIMESTAMP, n DOUBLE PRECISION);"; @@ -30,11 +32,7 @@ fn translate(pg: &str) -> String { } fn evaluate(pg: &str) -> T { - let probe = translate(pg); - let connection = Connection::open_in_memory().expect("in-memory SQLite"); - connection - .query_row(&probe, [], |row| row.get::<_, T>(0)) - .unwrap_or_else(|error| panic!("emitted probe failed: {error}\n{probe}")) + helpers::evaluate_probe(&translate(pg)) } /// Reverse translates one SQLite statement back to PostgreSQL. diff --git a/tests/test_table_constraint_gaps.rs b/tests/test_table_constraint_gaps.rs index f389963b5..a4cb5dbe6 100644 --- a/tests/test_table_constraint_gaps.rs +++ b/tests/test_table_constraint_gaps.rs @@ -3,18 +3,10 @@ //! Verifies that table-level FOREIGN KEY constraints translate //! characteristics consistently with column-level FK (column_option.rs). -use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; +use pg2sqlite::prelude::Pg2SqliteOptions; mod helpers; -fn translate(sql: &str) -> Result { - Pg2Sqlite::default() - .sql(sql) - .map_err(|e| e.to_string())? - .translate(&Pg2SqliteOptions::default()) - .map(pg2sqlite::script::Script::into_vec) - .map(|stmts| stmts.iter().map(ToString::to_string).collect::>().join("\n")) - .map_err(|e| e.to_string()) -} +use helpers::translate_default_joined as translate; #[test] fn fk_deferrable_initially_deferred_translates_consistently() { diff --git a/tests/test_to_char.rs b/tests/test_to_char.rs index f7b268cce..55f4a252a 100644 --- a/tests/test_to_char.rs +++ b/tests/test_to_char.rs @@ -2,15 +2,12 @@ use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; -fn translate(sql: &str) -> Result { - Pg2Sqlite::default() - .sql(sql) - .map_err(|e| e.to_string())? - .translate(&Pg2SqliteOptions::default()) - .map(pg2sqlite::script::Script::into_vec) - .map(|stmts| stmts.iter().map(ToString::to_string).collect::>().join("\n")) - .map_err(|e| e.to_string()) -} +mod helpers; + +use helpers::{ + assert_translated_statements_run as assert_all_stmts_parse_as_sqlite, + translate_default_joined as translate, +}; const TABLE: &str = "CREATE TABLE t (id INT PRIMARY KEY, ts TIMESTAMP);"; @@ -196,20 +193,6 @@ fn to_char_tz_code_causes_error() { assert!(!err.is_empty(), "Should have a non-empty error message: {err}"); } -fn assert_all_stmts_parse_as_sqlite(pg_sql: &str) { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - for stmt in pg2sqlite::prelude::Pg2Sqlite::default() - .sql(pg_sql) - .expect("parse") - .translate(&pg2sqlite::prelude::Pg2SqliteOptions::default()) - .expect("translate") - .into_vec() - { - conn.execute_batch(&format!("{stmt};")) - .unwrap_or_else(|e| panic!("translated statement must run in SQLite: {e}\n{stmt}")); - } -} - /// PostgreSQL's ISO codes map exactly onto strftime specifiers: IYYY is %G, /// IW is %V, ID is %u. Measured on PostgreSQL 18 and SQLite 3.51 at the year /// boundary: `to_char(date '2024-12-30', 'IYYY-IW-ID')` and diff --git a/tests/test_to_char_template.rs b/tests/test_to_char_template.rs index 908d2ac7f..e20a913c8 100644 --- a/tests/test_to_char_template.rs +++ b/tests/test_to_char_template.rs @@ -16,16 +16,13 @@ use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use rusqlite::Connection; +mod helpers; + const TABLE: &str = "CREATE TABLE t (id INT PRIMARY KEY, ts TIMESTAMP);"; const INSTANT: &str = "2026-08-08 15:04:05"; fn translate(pg: &str) -> Result, String> { - Pg2Sqlite::default() - .sql(&format!("{TABLE}\n{pg}")) - .map_err(|e| e.to_string())? - .translate_to_sql(&Pg2SqliteOptions::default()) - .map(pg2sqlite::script::Script::into_vec) - .map_err(|e| e.to_string()) + helpers::translate_prefixed(TABLE, pg) } fn translate_err(pg: &str) -> String { diff --git a/tests/test_trigger_maintenance_update.rs b/tests/test_trigger_maintenance_update.rs index ab043a1a1..d2857baf7 100644 --- a/tests/test_trigger_maintenance_update.rs +++ b/tests/test_trigger_maintenance_update.rs @@ -75,6 +75,8 @@ fn read_tag(conn: &mut SqliteConnection) -> Option { } /// PostgreSQL ground truth: UPDATE SET tag='y' → tag='y!' (trigger fires). +/// The WHEN clause must also prevent re-firing under recursive triggers, +/// which would produce 'y!!' instead of 'y!'. #[test] fn maintenance_trigger_fires_on_update_to_maintenance_column() { let mut conn = apply_schema(BUMP_TAG_SCHEMA); @@ -97,18 +99,6 @@ fn maintenance_trigger_chains_on_second_update() { assert_eq!(read_tag(&mut conn).as_deref(), Some("z!")); } -/// WHEN clause must prevent the trigger from re-firing on its own UPDATE. -/// Recursion would produce 'y!!' instead of 'y!'. -#[test] -fn maintenance_trigger_does_not_recurse_with_recursive_triggers_enabled() { - let mut conn = apply_schema(BUMP_TAG_SCHEMA); - diesel::sql_query("INSERT INTO mut (id, tag) VALUES (1, 'x')") - .execute(&mut conn) - .expect("insert"); - diesel::sql_query("UPDATE mut SET tag = 'y' WHERE id = 1").execute(&mut conn).expect("update"); - assert_eq!(read_tag(&mut conn).as_deref(), Some("y!")); -} - #[test] fn single_trigger_per_event_still_translates() { Pg2Sqlite::default() diff --git a/tests/test_trunc_scale_range.rs b/tests/test_trunc_scale_range.rs index 85a12f620..c0299cd23 100644 --- a/tests/test_trunc_scale_range.rs +++ b/tests/test_trunc_scale_range.rs @@ -17,15 +17,12 @@ use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use rusqlite::Connection; +mod helpers; + const FIXTURE: &str = "CREATE TABLE t (id INT PRIMARY KEY);"; fn translate(dml: &str) -> Result, String> { - Pg2Sqlite::default() - .sql(&format!("{FIXTURE}\n{dml}")) - .map_err(|e| e.to_string())? - .translate_to_sql(&Pg2SqliteOptions::default()) - .map(pg2sqlite::script::Script::into_vec) - .map_err(|e| e.to_string()) + helpers::translate_prefixed(FIXTURE, dml) } /// Runs the emitted script and returns the last statement's first column. diff --git a/tests/test_truncate.rs b/tests/test_truncate.rs index c8f139fb5..9699be356 100644 --- a/tests/test_truncate.rs +++ b/tests/test_truncate.rs @@ -68,23 +68,7 @@ fn apply(pg: &str) -> Result> { apply_with(pg, &Pg2SqliteOptions::default()) } -/// Translates `pg` and applies every emitted statement to a fresh database. -/// -/// The generated SQL is itself the artifact under test, so it is applied as -/// text rather than through the query DSL. That is the documented exception: no -/// typed query can stand in for "execute exactly what the translator emitted". -/// Every assertion about the resulting data uses the DSL. -fn apply_with( - pg: &str, - options: &Pg2SqliteOptions, -) -> Result> { - let statements = Pg2Sqlite::default().sql(pg)?.translate(options)?.into_vec(); - let mut conn = SqliteConnection::establish(":memory:")?; - for statement in &statements { - sql_query(statement.to_string()).execute(&mut conn)?; - } - Ok(conn) -} +use helpers::apply_with_options as apply_with; /// Seeds both tables through the typed DSL. fn seed(conn: &mut SqliteConnection) -> Result<(), Box> { diff --git a/tests/test_vector_edge_cases.rs b/tests/test_vector_edge_cases.rs index 97363546d..e38e69faa 100644 --- a/tests/test_vector_edge_cases.rs +++ b/tests/test_vector_edge_cases.rs @@ -8,21 +8,10 @@ mod helpers; #[path = "helpers/translate.rs"] mod translate_helpers; +use helpers::translate_default_or_err as translate_err; use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; use translate_helpers::translate_default as translate; -fn translate_err(sql: &str) -> String { - let result = Pg2Sqlite::default() - .sql(sql) - .unwrap() - .translate(&Pg2SqliteOptions::default()) - .map(pg2sqlite::script::Script::into_vec); - match result { - Err(e) => e.to_string(), - Ok(stmts) => stmts.iter().map(ToString::to_string).collect::>().join("\n"), - } -} - #[test] fn vector_with_table_level_pk() { let sql = " diff --git a/tests/test_vector_native_compatibility.rs b/tests/test_vector_native_compatibility.rs index 411707b12..34f3af503 100644 --- a/tests/test_vector_native_compatibility.rs +++ b/tests/test_vector_native_compatibility.rs @@ -30,6 +30,29 @@ struct Distance { distance: f64, } +/// Executes every emitted statement, loading rows of `T` from any query +/// statement. +fn run_emitted( + statements: &pg2sqlite::script::Script, + connection: &mut diesel::sqlite::SqliteConnection, +) -> Vec +where + diesel::query_builder::SqlQuery: + diesel::query_dsl::LoadQuery<'static, diesel::sqlite::SqliteConnection, T>, +{ + let mut rows = Vec::new(); + for statement in statements.iter() { + // Emitted statements are dynamic SQL. + let query = diesel::sql_query(statement.to_string()); + if matches!(statement, sqlparser::ast::Statement::Query(_)) { + rows = query.load::(connection).unwrap(); + } else { + query.execute(connection).unwrap(); + } + } + rows +} + fn half_schema() -> ParserDB { ParserDB::from_statements( Parser::parse_sql( @@ -228,16 +251,7 @@ fn an_explicit_caller_codec_preserves_genuine_binary16_bytes() { .collect::>() }) .unwrap(); - let mut rows = Vec::new(); - for statement in statements.iter() { - // Emitted statements are dynamic SQL. - let query = diesel::sql_query(statement.to_string()); - if matches!(statement, sqlparser::ast::Statement::Query(_)) { - rows = query.load::(&mut connection).unwrap(); - } else { - query.execute(&mut connection).unwrap(); - } - } + let rows = run_emitted::(&statements, &mut connection); assert_eq!(rows, vec![Encoded { encoded: vec![0x66, 0x2e, 0x66, 0x32, 0xcd, 0x34] }]); } @@ -258,16 +272,7 @@ fn float32_distance_executes_with_an_opaque_function_relation() { .translate(&options) .unwrap(); let mut connection = diesel::sqlite::SqliteConnection::establish(":memory:").unwrap(); - let mut rows = Vec::new(); - for statement in statements.iter() { - // Emitted statements are dynamic SQL. - let query = diesel::sql_query(statement.to_string()); - if matches!(statement, sqlparser::ast::Statement::Query(_)) { - rows = query.load::(&mut connection).unwrap(); - } else { - query.execute(&mut connection).unwrap(); - } - } + let rows = run_emitted::(&statements, &mut connection); assert_eq!( rows, vec![ diff --git a/tests/test_vector_semantic.rs b/tests/test_vector_semantic.rs index b465e2b6a..c186ea29b 100644 --- a/tests/test_vector_semantic.rs +++ b/tests/test_vector_semantic.rs @@ -32,6 +32,31 @@ fn random_vector(dim: usize) -> Vec { (0..dim).map(|_| rng.random::()).collect() } +/// Translates `sql` with default options, applies every emitted statement to +/// a fresh in-memory database, and returns it. +fn translated_database(sql: &str) -> Result { + let options = Pg2SqliteOptions::default(); + let translated = Pg2Sqlite::default().sql(sql).unwrap().translate(&options).unwrap().into_vec(); + let db = Connection::open_in_memory()?; + for stmt in &translated { + db.execute_batch(&stmt.to_string())?; + } + Ok(db) +} + +/// Inserts `count` random vectors into `items` in one transaction. +fn insert_random_vectors(db: &Connection, count: usize, dim: usize) -> Result<()> { + debug_assert!(i32::try_from(count).is_ok(), "row ids exceed i32 range"); + let tx = db.unchecked_transaction()?; + let mut insert_stmt = db.prepare("INSERT INTO items (id, embedding) VALUES (?, ?)")?; + for i in 0..count { + let v = random_vector(dim); + insert_stmt.execute(rusqlite::params![i as i32, v.as_bytes()])?; + } + tx.commit()?; + Ok(()) +} + #[test] fn test_sqlite_vec_loaded() -> Result<()> { helpers::register_sqlite_vec_once(); @@ -58,16 +83,7 @@ fn test_translated_vector_table_works() -> Result<()> { ); "; - let options = Pg2SqliteOptions::default(); - let translated = Pg2Sqlite::default().sql(sql).unwrap().translate(&options).unwrap().into_vec(); - - let db = Connection::open_in_memory()?; - - // Execute all translated statements - for stmt in &translated { - let sql_str = stmt.to_string(); - db.execute_batch(&sql_str)?; - } + let db = translated_database(sql)?; // Insert some data - the triggers should sync to vec0 let v1: Vec = vec![1.0, 0.0, 0.0, 0.0]; @@ -102,14 +118,7 @@ fn test_vec0_knn_query_correctness() -> Result<()> { ); "; - let options = Pg2SqliteOptions::default(); - let translated = Pg2Sqlite::default().sql(sql).unwrap().translate(&options).unwrap().into_vec(); - - let db = Connection::open_in_memory()?; - - for stmt in &translated { - db.execute_batch(&stmt.to_string())?; - } + let db = translated_database(sql)?; // Insert vectors at known positions let v1: Vec = vec![1.0, 0.0, 0.0, 0.0]; // Unit vector along x @@ -168,29 +177,13 @@ fn test_vec0_indexed_knn_large_dataset() -> Result<()> { ); "; - let options = Pg2SqliteOptions::default(); - let translated = Pg2Sqlite::default().sql(sql).unwrap().translate(&options).unwrap().into_vec(); - - let db = Connection::open_in_memory()?; - - for stmt in &translated { - db.execute_batch(&stmt.to_string())?; - } + let db = translated_database(sql)?; // Insert many random vectors println!("Inserting {NUM_VECTORS} vectors of dimension {DIMENSIONS}..."); let insert_start = Instant::now(); - { - let tx = db.unchecked_transaction()?; - let mut insert_stmt = db.prepare("INSERT INTO items (id, embedding) VALUES (?, ?)")?; - - for i in 0..NUM_VECTORS { - let v = random_vector(DIMENSIONS); - insert_stmt.execute(rusqlite::params![i as i32, v.as_bytes()])?; - } - tx.commit()?; - } + insert_random_vectors(&db, NUM_VECTORS, DIMENSIONS)?; println!("Insert took {:?}", insert_start.elapsed()); @@ -295,29 +288,13 @@ fn test_vec0_faster_than_naive_at_scale() -> Result<()> { ); "; - let options = Pg2SqliteOptions::default(); - let translated = Pg2Sqlite::default().sql(sql).unwrap().translate(&options).unwrap().into_vec(); - - let db = Connection::open_in_memory()?; - - for stmt in &translated { - db.execute_batch(&stmt.to_string())?; - } + let db = translated_database(sql)?; // Insert many random vectors println!("Inserting {NUM_VECTORS} vectors of dimension {DIMENSIONS}..."); let insert_start = Instant::now(); - { - let tx = db.unchecked_transaction()?; - let mut insert_stmt = db.prepare("INSERT INTO items (id, embedding) VALUES (?, ?)")?; - - for i in 0..NUM_VECTORS { - let v = random_vector(DIMENSIONS); - insert_stmt.execute(rusqlite::params![i as i32, v.as_bytes()])?; - } - tx.commit()?; - } + insert_random_vectors(&db, NUM_VECTORS, DIMENSIONS)?; println!("Insert took {:?}", insert_start.elapsed()); @@ -397,14 +374,7 @@ fn test_update_trigger_syncs_to_vec0() -> Result<()> { ); "; - let options = Pg2SqliteOptions::default(); - let translated = Pg2Sqlite::default().sql(sql).unwrap().translate(&options).unwrap().into_vec(); - - let db = Connection::open_in_memory()?; - - for stmt in &translated { - db.execute_batch(&stmt.to_string())?; - } + let db = translated_database(sql)?; // Insert initial vector let v1: Vec = vec![1.0, 0.0, 0.0, 0.0]; @@ -443,13 +413,7 @@ fn an_update_to_null_removes_the_row_from_the_index() -> Result<()> { ); "; - let options = Pg2SqliteOptions::default(); - let translated = Pg2Sqlite::default().sql(sql).unwrap().translate(&options).unwrap().into_vec(); - - let db = Connection::open_in_memory()?; - for stmt in &translated { - db.execute_batch(&stmt.to_string())?; - } + let db = translated_database(sql)?; let v1: Vec = vec![1.0, 0.0, 0.0, 0.0]; db.execute("INSERT INTO items (id, embedding) VALUES (1, ?)", [v1.as_bytes()])?; @@ -481,13 +445,7 @@ fn a_null_row_updated_to_a_vector_joins_the_index() -> Result<()> { ); "; - let options = Pg2SqliteOptions::default(); - let translated = Pg2Sqlite::default().sql(sql).unwrap().translate(&options).unwrap().into_vec(); - - let db = Connection::open_in_memory()?; - for stmt in &translated { - db.execute_batch(&stmt.to_string())?; - } + let db = translated_database(sql)?; db.execute("INSERT INTO items (id, embedding) VALUES (1, NULL)", [])?; let count: i32 = @@ -521,13 +479,7 @@ fn a_pk_update_moves_the_indexed_row() -> Result<()> { ); "; - let options = Pg2SqliteOptions::default(); - let translated = Pg2Sqlite::default().sql(sql).unwrap().translate(&options).unwrap().into_vec(); - - let db = Connection::open_in_memory()?; - for stmt in &translated { - db.execute_batch(&stmt.to_string())?; - } + let db = translated_database(sql)?; let v1: Vec = vec![1.0, 0.0, 0.0, 0.0]; db.execute("INSERT INTO items (id, embedding) VALUES (1, ?)", [v1.as_bytes()])?; @@ -551,14 +503,7 @@ fn test_delete_trigger_removes_from_vec0() -> Result<()> { ); "; - let options = Pg2SqliteOptions::default(); - let translated = Pg2Sqlite::default().sql(sql).unwrap().translate(&options).unwrap().into_vec(); - - let db = Connection::open_in_memory()?; - - for stmt in &translated { - db.execute_batch(&stmt.to_string())?; - } + let db = translated_database(sql)?; // Insert vectors let v1: Vec = vec![1.0, 0.0, 0.0, 0.0]; diff --git a/tests/test_window_spec_expr_recursion.rs b/tests/test_window_spec_expr_recursion.rs index c6c51451d..c2d5cf202 100644 --- a/tests/test_window_spec_expr_recursion.rs +++ b/tests/test_window_spec_expr_recursion.rs @@ -6,23 +6,12 @@ //! - CTE (WITH clause) body expressions use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; -use sqlparser::ast::Statement; +#[path = "helpers/translate.rs"] +mod translate_helpers; +use translate_helpers::translate_default_first_query as translate_ok; const SCHEMA: &str = "CREATE TABLE events (id INT PRIMARY KEY, val FLOAT, created_at TIMESTAMP);"; -fn translate_ok(sql: &str) -> String { - Pg2Sqlite::default() - .sql(sql) - .unwrap() - .translate(&Pg2SqliteOptions::default()) - .unwrap() - .into_vec() - .into_iter() - .find(|s| matches!(s, Statement::Query(_))) - .unwrap() - .to_string() -} - // Inline OVER (PARTITION BY ) — partition_by expressions must be // translated, not left as PG syntax.