diff --git a/.changes/sql-auto-create-option.md b/.changes/sql-auto-create-option.md new file mode 100644 index 000000000..befedcada --- /dev/null +++ b/.changes/sql-auto-create-option.md @@ -0,0 +1,6 @@ +--- +sql: minor +sql-js: minor +--- + +Add `autoCreate` option to `Database.load` to control automatic database creation. diff --git a/plugins/sql/api-iife.js b/plugins/sql/api-iife.js index 036d8ee3a..0f847a9b1 100644 --- a/plugins/sql/api-iife.js +++ b/plugins/sql/api-iife.js @@ -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__})} diff --git a/plugins/sql/guest-js/index.ts b/plugins/sql/guest-js/index.ts index 19e40afac..e887a711f 100644 --- a/plugins/sql/guest-js/index.ts +++ b/plugins/sql/guest-js/index.ts @@ -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 { + static async load(path: string, options?: LoadOptions): Promise { const _path = await invoke('plugin:sql|load', { - db: path + db: path, + options }) return new Database(_path) diff --git a/plugins/sql/src/commands.rs b/plugins/sql/src/commands.rs index 89b89bea3..a1100951d 100644 --- a/plugins/sql/src/commands.rs +++ b/plugins/sql/src/commands.rs @@ -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( app: AppHandle, db_instances: State<'_, DbInstances>, migrations: State<'_, Migrations>, db: String, + options: Option, ) -> Result { - 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?; diff --git a/plugins/sql/src/error.rs b/plugins/sql/src/error.rs index 0f4f68198..42d9a3656 100644 --- a/plugins/sql/src/error.rs +++ b/plugins/sql/src/error.rs @@ -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}")] diff --git a/plugins/sql/src/lib.rs b/plugins/sql/src/lib.rs index 20eee0337..81c357e38 100644 --- a/plugins/sql/src/lib.rs +++ b/plugins/sql/src/lib.rs @@ -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)) diff --git a/plugins/sql/src/wrapper.rs b/plugins/sql/src/wrapper.rs index da0589433..5f3819f91 100644 --- a/plugins/sql/src/wrapper.rs +++ b/plugins/sql/src/wrapper.rs @@ -78,6 +78,7 @@ impl DbPool { pub(crate) async fn connect( conn_url: &str, _app: &AppHandle, + _auto_create: bool, ) -> Result { 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::(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::(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::(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( + 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::(&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::(&url, "sqlite:test.db", true) + .await + .unwrap(); + assert!(path.exists()); + + ensure_database::(&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" + ); + } +}