Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/litewire-mysql/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ fn param_to_value(param: ParamValue<'_>) -> Value {
impl<W: AsyncWrite + Send + Unpin> AsyncMysqlShim<W> 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,
Expand Down
10 changes: 10 additions & 0 deletions crates/litewire-translate/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
161 changes: 159 additions & 2 deletions crates/litewire-translate/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,176 @@ pub fn translate(sql: &str, dialect: Dialect) -> Result<Vec<TranslateResult>, 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 `(<digits>)` 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 `(<digits>)` 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<u8> = 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<u8> = 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`].
///
Expand Down
110 changes: 107 additions & 3 deletions crates/litewire-translate/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ pub enum MetadataQuery {
ShowIndex { table: String },
/// `SELECT @@variable` queries — MySQL system variables.
SystemVariables { variables: Vec<String> },
/// `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE
/// table_name = '<t>')` — 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<String> },
/// `SELECT ... FROM information_schema.columns` — column listing.
Expand Down Expand Up @@ -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!(
Expand All @@ -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_%'";
Expand Down Expand Up @@ -175,10 +213,15 @@ pub fn detect_metadata_query(sql: &str, _dialect: Dialect) -> Option<MetadataQue
return Some(MetadataQuery::ShowDatabases);
}

// SHOW COLUMNS FROM <table> / SHOW FIELDS FROM <table>
// SHOW [FULL] COLUMNS FROM <table> / SHOW [FULL] FIELDS FROM <table>
// (`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 });
Expand Down Expand Up @@ -214,6 +257,14 @@ pub fn detect_metadata_query(sql: &str, _dialect: Dialect) -> Option<MetadataQue
if upper.contains("INFORMATION_SCHEMA.TABLES")
|| upper.contains("INFORMATION_SCHEMA.`TABLES`")
{
// Existence probe (`SELECT EXISTS (...)` with a TABLE_NAME
// filter) must produce a single scalar column — Laravel's
// Schema::hasTable calls Connection::scalar() on it.
if upper.starts_with("SELECT EXISTS") {
if let Some(table) = extract_where_value_original(trimmed, "TABLE_NAME") {
return Some(MetadataQuery::TableExists { table });
}
}
let schema_filter = extract_where_value_original(trimmed, "TABLE_SCHEMA");
return Some(MetadataQuery::InformationSchemaTables { schema_filter });
}
Expand Down Expand Up @@ -496,8 +547,61 @@ mod tests {
table: "users".into(),
}
.to_sqlite_sql();
assert!(sql.contains("PRAGMA table_info"));
assert!(sql.contains("pragma_table_info"));
assert!(sql.contains("users"));
// MySQL SHOW FULL COLUMNS shape: wpdb::get_table_charset needs
// Field/Type/Collation; text affinity must not map to a capped type.
for col in [
"\"Field\"",
"\"Type\"",
"\"Null\"",
"\"Key\"",
"\"Collation\"",
] {
assert!(sql.contains(col), "missing column {col}: {sql}");
}
assert!(
sql.contains("longtext"),
"text affinity must map to longtext: {sql}"
);
assert!(
sql.contains("utf8mb4_unicode_ci"),
"collation missing: {sql}"
);
}

#[test]
fn laravel_has_table_exists_probe() {
// Laravel 11+/12 Schema::hasTable — must map to a single scalar.
let q = detect_metadata_query(
"select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'migrations') as `exists`",
Dialect::MySQL,
);
let Some(MetadataQuery::TableExists { table }) = q else {
panic!("expected TableExists, got {q:?}");
};
assert_eq!(table, "migrations");
let sql = MetadataQuery::TableExists { table }.to_sqlite_sql();
assert!(sql.contains("sqlite_master"), "got: {sql}");
assert!(
sql.to_uppercase().starts_with("SELECT EXISTS"),
"got: {sql}"
);
}

#[test]
fn show_full_columns_detected() {
for q in [
"SHOW FULL COLUMNS FROM wp_options",
"SHOW FULL COLUMNS FROM `wp_options`",
"show full fields from wp_options",
] {
let det = detect_metadata_query(q, Dialect::MySQL);
assert!(
matches!(det, Some(MetadataQuery::ShowColumns { ref table }) if table == "wp_options"),
"not detected: {q} -> {det:?}"
);
}
}

#[test]
Expand Down
Loading
Loading