-
Notifications
You must be signed in to change notification settings - Fork 5
refactor(sql): share transaction-wrapping and error-formatting across transports #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| // Transport-agnostic helpers shared by every SQL execution path (the ConnectRPC ExecuteSQL | ||
| // handler and the MCP execution tools). Identity resolution, permission enforcement, and | ||
| // response shaping stay in each transport; what's identical — how a batch is made safe to run | ||
| // and how a Postgres error is rendered — lives here so the two paths can't drift. | ||
|
|
||
| // The shape of detectRequiredPermissions() this module needs. | ||
| interface StatementAnalysis { | ||
| statementCount: number | ||
| transactionSafe: boolean | ||
| } | ||
|
|
||
| // Build the SQL actually sent to the server. A safe multi-statement batch is wrapped in a | ||
| // transaction so a mid-batch failure rolls back; without it, PostgreSQL's Simple Query protocol | ||
| // runs each statement in autocommit, leaving 1..N-1 committed when statement N fails. Statements | ||
| // that cannot run inside a transaction (CREATE DATABASE, VACUUM, CREATE INDEX CONCURRENTLY) are | ||
| // excluded upstream via `transactionSafe`. The `\n;\n` before COMMIT terminates the user's last | ||
| // statement even when it lacks a trailing semicolon or ends in a line comment (a bare `;` would | ||
| // be swallowed by the comment). | ||
| export function buildExecutableSql(rawSql: string, analysis: StatementAnalysis): string { | ||
| return analysis.statementCount > 1 && analysis.transactionSafe ? `BEGIN;\n${rawSql}\n;\nCOMMIT;` : rawSql | ||
| } | ||
|
|
||
| // Render a thrown postgres.js error into a readable message: the base message, plus line context | ||
| // derived from the error position, plus PostgreSQL's DETAIL and HINT when present. `sql` is the | ||
| // statement the position refers to. | ||
| export function formatExecutionError(err: unknown, sql: string): string { | ||
| const baseMessage = err instanceof Error ? err.message : 'Query execution failed' | ||
| let fullError = baseMessage | ||
|
|
||
| const pgErr = err as Record<string, unknown> | ||
| const pos = pgErr?.position | ||
| if (typeof pos === 'string' && pos) { | ||
| const charPos = parseInt(pos, 10) | ||
| if (charPos > 0) { | ||
| const before = sql.slice(0, charPos - 1) | ||
| const lineNumber = before.split('\n').length | ||
| const lines = sql.split('\n') | ||
| const offendingLine = lines[lineNumber - 1] | ||
| if (offendingLine !== undefined) { | ||
| fullError = `ERROR at Line ${lineNumber}: ${baseMessage}\nLINE ${lineNumber}: ${offendingLine}` | ||
| } | ||
| } | ||
| } | ||
| if (typeof pgErr?.detail === 'string' && pgErr.detail) { | ||
| fullError += `\nDETAIL: ${pgErr.detail}` | ||
| } | ||
| if (typeof pgErr?.hint === 'string' && pgErr.hint) { | ||
| fullError += `\nHINT: ${pgErr.hint}` | ||
| } | ||
| return fullError | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { describe, it, expect } from 'vitest' | ||
| import { buildExecutableSql, formatExecutionError } from '../server/lib/execute-sql' | ||
|
|
||
| describe('buildExecutableSql', () => { | ||
| it('leaves a single statement untouched', () => { | ||
| expect(buildExecutableSql('SELECT 1', { statementCount: 1, transactionSafe: true })).toBe('SELECT 1') | ||
| }) | ||
|
|
||
| it('wraps a safe multi-statement batch in BEGIN/COMMIT', () => { | ||
| const sql = 'INSERT INTO t VALUES (1);\nUPDATE t SET x = 2' | ||
| expect(buildExecutableSql(sql, { statementCount: 2, transactionSafe: true })).toBe(`BEGIN;\n${sql}\n;\nCOMMIT;`) | ||
| }) | ||
|
|
||
| it('does not wrap a multi-statement batch that is not transaction-safe', () => { | ||
| const sql = 'VACUUM;\nSELECT 1' | ||
| expect(buildExecutableSql(sql, { statementCount: 2, transactionSafe: false })).toBe(sql) | ||
| }) | ||
|
|
||
| it('does not wrap a single transaction-unsafe statement', () => { | ||
| expect(buildExecutableSql('VACUUM', { statementCount: 1, transactionSafe: false })).toBe('VACUUM') | ||
| }) | ||
| }) | ||
|
|
||
| describe('formatExecutionError', () => { | ||
| it('returns the bare message when there is no position/detail/hint', () => { | ||
| expect(formatExecutionError(new Error('syntax error'), 'SELECT')).toBe('syntax error') | ||
| }) | ||
|
|
||
| it('adds line context from the error position', () => { | ||
| const sql = 'SELECT 1\nFROM nope\nWHERE x' | ||
| // position points into line 2 (1-based char offset) | ||
| const err = Object.assign(new Error('relation "nope" does not exist'), { position: '15' }) | ||
| const out = formatExecutionError(err, sql) | ||
| expect(out).toContain('ERROR at Line 2:') | ||
| expect(out).toContain('LINE 2: FROM nope') | ||
| }) | ||
|
|
||
| it('appends DETAIL and HINT when present', () => { | ||
| const err = Object.assign(new Error('boom'), { detail: 'the detail', hint: 'try this' }) | ||
| const out = formatExecutionError(err, 'SELECT 1') | ||
| expect(out).toBe('boom\nDETAIL: the detail\nHINT: try this') | ||
| }) | ||
|
|
||
| it('falls back for a non-Error throwable', () => { | ||
| expect(formatExecutionError('weird', 'SELECT 1')).toBe('Query execution failed') | ||
| }) | ||
|
|
||
| // Callers must pass the executed SQL (Postgres `position` indexes into what actually ran). For a | ||
| // transaction-wrapped batch, BEGIN; is line 1, so the user's statements shift down by one. | ||
| it('maps position onto the executed transaction-wrapped SQL', () => { | ||
| const raw = 'INSERT INTO t VALUES (1);\nUPDATE nope SET x = 2' | ||
| const executed = buildExecutableSql(raw, { statementCount: 2, transactionSafe: true }) | ||
| const pos = executed.indexOf('UPDATE') + 1 // 1-based offset into the executed string | ||
| const err = Object.assign(new Error('relation "nope" does not exist'), { position: String(pos) }) | ||
| expect(formatExecutionError(err, executed)).toContain('LINE 3: UPDATE nope SET x = 2') | ||
| }) | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looked into this and I'm going to leave it as-is. Two reasons:
The suggested remedy isn't available. postgres.js builds its error solely from PG's
ErrorResponsefields (parseErrorin connection.js →Errors.postgres); it does not attach the failing SQL text to the error object. There's noerr.queryto prefer overexecutableSql.The edge is effectively unreachable and not a regression. The only other statements in this try are
SELECT pg_backend_pid()(a constant — cannot carry aposition) andSET search_path TO <escaped idents>(doesn't validate schema existence, won't produce a position-bearing error with quoted identifiers). The pre-existing code formatted againstreq.sql, which was equally unrelated to a setup-statement error — so this isn't introduced here.Isolating the main query in its own try/catch to scope the formatting would be a real restructure of a streaming generator for a scenario that can't occur in practice, so per the repo's 'no error handling for impossible scenarios' guideline I'll skip it.