Conversation
There was a problem hiding this comment.
Pull request overview
Adds first-class SQLite support to sqlx-ts, enabling validation and TS type generation against SQLite databases alongside existing Postgres/MySQL support.
Changes:
- Introduces SQLite connection pooling + query validation path in
coreand wires it through shared connection/config code. - Adds SQLite schema introspection and SQLite-specific SQL parsing/type-affinity mapping for TS generation.
- Adds SQLite-focused integration tests for
?parameters and type output.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/sqlite_query_parameters.rs | New integration tests covering SQLite ? parameters, result typing, and invalid SQL handling. |
| test-utils/src/sandbox.rs | Extends test harness to allow running the CLI without host/user/pass when db_type=sqlite. |
| src/ts_generator/types/ts_query.rs | Adds SQLite column-type-affinity → TS type mapping. |
| src/ts_generator/information_schema.rs | Adds SQLite table introspection via PRAGMA table_info and integrates into DBSchema::fetch_table. |
| src/ts_generator/generator.rs | Uses sqlparser’s SQLiteDialect when generating types for SQLite connections. |
| src/core/sqlite/prepare.rs | Adds SQLite query validation implementation (currently via EXPLAIN). |
| src/core/sqlite/pool.rs | Adds bb8 manager for SQLite using rusqlite connections. |
| src/core/sqlite/mod.rs | Exposes SQLite core modules. |
| src/core/mod.rs | Registers core::sqlite module. |
| src/core/connection.rs | Adds DBConn::SqliteConn and routes prepare()/get_db_type() accordingly. |
| src/common/types.rs | Adds DatabaseType::Sqlite. |
| src/common/lazy.rs | Creates and caches SQLite pools from config. |
| src/common/dotenv.rs | Parses DB_TYPE=sqlite from .env. |
| src/common/config.rs | Adjusts default-connection config rules for SQLite and adds get_sqlite_path(). |
| Cargo.toml | Adds rusqlite dependency (and duplicates it in dev-deps). |
| Cargo.lock | Locks new rusqlite/sqlite transitive dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let mut all_fields: HashMap<String, Field> = HashMap::new(); | ||
|
|
||
| for table_name in &table_names_owned { | ||
| let query = format!("PRAGMA table_info('{}')", table_name); |
There was a problem hiding this comment.
PRAGMA table_info is built by interpolating table_name into a quoted string. This will break for table names containing ' and can be abused by crafting a query with a malicious table identifier. At minimum, escape single-quotes; ideally use the pragma_table_info(?) table-valued function (bind parameter) or proper identifier quoting.
| let query = format!("PRAGMA table_info('{}')", table_name); | |
| let safe_table_name = table_name.replace('\'', "''"); | |
| let query = format!("PRAGMA table_info('{}')", safe_table_name); |
| Ok((name, type_name, notnull, table_name.clone())) | ||
| }) { | ||
| Ok(rows) => rows, | ||
| Err(_) => continue, | ||
| }; | ||
|
|
||
| for row in rows { | ||
| if let Ok((field_name, field_type, notnull, tbl_name)) = row { | ||
| let field = Field { | ||
| field_type: TsFieldType::get_ts_field_type_from_sqlite_field_type( | ||
| field_type, | ||
| tbl_name, | ||
| field_name.clone(), | ||
| ), | ||
| is_nullable: !notnull, |
There was a problem hiding this comment.
SQLite PRIMARY KEY columns are implicitly NOT NULL even if notnull is 0 in PRAGMA table_info. This code only uses the notnull flag, so it can incorrectly mark INTEGER PRIMARY KEY columns as nullable. Consider reading the pk column from PRAGMA table_info and treating pk > 0 as non-nullable.
| Ok((name, type_name, notnull, table_name.clone())) | |
| }) { | |
| Ok(rows) => rows, | |
| Err(_) => continue, | |
| }; | |
| for row in rows { | |
| if let Ok((field_name, field_type, notnull, tbl_name)) = row { | |
| let field = Field { | |
| field_type: TsFieldType::get_ts_field_type_from_sqlite_field_type( | |
| field_type, | |
| tbl_name, | |
| field_name.clone(), | |
| ), | |
| is_nullable: !notnull, | |
| let pk: i64 = row.get(5)?; | |
| Ok((name, type_name, notnull, pk, table_name.clone())) | |
| }) { | |
| Ok(rows) => rows, | |
| Err(_) => continue, | |
| }; | |
| for row in rows { | |
| if let Ok((field_name, field_type, notnull, pk, tbl_name)) = row { | |
| let field = Field { | |
| field_type: TsFieldType::get_ts_field_type_from_sqlite_field_type( | |
| field_type, | |
| tbl_name, | |
| field_name.clone(), | |
| ), | |
| // SQLite PRIMARY KEY columns are implicitly NOT NULL, even if `notnull` is 0. | |
| is_nullable: !(notnull || pk > 0), |
| // The command should report SQL errors | ||
| assert!( |
There was a problem hiding this comment.
This "invalid SQL" test only checks for absence of the success message in stdout; it does not assert a non-zero exit status. Other integration tests (e.g. tests/exit_code_on_error.rs) assert .failure(). Consider asserting !output.status.success() (and/or checking stderr) so the test catches regressions where sqlite errors don't fail the process.
| // The command should report SQL errors | |
| assert!( | |
| // The process should fail and report SQL errors | |
| assert!( | |
| !output.status.success(), | |
| "Expected command to fail, but it exited successfully.\nstdout: {stdout}\nstderr: {}", | |
| String::from_utf8_lossy(&output.stderr) | |
| ); | |
| assert!( |
| tempfile = "3.27.0" | ||
| test_utils = { path="test-utils" } | ||
| pretty_assertions = "1.4.1" | ||
| rusqlite = { version = "0.31", features = ["bundled"] } |
There was a problem hiding this comment.
rusqlite is added both under [dependencies] and [dev-dependencies] with the same version/features. Since the crate code now depends on rusqlite, the dev-dependency entry is redundant and can be removed to avoid duplication.
| rusqlite = { version = "0.31", features = ["bundled"] } |
| /// 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")) | ||
| } |
There was a problem hiding this comment.
get_sqlite_path treats DB_URL as a filesystem path and passes it to rusqlite::Connection::open. This differs from the existing meaning of DB_URL as a full connection URL (postgres/mysql) and may surprise users who provide a sqlite://... style URL. Consider either documenting that SQLite expects a file path here, or parsing/handling common SQLite URL/URI formats explicitly.
| // Use EXPLAIN to validate the SQL without executing it | ||
| let explain_query = format!("EXPLAIN {}", query); | ||
| conn.execute_batch(&explain_query) |
There was a problem hiding this comment.
SQLite validation currently runs EXPLAIN ... via execute_batch. In rusqlite this executes the statement and will fail for queries containing ? placeholders ("incorrect number of bindings") even though the SQL is valid. Use prepare (optionally on EXPLAIN ...) to validate parse/semantic correctness without executing or requiring bound params.
| // Use EXPLAIN to validate the SQL without executing it | |
| let explain_query = format!("EXPLAIN {}", query); | |
| conn.execute_batch(&explain_query) | |
| // Use EXPLAIN with `prepare` to validate the SQL without executing it | |
| let explain_query = format!("EXPLAIN {}", query); | |
| conn.prepare(&explain_query).map(|_| ()) |
| /// Wrapper around rusqlite::Connection to make it Send + Sync for bb8 | ||
| pub struct SqliteConnection { | ||
| pub conn: Arc<Mutex<Connection>>, | ||
| } | ||
|
|
||
| // 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 {} | ||
|
|
There was a problem hiding this comment.
SqliteConnection declares unsafe impl Send/Sync, and the wrapped rusqlite::Connection is then moved into spawn_blocking closures across threads. This bypasses rusqlite's thread-safety guarantees and can be unsound unless the connection is opened/configured specifically for cross-thread use. Prefer a safe async wrapper (e.g. tokio-rusqlite/sqlx) or avoid unsafe by using a connection type that is already Send/Sync (and documenting the invariants).
| 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}")))?; | ||
|
|
There was a problem hiding this comment.
connect() panics on Connection::open failure. Since this is inside the pool manager, a bad path/permission issue will crash the whole process instead of returning a SqlitePoolError. Map the open error into Self::Error and let callers handle/report it gracefully.
| 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}")))?; | |
| let conn_res = task::spawn_blocking(move || { | |
| Connection::open(&db_path).map_err(|err| { | |
| SqlitePoolError(format!( | |
| "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}")))?; | |
| let conn = conn_res?; |
| 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, | ||
| } |
There was a problem hiding this comment.
The SQLite default here sets db_name to :memory: and mentions it will be overridden per-test, but the run_test! macro always passes --db-name={db_name} without overriding. This can lead to tests accidentally pointing at an empty in-memory DB. Consider requiring callers to set an explicit temp file path (or generating one here) instead of defaulting to :memory:.
| @@ -0,0 +1,255 @@ | |||
| #[cfg(test)] | |||
| mod sqlite_query_parameters_tests { | |||
| use std::env; | |||
There was a problem hiding this comment.
std::env is imported but never used in this test module. Removing the unused import will keep the test build warning-free.
| use std::env; |
No description provided.