Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/lib/PostgresMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { PostgresMetaResult, PoolConfig } from './types.js'
export default class PostgresMeta {
query: (
sql: string,
opts?: { statementQueryTimeout?: number; trackQueryInSentry?: boolean }
opts?: { statementQueryTimeout?: number; trackQueryInSentry?: boolean; parameters?: unknown[] }
) => Promise<PostgresMetaResult<any>>
end: () => Promise<void>
columnPrivileges: PostgresMetaColumnPrivileges
Expand Down
16 changes: 10 additions & 6 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ pg.types.setTypeParser(1017, (x) => x) // _point

// Ensure any query will have an appropriate error handler on the pool to prevent connections errors
// to bubble up all the stack eventually killing the server
const poolerQueryHandleError = (pgpool: pg.Pool, sql: string): Promise<pg.QueryResult<any>> => {
const poolerQueryHandleError = (
pgpool: pg.Pool,
sql: string,
parameters?: unknown[]
): Promise<pg.QueryResult<any>> => {
return Sentry.startSpan(
{ op: 'db', name: 'poolerQuery' },
() =>
Expand All @@ -44,7 +48,7 @@ const poolerQueryHandleError = (pgpool: pg.Pool, sql: string): Promise<pg.QueryR
// such as parse or RESULT_SIZE_EXCEEDED errors instead, handle the error gracefully by bubbling in up to the caller
pgpool.once('error', connectionErrorHandler)
pgpool
.query(sql)
.query(sql, parameters)
.then((results: pg.QueryResult<any>) => {
if (!rejected) {
return resolve(results)
Expand All @@ -64,7 +68,7 @@ const poolerQueryHandleError = (pgpool: pg.Pool, sql: string): Promise<pg.QueryR
export const init: (config: PoolConfig) => {
query: (
sql: string,
opts?: { statementQueryTimeout?: number; trackQueryInSentry?: boolean }
opts?: { statementQueryTimeout?: number; trackQueryInSentry?: boolean; parameters?: unknown[] }
) => Promise<PostgresMetaResult<any>>
end: () => Promise<void>
} = (config) => {
Expand Down Expand Up @@ -108,7 +112,7 @@ export const init: (config: PoolConfig) => {
return {
async query(
sql,
{ statementQueryTimeout, trackQueryInSentry } = { trackQueryInSentry: true }
{ statementQueryTimeout, trackQueryInSentry, parameters } = { trackQueryInSentry: true }
) {
return Sentry.startSpan(
// For metrics purposes, log the query that will be run if it's not an user provided query (with possibly sentitives infos)
Expand All @@ -131,15 +135,15 @@ export const init: (config: PoolConfig) => {
try {
if (!pool) {
const pool = new pg.Pool(config)
let res = await poolerQueryHandleError(pool, sqlWithStatementTimeout)
let res = await poolerQueryHandleError(pool, sqlWithStatementTimeout, parameters)
if (Array.isArray(res)) {
res = res.reverse().find((x) => x.rows.length !== 0) ?? { rows: [] }
}
await pool.end()
return { data: res.rows, error: null }
}

let res = await poolerQueryHandleError(pool, sqlWithStatementTimeout)
let res = await poolerQueryHandleError(pool, sqlWithStatementTimeout, parameters)
if (Array.isArray(res)) {
res = res.reverse().find((x) => x.rows.length !== 0) ?? { rows: [] }
}
Expand Down
21 changes: 6 additions & 15 deletions src/server/routes/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,8 @@ const errorOnEmptyQuery = (request: FastifyRequest) => {
export default async (fastify: FastifyInstance) => {
fastify.post<{
Headers: { pg: string; 'x-pg-application-name'?: string }
Body: {
query: string
}
Querystring: {
statementTimeoutSecs?: number
}
Body: { query: string; parameters?: unknown[] }
Querystring: { statementTimeoutSecs?: number }
}>('/', async (request, reply) => {
const statementTimeoutSecs = request.query.statementTimeoutSecs
errorOnEmptyQuery(request)
Expand All @@ -30,6 +26,7 @@ export default async (fastify: FastifyInstance) => {
const { data, error } = await pgMeta.query(request.body.query, {
trackQueryInSentry: true,
statementQueryTimeout: statementTimeoutSecs,
parameters: request.body.parameters,
})
await pgMeta.end()
if (error) {
Expand All @@ -43,9 +40,7 @@ export default async (fastify: FastifyInstance) => {

fastify.post<{
Headers: { pg: string; 'x-pg-application-name'?: string }
Body: {
query: string
}
Body: { query: string }
}>('/format', async (request, reply) => {
errorOnEmptyQuery(request)
const { data, error } = await Parser.Format(request.body.query)
Expand All @@ -61,9 +56,7 @@ export default async (fastify: FastifyInstance) => {

fastify.post<{
Headers: { pg: string; 'x-pg-application-name'?: string }
Body: {
query: string
}
Body: { query: string }
}>('/parse', async (request, reply) => {
errorOnEmptyQuery(request)
const { data, error } = Parser.Parse(request.body.query)
Expand All @@ -79,9 +72,7 @@ export default async (fastify: FastifyInstance) => {

fastify.post<{
Headers: { pg: string; 'x-pg-application-name'?: string }
Body: {
ast: object
}
Body: { ast: object }
}>('/deparse', async (request, reply) => {
const { data, error } = Parser.Deparse(request.body.ast)

Expand Down
106 changes: 95 additions & 11 deletions test/server/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,9 +547,7 @@ test('return interval as string', async () => {
const res = await app.inject({
method: 'POST',
path: '/query',
payload: {
query: `SELECT '1 day 1 hour 45 minutes'::interval`,
},
payload: { query: `SELECT '1 day 1 hour 45 minutes'::interval` },
})
expect(res.json()).toMatchInlineSnapshot(`
[
Expand Down Expand Up @@ -703,9 +701,7 @@ test('error with internalQuery property', async () => {
const res = await app.inject({
method: 'POST',
path: '/query',
payload: {
query: 'SELECT test_internal_query();',
},
payload: { query: 'SELECT test_internal_query();' },
})

expect(res.json()).toMatchInlineSnapshot(`
Expand Down Expand Up @@ -737,19 +733,107 @@ test('custom application_name', async () => {
const res = await app.inject({
method: 'POST',
path: '/query',
headers: {
'x-pg-application-name': 'test',
},
headers: { 'x-pg-application-name': 'test' },
payload: { query: 'SHOW application_name;' },
})

expect(res.json()).toMatchInlineSnapshot(`
[
{
"application_name": "test",
},
]
`)
})

test('parameter binding with positional parameters', async () => {
const res = await app.inject({
method: 'POST',
path: '/query',
payload: {
query: 'SHOW application_name;',
query: 'SELECT * FROM users WHERE id = $1 AND status = $2',
parameters: [1, 'ACTIVE'],
},
})
expect(res.json()).toMatchInlineSnapshot(`
[
{
"decimal": null,
"id": 1,
"name": "Joe Bloggs",
"status": "ACTIVE",
},
]
`)
})

test('parameter binding with single parameter', async () => {
const res = await app.inject({
method: 'POST',
path: '/query',
payload: { query: 'SELECT name FROM users WHERE id = $1', parameters: [2] },
})
expect(res.json()).toMatchInlineSnapshot(`
[
{
"application_name": "test",
"name": "Jane Doe",
},
]
`)
})

test('parameter binding with no matches', async () => {
const res = await app.inject({
method: 'POST',
path: '/query',
payload: { query: 'SELECT * FROM users WHERE id = $1', parameters: [999] },
})
expect(res.json()).toMatchInlineSnapshot(`[]`)
})

test('no parameters field', async () => {
const res = await app.inject({
method: 'POST',
path: '/query',
payload: { query: 'SELECT COUNT(*) as count FROM users' },
})
expect(res.json()).toMatchInlineSnapshot(`
[
{
"count": 2,
},
]
`)
})

test('parameter binding with empty parameters array', async () => {
const res = await app.inject({
method: 'POST',
path: '/query',
payload: { query: 'SELECT COUNT(*) as count FROM users', parameters: [] },
})
expect(res.json()).toMatchInlineSnapshot(`
[
{
"count": 2,
},
]
`)
})

test('parameter binding error - wrong parameter count', async () => {
const res = await app.inject({
method: 'POST',
path: '/query',
payload: {
query: 'SELECT * FROM users WHERE id = $1 AND status = $2',
parameters: [1], // Missing second parameter
},
})
expect(res.statusCode).toBe(400)
const json = res.json()
expect(json.code).toBe('08P01')
expect(json.message).toContain(
'bind message supplies 1 parameters, but prepared statement "" requires 2'
)
})
4 changes: 1 addition & 3 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
coverage: {
reporter: ['lcov'],
},
coverage: { reporter: ['lcov'] },
maxConcurrency: 1,
// https://github.com/vitest-dev/vitest/issues/317#issuecomment-1542319622
pool: 'forks',
Expand Down