diff --git a/src/lib/PostgresMeta.ts b/src/lib/PostgresMeta.ts index 91050383..eb931624 100644 --- a/src/lib/PostgresMeta.ts +++ b/src/lib/PostgresMeta.ts @@ -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> end: () => Promise columnPrivileges: PostgresMetaColumnPrivileges diff --git a/src/lib/db.ts b/src/lib/db.ts index 263be4d8..d43ef8f5 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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> => { +const poolerQueryHandleError = ( + pgpool: pg.Pool, + sql: string, + parameters?: unknown[] +): Promise> => { return Sentry.startSpan( { op: 'db', name: 'poolerQuery' }, () => @@ -44,7 +48,7 @@ const poolerQueryHandleError = (pgpool: pg.Pool, sql: string): Promise) => { if (!rejected) { return resolve(results) @@ -64,7 +68,7 @@ const poolerQueryHandleError = (pgpool: pg.Pool, sql: string): Promise { query: ( sql: string, - opts?: { statementQueryTimeout?: number; trackQueryInSentry?: boolean } + opts?: { statementQueryTimeout?: number; trackQueryInSentry?: boolean; parameters?: unknown[] } ) => Promise> end: () => Promise } = (config) => { @@ -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) @@ -131,7 +135,7 @@ 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: [] } } @@ -139,7 +143,7 @@ export const init: (config: PoolConfig) => { 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: [] } } diff --git a/src/server/routes/query.ts b/src/server/routes/query.ts index c8f23bc9..2cc6ad94 100644 --- a/src/server/routes/query.ts +++ b/src/server/routes/query.ts @@ -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) @@ -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) { @@ -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) @@ -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) @@ -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) diff --git a/test/server/query.ts b/test/server/query.ts index 8a9d6076..2cd86f52 100644 --- a/test/server/query.ts +++ b/test/server/query.ts @@ -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(` [ @@ -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(` @@ -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' + ) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 460baf6e..da50a76b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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',