diff --git a/crates/litewire-mysql/src/handler.rs b/crates/litewire-mysql/src/handler.rs index 41c9e78..f954bd1 100644 --- a/crates/litewire-mysql/src/handler.rs +++ b/crates/litewire-mysql/src/handler.rs @@ -258,6 +258,16 @@ fn param_to_value(param: ParamValue<'_>) -> Value { impl AsyncMysqlShim for LiteWireHandler { type Error = std::io::Error; + /// Server version advertised in the wire handshake. + /// + /// The opensrv default is `5.1.10-alpha-msql-proxy`, which WordPress + /// >= 6.5 rejects outright ("requires MySQL 5.5.5 or higher") — clients + /// read this from `mysqli_get_server_info()`, not `SELECT VERSION()`. + /// Advertise a modern 8.0.x version, suffixed so it is identifiable. + fn version(&self) -> String { + "8.0.36-litewire".to_string() + } + async fn on_prepare<'a>( &'a mut self, query: &'a str, diff --git a/crates/litewire-translate/src/common.rs b/crates/litewire-translate/src/common.rs index 88d2c77..e0b39a6 100644 --- a/crates/litewire-translate/src/common.rs +++ b/crates/litewire-translate/src/common.rs @@ -260,6 +260,16 @@ fn rewrite_function(func: &mut Function) { "8.0.0-litewire".into(), ))]); } + // FOUND_ROWS(): the paired SQL_CALC_FOUND_ROWS hint is stripped + // before parsing (SQLite cannot emulate it without per-session + // state), so return 0. Known semantic gap: WP-style pagination + // totals read 0; result sets themselves are unaffected. + "FOUND_ROWS" => { + // abs(0): single-argument scalar that SQLite accepts (coalesce + // and max need >= 2 args). + func.name = func_name("abs"); + func.args = func_args(vec![value_expr(Value::Number("0".into(), false))]); + } "USER" | "CURRENT_USER" | "SESSION_USER" | "SYSTEM_USER" => { func.name = func_name("coalesce"); func.args = func_args(vec![value_expr(Value::SingleQuotedString( diff --git a/crates/litewire-translate/src/lib.rs b/crates/litewire-translate/src/lib.rs index 9e4cd44..839afe4 100644 --- a/crates/litewire-translate/src/lib.rs +++ b/crates/litewire-translate/src/lib.rs @@ -79,19 +79,176 @@ pub fn translate(sql: &str, dialect: Dialect) -> Result, Tr Dialect::TDS => Box::new(MsSqlDialect {}), }; + // MySQL DDL pre-pass: sqlparser (0.57) cannot parse index column prefix + // lengths (`KEY meta_key (meta_key(191))`), which every WordPress schema + // uses. Display widths / prefix lengths are meaningless to SQLite, so + // strip bare `()` groups from DDL text before parsing. Applied + // to CREATE/ALTER TABLE and CREATE INDEX only -- DML literals are never + // touched. (`decimal(10,2)` is unaffected: it contains a comma.) + let owned_sql; + let sql = if dialect == Dialect::MySQL && is_mysql_ddl(sql) { + owned_sql = strip_numeric_paren_groups(sql); + owned_sql.as_str() + } else { + sql + }; + + // MySQL SELECT hints (`SQL_CALC_FOUND_ROWS`, `SQL_NO_CACHE`, ...) are not + // parseable by sqlparser and meaningless to SQLite. WordPress's main + // post/comment queries lead with SQL_CALC_FOUND_ROWS. Quote-aware strip. + let owned_hintless; + let sql = if dialect == Dialect::MySQL && has_mysql_select_hint(sql) { + owned_hintless = strip_mysql_select_hints(sql); + owned_hintless.as_str() + } else { + sql + }; + let statements = Parser::parse_sql(parser_dialect.as_ref(), sql) .map_err(|e| TranslateError::Parse(e.to_string()))?; let mut results = Vec::with_capacity(statements.len()); for stmt in statements { let rewritten = rewrite_statement(stmt, dialect)?; - let sqlite_sql = emit::emit_statement(&rewritten); - results.push(TranslateResult::Sql(sqlite_sql)); + if dialect == Dialect::MySQL { + // `ALTER TABLE ... ADD KEY/UNIQUE` expands to CREATE INDEX + // statements (SQLite has no ALTER ... ADD CONSTRAINT). + for expanded in mysql::expand_alter_table(rewritten) { + results.push(TranslateResult::Sql(emit::emit_statement(&expanded))); + } + } else { + let sqlite_sql = emit::emit_statement(&rewritten); + results.push(TranslateResult::Sql(sqlite_sql)); + } } Ok(results) } +/// Is this statement MySQL DDL that may carry display widths / index +/// prefix lengths (`CREATE TABLE`, `ALTER TABLE`, `CREATE [UNIQUE] INDEX`)? +fn is_mysql_ddl(sql: &str) -> bool { + let upper = sql.trim_start().to_ascii_uppercase(); + upper.starts_with("CREATE TABLE") + || upper.starts_with("CREATE TEMPORARY TABLE") + || upper.starts_with("ALTER TABLE") + || upper.starts_with("CREATE INDEX") + || upper.starts_with("CREATE UNIQUE INDEX") + || upper.starts_with("CREATE FULLTEXT INDEX") +} + +/// Remove bare `()` groups outside string/identifier quotes: +/// `bigint(20)` -> `bigint`, `KEY k (col(191))` -> `KEY k (col)`. +/// Groups containing anything but digits (e.g. `decimal(10,2)`) are kept. +fn strip_numeric_paren_groups(sql: &str) -> String { + let bytes = sql.as_bytes(); + let mut out = String::with_capacity(sql.len()); + let mut i = 0; + let mut quote: Option = None; + while i < bytes.len() { + let c = bytes[i]; + if let Some(q) = quote { + out.push(c as char); + if c == q { + quote = None; + } + i += 1; + continue; + } + match c { + b'\'' | b'"' | b'`' => { + quote = Some(c); + out.push(c as char); + i += 1; + } + b'(' => { + // Look ahead: digits then ')'. + let mut j = i + 1; + while j < bytes.len() && bytes[j].is_ascii_digit() { + j += 1; + } + if j > i + 1 && j < bytes.len() && bytes[j] == b')' { + i = j + 1; // skip the whole (NNN) group + } else { + out.push('('); + i += 1; + } + } + _ => { + out.push(c as char); + i += 1; + } + } + } + out +} + +/// MySQL-only SELECT hint keywords stripped before parsing. +const MYSQL_SELECT_HINTS: [&str; 6] = [ + "SQL_CALC_FOUND_ROWS", + "SQL_NO_CACHE", + "SQL_CACHE", + "SQL_SMALL_RESULT", + "SQL_BIG_RESULT", + "SQL_BUFFER_RESULT", +]; + +/// Cheap pre-check (may false-positive on hints inside string literals — +/// the quote-aware strip below won't touch those). +fn has_mysql_select_hint(sql: &str) -> bool { + let upper = sql.to_ascii_uppercase(); + MYSQL_SELECT_HINTS.iter().any(|h| upper.contains(h)) +} + +/// Remove MySQL SELECT hint keywords outside string/identifier quotes. +fn strip_mysql_select_hints(sql: &str) -> String { + let bytes = sql.as_bytes(); + let mut out = String::with_capacity(sql.len()); + let mut i = 0; + let mut quote: Option = None; + while i < bytes.len() { + let c = bytes[i]; + if let Some(q) = quote { + out.push(c as char); + if c == q { + quote = None; + } + i += 1; + continue; + } + match c { + b'\'' | b'"' | b'`' => { + quote = Some(c); + out.push(c as char); + i += 1; + } + b'A'..=b'Z' | b'a'..=b'z' | b'_' => { + // Read a whole word, compare against the hint list. + let start = i; + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { + i += 1; + } + let word = &sql[start..i]; + let upper_word = word.to_ascii_uppercase(); + if MYSQL_SELECT_HINTS.contains(&upper_word.as_str()) { + // Drop the hint and one following space (if any) so + // `SELECT SQL_NO_CACHE x` becomes `SELECT x`. + if i < bytes.len() && bytes[i] == b' ' { + i += 1; + } + } else { + out.push_str(word); + } + } + _ => { + out.push(c as char); + i += 1; + } + } + } + out +} + /// Translate a SQL string, using a bounded LRU cache in front of the /// parser + rewriter. See [`TranslateCache`]. /// diff --git a/crates/litewire-translate/src/metadata.rs b/crates/litewire-translate/src/metadata.rs index 1a6ba22..3e9f26a 100644 --- a/crates/litewire-translate/src/metadata.rs +++ b/crates/litewire-translate/src/metadata.rs @@ -20,6 +20,10 @@ pub enum MetadataQuery { ShowIndex { table: String }, /// `SELECT @@variable` queries — MySQL system variables. SystemVariables { variables: Vec }, + /// `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE + /// table_name = '')` — table-existence probe (Laravel's + /// `Schema::hasTable`). Must return exactly one scalar column. + TableExists { table: String }, /// `SELECT ... FROM information_schema.tables` — table listing. InformationSchemaTables { schema_filter: Option }, /// `SELECT ... FROM information_schema.columns` — column listing. @@ -48,7 +52,36 @@ impl MetadataQuery { "SELECT 'main' AS Database".into() } Self::ShowColumns { table } => { - format!("PRAGMA table_info('{table}')") + // MySQL-shaped `SHOW FULL COLUMNS` / `DESCRIBE` output. + // + // WordPress's wpdb::get_table_charset() reads `Collation` and + // `Type` from `SHOW FULL COLUMNS`; if they're missing it + // returns a WP_Error and sanitize_option() then blanks the + // value being saved (observed: every option routed through + // strip_invalid_text_for_column stored as ''). Text affinity + // maps to longtext/longblob so WP applies no 64 KB length cap. + format!( + "SELECT name AS \"Field\", \ + CASE \ + WHEN upper(type) LIKE '%INT%' THEN 'bigint' \ + WHEN upper(type) IN ('REAL', 'DOUBLE', 'FLOAT', 'NUMERIC', 'DECIMAL') THEN 'double' \ + WHEN upper(type) LIKE '%BLOB%' THEN 'longblob' \ + WHEN upper(type) LIKE '%CHAR%' OR upper(type) LIKE '%CLOB%' OR upper(type) LIKE '%TEXT%' THEN 'longtext' \ + WHEN type = '' THEN 'longtext' \ + ELSE lower(type) \ + END AS \"Type\", \ + CASE WHEN \"notnull\" = 1 THEN 'NO' ELSE 'YES' END AS \"Null\", \ + CASE WHEN pk > 0 THEN 'PRI' ELSE '' END AS \"Key\", \ + dflt_value AS \"Default\", \ + '' AS \"Extra\", \ + CASE \ + WHEN upper(type) LIKE '%CHAR%' OR upper(type) LIKE '%CLOB%' OR upper(type) LIKE '%TEXT%' OR type = '' THEN 'utf8mb4_unicode_ci' \ + ELSE NULL \ + END AS \"Collation\", \ + 'select,insert,update,references' AS \"Privileges\", \ + '' AS \"Comment\" \ + FROM pragma_table_info('{table}') ORDER BY cid" + ) } Self::ShowCreateTable { table } => { format!( @@ -58,6 +91,11 @@ impl MetadataQuery { Self::ShowIndex { table } => { format!("PRAGMA index_list('{table}')") } + Self::TableExists { table } => { + format!( + "SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name='{table}') AS `exists`" + ) + } Self::InformationSchemaTables { schema_filter } => { // Map INFORMATION_SCHEMA.TABLES to sqlite_master. let base = "SELECT name AS TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE, 'main' AS TABLE_SCHEMA FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"; @@ -175,10 +213,15 @@ pub fn detect_metadata_query(sql: &str, _dialect: Dialect) -> Option / SHOW FIELDS FROM + // SHOW [FULL] COLUMNS FROM
/ SHOW [FULL] FIELDS FROM
+ // (`FULL` adds Collation/Privileges/Comment columns — WordPress's + // wpdb::get_table_charset issues `SHOW FULL COLUMNS` on every table it + // writes user text into.) if let Some(rest) = upper .strip_prefix("SHOW COLUMNS FROM ") .or_else(|| upper.strip_prefix("SHOW FIELDS FROM ")) + .or_else(|| upper.strip_prefix("SHOW FULL COLUMNS FROM ")) + .or_else(|| upper.strip_prefix("SHOW FULL FIELDS FROM ")) { let table = extract_table_name(rest, trimmed); return Some(MetadataQuery::ShowColumns { table }); @@ -214,6 +257,14 @@ pub fn detect_metadata_query(sql: &str, _dialect: Dialect) -> Option {det:?}" + ); + } } #[test] diff --git a/crates/litewire-translate/src/mysql.rs b/crates/litewire-translate/src/mysql.rs index bd9272b..8a86d29 100644 --- a/crates/litewire-translate/src/mysql.rs +++ b/crates/litewire-translate/src/mysql.rs @@ -23,16 +23,156 @@ pub fn rewrite_statement(stmt: &mut Statement) -> Result<(), TranslateError> { Statement::Query(query) => { rewrite_limit_clause(query); } + Statement::Update { assignments, .. } => { + rewrite_update_assignment_targets(assignments); + } _ => {} } Ok(()) } +/// Strip table qualifiers from UPDATE SET targets: MySQL accepts +/// `UPDATE users SET users.updated_at = ...`, SQLite requires a bare +/// column name. (Laravel's Eloquent qualifies the `updated_at` column.) +fn rewrite_update_assignment_targets(assignments: &mut [sqlparser::ast::Assignment]) { + use sqlparser::ast::{AssignmentTarget, ObjectName}; + for a in assignments { + if let AssignmentTarget::ColumnName(name) = &mut a.target { + if name.0.len() > 1 { + if let Some(last) = name.0.pop() { + *name = ObjectName(vec![last]); + } + } + } + } +} + +// ── ALTER TABLE ADD KEY/UNIQUE -> CREATE INDEX ─────────────────────────────── + +/// Expand MySQL `ALTER TABLE ... ADD {KEY|INDEX|UNIQUE} name (cols)` into +/// standalone `CREATE [UNIQUE] INDEX` statements (SQLite has no +/// `ALTER TABLE ... ADD CONSTRAINT`). Laravel's schema builder emits one +/// such ALTER per `->unique()` / `->index()` after every `create table`. +/// +/// Non-index operations are preserved in a residual `ALTER TABLE`. +#[must_use] +pub fn expand_alter_table(stmt: Statement) -> Vec { + use sqlparser::ast::{ + AlterTableOperation, CreateIndex, IndexColumn, OrderByExpr, OrderByOptions, TableConstraint, + }; + + let Statement::AlterTable { + name, + if_exists, + only, + operations, + location, + on_cluster, + iceberg, + } = stmt + else { + return vec![stmt]; + }; + + let to_index_columns = |cols: &[sqlparser::ast::Ident]| -> Vec { + cols.iter() + .map(|c| IndexColumn { + column: OrderByExpr { + expr: sqlparser::ast::Expr::Identifier(c.clone()), + options: OrderByOptions { + asc: None, + nulls_first: None, + }, + with_fill: None, + }, + operator_class: None, + }) + .collect() + }; + + let mut indexes: Vec = Vec::new(); + let mut residual_ops: Vec = Vec::new(); + + for op in operations { + match &op { + AlterTableOperation::AddConstraint(TableConstraint::Unique { + name: cname, + index_name, + columns, + .. + }) => { + let idx_name = index_name.clone().or_else(|| cname.clone()); + indexes.push(Statement::CreateIndex(CreateIndex { + name: idx_name.map(|i| sqlparser::ast::ObjectName::from(vec![i])), + table_name: name.clone(), + using: None, + columns: to_index_columns(columns), + unique: true, + concurrently: false, + if_not_exists: false, + include: vec![], + nulls_distinct: None, + with: vec![], + predicate: None, + })); + } + AlterTableOperation::AddConstraint(TableConstraint::Index { + name: iname, + columns, + .. + }) => { + indexes.push(Statement::CreateIndex(CreateIndex { + name: iname + .clone() + .map(|i| sqlparser::ast::ObjectName::from(vec![i])), + table_name: name.clone(), + using: None, + columns: to_index_columns(columns), + unique: false, + concurrently: false, + if_not_exists: false, + include: vec![], + nulls_distinct: None, + with: vec![], + predicate: None, + })); + } + // FULLTEXT/SPATIAL: no SQLite analogue — drop (perf-only). + AlterTableOperation::AddConstraint(TableConstraint::FulltextOrSpatial { .. }) => {} + _ => residual_ops.push(op), + } + } + + let mut out = Vec::new(); + if !residual_ops.is_empty() { + out.push(Statement::AlterTable { + name, + if_exists, + only, + operations: residual_ops, + location, + on_cluster, + iceberg, + }); + } + out.extend(indexes); + out +} + // ── ON DUPLICATE KEY UPDATE -> ON CONFLICT DO UPDATE ───────────────────────── /// Rewrite MySQL's `ON DUPLICATE KEY UPDATE` to SQLite's `ON CONFLICT DO UPDATE`. +/// +/// MySQL's `VALUES(col)` in the update list (the would-be-inserted value) +/// becomes SQLite's `excluded.col`. Without this, the emitted SQL keeps the +/// MySQL-only `VALUES(col)` call and SQLite rejects the whole upsert — +/// WordPress `add_option()`/`update_option()` depend on this shape. +/// (SQLite >= 3.35 accepts `DO UPDATE` without a conflict target.) fn rewrite_insert_on_duplicate(insert: &mut sqlparser::ast::Insert) { - if let Some(OnInsert::DuplicateKeyUpdate(assignments)) = insert.on.take() { + if let Some(OnInsert::DuplicateKeyUpdate(mut assignments)) = insert.on.take() { + for assignment in &mut assignments { + rewrite_values_call_to_excluded(&mut assignment.value); + } insert.on = Some(OnInsert::OnConflict(OnConflict { conflict_target: None, action: OnConflictAction::DoUpdate(DoUpdate { @@ -43,6 +183,37 @@ fn rewrite_insert_on_duplicate(insert: &mut sqlparser::ast::Insert) { } } +/// Replace `VALUES(col)` with `excluded.col` in an upsert assignment value. +fn rewrite_values_call_to_excluded(expr: &mut sqlparser::ast::Expr) { + use sqlparser::ast::{Expr, FunctionArg, FunctionArgExpr, FunctionArguments, Ident}; + + if let Expr::Function(func) = expr { + if func.name.to_string().eq_ignore_ascii_case("VALUES") { + if let FunctionArguments::List(args) = &func.args { + if args.args.len() == 1 { + if let FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Identifier(col))) = + &args.args[0] + { + *expr = Expr::CompoundIdentifier(vec![Ident::new("excluded"), col.clone()]); + return; + } + } + } + } + } + // Recurse through common wrappers so `VALUES(a) + 1` style values work. + match expr { + sqlparser::ast::Expr::BinaryOp { left, right, .. } => { + rewrite_values_call_to_excluded(left); + rewrite_values_call_to_excluded(right); + } + sqlparser::ast::Expr::Nested(inner) | sqlparser::ast::Expr::UnaryOp { expr: inner, .. } => { + rewrite_values_call_to_excluded(inner); + } + _ => {} + } +} + // ── LIMIT offset, count -> LIMIT count OFFSET offset ──────────────────────── /// Rewrite MySQL's `LIMIT offset, count` to standard `LIMIT count OFFSET offset`. @@ -63,6 +234,45 @@ fn rewrite_limit_clause(query: &mut sqlparser::ast::Query) { /// Rewrite `CREATE TABLE` for SQLite compatibility. fn rewrite_create_table(create: &mut sqlparser::ast::CreateTable) { + // Drop MySQL table options (`ENGINE=InnoDB`, `DEFAULT CHARACTER SET = + // utf8mb4 COLLATE = 'utf8mb4_unicode_ci'`, ...): SQLite rejects them all. + // Laravel's schema builder emits the CHARACTER SET / COLLATE pair on + // every `create table`. + create.table_options = sqlparser::ast::CreateTableOptions::None; + + // Constraint fixups. The emitter is sqlparser `Display`, so MySQL-only + // constraint syntax must be removed or normalized here: + // - inline `KEY name (cols)` / FULLTEXT / SPATIAL: not valid SQLite — + // dropped (secondary indexes are a performance concern, not a + // correctness one; WordPress dbDelta emits one per table). + // - `UNIQUE KEY name (cols)`: normalize to bare `UNIQUE (cols)` — + // functionally required (MySQL upserts target these). + // - `PRIMARY KEY (col)`: displays fine for SQLite; kept as-is. + create.constraints.retain(|c| { + !matches!( + c, + sqlparser::ast::TableConstraint::Index { .. } + | sqlparser::ast::TableConstraint::FulltextOrSpatial { .. } + ) + }); + for c in &mut create.constraints { + if let sqlparser::ast::TableConstraint::Unique { + name, + index_name, + index_type_display, + index_type, + index_options, + .. + } = c + { + *name = None; + *index_name = None; + *index_type = None; + *index_type_display = sqlparser::ast::KeyOrIndexDisplay::None; + index_options.clear(); + } + } + // Rewrite column types. for col in &mut create.columns { col.data_type = rewrite_data_type(&col.data_type); @@ -357,6 +567,217 @@ mod tests { assert!(upper.contains("TEXT"), "no TEXT found: {sql}"); } + // ── Real-app DDL (WordPress / Laravel shapes) ─────────────────────────── + + #[test] + fn wordpress_index_prefix_length_parses() { + // wp_usermeta: index prefix lengths broke parsing before the DDL + // pre-pass (sqlparser cannot represent them). + let results = translate( + "CREATE TABLE wp_usermeta (\n\ + umeta_id bigint(20) unsigned NOT NULL auto_increment,\n\ + user_id bigint(20) unsigned NOT NULL default '0',\n\ + meta_key varchar(255) default NULL,\n\ + meta_value longtext,\n\ + PRIMARY KEY (umeta_id),\n\ + KEY user_id (user_id),\n\ + KEY meta_key (meta_key(191))\n\ + ) DEFAULT CHARACTER SET utf8", + Dialect::MySQL, + ) + .unwrap(); + let sql = extract_sql(&results[0]); + assert!(!sql.contains("191"), "prefix length survived: {sql}"); + assert!( + !sql.to_ascii_uppercase().contains("CHARACTER SET"), + "table options survived: {sql}" + ); + // Inline `KEY name (col)` constraints are MySQL-only syntax; they + // must not reach SQLite. PRIMARY KEY stays. + let upper = sql.to_ascii_uppercase(); + assert!( + !upper.replace("PRIMARY KEY (", "").contains(" KEY ("), + "inline KEY constraint survived: {sql}" + ); + assert!(upper.contains("PRIMARY KEY"), "primary key lost: {sql}"); + } + + #[test] + fn wordpress_unique_key_normalized() { + // wp_options: `UNIQUE KEY option_name (option_name)` must become a + // bare `UNIQUE (option_name)` — upserts depend on the constraint. + let results = translate( + "CREATE TABLE wp_options (\n\ + option_id bigint(20) unsigned NOT NULL auto_increment,\n\ + option_name varchar(191) NOT NULL default '',\n\ + option_value longtext NOT NULL,\n\ + autoload varchar(20) NOT NULL default 'yes',\n\ + PRIMARY KEY (option_id),\n\ + UNIQUE KEY option_name (option_name),\n\ + KEY autoload (autoload)\n\ + ) DEFAULT CHARACTER SET utf8", + Dialect::MySQL, + ) + .unwrap(); + let sql = extract_sql(&results[0]); + let upper = sql.to_ascii_uppercase(); + assert!(upper.contains("UNIQUE ("), "unique constraint lost: {sql}"); + assert!( + !upper.contains("UNIQUE KEY"), + "MySQL UNIQUE KEY survived: {sql}" + ); + assert!( + !upper.contains("KEY AUTOLOAD"), + "plain KEY constraint survived: {sql}" + ); + } + + #[test] + fn laravel_charset_collate_table_options_dropped() { + let results = translate( + "create table `migrations` (`id` int unsigned not null auto_increment primary key, \ + `migration` varchar(255) not null, `batch` int not null) \ + default character set utf8mb4 collate 'utf8mb4_unicode_ci'", + Dialect::MySQL, + ) + .unwrap(); + let sql = extract_sql(&results[0]); + let upper = sql.to_ascii_uppercase(); + assert!(!upper.contains("CHARACTER SET"), "charset survived: {sql}"); + assert!(!upper.contains("COLLATE"), "collate survived: {sql}"); + } + + #[test] + fn upsert_values_call_becomes_excluded() { + // WordPress add_option()/update_option() shape. + let results = translate( + "INSERT INTO wp_options (option_name, option_value, autoload) \ + VALUES ('siteurl', 'http://x', 'yes') \ + ON DUPLICATE KEY UPDATE option_value = VALUES(option_value), autoload = VALUES(autoload)", + Dialect::MySQL, + ) + .unwrap(); + let sql = extract_sql(&results[0]); + let upper = sql.to_ascii_uppercase(); + assert!( + upper.contains("EXCLUDED.OPTION_VALUE") && upper.contains("EXCLUDED.AUTOLOAD"), + "VALUES() not rewritten to excluded.*: {sql}" + ); + assert!( + !upper.contains("= VALUES("), + "MySQL VALUES() call survived: {sql}" + ); + } + + #[test] + fn alter_add_unique_becomes_create_unique_index() { + // Laravel: alter table `users` add unique `users_email_unique`(`email`) + let results = translate( + "alter table `users` add unique `users_email_unique`(`email`)", + Dialect::MySQL, + ) + .unwrap(); + assert_eq!(results.len(), 1, "expected exactly one statement"); + let sql = extract_sql(&results[0]); + let upper = sql.to_ascii_uppercase(); + assert!( + upper.starts_with("CREATE UNIQUE INDEX"), + "expected CREATE UNIQUE INDEX, got: {sql}" + ); + assert!( + sql.contains("users") && sql.contains("email"), + "table/column lost: {sql}" + ); + } + + #[test] + fn alter_add_index_becomes_create_index() { + let results = translate( + "alter table `sessions` add index `sessions_user_id_index`(`user_id`)", + Dialect::MySQL, + ) + .unwrap(); + let sql = extract_sql(&results[0]); + let upper = sql.to_ascii_uppercase(); + assert!( + upper.starts_with("CREATE INDEX"), + "expected CREATE INDEX, got: {sql}" + ); + assert!(sql.contains("user_id"), "column lost: {sql}"); + } + + #[test] + fn decimal_precision_kept_in_ddl_prepass() { + let results = translate( + "CREATE TABLE t (price decimal(10,2) NOT NULL)", + Dialect::MySQL, + ) + .unwrap(); + let sql = extract_sql(&results[0]); + assert!( + sql.to_ascii_uppercase().contains("REAL"), + "decimal not mapped: {sql}" + ); + } + + #[test] + fn update_qualified_set_target_dequalified() { + // Laravel Eloquent: update `users` set `name` = ?, `users`.`updated_at` = ? + let results = translate( + "update `users` set `name` = 'x', `users`.`updated_at` = '2026-01-01' where `id` = 2", + Dialect::MySQL, + ) + .unwrap(); + let sql = extract_sql(&results[0]); + assert!( + !sql.contains("`users`.`updated_at`") && !sql.contains("users.updated_at"), + "qualified SET target survived: {sql}" + ); + assert!(sql.contains("updated_at"), "column lost: {sql}"); + } + + #[test] + fn sql_calc_found_rows_hint_stripped() { + // WordPress main post/comment query shape. + let results = translate( + "SELECT SQL_CALC_FOUND_ROWS wp_comments.comment_ID FROM wp_comments \ + WHERE ( comment_approved = '1' ) AND comment_post_ID = 4 \ + ORDER BY wp_comments.comment_date_gmt ASC", + Dialect::MySQL, + ) + .unwrap(); + let sql = extract_sql(&results[0]); + assert!( + !sql.to_ascii_uppercase().contains("SQL_CALC_FOUND_ROWS"), + "hint survived: {sql}" + ); + assert!(sql.contains("comment_ID"), "column lost: {sql}"); + } + + #[test] + fn hint_inside_string_literal_untouched() { + let results = translate( + "INSERT INTO posts (content) VALUES ('how to use SQL_CALC_FOUND_ROWS by hand') ON DUPLICATE KEY UPDATE content = VALUES(content)", + Dialect::MySQL, + ) + .unwrap(); + let sql = extract_sql(&results[0]); + assert!( + sql.contains("SQL_CALC_FOUND_ROWS"), + "literal content mangled: {sql}" + ); + } + + #[test] + fn found_rows_returns_zero_shim() { + let results = translate("SELECT FOUND_ROWS()", Dialect::MySQL).unwrap(); + let sql = extract_sql(&results[0]); + assert!( + sql.to_ascii_lowercase().contains("abs(0)"), + "FOUND_ROWS not shimmed: {sql}" + ); + } + // ── Passthrough ───────────────────────────────────────────────────────── #[test] diff --git a/crates/litewire-turso/src/lib.rs b/crates/litewire-turso/src/lib.rs index c086bb7..72c429a 100644 --- a/crates/litewire-turso/src/lib.rs +++ b/crates/litewire-turso/src/lib.rs @@ -341,6 +341,12 @@ fn reject_unsupported(sql: &str) -> Result<(), BackendError> { Ok(()) } +/// Does this SQL read from a pragma table-valued function +/// (`pragma_table_info(...)`, `pragma_index_list(...)`, ...)? +fn sql_uses_pragma_tvf(sql: &str) -> bool { + sql.to_ascii_lowercase().contains("pragma_") +} + #[async_trait::async_trait] impl BackendConn for TursoConn { async fn query(&self, sql: &str, params: &[Value]) -> Result { @@ -363,6 +369,23 @@ impl BackendConn for TursoConn { result_rows.push(values); } + // WORKAROUND (turso 0.7.0): a SELECT from a pragma table-valued + // function (e.g. `pragma_table_info(...)`) leaves the connection in + // a phantom-transaction state: every subsequent write is accepted + // and visible to this session but never committed — silently lost + // on close. `COMMIT` then reports "no transaction is active", yet + // an explicit `BEGIN; COMMIT;` pair restores normal autocommit — + // but only once the poisoning statement handle has been dropped. + // Verified empirically (see ePHPm docs/turso-gate5-results.md); + // upstream issue to be filed. Without this, WordPress is unusable + // (dbDelta's DESCRIBE poisons the session before any writes). + if sql_uses_pragma_tvf(sql) { + drop(rows); + drop(stmt); + conn.execute("BEGIN", ()).await.map_err(map_turso_err)?; + conn.execute("COMMIT", ()).await.map_err(map_turso_err)?; + } + Ok(ResultSet { columns, rows: result_rows, @@ -404,6 +427,42 @@ mod tests { // between the two engines show up as test failures here, not as // production surprises. + #[tokio::test] + async fn pragma_tvf_read_does_not_poison_session() { + // Regression (turso 0.7.0): a SELECT from pragma_table_info() left + // the session in a phantom-transaction state — subsequent writes + // were visible to the same session but silently lost to others. + let dir = std::env::temp_dir().join(format!("lw-tvf-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("poison.db"); + let backend = Turso::open(path.to_str().unwrap()).await.unwrap(); + let a = backend.connect().await.unwrap(); + a.execute("CREATE TABLE anchor (id INTEGER PRIMARY KEY)", &[]) + .await + .unwrap(); + // The poisoning read (WordPress: DESCRIBE / SHOW FULL COLUMNS). + a.query("SELECT name FROM pragma_table_info('anchor')", &[]) + .await + .unwrap(); + // Writes after the TVF read... + a.execute( + "CREATE TABLE after_tvf (id INTEGER PRIMARY KEY, v TEXT)", + &[], + ) + .await + .unwrap(); + a.execute("INSERT INTO after_tvf (v) VALUES ('x')", &[]) + .await + .unwrap(); + // ...must be visible to a different session. + let b = backend.connect().await.unwrap(); + let rs = b + .query("SELECT COUNT(*) FROM after_tvf", &[]) + .await + .expect("table written after pragma TVF read must exist for other sessions"); + assert_eq!(rs.rows[0][0], Value::Integer(1)); + } + #[tokio::test] async fn basic_crud() { let backend = Turso::memory().await.unwrap();