Skip to content

Commit e04ab26

Browse files
committed
feat: add schema.array for IN clause values
Validates every element against an inner descriptor and renders a comma-separated SQL list. Strings are quoted with single quotes escaped, numbers and booleans are rendered raw. Empty arrays are rejected. Closes a gap where users previously had to manually join array values, which bypassed the injection denylist. TypeDescriptor now has an optional escape(val) method; when set, the renderer uses it instead of the default escapeValue to format the interpolated string.
1 parent 994f879 commit e04ab26

7 files changed

Lines changed: 124 additions & 1 deletion

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,26 @@ const { sql } = getEvents({
111111
| `schema.positiveInt` | Positive integer | `100` |
112112
| `schema.enum(...)` | Whitelist of allowed values | `schema.enum('asc', 'desc')` |
113113
| `schema.s3Path` | S3 URI | `'s3://athena-results/queries/'` |
114+
| `schema.array(inner)` | Non-empty array of `inner` values | `schema.array(schema.positiveInt)` |
115+
116+
### Array Values (IN clauses)
117+
118+
`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.
119+
120+
```sql
121+
-- queries/getByIds.sql
122+
SELECT * FROM {{table}} WHERE id IN ({{ids}})
123+
```
124+
125+
```typescript
126+
const getByIds = defineQuery('./queries/getByIds.sql', {
127+
table: schema.identifier,
128+
ids: schema.array(schema.positiveInt),
129+
});
130+
131+
const { sql } = getByIds({ table: 'users', ids: [1, 2, 3] });
132+
// ... WHERE id IN (1, 2, 3)
133+
```
114134

115135
### Custom Schema Types
116136

llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Schema mode validates at define-time (schema keys must match template tokens) an
5959
- `schema.positiveInt` - integer greater than 0
6060
- `schema.enum(...values)` - whitelist of allowed string values
6161
- `schema.s3Path` - S3 URI (s3://athena-results/queries/)
62+
- `schema.array(inner)` - non-empty array; renders `'a', 'b'` for strings, `1, 2, 3` for numbers; rejects empty arrays
6263

6364
### Custom schema types
6465

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export function defineQuery(
8282
`Schema validation failed for '${key}': received ${typeof value} (${JSON.stringify(value)})`,
8383
);
8484
}
85-
values[key] = escapeValue(value);
85+
values[key] = desc.escape ? desc.escape(value) : escapeValue(value);
8686
} else {
8787
values[key] = validateAndConvert(key, value);
8888
}

src/schema.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@ const S3_PATH_REGEX = /^s3:\/\/(?![^/]*\.\.)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9](\/[
99
export interface TypeDescriptor<T = unknown> {
1010
readonly __phantom?: T;
1111
validate(val: unknown): boolean;
12+
escape?(val: unknown): string;
1213
}
1314

1415
function descriptor<T>(validate: (val: unknown) => boolean): TypeDescriptor<T> {
1516
return { validate };
1617
}
1718

19+
function formatArrayElement(val: unknown): string {
20+
if (typeof val === 'string') return `'${val.replace(/'/g, "''")}'`;
21+
return String(val);
22+
}
23+
1824
function isString(val: unknown): val is string {
1925
return typeof val === 'string';
2026
}
@@ -61,6 +67,15 @@ export const schema = {
6167
enum: <T extends string>(...values: T[]): TypeDescriptor<T> => descriptor<T>(
6268
(val) => isString(val) && (values as string[]).includes(val),
6369
),
70+
71+
array: <T>(inner: TypeDescriptor<T>): TypeDescriptor<T[]> => ({
72+
validate: (val) => Array.isArray(val)
73+
&& val.length > 0
74+
&& val.every((v) => inner.validate(v)),
75+
escape: (val) => (val as unknown[])
76+
.map((v) => (inner.escape ? inner.escape(v) : formatArrayElement(v)))
77+
.join(', '),
78+
}),
6479
};
6580

6681
export type SchemaDefinition = Record<string, TypeDescriptor>;

tests/define-query.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,45 @@ describe('defineQuery — schema mode', () => {
290290
});
291291
});
292292

293+
describe('schema.array', () => {
294+
it('renders a list of numbers without quotes', () => {
295+
const query = defineQuery(fixture('in-clause.sql'), {
296+
table: schema.identifier,
297+
ids: schema.array(schema.positiveInt),
298+
});
299+
const { sql } = query({ table: 'users', ids: [1, 2, 3] });
300+
expect(sql).toContain('IN (1, 2, 3)');
301+
});
302+
303+
it('renders a list of strings with quotes and escapes single quotes', () => {
304+
const query = defineQuery(fixture('in-clause.sql'), {
305+
table: schema.identifier,
306+
ids: schema.array(schema.string),
307+
});
308+
const { sql } = query({ table: 'users', ids: ['a', "O'Brien"] });
309+
expect(sql).toContain("IN ('a', 'O''Brien')");
310+
});
311+
312+
it('rejects an empty array', () => {
313+
const query = defineQuery(fixture('in-clause.sql'), {
314+
table: schema.identifier,
315+
ids: schema.array(schema.positiveInt),
316+
});
317+
expect(() => query({ table: 'users', ids: [] })).toThrow('Schema validation failed');
318+
});
319+
320+
it('rejects a mixed-type array when inner is strict', () => {
321+
const query = defineQuery(fixture('in-clause.sql'), {
322+
table: schema.identifier,
323+
ids: schema.array(schema.positiveInt),
324+
});
325+
expect(() => query({
326+
table: 'users',
327+
ids: [1, -1, 2] as unknown as number[],
328+
})).toThrow('Schema validation failed');
329+
});
330+
});
331+
293332
describe('custom schema types', () => {
294333
it('accepts a custom type descriptor', () => {
295334
const prodTable = {

tests/fixtures/in-clause.sql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
SELECT
2+
*
3+
FROM
4+
{{table}}
5+
WHERE
6+
id IN ({{ids}})

tests/schema.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,48 @@ describe('schema.enum', () => {
189189
});
190190
});
191191

192+
describe('schema.array', () => {
193+
it('accepts non-empty arrays whose elements pass the inner validator', () => {
194+
const ints = schema.array(schema.positiveInt);
195+
expect(ints.validate([1, 2, 3])).toBe(true);
196+
197+
const dates = schema.array(schema.isoDate);
198+
expect(dates.validate(['2022-01-01', '2022-02-02'])).toBe(true);
199+
200+
const statuses = schema.array(schema.enum('active', 'pending'));
201+
expect(statuses.validate(['active', 'pending'])).toBe(true);
202+
});
203+
204+
it('rejects empty arrays', () => {
205+
expect(schema.array(schema.positiveInt).validate([])).toBe(false);
206+
});
207+
208+
it('rejects non-arrays', () => {
209+
expect(schema.array(schema.positiveInt).validate(1)).toBe(false);
210+
expect(schema.array(schema.positiveInt).validate('1,2,3')).toBe(false);
211+
expect(schema.array(schema.positiveInt).validate(null)).toBe(false);
212+
});
213+
214+
it('rejects arrays with any invalid element', () => {
215+
expect(schema.array(schema.positiveInt).validate([1, -1, 2])).toBe(false);
216+
expect(schema.array(schema.isoDate).validate(['2022-01-01', 'bad'])).toBe(false);
217+
});
218+
219+
it('rejects arrays of strings with SQL injection', () => {
220+
expect(schema.array(schema.string).validate(['a', "b'; DROP TABLE x"])).toBe(false);
221+
});
222+
223+
it('escapes strings by quoting and doubling single quotes', () => {
224+
const arr = schema.array(schema.string);
225+
expect(arr.escape?.(['a', "O'Brien"])).toBe("'a', 'O''Brien'");
226+
});
227+
228+
it('escapes numbers without quotes', () => {
229+
const arr = schema.array(schema.positiveInt);
230+
expect(arr.escape?.([1, 2, 3])).toBe('1, 2, 3');
231+
});
232+
});
233+
192234
describe('schema.s3Path', () => {
193235
it('accepts valid S3 paths', () => {
194236
expect(schema.s3Path.validate('s3://my-bucket/data/')).toBe(true);

0 commit comments

Comments
 (0)