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
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"

jobs:
fmt:
name: rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all -- --check

clippy:
name: clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --workspace --all-targets -- -D warnings

test:
name: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --workspace
36 changes: 24 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ libsql SDK (Rust, JS, Python, Go)
# Start with a MySQL frontend
litewire --mysql-listen 127.0.0.1:3306 --db app.db

# Start with all frontends
# Start with all frontends (postgres + tds require --features postgres,tds at build time)
litewire --mysql-listen 127.0.0.1:3306 --postgres-listen 127.0.0.1:5432 --tds-listen 127.0.0.1:1433 --hrana-listen 127.0.0.1:8080 --db app.db

# Connect from any MySQL client
Expand Down Expand Up @@ -110,17 +110,29 @@ litewire translates MySQL and PostgreSQL SQL dialects to SQLite on the fly:
| `SET NAMES utf8mb4` / `SET NOCOUNT ON` | No-op |
| Backtick / `[bracket]` quoting | Passed through or converted |

See [docs/architecture.md](docs/architecture.md) for the full translation reference.

## Tested With

- WordPress (via `pdo_mysql`)
- Laravel (via `pdo_mysql` / `pdo_pgsql` / `pdo_sqlsrv`)
- Drupal
- `mysql` CLI
- `psql` CLI
- `sqlcmd` CLI
- DBeaver, pgAdmin, SSMS, TablePlus
See [docs/architecture.md](docs/architecture.md) for the full architecture and translation reference.

## Compatibility

The MySQL frontend is exercised end-to-end by an in-process test suite
(`crates/litewire/tests/mysql_e2e.rs`) that drives the wire protocol via
`mysql_async` -- CRUD, prepared statements, transactions (`START TRANSACTION` /
`BEGIN` / `COMMIT` / `ROLLBACK`), `LAST_INSERT_ID()`, `SHOW TABLES`,
`DESCRIBE`, `INFORMATION_SCHEMA` probes, `SET NAMES` / `SET autocommit`, and
the metadata queries used at connection setup.

The PostgreSQL and TDS frontends are wire-compatible enough for basic CRUD
against `psql` / `sqlcmd` and the extended-query flow used by `pdo_pgsql` /
`pdo_sqlsrv`; the TDS frontend is **experimental** -- authentication is
simplified, the type coverage is a subset (BigInt / Float8 / NVARCHAR /
VarBinary), and the SSL handshake is not implemented. Real SQL Server tools
(SSMS, sqlcmd with encryption) will not connect until those land.

Anywhere you would normally point at MySQL/PG/SQL Server -- PHP PDO drivers,
`mysql` / `psql` / `sqlcmd` CLIs, DBeaver, pgAdmin -- should work for standard
CRUD workloads. Anything that depends on server-side features SQLite doesn't
have (stored procedures, `SQL_CALC_FOUND_ROWS`, `LOCK TABLES` isolation,
row-level locking semantics, dollar-quoted PL/pgSQL bodies, etc.) will not.

## Limitations

Expand Down
12 changes: 3 additions & 9 deletions crates/litewire-backend/src/hrana_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,7 @@ fn value_to_hrana(val: &Value) -> HranaValue {
value: i.to_string(),
},
Value::Float(f) => HranaValue::Float { value: *f },
Value::Text(s) => HranaValue::Text {
value: s.clone(),
},
Value::Text(s) => HranaValue::Text { value: s.clone() },
Value::Blob(b) => {
use base64::Engine;
HranaValue::Blob {
Expand Down Expand Up @@ -375,17 +373,13 @@ mod tests {
],
rows: vec![
vec![
ResponseValue::Integer {
value: "1".into(),
},
ResponseValue::Integer { value: "1".into() },
ResponseValue::Text {
value: "alice".into(),
},
],
vec![
ResponseValue::Integer {
value: "2".into(),
},
ResponseValue::Integer { value: "2".into() },
ResponseValue::Text {
value: "bob".into(),
},
Expand Down
10 changes: 8 additions & 2 deletions crates/litewire-backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,14 @@ mod tests {
fn empty_result_set() {
let rs = ResultSet {
columns: vec![
Column { name: "a".into(), decltype: None },
Column { name: "b".into(), decltype: None },
Column {
name: "a".into(),
decltype: None,
},
Column {
name: "b".into(),
decltype: None,
},
],
rows: vec![],
};
Expand Down
48 changes: 31 additions & 17 deletions crates/litewire-backend/src/rusqlite_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ impl Rusqlite {
///
/// Returns an error if the database cannot be opened.
pub fn memory() -> Result<Self, BackendError> {
let conn =
Connection::open_in_memory().map_err(|e| BackendError::Sqlite(e.to_string()))?;
let conn = Connection::open_in_memory().map_err(|e| BackendError::Sqlite(e.to_string()))?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
})
Expand Down Expand Up @@ -73,9 +72,7 @@ fn extract_value(row: &rusqlite::Row<'_>, idx: usize) -> Result<Value, rusqlite:
ValueRef::Null => Ok(Value::Null),
ValueRef::Integer(i) => Ok(Value::Integer(i)),
ValueRef::Real(f) => Ok(Value::Float(f)),
ValueRef::Text(s) => Ok(Value::Text(
String::from_utf8_lossy(s).into_owned(),
)),
ValueRef::Text(s) => Ok(Value::Text(String::from_utf8_lossy(s).into_owned())),
ValueRef::Blob(b) => Ok(Value::Blob(b.to_vec())),
}
}
Expand Down Expand Up @@ -110,12 +107,14 @@ impl Backend for Rusqlite {
.query(param_refs.as_slice())
.map_err(|e| BackendError::Sqlite(e.to_string()))?;

while let Some(row) = rows.next().map_err(|e| BackendError::Sqlite(e.to_string()))? {
while let Some(row) = rows
.next()
.map_err(|e| BackendError::Sqlite(e.to_string()))?
{
let mut values = Vec::with_capacity(col_count);
for i in 0..col_count {
values.push(
extract_value(row, i)
.map_err(|e| BackendError::Sqlite(e.to_string()))?,
extract_value(row, i).map_err(|e| BackendError::Sqlite(e.to_string()))?,
);
}
result_rows.push(values);
Expand Down Expand Up @@ -193,7 +192,10 @@ mod tests {
.unwrap();
assert_eq!(result.last_insert_rowid, Some(2));

let rs = backend.query("SELECT id, name FROM users ORDER BY id", &[]).await.unwrap();
let rs = backend
.query("SELECT id, name FROM users ORDER BY id", &[])
.await
.unwrap();
assert_eq!(rs.columns.len(), 2);
assert_eq!(rs.columns[0].name, "id");
assert_eq!(rs.columns[1].name, "name");
Expand Down Expand Up @@ -243,7 +245,10 @@ mod tests {
#[tokio::test]
async fn null_handling() {
let backend = Rusqlite::memory().unwrap();
backend.execute("CREATE TABLE t (v TEXT)", &[]).await.unwrap();
backend
.execute("CREATE TABLE t (v TEXT)", &[])
.await
.unwrap();
backend
.execute("INSERT INTO t VALUES (?1)", &[Value::Null])
.await
Expand Down Expand Up @@ -286,10 +291,10 @@ mod tests {
.unwrap();

let rs = backend
.query("SELECT * FROM t WHERE a = ?1 AND b = ?2", &[
Value::Integer(1),
Value::Text("hello".into()),
])
.query(
"SELECT * FROM t WHERE a = ?1 AND b = ?2",
&[Value::Integer(1), Value::Text("hello".into())],
)
.await
.unwrap();
assert_eq!(rs.rows.len(), 1);
Expand Down Expand Up @@ -386,11 +391,17 @@ mod tests {
async fn column_names_preserved() {
let backend = Rusqlite::memory().unwrap();
backend
.execute("CREATE TABLE users (id INTEGER, name TEXT, email TEXT)", &[])
.execute(
"CREATE TABLE users (id INTEGER, name TEXT, email TEXT)",
&[],
)
.await
.unwrap();

let rs = backend.query("SELECT id, name, email FROM users", &[]).await.unwrap();
let rs = backend
.query("SELECT id, name, email FROM users", &[])
.await
.unwrap();
assert_eq!(rs.columns[0].name, "id");
assert_eq!(rs.columns[1].name, "name");
assert_eq!(rs.columns[2].name, "email");
Expand All @@ -399,7 +410,10 @@ mod tests {
#[tokio::test]
async fn query_with_alias() {
let backend = Rusqlite::memory().unwrap();
let rs = backend.query("SELECT 1 AS num, 'hello' AS greeting", &[]).await.unwrap();
let rs = backend
.query("SELECT 1 AS num, 'hello' AS greeting", &[])
.await
.unwrap();
assert_eq!(rs.columns[0].name, "num");
assert_eq!(rs.columns[1].name, "greeting");
assert_eq!(rs.rows[0][0], Value::Integer(1));
Expand Down
29 changes: 15 additions & 14 deletions crates/litewire-backend/tests/hrana_client_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ async fn create_table_and_insert() {

// Create table
let result = client
.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)", &[])
.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)",
&[],
)
.await
.expect("CREATE TABLE failed");
assert_eq!(result.affected_rows, 0);
Expand Down Expand Up @@ -89,7 +92,10 @@ async fn query_rows() {
let (client, _server) = start_server().await;

client
.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, label TEXT, price REAL)", &[])
.execute(
"CREATE TABLE items (id INTEGER PRIMARY KEY, label TEXT, price REAL)",
&[],
)
.await
.unwrap();
client
Expand Down Expand Up @@ -183,7 +189,10 @@ async fn blob_roundtrip() {
let (client, _server) = start_server().await;

client
.execute("CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB)", &[])
.execute(
"CREATE TABLE blobs (id INTEGER PRIMARY KEY, data BLOB)",
&[],
)
.await
.unwrap();

Expand Down Expand Up @@ -234,9 +243,7 @@ async fn null_values() {
async fn sql_error_returns_backend_error() {
let (client, _server) = start_server().await;

let result = client
.query("SELECT * FROM nonexistent_table", &[])
.await;
let result = client.query("SELECT * FROM nonexistent_table", &[]).await;

assert!(result.is_err());
let err = result.unwrap_err().to_string();
Expand All @@ -255,18 +262,12 @@ async fn update_and_delete() {
.await
.unwrap();
client
.execute(
"INSERT INTO counters VALUES ('hits', 0)",
&[],
)
.execute("INSERT INTO counters VALUES ('hits', 0)", &[])
.await
.unwrap();

let result = client
.execute(
"UPDATE counters SET val = val + 1 WHERE name = 'hits'",
&[],
)
.execute("UPDATE counters SET val = val + 1 WHERE name = 'hits'", &[])
.await
.unwrap();
assert_eq!(result.affected_rows, 1);
Expand Down
22 changes: 9 additions & 13 deletions crates/litewire-hrana/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,8 @@ async fn execute_stmt(
backend: &SharedBackend,
stmt: &StmtRequest,
) -> Result<StreamResult, litewire_backend::BackendError> {
let params: Vec<litewire_backend::Value> = stmt
.args
.iter()
.map(|a| a.to_backend_value())
.collect();
let params: Vec<litewire_backend::Value> =
stmt.args.iter().map(|a| a.to_backend_value()).collect();

// Hrana sends SQLite SQL natively -- no translation needed.
let sql_upper = stmt.sql.trim().to_ascii_uppercase();
Expand Down Expand Up @@ -257,10 +254,7 @@ mod tests {
.await
.unwrap();
backend
.execute(
"INSERT INTO t VALUES (1, 'Alice')",
&[],
)
.execute("INSERT INTO t VALUES (1, 'Alice')", &[])
.await
.unwrap();

Expand Down Expand Up @@ -356,10 +350,12 @@ mod tests {
let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(resp["results"][0]["type"], "error");
assert!(resp["results"][0]["error"]["message"]
.as_str()
.unwrap()
.contains("nonexistent_table"));
assert!(
resp["results"][0]["error"]["message"]
.as_str()
.unwrap()
.contains("nonexistent_table")
);
}

#[tokio::test]
Expand Down
2 changes: 1 addition & 1 deletion crates/litewire-hrana/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
mod http;
mod types;

use std::net::SocketAddr;
use litewire_backend::SharedBackend;
use std::net::SocketAddr;
use tracing::info;

/// Configuration for the Hrana HTTP frontend.
Expand Down
Loading
Loading