Skip to content

Commit 8de565b

Browse files
authored
Error snippets, "query" cli command, more helpful errors (#441)
- Error snippets: parse out errors and 👆 point to the syntax error in a sql comment on the line below - Add a `query` command used like: `pgkit query 'select x from y'`. There are obviously myriad ways to do this already but this will use your existing pgkit config include type parsers et al - Error helpfulness: using `pg-query-emscripten` instead of `pgsql-ast-parser` in the admin ui now to get better parse results and more accurate error positions Next: standard-schema support + standard-schema error pretty-printing. So more changes expected for errors --------- Co-authored-by: Misha Kaletsky <mmkal@users.noreply.github.com>
1 parent 05a949d commit 8de565b

34 files changed

+1233
-515
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929

3030
strategy:
3131
matrix:
32-
node-version: [23.x]
32+
node-version: [22]
3333

3434
steps:
3535
- name: check psql

packages/admin/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
"mermaid": "^10.9.1",
7878
"next-themes": "^0.3.0",
7979
"p-memoize": "^7.1.1",
80-
"pgsql-ast-parser": "^12.0.1",
80+
"pg-query-emscripten": "^5.1.0",
8181
"react": "^18.2.0",
8282
"react-dom": "^18.2.0",
8383
"react-hook-form": "^7.51.3",
@@ -99,6 +99,7 @@
9999
"autoprefixer": "^10.4.19",
100100
"eslint": "^8.57.0",
101101
"eslint-plugin-tailwindcss": "3.15.1",
102+
"pgsql-ast-parser": "^12.0.1",
102103
"postcss": "^8.4.38",
103104
"shadcn-ui": "0.8.0",
104105
"sql-autocomplete": "^1.1.1",

packages/admin/src/client/sql-codemirror.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export interface SqlCodeMirrorProps {
1414
code?: string
1515
onChange?: (query: string) => void
1616
onExecute?: (sql: string) => void
17-
errors?: Array<{position: number; message: string}>
17+
errors?: Array<{position: number; message: string | string[]}>
1818
height: string
1919
readonly?: boolean
2020
wrapText?: boolean
@@ -75,7 +75,13 @@ export const SqlCodeMirror = ({code, onChange, onExecute, errors, height, ...pro
7575
const linterExtension = linter(v => {
7676
return (errors || []).map(e => {
7777
const {from, to} = v.state.wordAt(e.position) || {from: e.position, to: e.position + 1}
78-
return {from, to, message: e.message, severity: 'error'}
78+
const unknownErrorMessage = `Unknown error. If this error came from the server, it may need a custom serializer - Errors by default are serialized to '{}'`
79+
return {
80+
from,
81+
to,
82+
message: [e.message].flat().join('\n').split('\n')[0] || unknownErrorMessage,
83+
severity: 'error',
84+
}
7985
})
8086
})
8187

packages/admin/src/client/views/Querier.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ export const Querier = () => {
2929
onSuccess: data => {
3030
const newErrors = data.results.flatMap(r => {
3131
if (r.error && typeof r.position === 'number') {
32-
return [{message: r.error.message, position: r.position + 1}]
32+
return [{message: r.error.message, position: r.position}]
3333
}
3434

3535
const parsed = PGErrorWrapper.safeParse(r.error?.cause)
3636
if (parsed.success) {
3737
const pgError = parsed.data.error
38-
return [{message: r.error?.message || pgError.code, position: Number(pgError.position) - 1}]
38+
return [{message: r.error?.message || pgError.code, position: Number(pgError.position)}]
3939
}
4040

4141
return []
@@ -76,7 +76,7 @@ export const Querier = () => {
7676
<div className="flex gap-1">
7777
<Button
7878
title="AI"
79-
disabled={aiMutation.isLoading}
79+
disabled={aiMutation.isPending}
8080
onClick={() => {
8181
const aiPrompt = prompt('Enter a prompt', aiMutation.variables?.prompt || '')
8282
if (!aiPrompt) return
@@ -94,7 +94,7 @@ export const Querier = () => {
9494
</Button>
9595
</div>
9696
</div>
97-
{aiMutation.isLoading && <FakeProgress value={aiMutation.isSuccess ? 100 : null} estimate={3000} />}
97+
{aiMutation.isPending && <FakeProgress value={aiMutation.isSuccess ? 100 : null} estimate={3000} />}
9898
<div className="flex flex-col gap-4 h-[90%] relative">
9999
<div ref={ref} className="h-1/2 border rounded-lg overflow-scroll relative bg-gray-800">
100100
<SqlCodeMirror
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
declare module 'pg-query-emscripten' {
2+
export type ParsedPGStatement = {
3+
stmt?: {}
4+
stmt_len?: number
5+
stmt_location?: number
6+
}
7+
type PgQueryEmscriptenParseError = {
8+
message: string
9+
funcname: string
10+
filename: string
11+
lineno: number
12+
cursorpos: number
13+
context: string
14+
}
15+
16+
export type PgQueryEmscriptenParseResult = {
17+
parse_tree: {version: number; stmts: ParsedPGStatement[]}
18+
stderr_buffer: string
19+
error: null | PgQueryEmscriptenParseError
20+
}
21+
export type PgQueryEmscriptenInstance = {
22+
parse(query: string): PgQueryEmscriptenParseResult
23+
}
24+
declare const Module: new () => Promise<PgQueryEmscriptenInstance>
25+
export default Module
26+
}

packages/admin/src/server/query.ts

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {FieldInfo, Queryable, nameQuery, sql} from '@pgkit/client'
2-
import * as parser from 'pgsql-ast-parser'
2+
import PGQueryEmscripten from 'pg-query-emscripten'
33
import {type ServerContext} from './context.js'
44

55
export const runQuery = async (query: string, {connection}: ServerContext): Promise<QueryResult[]> => {
@@ -15,46 +15,60 @@ export const runQuery = async (query: string, {connection}: ServerContext): Prom
1515
return [await runOneQuery(query, connection)]
1616
}
1717

18-
let parsed: parser.Statement[]
19-
try {
20-
parsed = parser.parse(query, {locationTracking: true})
21-
} catch (err) {
22-
makeJsonable(err)
23-
err.message = [
24-
err.message,
18+
const results = [] as QueryResult[]
19+
20+
const [parseError, nativeParsed] = await Promise.resolve()
21+
.then(() => new PGQueryEmscripten())
22+
.then(parser => [null, parser.parse(query)] as const)
23+
.catch((error: Error) => [error, null] as const)
24+
25+
if (parseError || nativeParsed.error) {
26+
const error = parseError || new Error(nativeParsed.error!.message, {cause: nativeParsed.error})
27+
error.message = [
28+
error.message,
2529
'',
2630
`If you think the query is actually valid, it's possible the parsing library has a bug.`,
27-
`Try adding --no-parse at the top of your query to disable statement-level query parsing and send it to the DB anyway.`,
31+
`Try adding --no-parse at the top of your query to disable statement-level query parsing and send it to the DB as-is.`,
2832
].join('\n')
29-
return [{query: nameQuery([query]), original: query, error: err, result: null, fields: null}]
33+
makeJsonable(error)
34+
return [
35+
{
36+
query: nameQuery([query]),
37+
original: query,
38+
error,
39+
result: null,
40+
fields: null,
41+
position: nativeParsed?.error?.cursorpos,
42+
},
43+
]
3044
}
3145

32-
const results = [] as QueryResult[]
33-
await connection
34-
.transaction(async tx => {
35-
for (const stmt of parsed) {
36-
const statementSql = stmt._location
37-
? query.slice(stmt._location.start, stmt._location.end + 1)
38-
: parser.toSql.statement(stmt)
39-
const result = await runOneQuery(statementSql, tx)
40-
results.push(result)
46+
const slices = nativeParsed.parse_tree.stmts.map(s => {
47+
// if (typeof s?.stmt_location !== 'number') return undefined
4148

42-
if (result.error) {
43-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
44-
const position = (result.error as any).cause?.error?.position
45-
if (position && stmt._location) {
46-
result.position = stmt._location.start + Number(position)
47-
}
49+
const start = s.stmt_location ?? 0
50+
const length = s.stmt_len
51+
const end = typeof length === 'number' ? start + length : undefined
4852

49-
throw result.error
50-
}
53+
return [start, end] as const
54+
})
55+
56+
if (!slices.every(Array.isArray)) {
57+
throw new Error('Failed to parse query')
58+
}
59+
60+
await connection.transaction(async tx => {
61+
for (const [start, end] of slices as [number, number][]) {
62+
const result = await runOneQuery(query.slice(start, end), tx)
63+
64+
if (result.error) {
65+
// @ts-expect-error optional chain yolo
66+
const position: unknown = result.error?.cause?.error?.position
67+
if (typeof position === 'number') result.position = start + Number(position)
5168
}
52-
})
53-
.catch((e: unknown) => {
54-
const error = new Error(`Transaction failed`, {cause: e})
55-
makeJsonable(error)
56-
results.push({query: nameQuery([query]), original: query, error, result: null, fields: null})
57-
})
69+
results.push(result)
70+
}
71+
})
5872

5973
return results
6074
}

0 commit comments

Comments
 (0)