From 783a937513613f3b8248afdea5b5c23826a6007e Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 23 Oct 2026 22:22:38 +0200 Subject: [PATCH] Quote identifiers that SQLite reserves PostgreSQL lets a source name a table, column or alias with words such as `set`, `values` or a bare `not`, and the forward output kept them bare, which SQLite rejects as a syntax error. `CREATE TABLE t (id int, set int, values int)` already failed in its own DDL. The finished script now double-quotes every unquoted identifier among the keywords SQLite 3.51.1 refuses as a bare name, lowercased the way PostgreSQL folds it. Function names stay bare because the translator emits `RAISE(IGNORE)`, and `DEFAULT` stays bare because SQLite would read it as a string once quoted. The pass runs once over the whole script, so replayed and rebound forms are covered too. --- src/impls.rs | 1 + src/impls/sqlite_keywords.rs | 131 +++++++++++++++++++++++ src/initialization.rs | 10 ++ src/pg2sqlite.rs | 5 + tests/test_sqlite_keyword_identifiers.rs | 80 ++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 src/impls/sqlite_keywords.rs create mode 100644 tests/test_sqlite_keyword_identifiers.rs diff --git a/src/impls.rs b/src/impls.rs index 8a8068463..f5ccc5e63 100644 --- a/src/impls.rs +++ b/src/impls.rs @@ -31,6 +31,7 @@ pub(crate) mod scoped_relations; pub(crate) mod session_variable; pub(crate) mod shared_helpers; pub(crate) mod sqlite_functions; +pub(crate) mod sqlite_keywords; pub(crate) mod temporal_arithmetic; pub(crate) mod temporal_literals; pub(crate) mod timezone; diff --git a/src/impls/sqlite_keywords.rs b/src/impls/sqlite_keywords.rs new file mode 100644 index 000000000..c4cbff724 --- /dev/null +++ b/src/impls/sqlite_keywords.rs @@ -0,0 +1,131 @@ +//! Quoting for identifiers that SQLite reserves. + +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +use core::{convert::Infallible, ops::ControlFlow}; + +use sqlparser::ast::{Expr, Ident, Statement, VisitMut, VisitorMut}; + +/// Keywords SQLite 3.51 rejects as a bare table, column or alias name, sorted +/// for binary search, without the `DEFAULT` the translator emits as a keyword. +const RESERVED: &[&str] = &[ + "ADD", + "ALL", + "ALTER", + "AND", + "AS", + "AUTOINCREMENT", + "BETWEEN", + "CASE", + "CAST", + "CHECK", + "COLLATE", + "COMMIT", + "CONSTRAINT", + "CREATE", + "DEFERRABLE", + "DELETE", + "DISTINCT", + "DROP", + "ELSE", + "ESCAPE", + "EXCEPT", + "EXISTS", + "FOREIGN", + "FROM", + "GROUP", + "HAVING", + "IN", + "INDEX", + "INSERT", + "INTERSECT", + "INTO", + "IS", + "ISNULL", + "JOIN", + "LIMIT", + "NOT", + "NOTHING", + "NOTNULL", + "NULL", + "ON", + "OR", + "ORDER", + "PRIMARY", + "RAISE", + "RECURSIVE", + "REFERENCES", + "RETURNING", + "SELECT", + "SET", + "TABLE", + "THEN", + "TO", + "TRANSACTION", + "UNION", + "UNIQUE", + "UPDATE", + "USING", + "VALUES", + "WHEN", + "WHERE", +]; + +fn is_reserved(word: &str) -> bool { + RESERVED + .binary_search_by(|reserved| { + reserved.bytes().cmp(word.bytes().map(|byte| byte.to_ascii_uppercase())) + }) + .is_ok() +} + +/// Quotes every bare identifier SQLite reserves, except function names, which +/// SQLite reads from the call syntax and which include the bare `RAISE` the +/// translator emits in trigger bodies. +#[derive(Default)] +struct ReservedIdentifierQuoter { + function_names: Vec<*const Ident>, +} + +impl VisitorMut for ReservedIdentifierQuoter { + type Break = Infallible; + + fn pre_visit_expr(&mut self, expr: &mut Expr) -> ControlFlow { + if let Expr::Function(function) = expr { + self.function_names.extend( + function.name.0.iter().filter_map(|part| part.as_ident()).map(core::ptr::from_ref), + ); + } + ControlFlow::Continue(()) + } + + fn post_visit_ident(&mut self, ident: &mut Ident) -> ControlFlow { + if ident.quote_style.is_none() + && is_reserved(&ident.value) + && !self.function_names.contains(&core::ptr::from_ref(ident)) + { + // PostgreSQL folds a bare identifier to lowercase, so the quoted + // spelling is the folded one. + ident.value.make_ascii_lowercase(); + ident.quote_style = Some('"'); + } + ControlFlow::Continue(()) + } +} + +/// Double-quotes every bare identifier in `statement` that SQLite reserves. +pub(crate) fn quote_reserved_identifiers(statement: &mut Statement) { + let _ = statement.visit(&mut ReservedIdentifierQuoter::default()); +} + +#[cfg(test)] +mod tests { + use super::{RESERVED, is_reserved}; + + #[test] + fn reserved_words_are_sorted_and_matched_without_case() { + assert!(RESERVED.is_sorted()); + assert!(is_reserved("set") && is_reserved("Values") && is_reserved("NOT")); + assert!(!is_reserved("default") && !is_reserved("key") && !is_reserved("replace")); + } +} diff --git a/src/initialization.rs b/src/initialization.rs index 6789bb6c7..dba40fe89 100644 --- a/src/initialization.rs +++ b/src/initialization.rs @@ -149,6 +149,16 @@ impl InitializationPlan { self.steps.into_iter().flat_map(|step| step.base) } + /// Every statement of every form, rebound ones included. + pub(crate) fn statements_mut(&mut self) -> impl Iterator { + self.steps.iter_mut().flat_map(|step| { + step.base + .iter_mut() + .chain(step.main_rebound.iter_mut().flatten()) + .chain(step.attached_rebound.iter_mut().flatten()) + }) + } + pub(crate) fn map(self, mut f: impl FnMut(S) -> T) -> InitializationPlan { let mut forms = |statements: Vec| statements.into_iter().map(&mut f).collect(); let steps = self diff --git a/src/pg2sqlite.rs b/src/pg2sqlite.rs index 85bc58098..177426e64 100644 --- a/src/pg2sqlite.rs +++ b/src/pg2sqlite.rs @@ -816,6 +816,11 @@ impl Pg2Sqlite { attached, ); let mut script = Script { setup: Vec::new(), files, connection }; + // Last, so the replay and rebinding forms built above are covered too. + for statement in script.files.statements_mut().chain(script.connection.iter_mut().flatten()) + { + crate::impls::sqlite_keywords::quote_reserved_identifiers(statement); + } // PostgreSQL's LIKE is case-sensitive. SQLite's is case-insensitive for // ASCII unless the connection says otherwise, and no expression-level diff --git a/tests/test_sqlite_keyword_identifiers.rs b/tests/test_sqlite_keyword_identifiers.rs new file mode 100644 index 000000000..7a67c96de --- /dev/null +++ b/tests/test_sqlite_keyword_identifiers.rs @@ -0,0 +1,80 @@ +//! PostgreSQL lets a source name a table, column or alias with a word SQLite +//! reserves, such as `set`, `values` or `not`, so the emitted SQLite quotes it. +//! +//! Every expectation was read off PostgreSQL 18.6. The emitted statements are +//! executed as text because that text is the artifact under test. + +use diesel::{prelude::*, sql_types::Integer}; +use pg2sqlite::prelude::{Pg2Sqlite, Pg2SqliteOptions}; + +const FIXTURE: &str = "CREATE TABLE t (id int PRIMARY KEY, set int, values int); + INSERT INTO t (id, set, values) VALUES (1, 2, 3); + UPDATE t SET set = set + 10 WHERE values = 3;"; + +/// The rows the last emitted statement returns after every earlier one ran. +fn rows + 'static>(pg: &str) -> Vec { + let statements = Pg2Sqlite::default() + .sql(pg) + .expect("parse") + .translate_to_sql(&Pg2SqliteOptions::default()) + .expect("translate") + .into_vec(); + let mut connection = SqliteConnection::establish(":memory:").expect("connect"); + let (probe, setup) = statements.split_last().expect("an emitted query"); + for statement in setup { + diesel::sql_query(statement) + .execute(&mut connection) + .unwrap_or_else(|error| panic!("emitted setup failed: {statement}: {error}")); + } + diesel::sql_query(probe) + .load::(&mut connection) + .unwrap_or_else(|error| panic!("emitted query failed: {probe}: {error}")) +} + +#[derive(QueryableByName, Debug, PartialEq)] +struct Columns { + #[diesel(sql_type = Integer)] + id: i32, + #[diesel(sql_type = Integer, column_name = "set")] + set_: i32, + #[diesel(sql_type = Integer, column_name = "values")] + values_: i32, +} + +/// Columns named by SQLite keywords work in `INSERT`, `UPDATE`, `WHERE` and the +/// projection. +#[test] +fn keyword_named_columns_work_through_dml() { + let read = rows::(&format!("{FIXTURE}\nSELECT id, set, values FROM t ORDER BY id;")); + assert_eq!(read, [Columns { id: 1, set_: 12, values_: 3 }]); +} + +#[derive(QueryableByName, Debug, PartialEq)] +struct Aliased { + #[diesel(sql_type = Integer, column_name = "not")] + not_: i32, + #[diesel(sql_type = Integer, column_name = "set")] + set_: i32, +} + +/// A bare keyword alias and a qualified keyword column keep the lowercase names +/// PostgreSQL gives them. +#[test] +fn keyword_aliases_keep_postgres_column_names() { + let read = rows::(&format!("{FIXTURE}\nSELECT id NOT, s.SET FROM t AS s;")); + assert_eq!(read, [Aliased { not_: 1, set_: 12 }]); +} + +#[derive(QueryableByName, Debug, PartialEq)] +struct Single { + #[diesel(sql_type = Integer)] + x: i32, +} + +/// A table named by a SQLite keyword is created, written and read. +#[test] +fn keyword_named_table_is_created_and_read() { + let read = + rows::("CREATE TABLE set (x int); INSERT INTO set VALUES (7); SELECT x FROM set;"); + assert_eq!(read, [Single { x: 7 }]); +}