Skip to content

Commit 2d646c1

Browse files
committed
feat: add schema.nullable for optional values
Accepts null and undefined in addition to the inner descriptor's accepted values, and emits the bare SQL NULL literal when nullish. Non-null values delegate to the inner descriptor's validate and escape. The existing null-rejection path for non-nullable descriptors is preserved with its dedicated error message.
1 parent e04ab26 commit 2d646c1

7 files changed

Lines changed: 130 additions & 4 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ const { sql } = getEvents({
112112
| `schema.enum(...)` | Whitelist of allowed values | `schema.enum('asc', 'desc')` |
113113
| `schema.s3Path` | S3 URI | `'s3://athena-results/queries/'` |
114114
| `schema.array(inner)` | Non-empty array of `inner` values | `schema.array(schema.positiveInt)` |
115+
| `schema.nullable(inner)` | `null` / `undefined` or `inner` value | `schema.nullable(schema.isoTimestamp)` |
115116

116117
### Array Values (IN clauses)
117118

@@ -132,6 +133,26 @@ const { sql } = getByIds({ table: 'users', ids: [1, 2, 3] });
132133
// ... WHERE id IN (1, 2, 3)
133134
```
134135

136+
### Nullable Values
137+
138+
`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.
139+
140+
```sql
141+
-- queries/updateLogin.sql
142+
UPDATE {{table}} SET last_login = {{lastLogin}} WHERE id = {{id}}
143+
```
144+
145+
```typescript
146+
const updateLogin = defineQuery('./queries/updateLogin.sql', {
147+
table: schema.identifier,
148+
lastLogin: schema.nullable(schema.isoTimestamp),
149+
id: schema.positiveInt,
150+
});
151+
152+
updateLogin({ table: 'users', lastLogin: null, id: 1 });
153+
// ... SET last_login = NULL ...
154+
```
155+
135156
### Custom Schema Types
136157

137158
Define your own type descriptors for project-specific validation:

llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Schema mode validates at define-time (schema keys must match template tokens) an
6060
- `schema.enum(...values)` - whitelist of allowed string values
6161
- `schema.s3Path` - S3 URI (s3://athena-results/queries/)
6262
- `schema.array(inner)` - non-empty array; renders `'a', 'b'` for strings, `1, 2, 3` for numbers; rejects empty arrays
63+
- `schema.nullable(inner)` - accepts null/undefined (emits SQL `NULL`) or delegates to `inner`; template must not quote the placeholder
6364

6465
### Custom schema types
6566

src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,11 @@ export function defineQuery(
7373
const value = params[key];
7474

7575
if (schemaDef) {
76-
if (value === null || value === undefined) {
77-
throw new Error(`Validation failed for '${key}': value cannot be null or undefined`);
78-
}
7976
const desc = schemaDef[key];
8077
if (!desc.validate(value)) {
78+
if (value === null || value === undefined) {
79+
throw new Error(`Validation failed for '${key}': value cannot be null or undefined`);
80+
}
8181
throw new Error(
8282
`Schema validation failed for '${key}': received ${typeof value} (${JSON.stringify(value)})`,
8383
);

src/schema.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SQL_INJECTION_PATTERNS } from './validator';
1+
import { SQL_INJECTION_PATTERNS, escapeValue } from './validator';
22

33
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
44
const ISO_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}:\d{2})$/;
@@ -76,6 +76,14 @@ export const schema = {
7676
.map((v) => (inner.escape ? inner.escape(v) : formatArrayElement(v)))
7777
.join(', '),
7878
}),
79+
80+
nullable: <T>(inner: TypeDescriptor<T>): TypeDescriptor<T | null> => ({
81+
validate: (val) => val === null || val === undefined || inner.validate(val),
82+
escape: (val) => {
83+
if (val === null || val === undefined) return 'NULL';
84+
return inner.escape ? inner.escape(val) : escapeValue(val);
85+
},
86+
}),
7987
};
8088

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

tests/define-query.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,65 @@ describe('defineQuery — schema mode', () => {
329329
});
330330
});
331331

332+
describe('schema.nullable', () => {
333+
it('renders null as the SQL NULL literal', () => {
334+
const query = defineQuery(fixture('nullable.sql'), {
335+
table: schema.identifier,
336+
lastLogin: schema.nullable(schema.isoTimestamp),
337+
id: schema.positiveInt,
338+
});
339+
const { sql } = query({ table: 'users', lastLogin: null, id: 1 });
340+
expect(sql).toContain('last_login = NULL');
341+
});
342+
343+
it('renders undefined as the SQL NULL literal', () => {
344+
const query = defineQuery(fixture('nullable.sql'), {
345+
table: schema.identifier,
346+
lastLogin: schema.nullable(schema.isoTimestamp),
347+
id: schema.positiveInt,
348+
});
349+
const { sql } = query({
350+
table: 'users',
351+
lastLogin: undefined as unknown as string,
352+
id: 1,
353+
});
354+
expect(sql).toContain('last_login = NULL');
355+
});
356+
357+
it('delegates to the inner escape for non-null values', () => {
358+
const query = defineQuery(fixture('nullable.sql'), {
359+
table: schema.identifier,
360+
lastLogin: schema.nullable(schema.isoTimestamp),
361+
id: schema.positiveInt,
362+
});
363+
const { sql } = query({
364+
table: 'users',
365+
lastLogin: '2022-02-22T22:02:22Z',
366+
id: 1,
367+
});
368+
expect(sql).toContain('last_login = 2022-02-22T22:02:22Z');
369+
});
370+
371+
it('still rejects invalid non-null values', () => {
372+
const query = defineQuery(fixture('nullable.sql'), {
373+
table: schema.identifier,
374+
lastLogin: schema.nullable(schema.isoTimestamp),
375+
id: schema.positiveInt,
376+
});
377+
expect(() => query({ table: 'users', lastLogin: 'nope', id: 1 }))
378+
.toThrow('Schema validation failed');
379+
});
380+
381+
it('non-nullable descriptors still reject null with the dedicated message', () => {
382+
const query = defineQuery(fixture('simple.sql'), {
383+
table: schema.identifier,
384+
id: schema.positiveInt,
385+
});
386+
const params = { table: 'users', id: null } as unknown as { table: string; id: number };
387+
expect(() => query(params)).toThrow('cannot be null or undefined');
388+
});
389+
});
390+
332391
describe('custom schema types', () => {
333392
it('accepts a custom type descriptor', () => {
334393
const prodTable = {

tests/fixtures/nullable.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
UPDATE {{table}}
2+
SET
3+
last_login = {{lastLogin}}
4+
WHERE
5+
id = {{id}}

tests/schema.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,38 @@ describe('schema.array', () => {
231231
});
232232
});
233233

234+
describe('schema.nullable', () => {
235+
it('accepts null and undefined', () => {
236+
const nstr = schema.nullable(schema.string);
237+
expect(nstr.validate(null)).toBe(true);
238+
expect(nstr.validate(undefined)).toBe(true);
239+
});
240+
241+
it('delegates validation to the inner descriptor for non-null values', () => {
242+
const nstr = schema.nullable(schema.string);
243+
expect(nstr.validate('hello')).toBe(true);
244+
expect(nstr.validate('x; DROP TABLE y')).toBe(false);
245+
246+
const nint = schema.nullable(schema.positiveInt);
247+
expect(nint.validate(5)).toBe(true);
248+
expect(nint.validate(-1)).toBe(false);
249+
});
250+
251+
it('renders null and undefined as the SQL NULL literal', () => {
252+
const nstr = schema.nullable(schema.string);
253+
expect(nstr.escape?.(null)).toBe('NULL');
254+
expect(nstr.escape?.(undefined)).toBe('NULL');
255+
});
256+
257+
it('delegates escape to the inner descriptor for non-null values', () => {
258+
const nstr = schema.nullable(schema.string);
259+
expect(nstr.escape?.("O'Brien")).toBe("O''Brien");
260+
261+
const nint = schema.nullable(schema.positiveInt);
262+
expect(nint.escape?.(42)).toBe('42');
263+
});
264+
});
265+
234266
describe('schema.s3Path', () => {
235267
it('accepts valid S3 paths', () => {
236268
expect(schema.s3Path.validate('s3://my-bucket/data/')).toBe(true);

0 commit comments

Comments
 (0)