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(())
+ }
+}