diff --git a/plugins/sql/permissions/autogenerated/commands/remove_update_hook.toml b/plugins/sql/permissions/autogenerated/commands/remove_update_hook.toml new file mode 100644 index 0000000000..0178cbdcf5 --- /dev/null +++ b/plugins/sql/permissions/autogenerated/commands/remove_update_hook.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-remove-update-hook" +description = "Enables the remove_update_hook command without any pre-configured scope." +commands.allow = ["remove_update_hook"] + +[[permission]] +identifier = "deny-remove-update-hook" +description = "Denies the remove_update_hook command without any pre-configured scope." +commands.deny = ["remove_update_hook"] diff --git a/plugins/sql/permissions/autogenerated/commands/setup_update_hook.toml b/plugins/sql/permissions/autogenerated/commands/setup_update_hook.toml new file mode 100644 index 0000000000..b8e54470d0 --- /dev/null +++ b/plugins/sql/permissions/autogenerated/commands/setup_update_hook.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-setup-update-hook" +description = "Enables the setup_update_hook command without any pre-configured scope." +commands.allow = ["setup_update_hook"] + +[[permission]] +identifier = "deny-setup-update-hook" +description = "Denies the setup_update_hook command without any pre-configured scope." +commands.deny = ["setup_update_hook"] diff --git a/plugins/sql/permissions/autogenerated/reference.md b/plugins/sql/permissions/autogenerated/reference.md index c829b103e8..7a60eaf5ee 100644 --- a/plugins/sql/permissions/autogenerated/reference.md +++ b/plugins/sql/permissions/autogenerated/reference.md @@ -106,6 +106,32 @@ Denies the load command without any pre-configured scope. +`sql:allow-remove-update-hook` + + + + +Enables the remove_update_hook command without any pre-configured scope. + + + + + + + +`sql:deny-remove-update-hook` + + + + +Denies the remove_update_hook command without any pre-configured scope. + + + + + + + `sql:allow-select` @@ -126,6 +152,32 @@ Enables the select command without any pre-configured scope. Denies the select command without any pre-configured scope. + + + + + + +`sql:allow-setup-update-hook` + + + + +Enables the setup_update_hook command without any pre-configured scope. + + + + + + + +`sql:deny-setup-update-hook` + + + + +Denies the setup_update_hook command without any pre-configured scope. + diff --git a/plugins/sql/permissions/schemas/schema.json b/plugins/sql/permissions/schemas/schema.json index 488a953c59..3c7093443c 100644 --- a/plugins/sql/permissions/schemas/schema.json +++ b/plugins/sql/permissions/schemas/schema.json @@ -330,6 +330,18 @@ "const": "deny-load", "markdownDescription": "Denies the load command without any pre-configured scope." }, + { + "description": "Enables the remove_update_hook command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-update-hook", + "markdownDescription": "Enables the remove_update_hook command without any pre-configured scope." + }, + { + "description": "Denies the remove_update_hook command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-update-hook", + "markdownDescription": "Denies the remove_update_hook command without any pre-configured scope." + }, { "description": "Enables the select command without any pre-configured scope.", "type": "string", @@ -342,6 +354,18 @@ "const": "deny-select", "markdownDescription": "Denies the select command without any pre-configured scope." }, + { + "description": "Enables the setup_update_hook command without any pre-configured scope.", + "type": "string", + "const": "allow-setup-update-hook", + "markdownDescription": "Enables the setup_update_hook command without any pre-configured scope." + }, + { + "description": "Denies the setup_update_hook command without any pre-configured scope.", + "type": "string", + "const": "deny-setup-update-hook", + "markdownDescription": "Denies the setup_update_hook command without any pre-configured scope." + }, { "description": "### Default Permissions\n\nThis permission set configures what kind of\ndatabase operations are available from the sql plugin.\n\n### Granted Permissions\n\nAll reading related operations are enabled.\nAlso allows to load or close a connection.\n\n\n#### This default permission set includes:\n\n- `allow-close`\n- `allow-load`\n- `allow-select`", "type": "string", diff --git a/plugins/sql/src/lib.rs b/plugins/sql/src/lib.rs index 56b2a3a6b7..96b8b93139 100644 --- a/plugins/sql/src/lib.rs +++ b/plugins/sql/src/lib.rs @@ -12,6 +12,8 @@ mod commands; mod decode; mod error; +#[cfg(feature = "sqlite")] +mod update_hook; mod wrapper; pub use error::Error; @@ -140,7 +142,11 @@ impl Builder { commands::load, commands::execute, commands::select, - commands::close + commands::close, + #[cfg(feature = "sqlite")] + update_hook::setup_update_hook, + #[cfg(feature = "sqlite")] + update_hook::remove_update_hook, ]) .setup(|app, api| { let config = api.config().clone().unwrap_or_default(); diff --git a/plugins/sql/src/update_hook.rs b/plugins/sql/src/update_hook.rs new file mode 100644 index 0000000000..710b91c0c4 --- /dev/null +++ b/plugins/sql/src/update_hook.rs @@ -0,0 +1,98 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use serde::Serialize; +use tauri::{command, AppHandle, Emitter, Runtime, State}; + +use crate::{DbInstances, DbPool, Error}; + +#[derive(Clone, Serialize)] +pub struct UpdateHookEvent { + pub operation: String, + pub database: String, + pub table: String, + pub rowid: i64, +} + +#[command] +pub(crate) async fn setup_update_hook( + app: AppHandle, + db_instances: State<'_, DbInstances>, + db: String, +) -> Result<(), Error> { + #[cfg(feature = "sqlite")] + { + let instances = db_instances.0.read().await; + let db_pool = instances + .get(&db) + .ok_or_else(|| Error::DatabaseNotLoaded(db.clone()))?; + + let sqlite_pool = match db_pool { + DbPool::Sqlite(pool) => pool, + _ => return Err(Error::InvalidDbUrl( + format!("Cannot setup update hook for {}: update hooks are only supported for SQLite databases", db) + )), + }; + + sqlite_pool + .rebuild_pool(Some(move |result: sqlx::sqlite::UpdateHookResult| { + let operation = match result.operation { + sqlx::sqlite::SqliteOperation::Insert => "INSERT", + sqlx::sqlite::SqliteOperation::Update => "UPDATE", + sqlx::sqlite::SqliteOperation::Delete => "DELETE", + sqlx::sqlite::SqliteOperation::Unknown(_) => "UNKNOWN", + }; + + let event = UpdateHookEvent { + operation: operation.to_string(), + database: result.database.to_string(), + table: result.table.to_string(), + rowid: result.rowid, + }; + + if let Err(e) = app.emit("sqlite-update-hook", &event) { + log::error!("[tauri-plugin-sql] Failed to emit update hook event: {}", e); + } + })) + .await + .map_err(Error::Sql)?; + + Ok(()) + } +} + +#[command] +pub(crate) async fn remove_update_hook( + db_instances: State<'_, DbInstances>, + db: String, +) -> Result<(), Error> { + #[cfg(feature = "sqlite")] + { + let instances = db_instances.0.read().await; + let db_pool = instances + .get(&db) + .ok_or_else(|| Error::DatabaseNotLoaded(db.clone()))?; + + let sqlite_pool = match db_pool { + DbPool::Sqlite(pool) => pool, + _ => return Err(Error::InvalidDbUrl( + format!("Cannot remove update hook for {}: update hooks are only supported for SQLite databases", db) + )), + }; + + sqlite_pool + .rebuild_pool(None::) + .await + .map_err(Error::Sql)?; + + Ok(()) + } + + #[cfg(not(feature = "sqlite"))] + { + Err(Error::InvalidDbUrl( + "Update hooks are only supported for SQLite".to_string(), + )) + } +} diff --git a/plugins/sql/src/wrapper.rs b/plugins/sql/src/wrapper.rs index d47b2d1cbe..2ba4ff2f58 100644 --- a/plugins/sql/src/wrapper.rs +++ b/plugins/sql/src/wrapper.rs @@ -18,13 +18,63 @@ use sqlx::MySql; #[cfg(feature = "postgres")] use sqlx::Postgres; #[cfg(feature = "sqlite")] -use sqlx::Sqlite; +use sqlx::{pool::PoolOptions, sqlite::SqliteConnection, Sqlite}; +#[cfg(feature = "sqlite")] +use std::sync::Arc; +#[cfg(feature = "sqlite")] +use tokio::sync::RwLock; use crate::LastInsertId; +#[cfg(feature = "sqlite")] +pub struct SqlitePoolWithHook { + pool: Arc>>, + db_url: String, +} + +#[cfg(feature = "sqlite")] +impl SqlitePoolWithHook { + pub fn pool(&self) -> Arc>> { + Arc::clone(&self.pool) + } + + pub fn db_url(&self) -> &str { + &self.db_url + } + + pub async fn rebuild_pool(&self, hook_fn: Option) -> Result<(), sqlx::Error> + where + F: Fn(sqlx::sqlite::UpdateHookResult) + Send + Sync + 'static, + { + let new_pool = if let Some(hook_fn) = hook_fn { + let hook_fn = Arc::new(hook_fn); + PoolOptions::new() + .after_connect(move |conn: &mut SqliteConnection, _meta| { + let hook_fn = Arc::clone(&hook_fn); + Box::pin(async move { + conn.lock_handle().await?.set_update_hook(move |result| { + hook_fn(result); + }); + Ok(()) + }) + }) + .connect(&self.db_url) + .await? + } else { + Pool::connect(&self.db_url).await? + }; + + let mut pool_guard = self.pool.write().await; + pool_guard.close().await; + *pool_guard = new_pool; + + Ok(()) + } +} + pub enum DbPool { #[cfg(feature = "sqlite")] - Sqlite(Pool), + Sqlite(SqlitePoolWithHook), #[cfg(feature = "mysql")] MySql(Pool), #[cfg(feature = "postgres")] @@ -88,7 +138,11 @@ impl DbPool { if !Sqlite::database_exists(conn_url).await.unwrap_or(false) { Sqlite::create_database(conn_url).await?; } - Ok(Self::Sqlite(Pool::connect(conn_url).await?)) + let pool = Pool::connect(conn_url).await?; + Ok(Self::Sqlite(SqlitePoolWithHook { + pool: Arc::new(RwLock::new(pool)), + db_url: conn_url.to_string(), + })) } #[cfg(feature = "mysql")] "mysql" => { @@ -119,7 +173,10 @@ impl DbPool { ) -> Result<(), crate::Error> { match self { #[cfg(feature = "sqlite")] - DbPool::Sqlite(pool) => _migrator.run(pool).await?, + DbPool::Sqlite(sqlite_pool) => { + let pool = sqlite_pool.pool.read().await; + _migrator.run(&*pool).await? + } #[cfg(feature = "mysql")] DbPool::MySql(pool) => _migrator.run(pool).await?, #[cfg(feature = "postgres")] @@ -133,7 +190,7 @@ impl DbPool { pub(crate) async fn close(&self) { match self { #[cfg(feature = "sqlite")] - DbPool::Sqlite(pool) => pool.close().await, + DbPool::Sqlite(sqlite_pool) => sqlite_pool.pool.read().await.close().await, #[cfg(feature = "mysql")] DbPool::MySql(pool) => pool.close().await, #[cfg(feature = "postgres")] @@ -150,7 +207,8 @@ impl DbPool { ) -> Result<(u64, LastInsertId), crate::Error> { Ok(match self { #[cfg(feature = "sqlite")] - DbPool::Sqlite(pool) => { + DbPool::Sqlite(sqlite_pool) => { + let pool = sqlite_pool.pool.read().await; let mut query = sqlx::query(&_query); for value in _values { if value.is_null() { @@ -218,7 +276,8 @@ impl DbPool { ) -> Result>, crate::Error> { Ok(match self { #[cfg(feature = "sqlite")] - DbPool::Sqlite(pool) => { + DbPool::Sqlite(sqlite_pool) => { + let pool = sqlite_pool.pool.read().await; let mut query = sqlx::query(&_query); for value in _values { if value.is_null() {