Skip to content

Commit 9beb707

Browse files
committed
fix: replace custom named param regex with named-placeholders package
The custom regex lookbehind could not properly detect parameters inside string literals (e.g. 'email@domain.com' would incorrectly match @Domain). This caused named parameters like @Identifier to silently fail in certain queries, resulting in NULL values being passed to MySQL. Switched to the named-placeholders npm package which uses full quote state tracking with escape handling, matching oxmysql's exact behavior for named parameter conversion.
1 parent 74cfb6a commit 9beb707

3 files changed

Lines changed: 33 additions & 62 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"build": "node build.js"
99
},
1010
"dependencies": {
11-
"mysql2": "^3.11.3"
11+
"mysql2": "^3.11.3",
12+
"named-placeholders": "^1.1.6"
1213
},
1314
"devDependencies": {
1415
"@citizenfx/server": "2.0.5132-1",

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { loadConfig } from './config';
22
import { initializePool, isPoolReady, preparePool } from './core/pool';
3-
import { initCache } from './cache/statementCache';
3+
44
import { initProfiler } from './profiler';
55
import { initLogger, refreshDebug } from './logger';
66
import { executeQuery, executeRaw } from './core/queryEngine';
@@ -13,7 +13,7 @@ import type { CFXCallback, CFXParameters, TransactionQuery } from './types';
1313
const config = loadConfig();
1414

1515
preparePool();
16-
initCache(config.cacheSize);
16+
1717
initProfiler(config.profilerSampleRate);
1818
initLogger(config);
1919

src/utils/parseArguments.ts

Lines changed: 29 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,48 @@
1-
import type { CFXParameters, CachedStatement } from '../types';
2-
import { getCached, setCached } from '../cache/statementCache';
1+
import type { CFXParameters } from '../types';
32

4-
const NAMED_PARAM_RE = /(?<!["'])[:@]([a-zA-Z][a-zA-Z0-9_]*)/g;
5-
6-
function convertNamedToPositional(
7-
query: string,
8-
params: Record<string, unknown>
9-
): [string, unknown[]] {
10-
const cached = getCached(query);
11-
if (cached) {
12-
return [cached.sql, cached.params.map((k) => params[k] ?? null)];
13-
}
14-
15-
const paramNames: string[] = [];
16-
const sql = query.replace(NAMED_PARAM_RE, (_match, name) => {
17-
paramNames.push(name);
18-
return '?';
19-
});
20-
21-
setCached(query, { sql, params: paramNames });
22-
return [sql, paramNames.map((k) => params[k] ?? null)];
23-
}
24-
25-
function normalizeObjectParams(params: Record<string, unknown>, count: number): unknown[] {
26-
const result: unknown[] = [];
27-
for (let i = 0; i < count; i++) {
28-
result.push(params[String(i + 1)] ?? null);
29-
}
30-
return result;
31-
}
32-
33-
function countPlaceholders(query: string): number {
34-
let count = 0;
35-
for (let i = 0; i < query.length; i++) {
36-
if (query[i] === '?' && query[i + 1] !== '?') count++;
37-
}
38-
return count;
39-
}
3+
const convertNamedPlaceholders: (query: string, params: Record<string, any>) => [string, any[]] =
4+
require('named-placeholders')();
405

416
export function parseArguments(
427
query: string,
438
parameters?: CFXParameters
449
): [string, unknown[]] {
45-
if (parameters === null || parameters === undefined || typeof parameters === 'function') {
46-
return [query, []];
47-
}
48-
49-
if (!Array.isArray(parameters) && typeof parameters === 'object') {
10+
if (convertNamedPlaceholders && parameters && typeof parameters === 'object' && !Array.isArray(parameters)) {
5011
if (query.includes(':') || query.includes('@')) {
51-
return convertNamedToPositional(query, parameters as Record<string, unknown>);
12+
[query, parameters] = convertNamedPlaceholders(query, parameters as Record<string, unknown>);
5213
}
14+
}
5315

54-
const count = countPlaceholders(query);
55-
return [query, normalizeObjectParams(parameters as Record<string, unknown>, count)];
16+
if (!parameters || typeof parameters === 'function') {
17+
return [query, []];
5618
}
5719

58-
if (Array.isArray(parameters)) {
59-
const count = countPlaceholders(query);
60-
const arr = parameters as unknown[];
20+
const placeholders = query.match(/\?(?!\?)/g)?.length ?? 0;
6121

62-
if (arr.length < count) {
63-
const padded = [...arr];
64-
while (padded.length < count) padded.push(null);
65-
return [query, padded];
22+
if (parameters && !Array.isArray(parameters)) {
23+
const arr: unknown[] = [];
24+
for (let i = 0; i < placeholders; i++) {
25+
arr[i] = (parameters as Record<string, unknown>)[i + 1] ?? null;
6626
}
27+
return [query, arr];
28+
}
6729

68-
if (arr.length > count && count > 0) {
69-
throw new Error(
70-
`[swiftdb] Too many parameters (${arr.length}) for query with ${count} placeholders`
71-
);
30+
if (placeholders) {
31+
if ((parameters as unknown[]).length === 0) {
32+
const arr: unknown[] = [];
33+
for (let i = 0; i < placeholders; i++) arr[i] = null;
34+
return [query, arr];
7235
}
7336

74-
return [query, arr];
37+
const diff = placeholders - (parameters as unknown[]).length;
38+
if (diff > 0) {
39+
const padded = [...(parameters as unknown[])];
40+
for (let i = 0; i < diff; i++) padded.push(null);
41+
return [query, padded];
42+
} else if (diff < 0) {
43+
throw new Error(`Expected ${placeholders} parameters, but received ${(parameters as unknown[]).length}.`);
44+
}
7545
}
7646

77-
return [query, []];
47+
return [query, parameters as unknown[]];
7848
}

0 commit comments

Comments
 (0)