Skip to content

Commit ee5d970

Browse files
committed
feat: add test connection and cancel query fix
1 parent 8a2f222 commit ee5d970

File tree

15 files changed

+220
-39
lines changed

15 files changed

+220
-39
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "rsql",
33
"private": true,
4-
"version": "1.1.1",
4+
"version": "1.1.2",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rsql"
3-
version = "1.1.1"
3+
version = "1.1.2"
44
description = "Modern SQL Client"
55
authors = ["rust-dd"]
66
edition = "2024"

src-tauri/src/drivers/pgsql.rs

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,27 @@ async fn acquire_client(
9494
.map_err(|e| AppError::ConnectionFailed(e.to_string()))
9595
}
9696

97+
async fn apply_statement_timeout(
98+
client: &deadpool_postgres::Client,
99+
timeout_ms: u32,
100+
) {
101+
if timeout_ms > 0 {
102+
client
103+
.simple_query(&format!("SET statement_timeout = {}", timeout_ms))
104+
.await
105+
.ok();
106+
}
107+
}
108+
109+
async fn reset_statement_timeout(
110+
client: &deadpool_postgres::Client,
111+
timeout_ms: u32,
112+
) {
113+
if timeout_ms > 0 {
114+
client.simple_query("RESET statement_timeout").await.ok();
115+
}
116+
}
117+
97118
async fn set_cancel_token(
98119
app_state: &AppState,
99120
project_id: &str,
@@ -377,6 +398,39 @@ async fn restore_virtual_from_snapshot(
377398
Ok(true)
378399
}
379400

401+
#[tauri::command(rename_all = "snake_case")]
402+
pub async fn pgsql_test_connection(
403+
key: [&str; 6],
404+
) -> Result<String> {
405+
let user = key[0];
406+
let password = key[1];
407+
let database = key[2];
408+
let host = key[3];
409+
let port: u16 = key[4].parse().unwrap_or(5432);
410+
let use_ssl = key[5] == "true";
411+
412+
let mut cfg = Config::new();
413+
cfg.user(user)
414+
.password(password)
415+
.dbname(database)
416+
.host(host)
417+
.port(port);
418+
419+
let pool = create_pg_pool(&cfg, use_ssl, 1)?;
420+
let client = pool
421+
.get()
422+
.await
423+
.map_err(|e| AppError::ConnectionFailed(full_error_chain(&e)))?;
424+
425+
let row = client
426+
.query_one("SELECT version()", &[])
427+
.await
428+
.map_err(|e| AppError::ConnectionFailed(e.to_string()))?;
429+
430+
let version: String = row.get(0);
431+
Ok(version)
432+
}
433+
380434
#[tauri::command(rename_all = "snake_case")]
381435
pub async fn pgsql_connector(
382436
project_id: &str,
@@ -836,12 +890,18 @@ pub async fn pgsql_cancel_query(project_id: &str, app_state: State<'_, AppState>
836890
pub async fn pgsql_run_query_packed(
837891
project_id: &str,
838892
sql: &str,
893+
timeout_ms: Option<u32>,
839894
app_state: State<'_, AppState>,
840895
) -> Result<Response> {
841896
let client = acquire_client(&app_state.clients, project_id).await?;
842897
set_cancel_token(&app_state, project_id, client.cancel_token()).await?;
843898

844-
let result = execute_query_packed(&client, sql).await?;
899+
let timeout = timeout_ms.unwrap_or(0);
900+
apply_statement_timeout(&client, timeout).await;
901+
let result = execute_query_packed(&client, sql).await;
902+
reset_statement_timeout(&client, timeout).await;
903+
904+
let result = result?;
845905
let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?;
846906
Ok(Response::new(json))
847907
}
@@ -868,13 +928,18 @@ pub async fn pgsql_execute_virtual(
868928
sql: &str,
869929
query_id: &str,
870930
page_size: usize,
931+
timeout_ms: Option<u32>,
871932
app_state: State<'_, AppState>,
872933
) -> Result<Response> {
873934
let client = acquire_client(&app_state.clients, project_id).await?;
874935
set_cancel_token(&app_state, project_id, client.cancel_token()).await?;
875936

937+
let timeout = timeout_ms.unwrap_or(0);
938+
apply_statement_timeout(&client, timeout).await;
876939
let result =
877-
execute_virtual(&client, &app_state.virtual_cache, sql, query_id, page_size).await?;
940+
execute_virtual(&client, &app_state.virtual_cache, sql, query_id, page_size).await;
941+
reset_statement_timeout(&client, timeout).await;
942+
let result = result?;
878943

879944
let col_count = if result.0.is_empty() {
880945
0

src-tauri/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ fn main() {
259259
dbs::workspace::workspace_save,
260260
dbs::workspace::workspace_load_all,
261261
dbs::workspace::workspace_delete,
262+
drivers::pgsql::pgsql_test_connection,
262263
drivers::pgsql::pgsql_connector,
263264
drivers::pgsql::pgsql_load_databases,
264265
drivers::pgsql::pgsql_load_tablespaces,

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "rsql",
4-
"version": "1.1.1",
4+
"version": "1.1.2",
55
"identifier": "com.rust-dd.rsql",
66
"build": {
77
"beforeDevCommand": "yarn dev",

src/App.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ function isQueryCancelledError(message: string): boolean {
4444
return lower.includes("canceling statement due to user request")
4545
|| lower.includes("cancelling statement due to user request")
4646
|| lower.includes("query canceled")
47-
|| lower.includes("query cancelled");
47+
|| lower.includes("query cancelled")
48+
|| lower.includes("statement timeout");
4849
}
4950

5051
function notifyQueryComplete(sql: string, time: number, success: boolean, rowCount?: number) {
@@ -126,11 +127,13 @@ export default function App() {
126127
setVirtualQuery(idx, undefined);
127128
}
128129

130+
const timeoutMs = tab.queryTimeout || undefined;
131+
129132
if (driver.executeVirtual) {
130133
const sql = tab.editorValue;
131134
const queryId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
132135
const [colsPacked, totalRows, pagePacked, elapsed] =
133-
await driver.executeVirtual(tab.projectId, sql, queryId, PAGE_SIZE);
136+
await driver.executeVirtual(tab.projectId, sql, queryId, PAGE_SIZE, timeoutMs);
134137

135138
if (!colsPacked) {
136139
// Fallback format from backend: header + rows in one packed string.
@@ -180,7 +183,7 @@ export default function App() {
180183
}
181184
} else {
182185
// One-shot fallback
183-
const [cols, rows, time] = await driver.runQuery(tab.projectId, tab.editorValue);
186+
const [cols, rows, time] = await driver.runQuery(tab.projectId, tab.editorValue, timeoutMs);
184187
updateResult(idx, { columns: cols, rows, time });
185188
notifyQueryComplete(tab.editorValue, time, true, rows.length);
186189
addHistoryEntry({
@@ -472,6 +475,7 @@ export default function App() {
472475
<EditorToolbar
473476
onExecute={() => void runQuery()}
474477
onExplain={() => void runExplain()}
478+
onCancel={() => void cancelQuery()}
475479
/>
476480
<div className="flex flex-1 min-h-0 overflow-hidden">
477481
{/* Left pane */}
@@ -531,6 +535,7 @@ export default function App() {
531535
<EditorToolbar
532536
onExecute={() => void runQuery()}
533537
onExplain={() => void runExplain()}
538+
onCancel={() => void cancelQuery()}
534539
/>
535540
<div style={{ height: `${editorHeight}%` }} className="flex flex-col overflow-hidden">
536541
<QueryEditor

src/components/connection-modal.tsx

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
44
import { Button } from "./ui/button"
55
import { Input } from "./ui/input"
66
import { Label } from "./ui/label"
7+
import { Loader2, CheckCircle2, XCircle } from "lucide-react"
78
import { DRIVER_CONFIGS } from "@/lib/database-driver"
9+
import { pgsqlTestConnection } from "@/tauri"
810
import type { DriverType, ProjectDetails } from "@/types"
911

1012
interface ConnectionModalProps {
@@ -75,6 +77,8 @@ export function ConnectionModal({ open, onOpenChange, onSave, editData }: Connec
7577
const [formData, setFormData] = useState<Omit<ConnectionConfig, "id">>(defaultForm)
7678
const [connString, setConnString] = useState("")
7779
const [connStringError, setConnStringError] = useState(false)
80+
const [testing, setTesting] = useState(false)
81+
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null)
7882

7983
useEffect(() => {
8084
if (open && editData) {
@@ -96,10 +100,12 @@ export function ConnectionModal({ open, onOpenChange, onSave, editData }: Connec
96100
})
97101
setConnString("")
98102
setConnStringError(false)
103+
setTestResult(null)
99104
} else if (open && !editData) {
100105
setFormData(defaultForm)
101106
setConnString("")
102107
setConnStringError(false)
108+
setTestResult(null)
103109
}
104110
}, [open, editData])
105111

@@ -117,6 +123,28 @@ export function ConnectionModal({ open, onOpenChange, onSave, editData }: Connec
117123

118124
const isEditing = !!editData
119125

126+
const handleTestConnection = async () => {
127+
setTesting(true)
128+
setTestResult(null)
129+
try {
130+
const key: [string, string, string, string, string, string] = [
131+
formData.username,
132+
formData.password,
133+
formData.database,
134+
formData.host,
135+
formData.port,
136+
formData.ssl ? "true" : "false",
137+
]
138+
const version = await pgsqlTestConnection(key)
139+
setTestResult({ ok: true, message: version })
140+
} catch (err: unknown) {
141+
const msg = err instanceof Error ? err.message : String(err)
142+
setTestResult({ ok: false, message: msg })
143+
} finally {
144+
setTesting(false)
145+
}
146+
}
147+
120148
const handleSubmit = (e: React.FormEvent) => {
121149
e.preventDefault()
122150
const connection: ConnectionConfig = {
@@ -341,13 +369,42 @@ export function ConnectionModal({ open, onOpenChange, onSave, editData }: Connec
341369
)}
342370
</div>
343371

344-
<div className="flex justify-end gap-2 pt-4">
345-
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} className="font-mono text-xs">
346-
Cancel
347-
</Button>
348-
<Button type="submit" variant="gradient" className="font-mono text-xs">
349-
{isEditing ? "Save Changes" : "Connect"}
372+
{testResult && (
373+
<div
374+
className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-xs font-mono ${
375+
testResult.ok
376+
? "border-emerald-500/30 bg-emerald-500/5 text-emerald-500"
377+
: "border-destructive/30 bg-destructive/5 text-destructive"
378+
}`}
379+
>
380+
{testResult.ok ? (
381+
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 mt-0.5" />
382+
) : (
383+
<XCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
384+
)}
385+
<span className="break-all">{testResult.message}</span>
386+
</div>
387+
)}
388+
389+
<div className="flex justify-between pt-4">
390+
<Button
391+
type="button"
392+
variant="outline"
393+
onClick={() => void handleTestConnection()}
394+
disabled={testing || !formData.host || !formData.database}
395+
className="font-mono text-xs"
396+
>
397+
{testing && <Loader2 className="h-3 w-3 animate-spin mr-1.5" />}
398+
Test Connection
350399
</Button>
400+
<div className="flex gap-2">
401+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} className="font-mono text-xs">
402+
Cancel
403+
</Button>
404+
<Button type="submit" variant="gradient" className="font-mono text-xs">
405+
{isEditing ? "Save Changes" : "Connect"}
406+
</Button>
407+
</div>
351408
</div>
352409
</form>
353410
</DialogContent>

0 commit comments

Comments
 (0)