diff --git a/Cargo.lock b/Cargo.lock index 6979e2c3..b0211ae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -531,6 +531,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -715,6 +727,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -946,6 +967,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-sys" version = "1.1.25" @@ -1316,7 +1348,7 @@ dependencies = [ "base64", "byteorder", "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "hmac", "md-5", "memchr", @@ -1332,7 +1364,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" dependencies = [ "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "postgres-protocol", ] @@ -1577,6 +1609,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -1815,6 +1861,7 @@ dependencies = [ "predicates", "pretty_assertions", "regex", + "rusqlite", "serde", "serde_json", "sqlparser", @@ -2147,7 +2194,7 @@ dependencies = [ "async-trait", "byteorder", "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "futures-channel", "futures-util", "log", diff --git a/Cargo.toml b/Cargo.toml index e04e4976..755e5eec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ tokio-postgres = "0.7.16" tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "default"]} async-recursion = "1.1.1" bb8 = "0.9.1" +rusqlite = { version = "0.31", features = ["bundled"] } log = "0.4.29" [dev-dependencies] @@ -39,3 +40,4 @@ predicates = "3.1.4" tempfile = "3.27.0" test_utils = { path="test-utils" } pretty_assertions = "1.4.1" +rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/README.md b/README.md index f1002898..6248a838 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ SQLx-ts is a CLI application featuring compile-time checked queries without a DSL and generates types against SQLs to keep your code type-safe - **Compile time checked queries** - never ship a broken SQL query to production (and [sqlx-ts is not an ORM](https://github.com/JasonShin/sqlx-ts#sqlx-ts-is-not-an-orm)) -- **TypeScript type generations** - generates type definitions based on the raw SQLs and you can use them with any MySQL or PostgreSQL driver -- **Database Agnostic** - support for [PostgreSQL](http://postgresql.org/) and [MySQL](https://www.mysql.com/) (and more DB supports to come) +- **TypeScript type generations** - generates type definitions based on the raw SQLs and you can use them with any MySQL, PostgreSQL, or SQLite driver +- **Database Agnostic** - support for [PostgreSQL](http://postgresql.org/), [MySQL](https://www.mysql.com/), and [SQLite](https://www.sqlite.org/) - **TypeScript and JavaScript** - supports for both [TypeScript](https://jasonshin.github.io/sqlx-ts/reference-guide/4.typescript-types-generation.html) and [JavaScript](https://github.com/JasonShin/sqlx-ts#using-sqlx-ts-in-vanilla-javascript)
diff --git a/book/docs/connect/README.md b/book/docs/connect/README.md index 41768914..32de2392 100644 --- a/book/docs/connect/README.md +++ b/book/docs/connect/README.md @@ -46,6 +46,14 @@ $ sqlx-ts --db-type postgres --db-url postgres://user:pass@localhost:5432 $ sqlx-ts --db-type mysql --db-url mysql://user:pass@localhost:3306/mydb ``` +#### SQLite + +For SQLite, you only need to provide the database file path. No host, port, or user credentials are required: + +```bash +$ sqlx-ts --db-type sqlite --db-name ./mydb.sqlite +``` + **Note:** When `--db-url` is provided, it takes precedence over individual connection parameters (`--db-host`, `--db-port`, `--db-user`, `--db-pass`, `--db-name`). Run the following command for more details: diff --git a/book/docs/connect/config-file.md b/book/docs/connect/config-file.md index a0280fda..15efa981 100644 --- a/book/docs/connect/config-file.md +++ b/book/docs/connect/config-file.md @@ -66,6 +66,22 @@ Alternatively, you can use `DB_URL` to specify the connection string directly: } ``` +For SQLite, only `DB_TYPE` and `DB_NAME` (the file path) are required: + +```json +{ + "generate_types": { + "enabled": true + }, + "connections": { + "default": { + "DB_TYPE": "sqlite", + "DB_NAME": "./mydb.sqlite" + } + } +} +``` + ## Configuration options ### connections (required) @@ -92,7 +108,7 @@ const postgresSQL = sql` Supported fields of each connection include - `DB_URL`: Database connection URL (e.g. `postgres://user:pass@host:port/dbname` or `mysql://user:pass@host:port/dbname`). If provided, this overrides individual connection parameters (`DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`) -- `DB_TYPE`: type of database connection (mysql | postgres) +- `DB_TYPE`: type of database connection (mysql | postgres | sqlite) - `DB_USER`: database user name - `DB_PASS`: database password - `DB_HOST`: database host (e.g. 127.0.0.1) diff --git a/book/docs/connect/environment-variables.md b/book/docs/connect/environment-variables.md index d57574ad..08210770 100644 --- a/book/docs/connect/environment-variables.md +++ b/book/docs/connect/environment-variables.md @@ -7,7 +7,7 @@ | DB_HOST | Primary DB host | | DB_PASS | Primary DB password | | DB_PORT | Primary DB port number | -| DB_TYPE | Type of primary database to connect [default: postgres] [possible values: postgres, mysql] | +| DB_TYPE | Type of primary database to connect [default: postgres] [possible values: postgres, mysql, sqlite] | | DB_USER | Primary DB user name | | DB_NAME | Primary DB name | | PG_SEARCH_PATH | PostgreSQL schema search path (default is "$user,public") [https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH](https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH) | @@ -47,3 +47,14 @@ sqlx-ts **Note:** When `DB_URL` is set, it takes precedence over individual connection parameters (`DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`). +### SQLite + +For SQLite, only `DB_TYPE` and `DB_NAME` (file path) are required: + +```bash +export DB_TYPE=sqlite +export DB_NAME=./mydb.sqlite + +sqlx-ts +``` + diff --git a/playpen/db/sqlite_migration.sql b/playpen/db/sqlite_migration.sql new file mode 100644 index 00000000..827ae379 --- /dev/null +++ b/playpen/db/sqlite_migration.sql @@ -0,0 +1,124 @@ +-- SQLite Migration +-- This migration creates the same tables as the PostgreSQL and MySQL migrations +-- but using SQLite-compatible syntax. + +-- Factions Table +CREATE TABLE IF NOT EXISTS factions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT +); + +-- Races Table +CREATE TABLE IF NOT EXISTS races ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + faction_id INTEGER REFERENCES factions(id) ON DELETE CASCADE +); + +-- Classes Table +CREATE TABLE IF NOT EXISTS classes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + specialization TEXT +); + +-- Characters Table +CREATE TABLE IF NOT EXISTS characters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + race_id INTEGER REFERENCES races(id), + class_id INTEGER REFERENCES classes(id), + level INTEGER DEFAULT 1, + experience INTEGER DEFAULT 0, + gold REAL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Guilds Table +CREATE TABLE IF NOT EXISTS guilds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Guild Members Table +CREATE TABLE IF NOT EXISTS guild_members ( + guild_id INTEGER REFERENCES guilds(id) ON DELETE CASCADE, + character_id INTEGER REFERENCES characters(id) ON DELETE CASCADE, + rank TEXT, + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (guild_id, character_id) +); + +-- Inventory Table +CREATE TABLE IF NOT EXISTS inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + character_id INTEGER REFERENCES characters(id) ON DELETE CASCADE, + quantity INTEGER DEFAULT 1 +); + +-- Items Table +CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + rarity TEXT, + flavor_text TEXT, + inventory_id INTEGER REFERENCES inventory(id) ON DELETE CASCADE +); + +-- Quests Table +CREATE TABLE IF NOT EXISTS quests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + rewards TEXT, + completed BOOLEAN DEFAULT 0, + required_level INTEGER DEFAULT 1 +); + +-- Character Quests Table +CREATE TABLE IF NOT EXISTS character_quests ( + character_id INTEGER REFERENCES characters(id) ON DELETE CASCADE, + quest_id INTEGER REFERENCES quests(id) ON DELETE CASCADE, + status TEXT DEFAULT 'In Progress', + PRIMARY KEY (character_id, quest_id) +); + +-- Random types table for testing SQLite type mappings +CREATE TABLE IF NOT EXISTS random ( + int1 INTEGER, + real1 REAL, + text1 TEXT, + blob1 BLOB, + numeric1 NUMERIC, + bool1 BOOLEAN, + date1 DATE, + datetime1 DATETIME, + float1 FLOAT, + double1 DOUBLE, + varchar1 VARCHAR(100), + char1 CHAR(10), + json1 JSON +); + +--- SEED DATA + +INSERT INTO factions (name, description) VALUES +('alliance', 'The noble and righteous faction'), +('horde', 'The fierce and battle-hardened faction'); + +INSERT INTO races (name, faction_id) VALUES +('human', 1), +('night elf', 1), +('dwarf', 1), +('gnome', 1), +('orc', 2), +('troll', 2), +('tauren', 2), +('undead', 2); + +INSERT INTO classes (name, specialization) VALUES +('warrior', '{"role": "tank", "weapon": "sword", "abilities": ["charge", "slam", "shield block"]}'), +('hunter', '{"role": "ranged", "weapon": "bow", "abilities": ["aimed shot", "multi-shot", "trap"]}'); diff --git a/src/common/config.rs b/src/common/config.rs index 02adc67f..0bbad139 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -234,7 +234,9 @@ impl Config { .or_else(|| default_config.map(|x| x.db_host.clone())) }; - let db_host = match (db_url.is_some(), db_host_chain()) { + let is_sqlite = matches!(db_type, DatabaseType::Sqlite); + + let db_host = match (db_url.is_some() || is_sqlite, db_host_chain()) { (true, Some(v)) => v, (true, None) => String::new(), (false, Some(v)) => v, @@ -254,7 +256,7 @@ impl Config { .or_else(|| default_config.map(|x| x.db_port)) }; - let db_port = match (db_url.is_some(), db_port_chain()) { + let db_port = match (db_url.is_some() || is_sqlite, db_port_chain()) { (true, Some(v)) => v, (true, None) => 0, (false, Some(v)) => v, @@ -275,7 +277,7 @@ impl Config { .or_else(|| default_config.map(|x| x.db_user.clone())) }; - let db_user = match (db_url.is_some(), db_user_chain()) { + let db_user = match (db_url.is_some() || is_sqlite, db_user_chain()) { (true, Some(v)) => v, (true, None) => String::new(), (false, Some(v)) => v, @@ -381,6 +383,19 @@ impl Config { .to_string() } + /// Returns the file path for a SQLite database connection. + /// If DB_URL is provided, it's used directly. Otherwise DB_NAME is used as the file path. + pub fn get_sqlite_path(&self, conn: &DbConnectionConfig) -> String { + if let Some(db_url) = &conn.db_url { + return db_url.to_owned(); + } + + conn + .db_name + .clone() + .unwrap_or_else(|| panic!("DB_NAME (file path) is required for SQLite connections")) + } + pub fn get_postgres_cred(&self, conn: &DbConnectionConfig) -> String { // If custom DB_URL is provided, use it directly if let Some(db_url) = &conn.db_url { diff --git a/src/common/dotenv.rs b/src/common/dotenv.rs index 17b65472..effcaea1 100644 --- a/src/common/dotenv.rs +++ b/src/common/dotenv.rs @@ -32,13 +32,11 @@ impl Dotenv { Dotenv { db_type: match Self::get_var("DB_TYPE") { None => None, - Some(val) => { - if val == "mysql" { - Some(DatabaseType::Mysql) - } else { - Some(DatabaseType::Postgres) - } - } + Some(val) => match val.as_str() { + "mysql" => Some(DatabaseType::Mysql), + "sqlite" => Some(DatabaseType::Sqlite), + _ => Some(DatabaseType::Postgres), + }, }, db_user: Self::get_var("DB_USER"), db_host: Self::get_var("DB_HOST"), diff --git a/src/common/lazy.rs b/src/common/lazy.rs index 75e561e6..3df1faa3 100644 --- a/src/common/lazy.rs +++ b/src/common/lazy.rs @@ -4,6 +4,7 @@ use crate::common::types::DatabaseType; use crate::core::connection::{DBConn, DBConnections}; use crate::core::mysql::pool::MySqlConnectionManager; use crate::core::postgres::pool::PostgresConnectionManager; +use crate::core::sqlite::pool::SqliteConnectionManager; use crate::ts_generator::information_schema::DBSchema; use clap::Parser; use std::sync::LazyLock; @@ -49,6 +50,20 @@ pub static DB_CONN_CACHE: LazyLock>>> = LazyLo DBConn::MySQLPooledConn(Mutex::new(pool)) }) }), + DatabaseType::Sqlite => task::block_in_place(|| { + Handle::current().block_on(async { + let sqlite_path = CONFIG.get_sqlite_path(connection_config); + let manager = SqliteConnectionManager::new(sqlite_path, connection.to_string()); + let pool = bb8::Pool::builder() + .max_size(connection_config.pool_size) + .connection_timeout(std::time::Duration::from_secs(connection_config.connection_timeout)) + .build(manager) + .await + .expect(&ERR_DB_CONNECTION_ISSUE); + + DBConn::SqliteConn(Mutex::new(pool)) + }) + }), DatabaseType::Postgres => task::block_in_place(|| { Handle::current().block_on(async { let postgres_cred = CONFIG.get_postgres_cred(connection_config); diff --git a/src/common/types.rs b/src/common/types.rs index 88a536b8..bc7f0268 100644 --- a/src/common/types.rs +++ b/src/common/types.rs @@ -18,6 +18,7 @@ pub enum FileExtension { pub enum DatabaseType { Postgres, Mysql, + Sqlite, } #[derive(ValueEnum, Debug, Clone, Serialize, Deserialize)] diff --git a/src/core/connection.rs b/src/core/connection.rs index b9fa51cc..01129926 100644 --- a/src/core/connection.rs +++ b/src/core/connection.rs @@ -3,6 +3,7 @@ use crate::common::types::DatabaseType; use crate::common::SQL; use crate::core::mysql::prepare as mysql_explain; use crate::core::postgres::prepare as postgres_explain; +use crate::core::sqlite::prepare as sqlite_explain; use crate::ts_generator::types::ts_query::TsQuery; use bb8::Pool; use std::collections::HashMap; @@ -11,14 +12,17 @@ use tokio::sync::Mutex; use super::mysql::pool::MySqlConnectionManager; use super::postgres::pool::PostgresConnectionManager; +use super::sqlite::pool::SqliteConnectionManager; use crate::common::errors::DB_CONN_FROM_LOCAL_CACHE_ERROR; use color_eyre::Result; use swc_common::errors::Handler; /// Enum to hold a specific database connection instance +#[allow(clippy::enum_variant_names)] pub enum DBConn { MySQLPooledConn(Mutex>), PostgresConn(Mutex>), + SqliteConn(Mutex>), } impl DBConn { @@ -31,6 +35,7 @@ impl DBConn { let (explain_failed, ts_query) = match &self { DBConn::MySQLPooledConn(_conn) => mysql_explain::prepare(self, sql, should_generate_types, handler).await?, DBConn::PostgresConn(_conn) => postgres_explain::prepare(self, sql, should_generate_types, handler).await?, + DBConn::SqliteConn(_conn) => sqlite_explain::prepare(self, sql, should_generate_types, handler).await?, }; Ok((explain_failed, ts_query)) @@ -41,6 +46,7 @@ impl DBConn { match self { DBConn::MySQLPooledConn(_) => DatabaseType::Mysql, DBConn::PostgresConn(_) => DatabaseType::Postgres, + DBConn::SqliteConn(_) => DatabaseType::Sqlite, } } } diff --git a/src/core/mod.rs b/src/core/mod.rs index bc280965..f1d630d6 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2,3 +2,4 @@ pub mod connection; pub mod execute; pub mod mysql; pub mod postgres; +pub mod sqlite; diff --git a/src/core/sqlite/mod.rs b/src/core/sqlite/mod.rs new file mode 100644 index 00000000..42fe7aa8 --- /dev/null +++ b/src/core/sqlite/mod.rs @@ -0,0 +1,2 @@ +pub mod pool; +pub mod prepare; diff --git a/src/core/sqlite/pool.rs b/src/core/sqlite/pool.rs new file mode 100644 index 00000000..24b4ab53 --- /dev/null +++ b/src/core/sqlite/pool.rs @@ -0,0 +1,82 @@ +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; +use tokio::task; + +/// A connection manager for SQLite that wraps rusqlite's synchronous Connection +/// behind an Arc> for thread-safe access with bb8 connection pooling. +#[derive(Clone, Debug)] +pub struct SqliteConnectionManager { + db_path: String, + connection_name: String, +} + +/// Wrapper around rusqlite::Connection to make it Send + Sync for bb8 +pub struct SqliteConnection { + pub conn: Arc>, +} + +// Safety: rusqlite::Connection is not Send by default, but we protect it with Mutex +// and only access it via spawn_blocking +unsafe impl Send for SqliteConnection {} +unsafe impl Sync for SqliteConnection {} + +impl SqliteConnectionManager { + pub fn new(db_path: String, connection_name: String) -> Self { + Self { + db_path, + connection_name, + } + } +} + +#[derive(Debug)] +pub struct SqlitePoolError(pub String); + +impl std::fmt::Display for SqlitePoolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "SQLite pool error: {}", self.0) + } +} + +impl std::error::Error for SqlitePoolError {} + +impl bb8::ManageConnection for SqliteConnectionManager { + type Connection = SqliteConnection; + type Error = SqlitePoolError; + + async fn connect(&self) -> Result { + let db_path = self.db_path.clone(); + let connection_name = self.connection_name.clone(); + + let conn = task::spawn_blocking(move || { + Connection::open(&db_path).unwrap_or_else(|err| { + panic!( + "Failed to open SQLite database at '{}' for connection '{}': {}", + db_path, connection_name, err + ) + }) + }) + .await + .map_err(|e| SqlitePoolError(format!("Failed to spawn blocking task: {e}")))?; + + Ok(SqliteConnection { + conn: Arc::new(Mutex::new(conn)), + }) + } + + async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> { + let inner = conn.conn.clone(); + task::spawn_blocking(move || { + let conn = inner.lock().unwrap(); + conn + .execute_batch("SELECT 1") + .map_err(|e| SqlitePoolError(format!("SQLite connection validation failed: {e}"))) + }) + .await + .map_err(|e| SqlitePoolError(format!("Failed to spawn blocking task: {e}")))? + } + + fn has_broken(&self, _conn: &mut Self::Connection) -> bool { + false + } +} diff --git a/src/core/sqlite/prepare.rs b/src/core/sqlite/prepare.rs new file mode 100644 index 00000000..90fa4da1 --- /dev/null +++ b/src/core/sqlite/prepare.rs @@ -0,0 +1,54 @@ +use crate::common::SQL; +use crate::core::connection::DBConn; +use crate::ts_generator::generator::generate_ts_interface; +use crate::ts_generator::types::ts_query::TsQuery; +use color_eyre::eyre::Result; + +use swc_common::errors::Handler; + +/// Runs the prepare statement on the input SQL. +/// Validates the query is right by directly connecting to the configured SQLite database. +/// It also processes ts interfaces if the configuration is set to generate_types = true +pub async fn prepare( + db_conn: &DBConn, + sql: &SQL, + should_generate_types: &bool, + handler: &Handler, +) -> Result<(bool, Option)> { + let mut failed = false; + + let conn = match &db_conn { + DBConn::SqliteConn(conn) => conn, + _ => panic!("Invalid connection type"), + }; + + { + let span = sql.span.to_owned(); + let query = sql.query.clone(); + let conn = conn.lock().await; + let pool_conn = conn.get().await.unwrap(); + let inner = pool_conn.conn.clone(); + + let result = tokio::task::spawn_blocking(move || { + let conn = inner.lock().unwrap(); + // Use EXPLAIN to validate the SQL without executing it + let explain_query = format!("EXPLAIN {}", query); + conn.execute_batch(&explain_query) + }) + .await + .unwrap(); + + if let Err(e) = result { + handler.span_bug_no_panic(span, &e.to_string()); + failed = true; + } + } + + let mut ts_query = None; + + if should_generate_types == &true { + ts_query = Some(generate_ts_interface(sql, db_conn).await?); + } + + Ok((failed, ts_query)) +} diff --git a/src/ts_generator/generator.rs b/src/ts_generator/generator.rs index e8265e36..3e0c2bb0 100644 --- a/src/ts_generator/generator.rs +++ b/src/ts_generator/generator.rs @@ -17,7 +17,7 @@ use color_eyre::eyre::Result; use convert_case::{Case, Casing}; use regex::Regex; use sqlparser::{ - dialect::{Dialect, MySqlDialect, PostgreSqlDialect}, + dialect::{Dialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect}, parser::Parser, }; @@ -125,6 +125,7 @@ pub async fn generate_ts_interface(sql: &SQL, db_conn: &DBConn) -> Result = match db_conn.get_db_type() { DatabaseType::Postgres => Box::new(PostgreSqlDialect {}), DatabaseType::Mysql => Box::new(MySqlDialect {}), + DatabaseType::Sqlite => Box::new(SQLiteDialect {}), }; let sql_ast = Parser::parse_sql(&*dialect, &sql.query)?; diff --git a/src/ts_generator/information_schema.rs b/src/ts_generator/information_schema.rs index 0ae93f07..79fd86ef 100644 --- a/src/ts_generator/information_schema.rs +++ b/src/ts_generator/information_schema.rs @@ -3,6 +3,7 @@ use crate::common::logger::*; use crate::core::connection::DBConn; use crate::core::mysql::pool::MySqlConnectionManager; use crate::core::postgres::pool::PostgresConnectionManager; +use crate::core::sqlite::pool::SqliteConnectionManager; use bb8::Pool; use mysql_async::prelude::Queryable; use std::collections::HashMap; @@ -54,6 +55,7 @@ impl DBSchema { let result = match &conn { DBConn::MySQLPooledConn(conn) => Self::mysql_fetch_table(self, table_name, conn).await, DBConn::PostgresConn(conn) => Self::postgres_fetch_table(self, &"public".to_string(), table_name, conn).await, + DBConn::SqliteConn(conn) => Self::sqlite_fetch_table(self, table_name, conn).await, }; if let Some(result) = &result { @@ -210,4 +212,62 @@ impl DBSchema { None } + + async fn sqlite_fetch_table( + &self, + table_names: &Vec<&str>, + conn: &Mutex>, + ) -> Option { + let mut fields: HashMap = HashMap::new(); + let conn = conn.lock().await; + let pool_conn = conn.get().await.expect(DB_CONN_POOL_RETRIEVE_ERROR); + let inner = pool_conn.conn.clone(); + + let table_names_owned: Vec = table_names.iter().map(|s| s.to_string()).collect(); + + let result = tokio::task::spawn_blocking(move || { + let conn = inner.lock().unwrap(); + let mut all_fields: HashMap = HashMap::new(); + + for table_name in &table_names_owned { + let query = format!("PRAGMA table_info('{}')", table_name); + let mut stmt = match conn.prepare(&query) { + Ok(stmt) => stmt, + Err(_) => continue, + }; + + let rows = match stmt.query_map([], |row| { + let name: String = row.get(1)?; + let type_name: String = row.get(2)?; + let notnull: bool = row.get(3)?; + let pk: i32 = row.get(5)?; + Ok((name, type_name, notnull, pk, table_name.clone())) + }) { + Ok(rows) => rows, + Err(_) => continue, + }; + + for (field_name, field_type, notnull, pk, tbl_name) in rows.flatten() { + let field = Field { + field_type: TsFieldType::get_ts_field_type_from_sqlite_field_type(field_type, tbl_name, field_name.clone()), + is_nullable: !notnull && pk == 0, + }; + all_fields.insert(field_name, field); + } + } + + all_fields + }) + .await; + + if let Ok(result) = result { + if result.is_empty() { + return None; + } + fields.extend(result); + return Some(fields); + } + + None + } } diff --git a/src/ts_generator/types/ts_query.rs b/src/ts_generator/types/ts_query.rs index 65e2a756..3c78ec8f 100644 --- a/src/ts_generator/types/ts_query.rs +++ b/src/ts_generator/types/ts_query.rs @@ -242,6 +242,44 @@ impl TsFieldType { } } + /// Converts SQLite type affinity strings to TsFieldType. + /// SQLite uses type affinity rules, so we match common type names. + pub fn get_ts_field_type_from_sqlite_field_type(field_type: String, table_name: String, field_name: String) -> Self { + let upper = field_type.to_uppercase(); + // SQLite type affinity rules (see https://www.sqlite.org/datatype3.html) + if upper.contains("INT") { + return Self::Number; + } + if upper.contains("CHAR") || upper.contains("CLOB") || upper.contains("TEXT") { + return Self::String; + } + if upper.contains("BLOB") || upper.is_empty() { + // Empty type name in SQLite means BLOB affinity + return Self::String; + } + if upper.contains("REAL") || upper.contains("FLOA") || upper.contains("DOUB") { + return Self::Number; + } + if upper.contains("BOOL") { + return Self::Boolean; + } + if upper.contains("DATE") || upper.contains("TIME") { + return Self::Date; + } + if upper.contains("NUMERIC") || upper.contains("DECIMAL") { + return Self::Number; + } + if upper.contains("JSON") { + return Self::Object; + } + // Default: SQLite NUMERIC affinity + let message = format!( + "The column {field_name} of type {field_type} in table {table_name} will be translated as any (unsupported SQLite type)" + ); + info!(message); + Self::Any + } + pub fn get_ts_field_from_annotation(annotated_type: &str) -> Self { if annotated_type == "string" { return Self::String; diff --git a/test-utils/src/sandbox.rs b/test-utils/src/sandbox.rs index 859e19fa..85f42cc4 100644 --- a/test-utils/src/sandbox.rs +++ b/test-utils/src/sandbox.rs @@ -46,6 +46,21 @@ impl TestConfig { config_file_name, } } + if db_type == "sqlite" { + return TestConfig { + db_type: "sqlite".into(), + file_extension: "ts".to_string(), + db_host: String::new(), + db_port: 0, + db_user: String::new(), + db_pass: None, + // db_name will be overridden per-test with the actual temp SQLite file path + db_name: ":memory:".to_string(), + generate_path, + generate_types, + config_file_name, + } + } TestConfig { db_type: "postgres".into(), file_extension: "ts".to_string(), @@ -148,6 +163,7 @@ $( let db_name = test_config.db_name; let config_file_name = test_config.config_file_name; let generate_path = test_config.generate_path; + let is_sqlite = db_type == "sqlite"; // SETUP let dir = tempdir()?; @@ -164,11 +180,14 @@ $( cmd.arg(parent_path.to_str().unwrap()) .arg(format!("--ext={file_extension}")) .arg(format!("--db-type={db_type}")) - .arg(format!("--db-host={db_host}")) - .arg(format!("--db-port={db_port}")) - .arg(format!("--db-user={db_user}")) .arg(format!("--db-name={db_name}")); + if !is_sqlite { + cmd.arg(format!("--db-host={db_host}")) + .arg(format!("--db-port={db_port}")) + .arg(format!("--db-user={db_user}")); + } + if &generate_path.is_some() == &true { let generate_path = generate_path.clone(); let generate_path = generate_path.unwrap(); @@ -190,11 +209,13 @@ $( cmd.arg(format!("--config={config_path}")); } - if (db_pass.is_some()) { - let db_pass = db_pass.unwrap(); - cmd.arg(format!("--db-pass={db_pass}")); - } else { - cmd.arg("--db-pass="); + if !is_sqlite { + if (db_pass.is_some()) { + let db_pass = db_pass.unwrap(); + cmd.arg(format!("--db-pass={db_pass}")); + } else { + cmd.arg("--db-pass="); + } } cmd.assert() @@ -248,6 +269,7 @@ $( let db_name = test_config.db_name; let config_file_name = test_config.config_file_name; let generate_path = test_config.generate_path; + let is_sqlite = db_type == "sqlite"; // SETUP let dir = tempdir()?; @@ -264,11 +286,14 @@ $( cmd.arg(parent_path.to_str().unwrap()) .arg(format!("--ext={file_extension}")) .arg(format!("--db-type={db_type}")) - .arg(format!("--db-host={db_host}")) - .arg(format!("--db-port={db_port}")) - .arg(format!("--db-user={db_user}")) .arg(format!("--db-name={db_name}")); + if !is_sqlite { + cmd.arg(format!("--db-host={db_host}")) + .arg(format!("--db-port={db_port}")) + .arg(format!("--db-user={db_user}")); + } + if &generate_path.is_some() == &true { let generate_path = generate_path.clone(); let generate_path = generate_path.unwrap(); @@ -290,11 +315,13 @@ $( cmd.arg(format!("--config={config_path}")); } - if (db_pass.is_some()) { - let db_pass = db_pass.unwrap(); - cmd.arg(format!("--db-pass={db_pass}")); - } else { - cmd.arg("--db-pass="); + if !is_sqlite { + if (db_pass.is_some()) { + let db_pass = db_pass.unwrap(); + cmd.arg(format!("--db-pass={db_pass}")); + } else { + cmd.arg("--db-pass="); + } } cmd.assert() diff --git a/tests/demo_happy_path.rs b/tests/demo_happy_path.rs index 9ce9c394..75bb631b 100644 --- a/tests/demo_happy_path.rs +++ b/tests/demo_happy_path.rs @@ -7,6 +7,7 @@ mod demo_happy_path_tests { use std::fs; use std::io::Write; use std::path::Path; + use tempfile::tempdir; use walkdir::WalkDir; fn run_demo_test(demo_path: &Path) -> Result<(), Box> { @@ -283,4 +284,83 @@ mod demo_happy_path_tests { Ok(()) } + + #[test] + fn all_demo_sqlite_should_pass() -> Result<(), Box> { + let root_path = current_dir().unwrap(); + let demo_path = root_path.join("tests/demo_sqlite"); + let migration_path = root_path.join("playpen/db/sqlite_migration.sql"); + + // Create a temporary SQLite database and run the migration + let tmp_dir = tempdir()?; + let db_path = tmp_dir.path().join("demo_test.db"); + let conn = rusqlite::Connection::open(&db_path)?; + let migration_sql = fs::read_to_string(&migration_path)?; + conn.execute_batch(&migration_sql)?; + drop(conn); + + // Create a temporary config file pointing to the SQLite database + let config_path = tmp_dir.path().join(".sqlxrc.json"); + let config_content = format!( + r#"{{ + "generateTypes": {{ + "enabled": true + }}, + "connections": {{ + "default": {{ + "DB_TYPE": "sqlite", + "DB_NAME": "{}" + }} + }} +}}"#, + db_path.display() + ); + fs::write(&config_path, &config_content)?; + + // Run sqlx-ts against the demo_sqlite directory + // Use --db-type and --db-name CLI args to override any .env file values + let mut cmd = cargo_bin_cmd!("sqlx-ts"); + cmd + .arg(demo_path.to_str().unwrap()) + .arg("--ext=ts") + .arg(format!("--config={}", config_path.display())) + .arg("--db-type=sqlite") + .arg(format!("--db-name={}", db_path.display())) + .arg("-g"); + + cmd + .assert() + .success() + .stdout(predicates::str::contains("No SQL errors detected!")); + + // Verify all generated types match snapshots + for entry in WalkDir::new(&demo_path) { + if entry.is_ok() { + let entry = entry.unwrap(); + let path = entry.path(); + let parent = entry.path().parent().unwrap(); + let file_name = path.file_name().unwrap().to_str().unwrap().to_string(); + + if path.is_file() && file_name.ends_with(".queries.ts") { + let base_file_name = file_name.split('.').collect::>(); + let base_file_name = base_file_name.first().unwrap(); + let snapshot_path = parent.join(format!("{base_file_name}.snapshot.ts")); + + let generated_types = fs::read_to_string(path)?; + + if !snapshot_path.exists() { + let mut snapshot_file = fs::File::create(&snapshot_path)?; + writeln!(snapshot_file, "{generated_types}")?; + } + + assert_eq!( + generated_types.trim().to_string().trim(), + fs::read_to_string(&snapshot_path)?.to_string().trim(), + ) + } + } + } + + Ok(()) + } } diff --git a/tests/demo_sqlite/delete_basic.queries.ts b/tests/demo_sqlite/delete_basic.queries.ts new file mode 100644 index 00000000..de5a2dd7 --- /dev/null +++ b/tests/demo_sqlite/delete_basic.queries.ts @@ -0,0 +1,21 @@ +export type DeleteItemParams = [number]; + +export interface IDeleteItemResult { + +} + +export interface IDeleteItemQuery { + params: DeleteItemParams; + result: IDeleteItemResult; +} + +export type DeleteCharacterParams = [number]; + +export interface IDeleteCharacterResult { + +} + +export interface IDeleteCharacterQuery { + params: DeleteCharacterParams; + result: IDeleteCharacterResult; +} diff --git a/tests/demo_sqlite/delete_basic.snapshot.ts b/tests/demo_sqlite/delete_basic.snapshot.ts new file mode 100644 index 00000000..c37449f4 --- /dev/null +++ b/tests/demo_sqlite/delete_basic.snapshot.ts @@ -0,0 +1,22 @@ +export type DeleteItemParams = [number]; + +export interface IDeleteItemResult { + +} + +export interface IDeleteItemQuery { + params: DeleteItemParams; + result: IDeleteItemResult; +} + +export type DeleteCharacterParams = [number]; + +export interface IDeleteCharacterResult { + +} + +export interface IDeleteCharacterQuery { + params: DeleteCharacterParams; + result: IDeleteCharacterResult; +} + diff --git a/tests/demo_sqlite/delete_basic.ts b/tests/demo_sqlite/delete_basic.ts new file mode 100644 index 00000000..f86c04ee --- /dev/null +++ b/tests/demo_sqlite/delete_basic.ts @@ -0,0 +1,11 @@ +import { sql } from 'sqlx-ts' + +const deleteItem = sql` +-- @name: delete item +DELETE FROM items WHERE id = $1 +` + +const deleteCharacter = sql` +-- @name: delete character +DELETE FROM characters WHERE id = $1 +` diff --git a/tests/demo_sqlite/insert_basic.queries.ts b/tests/demo_sqlite/insert_basic.queries.ts new file mode 100644 index 00000000..ce6c9ba0 --- /dev/null +++ b/tests/demo_sqlite/insert_basic.queries.ts @@ -0,0 +1,21 @@ +export type InsertItemParams = [string, string | null, string | null, number | null]; + +export interface IInsertItemResult { + +} + +export interface IInsertItemQuery { + params: InsertItemParams; + result: IInsertItemResult; +} + +export type InsertCharacterParams = [string, number | null, number | null, number | null]; + +export interface IInsertCharacterResult { + +} + +export interface IInsertCharacterQuery { + params: InsertCharacterParams; + result: IInsertCharacterResult; +} diff --git a/tests/demo_sqlite/insert_basic.snapshot.ts b/tests/demo_sqlite/insert_basic.snapshot.ts new file mode 100644 index 00000000..8d833e7f --- /dev/null +++ b/tests/demo_sqlite/insert_basic.snapshot.ts @@ -0,0 +1,22 @@ +export type InsertItemParams = [string, string | null, string | null, number | null]; + +export interface IInsertItemResult { + +} + +export interface IInsertItemQuery { + params: InsertItemParams; + result: IInsertItemResult; +} + +export type InsertCharacterParams = [string, number | null, number | null, number | null]; + +export interface IInsertCharacterResult { + +} + +export interface IInsertCharacterQuery { + params: InsertCharacterParams; + result: IInsertCharacterResult; +} + diff --git a/tests/demo_sqlite/insert_basic.ts b/tests/demo_sqlite/insert_basic.ts new file mode 100644 index 00000000..1d17e91c --- /dev/null +++ b/tests/demo_sqlite/insert_basic.ts @@ -0,0 +1,11 @@ +import { sql } from 'sqlx-ts' + +const insertItem = sql` +-- @name: insert item +INSERT INTO items (name, rarity, flavor_text, inventory_id) VALUES ($1, $2, $3, $4) +` + +const insertCharacter = sql` +-- @name: insert character +INSERT INTO characters (name, race_id, class_id, level) VALUES ($1, $2, $3, $4) +` diff --git a/tests/demo_sqlite/join_basic.queries.ts b/tests/demo_sqlite/join_basic.queries.ts new file mode 100644 index 00000000..dcc4473b --- /dev/null +++ b/tests/demo_sqlite/join_basic.queries.ts @@ -0,0 +1,13 @@ +export type SelectItemsWithInventoryParams = [number | null]; + +export interface ISelectItemsWithInventoryResult { + inventory_quantity: number | null; + items_id: number; + items_name: string; + items_rarity: string | null; +} + +export interface ISelectItemsWithInventoryQuery { + params: SelectItemsWithInventoryParams; + result: ISelectItemsWithInventoryResult; +} diff --git a/tests/demo_sqlite/join_basic.snapshot.ts b/tests/demo_sqlite/join_basic.snapshot.ts new file mode 100644 index 00000000..735baf23 --- /dev/null +++ b/tests/demo_sqlite/join_basic.snapshot.ts @@ -0,0 +1,14 @@ +export type SelectItemsWithInventoryParams = [number | null]; + +export interface ISelectItemsWithInventoryResult { + inventory_quantity: number | null; + items_id: number; + items_name: string; + items_rarity: string | null; +} + +export interface ISelectItemsWithInventoryQuery { + params: SelectItemsWithInventoryParams; + result: ISelectItemsWithInventoryResult; +} + diff --git a/tests/demo_sqlite/join_basic.ts b/tests/demo_sqlite/join_basic.ts new file mode 100644 index 00000000..55e8cb4d --- /dev/null +++ b/tests/demo_sqlite/join_basic.ts @@ -0,0 +1,9 @@ +import { sql } from 'sqlx-ts' + +const selectItemsWithInventory = sql` +-- @name: select items with inventory +SELECT items.id, items.name, items.rarity, inventory.quantity +FROM items +JOIN inventory ON items.inventory_id = inventory.id +WHERE inventory.quantity > $1 +` diff --git a/tests/demo_sqlite/select_basic.queries.ts b/tests/demo_sqlite/select_basic.queries.ts new file mode 100644 index 00000000..e8bb2342 --- /dev/null +++ b/tests/demo_sqlite/select_basic.queries.ts @@ -0,0 +1,41 @@ +export type SelectAllItemsParams = []; + +export interface ISelectAllItemsResult { + flavor_text: string | null; + id: number; + inventory_id: number | null; + name: string; + rarity: string | null; +} + +export interface ISelectAllItemsQuery { + params: SelectAllItemsParams; + result: ISelectAllItemsResult; +} + +export type SelectItemByIdParams = [number]; + +export interface ISelectItemByIdResult { + flavor_text: string | null; + id: number; + inventory_id: number | null; + name: string; + rarity: string | null; +} + +export interface ISelectItemByIdQuery { + params: SelectItemByIdParams; + result: ISelectItemByIdResult; +} + +export type SelectItemsByNameParams = [string]; + +export interface ISelectItemsByNameResult { + id: number; + name: string; +} + +export interface ISelectItemsByNameQuery { + params: SelectItemsByNameParams; + result: ISelectItemsByNameResult; +} diff --git a/tests/demo_sqlite/select_basic.snapshot.ts b/tests/demo_sqlite/select_basic.snapshot.ts new file mode 100644 index 00000000..ce5421f5 --- /dev/null +++ b/tests/demo_sqlite/select_basic.snapshot.ts @@ -0,0 +1,42 @@ +export type SelectAllItemsParams = []; + +export interface ISelectAllItemsResult { + flavor_text: string | null; + id: number; + inventory_id: number | null; + name: string; + rarity: string | null; +} + +export interface ISelectAllItemsQuery { + params: SelectAllItemsParams; + result: ISelectAllItemsResult; +} + +export type SelectItemByIdParams = [number]; + +export interface ISelectItemByIdResult { + flavor_text: string | null; + id: number; + inventory_id: number | null; + name: string; + rarity: string | null; +} + +export interface ISelectItemByIdQuery { + params: SelectItemByIdParams; + result: ISelectItemByIdResult; +} + +export type SelectItemsByNameParams = [string]; + +export interface ISelectItemsByNameResult { + id: number; + name: string; +} + +export interface ISelectItemsByNameQuery { + params: SelectItemsByNameParams; + result: ISelectItemsByNameResult; +} + diff --git a/tests/demo_sqlite/select_basic.ts b/tests/demo_sqlite/select_basic.ts new file mode 100644 index 00000000..350cd7fc --- /dev/null +++ b/tests/demo_sqlite/select_basic.ts @@ -0,0 +1,16 @@ +import { sql } from 'sqlx-ts' + +const selectAllItems = sql` +-- @name: select all items +SELECT * FROM items +` + +const selectItemById = sql` +-- @name: select item by id +SELECT * FROM items WHERE id = $1 +` + +const selectItemsByName = sql` +-- @name: select items by name +SELECT id, name FROM items WHERE name = $1 +` diff --git a/tests/demo_sqlite/update_basic.queries.ts b/tests/demo_sqlite/update_basic.queries.ts new file mode 100644 index 00000000..094ff6b8 --- /dev/null +++ b/tests/demo_sqlite/update_basic.queries.ts @@ -0,0 +1,21 @@ +export type UpdateItemNameParams = [string, number]; + +export interface IUpdateItemNameResult { + +} + +export interface IUpdateItemNameQuery { + params: UpdateItemNameParams; + result: IUpdateItemNameResult; +} + +export type UpdateCharacterLevelParams = [number | null, number | null, number]; + +export interface IUpdateCharacterLevelResult { + +} + +export interface IUpdateCharacterLevelQuery { + params: UpdateCharacterLevelParams; + result: IUpdateCharacterLevelResult; +} diff --git a/tests/demo_sqlite/update_basic.snapshot.ts b/tests/demo_sqlite/update_basic.snapshot.ts new file mode 100644 index 00000000..5072cf08 --- /dev/null +++ b/tests/demo_sqlite/update_basic.snapshot.ts @@ -0,0 +1,22 @@ +export type UpdateItemNameParams = [string, number]; + +export interface IUpdateItemNameResult { + +} + +export interface IUpdateItemNameQuery { + params: UpdateItemNameParams; + result: IUpdateItemNameResult; +} + +export type UpdateCharacterLevelParams = [number | null, number | null, number]; + +export interface IUpdateCharacterLevelResult { + +} + +export interface IUpdateCharacterLevelQuery { + params: UpdateCharacterLevelParams; + result: IUpdateCharacterLevelResult; +} + diff --git a/tests/demo_sqlite/update_basic.ts b/tests/demo_sqlite/update_basic.ts new file mode 100644 index 00000000..8e7d3493 --- /dev/null +++ b/tests/demo_sqlite/update_basic.ts @@ -0,0 +1,11 @@ +import { sql } from 'sqlx-ts' + +const updateItemName = sql` +-- @name: update item name +UPDATE items SET name = $1 WHERE id = $2 +` + +const updateCharacterLevel = sql` +-- @name: update character level +UPDATE characters SET level = $1, experience = $2 WHERE id = $3 +` diff --git a/tests/sqlite_query_parameters.rs b/tests/sqlite_query_parameters.rs new file mode 100644 index 00000000..b21c5abd --- /dev/null +++ b/tests/sqlite_query_parameters.rs @@ -0,0 +1,255 @@ +#[cfg(test)] +mod sqlite_query_parameters_tests { + use std::env; + use std::fs; + use std::io::Write; + use tempfile::tempdir; + + use assert_cmd::cargo::cargo_bin_cmd; + use pretty_assertions::assert_eq; + use test_utils::test_utils::TSString; + + /// Helper: creates a temporary SQLite database with the given schema, + /// then runs sqlx-ts on the given TS content, and returns the generated types. + fn run_sqlite_test( + schema_sql: &str, + ts_content: &str, + generate_types: bool, + ) -> Result<(String, String), Box> { + let dir = tempdir()?; + let parent_path = dir.path(); + + // Create the SQLite database and populate it with the schema + let db_path = parent_path.join("test.db"); + let conn = rusqlite::Connection::open(&db_path)?; + conn.execute_batch(schema_sql)?; + drop(conn); + + // Write the TS file + let file_path = parent_path.join("index.ts"); + let mut temp_file = fs::File::create(&file_path)?; + writeln!(temp_file, "{}", ts_content)?; + + // Run sqlx-ts + let mut cmd = cargo_bin_cmd!("sqlx-ts"); + cmd + .arg(parent_path.to_str().unwrap()) + .arg("--ext=ts") + .arg("--db-type=sqlite") + .arg(format!("--db-name={}", db_path.display())); + + if generate_types { + cmd.arg("-g"); + } + + let output = cmd.output()?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + assert!( + output.status.success(), + "sqlx-ts failed!\nstdout: {stdout}\nstderr: {stderr}" + ); + assert!( + stdout.contains("No SQL errors detected!"), + "Expected success message in stdout: {stdout}" + ); + + // Read generated types + let type_file_path = parent_path.join("index.queries.ts"); + let type_file = if type_file_path.exists() { + fs::read_to_string(type_file_path)? + } else { + String::new() + }; + + Ok((stdout, type_file)) + } + + #[test] + fn should_validate_simple_select() -> Result<(), Box> { + let schema = "CREATE TABLE items (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, price REAL);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' + +const someQuery = sql`SELECT * FROM items` +"#; + + let (_, type_file) = run_sqlite_test(schema, ts_content, true)?; + + let expected = r#" +export type SomeQueryParams = []; + +export interface ISomeQueryResult { + id: number; + name: string; + price: number | null; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#; + + assert_eq!( + expected.trim().to_string().flatten(), + type_file.trim().to_string().flatten() + ); + Ok(()) + } + + #[test] + fn should_handle_query_params_with_question_mark() -> Result<(), Box> { + let schema = "CREATE TABLE items (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, price REAL);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' + +const someQuery = sql`SELECT * FROM items WHERE id = ? AND name = ?` +"#; + + let (_, type_file) = run_sqlite_test(schema, ts_content, true)?; + + let expected = r#" +export type SomeQueryParams = [number, string]; + +export interface ISomeQueryResult { + id: number; + name: string; + price: number | null; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#; + + assert_eq!( + expected.trim().to_string().flatten(), + type_file.trim().to_string().flatten() + ); + Ok(()) + } + + #[test] + fn should_handle_insert_with_params() -> Result<(), Box> { + let schema = "CREATE TABLE items (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, price REAL);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' + +const someQuery = sql`INSERT INTO items (name, price) VALUES (?, ?)` +"#; + + let (_, type_file) = run_sqlite_test(schema, ts_content, true)?; + + let expected = r#" +export type SomeQueryParams = [[string, number | null]]; + +export interface ISomeQueryResult { +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#; + + assert_eq!( + expected.trim().to_string().flatten(), + type_file.trim().to_string().flatten() + ); + Ok(()) + } + + #[test] + fn should_handle_multiple_types() -> Result<(), Box> { + let schema = r" + CREATE TABLE events ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT, + start_date DATETIME, + is_active BOOLEAN NOT NULL DEFAULT 1, + score REAL, + metadata JSON + ); + "; + + let ts_content = r#" +import { sql } from 'sqlx-ts' + +const someQuery = sql`SELECT * FROM events WHERE id = ?` +"#; + + let (_, type_file) = run_sqlite_test(schema, ts_content, true)?; + + let expected = r#" +export type SomeQueryParams = [number]; + +export interface ISomeQueryResult { + description: string | null; + id: number; + is_active: boolean; + metadata: object | null; + name: string; + score: number | null; + start_date: Date | null; +} + +export interface ISomeQueryQuery { + params: SomeQueryParams; + result: ISomeQueryResult; +} +"#; + + assert_eq!( + expected.trim().to_string().flatten(), + type_file.trim().to_string().flatten() + ); + Ok(()) + } + + #[test] + fn should_detect_invalid_sql() -> Result<(), Box> { + let schema = "CREATE TABLE items (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL);"; + + let ts_content = r#" +import { sql } from 'sqlx-ts' + +const someQuery = sql`SELECT * FROM nonexistent_table` +"#; + + let dir = tempdir()?; + let parent_path = dir.path(); + + let db_path = parent_path.join("test.db"); + let conn = rusqlite::Connection::open(&db_path)?; + conn.execute_batch(schema)?; + drop(conn); + + let file_path = parent_path.join("index.ts"); + let mut temp_file = fs::File::create(&file_path)?; + writeln!(temp_file, "{}", ts_content)?; + + let mut cmd = cargo_bin_cmd!("sqlx-ts"); + cmd + .arg(parent_path.to_str().unwrap()) + .arg("--ext=ts") + .arg("--db-type=sqlite") + .arg(format!("--db-name={}", db_path.display())); + + // This should fail because the table doesn't exist + let output = cmd.output()?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + // The command should report SQL errors + assert!( + !stdout.contains("No SQL errors detected!"), + "Expected SQL errors but got success: {stdout}" + ); + Ok(()) + } +}