From b65af3600d62452655805de28071d810e4470db7 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Tue, 28 Jul 2026 12:16:02 +0200 Subject: [PATCH] ref(node): Remove vendored postgres.js instrumentation This instrumentation is dead code: the postgres.js integration is provided by the channel-based implementation in `@sentry/server-utils` (default since #22501), and the vendored OpenTelemetry instrumentation here is no longer wired up. Ref: #22346 Co-Authored-By: Claude Opus 4.8 --- .../src/integrations/tracing/postgresjs.ts | 401 ----------------- .../integrations/tracing/postgresjs.test.ts | 411 ------------------ 2 files changed, 812 deletions(-) delete mode 100644 packages/node/src/integrations/tracing/postgresjs.ts delete mode 100644 packages/node/test/integrations/tracing/postgresjs.test.ts diff --git a/packages/node/src/integrations/tracing/postgresjs.ts b/packages/node/src/integrations/tracing/postgresjs.ts deleted file mode 100644 index 4de3617d60ec..000000000000 --- a/packages/node/src/integrations/tracing/postgresjs.ts +++ /dev/null @@ -1,401 +0,0 @@ -// Instrumentation for https://github.com/porsager/postgres - -import { context, trace } from '@opentelemetry/api'; -import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; -import { - InstrumentationBase, - InstrumentationNodeModuleDefinition, - safeExecuteInTheMiddle, -} from '@opentelemetry/instrumentation'; -import { InstrumentationNodeModuleFile } from './InstrumentationNodeModuleFile'; -import { DB_OPERATION_NAME, DB_QUERY_TEXT, DB_SYSTEM_NAME, ERROR_TYPE } from '@sentry/conventions/attributes'; -import type { Span } from '@sentry/core'; -import { - debug, - instrumentPostgresJsSql, - replaceExports, - SDK_VERSION, - SPAN_STATUS_ERROR, - startSpanManual, -} from '@sentry/core'; -import { generateInstrumentOnce } from '../../otel/instrument'; -import { addOriginToSpan } from '../../utils/addOriginToSpan'; -import { DEBUG_BUILD } from '../../debug-build'; - -const INTEGRATION_NAME = 'PostgresJs' as const; -const SUPPORTED_VERSIONS = ['>=3.0.0 <4']; -// Not part of `@sentry/conventions`, so we keep it inline. -const ATTR_DB_RESPONSE_STATUS_CODE = 'db.response.status_code'; -const SQL_OPERATION_REGEX = /^(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)/i; - -type PostgresConnectionContext = { - ATTR_DB_NAMESPACE?: string; // Database name - ATTR_SERVER_ADDRESS?: string; // Hostname or IP address of the database server - ATTR_SERVER_PORT?: string; // Port number of the database server -}; - -// Marker to track if a query was created from an instrumented sql instance -// This prevents double-spanning when both wrapper and prototype patches are active -const QUERY_FROM_INSTRUMENTED_SQL = Symbol.for('sentry.query.from.instrumented.sql'); - -type PostgresJsInstrumentationConfig = InstrumentationConfig & { - /** - * Whether to require a parent span for the instrumentation. - * If set to true, the instrumentation will only create spans if there is a parent span - * available in the current scope. - * @default true - */ - requireParentSpan?: boolean; - /** - * Hook to modify the span before it is started. - * This can be used to set additional attributes or modify the span in any way. - */ - requestHook?: (span: Span, sanitizedSqlQuery: string, postgresConnectionContext?: PostgresConnectionContext) => void; -}; - -export const instrumentPostgresJs = generateInstrumentOnce( - INTEGRATION_NAME, - (options?: PostgresJsInstrumentationConfig) => - new PostgresJsInstrumentation({ - requireParentSpan: options?.requireParentSpan ?? true, - requestHook: options?.requestHook, - }), -); - -/** - * Instrumentation for the [postgres](https://www.npmjs.com/package/postgres) library. - * This instrumentation captures postgresjs queries and their attributes. - * - * Uses internal Sentry patching patterns to support both CommonJS and ESM environments. - */ -export class PostgresJsInstrumentation extends InstrumentationBase { - public constructor(config: PostgresJsInstrumentationConfig) { - super('sentry-postgres-js', SDK_VERSION, config); - } - - /** - * Initializes the instrumentation by patching the postgres module. - * Uses two complementary approaches: - * 1. Main function wrapper: instruments sql instances created AFTER instrumentation is set up (CJS + ESM) - * 2. Query.prototype patch: fallback for sql instances created BEFORE instrumentation (CJS only) - */ - public init(): InstrumentationNodeModuleDefinition { - const module = new InstrumentationNodeModuleDefinition( - 'postgres', - SUPPORTED_VERSIONS, - exports => { - try { - return this._patchPostgres(exports); - } catch (e) { - DEBUG_BUILD && debug.error('Failed to patch postgres module:', e); - return exports; - } - }, - exports => exports, - ); - - // Add fallback Query.prototype patching for pre-existing sql instances (CJS only) - // This catches queries from sql instances created before Sentry was initialized - ['src', 'cf/src', 'cjs/src'].forEach(path => { - module.files.push( - new InstrumentationNodeModuleFile( - `postgres/${path}/query.js`, - SUPPORTED_VERSIONS, - this._patchQueryPrototype.bind(this), - this._unpatchQueryPrototype.bind(this), - ), - ); - }); - - return module; - } - - /** - * Patches the postgres module by wrapping the main export function. - * This intercepts the creation of sql instances and instruments them. - */ - private _patchPostgres(exports: { [key: string]: unknown }): { [key: string]: unknown } { - // In CJS: exports is the function itself - // In ESM: exports.default is the function - const isFunction = typeof exports === 'function'; - const Original = isFunction ? exports : exports.default; - - if (typeof Original !== 'function') { - DEBUG_BUILD && debug.warn('postgres module does not export a function. Skipping instrumentation.'); - return exports; - } - - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - - const WrappedPostgres = function (this: unknown, ...args: unknown[]): unknown { - const sql = Reflect.construct(Original as (...args: unknown[]) => unknown, args); - - // Validate that construction succeeded and returned a valid function object - if (!sql || typeof sql !== 'function') { - DEBUG_BUILD && debug.warn('postgres() did not return a valid instance'); - return sql; - } - - // Delegate to the portable instrumentation from @sentry/core - const config = self.getConfig(); - return instrumentPostgresJsSql(sql, { - requireParentSpan: config.requireParentSpan, - requestHook: config.requestHook, - }); - }; - - Object.setPrototypeOf(WrappedPostgres, Original); - Object.setPrototypeOf(WrappedPostgres.prototype, (Original as { prototype: object }).prototype); - - for (const key of Object.getOwnPropertyNames(Original)) { - if (!['length', 'name', 'prototype'].includes(key)) { - const descriptor = Object.getOwnPropertyDescriptor(Original, key); - if (descriptor) { - Object.defineProperty(WrappedPostgres, key, descriptor); - } - } - } - - // For CJS: the exports object IS the function, so return the wrapped function - // For ESM: replace the default export - if (isFunction) { - return WrappedPostgres as unknown as { [key: string]: unknown }; - } else { - replaceExports(exports, 'default', WrappedPostgres); - return exports; - } - } - - /** - * Determines whether a span should be created based on the current context. - * If `requireParentSpan` is set to true in the configuration, a span will - * only be created if there is a parent span available. - */ - private _shouldCreateSpans(): boolean { - const config = this.getConfig(); - const hasParentSpan = trace.getSpan(context.active()) !== undefined; - return hasParentSpan || !config.requireParentSpan; - } - - /** - * Extracts DB operation name from SQL query and sets it on the span. - */ - private _setOperationName(span: Span, sanitizedQuery: string | undefined, command?: string): void { - if (command) { - span.setAttribute(DB_OPERATION_NAME, command); - return; - } - // Fallback: extract operation from the SQL query - const operationMatch = sanitizedQuery?.match(SQL_OPERATION_REGEX); - if (operationMatch?.[1]) { - span.setAttribute(DB_OPERATION_NAME, operationMatch[1].toUpperCase()); - } - } - - /** - * Reconstructs the full SQL query from template strings with PostgreSQL placeholders. - * - * For sql`SELECT * FROM users WHERE id = ${123} AND name = ${'foo'}`: - * strings = ["SELECT * FROM users WHERE id = ", " AND name = ", ""] - * returns: "SELECT * FROM users WHERE id = $1 AND name = $2" - */ - private _reconstructQuery(strings: string[] | undefined): string | undefined { - if (!strings?.length) { - return undefined; - } - if (strings.length === 1) { - return strings[0] || undefined; - } - // Join template parts with PostgreSQL placeholders ($1, $2, etc.) - return strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), ''); - } - - /** - * Sanitize SQL query as per the OTEL semantic conventions - * https://opentelemetry.io/docs/specs/semconv/database/database-spans/#sanitization-of-dbquerytext - * - * PostgreSQL $n placeholders are preserved per OTEL spec - they're parameterized queries, - * not sensitive literals. Only actual values (strings, numbers, booleans) are sanitized. - */ - private _sanitizeSqlQuery(sqlQuery: string | undefined): string { - if (!sqlQuery) { - return 'Unknown SQL Query'; - } - - return ( - sqlQuery - // Remove comments first (they may contain newlines and extra spaces) - .replace(/--.*$/gm, '') // Single line comments (multiline mode) - .replace(/\/\*[\s\S]*?\*\//g, '') // Multi-line comments - .replace(/;\s*$/, '') // Remove trailing semicolons - // Collapse whitespace to a single space (after removing comments) - .replace(/\s+/g, ' ') - .trim() // Remove extra spaces and trim - // Sanitize hex/binary literals before string literals - .replace(/\bX'[0-9A-Fa-f]*'/gi, '?') // Hex string literals - .replace(/\bB'[01]*'/gi, '?') // Binary string literals - // Sanitize string literals (handles escaped quotes) - .replace(/'(?:[^']|'')*'/g, '?') - // Sanitize hex numbers - .replace(/\b0x[0-9A-Fa-f]+/gi, '?') - // Sanitize boolean literals - .replace(/\b(?:TRUE|FALSE)\b/gi, '?') - // Sanitize numeric literals (preserve $n placeholders via negative lookbehind) - .replace(/-?\b\d+\.?\d*[eE][+-]?\d+\b/g, '?') // Scientific notation - .replace(/-?\b\d+\.\d+\b/g, '?') // Decimals - .replace(/-?\.\d+\b/g, '?') // Decimals starting with dot - .replace(/(? Promise) & { - __sentry_original__?: (...args: unknown[]) => Promise; - }; - }; - }; - }): typeof moduleExports { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - const originalHandle = moduleExports.Query.prototype.handle; - - moduleExports.Query.prototype.handle = async function ( - this: { - resolve: unknown; - reject: unknown; - strings?: string[]; - executed?: boolean; - }, - ...args: unknown[] - ): Promise { - // Skip if this query came from an instrumented sql instance (already handled by wrapper), - // or if handle() was already called (postgres.js calls handle() from then/catch/finally — - // only the first call executes SQL, subsequent calls are no-ops). - if (this.executed || (this as Record)[QUERY_FROM_INSTRUMENTED_SQL]) { - return originalHandle.apply(this, args); - } - - // Skip if we shouldn't create spans - if (!self._shouldCreateSpans()) { - return originalHandle.apply(this, args); - } - - const fullQuery = self._reconstructQuery(this.strings); - const sanitizedSqlQuery = self._sanitizeSqlQuery(fullQuery); - - return startSpanManual( - { - name: sanitizedSqlQuery || 'postgresjs.query', - op: 'db', - }, - (span: Span) => { - addOriginToSpan(span, 'auto.db.postgresjs'); - - span.setAttributes({ - [DB_SYSTEM_NAME]: 'postgres', - [DB_QUERY_TEXT]: sanitizedSqlQuery, - }); - - // Note: No connection context available for pre-existing instances - // because the sql instance wasn't created through our instrumented wrapper - - const config = self.getConfig(); - const { requestHook } = config; - if (requestHook) { - safeExecuteInTheMiddle( - () => requestHook(span, sanitizedSqlQuery, undefined), - e => { - if (e) { - span.setAttribute('sentry.hook.error', 'requestHook failed'); - DEBUG_BUILD && debug.error(`Error in requestHook for ${INTEGRATION_NAME} integration:`, e); - } - }, - true, - ); - } - - // Wrap resolve to end span on success - const originalResolve = this.resolve; - this.resolve = new Proxy(originalResolve as (...args: unknown[]) => unknown, { - apply: (resolveTarget, resolveThisArg, resolveArgs: [{ command?: string }]) => { - try { - self._setOperationName(span, sanitizedSqlQuery, resolveArgs?.[0]?.command); - span.end(); - } catch (e) { - DEBUG_BUILD && debug.error('Error ending span in resolve callback:', e); - } - return Reflect.apply(resolveTarget, resolveThisArg, resolveArgs); - }, - }); - - // Wrap reject to end span on error - const originalReject = this.reject; - this.reject = new Proxy(originalReject as (...args: unknown[]) => unknown, { - apply: (rejectTarget, rejectThisArg, rejectArgs: { message?: string; code?: string; name?: string }[]) => { - try { - span.setStatus({ - code: SPAN_STATUS_ERROR, - message: rejectArgs?.[0]?.message || 'unknown_error', - }); - span.setAttribute(ATTR_DB_RESPONSE_STATUS_CODE, rejectArgs?.[0]?.code || 'unknown'); - span.setAttribute(ERROR_TYPE, rejectArgs?.[0]?.name || 'unknown'); - self._setOperationName(span, sanitizedSqlQuery); - span.end(); - } catch (e) { - DEBUG_BUILD && debug.error('Error ending span in reject callback:', e); - } - return Reflect.apply(rejectTarget, rejectThisArg, rejectArgs); - }, - }); - - try { - return originalHandle.apply(this, args); - } catch (e) { - span.setStatus({ - code: SPAN_STATUS_ERROR, - message: e instanceof Error ? e.message : 'unknown_error', - }); - span.end(); - throw e; - } - }, - ); - }; - - // Store original for unpatch - must be set on the NEW patched function - moduleExports.Query.prototype.handle.__sentry_original__ = originalHandle; - - return moduleExports; - } - - /** - * Restores the original Query.prototype.handle method. - */ - private _unpatchQueryPrototype(moduleExports: { - Query: { - prototype: { - handle: ((...args: unknown[]) => Promise) & { - __sentry_original__?: (...args: unknown[]) => Promise; - }; - }; - }; - }): typeof moduleExports { - if (moduleExports.Query.prototype.handle.__sentry_original__) { - moduleExports.Query.prototype.handle = moduleExports.Query.prototype.handle.__sentry_original__; - } - return moduleExports; - } -} diff --git a/packages/node/test/integrations/tracing/postgresjs.test.ts b/packages/node/test/integrations/tracing/postgresjs.test.ts deleted file mode 100644 index a20b1941bb28..000000000000 --- a/packages/node/test/integrations/tracing/postgresjs.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { PostgresJsInstrumentation } from '../../../src/integrations/tracing/postgresjs'; - -describe('PostgresJs', () => { - const instrumentation = new PostgresJsInstrumentation({ requireParentSpan: true }); - - describe('_reconstructQuery', () => { - const reconstruct = (strings: string[] | undefined) => - ( - instrumentation as unknown as { _reconstructQuery: (s: string[] | undefined) => string | undefined } - )._reconstructQuery(strings); - - describe('empty input handling', () => { - it.each([ - [undefined, undefined], - [null as unknown as undefined, undefined], - [[], undefined], - [[''], undefined], - ])('returns undefined for %p', (input, expected) => { - expect(reconstruct(input)).toBe(expected); - }); - - it('returns whitespace-only string as-is', () => { - expect(reconstruct([' '])).toBe(' '); - }); - }); - - describe('single-element array (non-parameterized)', () => { - it.each([ - ['SELECT * FROM users', 'SELECT * FROM users'], - ['SELECT * FROM users WHERE id = $1', 'SELECT * FROM users WHERE id = $1'], - ['INSERT INTO users (email, name) VALUES ($1, $2)', 'INSERT INTO users (email, name) VALUES ($1, $2)'], - ])('returns %p as-is', (input, expected) => { - expect(reconstruct([input])).toBe(expected); - }); - }); - - describe('multi-element array (parameterized)', () => { - it.each([ - [['SELECT * FROM users WHERE id = ', ''], 'SELECT * FROM users WHERE id = $1'], - [['SELECT * FROM users WHERE id = ', ' AND name = ', ''], 'SELECT * FROM users WHERE id = $1 AND name = $2'], - [['INSERT INTO t VALUES (', ', ', ', ', ')'], 'INSERT INTO t VALUES ($1, $2, $3)'], - [['', ' WHERE id = ', ''], '$1 WHERE id = $2'], - [ - ['SELECT * FROM ', ' WHERE id = ', ' AND status IN (', ', ', ') ORDER BY ', ''], - 'SELECT * FROM $1 WHERE id = $2 AND status IN ($3, $4) ORDER BY $5', - ], - ])('reconstructs %p to %p', (input, expected) => { - expect(reconstruct(input)).toBe(expected); - }); - }); - - describe('edge cases', () => { - it('handles 10+ parameters', () => { - const strings = ['INSERT INTO t VALUES (', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ', ')']; - expect(reconstruct(strings)).toBe('INSERT INTO t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)'); - }); - - it.each([ - [['SELECT * FROM users WHERE id = ', ' ', ''], 'SELECT * FROM users WHERE id = $1 $2'], - [['SELECT * FROM users WHERE id = ', ' LIMIT 10'], 'SELECT * FROM users WHERE id = $1 LIMIT 10'], - [['SELECT *\nFROM users\nWHERE id = ', ''], 'SELECT *\nFROM users\nWHERE id = $1'], - [['SELECT * FROM "User" WHERE "email" = ', ''], 'SELECT * FROM "User" WHERE "email" = $1'], - [['SELECT ', '', '', ''], 'SELECT $1$2$3'], - [['', ''], '$1'], - ])('handles edge case %p', (input, expected) => { - expect(reconstruct(input)).toBe(expected); - }); - }); - - describe('integration with _sanitizeSqlQuery', () => { - const sanitize = (query: string | undefined) => - (instrumentation as unknown as { _sanitizeSqlQuery: (q: string | undefined) => string })._sanitizeSqlQuery( - query, - ); - - it('preserves $n placeholders per OTEL spec', () => { - const strings = ['SELECT * FROM users WHERE id = ', ' AND name = ', '']; - expect(sanitize(reconstruct(strings))).toBe('SELECT * FROM users WHERE id = $1 AND name = $2'); - }); - - it('collapses IN clause with $n to IN ($?)', () => { - const strings = ['SELECT * FROM users WHERE id = ', ' AND status IN (', ', ', ', ', ')']; - expect(sanitize(reconstruct(strings))).toBe('SELECT * FROM users WHERE id = $1 AND status IN ($?)'); - }); - - it('returns Unknown SQL Query for undefined input', () => { - expect(sanitize(reconstruct(undefined))).toBe('Unknown SQL Query'); - }); - - it('normalizes whitespace and removes trailing semicolon', () => { - const strings = ['SELECT *\n FROM users\n WHERE id = ', ';']; - expect(sanitize(reconstruct(strings))).toBe('SELECT * FROM users WHERE id = $1'); - }); - }); - }); - - describe('_sanitizeSqlQuery', () => { - const sanitize = (query: string | undefined) => - (instrumentation as unknown as { _sanitizeSqlQuery: (q: string | undefined) => string })._sanitizeSqlQuery(query); - - describe('passthrough (no literals)', () => { - it.each([ - ['SELECT * FROM users', 'SELECT * FROM users'], - ['INSERT INTO users (a, b) SELECT a, b FROM other', 'INSERT INTO users (a, b) SELECT a, b FROM other'], - [ - 'SELECT col1, col2 FROM table1 JOIN table2 ON table1.id = table2.id', - 'SELECT col1, col2 FROM table1 JOIN table2 ON table1.id = table2.id', - ], - ])('passes through %p unchanged', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('comment removal', () => { - it.each([ - ['SELECT * FROM users -- comment', 'SELECT * FROM users'], - ['SELECT * -- comment\nFROM users', 'SELECT * FROM users'], - ['SELECT /* comment */ * FROM users', 'SELECT * FROM users'], - ['SELECT /* multi\nline */ * FROM users', 'SELECT * FROM users'], - ['SELECT /* c1 */ * FROM /* c2 */ users -- c3', 'SELECT * FROM users'], - ])('removes comments: %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('whitespace normalization', () => { - it.each([ - ['SELECT * FROM users', 'SELECT * FROM users'], - ['SELECT *\n\tFROM\n\tusers', 'SELECT * FROM users'], - [' SELECT * FROM users ', 'SELECT * FROM users'], - [' SELECT \n\t * \r\n FROM \t\t users ', 'SELECT * FROM users'], - ])('normalizes %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('trailing semicolon removal', () => { - it.each([ - ['SELECT * FROM users;', 'SELECT * FROM users'], - ['SELECT * FROM users; ', 'SELECT * FROM users'], - ])('removes trailing semicolon: %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('$n placeholder preservation (OTEL compliance)', () => { - it.each([ - ['SELECT * FROM users WHERE id = $1', 'SELECT * FROM users WHERE id = $1'], - ['SELECT * FROM users WHERE id = $1 AND name = $2', 'SELECT * FROM users WHERE id = $1 AND name = $2'], - ['INSERT INTO t VALUES ($1, $10, $100)', 'INSERT INTO t VALUES ($1, $10, $100)'], - ['$1 UNION SELECT * FROM users', '$1 UNION SELECT * FROM users'], - ['SELECT * FROM users LIMIT $1', 'SELECT * FROM users LIMIT $1'], - ['SELECT $1$2$3', 'SELECT $1$2$3'], - ['SELECT generate_series($1, $2)', 'SELECT generate_series($1, $2)'], - ])('preserves $n: %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('string literal sanitization', () => { - it.each([ - ["SELECT * FROM users WHERE name = 'John'", 'SELECT * FROM users WHERE name = ?'], - ["SELECT * FROM users WHERE a = 'x' AND b = 'y'", 'SELECT * FROM users WHERE a = ? AND b = ?'], - ["SELECT * FROM users WHERE name = ''", 'SELECT * FROM users WHERE name = ?'], - ["SELECT * FROM users WHERE name = 'it''s'", 'SELECT * FROM users WHERE name = ?'], - ["SELECT * FROM users WHERE data = 'a''b''c'", 'SELECT * FROM users WHERE data = ?'], - ["SELECT * FROM t WHERE desc = 'Use $1 for param'", 'SELECT * FROM t WHERE desc = ?'], - ["SELECT * FROM users WHERE name = '日本語'", 'SELECT * FROM users WHERE name = ?'], - ])('sanitizes string: %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('numeric literal sanitization', () => { - it.each([ - ['SELECT * FROM users WHERE id = 123', 'SELECT * FROM users WHERE id = ?'], - ['SELECT * FROM users WHERE count = 0', 'SELECT * FROM users WHERE count = ?'], - ['SELECT * FROM products WHERE price = 19.99', 'SELECT * FROM products WHERE price = ?'], - ['SELECT * FROM products WHERE discount = .5', 'SELECT * FROM products WHERE discount = ?'], - ['SELECT * FROM accounts WHERE balance = -500', 'SELECT * FROM accounts WHERE balance = ?'], - ['SELECT * FROM accounts WHERE rate = -0.05', 'SELECT * FROM accounts WHERE rate = ?'], - ['SELECT * FROM data WHERE value = 1e10', 'SELECT * FROM data WHERE value = ?'], - ['SELECT * FROM data WHERE value = 1.5e-3', 'SELECT * FROM data WHERE value = ?'], - ['SELECT * FROM data WHERE value = 2.5E+10', 'SELECT * FROM data WHERE value = ?'], - ['SELECT * FROM data WHERE value = -1e10', 'SELECT * FROM data WHERE value = ?'], - ['SELECT * FROM users LIMIT 10 OFFSET 20', 'SELECT * FROM users LIMIT ? OFFSET ?'], - ])('sanitizes number: %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - - it('preserves numbers in identifiers', () => { - expect(sanitize('SELECT * FROM users2 WHERE col1 = 5')).toBe('SELECT * FROM users2 WHERE col1 = ?'); - expect(sanitize('SELECT * FROM "table1" WHERE "col2" = 5')).toBe('SELECT * FROM "table1" WHERE "col2" = ?'); - }); - }); - - describe('hex and binary literal sanitization', () => { - it.each([ - ["SELECT * FROM t WHERE data = X'1A2B'", 'SELECT * FROM t WHERE data = ?'], - ["SELECT * FROM t WHERE data = x'ff'", 'SELECT * FROM t WHERE data = ?'], - ["SELECT * FROM t WHERE data = X''", 'SELECT * FROM t WHERE data = ?'], - ['SELECT * FROM t WHERE flags = 0x1A2B', 'SELECT * FROM t WHERE flags = ?'], - ['SELECT * FROM t WHERE flags = 0XFF', 'SELECT * FROM t WHERE flags = ?'], - ["SELECT * FROM t WHERE bits = B'1010'", 'SELECT * FROM t WHERE bits = ?'], - ["SELECT * FROM t WHERE bits = b'1111'", 'SELECT * FROM t WHERE bits = ?'], - ["SELECT * FROM t WHERE bits = B''", 'SELECT * FROM t WHERE bits = ?'], - ])('sanitizes hex/binary: %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('boolean literal sanitization', () => { - it.each([ - ['SELECT * FROM users WHERE active = TRUE', 'SELECT * FROM users WHERE active = ?'], - ['SELECT * FROM users WHERE active = FALSE', 'SELECT * FROM users WHERE active = ?'], - ['SELECT * FROM users WHERE a = true AND b = false', 'SELECT * FROM users WHERE a = ? AND b = ?'], - ['SELECT * FROM users WHERE a = True AND b = False', 'SELECT * FROM users WHERE a = ? AND b = ?'], - ])('sanitizes boolean: %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - - it('does not affect identifiers containing TRUE/FALSE', () => { - expect(sanitize('SELECT TRUE_FLAG FROM users WHERE active = TRUE')).toBe( - 'SELECT TRUE_FLAG FROM users WHERE active = ?', - ); - }); - }); - - describe('IN clause collapsing', () => { - it.each([ - ['SELECT * FROM users WHERE id IN (?, ?, ?)', 'SELECT * FROM users WHERE id IN (?)'], - ['SELECT * FROM users WHERE id IN ($1, $2, $3)', 'SELECT * FROM users WHERE id IN ($?)'], - ['SELECT * FROM users WHERE id in ($1, $2)', 'SELECT * FROM users WHERE id IN ($?)'], - ['SELECT * FROM users WHERE id IN ( $1 , $2 , $3 )', 'SELECT * FROM users WHERE id IN ($?)'], - [ - 'SELECT * FROM users WHERE id IN ($1, $2) AND status IN ($3, $4)', - 'SELECT * FROM users WHERE id IN ($?) AND status IN ($?)', - ], - ['SELECT * FROM users WHERE id NOT IN ($1, $2)', 'SELECT * FROM users WHERE id NOT IN ($?)'], - ['SELECT * FROM users WHERE id NOT IN (?, ?)', 'SELECT * FROM users WHERE id NOT IN (?)'], - ['SELECT * FROM users WHERE id IN ($1)', 'SELECT * FROM users WHERE id IN ($?)'], - ['SELECT * FROM users WHERE id IN (1, 2, 3)', 'SELECT * FROM users WHERE id IN (?)'], - ])('collapses IN clause: %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('mixed scenarios (params + literals)', () => { - it.each([ - ["SELECT * FROM users WHERE id = $1 AND status = 'active'", 'SELECT * FROM users WHERE id = $1 AND status = ?'], - ['SELECT * FROM users WHERE id = $1 AND limit = 100', 'SELECT * FROM users WHERE id = $1 AND limit = ?'], - [ - "SELECT * FROM t WHERE a = $1 AND b = 'foo' AND c = 123 AND d = TRUE AND e IN ($2, $3)", - 'SELECT * FROM t WHERE a = $1 AND b = ? AND c = ? AND d = ? AND e IN ($?)', - ], - ])('handles mixed: %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('PostgreSQL-specific syntax', () => { - it.each([ - ['SELECT $1::integer', 'SELECT $1::integer'], - ['SELECT $1::text', 'SELECT $1::text'], - ['SELECT * FROM t WHERE tags = ARRAY[1, 2, 3]', 'SELECT * FROM t WHERE tags = ARRAY[?, ?, ?]'], - ['SELECT * FROM t WHERE tags = ARRAY[$1, $2]', 'SELECT * FROM t WHERE tags = ARRAY[$1, $2]'], - ["SELECT data->'key' FROM t WHERE id = $1", 'SELECT data->? FROM t WHERE id = $1'], - ["SELECT data->>'key' FROM t WHERE id = $1", 'SELECT data->>? FROM t WHERE id = $1'], - ["SELECT * FROM t WHERE data @> '{}'", 'SELECT * FROM t WHERE data @> ?'], - [ - "SELECT * FROM t WHERE created_at > NOW() - INTERVAL '7 days'", - 'SELECT * FROM t WHERE created_at > NOW() - INTERVAL ?', - ], - ['CREATE TABLE t (created_at TIMESTAMP(3))', 'CREATE TABLE t (created_at TIMESTAMP(?))'], - ['CREATE TABLE t (price NUMERIC(10, 2))', 'CREATE TABLE t (price NUMERIC(?, ?))'], - ])('handles PostgreSQL syntax: %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('empty/undefined input', () => { - it.each([ - [undefined, 'Unknown SQL Query'], - ['', 'Unknown SQL Query'], - [' ', ''], - [' \n\t ', ''], - ])('handles empty input %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('complex real-world queries', () => { - it('handles query with comments, whitespace, and IN clause', () => { - const input = ` - SELECT * FROM users -- fetch all users - WHERE id = $1 - AND status IN ($2, $3, $4); - `; - expect(sanitize(input)).toBe('SELECT * FROM users WHERE id = $1 AND status IN ($?)'); - }); - - it('handles Prisma-style query', () => { - const input = ` - SELECT "User"."id", "User"."email", "User"."name" - FROM "User" - WHERE "User"."email" = $1 - AND "User"."deleted_at" IS NULL - LIMIT $2; - `; - expect(sanitize(input)).toBe( - 'SELECT "User"."id", "User"."email", "User"."name" FROM "User" WHERE "User"."email" = $1 AND "User"."deleted_at" IS NULL LIMIT $2', - ); - }); - - it('handles CREATE TABLE with various types', () => { - const input = ` - CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "email" TEXT NOT NULL, - "balance" NUMERIC(10, 2) DEFAULT 0.00, - CONSTRAINT "User_pkey" PRIMARY KEY ("id") - ); - `; - expect(sanitize(input)).toBe( - 'CREATE TABLE "User" ( "id" SERIAL NOT NULL, "createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP, "email" TEXT NOT NULL, "balance" NUMERIC(?, ?) DEFAULT ?, CONSTRAINT "User_pkey" PRIMARY KEY ("id") )', - ); - }); - - it('handles INSERT/UPDATE with mixed literals and params', () => { - expect(sanitize("INSERT INTO users (name, age, active) VALUES ('John', 30, TRUE)")).toBe( - 'INSERT INTO users (name, age, active) VALUES (?, ?, ?)', - ); - expect(sanitize("UPDATE users SET name = $1, updated_at = '2024-01-01' WHERE id = 123")).toBe( - 'UPDATE users SET name = $1, updated_at = ? WHERE id = ?', - ); - }); - }); - - describe('edge cases', () => { - it.each([ - ['SELECT * FROM "my-table" WHERE "my-column" = $1', 'SELECT * FROM "my-table" WHERE "my-column" = $1'], - ['SELECT * FROM t WHERE big_id = 99999999999999999999', 'SELECT * FROM t WHERE big_id = ?'], - ['SELECT * FROM t WHERE val > -5', 'SELECT * FROM t WHERE val > ?'], - ['SELECT * FROM t WHERE id IN (1, -2, 3)', 'SELECT * FROM t WHERE id IN (?)'], - ['SELECT 1+2*3', 'SELECT ?+?*?'], - ["SELECT * FROM users WHERE name LIKE '%john%'", 'SELECT * FROM users WHERE name LIKE ?'], - ['SELECT * FROM t WHERE age BETWEEN 18 AND 65', 'SELECT * FROM t WHERE age BETWEEN ? AND ?'], - ['SELECT * FROM t WHERE age BETWEEN $1 AND $2', 'SELECT * FROM t WHERE age BETWEEN $1 AND $2'], - [ - "SELECT CASE WHEN status = 'active' THEN 1 ELSE 0 END FROM users", - 'SELECT CASE WHEN status = ? THEN ? ELSE ? END FROM users', - ], - [ - 'SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 100)', - 'SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > ?)', - ], - [ - "WITH cte AS (SELECT * FROM users WHERE status = 'active') SELECT * FROM cte WHERE id = $1", - 'WITH cte AS (SELECT * FROM users WHERE status = ?) SELECT * FROM cte WHERE id = $1', - ], - [ - 'SELECT COUNT(*), SUM(amount), AVG(price) FROM orders WHERE status = $1', - 'SELECT COUNT(*), SUM(amount), AVG(price) FROM orders WHERE status = $1', - ], - [ - 'SELECT status, COUNT(*) FROM orders GROUP BY status HAVING COUNT(*) > 10', - 'SELECT status, COUNT(*) FROM orders GROUP BY status HAVING COUNT(*) > ?', - ], - [ - 'SELECT ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) FROM orders', - 'SELECT ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) FROM orders', - ], - ])('handles edge case: %p', (input, expected) => { - expect(sanitize(input)).toBe(expected); - }); - }); - - describe('regression tests', () => { - it('does not replace $n with ? (OTEL compliance)', () => { - const result = sanitize('SELECT * FROM users WHERE id = $1'); - expect(result).not.toContain('?'); - expect(result).toBe('SELECT * FROM users WHERE id = $1'); - }); - - it('does not split decimal numbers into ?.?', () => { - const result = sanitize('SELECT * FROM t WHERE price = 19.99'); - expect(result).not.toBe('SELECT * FROM t WHERE price = ?.?'); - expect(result).toBe('SELECT * FROM t WHERE price = ?'); - }); - - it('does not leave minus sign when sanitizing negative numbers', () => { - const result = sanitize('SELECT * FROM t WHERE val = -500'); - expect(result).not.toBe('SELECT * FROM t WHERE val = -?'); - expect(result).toBe('SELECT * FROM t WHERE val = ?'); - }); - - it('handles exact queries from integration tests', () => { - expect( - sanitize( - 'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))', - ), - ).toBe( - 'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))', - ); - expect(sanitize('SELECT * from generate_series(1,1000) as x')).toBe('SELECT * from generate_series(?,?) as x'); - }); - }); - }); -});