Skip to content

Commit 8f1b6e1

Browse files
chore: regenerate llms-full.txt
1 parent 5bc9a5d commit 8f1b6e1

1 file changed

Lines changed: 269 additions & 14 deletions

File tree

llms-full.txt

Lines changed: 269 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ tests/
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

315313
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
316314
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})$/;
@@ -321,12 +319,18 @@ const S3_PATH_REGEX = /^s3:\/\/(?![^/]*\.\.)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9](\/[
321319
export interface TypeDescriptor<T = unknown> {
322320
readonly __phantom?: T;
323321
validate(val: unknown): boolean;
322+
escape?(val: unknown): string;
324323
}
325324

326325
function 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+
330334
function 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

378399
export 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
511542
SELECT
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
517557
SELECT
@@ -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+
11281340
describe('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

Comments
 (0)