Skip to content

Commit b0830ed

Browse files
committed
feat: add Set and Map support to ? placeholder expansion
Extend the `?` placeholder handling so that `Set` instances are expanded like arrays and `Map` instances are expanded like plain objects. - `Set` values used inside `IN (?)` or `SELECT ?` are expanded as a comma-separated list of escaped values, matching existing Array behavior. - `Map` values used in `UPDATE ... SET ?` are expanded as `` `key` = value `` pairs, matching existing Object behavior. - A `Map` used in a regular `?` position (non-SET) falls back to the default string coercion (`'[object Map]'`), consistent with how non-plain objects are handled today. - Empty `Set` and empty `Map` produce empty expansions, mirroring the behavior of empty arrays and empty objects. Tests covering each of these cases are included.
1 parent 09e7e65 commit b0830ed

3 files changed

Lines changed: 78 additions & 10 deletions

File tree

src/index.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ const charCode = {
4242
} as const;
4343

4444
const isRecord = (value: unknown): value is Record<string, SqlValue> =>
45-
typeof value === 'object' && value !== null && !Array.isArray(value);
45+
typeof value === 'object' &&
46+
value !== null &&
47+
!Array.isArray(value) &&
48+
!(value instanceof Set) &&
49+
!(value instanceof Map);
4650

4751
const isWordChar = (code: number): boolean =>
4852
(code >= 65 && code <= 90) ||
@@ -349,19 +353,21 @@ export const escapeId = (
349353
};
350354

351355
export const objectToValues = (
352-
object: Record<string, SqlValue>,
356+
object: Record<string, SqlValue> | Map<string, SqlValue>,
353357
timezone?: Timezone
354358
): string => {
355-
const keys = Object.keys(object);
356-
const keysLength = keys.length;
359+
const entries: Array<[string, SqlValue]> = object instanceof Map
360+
? Array.from(object.entries(), ([k, v]) => [String(k), v])
361+
: Object.entries(object);
362+
363+
const keysLength = entries.length;
357364

358365
if (keysLength === 0) return '';
359366

360367
let sql = '';
361368

362369
for (let i = 0; i < keysLength; i++) {
363-
const key = keys[i]!;
364-
const value = object[key];
370+
const [key, value] = entries[i]!;
365371

366372
if (typeof value === 'function') continue;
367373

@@ -384,8 +390,10 @@ export const arrayToList = (array: SqlValue[], timezone?: Timezone): string => {
384390
for (let i = 0; i < length; i++) {
385391
const value = array[i];
386392

387-
if (Array.isArray(value)) parts[i] = `(${arrayToList(value, timezone)})`;
388-
else parts[i] = escape(value, true, timezone);
393+
if (Array.isArray(value) || value instanceof Set) {
394+
const sub = Array.isArray(value) ? value : Array.from(value as Set<SqlValue>);
395+
parts[i] = `(${arrayToList(sub, timezone)})`;
396+
} else parts[i] = escape(value, true, timezone);
389397
}
390398

391399
return parts.join(', ');
@@ -410,13 +418,15 @@ export const escape = (
410418
case 'object': {
411419
if (isDate(value)) return dateToString(value, timezone || 'local');
412420
if (Array.isArray(value)) return arrayToList(value, timezone);
421+
if (value instanceof Set)
422+
return arrayToList(Array.from(value as Set<SqlValue>), timezone);
413423
if (Buffer.isBuffer(value)) return bufferToString(value);
414424
if (value instanceof Uint8Array)
415425
return bufferToString(Buffer.from(value));
416426
if (hasSqlString(value)) return String(value.toSqlString());
417427
if (!(stringifyObjects === undefined || stringifyObjects === null))
418428
return escapeString(String(value));
419-
if (isRecord(value)) return objectToValues(value, timezone);
429+
if (isRecord(value) || value instanceof Map) return objectToValues(value, timezone);
420430

421431
return escapeString(String(value));
422432
}
@@ -480,7 +490,7 @@ export const format = (
480490
!Buffer.isBuffer(currentValue) &&
481491
!(currentValue instanceof Uint8Array) &&
482492
!isDate(currentValue) &&
483-
isRecord(currentValue)
493+
(isRecord(currentValue) || currentValue instanceof Map)
484494
) {
485495
escapedValue = objectToValues(currentValue, timezone);
486496
setIndex = findSetKeyword(sql, placeholderEnd);

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export type SqlValue =
1313
| Raw
1414
| Record<string, unknown>
1515
| SqlValue[]
16+
| Set<SqlValue>
17+
| Map<string, SqlValue>
1618
| null
1719
| undefined;
1820

test/everyday-queries.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,3 +616,59 @@ describe('Critical: Backtick-quoted identifiers with comment-like sequences', ()
616616
assert.equal(sql, 'SELECT `id`, `name` FROM users WHERE id = 1');
617617
});
618618
});
619+
620+
describe('Set (treated like Array) and Map (treated like Object) via ?', () => {
621+
test('WHERE id IN (?) with Set of numbers', () => {
622+
const sql = format('SELECT * FROM users WHERE id IN (?)', [
623+
new Set([1, 2, 3]),
624+
]);
625+
assert.equal(sql, 'SELECT * FROM users WHERE id IN (1, 2, 3)');
626+
});
627+
628+
test('WHERE status IN (?) with Set of strings', () => {
629+
const sql = format('SELECT * FROM users WHERE status IN (?)', [
630+
new Set(['active', 'pending']),
631+
]);
632+
assert.equal(
633+
sql,
634+
"SELECT * FROM users WHERE status IN ('active', 'pending')"
635+
);
636+
});
637+
638+
test('SELECT ? with Set expands like array', () => {
639+
const sql = format('SELECT ?', [new Set([42])]);
640+
assert.equal(sql, 'SELECT 42');
641+
});
642+
643+
test('UPDATE t SET ? with Map expands like object', () => {
644+
const sql = format('UPDATE t SET ?', [
645+
new Map<string, number|string>([
646+
['name', 'foo'],
647+
['count', 7],
648+
]),
649+
]);
650+
assert.equal(sql, "UPDATE t SET `name` = 'foo', `count` = 7");
651+
});
652+
653+
test('Map in regular ? position stringifies like object', () => {
654+
const sql = format('SELECT * FROM t WHERE data = ?', [
655+
new Map([['x', 1]]),
656+
]);
657+
assert.equal(sql, "SELECT * FROM t WHERE data = '[object Map]'");
658+
});
659+
660+
test('Set in SET position treated like array (lists)', () => {
661+
const sql = format('UPDATE t SET ?', [new Set(['a', 'b'])]);
662+
assert.equal(sql, "UPDATE t SET 'a', 'b'");
663+
});
664+
665+
test('empty Set in IN (?)', () => {
666+
const sql = format('SELECT * FROM t WHERE id IN (?)', [new Set()]);
667+
assert.equal(sql, 'SELECT * FROM t WHERE id IN ()');
668+
});
669+
670+
test('empty Map in SET ?', () => {
671+
const sql = format('UPDATE t SET ?', [new Map()]);
672+
assert.equal(sql, 'UPDATE t SET ');
673+
});
674+
});

0 commit comments

Comments
 (0)