Skip to content

Commit 589cfdb

Browse files
committed
feat: escape single quotes and lone backslashes in SOQL string literals
1 parent 06c0da7 commit 589cfdb

4 files changed

Lines changed: 349 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 7.2.1
4+
5+
Jun 8, 2026
6+
7+
### Bug Fixes
8+
9+
- **Escape single quotes and lone backslashes in composed `STRING` values**`composeQuery` previously wrapped `STRING` literal values in single quotes without escaping their contents, so a value like `med'ia` produced invalid SOQL (`Industry = 'med'ia'`). String values are now escaped before being wrapped: bare single quotes become `\'` and lone backslashes become `\\`. Backslash sequences that are already valid SOQL escapes (`\n`, `\r`, `\t`, `\b`, `\f`, `\"`, `\'`, `\\`, `\_`, `\%`, `\uXXXX`, including their uppercase variants) are left unchanged so callers can pre-escape values themselves. Values that are already wrapped in single quotes (e.g. round-tripped through `parseQuery`) are passed through unchanged.
10+
311
## 7.2.0
412

513
May 11, 2026

src/utils.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,9 @@ export function getWhereValue(value: any | any[], literalType?: LiteralType | Li
237237
switch (literalType) {
238238
case 'STRING': {
239239
if (Array.isArray(value)) {
240-
return value.filter(Boolean).map(val => (isString(val) && val.startsWith("'") ? val : `'${val ?? ''}'`));
240+
return value.filter(Boolean).map(val => wrapStringLiteral(val));
241241
} else {
242-
value = String(value ?? '');
243-
return isString(value) && value.startsWith("'") ? value : `'${value ?? ''}'`;
242+
return wrapStringLiteral(value);
244243
}
245244
}
246245
case 'APEX_BIND_VARIABLE': {
@@ -256,7 +255,7 @@ export function getWhereValue(value: any | any[], literalType?: LiteralType | Li
256255
function whereValueHelper(value: any, literalType?: LiteralType) {
257256
switch (literalType) {
258257
case 'STRING': {
259-
return isString(value) && value.startsWith("'") ? value : `'${value ?? ''}'`;
258+
return wrapStringLiteral(value);
260259
}
261260
case 'NULL': {
262261
return 'null';
@@ -266,3 +265,54 @@ function whereValueHelper(value: any, literalType?: LiteralType) {
266265
}
267266
}
268267
}
268+
269+
/**
270+
* Wraps a raw value as a SOQL string literal, escaping unescaped single quotes
271+
* and lone backslashes. If the value already starts with `'`, it is assumed to
272+
* be pre-quoted (e.g. from a parsed query) and is returned unchanged.
273+
*/
274+
function wrapStringLiteral(value: any): string {
275+
if (isString(value) && value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
276+
return value;
277+
}
278+
const str = String(value ?? '');
279+
return `'${escapeStringLiteral(str)}'`;
280+
}
281+
282+
// SOQL recognizes these chars as valid escape sequences after a backslash.
283+
// See: https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_quotedstringescapes.htm
284+
const SOQL_ESCAPE_FOLLOW_CHARS = /[nNrRtTbBfF"'\\_%]/;
285+
const HEX_QUAD = /^[0-9a-fA-F]{4}$/;
286+
287+
/**
288+
* Escapes characters that would otherwise break a SOQL string literal.
289+
*
290+
* - A single quote (`'`) is escaped to `\'`.
291+
* - A backslash followed by a known SOQL escape character is left as-is so
292+
* users can supply already-escaped values (e.g. `\n`, `\%`, `\uXXXX`).
293+
* - A lone backslash (one not part of a known escape sequence) is escaped to
294+
* `\\`.
295+
*/
296+
export function escapeStringLiteral(value: string): string {
297+
let out = '';
298+
for (let i = 0; i < value.length; i++) {
299+
const ch = value[i];
300+
if (ch === '\\') {
301+
const next = value[i + 1];
302+
if (next !== undefined && SOQL_ESCAPE_FOLLOW_CHARS.test(next)) {
303+
out += ch + next;
304+
i++;
305+
} else if (next === 'u' && HEX_QUAD.test(value.substr(i + 2, 4))) {
306+
out += value.substr(i, 6);
307+
i += 5;
308+
} else {
309+
out += '\\\\';
310+
}
311+
} else if (ch === "'") {
312+
out += "\\'";
313+
} else {
314+
out += ch;
315+
}
316+
}
317+
return out;
318+
}

test/escape-string-literal.spec.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { escapeStringLiteral } from '../src/utils';
3+
4+
describe('escapeStringLiteral', () => {
5+
describe('single quotes', () => {
6+
it('escapes a bare single quote', () => {
7+
expect(escapeStringLiteral("med'ia")).toEqual("med\\'ia");
8+
});
9+
10+
it('escapes multiple bare single quotes', () => {
11+
expect(escapeStringLiteral("it's a 'test'")).toEqual("it\\'s a \\'test\\'");
12+
});
13+
14+
it('preserves a pre-escaped single quote', () => {
15+
expect(escapeStringLiteral("mr\\'s")).toEqual("mr\\'s");
16+
});
17+
18+
it('handles a leading single quote', () => {
19+
expect(escapeStringLiteral("'foo")).toEqual("\\'foo");
20+
});
21+
22+
it('handles a trailing single quote', () => {
23+
expect(escapeStringLiteral("foo'")).toEqual("foo\\'");
24+
});
25+
});
26+
27+
describe('backslashes', () => {
28+
it('escapes a lone backslash followed by a non-escape char', () => {
29+
expect(escapeStringLiteral('back\\slash')).toEqual('back\\\\slash');
30+
});
31+
32+
it('escapes a trailing backslash with nothing after it', () => {
33+
expect(escapeStringLiteral('trailing\\')).toEqual('trailing\\\\');
34+
});
35+
36+
it('preserves a pre-escaped backslash (\\\\)', () => {
37+
expect(escapeStringLiteral('a\\\\b')).toEqual('a\\\\b');
38+
});
39+
40+
it('preserves \\n', () => {
41+
expect(escapeStringLiteral('line1\\nline2')).toEqual('line1\\nline2');
42+
});
43+
44+
it('preserves \\t', () => {
45+
expect(escapeStringLiteral('a\\tb')).toEqual('a\\tb');
46+
});
47+
48+
it('preserves \\r', () => {
49+
expect(escapeStringLiteral('a\\rb')).toEqual('a\\rb');
50+
});
51+
52+
it('preserves \\b', () => {
53+
expect(escapeStringLiteral('a\\bb')).toEqual('a\\bb');
54+
});
55+
56+
it('preserves \\f', () => {
57+
expect(escapeStringLiteral('a\\fb')).toEqual('a\\fb');
58+
});
59+
60+
it('preserves uppercase escape chars (\\N, \\R, \\T, \\B, \\F)', () => {
61+
expect(escapeStringLiteral('a\\Nb\\Rc\\Td\\Be\\Ff')).toEqual('a\\Nb\\Rc\\Td\\Be\\Ff');
62+
});
63+
64+
it('preserves \\"', () => {
65+
expect(escapeStringLiteral('a\\"b')).toEqual('a\\"b');
66+
});
67+
68+
it('preserves \\_ (LIKE underscore escape)', () => {
69+
expect(escapeStringLiteral('a\\_b')).toEqual('a\\_b');
70+
});
71+
72+
it('preserves \\% (LIKE percent escape)', () => {
73+
expect(escapeStringLiteral('a\\%b')).toEqual('a\\%b');
74+
});
75+
});
76+
77+
describe('unicode escapes', () => {
78+
it('preserves \\uXXXX with valid hex', () => {
79+
expect(escapeStringLiteral('caf\\u00e9')).toEqual('caf\\u00e9');
80+
});
81+
82+
it('preserves \\uXXXX with uppercase hex', () => {
83+
expect(escapeStringLiteral('a\\uFFFFb')).toEqual('a\\uFFFFb');
84+
});
85+
86+
it('escapes the backslash when \\u is not followed by 4 hex digits', () => {
87+
expect(escapeStringLiteral('\\uZZZZ')).toEqual('\\\\uZZZZ');
88+
});
89+
90+
it('escapes the backslash when \\u has fewer than 4 digits remaining', () => {
91+
expect(escapeStringLiteral('\\u12')).toEqual('\\\\u12');
92+
});
93+
});
94+
95+
describe('mixed', () => {
96+
it('handles a quote alongside a valid escape', () => {
97+
expect(escapeStringLiteral("it's\\na test")).toEqual("it\\'s\\na test");
98+
});
99+
100+
it('handles multiple lone backslashes', () => {
101+
expect(escapeStringLiteral('a\\xb\\yc')).toEqual('a\\\\xb\\\\yc');
102+
});
103+
104+
it('returns empty string for empty input', () => {
105+
expect(escapeStringLiteral('')).toEqual('');
106+
});
107+
108+
it('returns input unchanged when no escaping is needed', () => {
109+
expect(escapeStringLiteral('hello world')).toEqual('hello world');
110+
});
111+
});
112+
113+
describe('round-trip integrity', () => {
114+
it('does not double-escape already-escaped values', () => {
115+
const input = "mr\\'s\\nback\\\\slash";
116+
expect(escapeStringLiteral(input)).toEqual(input);
117+
});
118+
});
119+
});

test/test-cases-compose.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,174 @@ export const testCases: TestCase[] = [
156156
},
157157
},
158158
},
159+
// Escape a single quote in a value
160+
{
161+
testCase: 100,
162+
soql: "SELECT Name FROM Account WHERE Industry = 'med\\'ia' LIMIT 125",
163+
input: {
164+
fields: [{ type: 'Field', field: 'Name' }],
165+
sObject: 'Account',
166+
where: {
167+
left: { field: 'Industry', operator: '=', value: "med'ia", literalType: 'STRING' },
168+
},
169+
limit: 125,
170+
},
171+
},
172+
// Pre-escaped single quote is left unchanged
173+
{
174+
testCase: 101,
175+
soql: "SELECT Name FROM Account WHERE Name = 'mr\\'s'",
176+
input: {
177+
fields: [{ type: 'Field', field: 'Name' }],
178+
sObject: 'Account',
179+
where: {
180+
left: { field: 'Name', operator: '=', value: "mr\\'s", literalType: 'STRING' },
181+
},
182+
},
183+
},
184+
// Lone backslash gets escaped
185+
{
186+
testCase: 102,
187+
soql: "SELECT Name FROM Account WHERE Name = 'back\\\\slash'",
188+
input: {
189+
fields: [{ type: 'Field', field: 'Name' }],
190+
sObject: 'Account',
191+
where: {
192+
left: { field: 'Name', operator: '=', value: 'back\\slash', literalType: 'STRING' },
193+
},
194+
},
195+
},
196+
// Multiple lone backslashes (using chars not in the SOQL escape set)
197+
{
198+
testCase: 103,
199+
soql: "SELECT Name FROM Account WHERE Name = 'a\\\\xb\\\\yc'",
200+
input: {
201+
fields: [{ type: 'Field', field: 'Name' }],
202+
sObject: 'Account',
203+
where: {
204+
left: { field: 'Name', operator: '=', value: 'a\\xb\\yc', literalType: 'STRING' },
205+
},
206+
},
207+
},
208+
// Pre-escaped backslash (\\) left unchanged
209+
{
210+
testCase: 104,
211+
soql: "SELECT Name FROM Account WHERE Name = 'a\\\\b'",
212+
input: {
213+
fields: [{ type: 'Field', field: 'Name' }],
214+
sObject: 'Account',
215+
where: {
216+
left: { field: 'Name', operator: '=', value: 'a\\\\b', literalType: 'STRING' },
217+
},
218+
},
219+
},
220+
// LIKE wildcard escapes (\% and \_) left unchanged
221+
{
222+
testCase: 105,
223+
soql: "SELECT Name FROM Account WHERE Name LIKE 'like\\%pct'",
224+
input: {
225+
fields: [{ type: 'Field', field: 'Name' }],
226+
sObject: 'Account',
227+
where: {
228+
left: { field: 'Name', operator: 'LIKE', value: 'like\\%pct', literalType: 'STRING' },
229+
},
230+
},
231+
},
232+
{
233+
testCase: 106,
234+
soql: "SELECT Name FROM Account WHERE Name LIKE 'a\\_b'",
235+
input: {
236+
fields: [{ type: 'Field', field: 'Name' }],
237+
sObject: 'Account',
238+
where: {
239+
left: { field: 'Name', operator: 'LIKE', value: 'a\\_b', literalType: 'STRING' },
240+
},
241+
},
242+
},
243+
// Unicode escape (\uXXXX) left unchanged
244+
{
245+
testCase: 107,
246+
soql: "SELECT Name FROM Account WHERE Name = 'caf\\u00e9'",
247+
input: {
248+
fields: [{ type: 'Field', field: 'Name' }],
249+
sObject: 'Account',
250+
where: {
251+
left: { field: 'Name', operator: '=', value: 'caf\\u00e9', literalType: 'STRING' },
252+
},
253+
},
254+
},
255+
// Invalid unicode escape (not 4 hex digits) — backslash escaped, body preserved
256+
{
257+
testCase: 108,
258+
soql: "SELECT Name FROM Account WHERE Name = '\\\\uZZZZ'",
259+
input: {
260+
fields: [{ type: 'Field', field: 'Name' }],
261+
sObject: 'Account',
262+
where: {
263+
left: { field: 'Name', operator: '=', value: '\\uZZZZ', literalType: 'STRING' },
264+
},
265+
},
266+
},
267+
// Other valid backslash escapes (\n, \t, \r, \", \b, \f) preserved
268+
{
269+
testCase: 109,
270+
soql: "SELECT Name FROM Account WHERE Name = 'line1\\nline2\\ttab\\rret'",
271+
input: {
272+
fields: [{ type: 'Field', field: 'Name' }],
273+
sObject: 'Account',
274+
where: {
275+
left: { field: 'Name', operator: '=', value: 'line1\\nline2\\ttab\\rret', literalType: 'STRING' },
276+
},
277+
},
278+
},
279+
// Mixed: unescaped quote alongside pre-escaped sequences
280+
{
281+
testCase: 110,
282+
soql: "SELECT Name FROM Account WHERE Name = 'it\\'s\\na test'",
283+
input: {
284+
fields: [{ type: 'Field', field: 'Name' }],
285+
sObject: 'Account',
286+
where: {
287+
left: { field: 'Name', operator: '=', value: "it's\\na test", literalType: 'STRING' },
288+
},
289+
},
290+
},
291+
// IN array with mixed escape needs
292+
{
293+
testCase: 111,
294+
soql: "SELECT Id FROM Account WHERE Name IN ('a\\'b', 'c\\\\d', 'e\\nf')",
295+
input: {
296+
fields: [{ type: 'Field', field: 'Id' }],
297+
sObject: 'Account',
298+
where: {
299+
left: { field: 'Name', operator: 'IN', value: ["a'b", 'c\\d', 'e\\nf'], literalType: 'STRING' },
300+
},
301+
},
302+
},
303+
// Trailing backslash with nothing after it gets escaped
304+
{
305+
testCase: 112,
306+
soql: "SELECT Name FROM Account WHERE Name = 'trailing\\\\'",
307+
input: {
308+
fields: [{ type: 'Field', field: 'Name' }],
309+
sObject: 'Account',
310+
where: {
311+
left: { field: 'Name', operator: '=', value: 'trailing\\', literalType: 'STRING' },
312+
},
313+
},
314+
},
315+
// Round-trip: value already wrapped in quotes (as parseQuery produces) passes through unchanged
316+
{
317+
testCase: 113,
318+
soql: "SELECT Name FROM Account WHERE Name = 'already\\'quoted'",
319+
input: {
320+
fields: [{ type: 'Field', field: 'Name' }],
321+
sObject: 'Account',
322+
where: {
323+
left: { field: 'Name', operator: '=', value: "'already\\'quoted'", literalType: 'STRING' },
324+
},
325+
},
326+
},
159327
];
160328

161329
export default testCases;

0 commit comments

Comments
 (0)