5252 fixtures/
5353 duplicate-vars.sql
5454 getEvents.sql
55+ in-clause.sql
5556 no-vars.sql
57+ nullable.sql
5658 simple.sql
5759 define-query.test.ts
5860 loader.test.ts
@@ -238,16 +240,16 @@ export function defineQuery(
238240 const value = params[key];
239241
240242 if (schemaDef) {
241- if (value === null || value === undefined) {
242- throw new Error(`Validation failed for '${key}': value cannot be null or undefined`);
243- }
244243 const desc = schemaDef[key];
245244 if (!desc.validate(value)) {
245+ if (value === null || value === undefined) {
246+ throw new Error(`Validation failed for '${key}': value cannot be null or undefined`);
247+ }
246248 throw new Error(
247249 `Schema validation failed for '${key}': received ${typeof value} (${JSON.stringify(value)})`,
248250 );
249251 }
250- values[key] = escapeValue(value);
252+ values[key] = desc.escape ? desc.escape(value) : escapeValue(value);
251253 } else {
252254 values[key] = validateAndConvert(key, value);
253255 }
@@ -295,22 +297,18 @@ export function loadTemplate(filePath: string): { template: string; tokens: stri
295297
296298## File: src/renderer.ts
297299````typescript
298- export function render(template: string, values: Record<string, string>): string {
299- let result = template;
300-
301- for (const [key, value] of Object.entries(values)) {
302- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
303- const pattern = new RegExp(`\\{\\{${escaped}\\}\\}`, 'g');
304- result = result.replace(pattern, () => value);
305- }
300+ const TOKEN_REGEX = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
306301
307- return result;
302+ export function render(template: string, values: Record<string, string>): string {
303+ return template.replace(TOKEN_REGEX, (match, key) => (
304+ Object.hasOwn(values, key) ? values[key] : match
305+ ));
308306}
309307````
310308
311309## File: src/schema.ts
312310````typescript
313- import { SQL_INJECTION_PATTERNS } from './validator';
311+ import { SQL_INJECTION_PATTERNS, escapeValue } from './validator';
314312
315313const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
316314const ISO_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}:\d{2})$/;
@@ -321,12 +319,18 @@ const S3_PATH_REGEX = /^s3:\/\/(?![^/]*\.\.)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9](\/[
321319export interface TypeDescriptor<T = unknown> {
322320 readonly __phantom?: T;
323321 validate(val: unknown): boolean;
322+ escape?(val: unknown): string;
324323}
325324
326325function descriptor<T>(validate: (val: unknown) => boolean): TypeDescriptor<T> {
327326 return { validate };
328327}
329328
329+ function formatArrayElement(val: unknown): string {
330+ if (typeof val === 'string') return `'${val.replace(/'/g, "''")}'`;
331+ return String(val);
332+ }
333+
330334function isString(val: unknown): val is string {
331335 return typeof val === 'string';
332336}
@@ -373,6 +377,23 @@ export const schema = {
373377 enum: <T extends string>(...values: T[]): TypeDescriptor<T> => descriptor<T>(
374378 (val) => isString(val) && (values as string[]).includes(val),
375379 ),
380+
381+ array: <T>(inner: TypeDescriptor<T>): TypeDescriptor<T[]> => ({
382+ validate: (val) => Array.isArray(val)
383+ && val.length > 0
384+ && val.every((v) => inner.validate(v)),
385+ escape: (val) => (val as unknown[])
386+ .map((v) => (inner.escape ? inner.escape(v) : formatArrayElement(v)))
387+ .join(', '),
388+ }),
389+
390+ nullable: <T>(inner: TypeDescriptor<T>): TypeDescriptor<T | null> => ({
391+ validate: (val) => val === null || val === undefined || inner.validate(val),
392+ escape: (val) => {
393+ if (val === null || val === undefined) return 'NULL';
394+ return inner.escape ? inner.escape(val) : escapeValue(val);
395+ },
396+ }),
376397};
377398
378399export type SchemaDefinition = Record<string, TypeDescriptor>;
@@ -506,12 +527,31 @@ LIMIT
506527 {{limit}}
507528````
508529
530+ ## File: tests/fixtures/in-clause.sql
531+ ````sql
532+ SELECT
533+ *
534+ FROM
535+ {{table}}
536+ WHERE
537+ id IN ({{ids}})
538+ ````
539+
509540## File: tests/fixtures/no-vars.sql
510541````sql
511542SELECT
512543 1
513544````
514545
546+ ## File: tests/fixtures/nullable.sql
547+ ````sql
548+ UPDATE {{table}}
549+ SET
550+ last_login = {{lastLogin}}
551+ WHERE
552+ id = {{id}}
553+ ````
554+
515555## File: tests/fixtures/simple.sql
516556````sql
517557SELECT
@@ -816,6 +856,104 @@ describe('defineQuery — schema mode', () => {
816856 });
817857 });
818858
859+ describe('schema.array', () => {
860+ it('renders a list of numbers without quotes', () => {
861+ const query = defineQuery(fixture('in-clause.sql'), {
862+ table: schema.identifier,
863+ ids: schema.array(schema.positiveInt),
864+ });
865+ const { sql } = query({ table: 'users', ids: [1, 2, 3] });
866+ expect(sql).toContain('IN (1, 2, 3)');
867+ });
868+
869+ it('renders a list of strings with quotes and escapes single quotes', () => {
870+ const query = defineQuery(fixture('in-clause.sql'), {
871+ table: schema.identifier,
872+ ids: schema.array(schema.string),
873+ });
874+ const { sql } = query({ table: 'users', ids: ['a', "O'Brien"] });
875+ expect(sql).toContain("IN ('a', 'O''Brien')");
876+ });
877+
878+ it('rejects an empty array', () => {
879+ const query = defineQuery(fixture('in-clause.sql'), {
880+ table: schema.identifier,
881+ ids: schema.array(schema.positiveInt),
882+ });
883+ expect(() => query({ table: 'users', ids: [] })).toThrow('Schema validation failed');
884+ });
885+
886+ it('rejects a mixed-type array when inner is strict', () => {
887+ const query = defineQuery(fixture('in-clause.sql'), {
888+ table: schema.identifier,
889+ ids: schema.array(schema.positiveInt),
890+ });
891+ expect(() => query({
892+ table: 'users',
893+ ids: [1, -1, 2] as unknown as number[],
894+ })).toThrow('Schema validation failed');
895+ });
896+ });
897+
898+ describe('schema.nullable', () => {
899+ it('renders null as the SQL NULL literal', () => {
900+ const query = defineQuery(fixture('nullable.sql'), {
901+ table: schema.identifier,
902+ lastLogin: schema.nullable(schema.isoTimestamp),
903+ id: schema.positiveInt,
904+ });
905+ const { sql } = query({ table: 'users', lastLogin: null, id: 1 });
906+ expect(sql).toContain('last_login = NULL');
907+ });
908+
909+ it('renders undefined as the SQL NULL literal', () => {
910+ const query = defineQuery(fixture('nullable.sql'), {
911+ table: schema.identifier,
912+ lastLogin: schema.nullable(schema.isoTimestamp),
913+ id: schema.positiveInt,
914+ });
915+ const { sql } = query({
916+ table: 'users',
917+ lastLogin: undefined as unknown as string,
918+ id: 1,
919+ });
920+ expect(sql).toContain('last_login = NULL');
921+ });
922+
923+ it('delegates to the inner escape for non-null values', () => {
924+ const query = defineQuery(fixture('nullable.sql'), {
925+ table: schema.identifier,
926+ lastLogin: schema.nullable(schema.isoTimestamp),
927+ id: schema.positiveInt,
928+ });
929+ const { sql } = query({
930+ table: 'users',
931+ lastLogin: '2022-02-22T22:02:22Z',
932+ id: 1,
933+ });
934+ expect(sql).toContain('last_login = 2022-02-22T22:02:22Z');
935+ });
936+
937+ it('still rejects invalid non-null values', () => {
938+ const query = defineQuery(fixture('nullable.sql'), {
939+ table: schema.identifier,
940+ lastLogin: schema.nullable(schema.isoTimestamp),
941+ id: schema.positiveInt,
942+ });
943+ expect(() => query({ table: 'users', lastLogin: 'nope', id: 1 }))
944+ .toThrow('Schema validation failed');
945+ });
946+
947+ it('non-nullable descriptors still reject null with the dedicated message', () => {
948+ const query = defineQuery(fixture('simple.sql'), {
949+ table: schema.identifier,
950+ id: schema.positiveInt,
951+ });
952+ const params = { table: 'users', id: null } as unknown as { table: string; id: number };
953+ expect(() => query(params)).toThrow('cannot be null or undefined');
954+ });
955+ });
956+
819957 describe('custom schema types', () => {
820958 it('accepts a custom type descriptor', () => {
821959 const prodTable = {
@@ -1125,6 +1263,80 @@ describe('schema.enum', () => {
11251263 });
11261264});
11271265
1266+ describe('schema.array', () => {
1267+ it('accepts non-empty arrays whose elements pass the inner validator', () => {
1268+ const ints = schema.array(schema.positiveInt);
1269+ expect(ints.validate([1, 2, 3])).toBe(true);
1270+
1271+ const dates = schema.array(schema.isoDate);
1272+ expect(dates.validate(['2022-01-01', '2022-02-02'])).toBe(true);
1273+
1274+ const statuses = schema.array(schema.enum('active', 'pending'));
1275+ expect(statuses.validate(['active', 'pending'])).toBe(true);
1276+ });
1277+
1278+ it('rejects empty arrays', () => {
1279+ expect(schema.array(schema.positiveInt).validate([])).toBe(false);
1280+ });
1281+
1282+ it('rejects non-arrays', () => {
1283+ expect(schema.array(schema.positiveInt).validate(1)).toBe(false);
1284+ expect(schema.array(schema.positiveInt).validate('1,2,3')).toBe(false);
1285+ expect(schema.array(schema.positiveInt).validate(null)).toBe(false);
1286+ });
1287+
1288+ it('rejects arrays with any invalid element', () => {
1289+ expect(schema.array(schema.positiveInt).validate([1, -1, 2])).toBe(false);
1290+ expect(schema.array(schema.isoDate).validate(['2022-01-01', 'bad'])).toBe(false);
1291+ });
1292+
1293+ it('rejects arrays of strings with SQL injection', () => {
1294+ expect(schema.array(schema.string).validate(['a', "b'; DROP TABLE x"])).toBe(false);
1295+ });
1296+
1297+ it('escapes strings by quoting and doubling single quotes', () => {
1298+ const arr = schema.array(schema.string);
1299+ expect(arr.escape?.(['a', "O'Brien"])).toBe("'a', 'O''Brien'");
1300+ });
1301+
1302+ it('escapes numbers without quotes', () => {
1303+ const arr = schema.array(schema.positiveInt);
1304+ expect(arr.escape?.([1, 2, 3])).toBe('1, 2, 3');
1305+ });
1306+ });
1307+
1308+ describe('schema.nullable', () => {
1309+ it('accepts null and undefined', () => {
1310+ const nstr = schema.nullable(schema.string);
1311+ expect(nstr.validate(null)).toBe(true);
1312+ expect(nstr.validate(undefined)).toBe(true);
1313+ });
1314+
1315+ it('delegates validation to the inner descriptor for non-null values', () => {
1316+ const nstr = schema.nullable(schema.string);
1317+ expect(nstr.validate('hello')).toBe(true);
1318+ expect(nstr.validate('x; DROP TABLE y')).toBe(false);
1319+
1320+ const nint = schema.nullable(schema.positiveInt);
1321+ expect(nint.validate(5)).toBe(true);
1322+ expect(nint.validate(-1)).toBe(false);
1323+ });
1324+
1325+ it('renders null and undefined as the SQL NULL literal', () => {
1326+ const nstr = schema.nullable(schema.string);
1327+ expect(nstr.escape?.(null)).toBe('NULL');
1328+ expect(nstr.escape?.(undefined)).toBe('NULL');
1329+ });
1330+
1331+ it('delegates escape to the inner descriptor for non-null values', () => {
1332+ const nstr = schema.nullable(schema.string);
1333+ expect(nstr.escape?.("O'Brien")).toBe("O''Brien");
1334+
1335+ const nint = schema.nullable(schema.positiveInt);
1336+ expect(nint.escape?.(42)).toBe('42');
1337+ });
1338+ });
1339+
11281340describe('schema.s3Path', () => {
11291341 it('accepts valid S3 paths', () => {
11301342 expect(schema.s3Path.validate('s3://my-bucket/data/')).toBe(true);
@@ -1384,6 +1596,8 @@ Schema mode validates at define-time (schema keys must match template tokens) an
13841596- `schema.positiveInt` - integer greater than 0
13851597- `schema.enum(...values)` - whitelist of allowed string values
13861598- `schema.s3Path` - S3 URI (s3://athena-results/queries/)
1599+ - `schema.array(inner)` - non-empty array; renders `'a', 'b'` for strings, `1, 2, 3` for numbers; rejects empty arrays
1600+ - `schema.nullable(inner)` - accepts null/undefined (emits SQL `NULL`) or delegates to `inner`; template must not quote the placeholder
13871601
13881602### Custom schema types
13891603
@@ -1668,6 +1882,47 @@ const { sql } = getEvents({
16681882| `schema.positiveInt` | Positive integer | `100` |
16691883| `schema.enum(...)` | Whitelist of allowed values | `schema.enum('asc', 'desc')` |
16701884| `schema.s3Path` | S3 URI | `'s3://athena-results/queries/'` |
1885+ | `schema.array(inner)` | Non-empty array of `inner` values | `schema.array(schema.positiveInt)` |
1886+ | `schema.nullable(inner)` | `null` / `undefined` or `inner` value | `schema.nullable(schema.isoTimestamp)` |
1887+
1888+ ### Array Values (IN clauses)
1889+
1890+ `schema.array(inner)` validates every element against `inner` and renders a comma-separated SQL list. Strings are quoted and their single quotes escaped; numbers and booleans are rendered raw. Empty arrays are rejected.
1891+
1892+ ```sql
1893+ -- queries/getByIds.sql
1894+ SELECT * FROM {{table}} WHERE id IN ({{ids}})
1895+ ```
1896+
1897+ ```typescript
1898+ const getByIds = defineQuery('./queries/getByIds.sql', {
1899+ table: schema.identifier,
1900+ ids: schema.array(schema.positiveInt),
1901+ });
1902+
1903+ const { sql } = getByIds({ table: 'users', ids: [1, 2, 3] });
1904+ // ... WHERE id IN (1, 2, 3)
1905+ ```
1906+
1907+ ### Nullable Values
1908+
1909+ `schema.nullable(inner)` accepts `null` / `undefined` in addition to whatever `inner` accepts, and emits the bare SQL `NULL` literal. Do not wrap the placeholder in quotes in your template, since `NULL` must be unquoted.
1910+
1911+ ```sql
1912+ -- queries/updateLogin.sql
1913+ UPDATE {{table}} SET last_login = {{lastLogin}} WHERE id = {{id}}
1914+ ```
1915+
1916+ ```typescript
1917+ const updateLogin = defineQuery('./queries/updateLogin.sql', {
1918+ table: schema.identifier,
1919+ lastLogin: schema.nullable(schema.isoTimestamp),
1920+ id: schema.positiveInt,
1921+ });
1922+
1923+ updateLogin({ table: 'users', lastLogin: null, id: 1 });
1924+ // ... SET last_login = NULL ...
1925+ ```
16711926
16721927### Custom Schema Types
16731928
0 commit comments