From 231cb3bb99860eaeeacacf78c46be0fbe9bf58d9 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Aug 2026 15:32:19 +0200 Subject: [PATCH 1/2] fix(core): Track quote state when sanitizing SQL literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_sanitizeSqlQuery` stripped string literals with a single regex over `'...'`, which misses two shapes MySQL produces. Double-quoted values are string literals in MySQL unless `ANSI_QUOTES` is set, and backslashes escape the next character unless `NO_BACKSLASH_ESCAPES` is set — and `mysql`/`mysql2` escape inlined values with backslashes, so `WHERE name = ?` with `O'Brien` arrives as `'O\'Brien'`. In both cases the value survived into `db.query.text`, and `getSqlQuerySummary` then read any `from`/`join` inside it as a table name, putting it in `db.query.summary` and — with span streaming — in the span name. Replace the literal and comment regexes with a single scanning pass, so quote state and comment state are no longer decided independently: `--` inside a literal no longer truncates it (which also leaked, in every dialect), and a literal's `X`/`B`/`E` prefix collapses into the same `?`. Quoted identifiers are still preserved, since the summary is built from them. The dialect is a parameter because the same characters mean different things per driver: `"` quotes identifiers in PostgreSQL and SQLite, and backslash is literal there. PostgreSQL dollar-quoted strings (`$$...$$`) are still unhandled — telling them apart from `$n` placeholders is ambiguous, and existing behavior for `$1$2$3` is pinned by tests. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/integrations/postgresjs.ts | 122 ++++++++++++++++-- .../test/lib/integrations/postgresjs.test.ts | 48 +++++++ .../server-utils/src/integrations/mysql.ts | 2 +- .../src/integrations/mysql2/index.ts | 4 +- .../mysql2/mysql2-dc-subscriber.ts | 2 +- .../mysql2/mysql2-dc-subscriber.test.ts | 33 +++++ 6 files changed, 198 insertions(+), 13 deletions(-) diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index 666d9e462b1d..c55462325255 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -368,6 +368,111 @@ export function _reconstructQuery(strings: string[] | undefined): string | undef let integerLiteralRE: RegExp | undefined; +/** + * SQL dialect variants that matter for finding the end of a string literal: + * - `standard` (PostgreSQL, SQLite): `"` quotes identifiers and `''` is the only in-string escape. + * - `mysql`: `"` quotes a string literal unless `ANSI_QUOTES` is set, and `\` escapes the next + * character unless `NO_BACKSLASH_ESCAPES` is set. Both default to off, and mysql/mysql2 escape + * inlined values with backslashes, so this is the mode their statements arrive in. + */ +export type SqlDialect = 'standard' | 'mysql'; + +/** + * Returns the index just past the run's closing `delimiter`, or the end of the query if the run is + * never closed — an unterminated literal must swallow the remainder rather than let it through. + * + * A doubled delimiter (`''`) escapes itself in every dialect; backslash escapes are dialect- and + * context-dependent, so the caller decides. + */ +function findQuotedRunEnd(sql: string, start: number, delimiter: string, backslashEscapes: boolean): number { + for (let i = start + 1; i < sql.length; i++) { + const char = sql[i]; + if (backslashEscapes && char === '\\') { + i++; + } else if (char === delimiter) { + if (sql[i + 1] !== delimiter) { + return i + 1; + } + i++; + } + } + return sql.length; +} + +/** + * Replaces every string literal with `?` and drops every comment, in one pass. + * + * Doing this by scanning rather than by regex is what keeps quote state and comment state from + * being decided independently: a regex for `'...'` cannot see that the quote it stopped at was + * backslash-escaped, and a regex for `--...` cannot see that the `--` sits inside a literal. Both + * mistakes end with user data surviving into `db.query.text` and `db.query.summary`. + * + * Quoted identifiers are preserved — they are the table and column names the query summary is + * built from. + */ +function stripLiteralsAndComments(sql: string, dialect: SqlDialect): string { + const isMysql = dialect === 'mysql'; + let out = ''; + let i = 0; + + while (i < sql.length) { + const char = sql[i]!; + const next = sql[i + 1]; + + if ((char === '-' && next === '-') || (isMysql && char === '#')) { + const lineEnd = sql.indexOf('\n', i); + i = lineEnd === -1 ? sql.length : lineEnd; + continue; + } + + if (char === '/' && next === '*') { + const commentEnd = sql.indexOf('*/', i + 2); + i = commentEnd === -1 ? sql.length : commentEnd + 2; + continue; + } + + // Quoted identifiers: backticks in MySQL, double quotes everywhere else + if (char === '`' || (char === '"' && !isMysql)) { + const runEnd = findQuotedRunEnd(sql, i, char, false); + out += sql.slice(i, runEnd); + i = runEnd; + continue; + } + + if (char === "'" || (char === '"' && isMysql)) { + // A prefix like `X'1A'`, `B'01'` or PostgreSQL's `E'a\nb'` is part of the literal, so it has + // to collapse into the same `?` instead of being left behind as a bare identifier. + const prefix = char === "'" ? getLiteralPrefix(out, isMysql) : undefined; + out = prefix ? out.slice(0, -1) : out; + i = findQuotedRunEnd(sql, i, char, isMysql || prefix === 'E'); + out += '?'; + continue; + } + + out += char; + i++; + } + + return out; +} + +/** + * Returns the literal-prefix character immediately before a `'`, if there is one: `X`/`B` for + * hex/binary literals, or `E` for a PostgreSQL escape string (which honors backslash escapes). + */ +function getLiteralPrefix(out: string, isMysql: boolean): 'X' | 'B' | 'E' | undefined { + // A prefix only counts when it stands alone — the `X` in `MAX'...'` belongs to the identifier + if (/[\w$]/.test(out.slice(-2, -1))) { + return undefined; + } + + const prefix = out.slice(-1).toUpperCase(); + if (prefix === 'X' || prefix === 'B') { + return prefix; + } + return prefix === 'E' && !isMysql ? 'E' : undefined; +} + /** * Sanitize SQL query as per the OTEL semantic conventions * https://opentelemetry.io/docs/specs/semconv/database/database-spans/#sanitization-of-dbquerytext @@ -375,9 +480,12 @@ let integerLiteralRE: RegExp | undefined; * PostgreSQL $n placeholders are preserved per OTEL spec - they're parameterized queries, * not sensitive literals. Only actual values (strings, numbers, booleans) are sanitized. * + * Pass `dialect` when the statement comes from a driver whose literals are not standard-quoted; + * see {@link SqlDialect}. + * * @internal Exported for testing only */ -export function _sanitizeSqlQuery(sqlQuery: string | undefined): string { +export function _sanitizeSqlQuery(sqlQuery: string | undefined, dialect: SqlDialect = 'standard'): string { if (!sqlQuery) { return 'Unknown SQL Query'; } @@ -390,19 +498,13 @@ export function _sanitizeSqlQuery(sqlQuery: string | undefined): string { } 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 + // Strip comments and string literals first: everything below is a regex that cannot tell + // whether it is looking at SQL syntax or at a user-supplied value. + stripLiteralsAndComments(sqlQuery, dialect) .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 diff --git a/packages/core/test/lib/integrations/postgresjs.test.ts b/packages/core/test/lib/integrations/postgresjs.test.ts index 6542a1d22358..ac31c262cfdc 100644 --- a/packages/core/test/lib/integrations/postgresjs.test.ts +++ b/packages/core/test/lib/integrations/postgresjs.test.ts @@ -399,6 +399,54 @@ describe('PostgresJs portable instrumentation', () => { 'SELECT * from generate_series(?,?) as x', ); }); + + it('does not let comment syntax inside a literal cut the literal short', () => { + expect(_sanitizeSqlQuery("SELECT * FROM t WHERE a = 'from secret--x'")).toBe('SELECT * FROM t WHERE a = ?'); + expect(_sanitizeSqlQuery("SELECT * FROM t WHERE a = 'from secret/*x*/'")).toBe('SELECT * FROM t WHERE a = ?'); + }); + + it('honors backslash escapes in PostgreSQL escape strings', () => { + expect(_sanitizeSqlQuery(String.raw`SELECT * FROM t WHERE a = E'it\'s from secret' AND b = 1`)).toBe( + 'SELECT * FROM t WHERE a = ? AND b = ?', + ); + }); + }); + + describe("dialect: 'mysql'", () => { + it.each([ + // MySQL reads `"..."` as a string literal, not as an identifier, unless ANSI_QUOTES is set + ['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` = "John"', 'SELECT * FROM `users` WHERE `name` = ?'], + ['SELECT * FROM t WHERE a = "x" # trailing comment', 'SELECT * FROM t WHERE a = ?'], + // backslash escapes — the shape mysql/mysql2 emit when they inline a value + [String.raw`SELECT * FROM users WHERE name = 'O\'Brien'`, 'SELECT * FROM users WHERE name = ?'], + [String.raw`SELECT * FROM users WHERE bio = 'a \"quote\" here'`, 'SELECT * FROM users WHERE bio = ?'], + [String.raw`SELECT * FROM t WHERE a = 'x\\' AND b = 'y'`, 'SELECT * FROM t WHERE a = ? AND b = ?'], + ])('sanitizes %p', (input, expected) => { + expect(_sanitizeSqlQuery(input, 'mysql')).toBe(expected); + }); + + it('keeps a quote inside a backticked identifier from opening a literal', () => { + expect(_sanitizeSqlQuery("SELECT `it's` FROM t WHERE a = 'x'", 'mysql')).toBe( + "SELECT `it's` FROM t WHERE a = ?", + ); + }); + }); + + describe('regression: values must not survive as summary targets', () => { + // A literal that survives sanitization and happens to contain `from`/`join`/`select` is read + // as a table name by getSqlQuerySummary, which puts it in `db.query.summary` and — with span + // streaming — in the span name. + it.each([ + ['SELECT * FROM users WHERE name = "from bob@secret.com"', 'bob@secret.com'], + ['SELECT * FROM users WHERE bio = "i come from Berlin and join clubs"', 'Berlin'], + ['INSERT INTO t (c) VALUES ("select from s3cret-token")', 's3cret-token'], + [String.raw`SELECT * FROM users WHERE name = 'O\'Brien from ACME'`, 'ACME'], + [String.raw`UPDATE t SET a = 'x\'y from Z' WHERE id = 5`, 'from Z'], + ])('strips the value out of %p', (input, value) => { + expect(_sanitizeSqlQuery(input, 'mysql')).not.toContain(value); + }); }); }); diff --git a/packages/server-utils/src/integrations/mysql.ts b/packages/server-utils/src/integrations/mysql.ts index ede932d84ba4..b3e254b0276c 100644 --- a/packages/server-utils/src/integrations/mysql.ts +++ b/packages/server-utils/src/integrations/mysql.ts @@ -87,7 +87,7 @@ function instrumentMysql(): void { // handler with the caller's context lost. `deferSpanEnd` replays this scope onto the emitter. data._sentryCallerScope = getCurrentScope(); - const querySummary = sql ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(sql)) : undefined; + const querySummary = sql ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(sql, 'mysql')) : undefined; const client = getClient(); const name = diff --git a/packages/server-utils/src/integrations/mysql2/index.ts b/packages/server-utils/src/integrations/mysql2/index.ts index 559de1986a12..856331ff0ded 100644 --- a/packages/server-utils/src/integrations/mysql2/index.ts +++ b/packages/server-utils/src/integrations/mysql2/index.ts @@ -84,7 +84,9 @@ function subscribeQueryChannel(channelName: ChannelName): void { data => { const statement = getQueryText(data.arguments); const connectionAttributes = getConnectionAttributes(data.self?.config); - const querySummary = statement ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(statement)) : undefined; + const querySummary = statement + ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(statement, 'mysql')) + : undefined; const client = getClient(); const name = diff --git a/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts b/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts index 88b0a4b4d7ff..266fef106002 100644 --- a/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts @@ -102,7 +102,7 @@ function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelN // mysql2 does not sanitize its channel payload, so the statement may carry // raw user values (on the `query` channel they are inlined). Strip every // literal before it leaves the process; `values` is never attached. - const queryText = data.query ? _INTERNAL_sanitizeSqlQuery(data.query) : undefined; + const queryText = data.query ? _INTERNAL_sanitizeSqlQuery(data.query, 'mysql') : undefined; const operation = queryText?.match(SQL_OPERATION_RE)?.[1]?.toUpperCase(); const querySummary = _INTERNAL_getSqlQuerySummary(queryText); diff --git a/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts b/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts index 3a489ba00999..9eea2d8b03b8 100644 --- a/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts +++ b/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts @@ -225,6 +225,39 @@ describe('subscribeMysql2DiagnosticChannels', () => { expect(json.name).toBe('SELECT * FROM users WHERE email = ? AND age = ?'); }); + it('sanitizes backslash-escaped values, which is how mysql2 inlines them', async () => { + initTestClient('stream'); + + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_QUERY, + // `sqlstring` escapes `'` as `\'`, so this is what the channel publishes for + // `WHERE name = ?` with the value `O'Brien from ACME` + { query: String.raw`SELECT * FROM users WHERE name = 'O\'Brien from ACME'` }, + { result: [] }, + ); + + const json = spanToJSON(span!); + expect(json.attributes['db.query.text']).toBe('SELECT * FROM users WHERE name = ?'); + expect(json.attributes['db.query.summary']).toBe('SELECT users'); + // `from ACME'` would otherwise read as a second table and land in the name + expect(json.name).toBe('SELECT users'); + }); + + it('sanitizes double-quoted values, which MySQL reads as string literals', async () => { + initTestClient('stream'); + + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_QUERY, + { query: 'SELECT * FROM users WHERE bio = "i come from Berlin and join clubs"' }, + { result: [] }, + ); + + const json = spanToJSON(span!); + expect(json.attributes['db.query.text']).toBe('SELECT * FROM users WHERE bio = ?'); + expect(json.attributes['db.query.summary']).toBe('SELECT users'); + expect(json.name).toBe('SELECT users'); + }); + it('names the span after the query summary with span streaming enabled', async () => { initTestClient('stream'); From 6624b07c89e21cd83414fec3923efa4eaf8081c1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Aug 2026 16:42:56 +0200 Subject: [PATCH 2/2] ref(core): Move SQL sanitization to `utils/sql` `_sanitizeSqlQuery` lived in the postgres.js integration, but it is dialect-generic and now serves the mysql, mysql2, postgres, postgres.js and Cloudflare D1 paths. Move it next to `getSqlQuerySummary`, which is its only downstream consumer and already lives there, so the sanitize-then-summarize pipeline reads as one unit and D1 no longer reaches into a postgres integration module for it. Rename `_sanitizeSqlQuery` to `sanitizeSqlQuery` to match its neighbour; the public `_INTERNAL_sanitizeSqlQuery` alias is unchanged, so nothing outside core moves. Tests move with it, and the leak regressions can now assert the summary directly instead of only the sanitized statement. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/integrations/postgresjs.ts | 158 +------- packages/core/src/server-exports.ts | 7 +- packages/core/src/utils/sql.ts | 152 +++++++ .../test/lib/integrations/postgresjs.test.ts | 376 +----------------- packages/core/test/lib/utils/sql.test.ts | 365 ++++++++++++++++- 5 files changed, 530 insertions(+), 528 deletions(-) diff --git a/packages/core/src/integrations/postgresjs.ts b/packages/core/src/integrations/postgresjs.ts index c55462325255..49bb7ff38cc5 100644 --- a/packages/core/src/integrations/postgresjs.ts +++ b/packages/core/src/integrations/postgresjs.ts @@ -9,7 +9,7 @@ import { SPAN_STATUS_ERROR } from '../tracing'; import { hasSpanStreamingEnabled } from '../tracing/spans/hasSpanStreamingEnabled'; import { startSpanManual } from '../tracing/trace'; import type { Span, SpanAttributes } from '../types/span'; -import { getSqlQuerySummary } from '../utils/sql'; +import { getSqlQuerySummary, sanitizeSqlQuery } from '../utils/sql'; import { debug } from '../utils/debug-logger'; import { isObjectLike } from '../utils/is'; import { getActiveSpan } from '../utils/spanUtils'; @@ -242,7 +242,7 @@ function _wrapSingleQueryHandle( } const fullQuery = _reconstructQuery(query.strings); - const sanitizedSqlQuery = _sanitizeSqlQuery(fullQuery); + const sanitizedSqlQuery = sanitizeSqlQuery(fullQuery); const client = getClient(); const querySummary = getSqlQuerySummary(sanitizedSqlQuery); @@ -366,160 +366,6 @@ export function _reconstructQuery(strings: string[] | undefined): string | undef return strings.reduce((acc, str, i) => (i === 0 ? str : `${acc}$${i}${str}`), ''); } -let integerLiteralRE: RegExp | undefined; - -/** - * SQL dialect variants that matter for finding the end of a string literal: - * - `standard` (PostgreSQL, SQLite): `"` quotes identifiers and `''` is the only in-string escape. - * - `mysql`: `"` quotes a string literal unless `ANSI_QUOTES` is set, and `\` escapes the next - * character unless `NO_BACKSLASH_ESCAPES` is set. Both default to off, and mysql/mysql2 escape - * inlined values with backslashes, so this is the mode their statements arrive in. - */ -export type SqlDialect = 'standard' | 'mysql'; - -/** - * Returns the index just past the run's closing `delimiter`, or the end of the query if the run is - * never closed — an unterminated literal must swallow the remainder rather than let it through. - * - * A doubled delimiter (`''`) escapes itself in every dialect; backslash escapes are dialect- and - * context-dependent, so the caller decides. - */ -function findQuotedRunEnd(sql: string, start: number, delimiter: string, backslashEscapes: boolean): number { - for (let i = start + 1; i < sql.length; i++) { - const char = sql[i]; - if (backslashEscapes && char === '\\') { - i++; - } else if (char === delimiter) { - if (sql[i + 1] !== delimiter) { - return i + 1; - } - i++; - } - } - return sql.length; -} - -/** - * Replaces every string literal with `?` and drops every comment, in one pass. - * - * Doing this by scanning rather than by regex is what keeps quote state and comment state from - * being decided independently: a regex for `'...'` cannot see that the quote it stopped at was - * backslash-escaped, and a regex for `--...` cannot see that the `--` sits inside a literal. Both - * mistakes end with user data surviving into `db.query.text` and `db.query.summary`. - * - * Quoted identifiers are preserved — they are the table and column names the query summary is - * built from. - */ -function stripLiteralsAndComments(sql: string, dialect: SqlDialect): string { - const isMysql = dialect === 'mysql'; - let out = ''; - let i = 0; - - while (i < sql.length) { - const char = sql[i]!; - const next = sql[i + 1]; - - if ((char === '-' && next === '-') || (isMysql && char === '#')) { - const lineEnd = sql.indexOf('\n', i); - i = lineEnd === -1 ? sql.length : lineEnd; - continue; - } - - if (char === '/' && next === '*') { - const commentEnd = sql.indexOf('*/', i + 2); - i = commentEnd === -1 ? sql.length : commentEnd + 2; - continue; - } - - // Quoted identifiers: backticks in MySQL, double quotes everywhere else - if (char === '`' || (char === '"' && !isMysql)) { - const runEnd = findQuotedRunEnd(sql, i, char, false); - out += sql.slice(i, runEnd); - i = runEnd; - continue; - } - - if (char === "'" || (char === '"' && isMysql)) { - // A prefix like `X'1A'`, `B'01'` or PostgreSQL's `E'a\nb'` is part of the literal, so it has - // to collapse into the same `?` instead of being left behind as a bare identifier. - const prefix = char === "'" ? getLiteralPrefix(out, isMysql) : undefined; - out = prefix ? out.slice(0, -1) : out; - i = findQuotedRunEnd(sql, i, char, isMysql || prefix === 'E'); - out += '?'; - continue; - } - - out += char; - i++; - } - - return out; -} - -/** - * Returns the literal-prefix character immediately before a `'`, if there is one: `X`/`B` for - * hex/binary literals, or `E` for a PostgreSQL escape string (which honors backslash escapes). - */ -function getLiteralPrefix(out: string, isMysql: boolean): 'X' | 'B' | 'E' | undefined { - // A prefix only counts when it stands alone — the `X` in `MAX'...'` belongs to the identifier - if (/[\w$]/.test(out.slice(-2, -1))) { - return undefined; - } - - const prefix = out.slice(-1).toUpperCase(); - if (prefix === 'X' || prefix === 'B') { - return prefix; - } - return prefix === 'E' && !isMysql ? 'E' : undefined; -} - -/** - * 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. - * - * Pass `dialect` when the statement comes from a driver whose literals are not standard-quoted; - * see {@link SqlDialect}. - * - * @internal Exported for testing only - */ -export function _sanitizeSqlQuery(sqlQuery: string | undefined, dialect: SqlDialect = 'standard'): string { - if (!sqlQuery) { - return 'Unknown SQL Query'; - } - - // Lazy init: constructing this at module scope would evaluate the lookbehind - // on import and crash Safari <16.4 browser bundles that reach this file via - // the core barrel. Building it on first call keeps the cost off the import path. - if (!integerLiteralRE) { - integerLiteralRE = new RegExp('(? 0 ? truncated.substring(0, lastSpace) : truncated; } + +let integerLiteralRE: RegExp | undefined; + +/** + * SQL dialect variants that matter for finding the end of a string literal: + * - `standard` (PostgreSQL, SQLite): `"` quotes identifiers and `''` is the only in-string escape. + * - `mysql`: `"` quotes a string literal unless `ANSI_QUOTES` is set, and `\` escapes the next + * character unless `NO_BACKSLASH_ESCAPES` is set. Both default to off, and mysql/mysql2 escape + * inlined values with backslashes, so this is the mode their statements arrive in. + */ +export type SqlDialect = 'standard' | 'mysql'; + +/** + * Returns the index just past the run's closing `delimiter`, or the end of the query if the run is + * never closed — an unterminated literal must swallow the remainder rather than let it through. + * + * A doubled delimiter (`''`) escapes itself in every dialect; backslash escapes are dialect- and + * context-dependent, so the caller decides. + */ +function findQuotedRunEnd(sql: string, start: number, delimiter: string, backslashEscapes: boolean): number { + for (let i = start + 1; i < sql.length; i++) { + const char = sql[i]; + if (backslashEscapes && char === '\\') { + i++; + } else if (char === delimiter) { + if (sql[i + 1] !== delimiter) { + return i + 1; + } + i++; + } + } + return sql.length; +} + +/** + * Replaces every string literal with `?` and drops every comment, in one pass. + * + * Doing this by scanning rather than by regex is what keeps quote state and comment state from + * being decided independently: a regex for `'...'` cannot see that the quote it stopped at was + * backslash-escaped, and a regex for `--...` cannot see that the `--` sits inside a literal. Both + * mistakes end with user data surviving into `db.query.text` and `db.query.summary`. + * + * Quoted identifiers are preserved — they are the table and column names the query summary is + * built from. + */ +function stripLiteralsAndComments(sql: string, dialect: SqlDialect): string { + const isMysql = dialect === 'mysql'; + let out = ''; + let i = 0; + + while (i < sql.length) { + const char = sql[i]!; + const next = sql[i + 1]; + + if ((char === '-' && next === '-') || (isMysql && char === '#')) { + const lineEnd = sql.indexOf('\n', i); + i = lineEnd === -1 ? sql.length : lineEnd; + continue; + } + + if (char === '/' && next === '*') { + const commentEnd = sql.indexOf('*/', i + 2); + i = commentEnd === -1 ? sql.length : commentEnd + 2; + continue; + } + + // Quoted identifiers: backticks in MySQL, double quotes everywhere else + if (char === '`' || (char === '"' && !isMysql)) { + const runEnd = findQuotedRunEnd(sql, i, char, false); + out += sql.slice(i, runEnd); + i = runEnd; + continue; + } + + if (char === "'" || (char === '"' && isMysql)) { + // A prefix like `X'1A'`, `B'01'` or PostgreSQL's `E'a\nb'` is part of the literal, so it has + // to collapse into the same `?` instead of being left behind as a bare identifier. + const prefix = char === "'" ? getLiteralPrefix(out, isMysql) : undefined; + out = prefix ? out.slice(0, -1) : out; + i = findQuotedRunEnd(sql, i, char, isMysql || prefix === 'E'); + out += '?'; + continue; + } + + out += char; + i++; + } + + return out; +} + +/** + * Returns the literal-prefix character immediately before a `'`, if there is one: `X`/`B` for + * hex/binary literals, or `E` for a PostgreSQL escape string (which honors backslash escapes). + */ +function getLiteralPrefix(out: string, isMysql: boolean): 'X' | 'B' | 'E' | undefined { + // A prefix only counts when it stands alone — the `X` in `MAX'...'` belongs to the identifier + if (/[\w$]/.test(out.slice(-2, -1))) { + return undefined; + } + + const prefix = out.slice(-1).toUpperCase(); + if (prefix === 'X' || prefix === 'B') { + return prefix; + } + return prefix === 'E' && !isMysql ? 'E' : undefined; +} + +/** + * 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. + * + * Pass `dialect` when the statement comes from a driver whose literals are not standard-quoted; + * see {@link SqlDialect}. + */ +export function sanitizeSqlQuery(sqlQuery: string | undefined, dialect: SqlDialect = 'standard'): string { + if (!sqlQuery) { + return 'Unknown SQL Query'; + } + + // Lazy init: constructing this at module scope would evaluate the lookbehind + // on import and crash Safari <16.4 browser bundles that reach this file via + // the core barrel. Building it on first call keeps the cost off the import path. + if (!integerLiteralRE) { + integerLiteralRE = new RegExp('(? { }); }); - describe('integration with _sanitizeSqlQuery', () => { + describe('integration with sanitizeSqlQuery', () => { it('preserves $n placeholders per OTEL spec', () => { const strings = ['SELECT * FROM users WHERE id = ', ' AND name = ', '']; - expect(_sanitizeSqlQuery(_reconstructQuery(strings))).toBe('SELECT * FROM users WHERE id = $1 AND name = $2'); + expect(sanitizeSqlQuery(_reconstructQuery(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(_sanitizeSqlQuery(_reconstructQuery(strings))).toBe( + expect(sanitizeSqlQuery(_reconstructQuery(strings))).toBe( 'SELECT * FROM users WHERE id = $1 AND status IN ($?)', ); }); it('returns Unknown SQL Query for undefined input', () => { - expect(_sanitizeSqlQuery(_reconstructQuery(undefined))).toBe('Unknown SQL Query'); + expect(sanitizeSqlQuery(_reconstructQuery(undefined))).toBe('Unknown SQL Query'); }); it('normalizes whitespace and removes trailing semicolon', () => { const strings = ['SELECT *\n FROM users\n WHERE id = ', ';']; - expect(_sanitizeSqlQuery(_reconstructQuery(strings))).toBe('SELECT * FROM users WHERE id = $1'); - }); - }); - }); - - describe('_sanitizeSqlQuery', () => { - 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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(input)).toBe(expected); - }); - - it('preserves numbers in identifiers', () => { - expect(_sanitizeSqlQuery('SELECT * FROM users2 WHERE col1 = 5')).toBe('SELECT * FROM users2 WHERE col1 = ?'); - expect(_sanitizeSqlQuery('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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(input)).toBe(expected); - }); - - it('does not affect identifiers containing TRUE/FALSE', () => { - expect(_sanitizeSqlQuery('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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(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(_sanitizeSqlQuery(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(_sanitizeSqlQuery("INSERT INTO users (name, age, active) VALUES ('John', 30, TRUE)")).toBe( - 'INSERT INTO users (name, age, active) VALUES (?, ?, ?)', - ); - expect(_sanitizeSqlQuery("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(_sanitizeSqlQuery(input)).toBe(expected); - }); - }); - - describe('regression tests', () => { - it('does not replace $n with ? (OTEL compliance)', () => { - const result = _sanitizeSqlQuery('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 = _sanitizeSqlQuery('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 = _sanitizeSqlQuery('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( - _sanitizeSqlQuery( - '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(_sanitizeSqlQuery('SELECT * from generate_series(1,1000) as x')).toBe( - 'SELECT * from generate_series(?,?) as x', - ); - }); - - it('does not let comment syntax inside a literal cut the literal short', () => { - expect(_sanitizeSqlQuery("SELECT * FROM t WHERE a = 'from secret--x'")).toBe('SELECT * FROM t WHERE a = ?'); - expect(_sanitizeSqlQuery("SELECT * FROM t WHERE a = 'from secret/*x*/'")).toBe('SELECT * FROM t WHERE a = ?'); - }); - - it('honors backslash escapes in PostgreSQL escape strings', () => { - expect(_sanitizeSqlQuery(String.raw`SELECT * FROM t WHERE a = E'it\'s from secret' AND b = 1`)).toBe( - 'SELECT * FROM t WHERE a = ? AND b = ?', - ); - }); - }); - - describe("dialect: 'mysql'", () => { - it.each([ - // MySQL reads `"..."` as a string literal, not as an identifier, unless ANSI_QUOTES is set - ['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` = "John"', 'SELECT * FROM `users` WHERE `name` = ?'], - ['SELECT * FROM t WHERE a = "x" # trailing comment', 'SELECT * FROM t WHERE a = ?'], - // backslash escapes — the shape mysql/mysql2 emit when they inline a value - [String.raw`SELECT * FROM users WHERE name = 'O\'Brien'`, 'SELECT * FROM users WHERE name = ?'], - [String.raw`SELECT * FROM users WHERE bio = 'a \"quote\" here'`, 'SELECT * FROM users WHERE bio = ?'], - [String.raw`SELECT * FROM t WHERE a = 'x\\' AND b = 'y'`, 'SELECT * FROM t WHERE a = ? AND b = ?'], - ])('sanitizes %p', (input, expected) => { - expect(_sanitizeSqlQuery(input, 'mysql')).toBe(expected); - }); - - it('keeps a quote inside a backticked identifier from opening a literal', () => { - expect(_sanitizeSqlQuery("SELECT `it's` FROM t WHERE a = 'x'", 'mysql')).toBe( - "SELECT `it's` FROM t WHERE a = ?", - ); - }); - }); - - describe('regression: values must not survive as summary targets', () => { - // A literal that survives sanitization and happens to contain `from`/`join`/`select` is read - // as a table name by getSqlQuerySummary, which puts it in `db.query.summary` and — with span - // streaming — in the span name. - it.each([ - ['SELECT * FROM users WHERE name = "from bob@secret.com"', 'bob@secret.com'], - ['SELECT * FROM users WHERE bio = "i come from Berlin and join clubs"', 'Berlin'], - ['INSERT INTO t (c) VALUES ("select from s3cret-token")', 's3cret-token'], - [String.raw`SELECT * FROM users WHERE name = 'O\'Brien from ACME'`, 'ACME'], - [String.raw`UPDATE t SET a = 'x\'y from Z' WHERE id = 5`, 'from Z'], - ])('strips the value out of %p', (input, value) => { - expect(_sanitizeSqlQuery(input, 'mysql')).not.toContain(value); + expect(sanitizeSqlQuery(_reconstructQuery(strings))).toBe('SELECT * FROM users WHERE id = $1'); }); }); }); diff --git a/packages/core/test/lib/utils/sql.test.ts b/packages/core/test/lib/utils/sql.test.ts index 7f3c54fc1139..b41463645870 100644 --- a/packages/core/test/lib/utils/sql.test.ts +++ b/packages/core/test/lib/utils/sql.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getSqlQuerySummary } from '../../../src/utils/sql'; +import { getSqlQuerySummary, sanitizeSqlQuery } from '../../../src/utils/sql'; describe('getSqlQuerySummary', () => { it.each([undefined, ''])('returns undefined for %j', input => { @@ -235,3 +235,366 @@ describe('getSqlQuerySummary', () => { expect(getSqlQuerySummary(' ')).toBe(''); }); }); + +describe('sanitizeSqlQuery', () => { + 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(sanitizeSqlQuery(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(sanitizeSqlQuery(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(sanitizeSqlQuery(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(sanitizeSqlQuery(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(sanitizeSqlQuery(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(sanitizeSqlQuery(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(sanitizeSqlQuery(input)).toBe(expected); + }); + + it('preserves numbers in identifiers', () => { + expect(sanitizeSqlQuery('SELECT * FROM users2 WHERE col1 = 5')).toBe('SELECT * FROM users2 WHERE col1 = ?'); + expect(sanitizeSqlQuery('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(sanitizeSqlQuery(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(sanitizeSqlQuery(input)).toBe(expected); + }); + + it('does not affect identifiers containing TRUE/FALSE', () => { + expect(sanitizeSqlQuery('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(sanitizeSqlQuery(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(sanitizeSqlQuery(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(sanitizeSqlQuery(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(sanitizeSqlQuery(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(sanitizeSqlQuery(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(sanitizeSqlQuery(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(sanitizeSqlQuery(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(sanitizeSqlQuery("INSERT INTO users (name, age, active) VALUES ('John', 30, TRUE)")).toBe( + 'INSERT INTO users (name, age, active) VALUES (?, ?, ?)', + ); + expect(sanitizeSqlQuery("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(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + + describe('regression tests', () => { + it('does not replace $n with ? (OTEL compliance)', () => { + const result = sanitizeSqlQuery('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 = sanitizeSqlQuery('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 = sanitizeSqlQuery('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( + sanitizeSqlQuery( + '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(sanitizeSqlQuery('SELECT * from generate_series(1,1000) as x')).toBe( + 'SELECT * from generate_series(?,?) as x', + ); + }); + + it('does not let comment syntax inside a literal cut the literal short', () => { + expect(sanitizeSqlQuery("SELECT * FROM t WHERE a = 'from secret--x'")).toBe('SELECT * FROM t WHERE a = ?'); + expect(sanitizeSqlQuery("SELECT * FROM t WHERE a = 'from secret/*x*/'")).toBe('SELECT * FROM t WHERE a = ?'); + }); + + it('honors backslash escapes in PostgreSQL escape strings', () => { + expect(sanitizeSqlQuery(String.raw`SELECT * FROM t WHERE a = E'it\'s from secret' AND b = 1`)).toBe( + 'SELECT * FROM t WHERE a = ? AND b = ?', + ); + }); + }); + + describe("dialect: 'mysql'", () => { + it.each([ + // MySQL reads `"..."` as a string literal, not as an identifier, unless ANSI_QUOTES is set + ['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` = "John"', 'SELECT * FROM `users` WHERE `name` = ?'], + ['SELECT * FROM t WHERE a = "x" # trailing comment', 'SELECT * FROM t WHERE a = ?'], + // backslash escapes — the shape mysql/mysql2 emit when they inline a value + [String.raw`SELECT * FROM users WHERE name = 'O\'Brien'`, 'SELECT * FROM users WHERE name = ?'], + [String.raw`SELECT * FROM users WHERE bio = 'a \"quote\" here'`, 'SELECT * FROM users WHERE bio = ?'], + [String.raw`SELECT * FROM t WHERE a = 'x\\' AND b = 'y'`, 'SELECT * FROM t WHERE a = ? AND b = ?'], + ])('sanitizes %p', (input, expected) => { + expect(sanitizeSqlQuery(input, 'mysql')).toBe(expected); + }); + + it('keeps a quote inside a backticked identifier from opening a literal', () => { + expect(sanitizeSqlQuery("SELECT `it's` FROM t WHERE a = 'x'", 'mysql')).toBe("SELECT `it's` FROM t WHERE a = ?"); + }); + }); + + describe('regression: values must not survive as summary targets', () => { + // A literal that survives sanitization and happens to contain `from`/`join`/`select` is read + // as a table name by getSqlQuerySummary, which puts it in `db.query.summary` and — with span + // streaming — in the span name. + it.each([ + ['SELECT * FROM users WHERE name = "from bob@secret.com"', 'bob@secret.com'], + ['SELECT * FROM users WHERE bio = "i come from Berlin and join clubs"', 'Berlin'], + ['INSERT INTO t (c) VALUES ("select from s3cret-token")', 's3cret-token'], + [String.raw`SELECT * FROM users WHERE name = 'O\'Brien from ACME'`, 'ACME'], + [String.raw`UPDATE t SET a = 'x\'y from Z' WHERE id = 5`, 'from Z'], + ])('strips the value out of %p', (input, value) => { + const sanitized = sanitizeSqlQuery(input, 'mysql'); + expect(sanitized).not.toContain(value); + expect(getSqlQuerySummary(sanitized)).not.toContain(value); + }); + }); +});