diff --git a/.tabularium b/.tabularium index 348b346..6f76b12 100644 --- a/.tabularium +++ b/.tabularium @@ -2,11 +2,13 @@ "name": "libsql", "version": "0.1.0", "kind": "driver", - "description": "libSQL / Turso driver. Connects to local libSQL/SQLite files and to remote Turso or sqld servers over the Hrana HTTP protocol.", + "description": "libSQL / Turso driver. Connects to local libSQL/SQLite files (embedded libSQL fork: ALTER COLUMN and foreign-key add/drop work locally) and to remote Turso or sqld servers over the Hrana HTTP protocol.", "engine": "libsql", "paradigms": [ "sql" ], + "icon": "sqlite", + "color": "#4ff8d2", "default_username": "", "executable": "libsql-plugin", "capabilities": { @@ -16,14 +18,21 @@ "file_based": false, "folder_based": false, "connection_string": true, - "connection_string_example": "libsql://my-db.turso.io?authToken=... (or a local path like /data/app.db)", + "connection_string_example": "libsql://my-db.turso.io?authToken=... (or a local file via file:///data/app.db)", + "connection_uri": true, + "connection_uri_schemes": [ + "turso", + "wss", + "ws", + "file" + ], "identifier_quote": "\"", "alter_primary_key": false, "inline_pk": true, "auto_increment_keyword": "AUTOINCREMENT", "serial_type": "", - "alter_column": false, - "create_foreign_keys": false, + "alter_column": true, + "create_foreign_keys": true, "no_connection_required": false, "manage_tables": true, "readonly": false, diff --git a/Cargo.toml b/Cargo.toml index bb77446..470213a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,14 @@ path = "src/main.rs" [dependencies] serde_json = "1" -# Local file backend: bundled SQLite, compiled from source -> no system lib -# needed, builds identically on Linux, macOS and Windows. -rusqlite = { version = "0.31", features = ["bundled"] } +# Local file backend: the libSQL fork of SQLite, bundled (compiled from source +# -> no system lib, builds identically on Linux, macOS and Windows). Using the +# fork gives local files the same ALTER COLUMN / FK-add extensions as remote +# Turso servers. The `core` feature is embedded-local only; the async API does +# all its work synchronously, so futures::executor::block_on bridges it +# without pulling in a tokio runtime. +libsql = { version = "0.9", default-features = false, features = ["core"] } +futures = "0.3" # Remote backend: blocking HTTP client with pure-Rust rustls TLS (no OpenSSL), # used to speak the Hrana-over-HTTP pipeline protocol to Turso / sqld. ureq = { version = "2", features = ["json"] } diff --git a/README.md b/README.md index 87525f4..2bbdbf7 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,41 @@ libsql://my-db.turso.io?authToken=eyJ... | Insert / update / delete rows (bound parameters) | ✅ | | Schema snapshot + batch columns/FKs (ER diagram) | ✅ | | `CREATE TABLE` SQL, add column, create/drop index | ✅ | -| Schemas, stored routines | ❌ (not a SQLite concept) | -| Alter column type, add/drop foreign key on existing table | ❌ (SQLite limitation — returns a clear error) | +| Schemas, stored routines | ❌ (not a SQLite concept — Turso has no stored procedures, and its multi-database model is separate databases, not schemas) | +| Rename column | ✅ everywhere (vanilla `RENAME COLUMN`) | +| Alter column type / default | ✅ remote Turso/sqld (libSQL `ALTER COLUMN` extension) / ⚠️ local files — the host calls the SQL builder without connection params, so the libSQL statement is generated unconditionally and local SQLite rejects it with its own parse error when the host runs it | +| Drop foreign key on existing table | ✅ remote Turso/sqld (has connection params) / ❌ local (clear error) | +| Add foreign key to existing table | ❌ (protocol limitation — see below) | Identifiers are quoted ANSI-style (`"name"`). Booleans are stored as `0`/`1` and BLOBs are returned base64-encoded. +### Turso-only schema changes + +Remote Turso / sqld servers run the **libSQL fork** of SQLite, which adds +`ALTER TABLE ... ALTER COLUMN col TO col [DEFAULT ...] [REFERENCES ...]`. +The plugin uses it to: + +- change a column's type (and optionally its DEFAULT / NOT NULL), +- drop a foreign key from an existing column (the same statement without the + `REFERENCES` clause). + +Local SQLite files cannot retype columns or touch existing foreign keys. The +driver reports a clear error for foreign-key drops; note that libSQL applies +constraint changes to newly inserted/updated rows only — existing rows are not +rewritten or revalidated — and foreign key *enforcement* requires +`PRAGMA foreign_keys=ON`. + +> **Why "add foreign key to existing table" is unavailable:** the host calls +> the SQL preview builders with *no connection params*, and the libSQL +> `ALTER COLUMN` rewrite replaces the column's whole definition — so building +> the statement requires the column's declared type, which the host does not +> send (only its name) and the plugin has no connection to look up. The +> `.tabularium` capability is `create_foreign_keys: false`, so Tabularis hides +> the add-FK dialog. Define foreign keys in the `CREATE TABLE` statement +> instead. Dropping a foreign key does receive connection params and works on +> remote Turso/sqld. + ## Build & test Requires a Rust toolchain (and a C compiler for the bundled SQLite). @@ -86,11 +115,16 @@ echo '{"jsonrpc":"2.0","method":"get_tables","params":{"params":{"database":"/tm ## Installing `just dev-install` copies `libsql-plugin` and `.tabularium` into the Tabularis -plugins folder: +plugins folder (the location Tabularis actually scans — derived from +`ProjectDirs::from("com", "debba", "tabularis")`): - **Linux:** `~/.local/share/tabularis/plugins/libsql/` -- **macOS:** `~/Library/Application Support/tabularis/plugins/libsql/` -- **Windows:** `%APPDATA%\tabularis\plugins\libsql\` +- **macOS:** `~/Library/Application Support/com.debba.tabularis/plugins/libsql/` +- **Windows:** `%APPDATA%\debba\tabularis\data\plugins\libsql\` + +Note: the plugin guide's plain `%APPDATA%\tabularis\plugins` / macOS +`tabularis/plugins` paths are wrong — that folder holds app config, not +plugins; Tabularis scans the ProjectDirs path above. Restart Tabularis (or toggle the plugin in Settings) and **libSQL** appears in the Database Type list. diff --git a/justfile b/justfile index 009caa0..d556161 100644 --- a/justfile +++ b/justfile @@ -47,12 +47,7 @@ dev-install: build [windows] dev-install: build - $dest = Join-Path $env:APPDATA "tabularis\plugins\libsql" - New-Item -ItemType Directory -Force -Path $dest | Out-Null - Copy-Item "target\debug\libsql-plugin.exe" $dest - Copy-Item ".tabularium" $dest - Write-Host "Installed to $dest" - Write-Host "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." + $dest = Join-Path $env:APPDATA "tabularis\plugins\libsql"; New-Item -ItemType Directory -Force -Path $dest | Out-Null; Copy-Item "target\debug\libsql-plugin.exe" $dest; Copy-Item ".tabularium" $dest; Write-Host "Installed to $dest"; Write-Host "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." [linux] uninstall: diff --git a/src/client.rs b/src/client.rs index 4144e23..036688f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2,8 +2,8 @@ //! expose a single `query`/`execute` surface the handlers can use without //! caring whether the database is a local file or a remote Turso server. -use rusqlite::types::Value as SqliteValue; -use rusqlite::Connection; +use libsql::Connection as LibsqlConnection; +use libsql::Value as LibsqlValue; use serde_json::{json, Value}; use crate::error::PluginError; @@ -13,7 +13,8 @@ use crate::models::ConnectionParams; /// Where a connection actually points. #[derive(Debug, PartialEq, Eq)] pub enum Backend { - /// Local libSQL/SQLite file (or `:memory:`). + /// Local libSQL/SQLite file (or `:memory:`), opened through the libSQL + /// fork of SQLite so fork extensions (ALTER COLUMN, FK add) work locally. Local(String), /// Remote Turso / sqld server reachable over Hrana HTTP. Remote { url: String, token: Option }, @@ -31,19 +32,14 @@ pub struct QueryResult { /// stateless (each call is an independent HTTP request), so there is no shared /// mutable state to manage. pub enum Client { - Local(Connection), + Local(LibsqlConnection), Remote(HranaClient), } impl Client { pub fn connect(params: &ConnectionParams) -> Result { match resolve_backend(params)? { - Backend::Local(path) => { - let conn = Connection::open(&path).map_err(|e| { - PluginError::internal(format!("cannot open libSQL file '{path}': {e}")) - })?; - Ok(Client::Local(conn)) - } + Backend::Local(path) => Ok(Client::Local(open_local(&path)?)), Backend::Remote { url, token } => Ok(Client::Remote(HranaClient::new(url, token))), } } @@ -67,10 +63,8 @@ impl Client { pub fn execute(&self, sql: &str, args: &[Value]) -> Result { match self { Client::Local(conn) => { - let sqlite_args = to_sqlite_params(args); - let mut stmt = conn.prepare(sql)?; - let affected = stmt.execute(rusqlite::params_from_iter(sqlite_args.iter()))?; - Ok(affected as u64) + let libsql_args: Vec = args.iter().map(json_to_libsql).collect(); + futures::executor::block_on(conn.execute(sql, libsql_args)).map_err(Into::into) } Client::Remote(client) => Ok(client.execute(sql, args)?.affected), } @@ -82,19 +76,34 @@ impl Client { } } -fn local_query(conn: &Connection, sql: &str, args: &[Value]) -> Result { - let sqlite_args = to_sqlite_params(args); - let mut stmt = conn.prepare(sql)?; - let columns: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); +/// Open a local file through the embedded libSQL fork. `build()` is async in +/// the crate's API but does its work synchronously, so `block_on` is a +/// straight bridge — no runtime threads, matching the plugin's sync stdio loop. +fn open_local(path: &str) -> Result { + let db = futures::executor::block_on(libsql::Builder::new_local(path).build()) + .map_err(|e| PluginError::internal(format!("cannot open libSQL file '{path}': {e}")))?; + db.connect() + .map_err(|e| PluginError::internal(format!("cannot open libSQL file '{path}': {e}"))) +} + +fn local_query( + conn: &LibsqlConnection, + sql: &str, + args: &[Value], +) -> Result { + let libsql_args: Vec = args.iter().map(json_to_libsql).collect(); + let mut rows = futures::executor::block_on(conn.query(sql, libsql_args))?; + let columns: Vec = (0..rows.column_count()) + .map(|i| rows.column_name(i).unwrap_or("").to_string()) + .collect(); let ncol = columns.len(); let mut out_rows = Vec::new(); - let mut rows = stmt.query(rusqlite::params_from_iter(sqlite_args.iter()))?; - while let Some(row) = rows.next()? { + while let Some(row) = futures::executor::block_on(rows.next())? { let mut cells = Vec::with_capacity(ncol); for i in 0..ncol { - let value: SqliteValue = row.get(i)?; - cells.push(sqlite_to_json(value)); + let value = row.get_value(i as i32)?; + cells.push(libsql_value_to_json(value)); } out_rows.push(cells); } @@ -106,37 +115,33 @@ fn local_query(conn: &Connection, sql: &str, args: &[Value]) -> Result Vec { - args.iter().map(json_to_sqlite).collect() -} - -fn json_to_sqlite(value: &Value) -> SqliteValue { +fn json_to_libsql(value: &Value) -> LibsqlValue { match value { - Value::Null => SqliteValue::Null, - Value::Bool(b) => SqliteValue::Integer(if *b { 1 } else { 0 }), + Value::Null => LibsqlValue::Null, + Value::Bool(b) => LibsqlValue::Integer(if *b { 1 } else { 0 }), Value::Number(n) => { if let Some(i) = n.as_i64() { - SqliteValue::Integer(i) + LibsqlValue::Integer(i) } else if let Some(f) = n.as_f64() { - SqliteValue::Real(f) + LibsqlValue::Real(f) } else { - SqliteValue::Text(n.to_string()) + LibsqlValue::Text(n.to_string()) } } - Value::String(s) => SqliteValue::Text(s.clone()), - other => SqliteValue::Text(other.to_string()), + Value::String(s) => LibsqlValue::Text(s.clone()), + other => LibsqlValue::Text(other.to_string()), } } -fn sqlite_to_json(value: SqliteValue) -> Value { +fn libsql_value_to_json(value: LibsqlValue) -> Value { use base64::engine::general_purpose::STANDARD; use base64::Engine; match value { - SqliteValue::Null => Value::Null, - SqliteValue::Integer(i) => json!(i), - SqliteValue::Real(f) => json!(f), - SqliteValue::Text(s) => Value::String(s), - SqliteValue::Blob(b) => Value::String(STANDARD.encode(b)), + LibsqlValue::Null => Value::Null, + LibsqlValue::Integer(i) => json!(i), + LibsqlValue::Real(f) => json!(f), + LibsqlValue::Text(s) => Value::String(s), + LibsqlValue::Blob(b) => Value::String(STANDARD.encode(b)), } } @@ -160,6 +165,23 @@ fn is_url(s: &str) -> bool { /// Decide which backend a set of connection params points at. pub fn resolve_backend(params: &ConnectionParams) -> Result { + // 0. The raw connection URI is authoritative when the host passes it + // through (drivers with the `connection_uri` capability). The host still + // fills `host` from the same URI, but rebuilding the URL from the + // decomposed fields would drop the query string — and with it the auth + // token — so the verbatim URI wins. + if let Some(uri) = params.connection_uri.as_deref() { + let uri = uri.trim(); + if !uri.is_empty() { + if is_url(uri) { + let (url, token_from_url) = normalize_remote_url(uri); + let token = token_from_url.or_else(|| params.password.clone()); + return Ok(Backend::Remote { url, token }); + } + return Ok(Backend::Local(expand_path(uri))); + } + } + let database = params.database.clone().unwrap_or_default(); let database = database.trim(); @@ -261,7 +283,11 @@ fn build_url_from_host(host: &str, port: Option, ssl_mode: Option<&str>) -> } fn expand_path(path: &str) -> String { - let path = path.strip_prefix("file:").unwrap_or(path); + let path = path + .strip_prefix("file:///") + .or_else(|| path.strip_prefix("file://")) + .or_else(|| path.strip_prefix("file:")) + .unwrap_or(path); if path == ":memory:" { return path.to_string(); } @@ -318,6 +344,84 @@ mod tests { assert_eq!(token.as_deref(), Some("abc123")); } + #[test] + fn connection_uri_beats_host_and_keeps_the_token() { + // The host parser fills `host` from the same URI; without the + // `connection_uri` step the query string (and token) would be lost. + let p = ConnectionParams { + host: Some("db.turso.io".into()), + database: None, + connection_uri: Some("libsql://db.turso.io?authToken=abc123".into()), + ..Default::default() + }; + assert_eq!( + resolve_backend(&p).unwrap(), + Backend::Remote { + url: "https://db.turso.io".into(), + token: Some("abc123".into()) + } + ); + } + + #[test] + fn connection_uri_token_wins_over_password() { + let p = ConnectionParams { + host: Some("db.turso.io".into()), + password: Some("pw".into()), + connection_uri: Some("libsql://db.turso.io?authToken=abc123".into()), + ..Default::default() + }; + assert_eq!( + resolve_backend(&p).unwrap(), + Backend::Remote { + url: "https://db.turso.io".into(), + token: Some("abc123".into()) + } + ); + } + + #[test] + fn connection_uri_falls_back_to_password_without_token() { + let p = ConnectionParams { + host: Some("db.turso.io".into()), + password: Some("pw".into()), + connection_uri: Some("libsql://db.turso.io".into()), + ..Default::default() + }; + assert_eq!( + resolve_backend(&p).unwrap(), + Backend::Remote { + url: "https://db.turso.io".into(), + token: Some("pw".into()) + } + ); + } + + #[test] + fn connection_uri_local_path_is_a_local_backend() { + let p = ConnectionParams { + host: Some("db.turso.io".into()), + connection_uri: Some("/data/app.db".into()), + ..Default::default() + }; + assert_eq!( + resolve_backend(&p).unwrap(), + Backend::Local("/data/app.db".into()) + ); + } + + #[test] + fn connection_uri_file_scheme_is_a_local_backend() { + let p = ConnectionParams { + connection_uri: Some("file:///C:/data/app.db".into()), + ..Default::default() + }; + assert_eq!( + resolve_backend(&p).unwrap(), + Backend::Local("C:/data/app.db".into()) + ); + } + #[test] fn websocket_schemes_map_to_http() { assert_eq!( diff --git a/src/error.rs b/src/error.rs index 9a46d1e..57ba218 100644 --- a/src/error.rs +++ b/src/error.rs @@ -45,8 +45,8 @@ impl fmt::Display for PluginError { impl std::error::Error for PluginError {} -impl From for PluginError { - fn from(err: rusqlite::Error) -> Self { +impl From for PluginError { + fn from(err: libsql::Error) -> Self { PluginError::internal(format!("sqlite error: {err}")) } } diff --git a/src/handlers/crud.rs b/src/handlers/crud.rs index e2baff6..41f564e 100644 --- a/src/handlers/crud.rs +++ b/src/handlers/crud.rs @@ -81,8 +81,8 @@ fn insert_impl(params: &Value) -> Result { columns.join(", "), placeholders.join(", "), ); - client.execute(&sql, &args)?; - Ok(Value::Null) + let affected = client.execute(&sql, &args)?; + Ok(json!(affected)) } pub fn update_record(id: Value, params: &Value) -> Value { diff --git a/src/handlers/ddl.rs b/src/handlers/ddl.rs index 83c1c15..dd8a1e2 100644 --- a/src/handlers/ddl.rs +++ b/src/handlers/ddl.rs @@ -1,191 +1,515 @@ -//! DDL generation and the few DDL mutations libSQL/SQLite supports. +//! DDL generation for the host's schema dialogs. //! -//! The `get_*_sql` methods return SQL strings the host may show before running -//! them through `execute_query`. SQLite cannot retype columns or add foreign -//! keys to an existing table, so those methods return an explicit unsupported -//! error rather than faking success. +//! The `get_*_sql` methods return SQL statements (as a JSON array, matching +//! the host's `Vec` contract) the host may show before running them +//! through `execute_query`. The host calls these without any connection +//! params — they are pure builders over `ColumnDefinition` objects +//! (`{name, data_type, is_nullable, is_pk, is_auto_increment, default_value}`) +//! — so none of them can open a connection or introspect the schema. +//! +//! Vanilla SQLite cannot retype columns or add/drop foreign keys on an +//! existing table. The libSQL fork used by remote Turso / sqld servers adds +//! `ALTER TABLE ... ALTER COLUMN col TO col [DEFAULT ...] [REFERENCES ...]`, +//! which covers both. Statement *builders* cannot tell local from remote (no +//! connection params arrive), so `get_alter_column_sql` emits the libSQL form +//! unconditionally: it works on remote connections and, on local files, the +//! embedded libSQL fork understands the same syntax, so it works there too. +//! Renames use plain `RENAME COLUMN`, which vanilla SQLite supports too. +//! +//! `get_create_foreign_key_sql` is the one SQL builder that *does* receive +//! connection params: the host's RpcDriver passes them through, so the +//! column's declared type can be introspected. It emits the libSQL +//! `ALTER COLUMN col TO col REFERENCES ...` form, which is the only +//! way the fork supports adding a foreign key to an existing table. +//! Dropping a foreign key (`drop_foreign_key`) also receives connection +//! params and stays supported on every backend. use serde_json::{json, Value}; +use crate::client::Client; use crate::error::PluginError; -use crate::handlers::{cell_str, connect, req_str, respond}; -use crate::utils::identifiers::{quote, quote_literal}; +use crate::handlers::{cell_i64, cell_str, connect, req_str, respond}; +use crate::utils::identifiers::quote; -pub fn get_create_table_sql(id: Value, params: &Value) -> Value { - respond(id, { - connect(params).and_then(|client| { - let table = req_str(params, "table")?; - let r = client.query( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1", - &[json!(table)], - )?; - let sql = r - .rows - .first() - .and_then(|row| cell_str(row, 0)) - .ok_or_else(|| PluginError::internal(format!("table '{table}' not found")))?; - Ok(Value::String(sql)) - }) - }) +// --------------------------------------------------------------------------- +// Column definitions (host `ColumnDefinition` contract) +// --------------------------------------------------------------------------- + +fn col_field<'a>(column: &'a Value, keys: &[&str]) -> Option<&'a Value> { + keys.iter().find_map(|k| column.get(*k)) } -pub fn get_add_column_sql(id: Value, params: &Value) -> Value { - respond(id, { - let table = req_str(params, "table"); - table.and_then(|table| { - let column = params - .get("column") - .ok_or_else(|| PluginError::invalid_params("missing 'column' definition"))?; - Ok(Value::String(build_add_column_sql(&table, column)?)) - }) - }) +fn col_name(column: &Value) -> Result { + col_field(column, &["name"]) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| PluginError::invalid_params("column definition needs a 'name'")) } -pub fn get_alter_column_sql(id: Value, _params: &Value) -> Value { - respond( - id, - Err(PluginError::unsupported( - "libSQL/SQLite cannot alter an existing column's type or constraints", - )), - ) +fn col_type(column: &Value) -> Result { + col_field(column, &["data_type", "type"]) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| PluginError::invalid_params("column definition needs a 'data_type'")) } -pub fn get_create_index_sql(id: Value, params: &Value) -> Value { - respond(id, { - let table = req_str(params, "table"); - table.and_then(|table| { - let index = params - .get("index") - .ok_or_else(|| PluginError::invalid_params("missing 'index' definition"))?; - Ok(Value::String(build_create_index_sql(&table, index)?)) +fn col_bool(column: &Value, key: &str) -> bool { + col_field(column, &[key]) + .map(|v| match v { + Value::Bool(b) => *b, + Value::Number(n) => n.as_u64() == Some(1), + _ => false, }) - }) + .unwrap_or(false) } -pub fn get_create_foreign_key_sql(id: Value, _params: &Value) -> Value { - respond( - id, - Err(PluginError::unsupported( - "libSQL/SQLite cannot add a foreign key to an existing table; define it at CREATE TABLE time", - )), - ) +/// Columns default to nullable when the flag is absent (SQLite semantics). +fn col_nullable(column: &Value) -> bool { + col_field(column, &["is_nullable", "nullable"]) + .map(|v| match v { + Value::Bool(b) => *b, + Value::Number(n) => n.as_u64() == Some(1), + _ => true, + }) + .unwrap_or(true) } -pub fn drop_index(id: Value, params: &Value) -> Value { - respond(id, { - connect(params).and_then(|client| { - let index_name = req_str(params, "index_name")?; - client.execute(&format!("DROP INDEX IF EXISTS {}", quote(&index_name)), &[])?; - Ok(Value::Null) - }) - }) +/// The host sends `default_value` as a ready-to-embed SQL literal (the raw +/// text the user typed in the dialog, e.g. `'active'` or `CURRENT_TIMESTAMP`). +fn col_default(column: &Value) -> Option<&str> { + col_field(column, &["default_value", "column_default", "default"]) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) } -pub fn drop_foreign_key(id: Value, _params: &Value) -> Value { +// --------------------------------------------------------------------------- +// CREATE TABLE +// --------------------------------------------------------------------------- + +pub fn get_create_table_sql(id: Value, params: &Value) -> Value { respond( id, - Err(PluginError::unsupported( - "libSQL/SQLite cannot drop a foreign key constraint from an existing table", - )), + (|| { + let table_name = req_str(params, "table_name").or_else(|_| req_str(params, "table"))?; + let columns = params + .get("columns") + .and_then(Value::as_array) + .ok_or_else(|| PluginError::invalid_params("missing 'columns' definition"))?; + Ok(json!([build_create_table_sql(&table_name, columns)?])) + })(), ) } +/// Mirror of the host's built-in SQLite driver: a single PK column gets an +/// inline `PRIMARY KEY [AUTOINCREMENT]`, multiple PK columns become a +/// table-level `PRIMARY KEY (a, b)` constraint. +fn build_create_table_sql(table_name: &str, columns: &[Value]) -> Result { + let single_pk = columns.iter().filter(|c| col_bool(c, "is_pk")).count() == 1; + let mut defs = Vec::new(); + let mut pk_cols = Vec::new(); + for column in columns { + let name = col_name(column)?; + let data_type = col_type(column)?; + let is_pk = col_bool(column, "is_pk"); + let mut def = format!("{} {}", quote(&name), data_type); + if is_pk && single_pk { + def.push_str(" PRIMARY KEY"); + if col_bool(column, "is_auto_increment") { + def.push_str(" AUTOINCREMENT"); + } + } + if !col_nullable(column) && !(is_pk && single_pk) { + def.push_str(" NOT NULL"); + } + if let Some(default) = col_default(column) { + def.push_str(&format!(" DEFAULT {default}")); + } + defs.push(def); + if is_pk && !single_pk { + pk_cols.push(quote(&name)); + } + } + if !pk_cols.is_empty() { + defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", "))); + } + Ok(format!( + "CREATE TABLE {} (\n {}\n)", + quote(table_name), + defs.join(",\n ") + )) +} + // --------------------------------------------------------------------------- -// Pure SQL builders +// ADD COLUMN // --------------------------------------------------------------------------- -fn column_field<'a>(column: &'a Value, keys: &[&str]) -> Option<&'a Value> { - keys.iter().find_map(|k| column.get(*k)) -} - -/// Render a default value for inclusion in DDL. String values are treated as -/// literals (quoted); numbers/booleans pass through. -fn render_default(value: &Value) -> Option { - match value { - Value::Null => None, - Value::String(s) => Some(quote_literal(s)), - Value::Bool(b) => Some(if *b { "1".into() } else { "0".into() }), - Value::Number(n) => Some(n.to_string()), - _ => None, - } +pub fn get_add_column_sql(id: Value, params: &Value) -> Value { + respond( + id, + (|| { + let table = req_str(params, "table")?; + let column = params + .get("column") + .ok_or_else(|| PluginError::invalid_params("missing 'column' definition"))?; + Ok(json!([build_add_column_sql(&table, column)?])) + })(), + ) } fn build_add_column_sql(table: &str, column: &Value) -> Result { - let name = column - .get("name") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .ok_or_else(|| PluginError::invalid_params("column definition needs a 'name'"))?; - let data_type = column_field(column, &["data_type", "type"]) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .unwrap_or("TEXT"); - + let name = col_name(column)?; + let data_type = col_type(column)?; let mut sql = format!( "ALTER TABLE {} ADD COLUMN {} {}", quote(table), - quote(name), + quote(&name), data_type ); - - let default = column_field(column, &["column_default", "default"]).and_then(render_default); - if let Some(default) = &default { + let default = col_default(column); + if let Some(default) = default { sql.push_str(&format!(" DEFAULT {default}")); } - - let nullable = column_field(column, &["is_nullable", "nullable"]) - .and_then(Value::as_bool) - .unwrap_or(true); // SQLite only accepts NOT NULL on ADD COLUMN when a default is provided. - if !nullable && default.is_some() { + if !col_nullable(column) && default.is_some() { sql.push_str(" NOT NULL"); } - Ok(sql) } -fn build_create_index_sql(table: &str, index: &Value) -> Result { - let name = column_field(index, &["index_name", "name"]) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .ok_or_else(|| PluginError::invalid_params("index definition needs a name"))?; - - let columns: Vec = index - .get("columns") - .and_then(Value::as_array) - .map(|arr| { - arr.iter() - .filter_map(Value::as_str) - .filter(|s| !s.is_empty()) - .map(quote) - .collect() - }) - .unwrap_or_default(); +// --------------------------------------------------------------------------- +// ALTER COLUMN +// --------------------------------------------------------------------------- - if columns.is_empty() { - return Err(PluginError::invalid_params( - "index definition needs at least one column", +pub fn get_alter_column_sql(id: Value, params: &Value) -> Value { + respond( + id, + (|| { + let table = req_str(params, "table")?; + let old_column = params + .get("old_column") + .ok_or_else(|| PluginError::invalid_params("missing 'old_column' definition"))?; + let new_column = params + .get("new_column") + .ok_or_else(|| PluginError::invalid_params("missing 'new_column' definition"))?; + Ok(json!([build_alter_column_sql( + &table, old_column, new_column + )?])) + })(), + ) +} + +/// A rename uses vanilla `RENAME COLUMN` (works on every backend). Any other +/// change emits the libSQL `ALTER COLUMN ... TO ...` form, which replaces the +/// column's whole definition — the new `data_type` is mandatory for it. +fn build_alter_column_sql( + table: &str, + old_column: &Value, + new_column: &Value, +) -> Result { + let old_name = col_name(old_column)?; + let new_name = col_name(new_column)?; + if old_name != new_name { + return Ok(format!( + "ALTER TABLE {} RENAME COLUMN {} TO {}", + quote(table), + quote(&old_name), + quote(&new_name), )); } + let data_type = col_type(new_column)?; + let mut sql = format!( + "ALTER TABLE {} ALTER COLUMN {} TO {} {}", + quote(table), + quote(&old_name), + quote(&new_name), + data_type + ); + if let Some(default) = col_default(new_column) { + sql.push_str(&format!(" DEFAULT {default}")); + } + if !col_nullable(new_column) { + sql.push_str(" NOT NULL"); + } + Ok(sql) +} - let unique = index - .get("is_unique") - .and_then(Value::as_bool) - .unwrap_or(false); - Ok(format!( +// --------------------------------------------------------------------------- +// CREATE INDEX +// --------------------------------------------------------------------------- + +pub fn get_create_index_sql(id: Value, params: &Value) -> Value { + respond( + id, + (|| { + let table = req_str(params, "table")?; + let name = req_str(params, "index_name").or_else(|_| req_str(params, "name"))?; + let columns: Vec = params + .get("columns") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .filter(|s| !s.is_empty()) + .map(quote) + .collect() + }) + .unwrap_or_default(); + if columns.is_empty() { + return Err(PluginError::invalid_params( + "index definition needs at least one column", + )); + } + Ok(json!([build_create_index_sql( + &table, + &name, + &columns, + col_bool(params, "is_unique"), + )])) + })(), + ) +} + +fn build_create_index_sql(table: &str, name: &str, columns: &[String], unique: bool) -> String { + format!( "CREATE {}INDEX {} ON {} ({})", if unique { "UNIQUE " } else { "" }, quote(name), quote(table), columns.join(", "), - )) + ) +} + +// --------------------------------------------------------------------------- +// Foreign keys +// --------------------------------------------------------------------------- + +/// `ALTER TABLE ... ALTER COLUMN ... REFERENCES ...` adds a foreign key on +/// the libSQL fork. Unlike the other SQL builders, the host sends connection +/// params for this method (the RpcDriver passes them through), so the +/// column's declared type can be introspected. Both remote servers and local +/// files (embedded fork) understand the syntax. +pub fn get_create_foreign_key_sql(id: Value, params: &Value) -> Value { + respond(id, { + (|| { + let client = connect(params)?; + let table = req_str(params, "table")?; + let column = req_str(params, "column")?; + let ref_table = req_str(params, "ref_table")?; + let ref_column = req_str(params, "ref_column")?; + let on_delete = params + .get("on_delete") + .and_then(Value::as_str) + .map(str::to_string); + let on_update = params + .get("on_update") + .and_then(Value::as_str) + .map(str::to_string); + let col_type = column_type_for(&client, &table, &column)?; + Ok(json!([build_create_fk_sql( + &table, + &column, + &col_type, + &ref_table, + &ref_column, + on_delete.as_deref(), + on_update.as_deref(), + )])) + })() + }) +} + +/// Build a libSQL statement that adds a foreign key to an existing column: +/// the ALTER COLUMN form with the column's full definition plus a REFERENCES +/// clause. The constraint name is not used — SQLite foreign keys carry no +/// names of their own; the host's generated `fk___` name is +/// cosmetic and the plugin re-derives names from `PRAGMA foreign_key_list`. +fn build_create_fk_sql( + table: &str, + column: &str, + col_type: &str, + ref_table: &str, + ref_column: &str, + on_delete: Option<&str>, + on_update: Option<&str>, +) -> String { + let mut sql = format!( + "ALTER TABLE {} ALTER COLUMN {} TO {} {} REFERENCES {} ({})", + quote(table), + quote(column), + quote(column), + col_type, + quote(ref_table), + quote(ref_column), + ); + if let Some(action) = on_delete { + sql.push_str(&format!(" ON DELETE {}", action)); + } + if let Some(action) = on_update { + sql.push_str(&format!(" ON UPDATE {}", action)); + } + sql +} + +pub fn drop_index(id: Value, params: &Value) -> Value { + respond(id, { + connect(params).and_then(|client| { + let index_name = req_str(params, "index_name")?; + client.execute(&format!("DROP INDEX IF EXISTS {}", quote(&index_name)), &[])?; + Ok(Value::Null) + }) + }) +} + +/// Dropping a foreign key is a schema rewrite on the libSQL fork: the same +/// ALTER COLUMN form without the REFERENCES clause. This mutation receives +/// connection params, so the column's type can be introspected. Works on +/// remote servers and local files alike. +pub fn drop_foreign_key(id: Value, params: &Value) -> Value { + respond( + id, + (|| { + let client = connect(params)?; + let table = req_str(params, "table")?; + let fk_name = req_str(params, "fk_name")?; + let column = foreign_key_column_for(&client, &table, &fk_name)?; + let col_type = column_type_for(&client, &table, &column)?; + client.execute(&build_fk_drop_sql(&table, &column, &col_type), &[])?; + Ok(Value::Null) + })(), + ) +} + +/// Build a libSQL statement that drops an existing column's foreign key: +/// the ALTER COLUMN form without the REFERENCES clause. +fn build_fk_drop_sql(table: &str, column: &str, col_type: &str) -> String { + format!( + "ALTER TABLE {} ALTER COLUMN {} TO {} {}", + quote(table), + quote(column), + quote(column), + col_type + ) +} + +/// Look up a column's declared type via `PRAGMA table_info`, defaulting to +/// TEXT when the type is blank (SQLite's bare-column shorthand). +fn column_type_for(client: &Client, table: &str, column: &str) -> Result { + let r = client.query(&format!("PRAGMA table_info({})", quote(table)), &[])?; + for row in &r.rows { + // table_info columns: cid, name, type, notnull, dflt_value, pk + if cell_str(row, 1).as_deref() == Some(column) { + let raw = cell_str(row, 2).unwrap_or_default(); + return Ok(if raw.is_empty() { + "TEXT".to_string() + } else { + raw + }); + } + } + Err(PluginError::invalid_params(format!( + "column '{column}' not found in table '{table}'" + ))) +} + +/// Map a host-side FK name (plugin-generated `fk_
__`) back +/// to the SQLite column it constrains. SQLite FKs carry no names of their own, +/// so the mapping re-derives the same naming scheme from +/// `PRAGMA foreign_key_list`. +fn foreign_key_column_for( + client: &Client, + table: &str, + fk_name: &str, +) -> Result { + let r = client.query(&format!("PRAGMA foreign_key_list({})", quote(table)), &[])?; + for row in &r.rows { + // foreign_key_list columns: id, seq, table, from, to, on_update, on_delete, match + let id = cell_i64(row, 0); + let from = cell_str(row, 3).unwrap_or_default(); + if format!("fk_{table}_{from}_{id}") == fk_name { + return Ok(from); + } + } + Err(PluginError::invalid_params(format!( + "foreign key '{fk_name}' not found on table '{table}'" + ))) } #[cfg(test)] mod tests { use super::*; + use crate::client::Client; + use crate::models::ConnectionParams; use serde_json::json; + // ----------------------------------------------------------------------- + // CREATE TABLE builder + // ----------------------------------------------------------------------- + + #[test] + fn create_table_single_pk_autoincrement() { + let cols = vec![ + json!({ "name": "id", "data_type": "INTEGER", "is_pk": true, "is_auto_increment": true, "is_nullable": false }), + json!({ "name": "title", "data_type": "TEXT", "is_nullable": false, "default_value": "'untitled'" }), + json!({ "name": "body", "data_type": "TEXT" }), + ]; + assert_eq!( + build_create_table_sql("blog", &cols).unwrap(), + "CREATE TABLE \"blog\" (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT,\n \"title\" TEXT NOT NULL DEFAULT 'untitled',\n \"body\" TEXT\n)" + ); + } + + #[test] + fn create_table_composite_pk() { + let cols = vec![ + json!({ "name": "a", "data_type": "INTEGER", "is_pk": true }), + json!({ "name": "b", "data_type": "INTEGER", "is_pk": true }), + ]; + assert_eq!( + build_create_table_sql("t", &cols).unwrap(), + "CREATE TABLE \"t\" (\n \"a\" INTEGER,\n \"b\" INTEGER,\n PRIMARY KEY (\"a\", \"b\")\n)" + ); + } + + #[test] + fn create_table_requires_name_and_type() { + assert!(build_create_table_sql("t", &[json!({ "name": "x" })]).is_err()); + assert!(build_create_table_sql("t", &[json!({ "data_type": "TEXT" })]).is_err()); + } + + #[test] + fn create_table_quotes_identifiers() { + let cols = vec![json!({ "name": "we\"ird", "data_type": "TEXT" })]; + assert_eq!( + build_create_table_sql("my\"t", &cols).unwrap(), + "CREATE TABLE \"my\"\"t\" (\n \"we\"\"ird\" TEXT\n)" + ); + } + + #[test] + fn create_table_sql_handler_uses_host_payload_shape() { + let resp = get_create_table_sql( + json!(1), + &json!({ + "table_name": "blog", + "columns": [ + { "name": "id", "data_type": "INTEGER", "is_pk": true, "is_auto_increment": true, "is_nullable": false, "default_value": null }, + { "name": "name", "data_type": "TEXT", "is_nullable": true, "is_pk": false, "is_auto_increment": false, "default_value": null } + ], + "schema": null + }), + ); + assert_eq!( + resp["result"], + json!(["CREATE TABLE \"blog\" (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT,\n \"name\" TEXT\n)"]) + ); + } + + // ----------------------------------------------------------------------- + // ADD COLUMN builder + // ----------------------------------------------------------------------- + #[test] fn add_column_basic() { let col = json!({ "name": "age", "data_type": "INTEGER" }); @@ -199,7 +523,7 @@ mod tests { fn add_column_with_default_and_not_null() { let col = json!({ "name": "status", "data_type": "TEXT", - "is_nullable": false, "column_default": "active" + "is_nullable": false, "default_value": "'active'" }); assert_eq!( build_add_column_sql("t", &col).unwrap(), @@ -207,9 +531,19 @@ mod tests { ); } + #[test] + fn add_column_default_is_verbatim_literal() { + let col = + json!({ "name": "ts", "data_type": "TEXT", "default_value": "CURRENT_TIMESTAMP" }); + assert_eq!( + build_add_column_sql("t", &col).unwrap(), + "ALTER TABLE \"t\" ADD COLUMN \"ts\" TEXT DEFAULT CURRENT_TIMESTAMP" + ); + } + #[test] fn add_column_not_null_without_default_drops_not_null() { - let col = json!({ "name": "x", "type": "INTEGER", "is_nullable": false }); + let col = json!({ "name": "x", "data_type": "INTEGER", "is_nullable": false }); // No default => NOT NULL is omitted (SQLite would otherwise reject it). assert_eq!( build_add_column_sql("t", &col).unwrap(), @@ -222,26 +556,293 @@ mod tests { assert!(build_add_column_sql("t", &json!({ "data_type": "TEXT" })).is_err()); } + // ----------------------------------------------------------------------- + // ALTER COLUMN builder (libSQL fork extension) + // ----------------------------------------------------------------------- + + #[test] + fn alter_column_rename_uses_vanilla_sqlite() { + let old_col = json!({ "name": "a", "data_type": "TEXT" }); + let new_col = json!({ "name": "b", "data_type": "TEXT" }); + assert_eq!( + build_alter_column_sql("t", &old_col, &new_col).unwrap(), + "ALTER TABLE \"t\" RENAME COLUMN \"a\" TO \"b\"" + ); + } + + #[test] + fn alter_column_type_change() { + let old_col = json!({ "name": "v", "data_type": "TEXT" }); + let new_col = json!({ "name": "v", "data_type": "INTEGER" }); + assert_eq!( + build_alter_column_sql("t", &old_col, &new_col).unwrap(), + "ALTER TABLE \"t\" ALTER COLUMN \"v\" TO \"v\" INTEGER" + ); + } + + #[test] + fn alter_column_with_default_and_not_null() { + let old_col = json!({ "name": "v", "data_type": "TEXT" }); + let new_col = json!({ + "name": "v", "data_type": "TEXT", + "default_value": "'hai'", "is_nullable": false + }); + assert_eq!( + build_alter_column_sql("t", &old_col, &new_col).unwrap(), + "ALTER TABLE \"t\" ALTER COLUMN \"v\" TO \"v\" TEXT DEFAULT 'hai' NOT NULL" + ); + } + + #[test] + fn alter_column_requires_new_type() { + let old_col = json!({ "name": "v", "data_type": "TEXT" }); + assert!(build_alter_column_sql("t", &old_col, &json!({ "name": "v" })).is_err()); + } + + #[test] + fn alter_column_quotes_identifiers() { + let old_col = json!({ "name": "a\"b", "data_type": "TEXT" }); + let new_col = json!({ "name": "c", "data_type": "TEXT" }); + assert_eq!( + build_alter_column_sql("weird\"t", &old_col, &new_col).unwrap(), + "ALTER TABLE \"weird\"\"t\" RENAME COLUMN \"a\"\"b\" TO \"c\"" + ); + } + + // ----------------------------------------------------------------------- + // CREATE INDEX builder + // ----------------------------------------------------------------------- + #[test] fn create_index_unique_multi_column() { - let idx = json!({ "index_name": "idx_a_b", "columns": ["a", "b"], "is_unique": true }); + let cols = vec![quote("a"), quote("b")]; assert_eq!( - build_create_index_sql("t", &idx).unwrap(), + build_create_index_sql("t", "idx_a_b", &cols, true), "CREATE UNIQUE INDEX \"idx_a_b\" ON \"t\" (\"a\", \"b\")" ); } #[test] fn create_index_plain() { - let idx = json!({ "name": "idx_email", "columns": ["email"] }); + let cols = vec![quote("email")]; assert_eq!( - build_create_index_sql("users", &idx).unwrap(), + build_create_index_sql("users", "idx_email", &cols, false), "CREATE INDEX \"idx_email\" ON \"users\" (\"email\")" ); } #[test] - fn create_index_requires_columns() { - assert!(build_create_index_sql("t", &json!({ "name": "i", "columns": [] })).is_err()); + fn create_index_handler_reads_host_payload_shape() { + let resp = get_create_index_sql( + json!(1), + &json!({ "table": "users", "index_name": "idx_email", "columns": ["email"], "is_unique": false, "schema": null }), + ); + assert_eq!( + resp["result"], + json!(["CREATE INDEX \"idx_email\" ON \"users\" (\"email\")"]) + ); + } + + // ----------------------------------------------------------------------- + // Foreign keys + // ----------------------------------------------------------------------- + + #[test] + fn create_foreign_key_builder_emits_libsql_alter_column() { + assert_eq!( + build_create_fk_sql( + "emails", + "user_id", + "INT", + "users", + "id", + None, + None, + ), + "ALTER TABLE \"emails\" ALTER COLUMN \"user_id\" TO \"user_id\" INT REFERENCES \"users\" (\"id\")" + ); + } + + #[test] + fn create_foreign_key_builder_appends_referential_actions() { + assert_eq!( + build_create_fk_sql( + "emails", + "user_id", + "INT", + "users", + "id", + Some("CASCADE"), + Some("SET NULL"), + ), + "ALTER TABLE \"emails\" ALTER COLUMN \"user_id\" TO \"user_id\" INT REFERENCES \"users\" (\"id\") ON DELETE CASCADE ON UPDATE SET NULL" + ); + } + + /// A temp-file database shared across connections (in-memory DBs are + /// per-connection, and the handlers open their own connection). + fn temp_file_client() -> (Client, std::path::PathBuf) { + let path = std::env::temp_dir().join(format!( + "libsql_plugin_test_{}_{}.db", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let cp = ConnectionParams { + database: Some(path.to_string_lossy().to_string()), + ..Default::default() + }; + let client = Client::connect(&cp).expect("temp-file connection"); + (client, path) + } + + #[test] + fn create_foreign_key_local_uses_the_fork() { + let (client, path) = temp_file_client(); + client + .execute("CREATE TABLE users(id INT PRIMARY KEY)", &[]) + .expect("create users"); + client + .execute("CREATE TABLE emails(user_id INT)", &[]) + .expect("create emails"); + let resp = get_create_foreign_key_sql( + json!(1), + &json!({ + "params": { "database": path }, + "table": "emails", + "fk_name": "fk_emails_user_id_0", + "column": "user_id", + "ref_table": "users", + "ref_column": "id", + "schema": null + }), + ); + assert_eq!( + resp["result"], + json!(["ALTER TABLE \"emails\" ALTER COLUMN \"user_id\" TO \"user_id\" INT REFERENCES \"users\" (\"id\")"]) + ); + } + + #[test] + fn drop_foreign_key_local_rewrites_the_schema() { + let (client, path) = temp_file_client(); + client + .execute("CREATE TABLE users(id INT PRIMARY KEY)", &[]) + .expect("create users"); + client + .execute("CREATE TABLE emails(user_id INT REFERENCES users(id))", &[]) + .expect("create emails"); + let resp = drop_foreign_key( + json!(1), + &json!({ + "params": { "database": path }, + "table": "emails", + "fk_name": "fk_emails_user_id_0" + }), + ); + assert!(resp.get("error").is_none(), "unexpected error: {resp}"); + let after = client + .query("PRAGMA foreign_key_list(emails)", &[]) + .expect("pragma"); + assert!(after.rows.is_empty(), "FK should be gone"); + } + + #[test] + fn local_alter_column_retypes_through_the_fork() { + // The whole point of the embedded fork: local files speak ALTER COLUMN. + let client = in_memory_client(); + client + .execute("CREATE TABLE t(v TEXT)", &[]) + .expect("create table"); + client + .execute("ALTER TABLE t ALTER COLUMN v TO v INTEGER", &[]) + .expect("alter column should work on local files"); + let r = client.query("PRAGMA table_info(t)", &[]).expect("pragma"); + assert_eq!(cell_str(&r.rows[0], 2), Some("INTEGER".to_string())); + } + + // ----------------------------------------------------------------------- + // Introspection against a real in-memory database + // ----------------------------------------------------------------------- + + fn in_memory_client() -> Client { + let cp = ConnectionParams { + database: Some(":memory:".into()), + ..Default::default() + }; + Client::connect(&cp).expect("in-memory connection") + } + + #[test] + fn column_type_for_reads_pragma() { + let client = in_memory_client(); + client + .execute("CREATE TABLE t(v TEXT, n INTEGER)", &[]) + .expect("create table"); + assert_eq!(column_type_for(&client, "t", "v").unwrap(), "TEXT"); + assert_eq!(column_type_for(&client, "t", "n").unwrap(), "INTEGER"); + } + + #[test] + fn column_type_for_defaults_blank_type_to_text() { + let client = in_memory_client(); + client + .execute("CREATE TABLE t(v)", &[]) + .expect("create table"); + assert_eq!(column_type_for(&client, "t", "v").unwrap(), "TEXT"); + } + + #[test] + fn column_type_for_missing_column_errors() { + let client = in_memory_client(); + client + .execute("CREATE TABLE t(v TEXT)", &[]) + .expect("create table"); + assert!(column_type_for(&client, "t", "nope").is_err()); + } + + #[test] + fn foreign_key_column_for_matches_constraint_name() { + let client = in_memory_client(); + client + .execute("CREATE TABLE users(id INT PRIMARY KEY)", &[]) + .expect("create users"); + client + .execute("CREATE TABLE emails(user_id INT REFERENCES users(id))", &[]) + .expect("create emails"); + assert_eq!( + foreign_key_column_for(&client, "emails", "fk_emails_user_id_0").unwrap(), + "user_id" + ); + } + + #[test] + fn foreign_key_column_for_unknown_errors() { + let client = in_memory_client(); + client + .execute("CREATE TABLE users(id INT PRIMARY KEY)", &[]) + .expect("create users"); + client + .execute("CREATE TABLE emails(user_id INT REFERENCES users(id))", &[]) + .expect("create emails"); + assert!(foreign_key_column_for(&client, "emails", "fk_emails_nope_0").is_err()); + } + + // ----------------------------------------------------------------------- + // Host-contract return shapes: SQL arrays, `table_name` key + // ----------------------------------------------------------------------- + + #[test] + fn add_column_sql_returns_array() { + let resp = get_add_column_sql( + json!(1), + &json!({ "table": "users", "column": { "name": "age", "data_type": "INTEGER" } }), + ); + assert_eq!( + resp["result"], + json!(["ALTER TABLE \"users\" ADD COLUMN \"age\" INTEGER"]) + ); } } diff --git a/src/handlers/metadata.rs b/src/handlers/metadata.rs index d147331..d4f8898 100644 --- a/src/handlers/metadata.rs +++ b/src/handlers/metadata.rs @@ -29,7 +29,6 @@ fn columns_for(client: &Client, table: &str) -> Result, PluginError> let name = cell_str(row, 1).unwrap_or_default(); let raw_type = cell_str(row, 2).unwrap_or_default(); let not_null = cell_i64(row, 3) != 0; - let default = cell(row, 4); let pk = cell_i64(row, 5) != 0; // INTEGER PRIMARY KEY is a rowid alias and behaves as auto-increment. let auto_increment = pk && raw_type.to_ascii_uppercase().contains("INT"); @@ -42,11 +41,10 @@ fn columns_for(client: &Client, table: &str) -> Result, PluginError> columns.push(json!({ "name": name, "data_type": data_type, + "is_pk": pk, "is_nullable": !not_null, - "column_default": default, - "is_primary_key": pk, "is_auto_increment": auto_increment, - "comment": Value::Null, + "default_value": cell_str(row, 4), })); } Ok(columns) @@ -62,10 +60,10 @@ fn foreign_keys_for(client: &Client, table: &str) -> Result, PluginEr let from = cell_str(row, 3).unwrap_or_default(); let to = cell_str(row, 4).unwrap_or_default(); fks.push(json!({ - "constraint_name": format!("fk_{table}_{from}_{id}"), + "name": format!("fk_{table}_{from}_{id}"), "column_name": from, - "referenced_table": ref_table, - "referenced_column": to, + "ref_table": ref_table, + "ref_column": to, "on_update": cell(row, 5), "on_delete": cell(row, 6), })); @@ -104,6 +102,51 @@ fn indexes_for(client: &Client, table: &str) -> Result, PluginError> Ok(indexes) } +fn triggers_for(client: &Client) -> Result, PluginError> { + let r = client.query( + "SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'trigger' ORDER BY name", + &[], + )?; + Ok(r.rows + .iter() + .filter_map(|row| { + let name = cell_str(row, 0)?; + let sql = cell_str(row, 2).unwrap_or_default(); + let (timing, event) = trigger_timing_event(&sql); + Some(json!({ + "name": name, + "table_name": cell_str(row, 1).unwrap_or_default(), + "event": event, + "timing": timing, + "definition": cell_str(row, 2), + })) + }) + .collect()) +} + +fn trigger_timing_event(sql: &str) -> (String, String) { + let upper = sql.to_uppercase(); + let timing = if upper.contains("INSTEAD OF") { + "INSTEAD OF" + } else if upper.contains("BEFORE") { + "BEFORE" + } else if upper.contains("AFTER") { + "AFTER" + } else { + "" + }; + let event = if upper.contains("INSERT") { + "INSERT" + } else if upper.contains("UPDATE") { + "UPDATE" + } else if upper.contains("DELETE") { + "DELETE" + } else { + "" + }; + (timing.to_string(), event.to_string()) +} + fn list_views(client: &Client) -> Result, PluginError> { let r = client.query( "SELECT name FROM sqlite_master WHERE type = 'view' ORDER BY name", @@ -174,6 +217,14 @@ pub fn get_views(id: Value, params: &Value) -> Value { respond(id, connect(params).and_then(|c| Ok(json!(list_views(&c)?)))) } +pub fn get_triggers(id: Value, params: &Value) -> Value { + // SQLite/libSQL has a single schema; the host's `schema` param is ignored. + respond( + id, + connect(params).and_then(|c| Ok(json!(triggers_for(&c)?))), + ) +} + pub fn get_view_definition(id: Value, params: &Value) -> Value { respond(id, { connect(params).and_then(|c| { @@ -313,3 +364,112 @@ fn batch_impl( } Ok(Value::Object(out)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::Client; + use crate::models::ConnectionParams; + + fn in_memory_client() -> Client { + let cp = ConnectionParams { + database: Some(":memory:".into()), + ..Default::default() + }; + Client::connect(&cp).expect("in-memory connection") + } + + #[test] + fn columns_use_host_contract_keys() { + let client = in_memory_client(); + client + .execute( + "CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT NOT NULL DEFAULT 'anon')", + &[], + ) + .expect("create users"); + + let cols = columns_for(&client, "users").expect("columns"); + assert_eq!(cols.len(), 2); + + let id = &cols[0]; + assert_eq!(id["name"], "id"); + assert_eq!(id["data_type"], "INTEGER"); + assert_eq!(id["is_pk"], true); + assert_eq!(id["is_nullable"], true); + assert_eq!(id["is_auto_increment"], true); + assert!(id.get("default_value").unwrap().is_null()); + + let name = &cols[1]; + assert_eq!(name["is_pk"], false); + assert_eq!(name["is_nullable"], false); + assert_eq!(name["is_auto_increment"], false); + assert_eq!(name["default_value"], "'anon'"); + } + + #[test] + fn foreign_keys_use_host_contract_keys() { + let client = in_memory_client(); + client + .execute("CREATE TABLE users(id INT PRIMARY KEY)", &[]) + .expect("create users"); + client + .execute( + "CREATE TABLE emails(user_id INT REFERENCES users(id) ON DELETE CASCADE)", + &[], + ) + .expect("create emails"); + + let fks = foreign_keys_for(&client, "emails").expect("foreign keys"); + assert_eq!(fks.len(), 1); + let fk = &fks[0]; + assert_eq!(fk["name"], "fk_emails_user_id_0"); + assert_eq!(fk["column_name"], "user_id"); + assert_eq!(fk["ref_table"], "users"); + assert_eq!(fk["ref_column"], "id"); + assert_eq!(fk["on_delete"], "CASCADE"); + assert_eq!(fk["on_update"], "NO ACTION"); + } + + #[test] + fn triggers_use_host_contract_keys() { + let client = in_memory_client(); + client + .execute("CREATE TABLE t(id INT)", &[]) + .expect("create t"); + client + .execute( + "CREATE TRIGGER trg_after_ins AFTER INSERT ON t BEGIN UPDATE t SET id = 1; END", + &[], + ) + .expect("create trigger"); + + let triggers = triggers_for(&client).expect("triggers"); + assert_eq!(triggers.len(), 1); + let trg = &triggers[0]; + assert_eq!(trg["name"], "trg_after_ins"); + assert_eq!(trg["table_name"], "t"); + assert_eq!(trg["timing"], "AFTER"); + assert_eq!(trg["event"], "INSERT"); + assert!(trg["definition"] + .as_str() + .unwrap() + .starts_with("CREATE TRIGGER")); + } + + #[test] + fn trigger_timing_and_event_parse_heuristics() { + assert_eq!( + trigger_timing_event("CREATE TRIGGER x BEFORE UPDATE ON t BEGIN END"), + ("BEFORE".into(), "UPDATE".into()) + ); + assert_eq!( + trigger_timing_event("CREATE TRIGGER x INSTEAD OF DELETE ON v BEGIN END"), + ("INSTEAD OF".into(), "DELETE".into()) + ); + assert_eq!( + trigger_timing_event("CREATE TRIGGER x AFTER INSERT ON t BEGIN END"), + ("AFTER".into(), "INSERT".into()) + ); + } +} diff --git a/src/handlers/query.rs b/src/handlers/query.rs index 787fdf4..eddf70a 100644 --- a/src/handlers/query.rs +++ b/src/handlers/query.rs @@ -4,7 +4,7 @@ use std::time::Instant; use serde_json::{json, Value}; -use crate::client::{Client, QueryResult}; +use crate::client::QueryResult; use crate::error::PluginError; use crate::handlers::{connect, req_str, respond}; use crate::utils::pagination::offset_for; @@ -37,27 +37,46 @@ pub fn execute_query(id: Value, params: &Value) -> Value { fn execute_query_impl(params: &Value) -> Result { let client = connect(params)?; let query = req_str(params, "query")?; - let page = params.get("page").and_then(Value::as_u64); - let page_size = params.get("page_size").and_then(Value::as_u64); + let page = params.get("page").and_then(Value::as_u64).unwrap_or(1); + let limit = params + .get("limit") + .or_else(|| params.get("page_size")) + .and_then(Value::as_u64) + .filter(|l| *l > 0); let started = Instant::now(); let trimmed = strip_trailing_semicolons(&query); if returns_rows(&query) { - match (page_size, is_wrappable(&query)) { - (Some(size), true) if size > 0 => { - let offset = offset_for(page.unwrap_or(1), size); - let paged = - format!("SELECT * FROM ({trimmed}) AS _tab_page LIMIT {size} OFFSET {offset}"); - let result = client.query(&paged, &[])?; - let total = count_rows(&client, trimmed).unwrap_or(result.rows.len() as u64); - Ok(build_payload(result, total, started)) - } - _ => { - let result = client.query(trimmed, &[])?; - let total = result.rows.len() as u64; - Ok(build_payload(result, total, started)) + if let Some(size) = limit.filter(|_| is_wrappable(&query)) { + // Fetch one row past the page: the host contract derives `has_more` + // from the extra row (mirrors the built-in drivers' LIMIT +1 trick). + let offset = offset_for(page, size); + let paged = format!( + "SELECT * FROM ({trimmed}) AS _tab_page LIMIT {} OFFSET {offset}", + size + 1 + ); + let mut result = client.query(&paged, &[])?; + let has_more = result.rows.len() > size as usize; + if has_more { + result.rows.truncate(size as usize); } + let pagination = json!({ + "page": page, + "page_size": size, + "total_rows": Value::Null, + "has_more": has_more, + }); + Ok(build_payload( + result, + 0, + has_more, + started, + Some(pagination), + )) + } else { + let result = client.query(trimmed, &[])?; + Ok(build_payload(result, 0, false, started, None)) } } else { // DML/DDL: no result set, report the affected-row count. @@ -65,7 +84,9 @@ fn execute_query_impl(params: &Value) -> Result { Ok(json!({ "columns": [], "rows": [], - "total_count": affected, + "affected_rows": affected, + "truncated": false, + "pagination": Value::Null, "execution_time_ms": started.elapsed().as_millis() as u64, })) } @@ -78,27 +99,26 @@ pub fn explain_query(id: Value, params: &Value) -> Value { let started = Instant::now(); let sql = format!("EXPLAIN QUERY PLAN {}", strip_trailing_semicolons(&query)); let result = client.query(&sql, &[])?; - let total = result.rows.len() as u64; - Ok(build_payload(result, total, started)) + Ok(build_payload(result, 0, false, started, None)) }) }) } -fn count_rows(client: &Client, inner_sql: &str) -> Option { - let sql = format!("SELECT COUNT(*) FROM ({inner_sql}) AS _tab_count"); - let result = client.query(&sql, &[]).ok()?; - result - .rows - .first() - .and_then(|row| row.first()) - .and_then(Value::as_u64) -} - -fn build_payload(result: QueryResult, total_count: u64, started: Instant) -> Value { +/// Serialise a result set into the host's `QueryResult` contract +/// (`affected_rows`/`truncated`/`pagination` are required fields). +fn build_payload( + result: QueryResult, + affected_rows: u64, + truncated: bool, + started: Instant, + pagination: Option, +) -> Value { json!({ "columns": result.columns, "rows": result.rows, - "total_count": total_count, + "affected_rows": affected_rows, + "truncated": truncated, + "pagination": pagination, "execution_time_ms": started.elapsed().as_millis() as u64, }) } diff --git a/src/models.rs b/src/models.rs index c7e4f83..25bf7dd 100644 --- a/src/models.rs +++ b/src/models.rs @@ -4,7 +4,9 @@ //! connection form. All fields are optional because libSQL is dual-mode: a //! local connection only fills `database` (a file path), while a remote Turso //! connection fills `host`/`database` with a URL and `password` with the auth -//! token. +//! token. Drivers with the `connection_uri` capability receive the raw URI +//! verbatim in `connection_uri`; when present it is authoritative over the +//! decomposed fields. use serde_json::Value; @@ -17,6 +19,7 @@ pub struct ConnectionParams { pub username: Option, pub password: Option, pub ssl_mode: Option, + pub connection_uri: Option, } impl ConnectionParams { @@ -45,6 +48,7 @@ impl ConnectionParams { username: get_str("username"), password: get_str("password"), ssl_mode: get_str("ssl_mode"), + connection_uri: get_str("connection_uri"), } } } diff --git a/src/rpc.rs b/src/rpc.rs index d9ff25d..9b40a2a 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -31,6 +31,7 @@ pub fn handle_line(line: &str) -> Value { "get_foreign_keys" => handlers::metadata::get_foreign_keys(id, ¶ms), "get_indexes" => handlers::metadata::get_indexes(id, ¶ms), "get_views" => handlers::metadata::get_views(id, ¶ms), + "get_triggers" => handlers::metadata::get_triggers(id, ¶ms), "get_view_definition" => handlers::metadata::get_view_definition(id, ¶ms), "get_view_columns" => handlers::metadata::get_view_columns(id, ¶ms), "get_routines" => handlers::metadata::get_routines(id, ¶ms), @@ -116,14 +117,77 @@ mod tests { } #[test] - fn unsupported_ddl_reports_clear_error() { + fn create_foreign_key_local_works_through_the_fork() { + // Local files run the embedded libSQL fork, so ALTER COLUMN works. + let db = format!( + "{}/libsql_plugin_rpc_test_{}.db", + std::env::temp_dir().display(), + std::process::id() + ); + let mk = |sql: &str| { + let request = json!({ + "jsonrpc": "2.0", + "method": "execute_query", + "params": { "params": { "database": db }, "query": sql, "limit": null, "page": 1, "schema": null }, + "id": 1, + }); + let resp = handle_line(&request.to_string()); + assert!(resp.get("error").is_none(), "setup failed: {resp}"); + }; + mk("CREATE TABLE users(id INT PRIMARY KEY)"); + mk("CREATE TABLE emails(user_id INT)"); + + let request = json!({ + "jsonrpc": "2.0", + "method": "get_create_foreign_key_sql", + "params": { + "params": { "database": db }, + "table": "emails", + "fk_name": "fk_emails_user_id_0", + "column": "user_id", + "ref_table": "users", + "ref_column": "id", + "schema": null + }, + "id": 1, + }); + let resp = handle_line(&request.to_string()); + assert_eq!( + resp["result"], + json!(["ALTER TABLE \"emails\" ALTER COLUMN \"user_id\" TO \"user_id\" INT REFERENCES \"users\" (\"id\")"]) + ); + } + + #[test] + fn alter_column_sql_builds_without_connection_params() { + // The host calls the SQL builders without any connection params. let resp = handle_line( - r#"{"jsonrpc":"2.0","method":"get_create_foreign_key_sql","params":{"params":{}},"id":1}"#, + r#"{"jsonrpc":"2.0","method":"get_alter_column_sql","params":{"table":"t","old_column":{"name":"v","data_type":"TEXT"},"new_column":{"name":"v","data_type":"INTEGER"},"schema":null},"id":1}"#, ); - assert_eq!(resp["error"]["code"], -32601); - assert!(resp["error"]["message"] - .as_str() - .unwrap() - .contains("foreign key")); + assert_eq!( + resp["result"], + json!(["ALTER TABLE \"t\" ALTER COLUMN \"v\" TO \"v\" INTEGER"]) + ); + } + + #[test] + fn create_table_sql_builds_from_host_payload() { + let resp = handle_line( + r#"{"jsonrpc":"2.0","method":"get_create_table_sql","params":{"table_name":"blog","columns":[{"name":"id","data_type":"INTEGER","is_pk":true,"is_auto_increment":true,"is_nullable":false,"default_value":null}],"schema":null},"id":1}"#, + ); + assert_eq!( + resp["result"], + json!(["CREATE TABLE \"blog\" (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT\n)"]) + ); + } + + #[test] + fn execute_query_response_matches_host_query_result_contract() { + let resp = handle_line( + r#"{"jsonrpc":"2.0","method":"execute_query","params":{"params":{"database":":memory:"},"query":"SELECT 1 AS n","limit":null,"page":1,"schema":null},"id":1}"#, + ); + assert!(resp["result"]["affected_rows"].is_number()); + assert_eq!(resp["result"]["truncated"], false); + assert!(resp["result"]["pagination"].is_null()); } } diff --git a/src/utils/values.rs b/src/utils/values.rs index da06e16..1a68810 100644 --- a/src/utils/values.rs +++ b/src/utils/values.rs @@ -3,7 +3,7 @@ //! Hrana (the protocol Turso/sqld speak over HTTP) encodes every value as a //! tagged object, e.g. `{"type":"integer","value":"42"}`. Integers are sent as //! strings to survive 64-bit precision, blobs as base64. These helpers are pure -//! and fully unit-tested; the rusqlite (local) conversions live in `client.rs` +//! and fully unit-tested; the libsql (local) conversions live in `client.rs` //! because they depend on the SQLite value type. use base64::engine::general_purpose::STANDARD;