feat: add Tauri v2 Rust backend with PostgreSQL support
Add Rust backend: AppState with connection pool management, TuskError handling, ConnectionConfig model, and all commands for connections, schema browsing, query execution, data CRUD, and CSV/JSON export via sqlx. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
4
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
5723
src-tauri/Cargo.lock
generated
Normal file
32
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "tusk"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "PostgreSQL Database Management GUI"
|
||||||
|
authors = ["you"]
|
||||||
|
license = ""
|
||||||
|
repository = ""
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "tusk_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "chrono", "uuid", "bigdecimal"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1", features = ["serde"] }
|
||||||
|
thiserror = "2"
|
||||||
|
csv = "1"
|
||||||
|
log = "0.4"
|
||||||
|
hex = "0.4"
|
||||||
|
bigdecimal = { version = "0.4", features = ["serde"] }
|
||||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
12
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "enables the default permissions",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"shell:allow-open",
|
||||||
|
"dialog:allow-save",
|
||||||
|
"dialog:allow-open"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
142
src-tauri/src/commands/connections.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use crate::error::{TuskError, TuskResult};
|
||||||
|
use crate::models::connection::ConnectionConfig;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use sqlx::Row;
|
||||||
|
use std::fs;
|
||||||
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
|
fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
Ok(dir.join("connections.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_connections(app: AppHandle) -> TuskResult<Vec<ConnectionConfig>> {
|
||||||
|
let path = get_connections_path(&app)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let data = fs::read_to_string(&path)?;
|
||||||
|
let connections: Vec<ConnectionConfig> = serde_json::from_str(&data)?;
|
||||||
|
Ok(connections)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_connection(app: AppHandle, config: ConnectionConfig) -> TuskResult<()> {
|
||||||
|
let path = get_connections_path(&app)?;
|
||||||
|
let mut connections = if path.exists() {
|
||||||
|
let data = fs::read_to_string(&path)?;
|
||||||
|
serde_json::from_str::<Vec<ConnectionConfig>>(&data)?
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(pos) = connections.iter().position(|c| c.id == config.id) {
|
||||||
|
connections[pos] = config;
|
||||||
|
} else {
|
||||||
|
connections.push(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = serde_json::to_string_pretty(&connections)?;
|
||||||
|
fs::write(&path, data)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_connection(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
let path = get_connections_path(&app)?;
|
||||||
|
if path.exists() {
|
||||||
|
let data = fs::read_to_string(&path)?;
|
||||||
|
let mut connections: Vec<ConnectionConfig> = serde_json::from_str(&data)?;
|
||||||
|
connections.retain(|c| c.id != id);
|
||||||
|
let data = serde_json::to_string_pretty(&connections)?;
|
||||||
|
fs::write(&path, data)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close pool if connected
|
||||||
|
let mut pools = state.pools.write().await;
|
||||||
|
if let Some(pool) = pools.remove(&id) {
|
||||||
|
pool.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn test_connection(config: ConnectionConfig) -> TuskResult<String> {
|
||||||
|
let pool = PgPool::connect(&config.connection_url())
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let row = sqlx::query("SELECT version()")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let version: String = row.get(0);
|
||||||
|
pool.close().await;
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn connect(state: State<'_, AppState>, config: ConnectionConfig) -> TuskResult<()> {
|
||||||
|
let pool = PgPool::connect(&config.connection_url())
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
// Verify connection
|
||||||
|
sqlx::query("SELECT 1")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let mut pools = state.pools.write().await;
|
||||||
|
pools.insert(config.id.clone(), pool);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn switch_database(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
config: ConnectionConfig,
|
||||||
|
database: String,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
let mut switched = config.clone();
|
||||||
|
switched.database = database;
|
||||||
|
|
||||||
|
let pool = PgPool::connect(&switched.connection_url())
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
sqlx::query("SELECT 1")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let mut pools = state.pools.write().await;
|
||||||
|
if let Some(old_pool) = pools.remove(&config.id) {
|
||||||
|
old_pool.close().await;
|
||||||
|
}
|
||||||
|
pools.insert(config.id.clone(), pool);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn disconnect(state: State<'_, AppState>, id: String) -> TuskResult<()> {
|
||||||
|
let mut pools = state.pools.write().await;
|
||||||
|
if let Some(pool) = pools.remove(&id) {
|
||||||
|
pool.close().await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
236
src-tauri/src/commands/data.rs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
use crate::commands::queries::pg_value_to_json;
|
||||||
|
use crate::error::{TuskError, TuskResult};
|
||||||
|
use crate::models::query_result::PaginatedQueryResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::{Column, Row, TypeInfo};
|
||||||
|
use std::time::Instant;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
fn escape_ident(name: &str) -> String {
|
||||||
|
format!("\"{}\"", name.replace('"', "\"\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_table_data(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
page: u32,
|
||||||
|
page_size: u32,
|
||||||
|
sort_column: Option<String>,
|
||||||
|
sort_direction: Option<String>,
|
||||||
|
filter: Option<String>,
|
||||||
|
) -> TuskResult<PaginatedQueryResult> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||||
|
|
||||||
|
let mut where_clause = String::new();
|
||||||
|
if let Some(ref f) = filter {
|
||||||
|
if !f.trim().is_empty() {
|
||||||
|
where_clause = format!(" WHERE {}", f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut order_clause = String::new();
|
||||||
|
if let Some(ref col) = sort_column {
|
||||||
|
let dir = sort_direction.as_deref().unwrap_or("ASC");
|
||||||
|
let dir = if dir.eq_ignore_ascii_case("desc") {
|
||||||
|
"DESC"
|
||||||
|
} else {
|
||||||
|
"ASC"
|
||||||
|
};
|
||||||
|
order_clause = format!(" ORDER BY {} {}", escape_ident(col), dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = (page.saturating_sub(1)) * page_size;
|
||||||
|
|
||||||
|
let data_sql = format!(
|
||||||
|
"SELECT * FROM {}{}{} LIMIT {} OFFSET {}",
|
||||||
|
qualified, where_clause, order_clause, page_size, offset
|
||||||
|
);
|
||||||
|
let count_sql = format!("SELECT COUNT(*) FROM {}{}", qualified, where_clause);
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
let (rows, count_row) = tokio::try_join!(
|
||||||
|
sqlx::query(&data_sql).fetch_all(pool),
|
||||||
|
sqlx::query(&count_sql).fetch_one(pool),
|
||||||
|
)
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let execution_time_ms = start.elapsed().as_millis();
|
||||||
|
let total_rows: i64 = count_row.get(0);
|
||||||
|
|
||||||
|
let mut columns = Vec::new();
|
||||||
|
let mut types = Vec::new();
|
||||||
|
|
||||||
|
if let Some(first_row) = rows.first() {
|
||||||
|
for col in first_row.columns() {
|
||||||
|
columns.push(col.name().to_string());
|
||||||
|
types.push(col.type_info().name().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result_rows: Vec<Vec<Value>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let row_count = result_rows.len();
|
||||||
|
|
||||||
|
Ok(PaginatedQueryResult {
|
||||||
|
columns,
|
||||||
|
types,
|
||||||
|
rows: result_rows,
|
||||||
|
row_count,
|
||||||
|
execution_time_ms,
|
||||||
|
total_rows,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_row(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
pk_columns: Vec<String>,
|
||||||
|
pk_values: Vec<Value>,
|
||||||
|
column: String,
|
||||||
|
value: Value,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||||
|
|
||||||
|
let set_clause = format!("{} = $1", escape_ident(&column));
|
||||||
|
|
||||||
|
let where_parts: Vec<String> = pk_columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 2))
|
||||||
|
.collect();
|
||||||
|
let where_clause = where_parts.join(" AND ");
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"UPDATE {} SET {} WHERE {}",
|
||||||
|
qualified, set_clause, where_clause
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
query = bind_json_value(query, &value);
|
||||||
|
for pk_val in &pk_values {
|
||||||
|
query = bind_json_value(query, pk_val);
|
||||||
|
}
|
||||||
|
|
||||||
|
query.execute(pool).await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn insert_row(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
values: Vec<Value>,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||||
|
|
||||||
|
let col_list: Vec<String> = columns.iter().map(|c| escape_ident(c)).collect();
|
||||||
|
let placeholders: Vec<String> = (1..=columns.len()).map(|i| format!("${}", i)).collect();
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"INSERT INTO {} ({}) VALUES ({})",
|
||||||
|
qualified,
|
||||||
|
col_list.join(", "),
|
||||||
|
placeholders.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
for val in &values {
|
||||||
|
query = bind_json_value(query, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
query.execute(pool).await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_rows(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
pk_columns: Vec<String>,
|
||||||
|
pk_values_list: Vec<Vec<Value>>,
|
||||||
|
) -> TuskResult<u64> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let qualified = format!("{}.{}", escape_ident(&schema), escape_ident(&table));
|
||||||
|
let mut total_affected: u64 = 0;
|
||||||
|
|
||||||
|
for pk_values in &pk_values_list {
|
||||||
|
let where_parts: Vec<String> = pk_columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, col)| format!("{} = ${}", escape_ident(col), i + 1))
|
||||||
|
.collect();
|
||||||
|
let where_clause = where_parts.join(" AND ");
|
||||||
|
|
||||||
|
let sql = format!("DELETE FROM {} WHERE {}", qualified, where_clause);
|
||||||
|
|
||||||
|
let mut query = sqlx::query(&sql);
|
||||||
|
for val in pk_values {
|
||||||
|
query = bind_json_value(query, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = query.execute(pool).await.map_err(TuskError::Database)?;
|
||||||
|
total_affected += result.rows_affected();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(total_affected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_json_value<'q>(
|
||||||
|
query: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
|
||||||
|
value: &'q Value,
|
||||||
|
) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
|
||||||
|
match value {
|
||||||
|
Value::Null => query.bind(None::<String>),
|
||||||
|
Value::Bool(b) => query.bind(*b),
|
||||||
|
Value::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
query.bind(i)
|
||||||
|
} else if let Some(f) = n.as_f64() {
|
||||||
|
query.bind(f)
|
||||||
|
} else {
|
||||||
|
query.bind(n.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::String(s) => query.bind(s.as_str()),
|
||||||
|
_ => query.bind(value.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src-tauri/src/commands/export.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use crate::error::{TuskError, TuskResult};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_csv(
|
||||||
|
path: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
rows: Vec<Vec<Value>>,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
let file = File::create(&path)?;
|
||||||
|
let mut wtr = csv::Writer::from_writer(file);
|
||||||
|
|
||||||
|
wtr.write_record(&columns)
|
||||||
|
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
||||||
|
|
||||||
|
for row in &rows {
|
||||||
|
let record: Vec<String> = row
|
||||||
|
.iter()
|
||||||
|
.map(|v| match v {
|
||||||
|
Value::Null => String::new(),
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
Value::Bool(b) => b.to_string(),
|
||||||
|
Value::Number(n) => n.to_string(),
|
||||||
|
_ => v.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
wtr.write_record(&record)
|
||||||
|
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
wtr.flush().map_err(|e| TuskError::Custom(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_json(
|
||||||
|
path: String,
|
||||||
|
columns: Vec<String>,
|
||||||
|
rows: Vec<Vec<Value>>,
|
||||||
|
) -> TuskResult<()> {
|
||||||
|
let objects: Vec<serde_json::Map<String, Value>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
columns
|
||||||
|
.iter()
|
||||||
|
.zip(row.iter())
|
||||||
|
.map(|(col, val)| (col.clone(), val.clone()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&objects)?;
|
||||||
|
let mut file = File::create(&path)?;
|
||||||
|
file.write_all(json.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
5
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod connections;
|
||||||
|
pub mod data;
|
||||||
|
pub mod export;
|
||||||
|
pub mod queries;
|
||||||
|
pub mod schema;
|
||||||
113
src-tauri/src/commands/queries.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use crate::error::{TuskError, TuskResult};
|
||||||
|
use crate::models::query_result::QueryResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::postgres::PgRow;
|
||||||
|
use sqlx::{Column, Row, TypeInfo};
|
||||||
|
use std::time::Instant;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
|
||||||
|
let col = &row.columns()[index];
|
||||||
|
let type_name = col.type_info().name();
|
||||||
|
|
||||||
|
macro_rules! try_get {
|
||||||
|
($t:ty) => {
|
||||||
|
match row.try_get::<Option<$t>, _>(index) {
|
||||||
|
Ok(Some(v)) => return serde_json::to_value(v).unwrap_or(Value::Null),
|
||||||
|
Ok(None) => return Value::Null,
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match type_name {
|
||||||
|
"BOOL" => try_get!(bool),
|
||||||
|
"INT2" => try_get!(i16),
|
||||||
|
"INT4" => try_get!(i32),
|
||||||
|
"INT8" => try_get!(i64),
|
||||||
|
"FLOAT4" => try_get!(f32),
|
||||||
|
"FLOAT8" => try_get!(f64),
|
||||||
|
"NUMERIC" => {
|
||||||
|
try_get!(bigdecimal::BigDecimal);
|
||||||
|
}
|
||||||
|
"TEXT" | "VARCHAR" | "CHAR" | "BPCHAR" | "NAME" => try_get!(String),
|
||||||
|
"JSON" | "JSONB" => try_get!(Value),
|
||||||
|
"UUID" => try_get!(uuid::Uuid),
|
||||||
|
"TIMESTAMP" => {
|
||||||
|
try_get!(chrono::NaiveDateTime);
|
||||||
|
}
|
||||||
|
"TIMESTAMPTZ" => {
|
||||||
|
try_get!(chrono::DateTime<chrono::Utc>);
|
||||||
|
}
|
||||||
|
"DATE" => try_get!(chrono::NaiveDate),
|
||||||
|
"TIME" => try_get!(chrono::NaiveTime),
|
||||||
|
"BYTEA" => {
|
||||||
|
match row.try_get::<Option<Vec<u8>>, _>(index) {
|
||||||
|
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
|
||||||
|
Ok(None) => return Value::Null,
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"OID" => {
|
||||||
|
match row.try_get::<Option<i32>, _>(index) {
|
||||||
|
Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)),
|
||||||
|
Ok(None) => return Value::Null,
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"VOID" => return Value::Null,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try as string
|
||||||
|
match row.try_get::<Option<String>, _>(index) {
|
||||||
|
Ok(Some(v)) => Value::String(v),
|
||||||
|
Ok(None) => Value::Null,
|
||||||
|
Err(_) => Value::String(format!("<unsupported type: {}>", type_name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn execute_query(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
sql: String,
|
||||||
|
) -> TuskResult<QueryResult> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let rows = sqlx::query(&sql)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
let execution_time_ms = start.elapsed().as_millis();
|
||||||
|
|
||||||
|
let mut columns = Vec::new();
|
||||||
|
let mut types = Vec::new();
|
||||||
|
|
||||||
|
if let Some(first_row) = rows.first() {
|
||||||
|
for col in first_row.columns() {
|
||||||
|
columns.push(col.name().to_string());
|
||||||
|
types.push(col.type_info().name().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result_rows: Vec<Vec<Value>> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let row_count = result_rows.len();
|
||||||
|
|
||||||
|
Ok(QueryResult {
|
||||||
|
columns,
|
||||||
|
types,
|
||||||
|
rows: result_rows,
|
||||||
|
row_count,
|
||||||
|
execution_time_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
342
src-tauri/src/commands/schema.rs
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
use crate::error::{TuskError, TuskResult};
|
||||||
|
use crate::models::schema::{ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use sqlx::Row;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_databases(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> TuskResult<Vec<String>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT datname FROM pg_database \
|
||||||
|
WHERE datistemplate = false \
|
||||||
|
ORDER BY datname",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_schemas(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> TuskResult<Vec<String>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT schema_name FROM information_schema.schemata \
|
||||||
|
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
|
||||||
|
ORDER BY schema_name",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_tables(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT table_name FROM information_schema.tables \
|
||||||
|
WHERE table_schema = $1 AND table_type = 'BASE TABLE' \
|
||||||
|
ORDER BY table_name",
|
||||||
|
)
|
||||||
|
.bind(&schema)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| SchemaObject {
|
||||||
|
name: r.get(0),
|
||||||
|
object_type: "table".to_string(),
|
||||||
|
schema: schema.clone(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_views(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT table_name FROM information_schema.views \
|
||||||
|
WHERE table_schema = $1 \
|
||||||
|
ORDER BY table_name",
|
||||||
|
)
|
||||||
|
.bind(&schema)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| SchemaObject {
|
||||||
|
name: r.get(0),
|
||||||
|
object_type: "view".to_string(),
|
||||||
|
schema: schema.clone(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_functions(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT routine_name FROM information_schema.routines \
|
||||||
|
WHERE routine_schema = $1 AND routine_type = 'FUNCTION' \
|
||||||
|
ORDER BY routine_name",
|
||||||
|
)
|
||||||
|
.bind(&schema)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| SchemaObject {
|
||||||
|
name: r.get(0),
|
||||||
|
object_type: "function".to_string(),
|
||||||
|
schema: schema.clone(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_indexes(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT indexname FROM pg_indexes \
|
||||||
|
WHERE schemaname = $1 \
|
||||||
|
ORDER BY indexname",
|
||||||
|
)
|
||||||
|
.bind(&schema)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| SchemaObject {
|
||||||
|
name: r.get(0),
|
||||||
|
object_type: "index".to_string(),
|
||||||
|
schema: schema.clone(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_sequences(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
) -> TuskResult<Vec<SchemaObject>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT sequence_name FROM information_schema.sequences \
|
||||||
|
WHERE sequence_schema = $1 \
|
||||||
|
ORDER BY sequence_name",
|
||||||
|
)
|
||||||
|
.bind(&schema)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| SchemaObject {
|
||||||
|
name: r.get(0),
|
||||||
|
object_type: "sequence".to_string(),
|
||||||
|
schema: schema.clone(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_table_columns(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
) -> TuskResult<Vec<ColumnInfo>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT \
|
||||||
|
c.column_name, \
|
||||||
|
c.data_type, \
|
||||||
|
c.is_nullable, \
|
||||||
|
c.column_default, \
|
||||||
|
c.ordinal_position::int, \
|
||||||
|
c.character_maximum_length::int, \
|
||||||
|
COALESCE(( \
|
||||||
|
SELECT true FROM information_schema.table_constraints tc \
|
||||||
|
JOIN information_schema.key_column_usage kcu \
|
||||||
|
ON tc.constraint_name = kcu.constraint_name \
|
||||||
|
AND tc.table_schema = kcu.table_schema \
|
||||||
|
WHERE tc.constraint_type = 'PRIMARY KEY' \
|
||||||
|
AND tc.table_schema = $1 \
|
||||||
|
AND tc.table_name = $2 \
|
||||||
|
AND kcu.column_name = c.column_name \
|
||||||
|
LIMIT 1 \
|
||||||
|
), false) as is_pk \
|
||||||
|
FROM information_schema.columns c \
|
||||||
|
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
||||||
|
ORDER BY c.ordinal_position",
|
||||||
|
)
|
||||||
|
.bind(&schema)
|
||||||
|
.bind(&table)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| ColumnInfo {
|
||||||
|
name: r.get::<String, _>(0),
|
||||||
|
data_type: r.get::<String, _>(1),
|
||||||
|
is_nullable: r.get::<String, _>(2) == "YES",
|
||||||
|
column_default: r.get::<Option<String>, _>(3),
|
||||||
|
ordinal_position: r.get::<i32, _>(4),
|
||||||
|
character_maximum_length: r.get::<Option<i32>, _>(5),
|
||||||
|
is_primary_key: r.get::<bool, _>(6),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_table_constraints(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
) -> TuskResult<Vec<ConstraintInfo>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT \
|
||||||
|
tc.constraint_name, \
|
||||||
|
tc.constraint_type, \
|
||||||
|
array_agg(kcu.column_name ORDER BY kcu.ordinal_position)::text[] as columns \
|
||||||
|
FROM information_schema.table_constraints tc \
|
||||||
|
JOIN information_schema.key_column_usage kcu \
|
||||||
|
ON tc.constraint_name = kcu.constraint_name \
|
||||||
|
AND tc.table_schema = kcu.table_schema \
|
||||||
|
WHERE tc.table_schema = $1 AND tc.table_name = $2 \
|
||||||
|
GROUP BY tc.constraint_name, tc.constraint_type \
|
||||||
|
ORDER BY tc.constraint_type, tc.constraint_name",
|
||||||
|
)
|
||||||
|
.bind(&schema)
|
||||||
|
.bind(&table)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| ConstraintInfo {
|
||||||
|
name: r.get::<String, _>(0),
|
||||||
|
constraint_type: r.get::<String, _>(1),
|
||||||
|
columns: r.get::<Vec<String>, _>(2),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_table_indexes(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
connection_id: String,
|
||||||
|
schema: String,
|
||||||
|
table: String,
|
||||||
|
) -> TuskResult<Vec<IndexInfo>> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT \
|
||||||
|
i.relname as index_name, \
|
||||||
|
pg_get_indexdef(i.oid) as index_def, \
|
||||||
|
ix.indisunique as is_unique, \
|
||||||
|
ix.indisprimary as is_primary \
|
||||||
|
FROM pg_index ix \
|
||||||
|
JOIN pg_class i ON i.oid = ix.indexrelid \
|
||||||
|
JOIN pg_class t ON t.oid = ix.indrelid \
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace \
|
||||||
|
WHERE n.nspname = $1 AND t.relname = $2 \
|
||||||
|
ORDER BY i.relname",
|
||||||
|
)
|
||||||
|
.bind(&schema)
|
||||||
|
.bind(&table)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| IndexInfo {
|
||||||
|
name: r.get::<String, _>(0),
|
||||||
|
definition: r.get::<String, _>(1),
|
||||||
|
is_unique: r.get::<bool, _>(2),
|
||||||
|
is_primary: r.get::<bool, _>(3),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
33
src-tauri/src/error.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum TuskError {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("Serialization error: {0}")]
|
||||||
|
Serde(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Connection not found: {0}")]
|
||||||
|
ConnectionNotFound(String),
|
||||||
|
|
||||||
|
#[error("Not connected: {0}")]
|
||||||
|
NotConnected(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Custom(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for TuskError {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type TuskResult<T> = Result<T, TuskError>;
|
||||||
46
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
mod commands;
|
||||||
|
mod error;
|
||||||
|
mod models;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use state::AppState;
|
||||||
|
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.manage(AppState::new())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
// connections
|
||||||
|
commands::connections::get_connections,
|
||||||
|
commands::connections::save_connection,
|
||||||
|
commands::connections::delete_connection,
|
||||||
|
commands::connections::test_connection,
|
||||||
|
commands::connections::connect,
|
||||||
|
commands::connections::switch_database,
|
||||||
|
commands::connections::disconnect,
|
||||||
|
// queries
|
||||||
|
commands::queries::execute_query,
|
||||||
|
// schema
|
||||||
|
commands::schema::list_databases,
|
||||||
|
commands::schema::list_schemas,
|
||||||
|
commands::schema::list_tables,
|
||||||
|
commands::schema::list_views,
|
||||||
|
commands::schema::list_functions,
|
||||||
|
commands::schema::list_indexes,
|
||||||
|
commands::schema::list_sequences,
|
||||||
|
commands::schema::get_table_columns,
|
||||||
|
commands::schema::get_table_constraints,
|
||||||
|
commands::schema::get_table_indexes,
|
||||||
|
// data
|
||||||
|
commands::data::get_table_data,
|
||||||
|
commands::data::update_row,
|
||||||
|
commands::data::insert_row,
|
||||||
|
commands::data::delete_rows,
|
||||||
|
// export
|
||||||
|
commands::export::export_csv,
|
||||||
|
commands::export::export_json,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tusk_lib::run();
|
||||||
|
}
|
||||||
41
src-tauri/src/models/connection.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConnectionConfig {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub user: String,
|
||||||
|
pub password: String,
|
||||||
|
pub database: String,
|
||||||
|
pub ssl_mode: Option<String>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectionConfig {
|
||||||
|
pub fn connection_url(&self) -> String {
|
||||||
|
let ssl = self.ssl_mode.as_deref().unwrap_or("prefer");
|
||||||
|
format!(
|
||||||
|
"postgres://{}:{}@{}:{}/{}?sslmode={}",
|
||||||
|
urlencoded(&self.user),
|
||||||
|
urlencoded(&self.password),
|
||||||
|
self.host,
|
||||||
|
self.port,
|
||||||
|
urlencoded(&self.database),
|
||||||
|
ssl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencoded(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')'
|
||||||
|
| '*' | '+' | ',' | ';' | '=' | '%' | ' ' => {
|
||||||
|
format!("%{:02X}", c as u8)
|
||||||
|
}
|
||||||
|
_ => c.to_string(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
3
src-tauri/src/models/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod connection;
|
||||||
|
pub mod query_result;
|
||||||
|
pub mod schema;
|
||||||
23
src-tauri/src/models/query_result.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QueryResult {
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub types: Vec<String>,
|
||||||
|
pub rows: Vec<Vec<Value>>,
|
||||||
|
pub row_count: usize,
|
||||||
|
pub execution_time_ms: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PaginatedQueryResult {
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub types: Vec<String>,
|
||||||
|
pub rows: Vec<Vec<Value>>,
|
||||||
|
pub row_count: usize,
|
||||||
|
pub execution_time_ms: u128,
|
||||||
|
pub total_rows: i64,
|
||||||
|
pub page: u32,
|
||||||
|
pub page_size: u32,
|
||||||
|
}
|
||||||
34
src-tauri/src/models/schema.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SchemaObject {
|
||||||
|
pub name: String,
|
||||||
|
pub object_type: String,
|
||||||
|
pub schema: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ColumnInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub data_type: String,
|
||||||
|
pub is_nullable: bool,
|
||||||
|
pub column_default: Option<String>,
|
||||||
|
pub ordinal_position: i32,
|
||||||
|
pub character_maximum_length: Option<i32>,
|
||||||
|
pub is_primary_key: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConstraintInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub constraint_type: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IndexInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub definition: String,
|
||||||
|
pub is_unique: bool,
|
||||||
|
pub is_primary: bool,
|
||||||
|
}
|
||||||
18
src-tauri/src/state.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub pools: RwLock<HashMap<String, PgPool>>,
|
||||||
|
pub config_path: RwLock<Option<PathBuf>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
pools: RwLock::new(HashMap::new()),
|
||||||
|
config_path: RwLock::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Tusk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.tusk.app",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../dist",
|
||||||
|
"devUrl": "http://localhost:5173",
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Tusk",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 600
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"shell": {
|
||||||
|
"open": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||