From dd5ec4be424dd0759692012030a69f936cc2fe6a Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Thu, 25 Jun 2026 11:49:13 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat(sql):=20=E7=94=B1=20schema=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=20ER=20=E5=9B=BE=20(D2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 utils/dbSchema(columnsSql/fksSql,覆盖 mysql/postgres/sqlite·内存,clickhouse/duckdb 无外键内省) - 新增 ErDiagram:取表+列+外键,表卡片(外键列标◆)+ SVG 贝塞尔连线,自动网格布局、表头可拖拽 - SQL 工具栏新增 ER 图入口;补充 er.* 文案 --- src/App.vue | 3 + src/components/ErDiagram.vue | 221 +++++++++++++++++++++++++++++++++++ src/i18n/locales/en.json | 7 ++ src/i18n/locales/zh-CN.json | 7 ++ src/utils/dbSchema.ts | 56 +++++++++ 5 files changed, 294 insertions(+) create mode 100644 src/components/ErDiagram.vue create mode 100644 src/utils/dbSchema.ts diff --git a/src/App.vue b/src/App.vue index 93ef9f21..05568e6a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -95,6 +95,7 @@ +
@@ -233,6 +234,7 @@ +
@@ -512,6 +514,7 @@ import TaskRunner from './components/TaskRunner.vue' import DebugToolbar from './components/DebugToolbar.vue' import DebugPanel from './components/DebugPanel.vue' import AiCodeAction from './components/AiCodeAction.vue' +import ErDiagram from './components/ErDiagram.vue' import GoToLine from './components/GoToLine.vue' import Outline from './components/Outline.vue' import SnippetManager from './components/SnippetManager.vue' diff --git a/src/components/ErDiagram.vue b/src/components/ErDiagram.vue new file mode 100644 index 00000000..e13ca36f --- /dev/null +++ b/src/components/ErDiagram.vue @@ -0,0 +1,221 @@ + + + diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a98d1125..5aaa6c5c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1030,6 +1030,13 @@ "revealIn": "Reveal in file manager: {path}", "unsaved": "Unsaved changes" }, + "er": { + "title": "ER diagram", + "summary": "{tables} tables · {fks} foreign keys", + "loading": "Loading schema…", + "empty": "No tables to show", + "tip": "Drag a table header to rearrange" + }, "aiCode": { "title": { "explain": "AI: Explain code", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ed31c4c9..2de99937 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1030,6 +1030,13 @@ "revealIn": "在访达中显示:{path}", "unsaved": "有未保存的修改" }, + "er": { + "title": "ER 图", + "summary": "{tables} 张表 · {fks} 个外键", + "loading": "加载结构中…", + "empty": "没有可显示的表", + "tip": "拖动表头可调整布局" + }, "aiCode": { "title": { "explain": "AI 解释代码", diff --git a/src/utils/dbSchema.ts b/src/utils/dbSchema.ts new file mode 100644 index 00000000..53ab712e --- /dev/null +++ b/src/utils/dbSchema.ts @@ -0,0 +1,56 @@ +// 按数据库类型构造「列」与「外键」查询。用于 schema 浏览 / ER 图。 +const esc = (s: string) => s.replace(/'/g, "''") + +// 返回 (tbl, col, typ) 行 +export function columnsSql(kind: string, db?: string): string { + if (kind === 'mysql') { + return 'SELECT table_name AS tbl, column_name AS col, column_type AS typ ' + + `FROM information_schema.columns WHERE table_schema = '${esc(db || '')}' ` + + 'ORDER BY table_name, ordinal_position' + } + if (kind === 'postgres') { + return 'SELECT table_name AS tbl, column_name AS col, data_type AS typ ' + + 'FROM information_schema.columns ' + + "WHERE table_schema NOT IN ('pg_catalog', 'information_schema') " + + 'ORDER BY table_name, ordinal_position' + } + if (kind === 'clickhouse') { + return 'SELECT table AS tbl, name AS col, type AS typ ' + + 'FROM system.columns WHERE database = currentDatabase() ' + + 'ORDER BY table, position' + } + if (kind === 'duckdb') { + return 'SELECT table_name AS tbl, column_name AS col, data_type AS typ ' + + 'FROM information_schema.columns ' + + "WHERE table_schema NOT IN ('information_schema', 'pg_catalog') " + + 'ORDER BY table_name, ordinal_position' + } + return 'SELECT m.name AS tbl, p.name AS col, p.type AS typ ' + + 'FROM sqlite_master m JOIN pragma_table_info(m.name) p ' + + "WHERE m.type IN ('table','view') AND m.name NOT LIKE 'sqlite\\_%' ESCAPE '\\' " + + 'ORDER BY m.name, p.cid' +} + +// 返回 (tbl, col, ref_table, ref_col) 行;无外键内省能力的库返回空串 +export function fksSql(kind: string, db?: string): string { + if (kind === 'mysql') { + return 'SELECT table_name AS tbl, column_name AS col, referenced_table_name AS ref_tbl, referenced_column_name AS ref_col ' + + `FROM information_schema.key_column_usage WHERE table_schema = '${esc(db || '')}' ` + + 'AND referenced_table_name IS NOT NULL' + } + if (kind === 'postgres') { + return 'SELECT tc.table_name AS tbl, kcu.column_name AS col, ccu.table_name AS ref_tbl, ccu.column_name AS ref_col ' + + '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 ' + + 'JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema ' + + "WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema NOT IN ('pg_catalog', 'information_schema')" + } + // clickhouse / duckdb 等无外键内省,返回空 + if (kind === 'clickhouse' || kind === 'duckdb') { + return '' + } + // sqlite / 内存库:用 pragma_foreign_key_list + return 'SELECT m.name AS tbl, f."from" AS col, f."table" AS ref_tbl, f."to" AS ref_col ' + + 'FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) f ' + + "WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite\\_%' ESCAPE '\\'" +} From c51c4fb40b5bfece011246009ff633b7c26f0422 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Thu, 25 Jun 2026 11:51:29 +0800 Subject: [PATCH 02/16] =?UTF-8?q?fix(sql):=20ER=20=E5=9B=BE=E5=9C=A8=20MyS?= =?UTF-8?q?QL=20=E6=9C=AA=E9=80=89=E5=BA=93=E6=97=B6=E7=BB=99=E5=87=BA?= =?UTF-8?q?=E6=98=8E=E7=A1=AE=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MySQL 数据源未指定 database 时 table_schema 为空、查不到表 - 不再显示空图,而是提示先在数据源中选择数据库(与 SchemaBrowser 一致的前置条件) --- src/components/ErDiagram.vue | 7 +++++++ src/i18n/locales/en.json | 1 + src/i18n/locales/zh-CN.json | 1 + 3 files changed, 9 insertions(+) diff --git a/src/components/ErDiagram.vue b/src/components/ErDiagram.vue index e13ca36f..a823664c 100644 --- a/src/components/ErDiagram.vue +++ b/src/components/ErDiagram.vue @@ -166,6 +166,13 @@ const load = async () => { error.value = '' try { const source = resolveActiveSource() + // MySQL 未选具体数据库时无法列表,提示用户先在数据源中选库 + if (source.kind === 'mysql' && !source.database) { + tables.value = [] + fks.value = [] + error.value = t('er.needDb') + return + } const db = source.kind === 'mysql' ? source.database || undefined : undefined tables.value = groupTables(await runRows(columnsSql(source.kind, db))) const fkQuery = fksSql(source.kind, db) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5aaa6c5c..5c7cfd99 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1035,6 +1035,7 @@ "summary": "{tables} tables · {fks} foreign keys", "loading": "Loading schema…", "empty": "No tables to show", + "needDb": "This MySQL data source has no database selected. Pick a database in the data source first, then open the ER diagram.", "tip": "Drag a table header to rearrange" }, "aiCode": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 2de99937..56a460a8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1035,6 +1035,7 @@ "summary": "{tables} 张表 · {fks} 个外键", "loading": "加载结构中…", "empty": "没有可显示的表", + "needDb": "当前 MySQL 数据源未指定数据库,请先在数据源中选择一个数据库再查看 ER 图", "tip": "拖动表头可调整布局" }, "aiCode": { From 235d709f47fce4c93ad19c77e221249e03e8c8bc Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Thu, 25 Jun 2026 12:03:31 +0800 Subject: [PATCH 03/16] =?UTF-8?q?feat(sql):=20ER=20=E5=9B=BE=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=86=85=E8=81=94=E9=80=89=E6=8B=A9=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=EF=BC=88MySQL=20=E6=9C=AA=E9=80=89=E5=BA=93=E6=97=B6?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MySQL 未指定库时不再只是提示,改为在 ER 图内列出数据库供点击选择 - 选库后加载该库 ER;头部显示当前库并提供「换库」返回选择 - 替换 er.needDb 为 er.pickDb/switchDb --- src/components/ErDiagram.vue | 44 ++++++++++++++++++++++++++++++------ src/i18n/locales/en.json | 3 ++- src/i18n/locales/zh-CN.json | 3 ++- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/components/ErDiagram.vue b/src/components/ErDiagram.vue index a823664c..4af37a9c 100644 --- a/src/components/ErDiagram.vue +++ b/src/components/ErDiagram.vue @@ -12,7 +12,9 @@
{{ t('er.title') }} · {{ activeLabel() }} - {{ t('er.summary', { tables: tables.length, fks: fks.length }) }} + / {{ pickedDb }} + + {{ t('er.summary', { tables: tables.length, fks: fks.length }) }}
+
+
{{ t('er.empty') }}
+
{{ t('er.empty') }}
@@ -76,6 +85,10 @@ const loading = ref(false) const error = ref('') const tables = ref([]) const fks = ref([]) +// MySQL 未选库时的内联数据库选择 +const picking = ref(false) +const dbList = ref([]) +const pickedDb = ref('') const pos = reactive>({}) const cardH = (tbl: Tbl) => HEADER_H + tbl.columns.length * ROW_H + 4 @@ -166,14 +179,18 @@ const load = async () => { error.value = '' try { const source = resolveActiveSource() - // MySQL 未选具体数据库时无法列表,提示用户先在数据源中选库 - if (source.kind === 'mysql' && !source.database) { - tables.value = [] - fks.value = [] - error.value = t('er.needDb') + // MySQL 未选具体数据库时,先列出数据库让用户在此选择 + if (source.kind === 'mysql' && !source.database && !pickedDb.value) { + picking.value = true + dbList.value = (await runRows( + 'SELECT schema_name FROM information_schema.schemata ' + + "WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys') " + + 'ORDER BY schema_name' + )).map(r => String(r[0])) return } - const db = source.kind === 'mysql' ? source.database || undefined : undefined + picking.value = false + const db = source.kind === 'mysql' ? source.database || pickedDb.value || undefined : undefined tables.value = groupTables(await runRows(columnsSql(source.kind, db))) const fkQuery = fksSql(source.kind, db) if (fkQuery) { @@ -195,6 +212,19 @@ const load = async () => { const open = () => { visible.value = true + pickedDb.value = '' + picking.value = false + load() +} + +// 选择数据库后加载该库的 ER +const selectDb = (name: string) => { + pickedDb.value = name + load() +} +// 返回数据库选择 +const backToPick = () => { + pickedDb.value = '' load() } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5c7cfd99..056e2d0d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1035,7 +1035,8 @@ "summary": "{tables} tables · {fks} foreign keys", "loading": "Loading schema…", "empty": "No tables to show", - "needDb": "This MySQL data source has no database selected. Pick a database in the data source first, then open the ER diagram.", + "pickDb": "Choose a database to view:", + "switchDb": "Switch DB", "tip": "Drag a table header to rearrange" }, "aiCode": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 56a460a8..4f8c6544 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1035,7 +1035,8 @@ "summary": "{tables} 张表 · {fks} 个外键", "loading": "加载结构中…", "empty": "没有可显示的表", - "needDb": "当前 MySQL 数据源未指定数据库,请先在数据源中选择一个数据库再查看 ER 图", + "pickDb": "请选择要查看的数据库:", + "switchDb": "换库", "tip": "拖动表头可调整布局" }, "aiCode": { From 59ea92e78fbf5d379df0495acc0b528a874f9046 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Thu, 25 Jun 2026 12:19:50 +0800 Subject: [PATCH 04/16] =?UTF-8?q?feat(sql):=20=E4=BA=A4=E4=BA=92=E5=BC=8F?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E6=8E=A7=E5=88=B6=20SQLite/DuckDB=20(D4-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 TxnConn trait + TxnState(单会话持久连接) + tx_begin/exec/finish/active 命令 - DbExecutor 加默认 begin()(不支持则报错);SQLite/DuckDB 抽出 run_on_conn 并实现 begin + Txn(BEGIN→同连接执行→COMMIT/ROLLBACK) - 前端 useSqlTxn + TxnControl 工具栏(开启/提交/回滚+事务中标识);事务进行中 runSql 改走 tx_exec(同一连接、不分页) - MySQL/Postgres 待 D4-2;补充 txn.* 文案 --- src-tauri/src/db/duckdb.rs | 148 ++++++++++++++++++++-------------- src-tauri/src/db/mod.rs | 91 +++++++++++++++++++++ src-tauri/src/db/sqlite.rs | 148 ++++++++++++++++++++-------------- src-tauri/src/main.rs | 8 +- src/App.vue | 31 +++++++ src/components/TxnControl.vue | 61 ++++++++++++++ src/composables/useSqlTxn.ts | 28 +++++++ src/i18n/locales/en.json | 11 +++ src/i18n/locales/zh-CN.json | 11 +++ 9 files changed, 416 insertions(+), 121 deletions(-) create mode 100644 src/components/TxnControl.vue create mode 100644 src/composables/useSqlTxn.ts diff --git a/src-tauri/src/db/duckdb.rs b/src-tauri/src/db/duckdb.rs index 27798a77..29f0a650 100644 --- a/src-tauri/src/db/duckdb.rs +++ b/src-tauri/src/db/duckdb.rs @@ -1,9 +1,84 @@ -use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, split_sql}; +use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, TxnConn, split_sql}; use duckdb::Connection; use serde_json::Value as JsonValue; pub(crate) struct DuckdbExecutor; +fn open_conn(source: &DataSource) -> Result { + let conn = match source.file.as_deref() { + Some(p) if !p.trim().is_empty() => Connection::open(p), + _ => Connection::open_in_memory(), + }; + conn.map_err(|e| format!("打开 DuckDB 失败: {}", e)) +} + +/// 在已有连接上执行脚本(run 与事务会话共用) +fn run_on_conn(conn: &Connection, sql: &str) -> SqlRunResult { + let mut result = SqlRunResult::new(); + 'stmts: for stmt_sql in split_sql(sql) { + let mut stmt = match conn.prepare(&stmt_sql) { + Ok(s) => s, + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + }; + let ncol = stmt.column_count(); + if ncol > 0 { + let columns: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); + let rows_iter = stmt.query_map([], |row| { + let mut v = Vec::with_capacity(ncol); + for idx in 0..ncol { + v.push(value_to_json(row.get(idx)?)); + } + Ok(v) + }); + match rows_iter { + Ok(it) => { + let mut rows = Vec::new(); + for r in it { + match r { + Ok(rw) => rows.push(rw), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + result.result_sets.push(SqlResultSet { columns, rows }); + } + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } else { + match stmt.execute([]) { + Ok(affected) => result.messages.push(format!("OK,影响 {} 行", affected)), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + } + result +} + +struct DuckdbTxn { + conn: Connection, +} +impl TxnConn for DuckdbTxn { + fn exec(&mut self, sql: &str) -> SqlRunResult { + run_on_conn(&self.conn, sql) + } + fn finish(&mut self, commit: bool) -> Result<(), String> { + self.conn + .execute_batch(if commit { "COMMIT" } else { "ROLLBACK" }) + .map_err(|e| e.to_string()) + } +} + fn value_to_json(v: duckdb::types::Value) -> JsonValue { use duckdb::types::Value::*; match v { @@ -33,67 +108,20 @@ impl DbExecutor for DuckdbExecutor { } fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { - let mut result = SqlRunResult::new(); - let conn = match source.file.as_deref() { - Some(p) if !p.trim().is_empty() => Connection::open(p), - _ => Connection::open_in_memory(), - }; - let conn = match conn { - Ok(c) => c, + match open_conn(source) { + Ok(conn) => run_on_conn(&conn, sql), Err(e) => { - result.error = Some(format!("打开 DuckDB 失败: {}", e)); - return result; - } - }; - - 'stmts: for stmt_sql in split_sql(sql) { - let mut stmt = match conn.prepare(&stmt_sql) { - Ok(s) => s, - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - }; - let ncol = stmt.column_count(); - if ncol > 0 { - let columns: Vec = - stmt.column_names().iter().map(|s| s.to_string()).collect(); - let rows_iter = stmt.query_map([], |row| { - let mut v = Vec::with_capacity(ncol); - for idx in 0..ncol { - v.push(value_to_json(row.get(idx)?)); - } - Ok(v) - }); - match rows_iter { - Ok(it) => { - let mut rows = Vec::new(); - for r in it { - match r { - Ok(rw) => rows.push(rw), - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } - result.result_sets.push(SqlResultSet { columns, rows }); - } - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } else { - match stmt.execute([]) { - Ok(affected) => result.messages.push(format!("OK,影响 {} 行", affected)), - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } + let mut result = SqlRunResult::new(); + result.error = Some(e); + result } } - result + } + + fn begin(&self, source: &DataSource) -> Result, String> { + let conn = open_conn(source)?; + conn.execute_batch("BEGIN TRANSACTION") + .map_err(|e| format!("开启事务失败: {}", e))?; + Ok(Box::new(DuckdbTxn { conn })) } } diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index adbb2735..d1108fe2 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -118,12 +118,103 @@ pub(crate) fn resolve_endpoint(source: &DataSource, default_port: u16) -> Result } } +/// 交互式事务会话:持有一条连接,跨多次调用在同一连接上执行。 +pub(crate) trait TxnConn: Send { + /// 在持有连接上执行 SQL(错误写入 result.error) + fn exec(&mut self, sql: &str) -> SqlRunResult; + /// 结束事务:commit=true 提交,否则回滚 + fn finish(&mut self, commit: bool) -> Result<(), String>; +} + /// 数据库执行器接口:新增数据库类型只需实现本 trait 并在 executors() 注册。 pub(crate) trait DbExecutor: Send + Sync { /// 是否处理该数据源类型(如 sqlite 同时处理 "sqlite" 与 "memory") fn handles(&self, kind: &str) -> bool; /// 执行脚本,返回结构化结果(错误写入 result.error,不以 Err 形式返回) fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult; + /// 开启交互式事务,返回持有连接的会话;不支持事务的执行器返回 Err。 + fn begin(&self, _source: &DataSource) -> Result, String> { + Err("该数据源暂不支持交互式事务".to_string()) + } +} + +/// 全局事务状态:同一时刻仅允许一个进行中的交互式事务(一个编辑器一个事务)。 +pub struct TxnState(pub std::sync::Arc>>>); + +impl TxnState { + pub fn new() -> Self { + Self(std::sync::Arc::new(std::sync::Mutex::new(None))) + } +} + +impl Default for TxnState { + fn default() -> Self { + Self::new() + } +} + +/// 开启事务(已有进行中的事务会先回滚丢弃)。 +#[tauri::command] +pub async fn tx_begin(source: DataSource, state: tauri::State<'_, TxnState>) -> Result<(), String> { + let slot = state.0.clone(); + tokio::task::spawn_blocking(move || { + let execs = executors(); + let exec = execs + .iter() + .find(|e| e.handles(&source.kind)) + .ok_or_else(|| format!("不支持的数据源类型: {}", source.kind))?; + let conn = exec.begin(&source)?; + let mut guard = slot.lock().map_err(|e| e.to_string())?; + if let Some(mut old) = guard.take() { + let _ = old.finish(false); + } + *guard = Some(conn); + Ok(()) + }) + .await + .map_err(|e| format!("事务任务失败: {}", e))? +} + +/// 在进行中的事务上执行 SQL。 +#[tauri::command] +pub async fn tx_exec( + sql: String, + state: tauri::State<'_, TxnState>, +) -> Result { + let slot = state.0.clone(); + tokio::task::spawn_blocking(move || { + let mut guard = slot.lock().map_err(|e| e.to_string())?; + let conn = guard + .as_mut() + .ok_or_else(|| "没有进行中的事务".to_string())?; + Ok(conn.exec(&sql)) + }) + .await + .map_err(|e| format!("事务任务失败: {}", e))? +} + +/// 结束事务:commit=true 提交,否则回滚。 +#[tauri::command] +pub async fn tx_finish(commit: bool, state: tauri::State<'_, TxnState>) -> Result<(), String> { + let slot = state.0.clone(); + tokio::task::spawn_blocking(move || { + let mut guard = slot.lock().map_err(|e| e.to_string())?; + match guard.take() { + Some(mut conn) => conn.finish(commit), + None => Err("没有进行中的事务".to_string()), + } + }) + .await + .map_err(|e| format!("事务任务失败: {}", e))? +} + +/// 是否有进行中的事务。 +#[tauri::command] +pub async fn tx_active(state: tauri::State<'_, TxnState>) -> Result { + let slot = state.0.clone(); + tokio::task::spawn_blocking(move || Ok(slot.lock().map(|g| g.is_some()).unwrap_or(false))) + .await + .map_err(|e| format!("事务任务失败: {}", e))? } /// 已注册的执行器。新增数据库类型:在此加一行。 diff --git a/src-tauri/src/db/sqlite.rs b/src-tauri/src/db/sqlite.rs index 9c563a5e..05ed25fc 100644 --- a/src-tauri/src/db/sqlite.rs +++ b/src-tauri/src/db/sqlite.rs @@ -1,9 +1,84 @@ -use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, split_sql}; +use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, TxnConn, split_sql}; use rusqlite::Connection; use serde_json::Value as JsonValue; pub(crate) struct SqliteExecutor; +fn open_conn(source: &DataSource) -> Result { + let conn = match source.file.as_deref() { + Some(p) if !p.trim().is_empty() => Connection::open(p), + _ => Connection::open_in_memory(), + }; + conn.map_err(|e| format!("打开数据库失败: {}", e)) +} + +/// 在已有连接上执行脚本(run 与事务会话共用) +fn run_on_conn(conn: &Connection, sql: &str) -> SqlRunResult { + let mut result = SqlRunResult::new(); + 'stmts: for stmt_sql in split_sql(sql) { + let mut stmt = match conn.prepare(&stmt_sql) { + Ok(s) => s, + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + }; + let ncol = stmt.column_count(); + if ncol > 0 { + let columns: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); + let rows_iter = stmt.query_map([], |row| { + let mut v = Vec::with_capacity(ncol); + for idx in 0..ncol { + v.push(value_to_json(row.get(idx)?)); + } + Ok(v) + }); + match rows_iter { + Ok(it) => { + let mut rows = Vec::new(); + for r in it { + match r { + Ok(rw) => rows.push(rw), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + result.result_sets.push(SqlResultSet { columns, rows }); + } + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } else { + match stmt.execute([]) { + Ok(affected) => result.messages.push(format!("OK,影响 {} 行", affected)), + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + } + result +} + +struct SqliteTxn { + conn: Connection, +} +impl TxnConn for SqliteTxn { + fn exec(&mut self, sql: &str) -> SqlRunResult { + run_on_conn(&self.conn, sql) + } + fn finish(&mut self, commit: bool) -> Result<(), String> { + self.conn + .execute_batch(if commit { "COMMIT" } else { "ROLLBACK" }) + .map_err(|e| e.to_string()) + } +} + fn value_to_json(v: rusqlite::types::Value) -> JsonValue { use rusqlite::types::Value::*; match v { @@ -21,67 +96,20 @@ impl DbExecutor for SqliteExecutor { } fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { - let mut result = SqlRunResult::new(); - let conn = match source.file.as_deref() { - Some(p) if !p.trim().is_empty() => Connection::open(p), - _ => Connection::open_in_memory(), - }; - let conn = match conn { - Ok(c) => c, + match open_conn(source) { + Ok(conn) => run_on_conn(&conn, sql), Err(e) => { - result.error = Some(format!("打开数据库失败: {}", e)); - return result; - } - }; - - 'stmts: for stmt_sql in split_sql(sql) { - let mut stmt = match conn.prepare(&stmt_sql) { - Ok(s) => s, - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - }; - let ncol = stmt.column_count(); - if ncol > 0 { - let columns: Vec = - stmt.column_names().iter().map(|s| s.to_string()).collect(); - let rows_iter = stmt.query_map([], |row| { - let mut v = Vec::with_capacity(ncol); - for idx in 0..ncol { - v.push(value_to_json(row.get(idx)?)); - } - Ok(v) - }); - match rows_iter { - Ok(it) => { - let mut rows = Vec::new(); - for r in it { - match r { - Ok(rw) => rows.push(rw), - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } - result.result_sets.push(SqlResultSet { columns, rows }); - } - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } else { - match stmt.execute([]) { - Ok(affected) => result.messages.push(format!("OK,影响 {} 行", affected)), - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } + let mut result = SqlRunResult::new(); + result.error = Some(e); + result } } - result + } + + fn begin(&self, source: &DataSource) -> Result, String> { + let conn = open_conn(source)?; + conn.execute_batch("BEGIN") + .map_err(|e| format!("开启事务失败: {}", e))?; + Ok(Box::new(SqliteTxn { conn })) } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index affbb8e6..3c063bc9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -43,7 +43,7 @@ use crate::custom_plugin_commands::{ use crate::dap::{ DapState, dap_adapter_list, dap_available, dap_install, dap_send, dap_start, dap_stop, }; -use crate::db::{run_sql, run_sql_paged}; +use crate::db::{TxnState, run_sql, run_sql_paged, tx_active, tx_begin, tx_exec, tx_finish}; use crate::db_connections::{ DbConnStore, db_connection_delete, db_connection_save, db_connections_list, }; @@ -125,6 +125,7 @@ fn main() { .manage(TerminalState::new()) .manage(LspState::new()) .manage(DapState::new()) + .manage(TxnState::new()) .manage(ExecutionPluginManagerState::new(PluginManager::new())) .manage(EnvironmentManagerState::new(env_manager)) .setup(|app| { @@ -327,6 +328,11 @@ fn main() { // SQL 执行 run_sql, run_sql_paged, + // 交互式事务 + tx_begin, + tx_exec, + tx_finish, + tx_active, // LSP 桥接 lsp_available, lsp_start, diff --git a/src/App.vue b/src/App.vue index 05568e6a..b77006d0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -96,6 +96,7 @@ +
@@ -235,6 +236,7 @@ +
@@ -515,6 +517,8 @@ import DebugToolbar from './components/DebugToolbar.vue' import DebugPanel from './components/DebugPanel.vue' import AiCodeAction from './components/AiCodeAction.vue' import ErDiagram from './components/ErDiagram.vue' +import TxnControl from './components/TxnControl.vue' +import {useSqlTxn} from './composables/useSqlTxn' import GoToLine from './components/GoToLine.vue' import Outline from './components/Outline.vue' import SnippetManager from './components/SnippetManager.vue' @@ -1593,6 +1597,7 @@ watch(editorView, () => applyDiffMarkers()) // ===== 断点(B1-P2):把当前文件的断点 + 执行行派发到编辑器 ===== const debug = useDebug() +const sqlTxn = useSqlTxn() const applyBreakpoints = () => { const view = editorView.value if (!view) { @@ -1839,6 +1844,32 @@ const runSql = async (sqlOverride?: string) => { toast.info(t('app.noSql')) return } + // 事务进行中:整段在持有连接上执行(不走分页,否则分页用的是另一条连接看不到未提交数据) + if (sqlTxn.active.value) { + output.value = '' + isSuccess.value = false + if (layoutMode.value === 'editor') { + showConsole.value = true + } + isRunning.value = true + try { + const res = await sqlTxn.exec(sql) + output.value = JSON.stringify(res) + isSuccess.value = !res.error + lastExecutionTime.value = res.elapsed_ms || 0 + if (res.error) { + toast.error(t('app.sqlFailed')) + } + } + catch (error) { + output.value = JSON.stringify({result_sets: [], messages: [], error: String(error)}) + toast.error(t('app.sqlFailedColon') + error) + } + finally { + isRunning.value = false + } + return + } const source = resolveActiveSource() output.value = '' isSuccess.value = false diff --git a/src/components/TxnControl.vue b/src/components/TxnControl.vue new file mode 100644 index 00000000..1a742771 --- /dev/null +++ b/src/components/TxnControl.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/composables/useSqlTxn.ts b/src/composables/useSqlTxn.ts new file mode 100644 index 00000000..1183800c --- /dev/null +++ b/src/composables/useSqlTxn.ts @@ -0,0 +1,28 @@ +// 交互式 SQL 事务(单例)。后端按会话持有一条连接,跨多次执行在同一连接上跑。 +import {ref} from 'vue' +import {invoke} from '@tauri-apps/api/core' + +const active = ref(false) + +async function begin(source: any): Promise { + await invoke('tx_begin', {source}) + active.value = true +} + +// 在事务连接上执行 SQL,返回与 run_sql 同结构的结果 +async function exec(sql: string): Promise { + return await invoke('tx_exec', {sql}) +} + +async function finish(commit: boolean): Promise { + try { + await invoke('tx_finish', {commit}) + } + finally { + active.value = false + } +} + +export function useSqlTxn() { + return {active, begin, exec, finish} +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 056e2d0d..d5e1d7f9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1030,6 +1030,17 @@ "revealIn": "Reveal in file manager: {path}", "unsaved": "Unsaved changes" }, + "txn": { + "begin": "Transaction", + "beginTitle": "Begin a transaction (runs on one connection until commit/rollback)", + "inTxn": "In transaction", + "commit": "Commit", + "rollback": "Rollback", + "began": "Transaction started", + "committed": "Committed", + "rolledBack": "Rolled back", + "failed": "Transaction failed" + }, "er": { "title": "ER diagram", "summary": "{tables} tables · {fks} foreign keys", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 4f8c6544..b7325615 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1030,6 +1030,17 @@ "revealIn": "在访达中显示:{path}", "unsaved": "有未保存的修改" }, + "txn": { + "begin": "事务", + "beginTitle": "开启事务(在同一连接上执行,直到提交/回滚)", + "inTxn": "事务中", + "commit": "提交", + "rollback": "回滚", + "began": "事务已开启", + "committed": "已提交", + "rolledBack": "已回滚", + "failed": "事务操作失败" + }, "er": { "title": "ER 图", "summary": "{tables} 张表 · {fks} 个外键", From af3e048263db023ad1612dfa6ff2dec70ef87afd Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Thu, 25 Jun 2026 12:22:17 +0800 Subject: [PATCH 05/16] =?UTF-8?q?feat(sql):=20=E4=BA=A4=E4=BA=92=E5=BC=8F?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E6=94=AF=E6=8C=81=20MySQL/Postgres=20(D4-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽出 connect()(返回连接+Endpoint,事务会话持有 Endpoint 以保活 SSH 隧道)与 run_on_conn/run_on_client - MySQL: START TRANSACTION → 同连接执行 → COMMIT/ROLLBACK;Postgres 同理(BEGIN) - ClickHouse 仍用默认 begin() 报「不支持事务」 --- src-tauri/src/db/mysql.rs | 163 +++++++++++++++----------- src-tauri/src/db/postgres.rs | 216 +++++++++++++++++++---------------- 2 files changed, 211 insertions(+), 168 deletions(-) diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs index 29d61e9d..af463544 100644 --- a/src-tauri/src/db/mysql.rs +++ b/src-tauri/src/db/mysql.rs @@ -1,8 +1,88 @@ -use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, resolve_endpoint, split_sql}; +use super::{ + DataSource, DbExecutor, Endpoint, SqlResultSet, SqlRunResult, TxnConn, resolve_endpoint, + split_sql, +}; use serde_json::Value as JsonValue; pub(crate) struct MysqlExecutor; +// 建连并返回连接 + 端点(端点持有 SSH 隧道,须随连接一起存活) +fn connect(source: &DataSource) -> Result<(mysql::Conn, Endpoint), String> { + let endpoint = resolve_endpoint(source, 3306)?; + let mut builder = mysql::OptsBuilder::new() + .ip_or_hostname(Some(endpoint.host.clone())) + .tcp_port(endpoint.port) + .user(source.user.clone()) + .pass(source.password.clone()) + .db_name(source.database.clone()); + if source.ssl.unwrap_or(false) { + builder = builder.ssl_opts(Some( + mysql::SslOpts::default() + .with_danger_accept_invalid_certs(true) + .with_danger_skip_domain_validation(true), + )); + } + let conn = mysql::Conn::new(builder).map_err(|e| format!("连接 MySQL 失败: {}", e))?; + Ok((conn, endpoint)) +} + +/// 在已有连接上执行脚本(run 与事务会话共用) +fn run_on_conn(conn: &mut mysql::Conn, sql: &str) -> SqlRunResult { + use mysql::prelude::Queryable; + let mut result = SqlRunResult::new(); + 'stmts: for stmt_sql in split_sql(sql) { + let mut qr = match conn.query_iter(&stmt_sql) { + Ok(q) => q, + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + }; + let columns: Vec = qr + .columns() + .as_ref() + .iter() + .map(|c| c.name_str().to_string()) + .collect(); + if columns.is_empty() { + let affected = qr.affected_rows(); + result.messages.push(format!("OK,影响 {} 行", affected)); + } else { + let mut rows = Vec::new(); + for r in qr.by_ref() { + match r { + Ok(row) => { + let vals = row.unwrap(); + rows.push(vals.iter().map(value_to_json).collect()); + } + Err(e) => { + result.error = Some(e.to_string()); + break 'stmts; + } + } + } + result.result_sets.push(SqlResultSet { columns, rows }); + } + } + result +} + +struct MysqlTxn { + conn: mysql::Conn, + _endpoint: Endpoint, +} +impl TxnConn for MysqlTxn { + fn exec(&mut self, sql: &str) -> SqlRunResult { + run_on_conn(&mut self.conn, sql) + } + fn finish(&mut self, commit: bool) -> Result<(), String> { + use mysql::prelude::Queryable; + self.conn + .query_drop(if commit { "COMMIT" } else { "ROLLBACK" }) + .map_err(|e| e.to_string()) + } +} + fn value_to_json(v: &mysql::Value) -> JsonValue { use mysql::Value::*; match v { @@ -36,76 +116,25 @@ impl DbExecutor for MysqlExecutor { } fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { - use mysql::prelude::Queryable; - let mut result = SqlRunResult::new(); - - // 解析端点:启用 SSH 时隧道转发到本地端口(隧道随 endpoint 在本函数结束时关闭) - let endpoint = match resolve_endpoint(source, 3306) { - Ok(e) => e, + match connect(source) { + // endpoint 持有隧道,run 期间保持存活 + Ok((mut conn, _endpoint)) => run_on_conn(&mut conn, sql), Err(e) => { + let mut result = SqlRunResult::new(); result.error = Some(e); - return result; + result } - }; - - let mut builder = mysql::OptsBuilder::new() - .ip_or_hostname(Some(endpoint.host.clone())) - .tcp_port(endpoint.port) - .user(source.user.clone()) - .pass(source.password.clone()) - .db_name(source.database.clone()); - - // 启用 SSL:rustls 加密连接(开发场景放宽证书校验) - if source.ssl.unwrap_or(false) { - builder = builder.ssl_opts(Some( - mysql::SslOpts::default() - .with_danger_accept_invalid_certs(true) - .with_danger_skip_domain_validation(true), - )); } + } - let mut conn = match mysql::Conn::new(builder) { - Ok(c) => c, - Err(e) => { - result.error = Some(format!("连接 MySQL 失败: {}", e)); - return result; - } - }; - - 'stmts: for stmt_sql in split_sql(sql) { - let mut qr = match conn.query_iter(&stmt_sql) { - Ok(q) => q, - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - }; - let columns: Vec = qr - .columns() - .as_ref() - .iter() - .map(|c| c.name_str().to_string()) - .collect(); - if columns.is_empty() { - let affected = qr.affected_rows(); - result.messages.push(format!("OK,影响 {} 行", affected)); - } else { - let mut rows = Vec::new(); - for r in qr.by_ref() { - match r { - Ok(row) => { - let vals = row.unwrap(); - rows.push(vals.iter().map(value_to_json).collect()); - } - Err(e) => { - result.error = Some(e.to_string()); - break 'stmts; - } - } - } - result.result_sets.push(SqlResultSet { columns, rows }); - } - } - result + fn begin(&self, source: &DataSource) -> Result, String> { + use mysql::prelude::Queryable; + let (mut conn, endpoint) = connect(source)?; + conn.query_drop("START TRANSACTION") + .map_err(|e| format!("开启事务失败: {}", e))?; + Ok(Box::new(MysqlTxn { + conn, + _endpoint: endpoint, + })) } } diff --git a/src-tauri/src/db/postgres.rs b/src-tauri/src/db/postgres.rs index 91d54974..56717b79 100644 --- a/src-tauri/src/db/postgres.rs +++ b/src-tauri/src/db/postgres.rs @@ -1,89 +1,61 @@ -use super::{DataSource, DbExecutor, SqlResultSet, SqlRunResult, resolve_endpoint}; +use super::{ + DataSource, DbExecutor, Endpoint, SqlResultSet, SqlRunResult, TxnConn, resolve_endpoint, +}; use postgres::config::SslMode; -use postgres::{Config, NoTls, SimpleQueryMessage}; +use postgres::{Client, Config, NoTls, SimpleQueryMessage}; use serde_json::Value as JsonValue; pub(crate) struct PostgresExecutor; -impl DbExecutor for PostgresExecutor { - fn handles(&self, kind: &str) -> bool { - kind == "postgres" || kind == "postgresql" +// 建连并返回客户端 + 端点(端点持有 SSH 隧道,须随客户端一起存活) +fn connect(source: &DataSource) -> Result<(Client, Endpoint), String> { + let endpoint = resolve_endpoint(source, 5432)?; + let use_ssl = source.ssl.unwrap_or(false); + let mut cfg = Config::new(); + cfg.host(&endpoint.host) + .port(endpoint.port) + .user(source.user.as_deref().unwrap_or("postgres")) + .ssl_mode(if use_ssl { + SslMode::Require + } else { + SslMode::Disable + }); + if let Some(pwd) = source.password.as_deref() { + cfg.password(pwd); } + if let Some(db) = source.database.as_deref() { + cfg.dbname(db); + } + let client = if use_ssl { + let connector = native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .build() + .map_err(|e| format!("初始化 TLS 失败: {}", e))?; + let tls = postgres_native_tls::MakeTlsConnector::new(connector); + cfg.connect(tls) + .map_err(|e| format!("连接 PostgreSQL 失败: {}", e))? + } else { + cfg.connect(NoTls) + .map_err(|e| format!("连接 PostgreSQL 失败: {}", e))? + }; + Ok((client, endpoint)) +} - fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { - let mut result = SqlRunResult::new(); - - // 解析端点:启用 SSH 时隧道转发到本地端口(隧道随 endpoint 在本函数结束时关闭) - let endpoint = match resolve_endpoint(source, 5432) { - Ok(e) => e, - Err(e) => { - result.error = Some(e); - return result; - } - }; - - let use_ssl = source.ssl.unwrap_or(false); - let mut cfg = Config::new(); - cfg.host(&endpoint.host) - .port(endpoint.port) - .user(source.user.as_deref().unwrap_or("postgres")) - .ssl_mode(if use_ssl { - SslMode::Require - } else { - SslMode::Disable - }); - if let Some(pwd) = source.password.as_deref() { - cfg.password(pwd); +/// 在已有客户端上执行脚本(run 与事务会话共用)。simple_query 一次执行整段,按消息流切分结果集。 +fn run_on_client(client: &mut Client, sql: &str) -> SqlRunResult { + let mut result = SqlRunResult::new(); + let messages = match client.simple_query(sql) { + Ok(m) => m, + Err(e) => { + result.error = Some(e.to_string()); + return result; } - if let Some(db) = source.database.as_deref() { - cfg.dbname(db); - } - - // 启用 SSL:native-tls 加密连接(开发场景放宽证书校验);否则 NoTls 明文 - let mut client = if use_ssl { - let connector = match native_tls::TlsConnector::builder() - .danger_accept_invalid_certs(true) - .danger_accept_invalid_hostnames(true) - .build() - { - Ok(c) => c, - Err(e) => { - result.error = Some(format!("初始化 TLS 失败: {}", e)); - return result; - } - }; - let tls = postgres_native_tls::MakeTlsConnector::new(connector); - match cfg.connect(tls) { - Ok(c) => c, - Err(e) => { - result.error = Some(format!("连接 PostgreSQL 失败: {}", e)); - return result; - } - } - } else { - match cfg.connect(NoTls) { - Ok(c) => c, - Err(e) => { - result.error = Some(format!("连接 PostgreSQL 失败: {}", e)); - return result; - } - } - }; - - // simple_query 一次执行整段脚本,按消息流切分结果集与影响行数 - let messages = match client.simple_query(sql) { - Ok(m) => m, - Err(e) => { - result.error = Some(e.to_string()); - return result; - } - }; - - let mut columns: Vec = Vec::new(); - let mut rows: Vec> = Vec::new(); - let flush = |columns: &mut Vec, - rows: &mut Vec>, - result: &mut SqlRunResult| { + }; + let mut columns: Vec = Vec::new(); + let mut rows: Vec> = Vec::new(); + let flush = + |columns: &mut Vec, rows: &mut Vec>, result: &mut SqlRunResult| { if !columns.is_empty() { result.result_sets.push(SqlResultSet { columns: std::mem::take(columns), @@ -91,32 +63,74 @@ impl DbExecutor for PostgresExecutor { }); } }; - - for msg in messages { - match msg { - SimpleQueryMessage::Row(row) => { - if columns.is_empty() { - columns = row.columns().iter().map(|c| c.name().to_string()).collect(); - } - let vals = (0..row.len()) - .map(|i| match row.get(i) { - Some(s) => JsonValue::from(s.to_string()), - None => JsonValue::Null, - }) - .collect(); - rows.push(vals); + for msg in messages { + match msg { + SimpleQueryMessage::Row(row) => { + if columns.is_empty() { + columns = row.columns().iter().map(|c| c.name().to_string()).collect(); } - SimpleQueryMessage::CommandComplete(n) => { - if columns.is_empty() { - result.messages.push(format!("OK,影响 {} 行", n)); - } else { - flush(&mut columns, &mut rows, &mut result); - } + let vals = (0..row.len()) + .map(|i| match row.get(i) { + Some(s) => JsonValue::from(s.to_string()), + None => JsonValue::Null, + }) + .collect(); + rows.push(vals); + } + SimpleQueryMessage::CommandComplete(n) => { + if columns.is_empty() { + result.messages.push(format!("OK,影响 {} 行", n)); + } else { + flush(&mut columns, &mut rows, &mut result); } - _ => {} + } + _ => {} + } + } + flush(&mut columns, &mut rows, &mut result); + result +} + +struct PostgresTxn { + client: Client, + _endpoint: Endpoint, +} +impl TxnConn for PostgresTxn { + fn exec(&mut self, sql: &str) -> SqlRunResult { + run_on_client(&mut self.client, sql) + } + fn finish(&mut self, commit: bool) -> Result<(), String> { + self.client + .simple_query(if commit { "COMMIT" } else { "ROLLBACK" }) + .map(|_| ()) + .map_err(|e| e.to_string()) + } +} + +impl DbExecutor for PostgresExecutor { + fn handles(&self, kind: &str) -> bool { + kind == "postgres" || kind == "postgresql" + } + + fn run(&self, sql: &str, source: &DataSource) -> SqlRunResult { + match connect(source) { + Ok((mut client, _endpoint)) => run_on_client(&mut client, sql), + Err(e) => { + let mut result = SqlRunResult::new(); + result.error = Some(e); + result } } - flush(&mut columns, &mut rows, &mut result); - result + } + + fn begin(&self, source: &DataSource) -> Result, String> { + let (mut client, endpoint) = connect(source)?; + client + .simple_query("BEGIN") + .map_err(|e| format!("开启事务失败: {}", e))?; + Ok(Box::new(PostgresTxn { + client, + _endpoint: endpoint, + })) } } From 9332348265bb56c80a7324181ec8490736b184c7 Mon Sep 17 00:00:00 2001 From: qianmoQ Date: Thu, 25 Jun 2026 12:26:39 +0800 Subject: [PATCH 06/16] =?UTF-8?q?fix(sql):=20=E4=BF=AE=E5=A4=8D=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1=20tooltip=20=E6=AE=8B=E7=95=99=E4=B8=8E=E6=8F=90?= =?UTF-8?q?=E4=BA=A4/=E5=9B=9E=E6=BB=9A=E6=97=A0=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 去掉开启事务按钮的原生 title:点击后按钮被 v-if 替换,浏览器收不到 mouseout 导致 title 提示卡住 - 开启/提交/回滚向结果面板写入一条消息(emit notice → onTxnNotice),不再只有 toast、面板无变化 --- src/App.vue | 13 +++++++++++-- src/components/TxnControl.vue | 8 ++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/App.vue b/src/App.vue index b77006d0..9ba981ed 100644 --- a/src/App.vue +++ b/src/App.vue @@ -96,7 +96,7 @@ - +
@@ -236,7 +236,7 @@ - +
@@ -1598,6 +1598,15 @@ watch(editorView, () => applyDiffMarkers()) // ===== 断点(B1-P2):把当前文件的断点 + 执行行派发到编辑器 ===== const debug = useDebug() const sqlTxn = useSqlTxn() + +// 事务开启/提交/回滚的反馈写入结果面板 +const onTxnNotice = (text: string) => { + if (layoutMode.value === 'editor') { + showConsole.value = true + } + output.value = JSON.stringify({result_sets: [], messages: [text], error: null}) + isSuccess.value = true +} const applyBreakpoints = () => { const view = editorView.value if (!view) { diff --git a/src/components/TxnControl.vue b/src/components/TxnControl.vue index 1a742771..f3d1060b 100644 --- a/src/components/TxnControl.vue +++ b/src/components/TxnControl.vue @@ -2,7 +2,7 @@