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>
This commit is contained in:
2026-02-11 19:06:27 +03:00
parent 27bdbf0112
commit 9b675babd5
36 changed files with 6918 additions and 0 deletions

4
src-tauri/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

32
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View 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(())
}

View 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()),
}
}

View 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(())
}

View File

@@ -0,0 +1,5 @@
pub mod connections;
pub mod data;
pub mod export;
pub mod queries;
pub mod schema;

View 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,
})
}

View 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
View 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
View 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
View 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();
}

View 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()
}

View File

@@ -0,0 +1,3 @@
pub mod connection;
pub mod query_result;
pub mod schema;

View 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,
}

View 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
View 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
View 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
}
}
}