diff --git a/crates/litewire-mysql/src/handler.rs b/crates/litewire-mysql/src/handler.rs index 45a0f32..41c9e78 100644 --- a/crates/litewire-mysql/src/handler.rs +++ b/crates/litewire-mysql/src/handler.rs @@ -37,7 +37,7 @@ fn ok_response(affected_rows: u64, last_insert_id: u64, in_transaction: bool) -> } } -use crate::types::sqlite_to_mysql_column_type; +use crate::types::{mysql_type_for_value, sqlite_to_mysql_column_type}; /// A cached prepared statement. struct PreparedStmt { @@ -103,11 +103,29 @@ impl LiteWireHandler { let columns: Vec = rs .columns .iter() - .map(|c| Column { - table: String::new(), - column: c.name.clone(), - coltype: sqlite_to_mysql_column_type(c.decltype.as_deref()), - colflags: ColumnFlags::empty(), + .enumerate() + .map(|(i, c)| { + // Declared type wins; for untyped expression columns + // (`SELECT 1`, decltype None) infer the wire type from + // the first non-NULL value so the declared column type + // matches how the row writer encodes it. Scan past + // leading NULLs; empty/all-NULL columns stay VAR_STRING + // (NULL is valid against any column type). + let coltype = if c.decltype.is_some() { + sqlite_to_mysql_column_type(c.decltype.as_deref()) + } else { + rs.rows + .iter() + .filter_map(|r| r.get(i)) + .find(|v| !matches!(v, Value::Null)) + .map_or(ColumnType::MYSQL_TYPE_VAR_STRING, mysql_type_for_value) + }; + Column { + table: String::new(), + column: c.name.clone(), + coltype, + colflags: ColumnFlags::empty(), + } }) .collect(); diff --git a/crates/litewire-mysql/src/types.rs b/crates/litewire-mysql/src/types.rs index 4451980..f52c849 100644 --- a/crates/litewire-mysql/src/types.rs +++ b/crates/litewire-mysql/src/types.rs @@ -1,7 +1,30 @@ //! MySQL type mapping from SQLite column declarations. +use litewire_backend::Value; use opensrv_mysql::ColumnType; +/// Map a runtime [`Value`] to the MySQL column type its binary encoding +/// requires. +/// +/// SQLite is dynamically typed: expression columns (`SELECT 1`, `SELECT +/// a + b`) have no declared type, so `decltype` is `None` and the +/// declaration-based mapping would fall back to `VAR_STRING`. But the row +/// writer emits each value in its native form (an integer as `LONGLONG`), +/// and opensrv's binary protocol rejects a declared/actual type mismatch +/// ("tried to use 1 as MYSQL_TYPE_VAR_STRING") and drops the connection — +/// surfacing to the client as `2006 server has gone away`. For untyped +/// columns we therefore derive the wire type from the actual data. +#[must_use] +pub fn mysql_type_for_value(value: &Value) -> ColumnType { + match value { + Value::Null => ColumnType::MYSQL_TYPE_VAR_STRING, + Value::Integer(_) => ColumnType::MYSQL_TYPE_LONGLONG, + Value::Float(_) => ColumnType::MYSQL_TYPE_DOUBLE, + Value::Text(_) => ColumnType::MYSQL_TYPE_VAR_STRING, + Value::Blob(_) => ColumnType::MYSQL_TYPE_BLOB, + } +} + /// Map a SQLite column declared type to a MySQL column type. /// /// Uses the declared type string from `PRAGMA table_info` or the column diff --git a/crates/litewire/tests/mysql_e2e.rs b/crates/litewire/tests/mysql_e2e.rs index caa82f5..33f83ca 100644 --- a/crates/litewire/tests/mysql_e2e.rs +++ b/crates/litewire/tests/mysql_e2e.rs @@ -280,6 +280,31 @@ async fn prepared_select_with_param() { drop(conn); } +#[tokio::test] +async fn prepared_select_expression_no_table() { + // A prepared SELECT of a bare expression (no FROM clause) — e.g. the + // `SELECT 1` liveness check some ORMs issue with prepare-emulation off. + // Regressed with "2006 server has gone away": the frontend dropped the + // connection because on_prepare and on_execute disagreed on column count + // for a table-less expression column. + init_tracing(); + let port = free_port().await; + let _server = start_litewire(port).await; + let mut conn = connect(port).await; + + let rows: Vec = conn.exec("SELECT 1", ()).await.unwrap(); + assert_eq!(rows, vec![1]); + + // Also exercise a multi-column expression select and an aliased one. + let rows: Vec<(i64, i64)> = conn.exec("SELECT 1 + 1, 40 + 2", ()).await.unwrap(); + assert_eq!(rows, vec![(2, 42)]); + + let rows: Vec = conn.exec("SELECT 7 AS answer", ()).await.unwrap(); + assert_eq!(rows, vec![7]); + + drop(conn); +} + #[tokio::test] async fn prepared_insert() { init_tracing();