feat(query): jump to PostgreSQL error position from result messages
This commit is contained in:
1 parent
571fc07d85
commit
5a0e74eec6
36 files changed
+2079
-23
No files matched your search
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { BackendError, SqlErrorPosition } from "@/lib/backend/errorUtils";
|
||||
import { mapExecutedOffsetToSource, scalarPositionToUtf16Offset, sqlErrorEditorOffset } from "@/lib/sql/errorPosition";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function backendError(position?: SqlErrorPosition): BackendError {
|
||||
return {
|
||||
version: 1,
|
||||
code: "DBX-JDBC-4001",
|
||||
messageKey: "backendErrors.jdbc.sqlFailed",
|
||||
messageParams: { stage: "execute" },
|
||||
source: "jdbcAgent",
|
||||
operationOutcome: "unknown",
|
||||
detail: "ERROR: relation does not exist",
|
||||
...(position ? { errorPosition: position } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function errorResult(options: { editorStatement?: string; sourceFrom?: number; sourceTo?: number; executedStatement?: string; position?: SqlErrorPosition }): QueryResult {
|
||||
const statement = options.editorStatement ?? "";
|
||||
return {
|
||||
columns: ["Error"],
|
||||
rows: [["ERROR: relation does not exist"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 0,
|
||||
execution_error: true,
|
||||
error: backendError(options.position),
|
||||
sourceStatement: statement,
|
||||
sourceFrom: options.sourceFrom ?? 0,
|
||||
sourceTo: options.sourceTo ?? statement.length,
|
||||
...(options.executedStatement ? { executedStatement: options.executedStatement } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("sqlErrorEditorOffset", () => {
|
||||
it("maps a first-line column to an absolute editor offset", () => {
|
||||
const sql = "SELECT * FROM no_such_table";
|
||||
const result = errorResult({
|
||||
editorStatement: sql,
|
||||
position: { line: 1, column: 15, offset: 14 },
|
||||
});
|
||||
|
||||
expect(sqlErrorEditorOffset({ editorSql: sql, result })).toEqual({ offset: 14, line: 1, column: 15 });
|
||||
});
|
||||
|
||||
it("maps a later-line column", () => {
|
||||
const statement = "SELECT *\nFROM missing";
|
||||
const result = errorResult({
|
||||
editorStatement: statement,
|
||||
position: { line: 2, column: 6, offset: 14 },
|
||||
});
|
||||
|
||||
expect(sqlErrorEditorOffset({ editorSql: statement, result })).toEqual({ offset: 14, line: 2, column: 6 });
|
||||
});
|
||||
|
||||
it("adds the statement's absolute start offset in a multi-statement editor", () => {
|
||||
const statement = "SELECT *\nFROM missing";
|
||||
const editorSql = "SELECT 1;\n" + statement;
|
||||
const start = editorSql.indexOf(statement);
|
||||
const result = errorResult({
|
||||
editorStatement: statement,
|
||||
sourceFrom: start,
|
||||
sourceTo: start + statement.length,
|
||||
position: { line: 2, column: 6, offset: 14 },
|
||||
});
|
||||
|
||||
expect(sqlErrorEditorOffset({ editorSql, result })).toEqual({ offset: start + 14, line: 2, column: 6 });
|
||||
});
|
||||
|
||||
it("converts scalar-value columns to UTF-16 offsets for astral characters", () => {
|
||||
const statement = "SELECT '😀' FROM t";
|
||||
// Code-point column of FROM: S E L E C T space ' 😀 ' space = 11 chars, so FROM is column 12.
|
||||
const result = errorResult({
|
||||
editorStatement: statement,
|
||||
position: { line: 1, column: 12, offset: 11 },
|
||||
});
|
||||
|
||||
// UTF-16 offset: the emoji occupies two code units, so 'FROM' starts at 12.
|
||||
expect(sqlErrorEditorOffset({ editorSql: statement, result })).toEqual({ offset: 12, line: 1, column: 12 });
|
||||
});
|
||||
|
||||
it("returns undefined when the result carries no position", () => {
|
||||
const sql = "SELECT 1";
|
||||
const result = errorResult({ editorStatement: sql });
|
||||
|
||||
expect(sqlErrorEditorOffset({ editorSql: sql, result })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the statement no longer matches the editor", () => {
|
||||
const result = errorResult({
|
||||
editorStatement: "SELECT * FROM gone",
|
||||
position: { line: 1, column: 15, offset: 14 },
|
||||
});
|
||||
|
||||
expect(sqlErrorEditorOffset({ editorSql: "SELECT 1", result })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clamps instead of failing when the position line is beyond the statement", () => {
|
||||
const sql = "SELECT 1";
|
||||
const result = errorResult({
|
||||
editorStatement: sql,
|
||||
position: { line: 5, column: 1, offset: 4 },
|
||||
});
|
||||
|
||||
// The position is relative to the statement, so a bad line clamps to its end.
|
||||
expect(sqlErrorEditorOffset({ editorSql: sql, result })?.offset).toBe(sql.length);
|
||||
});
|
||||
|
||||
describe("when the executed statement differs from the source", () => {
|
||||
it("projects an appended LIMIT/OFFSET position back onto the source", () => {
|
||||
const source = "SELECT * FROM t";
|
||||
const executed = "SELECT * FROM t LIMIT 100 OFFSET 0;";
|
||||
const tableOffset = executed.indexOf("FROM t") + "FROM ".length;
|
||||
const result = errorResult({
|
||||
editorStatement: source,
|
||||
executedStatement: executed,
|
||||
position: { line: 1, column: tableOffset + 1, offset: tableOffset },
|
||||
});
|
||||
|
||||
expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("FROM t") + "FROM ".length);
|
||||
});
|
||||
|
||||
it("projects a pagination wrapper position back onto the inner statement", () => {
|
||||
const source = "SELECT * FROM t";
|
||||
const executed = "SELECT * FROM (SELECT * FROM t) AS dbx_page LIMIT 100 OFFSET 0;";
|
||||
const tableOffset = executed.indexOf("FROM t") + "FROM ".length;
|
||||
const result = errorResult({
|
||||
editorStatement: source,
|
||||
executedStatement: executed,
|
||||
position: { line: 1, column: tableOffset + 1, offset: tableOffset },
|
||||
});
|
||||
|
||||
expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("FROM t") + "FROM ".length);
|
||||
});
|
||||
|
||||
it("projects an injected hidden-key column position back onto the source", () => {
|
||||
const source = "SELECT * FROM users";
|
||||
const executed = 'SELECT *, "id" AS "__dbx_hidden_pk" FROM users LIMIT 100 OFFSET 0;';
|
||||
const tableOffset = executed.indexOf("FROM users") + "FROM ".length;
|
||||
const result = errorResult({
|
||||
editorStatement: source,
|
||||
executedStatement: executed,
|
||||
position: { line: 1, column: tableOffset + 1, offset: tableOffset },
|
||||
});
|
||||
|
||||
expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("users"));
|
||||
});
|
||||
|
||||
it("keeps a multi-line position aligned when the wrapper is on the first line only", () => {
|
||||
const source = "SELECT *\nFROM users\nWHERE id = 1";
|
||||
const executed = `SELECT * FROM (${source}) AS dbx_page LIMIT 100 OFFSET 0;`;
|
||||
const executedMissing = executed.indexOf("users");
|
||||
const result = errorResult({
|
||||
editorStatement: source,
|
||||
executedStatement: executed,
|
||||
// `users` is on line 2 of both texts.
|
||||
position: { line: 2, column: 6, offset: executedMissing + 5 },
|
||||
});
|
||||
|
||||
expect(sqlErrorEditorOffset({ editorSql: source, result })?.offset).toBe(source.indexOf("users"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scalarPositionToUtf16Offset", () => {
|
||||
it("converts scalar line/column to a UTF-16 offset", () => {
|
||||
expect(scalarPositionToUtf16Offset("SELECT '😀' FROM t", 1, 12)).toBe(12);
|
||||
});
|
||||
|
||||
it("clamps a column past the end of its line", () => {
|
||||
expect(scalarPositionToUtf16Offset("SELECT 1\nFROM t", 1, 999)).toBe("SELECT 1".length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapExecutedOffsetToSource", () => {
|
||||
it("returns the offset unchanged when both texts are identical", () => {
|
||||
expect(mapExecutedOffsetToSource("SELECT 1", "SELECT 1", 4)).toBe(4);
|
||||
});
|
||||
|
||||
it("maps an append-only rewrite through the shared prefix", () => {
|
||||
expect(mapExecutedOffsetToSource("SELECT 1 LIMIT 10", "SELECT 1", 4)).toBe(4);
|
||||
});
|
||||
|
||||
it("clamps offsets in the appended region to the source end", () => {
|
||||
expect(mapExecutedOffsetToSource("SELECT 1 LIMIT 10", "SELECT 1", 15)).toBe("SELECT 1".length);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,19 @@
|
||||
|
||||
export type BackendErrorParam = string | number | boolean;
|
||||
|
||||
/**
|
||||
* Driver-reported SQL error position, relative to the statement text that was
|
||||
* actually sent to the database. `line`/`column` are 1-based and counted in
|
||||
* Unicode scalar values; `offset` is the 0-based scalar-value index.
|
||||
*
|
||||
* Currently only populated by the native PostgreSQL driver.
|
||||
*/
|
||||
export interface SqlErrorPosition {
|
||||
line: number;
|
||||
column: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface BackendError {
|
||||
version: 1;
|
||||
code: string;
|
||||
@@ -20,6 +33,7 @@ export interface BackendError {
|
||||
detail?: string;
|
||||
diagnostics?: Record<string, unknown>;
|
||||
helpUrl?: string;
|
||||
errorPosition?: SqlErrorPosition;
|
||||
}
|
||||
|
||||
export const MANUAL_TRANSACTION_SESSION_EXPIRED_CODE = "DBX-TXN-1001";
|
||||
@@ -27,20 +41,25 @@ export const MANUAL_TRANSACTION_SESSION_EXPIRED_CODE = "DBX-TXN-1001";
|
||||
const MAX_FALLBACK_CHARS = 64 * 1024;
|
||||
const MAX_ERROR_PARSE_DEPTH = 16;
|
||||
const AGENT_RPC_ERROR_DATA_MARKER = "\nDBX_AGENT_ERROR_DATA:";
|
||||
// Rust-side transport suffix carrying a driver cursor position. It is stripped
|
||||
// before a structured envelope is built, but metadata/catalog errors can surface
|
||||
// as raw strings, so strip it here so it never reaches the UI.
|
||||
const SQL_ERROR_POSITION_MARKER_PATTERN = /\nDBX_SQL_ERROR_POSITION:\d+/g;
|
||||
|
||||
export function sanitizeBackendErrorMessage(message: string): string {
|
||||
const markerIndex = message.lastIndexOf(AGENT_RPC_ERROR_DATA_MARKER);
|
||||
if (markerIndex < 0) return message;
|
||||
const withoutPositionMarker = message.replace(SQL_ERROR_POSITION_MARKER_PATTERN, "");
|
||||
const markerIndex = withoutPositionMarker.lastIndexOf(AGENT_RPC_ERROR_DATA_MARKER);
|
||||
if (markerIndex < 0) return withoutPositionMarker;
|
||||
|
||||
const rawData = message.slice(markerIndex + AGENT_RPC_ERROR_DATA_MARKER.length).trim();
|
||||
const rawData = withoutPositionMarker.slice(markerIndex + AGENT_RPC_ERROR_DATA_MARKER.length).trim();
|
||||
try {
|
||||
const data: unknown = JSON.parse(rawData);
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return message;
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return withoutPositionMarker;
|
||||
} catch {
|
||||
return message;
|
||||
return withoutPositionMarker;
|
||||
}
|
||||
|
||||
return message.slice(0, markerIndex).trimEnd();
|
||||
return withoutPositionMarker.slice(0, markerIndex).trimEnd();
|
||||
}
|
||||
|
||||
function isBackendError(value: unknown): value is BackendError {
|
||||
@@ -79,9 +98,29 @@ function isBackendError(value: unknown): value is BackendError {
|
||||
}
|
||||
}
|
||||
if (candidate.detail !== undefined && typeof candidate.detail !== "string") return false;
|
||||
// Optional driver-reported position. Malformed values are rejected so a
|
||||
// corrupted envelope never drives a wrong editor jump, while a missing field
|
||||
// stays valid (all non-PostgreSQL errors and older backends).
|
||||
if (candidate.errorPosition !== undefined) {
|
||||
if (!isValidErrorPosition(candidate.errorPosition)) return false;
|
||||
}
|
||||
return Object.values(candidate.messageParams).every((param) => typeof param === "string" || typeof param === "boolean" || (typeof param === "number" && Number.isFinite(param)));
|
||||
}
|
||||
|
||||
function isValidErrorPosition(value: unknown): value is SqlErrorPosition {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const { line, column, offset } = value as Record<string, unknown>;
|
||||
return isPositiveInt(line) && isPositiveInt(column) && isNonNegativeInt(offset);
|
||||
}
|
||||
|
||||
function isPositiveInt(value: unknown): boolean {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 1;
|
||||
}
|
||||
|
||||
function isNonNegativeInt(value: unknown): boolean {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
export function normalizeBackendError(error: unknown): BackendError | null {
|
||||
return normalizeBackendErrorAtDepth(error, new WeakSet<object>(), 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
|
||||
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
|
||||
import { resultSourceRange } from "@/lib/tabs/tabPresentation";
|
||||
|
||||
export interface EditorErrorPosition {
|
||||
/** Absolute UTF-16 offset into the current editor document. */
|
||||
offset: number;
|
||||
/** 1-based line within the statement, for display. */
|
||||
line: number;
|
||||
/** 1-based column within the statement, for display. */
|
||||
column: number;
|
||||
}
|
||||
|
||||
export interface SqlErrorOffsetOptions {
|
||||
editorSql: string;
|
||||
result: QueryResult | undefined | null;
|
||||
resultIndex?: number;
|
||||
databaseType?: DatabaseType;
|
||||
parameterOptions?: SqlParameterOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostics for the "locate SQL error" flow.
|
||||
*
|
||||
* Failures are always logged (they explain the "position unavailable" toast).
|
||||
* Successful resolutions are verbose-only: run
|
||||
* `localStorage.setItem("dbx:debug:sql-error-position", "1")` once (then reload)
|
||||
* to also inspect the drift/offset details.
|
||||
*/
|
||||
const LOG_TAG = "[DBX][sql-error-position]";
|
||||
const DEBUG_FLAG_KEY = "dbx:debug:sql-error-position";
|
||||
|
||||
export function isSqlErrorPositionDebugEnabled(): boolean {
|
||||
try {
|
||||
return globalThis.localStorage?.getItem(DEBUG_FLAG_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Emit a diagnostics record. Failures are always logged; success details are verbose-only. */
|
||||
export function logSqlErrorPosition(stage: string, payload: Record<string, unknown>): void {
|
||||
console.info(`${LOG_TAG} ${stage}`, payload);
|
||||
}
|
||||
|
||||
function previewText(value: string | undefined | null, max = 2000): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return value.length > max ? `${value.slice(0, max)}…(len=${value.length})` : value;
|
||||
}
|
||||
|
||||
function logDiagnostics(stage: string, options: SqlErrorOffsetOptions, extra: Record<string, unknown>): void {
|
||||
const result = options.result ?? undefined;
|
||||
const details: Record<string, unknown> = {
|
||||
stage,
|
||||
resultIndex: options.resultIndex,
|
||||
editorLength: options.editorSql.length,
|
||||
result: result
|
||||
? {
|
||||
statementIndex: result.statement_index,
|
||||
executionError: result.execution_error,
|
||||
hasError: Boolean(result.error),
|
||||
errorPosition: result.error?.errorPosition ?? null,
|
||||
sourceStatement: previewText(result.sourceStatement),
|
||||
executedStatement: previewText(result.executedStatement),
|
||||
sourceFrom: result.sourceFrom,
|
||||
sourceTo: result.sourceTo,
|
||||
}
|
||||
: null,
|
||||
editorSliceAtSourceRange: typeof result?.sourceFrom === "number" && typeof result?.sourceTo === "number" ? previewText(options.editorSql.slice(result.sourceFrom, result.sourceTo)) : undefined,
|
||||
editorContainsSourceStatement: result?.sourceStatement ? options.editorSql.includes(result.sourceStatement) : undefined,
|
||||
...extra,
|
||||
};
|
||||
if (options.editorSql.trim()) {
|
||||
try {
|
||||
details.editorStatements = splitSqlStatementRanges(options.editorSql, options.databaseType, options.parameterOptions).map((statement, index) => ({
|
||||
index,
|
||||
from: statement.from,
|
||||
to: statement.to,
|
||||
sql: previewText(statement.sql, 200),
|
||||
}));
|
||||
} catch (error) {
|
||||
details.editorStatementSplitError = String(error);
|
||||
}
|
||||
}
|
||||
logSqlErrorPosition(stage, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a backend-reported SQL error position into an absolute offset in the
|
||||
* current editor document.
|
||||
*
|
||||
* The backend reports the position against the statement text it actually sent.
|
||||
* DBX frequently rewrites the user's statement before execution (pagination
|
||||
* wrappers, appended LIMIT/OFFSET, injected hidden key columns), so the position
|
||||
* is first resolved inside `executedStatement` and then projected back onto the
|
||||
* user's `sourceStatement` before it is placed in the editor.
|
||||
*
|
||||
* Returns `undefined` only when the result carries no position, or when
|
||||
* {@link resultSourceRange} cannot prove the result still maps to the same
|
||||
* statement text in the editor (stale editor / different statement) — jumping
|
||||
* anywhere in that case would be wrong.
|
||||
*/
|
||||
export function sqlErrorEditorOffset(options: SqlErrorOffsetOptions): EditorErrorPosition | undefined {
|
||||
const position = options.result?.error?.errorPosition;
|
||||
if (!position) {
|
||||
if (isSqlErrorPositionDebugEnabled()) logDiagnostics("skip:no-error-position", options, {});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const range = resultSourceRange(options.editorSql, options.result ?? undefined, options.resultIndex, options.databaseType, options.parameterOptions);
|
||||
if (!range) {
|
||||
const reason = "resultSourceRange returned undefined: the result's statement text is not found (or no longer unique) in the editor";
|
||||
if (isSqlErrorPositionDebugEnabled()) {
|
||||
logDiagnostics("unresolved:result-source-range", options, { reason });
|
||||
} else {
|
||||
// Redacted failure record: SQL previews (user data) go to the debug-gated diagnostics only.
|
||||
logSqlErrorPosition("unresolved:result-source-range", { reason, resultIndex: options.resultIndex, statementIndex: options.result?.statement_index ?? null });
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// The position is relative to the executed statement when we recorded one;
|
||||
// older/other results only have the source statement to fall back on.
|
||||
const executedStatement = options.result?.executedStatement;
|
||||
const positionBasis = executedStatement ?? range.sql;
|
||||
|
||||
// Walk the line/column in the text the position is relative to (scalar values,
|
||||
// matching PostgreSQL's character-based cursor), then convert to UTF-16.
|
||||
const basisOffset = scalarPositionToUtf16Offset(positionBasis, position.line, position.column);
|
||||
const drifted = Boolean(executedStatement && executedStatement !== range.sql);
|
||||
const sourceOffset = drifted ? mapExecutedOffsetToSource(executedStatement!, range.sql, basisOffset) : basisOffset;
|
||||
|
||||
// Clamp inside the resolved statement range so a residual mismatch can never
|
||||
// place the caret outside the statement it belongs to.
|
||||
const editorOffset = Math.max(range.from, Math.min(range.from + sourceOffset, range.to));
|
||||
if (isSqlErrorPositionDebugEnabled()) {
|
||||
logDiagnostics("resolved", options, {
|
||||
position,
|
||||
rangeFrom: range.from,
|
||||
rangeTo: range.to,
|
||||
rangeSql: previewText(range.sql),
|
||||
drifted,
|
||||
positionBasis: previewText(positionBasis),
|
||||
basisOffset,
|
||||
sourceOffset,
|
||||
editorOffset,
|
||||
});
|
||||
}
|
||||
return { offset: editorOffset, line: position.line, column: position.column };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 1-based line/column counted in Unicode scalar values into a UTF-16
|
||||
* offset. Lines beyond the text are clamped to its end (the position is always
|
||||
* relative to `text`, so this only guards malformed/edge input).
|
||||
*/
|
||||
export function scalarPositionToUtf16Offset(text: string, line: number, column: number): number {
|
||||
const characters = Array.from(text);
|
||||
if (characters.length === 0) return 0;
|
||||
const targetLine = Math.max(1, line);
|
||||
const targetColumn = Math.max(1, column);
|
||||
|
||||
let currentLine = 1;
|
||||
let lineStart = 0;
|
||||
let index = 0;
|
||||
while (index < characters.length && currentLine < targetLine) {
|
||||
if (characters[index] === "\n") {
|
||||
currentLine += 1;
|
||||
lineStart = index + 1;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if (currentLine < targetLine) return text.length;
|
||||
|
||||
let lineEnd = characters.length;
|
||||
for (let cursor = lineStart; cursor < characters.length; cursor += 1) {
|
||||
if (characters[cursor] === "\n") {
|
||||
lineEnd = cursor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const target = Math.min(lineStart + (targetColumn - 1), lineEnd);
|
||||
return characters.slice(0, target).join("").length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a UTF-16 offset in the executed statement onto the original source
|
||||
* statement.
|
||||
*
|
||||
* 1. Pagination wraps (`SELECT * FROM (<sql>) …`) and appended clauses keep the
|
||||
* original statement contiguous, so a direct substring search resolves them
|
||||
* exactly.
|
||||
* 2. Injected columns (hidden keys) break contiguity: align the common prefix,
|
||||
* then let the source resume where its remaining text next appears in the
|
||||
* executed statement.
|
||||
* 3. Otherwise fall back to a single changed-region alignment between the first
|
||||
* and last difference.
|
||||
*/
|
||||
export function mapExecutedOffsetToSource(executed: string, source: string, executedOffset: number): number {
|
||||
if (executed === source) return clamp(executedOffset, 0, source.length);
|
||||
const clampedExecuted = clamp(executedOffset, 0, executed.length);
|
||||
|
||||
const direct = executed.indexOf(source);
|
||||
if (direct >= 0) return clamp(clampedExecuted - direct, 0, source.length);
|
||||
|
||||
const prefix = commonPrefixLength(executed, source);
|
||||
const remainder = source.slice(prefix);
|
||||
if (remainder.length >= 2) {
|
||||
const resume = executed.indexOf(remainder, prefix);
|
||||
if (resume >= 0) {
|
||||
if (clampedExecuted < resume) return Math.min(clampedExecuted, prefix);
|
||||
return clamp(clampedExecuted - (resume - prefix), prefix, source.length);
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = commonSuffixLength(executed, source, prefix);
|
||||
const executedChangeEnd = executed.length - suffix;
|
||||
const sourceChangeEnd = source.length - suffix;
|
||||
if (clampedExecuted <= prefix) return clampedExecuted;
|
||||
if (clampedExecuted >= executedChangeEnd) {
|
||||
return clamp(clampedExecuted - (executedChangeEnd - sourceChangeEnd), prefix, sourceChangeEnd);
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function commonPrefixLength(a: string, b: string): number {
|
||||
let index = 0;
|
||||
const max = Math.min(a.length, b.length);
|
||||
while (index < max && a[index] === b[index]) index += 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
function commonSuffixLength(a: string, b: string, prefix: number): number {
|
||||
let index = 0;
|
||||
const max = Math.min(a.length, b.length) - prefix;
|
||||
while (index < max && a[a.length - 1 - index] === b[b.length - 1 - index]) index += 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (max < min) return min;
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export const contentSurfaceEventNames = [
|
||||
"structureEditorClose",
|
||||
"previewStatement",
|
||||
"focusStatement",
|
||||
"focusErrorOffset",
|
||||
"openSettings",
|
||||
"openConnectionSettings",
|
||||
"toggleZenMode",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { executableStatementRanges, splitSqlStatementRanges, sqlStatementParamet
|
||||
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
|
||||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||||
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
import type { SqlErrorPosition } from "@/lib/backend/errorUtils";
|
||||
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
@@ -416,6 +417,8 @@ export interface ExecutionSummaryItem {
|
||||
sourceTo?: number;
|
||||
status: "pending" | "running" | "success" | "error" | "skipped" | "cancelled";
|
||||
error?: string;
|
||||
/** Backend-reported error row/column, when the driver provides one. */
|
||||
errorPosition?: SqlErrorPosition;
|
||||
returnedColumns: number;
|
||||
returnedRows: number;
|
||||
affectedRows: number;
|
||||
@@ -446,6 +449,7 @@ export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results" |
|
||||
sourceTo: item.to,
|
||||
status: item.status,
|
||||
error: item.error,
|
||||
errorPosition: item.errorDetails?.errorPosition ?? result?.error?.errorPosition,
|
||||
returnedColumns: result?.columns.length ?? 0,
|
||||
returnedRows,
|
||||
affectedRows,
|
||||
@@ -467,6 +471,7 @@ export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results" |
|
||||
sourceTo: result.sourceTo,
|
||||
status: isError ? "error" : "success",
|
||||
error: isError ? String(result.rows[0]?.[0] ?? "") : undefined,
|
||||
errorPosition: result.error?.errorPosition,
|
||||
returnedColumns: result.columns.length,
|
||||
returnedRows: result.rows.length,
|
||||
affectedRows: result.affected_rows,
|
||||
|
||||
Reference in new issue
Block a user