30 KiB
PostgreSQL 执行错误「点击定位到行列」开发方案
目标:在 PG 连接下执行 SQL 失败后,错误信息区域出现「定位错误」入口,点击后把编辑器光标定位到出错的行/列(并高亮该位置)。 本文先给出代码现状分析,再给出可执行的改造方案与分阶段任务。
1. 现状分析
1.1 错误从 PG 到界面的完整链路
tokio_postgres::Error
└─ DbError.position() = ErrorPosition::Original(cursorpos) ← 位置信息在这里,当前被丢弃
│
│ crates/dbx-core/src/db/postgres.rs::pg_error_to_string()
│ (只取 err.as_db_error().map(ToString::to_string)) ← 只保留文本,未保留 cursorpos
▼
Result<db::QueryResult, String> ← db 层统一用 String 承载错误
│
│ crates/dbx-core/src/query.rs::do_execute_typed() 尾部 .map_err(...)
▼
QueryExecutionError::{Legacy|Sql}(String) ← 仍是纯文本
│
│ QueryExecutionError::into_backend_error()
▼
BackendError { code, messageKey, detail, diagnostics, ... } ← 结构化,但无位置
│
├─ 单语句/事务:tauri command 返回 Err(BackendError)
└─ 多语句:ExecuteMultiResult { execution_error: true, error: BackendError, statement_index }
▼
前端 queryStore:
- 多语句:annotateQueryResultSources() 给每个 result 写 sourceStatement / sourceFrom / sourceTo / statement_index
- 单语句异常:toErrorResult() 合成 { columns:["Error"], execution_error:true, error:BackendError }
▼
DataGrid ErrorBanner(apps/desktop/src/components/grid/DataGrid.vue:11835)
└─ #error-actions slot → ContentArea.vue:2032 → QueryErrorActions.vue
1.2 关键结论
- 位置信息在驱动层就已存在:
tokio_postgres::error::DbError::position()返回ErrorPosition::Original(u32),语义是 1-based 的字符下标(不是字节,PG 文档明确规定)。 - 当前被丢弃:
postgres.rs:1482的pg_error_to_string只用Display,而DbError的Display实现不包含 position 字段(见tokio-postgres/src/error/mod.rs的impl fmt::Display for DbError)。所以前端完全拿不到。 - 中间层是 String ABI:
db::postgres到query.rs之间所有用户查询函数都返回Result<_, String>(execute_query_with_max_rows_and_cancel/execute_postgres_user_query(_with_mode)/execute_query_with_max_rows_inner等),错误在多个?/map_err(pg_error_to_string)中被反复包装、拼接(如merge_postgres_operation_and_rollback_result)。直接引入类型化错误会牵动整个postgres.rs。 - 前端已经具备映射到编辑器位置的能力:
apps/desktop/src/lib/tabs/tabPresentation.ts:244的resultSourceRange(editorSql, result, resultIndex, dbType, params)能把「某个 result 对应的语句」映射回当前编辑器文档的{from,to,sql},并且带一致性校验(editorSql.slice(from,to) === sourceStatement),stale 时返回undefined。这就是把「语句内行列」换算成「编辑器绝对 offset」的现成入口。 - 编辑器已有定位能力:
QueryEditor.vue通过defineExpose暴露focusStatementRange({from,to})(1105 行)与previewStatementRange,内部用 CodeMirror 的EditorView.scrollIntoView+ selection 实现。新增一个「定位到点」的方法即可复用同一套机制。 - UI 入口已经存在:错误横幅的 action 插槽(
ContentArea.vue:2032/:2390)已经挂了QueryErrorActions;执行摘要(executionSummaryItems+ContentArea.vue:1869)也已经有「单击预览语句 / 双击聚焦语句」的交互。这两个地方都是本次新增「定位错误」的天然落点。 - 后端契约允许加可选字段:
docs/backend-error-handling.md明确「新增可选字段可以保持 v1」,并要求错误对象由 catalog 构造、前后端不因未知可选字段丢弃 envelope。因此给BackendError增加可选errorPosition是向后兼容的。 - 契约同时要求:「不得先降级为字符串再重建 envelope」——这条针对的是错误分类。位置是驱动事实、不是分类依据,但方案会尽量把它做成类型化字段,字符串只作为跨
StringABI 的临时载体,并在第一层知道 SQL 文本的地方立刻还原为类型化字段。
1.3 需要动到的文件清单
| 层 | 文件 | 作用 |
|---|---|---|
| 驱动 | crates/dbx-core/src/db/postgres.rs |
从 DbError 提取 cursorpos |
| 新模块 | crates/dbx-core/src/sql_error_position.rs(新增) |
位置类型、行列换算、marker 编解码 |
| 查询层 | crates/dbx-core/src/query.rs |
还原位置、挂到 QueryExecutionError |
| 契约 | crates/dbx-core/src/backend_error.rs |
BackendError.errorPosition 字段与构造器 |
| 契约文档 | docs/backend-error-handling.md |
记录新可选字段 |
| 前端类型 | apps/desktop/src/lib/backend/errorUtils.ts |
TS BackendError.errorPosition + 校验 |
| 前端映射 | apps/desktop/src/lib/sql/errorPosition.ts(新增) |
语句内行列 → 编辑器 offset |
| 前端状态 | apps/desktop/src/stores/queryStore.ts |
错误结果附带位置(必要时) |
| 前端 UI | apps/desktop/src/components/common/QueryErrorActions.vue、ContentArea.vue、editor/QueryEditor.vue、grid/DataGrid.vue(如做内联标记) |
按钮 + 定位 + 行列展示 |
| i18n | apps/desktop/src/i18n/locales/*.ts |
新增文案 |
| 测试 | 各处 #[cfg(test)] / apps/desktop/src/**/__tests__ |
单测覆盖 |
2. 方案总览
PG server cursorpos (字符下标)
│ ① 提取
▼
pg_error_to_string() 追加临时 marker: "\nDBX_SQL_ERROR_POSITION:<cursor>"
│ ② 沿既有 String ABI 冒泡(不改各函数签名)
▼
query.rs::do_execute_typed() 尾部 .map_err:
PG 分支解析 marker + 用已执行 SQL 文本换算 line/column/offset
→ QueryExecutionError::SqlWithPosition { message, position }
│ ③ 类型化
▼
QueryExecutionError::into_backend_error()
→ BackendError { ..., errorPosition: { line, column, offset } }
│ ④ 随 envelope 到前端(单语句 Err / 多语句 result.error 两条路都覆盖)
▼
前端 QueryErrorActions / ExecutionSummary 显示「行 L 列 C」+「定位」按钮
│ ⑤ 点击
▼
sqlErrorEditorOffset(): resultSourceRange() 得到语句范围 → 语句内行列换算为编辑器 UTF-16 offset
▼
QueryEditor.focusErrorPosition(offset):selection + scrollIntoView + 高亮
设计取舍:
- 位置换算放在后端:后端在
do_execute_typed处同时掌握「原始 SQL 文本」和「cursorpos」,直接算出行列,前端只做「语句范围 → 文档 offset」的平移,避免前端再次处理字符/字节/码点差异。 - 跨层用 marker:
db::postgres全链路是Result<_, String>,引入类型化错误代价大且风险高。用与仓库既有DBX_AGENT_ERROR_DATA(agent_driver.rs:385)同款「结构化后缀 marker」把类型化事实无损穿过 String 边界,并在第一层知道 SQL 的query.rs立即还原并剥离。marker 是位置载体而非分类依据,符合契约边界。 - 两级安全网:
BackendError的detail清洗(bounded_detail/bounded_native_detail)里也剥离 marker,保证元数据/连接等旁路错误即使没走到 PG 分支也不会把 marker 泄漏到界面。
备选方案(更「类型化」但改动大):把
db::postgres用户查询链路(execute_query_with_max_rows_and_cancel→execute_postgres_user_query(_with_mode)→execute_query_with_max_rows_inner及其等待包装)的返回错误从String改为PostgresQueryError { message, position },并给String提供From以便内部?继续工作。优点是彻底无 marker;缺点是触及postgres.rs多处签名与等待辅助函数。若团队不接受 marker,可切换到该方案,query.rs以上的设计不变。
3. 后端改造
3.1 新模块 crates/dbx-core/src/sql_error_position.rs
use serde::{Deserialize, Serialize};
/// 跨 String 边界携带 PG cursorpos 的临时后缀。
pub const SQL_ERROR_POSITION_MARKER: &str = "\nDBX_SQL_ERROR_POSITION:";
/// 相对「实际下发的那条语句文本」的出错位置。
/// line/column 为 1-based、按 Unicode 码点计数;offset 为 0-based 码点下标。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SqlErrorPosition {
pub line: u32,
pub column: u32,
pub offset: u32,
}
impl SqlErrorPosition {
/// `cursor` 为 PostgreSQL 的 1-based 字符下标。
pub fn from_pg_cursor(sql: &str, cursor: u32) -> Option<Self> {
if cursor == 0 {
return None;
}
let total = sql.chars().count() as u32;
// 越界时钳到末尾("unexpected end of input" 这类错误常见 position == len+1)
let target = (cursor - 1).min(total.saturating_sub(1));
if total == 0 {
return None;
}
let mut line = 1u32;
let mut column = 1u32;
for (index, ch) in sql.chars().enumerate() {
if index as u32 == target {
return Some(Self { line, column, offset: target });
}
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
None
}
}
pub fn encode_marker(cursor: u32) -> String {
format!("{SQL_ERROR_POSITION_MARKER}{cursor}")
}
/// 从字符串尾部取出 marker 并剥离。返回原始 cursor。
pub fn take_marker(message: &mut String) -> Option<u32> {
let index = message.rfind(SQL_ERROR_POSITION_MARKER)?;
let cursor = message[index + SQL_ERROR_POSITION_MARKER.len()..].trim().parse::<u32>().ok()?;
message.truncate(index);
Some(cursor)
}
pub fn strip_marker(message: &str) -> String {
let mut owned = message.to_string();
let _ = take_marker(&mut owned);
owned
}
/// 把一条错误文本(可能带 marker)解析为「清理后的文本 + 已换算位置」。
pub fn resolve_message(message: &str, executed_sql: &str) -> Option<(String, SqlErrorPosition)> {
let mut owned = message.to_string();
let cursor = take_marker(&mut owned)?;
let position = SqlErrorPosition::from_pg_cursor(executed_sql, cursor)?;
Some((owned, position))
}
在
crates/dbx-core/src/lib.rs注册pub mod sql_error_position;(仓库若已有模块清单,按现有顺序追加)。
3.2 db/postgres.rs:提取 cursorpos
改 pg_error_to_string(1482 行)——这是把 tokio 错误变文本的唯一汇聚点:
fn pg_error_to_string(err: tokio_postgres::Error) -> String {
let Some(db_error) = err.as_db_error() else {
return err.to_string();
};
let mut message = db_error.to_string();
if let Some(tokio_postgres::error::ErrorPosition::Original(cursor)) = db_error.position() {
message.push_str(&crate::sql_error_position::encode_marker(*cursor));
}
message
}
注意:
ErrorPosition::Internal { .. }表示位置在服务端内部生成的语句里,不映射,保持无位置。pg_db_error_to_string(1542 行,给pg_error_from_sources/池错误用)不加 marker,因为那条路径没有用户 SQL 上下文。- marker 只对 PG 生效,其它驱动零影响。
3.3 query.rs:还原为类型化位置
- 扩展
QueryExecutionError(query.rs:69)新增变体:
pub enum QueryExecutionError {
Agent(AgentCallError),
DuckDb { code: String, message: String },
Canceled { stage: AgentErrorStage, operation_outcome: AgentOperationOutcome },
Timeout(String),
Sql(String),
/// PG 原生驱动:携带已换算好的出错行列。
SqlWithPosition { message: String, position: crate::sql_error_position::SqlErrorPosition },
Legacy(String),
}
- 补齐所有 match 分支(按现有
Self::Sql(..)处理方式):
into_legacy_string()(85 行附近):Self::SqlWithPosition { message, .. } => messageinto_backend_error()(90 行附近):Self::SqlWithPosition { message, position } => crate::backend_error::BackendError::from_sql_detail_with_position(&message, position),with_omitted_sql_context():保留 position,仅对 message 追加SQL_OMITTED_ERROR_CONTEXTwith_context():同上,仅改 messageas_agent_error():归入NoneDisplay:输出 messagequery_execution_error_action()的Sql(message) | Legacy(message)分支(1497 行)加SqlWithPosition { message, .. }classify_query_error()(2358 行)保持other => other即可透传
- 在
do_execute_typed的最终.map_err(2350~2357 行)解析 marker:
.map_err(|error| {
#[cfg(feature = "duckdb-sidecar")]
if let Some(duckdb_error) = typed_duckdb_error {
return QueryExecutionError::DuckDb { code: duckdb_error.code, message: duckdb_error.message };
}
if let Some(agent_error) = typed_agent_error {
return QueryExecutionError::Agent(agent_error);
}
if pool_db_type == Some(DatabaseType::Postgres) {
if let Some((message, position)) = crate::sql_error_position::resolve_message(&error, sql) {
return QueryExecutionError::SqlWithPosition { message, position };
}
}
QueryExecutionError::Legacy(error)
})
此处 sql: &str 就是该语句实际下发的文本,pool_db_type 已在前文计算。注意:execute_sql_statement_with_options_typed(_inner) 里 with_omitted_sql_context 之后还会走 reconnect 重试等分支,位置只在真正执行、且 SQL 未再改写的那次错误上正确;重试后再次执行会重新产生新的 marker,逻辑自洽。
error_query_result(error.clone().into_legacy_string())(query.rs:3345/3833)自动拿到清理后的文本,无需改动。
3.4 backend_error.rs:新增可选字段
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackendError {
// ...既有字段...
#[serde(skip_serializing_if = "Option::is_none")]
error_position: Option<crate::sql_error_position::SqlErrorPosition>,
}
impl BackendError {
/// 在既有 SQL 失败 envelope 上附加驱动给出的出错位置(保持 v1)。
pub fn from_sql_detail_with_position(
message: &str,
position: crate::sql_error_position::SqlErrorPosition,
) -> Self {
let mut error = Self::from_sql_detail(message);
error.error_position = Some(position);
error
}
pub fn error_position(&self) -> Option<crate::sql_error_position::SqlErrorPosition> {
self.error_position
}
fn new(/* 既有参数 */) -> Self {
Self { /* ... */ error_position: None }
}
}
安全网(防止 marker 从旁路泄漏到 detail):
fn bounded_detail(message: &str) -> Option<String> {
let message = crate::sql_error_position::strip_marker(message);
// ...对 message 走原有逻辑
}
fn bounded_native_detail(message: &str) -> Option<String> {
let message = crate::sql_error_position::strip_marker(message);
// ...原有逻辑
}
(注意现有函数签名与所有权,改成接收 &str 后用局部 String 即可。)
3.5 契约文档
在 docs/backend-error-handling.md 的「公共错误对象」示例与「协议演进」段落补充:新增可选字段 errorPosition: { line, column, offset },仅 PG 原生驱动 SQL 错误存在,为 v1 向后兼容可选字段;offset 为语句内 0-based 码点下标,line/column 为 1-based 码点计数。
4. 前端改造
4.1 类型与校验(lib/backend/errorUtils.ts)
export interface SqlErrorPosition {
line: number;
column: number;
offset: number;
}
export interface BackendError {
// ...既有字段...
/** PostgreSQL 原生驱动给出的出错位置(相对该语句文本)。 */
errorPosition?: SqlErrorPosition;
}
在 isBackendError 中对 errorPosition 做形状校验(三个字段均为有限非负整数,line/column >= 1),不合法时忽略整个字段但不丢弃 envelope(与既有「未知可选字段」策略一致)。
4.2 行列 → 编辑器 offset(新增 lib/sql/errorPosition.ts)
import { resultSourceRange } from "@/lib/tabs/tabPresentation";
import type { QueryResult, DatabaseType } from "@/types/database";
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
export interface EditorErrorPosition {
offset: number;
line: number;
column: number;
}
/**
* 把 result.error.errorPosition(语句内行列)换算为当前编辑器文档的 UTF-16 offset。
* 只有在 resultSourceRange 能证明该 result 仍对应编辑器里同一段语句时才返回。
*/
export function sqlErrorEditorOffset(options: {
editorSql: string;
result: QueryResult | undefined;
resultIndex?: number;
databaseType?: DatabaseType;
parameterOptions?: SqlParameterOptions;
}): EditorErrorPosition | undefined {
const position = options.result?.error?.errorPosition;
if (!position) return undefined;
const range = resultSourceRange(
options.editorSql,
options.result,
options.resultIndex,
options.databaseType,
options.parameterOptions,
);
if (!range) return undefined;
// 目标:range.sql 内第 position.line 行、第 position.column 列(均 1-based,按码点)
const chars = Array.from(range.sql);
let line = 1;
let lineStart = 0;
let index = 0;
while (index < chars.length && line < position.line) {
if (chars[index] === "\n") {
line += 1;
lineStart = index + 1;
}
index += 1;
}
if (line !== position.line) return undefined; // 行号超出语句
// 该行内前进 column-1 个码点,遇到行尾或换行则钳制
let columnOffset = 0;
while (columnOffset < position.column - 1 && lineStart + columnOffset < chars.length) {
if (chars[lineStart + columnOffset] === "\n") break;
columnOffset += 1;
}
const charIndex = lineStart + columnOffset;
// 码点下标 → range.sql 内 UTF-16 下标
const utf16Offset = chars.slice(0, charIndex).join("").length;
return { offset: range.from + utf16Offset, line: position.line, column: position.column };
}
优先用
line/column而非offset,因为二者都是「码点计数」,语义一致;offset作为兜底/测试信号。
4.3 编辑器定位能力(components/editor/QueryEditor.vue)
在 focusStatementRange(1105 行)旁新增并加入 defineExpose(7542 行):
function focusErrorPosition(offset: number) {
const currentView = view.value;
if (!currentView || !editorViewModule) return;
const pos = Math.max(0, Math.min(offset, currentView.state.doc.length));
currentView.dispatch({
selection: { anchor: pos },
effects: [editorViewModule.EditorView.scrollIntoView(pos, { y: "center" })],
});
currentView.focus();
}
可选增强(二选一或都做):
- 用一次性 decoration 在
pos处画一个短暂的错误下划线/闪烁光标(复用已有cm-sql-error样式与Decoration机制,5897 行附近)。 - 在
pos处选中一个「词」(向两侧扫描标识符边界),让出错 token 更醒目。
4.4 UI 入口
A. 错误横幅(主入口,符合「点击错误信息定位」)
QueryErrorActions.vue 增加 props/emit 与按钮:
const props = defineProps<{ errorMessage: string; backendError?: BackendError; connectionId?: string; errorPosition?: { line: number; column: number } }>();
const emit = defineEmits<{
/* 既有 */
locateError: [];
}>();
const showLocate = computed(() => !!props.errorPosition);
<Button v-if="showLocate" variant="outline" size="sm" class="h-7 gap-1.5 px-2.5 text-xs" @click="emit('locateError')">
<LocateFixed class="h-3.5 w-3.5" />
{{ t("editor.locateError", { line: errorPosition!.line, column: errorPosition!.column }) }}
</Button>
ContentArea.vue 两处 #error-actions(2032 / 2390 行)传入 :error-position="activeTab.result?.error?.errorPosition" 并绑定 @locate-error="locateActiveResultError":
function locateActiveResultError() {
const result = props.activeTab.result;
const mapped = sqlErrorEditorOffset({
editorSql: props.activeTab.sql,
result,
resultIndex: props.activeTab.activeResultIndex,
databaseType: activeEffectiveDatabaseType.value,
parameterOptions: activeSqlStatementParameterOptions.value,
});
if (!mapped) {
toast(t("editor.errorPositionUnavailable"), 3000);
return;
}
if (queryEditorRef.value) queryEditorRef.value.focusErrorPosition(mapped.offset);
else emit("focusErrorPosition", props.activeTab.id, mapped.offset); // 多窗口/分离 tab 兼容
}
B. 执行摘要(多语句场景体验更好)
executionSummaryItems(tabPresentation.ts:419)的ExecutionSummaryItem增加errorPosition?,多语句分支直接从result.error?.errorPosition取。ContentArea.vue摘要行的错误文本后追加「行 L 列 C」小标签;focusExecutionSummaryItem改为优先使用sqlErrorEditorOffset(有位置就精确定位,否则退回整条语句focusStatementRange)。
C. DataGrid 内联提示(可选)
如需「点击错误信息本体」而非按钮:在 DataGrid.vue:11835 的 ErrorBanner 上对 message 绑定点击,emit 一个新事件透传到 ContentArea 的同一处理函数。建议保留按钮作为主要交互,避免与文本选择冲突。
4.5 i18n
在 i18n/locales/en.ts 与 zh-CN.ts(其余 locale 走 fallback)新增:
editor: {
locateError: "Locate error (line {line}, col {column})", // zh: 定位错误(第 {line} 行,第 {column} 列)
errorPositionUnavailable: "The error position no longer matches the editor content", // zh: 错误位置与当前编辑器内容不一致,无法定位
}
executionSummary: {
lineColumn: "line {line}, col {column}", // zh: 第 {line} 行,第 {column} 列
}
5. 分阶段任务清单
Phase 1 — 后端位置提取与透传(不影响 UI)
- 新增
crates/dbx-core/src/sql_error_position.rs并在lib.rs注册;写from_pg_cursor/ marker 的单测(多行、\r\n、含 emoji、越界钳制)。 postgres.rs::pg_error_to_string追加 marker(仅ErrorPosition::Original)。backend_error.rs:加error_position字段、from_sql_detail_with_position、error_position(),new()补默认;在bounded_detail/bounded_native_detail内strip_marker。query.rs:新增QueryExecutionError::SqlWithPosition,补齐所有 match 分支,在do_execute_typed尾部 PG 分支解析。- Rust 测试:序列化
errorPosition(camelCase);into_backend_error()产出正确 position;into_legacy_string()不含 marker;SqlWithPosition不被classify_query_error降级。
Phase 2 — 前端类型与映射
errorUtils.ts增加SqlErrorPosition与errorPosition?,扩展isBackendError校验。- 新增
lib/sql/errorPosition.ts+ vitest:用带sourceFrom/sourceTo/sourceStatement的假 result 验证多行/非 BMP 字符/行超界/stale 返回undefined。 - 同步
lib/backend/http.ts、lib/backend/tauri.ts的错误规范化路径(复用normalizeBackendError应无需改,确认即可)。
Phase 3 — UI 与编辑器定位
QueryErrorActions.vue增加errorPositionprop、locateErroremit 与按钮;__tests__/QueryErrorActions.spec.ts补用例。QueryEditor.vue增加focusErrorPosition(offset)并defineExpose;ContentArea.vue接线 + toast 兜底。tabPresentation.ts的ExecutionSummaryItem增加errorPosition;ExecutionSummary行显示行列并支持精确定位。- i18n 文案(en / zh-CN)。
- 手工验证:
SELECT * FROM no_such_table;(表不存在)、SELECT FROMM t;(语法错误)、多语句中第 2 条报错、光标定位与高亮。
Phase 4 — 文档与收尾
- 更新
docs/backend-error-handling.md。 - 跑提交前检查(见第 7 节)。
6. 边界、风险与对策
| 风险 | 说明 | 对策 |
|---|---|---|
| SQL 被前端改写 | 分页包装、排序、只读事务、schema 限定等会在下发前改写 SQL,PG 的 cursorpos 相对改写后的文本,映射到用户原文会偏移 | 位置只在「result 仍能通过 resultSourceRange 证明对应当前编辑器同一段语句」时启用;仍可能因包装漂移时,回退为仅展示行列、禁用定位(Phase 3 的 undefined 分支)。后续可在前端记录 sqlToExecute === queryBaseSql 标志,仅在该成立时启用 |
| 前后端语句切分差异 | 后端 split_sql_statements 与前端 splitSqlStatementRanges 对前导注释/空白/分号处理可能不同 |
加对照测试(同一批脚本);前端已有 submittedStatement.sql === sourceStatement 校验,边界不符时不定位 |
| PG cursorpos 语义 | 1-based、按字符(码点)而非字节 | 全部换算按 chars()/Array.from 码点进行;单测覆盖多字节与 emoji |
| agent/JDBC PG 无位置 | JDBC 路径错误来自 AgentCallError,结构化上下文里没有 position |
明确 Phase 1 只覆盖 tokio-postgres 原生驱动;JDBC 需在 Agent Protocol 扩展字段,另开阶段 |
| 字符串 marker 泄漏到 UI | 旁路错误(元数据/连接)可能带 marker | bounded_detail/bounded_native_detail 统一剥离 + into_legacy_string 返回已清理文本;补「不含 marker」单测 |
| 契约兼容 | BackendError 新增字段 |
可选字段保持 v1;前端 isBackendError 对未知/非法可选字段宽容处理 |
| 大小写/换行 | \r\n 的行列计算 |
from_pg_cursor 只以 \n 分行,\r 计入列;前端按同一规则处理,保持一致 |
7. 测试与验收
Rust 单测
cargo test -j 1 -p dbx-core --no-default-features --lib sql_error_position::tests
cargo test -j 1 -p dbx-core --no-default-features --lib backend_error::tests
cargo test -j 1 -p dbx-core --no-default-features --lib query::tests # QueryExecutionError 分支
前端
pnpm vitest run apps/desktop/src/lib/__tests__/sql/errorPosition.spec.ts
pnpm vitest run apps/desktop/src/components/common/__tests__/QueryErrorActions.spec.ts
pnpm vitest run apps/desktop/src/i18n/__tests__/backendErrors.spec.ts
pnpm typecheck
手工验收(PG 连接)
SELECT * FROM no_such_table;→ 错误横幅出现「定位错误(第 1 行,第 15 列)」→ 点击后光标落在no_such_table起首。- 多行脚本第 3 条报错 → 执行摘要该行显示行列,点击精确跳转;错误横幅定位到同一条语句。
- 执行后手动改动编辑器使语句不再匹配 → 点击定位给出「位置不可用」提示,不误跳。
- 非 PG 连接(MySQL/SQLite)错误 → 无定位入口,行为不变。
完成标准
- PG 原生驱动的 SQL 错误(语法错误、对象不存在等)在单语句与多语句两条路径都能拿到
errorPosition并正确定位。 - 其它驱动与既有错误展示零回归(现有
BackendError/translateBackendError测试全绿)。 - 契约文档更新;
docs/backend-error-handling.md的提交前检查全过。
8. 工作量估算
| 阶段 | 内容 | 估算 |
|---|---|---|
| Phase 1 | 后端位置提取 + 类型化 + 透传 + 单测 | 0.5–1 天 |
| Phase 2 | 前端类型 + 映射函数 + 单测 | 0.5 天 |
| Phase 3 | UI 按钮 + 编辑器定位 + 摘要 + i18n + 手工验证 | 1 天 |
| Phase 4 | 文档、回归、提测 | 0.5 天 |
| 合计 | 约 2.5–3 天 |
9. 一句话总结
PG 的出错行列一直存在于 DbError::position(),只是被 pg_error_to_string 丢弃;本方案在驱动层提取 cursorpos,用轻量 marker 穿过既有 String 错误 ABI,在 query.rs 还原为类型化的 BackendError.errorPosition,前端借助已有的 resultSourceRange 把「语句内行列」换算成编辑器 offset,并在错误横幅/执行摘要上提供一键定位。整体对现有架构侵入小、对非 PG 驱动零影响。
10. 实现补充:下发语句漂移的处理(已落地)
「位置相对实际下发语句」在本方案实现后暴露出一个高频问题:DBX 常在下发前改写语句(追加 LIMIT/OFFSET、用 SELECT * FROM (…) 包裹分页、为可编辑查询注入隐藏主键列),导致后端位置与用户原文对不上,定位会失败或偏移。已在前端增加一层投影,无需改动后端协议:
annotateQueryResultSources额外接收本次实际下发的 SQL(sqlToExecute),当某个 result 的语句文本与sourceStatement不同时,把实际下发的语句文本记录到QueryResult.executedStatement(仅在前端内部使用)。sqlErrorEditorOffset先把后端line/column解析到executedStatement(位置本就相对它),再用mapExecutedOffsetToSource投影回sourceStatement:- 直接子串匹配 → 精确处理「追加子句」与「子查询包裹」;
- 前缀对齐 + 剩余文本重定位 → 处理「在投影中注入隐藏列」;
- 最后退回「首个/末个差异之间的单一变更区间」对齐。
- 行/列越界时钳制而非报「不可用」,因此只要还存在该语句的源码范围,点击定位总能落到句式内的合理位置;只有编辑器内容与结果语句确实不一致(stale)时才提示无法定位。
新增单测覆盖:追加 LIMIT、分页包裹、注入隐藏列、多行语句、软字符(emoji)列宽换算与越界钳制。