feat(sql): add autoCreate option to Database.load (#3702)

* feat(sql): add autoCreate option to Database.load

Add utoCreate option to Database.load allowing users to disable implicit database creation when loading a database.

Closes #299

* fix(sql): fix unused variable without a database feature

* refactor(sql): share the database existence check between drivers

* fix(sql): don't report a failed existence check as a missing database

* fix(sql): name the database as passed, without password, in the missing database error

* fix(sql): don't create the app config dir when autoCreate is false

* refactor(sql): use the LoadOptions default for missing options

* test(sql): cover autoCreate and password redaction

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
kxrn_guptaandLucas Nogueira authored and GitHub committed 2026-10-12 10:46:00 -03:00
1 parent 0374bec1e2
commit c91a9e6af5
7 files changed
+151 -19

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
sql: minor
sql-js: minor
---
Add `autoCreate` option to `Database.load` to control automatic database creation.
+1 -1
View File
@@ -1 +1 @@
if("__TAURI__"in window){var __TAURI_PLUGIN_SQL__=function(e){"use strict";class t{constructor(e){this.path=e}static async load(n){const s=await e.invoke("plugin:sql|load",{db:n});return new t(s)}static get(e){return new t(e)}async execute(t,n){const[s,a]=await e.invoke("plugin:sql|execute",{db:this.path,query:t,values:n??[]});return{lastInsertId:a,rowsAffected:s}}async select(t,n){return await e.invoke("plugin:sql|select",{db:this.path,query:t,values:n??[]})}async close(t){return await e.invoke("plugin:sql|close",{db:t})}}return t}(window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"sql",{value:__TAURI_PLUGIN_SQL__})}
if("__TAURI__"in window){var __TAURI_PLUGIN_SQL__=function(e){"use strict";class t{constructor(e){this.path=e}static async load(n,s){const i=await e.invoke("plugin:sql|load",{db:n,options:s});return new t(i)}static get(e){return new t(e)}async execute(t,n){const[s,i]=await e.invoke("plugin:sql|execute",{db:this.path,query:t,values:n??[]});return{lastInsertId:i,rowsAffected:s}}async select(t,n){return await e.invoke("plugin:sql|select",{db:this.path,query:t,values:n??[]})}async close(t){return await e.invoke("plugin:sql|close",{db:t})}}return t}(window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"sql",{value:__TAURI_PLUGIN_SQL__})}
+19 -3
View File
@@ -29,6 +29,19 @@ export interface QueryResult {
lastInsertId?: number
}
/**
* Options for loading a database.
*
* @since 2.6.0
*/
export interface LoadOptions {
/**
* Whether to automatically create the database if it does not exist yet.
* Defaults to `true`.
*/
autoCreate?: boolean
}
/**
* **Database**
*
@@ -76,14 +89,17 @@ export default class Database {
* ```typescript
* import Database from '@tauri-apps/plugin-sql'
* const db = await Database.load('sqlite:test.db')
* const existingDb = await Database.load('sqlite:test.db', { autoCreate: false })
* ```
*
* @param path The database connection string, such as `sqlite:test.db`. The database is created if it does not exist yet, and any migration registered for it on the Rust side is run.
* @param path The database connection string, such as `sqlite:test.db`. The database is created if it does not exist yet (unless `options.autoCreate` is `false`), and any migration registered for it on the Rust side is run.
* @param options Configuration options for connecting to the database.
* @returns A promise resolving to a `Database` instance connected to the given database.
*/
static async load(path: string): Promise<Database> {
static async load(path: string, options?: LoadOptions): Promise<Database> {
const _path = await invoke<string>('plugin:sql|load', {
db: path
db: path,
options
})
return new Database(_path)
+15 -1
View File
@@ -9,14 +9,28 @@ use tauri::{AppHandle, Runtime, State, command};
use crate::{DbInstances, DbPool, Error, LastInsertId, Migrations};
#[derive(Debug, Clone, Copy, serde::Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub(crate) struct LoadOptions {
pub auto_create: bool,
}
impl Default for LoadOptions {
fn default() -> Self {
Self { auto_create: true }
}
}
#[command]
pub(crate) async fn load<R: Runtime>(
app: AppHandle<R>,
db_instances: State<'_, DbInstances>,
migrations: State<'_, Migrations>,
db: String,
options: Option<LoadOptions>,
) -> Result<String, crate::Error> {
let pool = DbPool::connect(&db, &app).await?;
let options = options.unwrap_or_default();
let pool = DbPool::connect(&db, &app, options.auto_create).await?;
if let Some(migrations) = migrations.0.lock().await.remove(&db) {
let migrator = Migrator::new(migrations).await?;
+4
View File
@@ -26,6 +26,10 @@ pub enum Error {
/// Contains the connection string of the database.
#[error("database {0} not loaded")]
DatabaseNotLoaded(String),
/// The database does not exist and `autoCreate` was disabled when loading it.
/// Contains the connection string of the database.
#[error("database {0} does not exist")]
DatabaseDoesNotExist(String),
/// A value selected from the database has a SQL type that cannot be converted
/// to JSON. Contains the name of that SQL type.
#[error("unsupported datatype: {0}")]
+1 -1
View File
@@ -217,7 +217,7 @@ impl Builder {
let mut lock = instances.0.write().await;
for db in config.preload {
let pool = DbPool::connect(&db, app).await?;
let pool = DbPool::connect(&db, app, true).await?;
if let Some(migrations) =
self.migrations.as_mut().and_then(|mm| mm.remove(&db))
+105 -13
View File
@@ -78,6 +78,7 @@ impl DbPool {
pub(crate) async fn connect<R: Runtime>(
conn_url: &str,
_app: &AppHandle<R>,
_auto_create: bool,
) -> Result<Self, crate::Error> {
match conn_url
.split_once(':')
@@ -91,27 +92,25 @@ impl DbPool {
.app_config_dir()
.expect("No App config path was found!");
create_dir_all(&app_path).expect("Couldn't create app config dir");
let conn_url = &path_mapper(app_path, conn_url);
if !Sqlite::database_exists(conn_url).await.unwrap_or(false) {
Sqlite::create_database(conn_url).await?;
if _auto_create {
create_dir_all(&app_path).expect("Couldn't create app config dir");
}
Ok(Self::Sqlite(Pool::connect(conn_url).await?))
let db_url = &path_mapper(app_path, conn_url);
ensure_database::<Sqlite>(db_url, conn_url, _auto_create).await?;
Ok(Self::Sqlite(Pool::connect(db_url).await?))
}
#[cfg(feature = "mysql")]
"mysql" => {
if !MySql::database_exists(conn_url).await.unwrap_or(false) {
MySql::create_database(conn_url).await?;
}
ensure_database::<MySql>(conn_url, &redact_password(conn_url), _auto_create)
.await?;
Ok(Self::MySql(Pool::connect(conn_url).await?))
}
#[cfg(feature = "postgres")]
"postgres" => {
if !Postgres::database_exists(conn_url).await.unwrap_or(false) {
Postgres::create_database(conn_url).await?;
}
ensure_database::<Postgres>(conn_url, &redact_password(conn_url), _auto_create)
.await?;
Ok(Self::Postgres(Pool::connect(conn_url).await?))
}
#[cfg(not(any(feature = "sqlite", feature = "postgres", feature = "mysql")))]
@@ -323,6 +322,43 @@ impl DbPool {
}
}
/// Creates the database if it does not exist yet, or fails with
/// [`crate::Error::DatabaseDoesNotExist`] (naming the connection string `db`
/// the app passed) when `auto_create` is `false`.
/// Errors of the existence check itself (unreachable server, bad credentials)
/// are returned as they are, not as a missing database.
#[cfg(any(feature = "sqlite", feature = "mysql", feature = "postgres"))]
async fn ensure_database<DB: MigrateDatabase>(
conn_url: &str,
db: &str,
auto_create: bool,
) -> Result<(), crate::Error> {
match DB::database_exists(conn_url).await {
Ok(true) => Ok(()),
// When the check fails, creating reports the actual error (or fixes it).
Ok(false) | Err(_) if auto_create => Ok(DB::create_database(conn_url).await?),
Ok(false) => Err(crate::Error::DatabaseDoesNotExist(db.to_string())),
Err(e) => Err(e.into()),
}
}
/// Removes the password from a `scheme://user:password@host/db` connection string,
/// so it can be put in an error message.
#[cfg(any(feature = "mysql", feature = "postgres"))]
fn redact_password(conn_url: &str) -> String {
let Some((scheme, rest)) = conn_url.split_once("://") else {
return conn_url.to_string();
};
let authority_end = rest.find(['/', '?']).unwrap_or(rest.len());
match rest[..authority_end].rsplit_once('@') {
Some((userinfo, _)) => match userinfo.split_once(':') {
Some((user, _)) => format!("{scheme}://{user}:***@{}", &rest[userinfo.len() + 1..]),
None => conn_url.to_string(),
},
None => conn_url.to_string(),
}
}
#[cfg(feature = "sqlite")]
/// Maps the user supplied DB connection string to a connection string
/// with a fully qualified file path to the App's designed "app_path"
@@ -341,3 +377,59 @@ fn path_mapper(mut app_path: std::path::PathBuf, connection_string: &str) -> Str
.expect("Problem creating fully qualified path to Database file!")
)
}
#[cfg(test)]
mod tests {
#[cfg(feature = "sqlite")]
#[test]
fn ensure_database_respects_auto_create() {
use super::ensure_database;
use sqlx::Sqlite;
let path = std::env::temp_dir().join(format!("tauri-plugin-sql-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let url = format!("sqlite:{}", path.display());
tauri::async_runtime::block_on(async {
let err = ensure_database::<Sqlite>(&url, "sqlite:test.db", false)
.await
.unwrap_err();
assert_eq!(err.to_string(), "database sqlite:test.db does not exist");
assert!(!path.exists());
ensure_database::<Sqlite>(&url, "sqlite:test.db", true)
.await
.unwrap();
assert!(path.exists());
ensure_database::<Sqlite>(&url, "sqlite:test.db", false)
.await
.unwrap();
});
let _ = std::fs::remove_file(&path);
}
#[cfg(any(feature = "mysql", feature = "postgres"))]
#[test]
fn redact_password() {
use super::redact_password;
assert_eq!(
redact_password("mysql://user:secret@localhost:3306/db"),
"mysql://user:***@localhost:3306/db"
);
assert_eq!(
redact_password("postgres://user:p@ss@host/db?sslmode=require"),
"postgres://user:***@host/db?sslmode=require"
);
assert_eq!(
redact_password("postgres://user@host/db"),
"postgres://user@host/db"
);
assert_eq!(
redact_password("postgres://host/db?user=a@b"),
"postgres://host/db?user=a@b"
);
}
}