Files
dbx/docs/pg-error-position-locate-plan.md

30 KiB
Raw Permalink Blame History

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 关键结论

  1. 位置信息在驱动层就已存在:tokio_postgres::error::DbError::position() 返回 ErrorPosition::Original(u32),语义是 1-based 的字符下标(不是字节,PG 文档明确规定)。
  2. 当前被丢弃:postgres.rs:1482 的 pg_error_to_string 只用 Display,而 DbError 的 Display 实现不包含 position 字段(见 tokio-postgres/src/error/mod.rs 的 impl fmt::Display for DbError)。所以前端完全拿不到。
  3. 中间层是 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。
  4. 前端已经具备映射到编辑器位置的能力: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」的现成入口。
  5. 编辑器已有定位能力:QueryEditor.vue 通过 defineExpose 暴露 focusStatementRange({from,to})(1105 行)与 previewStatementRange,内部用 CodeMirror 的 EditorView.scrollIntoView + selection 实现。新增一个「定位到点」的方法即可复用同一套机制。
  6. UI 入口已经存在:错误横幅的 action 插槽(ContentArea.vue:2032 / :2390)已经挂了 QueryErrorActions;执行摘要(executionSummaryItems + ContentArea.vue:1869)也已经有「单击预览语句 / 双击聚焦语句」的交互。这两个地方都是本次新增「定位错误」的天然落点。
  7. 后端契约允许加可选字段:docs/backend-error-handling.md 明确「新增可选字段可以保持 v1」,并要求错误对象由 catalog 构造、前后端不因未知可选字段丢弃 envelope。因此给 BackendError 增加可选 errorPosition 是向后兼容的。
  8. 契约同时要求:「不得先降级为字符串再重建 envelope」——这条针对的是错误分类。位置是驱动事实、不是分类依据,但方案会尽量把它做成类型化字段,字符串只作为跨 String ABI 的临时载体,并在第一层知道 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:还原为类型化位置

  1. 扩展 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),
}
  1. 补齐所有 match 分支(按现有 Self::Sql(..) 处理方式):
  • into_legacy_string()(85 行附近):Self::SqlWithPosition { message, .. } => message
  • into_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_CONTEXT
  • with_context():同上,仅改 message
  • as_agent_error():归入 None
  • Display:输出 message
  • query_execution_error_action() 的 Sql(message) | Legacy(message) 分支(1497 行)加 SqlWithPosition { message, .. }
  • classify_query_error()(2358 行)保持 other => other 即可透传
  1. 在 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,逻辑自洽。

  1. 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 增加 errorPosition prop、locateError emit 与按钮;__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 连接)

  1. SELECT * FROM no_such_table; → 错误横幅出现「定位错误(第 1 行,第 15 列)」→ 点击后光标落在 no_such_table 起首。
  2. 多行脚本第 3 条报错 → 执行摘要该行显示行列,点击精确跳转;错误横幅定位到同一条语句。
  3. 执行后手动改动编辑器使语句不再匹配 → 点击定位给出「位置不可用」提示,不误跳。
  4. 非 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 (…) 包裹分页、为可编辑查询注入隐藏主键列),导致后端位置与用户原文对不上,定位会失败或偏移。已在前端增加一层投影,无需改动后端协议:

  1. annotateQueryResultSources 额外接收本次实际下发的 SQL(sqlToExecute),当某个 result 的语句文本与 sourceStatement 不同时,把实际下发的语句文本记录到 QueryResult.executedStatement(仅在前端内部使用)。
  2. sqlErrorEditorOffset 先把后端 line/column 解析到 executedStatement(位置本就相对它),再用 mapExecutedOffsetToSource 投影回 sourceStatement:
    • 直接子串匹配 → 精确处理「追加子句」与「子查询包裹」;
    • 前缀对齐 + 剩余文本重定位 → 处理「在投影中注入隐藏列」;
    • 最后退回「首个/末个差异之间的单一变更区间」对齐。
  3. 行/列越界时钳制而非报「不可用」,因此只要还存在该语句的源码范围,点击定位总能落到句式内的合理位置;只有编辑器内容与结果语句确实不一致(stale)时才提示无法定位。

新增单测覆盖:追加 LIMIT、分页包裹、注入隐藏列、多行语句、软字符(emoji)列宽换算与越界钳制。