From 30cb94274b957b337ef969c4158e516cb9d0964d Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Sun, 17 Nov 2024 22:31:34 +1100 Subject: [PATCH] Implement SQL transactions --- src-tauri/Cargo.lock | 3 + src-tauri/Cargo.toml | 8 +- src-tauri/src/lib.rs | 35 ++-- src-tauri/src/sql.rs | 233 +++++++++++++++++++++++++++ src/components/TransactionEditor.vue | 61 +++---- src/db.ts | 21 ++- src/dbutil.ts | 98 +++++++++++ src/pages/TransactionsView.vue | 2 +- 8 files changed, 411 insertions(+), 50 deletions(-) create mode 100644 src-tauri/src/sql.rs create mode 100644 src/dbutil.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6190309..9d130d7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -927,14 +927,17 @@ dependencies = [ name = "drcr" version = "0.1.0" dependencies = [ + "indexmap 2.6.0", "serde", "serde_json", + "sqlx", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-shell", "tauri-plugin-sql", "tauri-plugin-store", + "tokio", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f267ddd..49e3241 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,11 +18,13 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } -tauri-plugin-shell = "2" +indexmap = { version = "2", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sqlx = { version = "0.8", features = ["json", "time"] } +tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" +tauri-plugin-shell = "2" tauri-plugin-sql = { version = "2", features = ["sqlite"] } tauri-plugin-store = "2" - +tokio = { version = "1", features = ["sync"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5ddbd40..44aa338 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,32 +16,41 @@ along with this program. If not, see . */ +mod sql; + use tauri::{AppHandle, Builder, Manager, State}; use tauri_plugin_store::StoreExt; +use tokio::sync::Mutex; use std::fs; -use std::sync::Mutex; struct AppState { db_filename: Option, + sql_transactions: Vec>, +} + +// Filename state + +#[tauri::command] +async fn get_open_filename(state: State<'_, Mutex>) -> Result, tauri_plugin_sql::Error> { + let state = state.lock().await; + Ok(state.db_filename.clone()) } #[tauri::command] -fn get_open_filename(state: State<'_, Mutex>) -> Option { - let state = state.lock().unwrap(); - state.db_filename.clone() -} - -#[tauri::command] -fn set_open_filename(state: State<'_, Mutex>, app: AppHandle, filename: Option) { - let mut state = state.lock().unwrap(); +async fn set_open_filename(state: State<'_, Mutex>, app: AppHandle, filename: Option) -> Result<(), tauri_plugin_sql::Error> { + let mut state = state.lock().await; state.db_filename = filename.clone(); // Persist in store let store = app.store("store.json").expect("Error opening store"); store.set("db_filename", filename); + + Ok(()) } +// Main method + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { Builder::default() @@ -61,7 +70,8 @@ pub fn run() { }; app.manage(Mutex::new(AppState { - db_filename: db_filename + db_filename: db_filename, + sql_transactions: Vec::new(), })); Ok(()) @@ -70,7 +80,10 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_sql::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build()) - .invoke_handler(tauri::generate_handler![get_open_filename, set_open_filename]) + .invoke_handler(tauri::generate_handler![ + get_open_filename, set_open_filename, + sql::sql_transaction_begin, sql::sql_transaction_execute, sql::sql_transaction_select, sql::sql_transaction_rollback, sql::sql_transaction_commit + ]) .run(tauri::generate_context!()) .expect("Error while running tauri application"); } diff --git a/src-tauri/src/sql.rs b/src-tauri/src/sql.rs new file mode 100644 index 0000000..db65083 --- /dev/null +++ b/src-tauri/src/sql.rs @@ -0,0 +1,233 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use indexmap::IndexMap; +use serde_json::Value as JsonValue; +use sqlx::{Column, Executor, Row, Sqlite, Transaction, TypeInfo, Value, ValueRef}; +use sqlx::query::Query; +use sqlx::sqlite::{SqliteArguments, SqliteRow, SqliteValueRef}; +use sqlx::types::time::{Date, PrimitiveDateTime, Time}; +use tokio::sync::Mutex; + +use tauri::State; +use tauri_plugin_sql::{DbInstances, DbPool, Error}; + +use crate::AppState; + +pub type SqliteTransaction = Transaction<'static, Sqlite>; + +#[tauri::command] +pub async fn sql_transaction_begin(state: State<'_, Mutex>, db_instances: State<'_, DbInstances>, db: String) -> Result { + let instances = db_instances.0.read().await; + let db = instances.get(&db).ok_or(Error::DatabaseNotLoaded(db))?; + + let pool = match db { + DbPool::Sqlite(pool) => pool, + //_ => panic!("Unexpected non-SQLite backend"), + }; + + // Open transaction + let transaction = pool.begin().await?; + + // Store transaction in state + let mut state = state.lock().await; + let available_index = state.sql_transactions.iter().position(|t| t.is_none()); + match available_index { + Some(i) => { + state.sql_transactions[i] = Some(transaction); + Ok(i) + } + None => { + state.sql_transactions.push(Some(transaction)); + Ok(state.sql_transactions.len() - 1) + } + } +} + +#[tauri::command] +pub async fn sql_transaction_execute(state: State<'_, Mutex>, transaction_instance_id: usize, query: String, values: Vec) -> Result<(u64, i64), Error> { + let mut state = state.lock().await; + let transaction = + state.sql_transactions.get_mut(transaction_instance_id) + .expect("Invalid database transaction ID") + .as_mut() // Take reference to transaction rather than moving out of the Vec + .expect("Database transaction ID used after closed"); + + let query = prepare_query(&query, values); + let result = transaction.execute(query).await?; + Ok(( + result.rows_affected(), + result.last_insert_rowid(), + )) +} + +#[tauri::command] +pub async fn sql_transaction_select(state: State<'_, Mutex>, transaction_instance_id: usize, query: String, values: Vec) -> Result>, Error> { + let mut state = state.lock().await; + let transaction = + state.sql_transactions.get_mut(transaction_instance_id) + .expect("Invalid database transaction ID") + .as_mut() // Take reference to transaction rather than moving out of the Vec + .expect("Database transaction ID used after closed"); + + let query = prepare_query(&query, values); + let rows = transaction.fetch_all(query).await?; + rows_to_vec(rows) +} + +#[tauri::command] +pub async fn sql_transaction_rollback(state: State<'_, Mutex>, transaction_instance_id: usize) -> Result<(), Error> { + let mut state = state.lock().await; + + let transaction = state.sql_transactions.get_mut(transaction_instance_id) + .expect("Invalid database transaction ID") + .take() // Remove from Vec + .expect("Database transaction ID used after closed"); + + transaction.rollback().await?; + Ok(()) +} + +#[tauri::command] +pub async fn sql_transaction_commit(state: State<'_, Mutex>, transaction_instance_id: usize) -> Result<(), Error> { + let mut state = state.lock().await; + + let transaction = state.sql_transactions.get_mut(transaction_instance_id) + .expect("Invalid database transaction ID") + .take() // Remove from Vec + .expect("Database transaction ID used after closed"); + + transaction.commit().await?; + Ok(()) +} + +fn prepare_query<'a, 'b: 'a>(_query: &'b str, _values: Vec) -> Query<'b, Sqlite, SqliteArguments<'a>> { + // Copied from tauri_plugin_sql/src/commands.rs + // Copyright 2019-2023 Tauri Programme within The Commons Conservancy + // Licensed under MIT/Apache 2.0 + + let mut query = sqlx::query(_query); + for value in _values { + if value.is_null() { + query = query.bind(None::); + } else if value.is_string() { + query = query.bind(value.as_str().unwrap().to_owned()) + } else if let Some(number) = value.as_number() { + query = query.bind(number.as_f64().unwrap_or_default()) + } else { + query = query.bind(value); + } + } + query +} + +fn rows_to_vec(rows: Vec) -> Result>, Error> { + // Copied from tauri_plugin_sql/src/commands.rs + // Copyright 2019-2023 Tauri Programme within The Commons Conservancy + // Licensed under MIT/Apache 2.0 + + let mut values = Vec::new(); + for row in rows { + let mut value = IndexMap::default(); + for (i, column) in row.columns().iter().enumerate() { + let v = row.try_get_raw(i)?; + + let v = decode_sqlite_to_json(v)?; + + value.insert(column.name().to_string(), v); + } + + values.push(value); + } + Ok(values) +} + +fn decode_sqlite_to_json(v: SqliteValueRef) -> Result { + // Copied from tauri_plugin_sql/src/decode/sqlite.rs + // Copyright 2019-2023 Tauri Programme within The Commons Conservancy + // Licensed under MIT/Apache 2.0 + + // Same as tauri_plugin_sql::decode::sqlite::to_json but that function is not exposed + + if v.is_null() { + return Ok(JsonValue::Null); + } + + let res = match v.type_info().name() { + "TEXT" => { + if let Ok(v) = v.to_owned().try_decode() { + JsonValue::String(v) + } else { + JsonValue::Null + } + } + "REAL" => { + if let Ok(v) = v.to_owned().try_decode::() { + JsonValue::from(v) + } else { + JsonValue::Null + } + } + "INTEGER" | "NUMERIC" => { + if let Ok(v) = v.to_owned().try_decode::() { + JsonValue::Number(v.into()) + } else { + JsonValue::Null + } + } + "BOOLEAN" => { + if let Ok(v) = v.to_owned().try_decode() { + JsonValue::Bool(v) + } else { + JsonValue::Null + } + } + "DATE" => { + if let Ok(v) = v.to_owned().try_decode::() { + JsonValue::String(v.to_string()) + } else { + JsonValue::Null + } + } + "TIME" => { + if let Ok(v) = v.to_owned().try_decode::