feat: add database select and execute commands

- Introduced `select` and `execute` functions in the database module to facilitate querying and executing SQL commands.
- Updated the Tauri plugin to expose these commands, allowing for database interactions from the frontend.
- Added corresponding permissions for the new commands in the permissions configuration.
- Enhanced the database library with JSON value handling for query parameters.
This commit is contained in:
Huakun Shen 2025-03-25 03:53:22 -04:00
parent b3e2082abe
commit 3271507d0c
No known key found for this signature in database
12 changed files with 340 additions and 1 deletions

View File

@ -31,6 +31,7 @@
SystemCmds
} from "@kksh/ui/main"
import { cn } from "@kksh/ui/utils"
import * as db from "@kunkunapi/src/commands/db"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import { getCurrentWindow, Window } from "@tauri-apps/api/window"
import { platform } from "@tauri-apps/plugin-os"
@ -110,6 +111,22 @@
<Inspect name="devStoreExtCmds" value={$devStoreExtCmds} />
<Inspect name="$appState.searchTerm" value={$appState.searchTerm} />
-->
<Button
onclick={() => {
db.select("SELECT * FROM extensions;", []).then((res) => {
console.log(res)
})
db.execute(
"INSERT INTO extension_data (ext_id, data_type, data, search_text, metadata) VALUES (?, ?, ?, ?, ?);",
[1, "Test", "Hello, world!", "Hello, world!", "{'metadata': 'test'}"]
).then((res) => {
console.log(res)
})
}}
>
Select
</Button>
<Command.Root
class={cn("h-screen rounded-lg shadow-md")}
bind:value={$appState.highlightedCmd}

View File

@ -5,6 +5,34 @@ import { CmdType, Ext, ExtCmd, ExtData } from "../models/extension"
import { convertDateToSqliteString, SearchMode, SearchModeEnum, SQLSortOrder } from "../models/sql"
import { generateJarvisPluginCommand } from "./common"
export interface QueryResult {
/** The number of rows affected by the query. */
rowsAffected: number
/**
* The last inserted `id`.
*
* This value is not set for Postgres databases. If the
* last inserted id is required on Postgres, the `select` function
* must be used, with a `RETURNING` clause
* (`INSERT INTO todos (title) VALUES ($1) RETURNING id`).
*/
lastInsertId?: number
}
export function select(query: string, values: any[]) {
return invoke<any[]>(generateJarvisPluginCommand("select"), {
query,
values
})
}
export function execute(query: string, values: any[]) {
return invoke<QueryResult>(generateJarvisPluginCommand("execute"), {
query,
values
})
}
/* -------------------------------------------------------------------------- */
/* Extension CRUD */
/* -------------------------------------------------------------------------- */

View File

@ -4,7 +4,9 @@ version = "0.1.0"
edition = "2021"
[dependencies]
rusqlite = { version = "0.31.0", features = ["bundled-sqlcipher-vendored-openssl"] }
rusqlite = { version = "0.31.0", features = [
"bundled-sqlcipher-vendored-openssl",
] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tempfile = "3.10.1"

View File

@ -2,6 +2,7 @@ pub mod models;
pub mod schema;
use models::{CmdType, ExtDataField, ExtDataSearchQuery, SearchMode};
use rusqlite::{params, params_from_iter, Connection, Result, ToSql};
use serde_json::Value as JsonValue;
use serde_json::{json, Value};
use std::path::Path;
@ -122,6 +123,95 @@ impl JarvisDB {
Ok(())
}
pub fn select(&self, query: String, values: Vec<JsonValue>) -> Result<Vec<JsonValue>> {
let mut stmt = self.conn.prepare(&query)?;
// Convert JsonValue parameters to appropriate types for rusqlite
let mut params: Vec<Box<dyn ToSql>> = Vec::new();
for value in values {
if value.is_null() {
params.push(Box::new(Option::<String>::None));
} else if value.is_string() {
params.push(Box::new(value.as_str().unwrap().to_owned()));
} else if let Some(number) = value.as_number() {
if number.is_i64() {
params.push(Box::new(number.as_i64().unwrap()));
} else if number.is_u64() {
params.push(Box::new(number.as_u64().unwrap() as i64));
} else {
params.push(Box::new(number.as_f64().unwrap()));
}
} else {
params.push(Box::new(value.to_string()));
}
}
// Get column names from statement
let column_names: Vec<String> = (0..stmt.column_count())
.map(|i| stmt.column_name(i).unwrap().to_string())
.collect();
// Execute the query with the converted parameters and map results
let rows = stmt.query_map(params_from_iter(params.iter().map(|p| p.as_ref())), |row| {
let mut result = serde_json::Map::new();
for (i, name) in column_names.iter().enumerate() {
let value: Value = match row.get_ref(i)? {
rusqlite::types::ValueRef::Null => Value::Null,
rusqlite::types::ValueRef::Integer(i) => Value::Number(i.into()),
rusqlite::types::ValueRef::Real(r) => Value::Number(
serde_json::Number::from_f64(r).unwrap_or(serde_json::Number::from(0)),
),
rusqlite::types::ValueRef::Text(t) => {
Value::String(String::from_utf8_lossy(t).into_owned())
}
rusqlite::types::ValueRef::Blob(b) => {
Value::String(String::from_utf8_lossy(b).into_owned())
}
};
result.insert(name.clone(), value);
}
Ok(Value::Object(result))
})?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
}
pub fn execute(&self, query: &str, values: Vec<JsonValue>) -> Result<(u64, i64)> {
let mut stmt = self.conn.prepare(query)?;
// Convert JsonValue parameters to appropriate types for rusqlite
let mut params: Vec<Box<dyn ToSql>> = Vec::new();
for value in values {
if value.is_null() {
params.push(Box::new(Option::<String>::None));
} else if value.is_string() {
params.push(Box::new(value.as_str().unwrap().to_owned()));
} else if let Some(number) = value.as_number() {
if number.is_i64() {
params.push(Box::new(number.as_i64().unwrap()));
} else if number.is_u64() {
params.push(Box::new(number.as_u64().unwrap() as i64));
} else {
params.push(Box::new(number.as_f64().unwrap()));
}
} else {
params.push(Box::new(value.to_string()));
}
}
// Execute the query with the converted parameters
let rows_affected = stmt.execute(params_from_iter(params.iter().map(|p| p.as_ref())))?;
// Get the last insert rowid
let last_insert_id = self.conn.last_insert_rowid();
Ok((rows_affected as u64, last_insert_id))
}
pub fn get_all_extensions(&self) -> Result<Vec<models::Ext>> {
let mut stmt = self.conn.prepare(
"SELECT ext_id, identifier, path, data, version, enabled, installed_at FROM extensions",
@ -983,4 +1073,72 @@ mod tests {
fs::remove_file(&db_path).unwrap();
}
#[test]
fn test_select_and_execute() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.db");
let db = JarvisDB::new(&db_path, None).unwrap();
db.init().unwrap();
// Create a simple todo table
db.execute(
"CREATE TABLE todos (id INTEGER PRIMARY KEY, title TEXT, completed BOOLEAN)",
vec![],
)
.unwrap();
// Test execute with INSERT
let (rows_affected, last_id) = db
.execute(
"INSERT INTO todos (title, completed) VALUES (?1, ?2)",
vec![json!("Buy groceries"), json!(false)],
)
.unwrap();
assert_eq!(rows_affected, 1);
assert_eq!(last_id, 1);
// Test select with basic query
let results = db
.select(
"SELECT title, completed FROM todos WHERE id = ?1".to_string(),
vec![json!(1)],
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0]["title"], "Buy groceries");
assert_eq!(results[0]["completed"], "false");
// Test execute with UPDATE
let (rows_affected, _) = db
.execute(
"UPDATE todos SET completed = ?1 WHERE id = ?2",
vec![json!(true), json!(1)],
)
.unwrap();
assert_eq!(rows_affected, 1);
// Verify the update with select
let results = db
.select(
"SELECT completed FROM todos WHERE id = ?1".to_string(),
vec![json!(1)],
)
.unwrap();
assert_eq!(results[0]["completed"], "true");
// Test execute with DELETE
let (rows_affected, _) = db
.execute("DELETE FROM todos WHERE id = ?1", vec![json!(1)])
.unwrap();
assert_eq!(rows_affected, 1);
// Verify the deletion
let results = db
.select("SELECT COUNT(*) as count FROM todos".to_string(), vec![])
.unwrap();
assert_eq!(results[0]["count"], 0);
fs::remove_file(&db_path).unwrap();
}
}

View File

@ -98,6 +98,8 @@ const COMMANDS: &[&str] = &[
"search_extension_data",
"delete_extension_data_by_id",
"update_extension_data_by_id",
"select",
"execute",
/* -------------------------------- Clipboard ------------------------------- */
"add_to_history",
"get_history",

View File

@ -65,6 +65,8 @@ commands.allow = [
"get_ext_label_map",
"get_frontmost_app",
# Database
"select",
"execute",
"create_extension",
"get_all_extensions",
"get_unique_extension_by_identifier",

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-execute"
description = "Enables the execute command without any pre-configured scope."
commands.allow = ["execute"]
[[permission]]
identifier = "deny-execute"
description = "Denies the execute command without any pre-configured scope."
commands.deny = ["execute"]

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-select"
description = "Enables the select command without any pre-configured scope."
commands.allow = ["select"]
[[permission]]
identifier = "deny-select"
description = "Denies the select command without any pre-configured scope."
commands.deny = ["select"]

View File

@ -492,6 +492,32 @@ Denies the empty_trash command without any pre-configured scope.
<tr>
<td>
`jarvis:allow-execute`
</td>
<td>
Enables the execute command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`jarvis:deny-execute`
</td>
<td>
Denies the execute command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`jarvis:allow-file-search`
</td>
@ -1610,6 +1636,32 @@ Denies the search_extension_data command without any pre-configured scope.
<tr>
<td>
`jarvis:allow-select`
</td>
<td>
Enables the select command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`jarvis:deny-select`
</td>
<td>
Denies the select command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`jarvis:allow-server-is-running`
</td>

View File

@ -479,6 +479,16 @@
"type": "string",
"const": "deny-empty-trash"
},
{
"description": "Enables the execute command without any pre-configured scope.",
"type": "string",
"const": "allow-execute"
},
{
"description": "Denies the execute command without any pre-configured scope.",
"type": "string",
"const": "deny-execute"
},
{
"description": "Enables the file_search command without any pre-configured scope.",
"type": "string",
@ -909,6 +919,16 @@
"type": "string",
"const": "deny-search-extension-data"
},
{
"description": "Enables the select command without any pre-configured scope.",
"type": "string",
"const": "allow-select"
},
{
"description": "Denies the select command without any pre-configured scope.",
"type": "string",
"const": "deny-select"
},
{
"description": "Enables the server_is_running command without any pre-configured scope.",
"type": "string",

View File

@ -2,6 +2,7 @@ use db::{
models::{Cmd, CmdType, Ext, ExtData, ExtDataField, ExtDataSearchQuery, SQLSortOrder},
JarvisDB,
};
use serde_json::{json, Value as JsonValue};
use std::{path::PathBuf, sync::Mutex};
use tauri::State;
@ -245,3 +246,31 @@ pub async fn update_extension_data_by_id(
.update_extension_data_by_id(data_id, data, search_text)
.map_err(|err| err.to_string())
}
#[tauri::command]
pub async fn select(
db: State<'_, DBState>,
query: &str,
values: Vec<JsonValue>,
) -> Result<Vec<JsonValue>, String> {
db.db
.lock()
.unwrap()
.select(query.to_string(), values)
.map_err(|err| err.to_string())
}
#[tauri::command]
pub async fn execute(
db: State<'_, DBState>,
query: &str,
values: Vec<JsonValue>,
) -> Result<Vec<JsonValue>, String> {
let (rows_affected, last_id) = db
.db
.lock()
.unwrap()
.execute(query, values)
.map_err(|err| err.to_string())?;
Ok(vec![json!([rows_affected, last_id])])
}

View File

@ -140,6 +140,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
// commands::storage::ext_store_wrapper_load,
// commands::storage::ext_store_wrapper_save,
/* -------------------------------- database -------------------------------- */
commands::db::select,
commands::db::execute,
commands::db::create_extension,
commands::db::create_extension,
commands::db::get_all_extensions,
commands::db::get_unique_extension_by_identifier,