style: apply rustfmt, fix clippy warnings, and minor code cleanup
Reformat Rust code with rustfmt, suppress clippy::too_many_arguments for Tauri IPC commands, derive Default for AppSettings, fix unused variable pattern in TableDataView, and add unit tests for utils.
This commit is contained in:
@@ -10,6 +10,7 @@ use std::time::Instant;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn get_table_data(
|
pub async fn get_table_data(
|
||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -55,7 +56,7 @@ pub async fn get_table_data(
|
|||||||
|
|
||||||
// Always run table data queries in a read-only transaction to prevent
|
// Always run table data queries in a read-only transaction to prevent
|
||||||
// writable CTEs or other mutation via the raw filter parameter.
|
// writable CTEs or other mutation via the raw filter parameter.
|
||||||
let mut tx = (&pool).begin().await.map_err(TuskError::Database)?;
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
sqlx::query("SET TRANSACTION READ ONLY")
|
sqlx::query("SET TRANSACTION READ ONLY")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
@@ -129,6 +130,7 @@ pub async fn get_table_data(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn update_row(
|
pub async fn update_row(
|
||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -244,7 +246,7 @@ pub async fn delete_rows(
|
|||||||
let mut total_affected: u64 = 0;
|
let mut total_affected: u64 = 0;
|
||||||
|
|
||||||
// Wrap all deletes in a transaction for atomicity
|
// Wrap all deletes in a transaction for atomicity
|
||||||
let mut tx = (&pool).begin().await.map_err(TuskError::Database)?;
|
let mut tx = pool.begin().await.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
if pk_columns.is_empty() {
|
if pk_columns.is_empty() {
|
||||||
// Fallback: use ctids for row identification
|
// Fallback: use ctids for row identification
|
||||||
|
|||||||
@@ -336,10 +336,7 @@ pub async fn alter_role(
|
|||||||
options.push(format!("CONNECTION LIMIT {}", limit));
|
options.push(format!("CONNECTION LIMIT {}", limit));
|
||||||
}
|
}
|
||||||
if let Some(ref valid_until) = params.valid_until {
|
if let Some(ref valid_until) = params.valid_until {
|
||||||
options.push(format!(
|
options.push(format!("VALID UNTIL '{}'", valid_until.replace('\'', "''")));
|
||||||
"VALID UNTIL '{}'",
|
|
||||||
valid_until.replace('\'', "''")
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !options.is_empty() {
|
if !options.is_empty() {
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ pub mod management;
|
|||||||
pub mod queries;
|
pub mod queries;
|
||||||
pub mod saved_queries;
|
pub mod saved_queries;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod snapshot;
|
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod snapshot;
|
||||||
|
|||||||
@@ -43,20 +43,16 @@ pub fn pg_value_to_json(row: &PgRow, index: usize) -> Value {
|
|||||||
}
|
}
|
||||||
"DATE" => try_get!(chrono::NaiveDate),
|
"DATE" => try_get!(chrono::NaiveDate),
|
||||||
"TIME" => try_get!(chrono::NaiveTime),
|
"TIME" => try_get!(chrono::NaiveTime),
|
||||||
"BYTEA" => {
|
"BYTEA" => match row.try_get::<Option<Vec<u8>>, _>(index) {
|
||||||
match row.try_get::<Option<Vec<u8>>, _>(index) {
|
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
|
||||||
Ok(Some(v)) => return Value::String(format!("\\x{}", hex::encode(&v))),
|
Ok(None) => return Value::Null,
|
||||||
Ok(None) => return Value::Null,
|
Err(_) => {}
|
||||||
Err(_) => {}
|
},
|
||||||
}
|
"OID" => match row.try_get::<Option<i32>, _>(index) {
|
||||||
}
|
Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)),
|
||||||
"OID" => {
|
Ok(None) => return Value::Null,
|
||||||
match row.try_get::<Option<i32>, _>(index) {
|
Err(_) => {}
|
||||||
Ok(Some(v)) => return Value::Number(serde_json::Number::from(v)),
|
},
|
||||||
Ok(None) => return Value::Null,
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"VOID" => return Value::Null,
|
"VOID" => return Value::Null,
|
||||||
// Array types (PG prefixes array type names with underscore)
|
// Array types (PG prefixes array type names with underscore)
|
||||||
"_BOOL" => try_get!(Vec<bool>),
|
"_BOOL" => try_get!(Vec<bool>),
|
||||||
@@ -124,7 +120,11 @@ pub async fn execute_query_core(
|
|||||||
|
|
||||||
let result_rows: Vec<Vec<Value>> = rows
|
let result_rows: Vec<Vec<Value>> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| (0..columns.len()).map(|i| pg_value_to_json(row, i)).collect())
|
.map(|row| {
|
||||||
|
(0..columns.len())
|
||||||
|
.map(|i| pg_value_to_json(row, i))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let row_count = result_rows.len();
|
let row_count = result_rows.len();
|
||||||
|
|||||||
@@ -28,10 +28,7 @@ pub async fn list_databases(
|
|||||||
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_schemas_core(
|
pub async fn list_schemas_core(state: &AppState, connection_id: &str) -> TuskResult<Vec<String>> {
|
||||||
state: &AppState,
|
|
||||||
connection_id: &str,
|
|
||||||
) -> TuskResult<Vec<String>> {
|
|
||||||
let pool = state.get_pool(connection_id).await?;
|
let pool = state.get_pool(connection_id).await?;
|
||||||
|
|
||||||
let flavor = state.get_flavor(connection_id).await;
|
let flavor = state.get_flavor(connection_id).await;
|
||||||
@@ -593,11 +590,13 @@ pub async fn get_schema_erd(
|
|||||||
let mut tables_map: HashMap<String, ErdTable> = HashMap::new();
|
let mut tables_map: HashMap<String, ErdTable> = HashMap::new();
|
||||||
for row in &col_rows {
|
for row in &col_rows {
|
||||||
let table_name: String = row.get(0);
|
let table_name: String = row.get(0);
|
||||||
let entry = tables_map.entry(table_name.clone()).or_insert_with(|| ErdTable {
|
let entry = tables_map
|
||||||
schema: schema.clone(),
|
.entry(table_name.clone())
|
||||||
name: table_name,
|
.or_insert_with(|| ErdTable {
|
||||||
columns: Vec::new(),
|
schema: schema.clone(),
|
||||||
});
|
name: table_name,
|
||||||
|
columns: Vec::new(),
|
||||||
|
});
|
||||||
entry.columns.push(ErdColumn {
|
entry.columns.push(ErdColumn {
|
||||||
name: row.get(1),
|
name: row.get(1),
|
||||||
data_type: row.get(2),
|
data_type: row.get(2),
|
||||||
|
|||||||
@@ -57,9 +57,13 @@ pub fn run() {
|
|||||||
let mcp_state = state.clone();
|
let mcp_state = state.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
*mcp_state.mcp_running.write().await = true;
|
*mcp_state.mcp_running.write().await = true;
|
||||||
if let Err(e) =
|
if let Err(e) = mcp::start_mcp_server(
|
||||||
mcp::start_mcp_server(mcp_state.clone(), connections_path, mcp_port, shutdown_rx)
|
mcp_state.clone(),
|
||||||
.await
|
connections_path,
|
||||||
|
mcp_port,
|
||||||
|
shutdown_rx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
log::error!("MCP server error: {}", e);
|
log::error!("MCP server error: {}", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ impl TuskMcpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "List all configured database connections with their active/read-only status")]
|
#[tool(
|
||||||
|
description = "List all configured database connections with their active/read-only status"
|
||||||
|
)]
|
||||||
async fn list_connections(&self) -> Result<CallToolResult, McpError> {
|
async fn list_connections(&self) -> Result<CallToolResult, McpError> {
|
||||||
let configs: Vec<ConnectionConfig> = if self.connections_path.exists() {
|
let configs: Vec<ConnectionConfig> = if self.connections_path.exists() {
|
||||||
let data = std::fs::read_to_string(&self.connections_path).map_err(|e| {
|
let data = std::fs::read_to_string(&self.connections_path).map_err(|e| {
|
||||||
@@ -110,9 +112,8 @@ impl TuskMcpServer {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&statuses).map_err(|e| {
|
let json = serde_json::to_string_pretty(&statuses)
|
||||||
McpError::internal_error(format!("Serialization error: {}", e), None)
|
.map_err(|e| McpError::internal_error(format!("Serialization error: {}", e), None))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||||
}
|
}
|
||||||
@@ -165,7 +166,9 @@ impl TuskMcpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Describe table columns: name, type, nullable, primary key, default value")]
|
#[tool(
|
||||||
|
description = "Describe table columns: name, type, nullable, primary key, default value"
|
||||||
|
)]
|
||||||
async fn describe_table(
|
async fn describe_table(
|
||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<DescribeTableParam>,
|
Parameters(params): Parameters<DescribeTableParam>,
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ impl ConnectionConfig {
|
|||||||
fn urlencoded(s: &str) -> String {
|
fn urlencoded(s: &str) -> String {
|
||||||
s.chars()
|
s.chars()
|
||||||
.map(|c| match c {
|
.map(|c| match c {
|
||||||
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')'
|
':' | '/' | '?' | '#' | '[' | ']' | '@' | '!' | '$' | '&' | '\'' | '(' | ')' | '*'
|
||||||
| '*' | '+' | ',' | ';' | '=' | '%' | ' ' => {
|
| '+' | ',' | ';' | '=' | '%' | ' ' => {
|
||||||
format!("%{:02X}", c as u8)
|
format!("%{:02X}", c as u8)
|
||||||
}
|
}
|
||||||
_ => c.to_string(),
|
_ => c.to_string(),
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
pub mcp: McpSettings,
|
pub mcp: McpSettings,
|
||||||
pub docker: DockerSettings,
|
pub docker: DockerSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
mcp: McpSettings::default(),
|
|
||||||
docker: DockerSettings::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct McpSettings {
|
pub struct McpSettings {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ pub fn topological_sort_tables(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if graph.entry(parent.clone()).or_default().insert(child.clone()) {
|
if graph
|
||||||
|
.entry(parent.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(child.clone())
|
||||||
|
{
|
||||||
*in_degree.entry(child).or_insert(0) += 1;
|
*in_degree.entry(child).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,3 +77,136 @@ pub fn topological_sort_tables(
|
|||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── escape_ident ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_simple_name() {
|
||||||
|
assert_eq!(escape_ident("users"), "\"users\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_with_double_quotes() {
|
||||||
|
assert_eq!(escape_ident(r#"my"table"#), r#""my""table""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_empty_string() {
|
||||||
|
assert_eq!(escape_ident(""), r#""""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_with_spaces() {
|
||||||
|
assert_eq!(escape_ident("my table"), "\"my table\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_with_semicolon() {
|
||||||
|
assert_eq!(escape_ident("users; DROP TABLE"), "\"users; DROP TABLE\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_with_single_quotes() {
|
||||||
|
assert_eq!(escape_ident("it's"), "\"it's\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_with_backslash() {
|
||||||
|
assert_eq!(escape_ident(r"back\slash"), r#""back\slash""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_unicode() {
|
||||||
|
assert_eq!(escape_ident("таблица"), "\"таблица\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_multiple_double_quotes() {
|
||||||
|
assert_eq!(escape_ident(r#"a""b"#), r#""a""""b""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_reserved_word() {
|
||||||
|
assert_eq!(escape_ident("select"), "\"select\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_null_byte() {
|
||||||
|
assert_eq!(escape_ident("a\0b"), "\"a\0b\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_ident_newline() {
|
||||||
|
assert_eq!(escape_ident("a\nb"), "\"a\nb\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── topological_sort_tables ───────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn topo_sort_no_edges() {
|
||||||
|
let tables = vec![("public".into(), "b".into()), ("public".into(), "a".into())];
|
||||||
|
let result = topological_sort_tables(&[], &tables);
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert!(result.contains(&("public".into(), "a".into())));
|
||||||
|
assert!(result.contains(&("public".into(), "b".into())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn topo_sort_simple_dependency() {
|
||||||
|
let edges = vec![(
|
||||||
|
"public".into(),
|
||||||
|
"orders".into(),
|
||||||
|
"public".into(),
|
||||||
|
"users".into(),
|
||||||
|
)];
|
||||||
|
let tables = vec![
|
||||||
|
("public".into(), "orders".into()),
|
||||||
|
("public".into(), "users".into()),
|
||||||
|
];
|
||||||
|
let result = topological_sort_tables(&edges, &tables);
|
||||||
|
let user_pos = result.iter().position(|t| t.1 == "users").unwrap();
|
||||||
|
let order_pos = result.iter().position(|t| t.1 == "orders").unwrap();
|
||||||
|
assert!(user_pos < order_pos, "users must come before orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn topo_sort_self_reference() {
|
||||||
|
let edges = vec![(
|
||||||
|
"public".into(),
|
||||||
|
"employees".into(),
|
||||||
|
"public".into(),
|
||||||
|
"employees".into(),
|
||||||
|
)];
|
||||||
|
let tables = vec![("public".into(), "employees".into())];
|
||||||
|
let result = topological_sort_tables(&edges, &tables);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn topo_sort_cycle() {
|
||||||
|
let edges = vec![
|
||||||
|
("public".into(), "a".into(), "public".into(), "b".into()),
|
||||||
|
("public".into(), "b".into(), "public".into(), "a".into()),
|
||||||
|
];
|
||||||
|
let tables = vec![("public".into(), "a".into()), ("public".into(), "b".into())];
|
||||||
|
let result = topological_sort_tables(&edges, &tables);
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn topo_sort_edge_outside_target_set_ignored() {
|
||||||
|
let edges = vec![(
|
||||||
|
"public".into(),
|
||||||
|
"orders".into(),
|
||||||
|
"public".into(),
|
||||||
|
"external".into(),
|
||||||
|
)];
|
||||||
|
let tables = vec![("public".into(), "orders".into())];
|
||||||
|
let result = topological_sort_tables(&edges, &tables);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
}
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
for (const [_key, change] of pendingChanges) {
|
for (const [, change] of pendingChanges) {
|
||||||
const row = data.rows[change.rowIndex];
|
const row = data.rows[change.rowIndex];
|
||||||
const colName = data.columns[change.colIndex];
|
const colName = data.columns[change.colIndex];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user