Skip to content

Commit c17c6f3

Browse files
committed
feat: add exportTo option to write rendered SQL to file
1 parent 095eb78 commit c17c6f3

7 files changed

Lines changed: 101 additions & 10 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,17 @@ const query = defineQuery('./query.sql', {
132132

133133
A type descriptor is any object with a `validate(val: unknown) => boolean` method.
134134

135+
## Exporting Rendered SQL
136+
137+
Pass an `exportTo` path to write the rendered SQL to disk for debugging or audit purposes. Missing parent directories are created automatically.
138+
139+
```typescript
140+
const { sql } = getEvents(
141+
{ tableName: 'prod_events', status: 'active', startDate: '2022-02-22', orderBy: 'created_at', limit: 99 },
142+
{ exportTo: './debug/getEvents.sql' },
143+
);
144+
```
145+
135146
## SQL Injection Protection
136147

137148
`schema.string` and the generic `string` type check values against built-in patterns:

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
## v0.2
44

55
- [ ] **Inline template support** - Allow passing SQL strings directly without requiring a `.sql` file, reducing boilerplate for short one-off queries
6-
- [ ] **Export rendered SQL to file** - Add an option to write the rendered SQL to a file for debugging and audit purposes
6+
- [x] **Export rendered SQL to file** - Add an option to write the rendered SQL to a file for debugging and audit purposes
77
- [ ] **TypeDescriptor `description` field** - Add an optional `description` property to `TypeDescriptor` so schema validation errors can display the expected format (e.g. `Expected: ISO date YYYY-MM-DD`)
88

99
## Maybe

llms.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,20 @@ ORDER BY {{orderBy}}
8888
LIMIT {{limit}}
8989
```
9090

91+
### Exporting rendered SQL
92+
93+
Pass `{ exportTo: string }` as the second argument to write the rendered SQL to disk. Missing parent directories are created recursively.
94+
95+
```typescript
96+
const { sql } = getEvents(params, { exportTo: './debug/getEvents.sql' });
97+
```
98+
9199
### Exports
92100

93101
- `defineQuery` - main function (overloaded for generic and schema modes)
94102
- `schema` - built-in schema type descriptors
95103
- `SQL_INJECTION_PATTERNS` - array of { name, regex } patterns used for injection detection
96-
- Types: `TypeDescriptor`, `SchemaDefinition`, `InferParams`, `QueryResult`, `GenericQueryFn`, `SchemaQueryFn`
104+
- Types: `TypeDescriptor`, `SchemaDefinition`, `InferParams`, `QueryResult`, `QueryOptions`, `GenericQueryFn`, `SchemaQueryFn`
97105

98106
## Key details
99107

src/index.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { loadTemplate } from './loader';
22
import { validateAndConvert, escapeValue } from './validator';
33
import { render } from './renderer';
4+
import { writeRendered } from './writer';
45
import type { SchemaDefinition } from './schema';
56
import type {
6-
QueryResult, GenericQueryFn, SchemaQueryFn,
7+
QueryResult, QueryOptions, GenericQueryFn, SchemaQueryFn,
78
} from './types';
89

910
export { SQL_INJECTION_PATTERNS } from './validator';
1011
export { schema } from './schema';
1112
export type { TypeDescriptor, SchemaDefinition, InferParams } from './schema';
1213
export type {
13-
QueryResult, GenericQueryFn, SchemaQueryFn,
14+
QueryResult, QueryOptions, GenericQueryFn, SchemaQueryFn,
1415
} from './types';
1516

1617
export function defineQuery<S extends SchemaDefinition>(
@@ -23,7 +24,7 @@ export function defineQuery<T extends Record<string, string | number | boolean>>
2324
export function defineQuery(
2425
filePath: string,
2526
schemaDef?: SchemaDefinition,
26-
): (params: Record<string, unknown>) => QueryResult {
27+
): (params: Record<string, unknown>, options?: QueryOptions) => QueryResult {
2728
const { template, tokens } = loadTemplate(filePath);
2829

2930
if (schemaDef) {
@@ -53,7 +54,7 @@ export function defineQuery(
5354
}
5455
}
5556

56-
return (params) => {
57+
return (params, options) => {
5758
const paramKeys = Object.keys(params);
5859

5960
const missing = tokens.filter((tok) => !paramKeys.includes(tok));
@@ -87,6 +88,12 @@ export function defineQuery(
8788
}
8889
}
8990

90-
return { sql: render(template, values) };
91+
const sql = render(template, values);
92+
93+
if (options?.exportTo) {
94+
writeRendered(options.exportTo, sql);
95+
}
96+
97+
return { sql };
9198
};
9299
}

src/types.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ export interface QueryResult {
44
sql: string;
55
}
66

7-
export type GenericQueryFn<T> = (params: T) => QueryResult;
7+
export interface QueryOptions {
8+
exportTo?: string;
9+
}
10+
11+
export type GenericQueryFn<T> = (params: T, options?: QueryOptions) => QueryResult;
812

9-
export type SchemaQueryFn<S extends SchemaDefinition> = (params: InferParams<S>) => QueryResult;
13+
export type SchemaQueryFn<S extends SchemaDefinition> = (
14+
params: InferParams<S>,
15+
options?: QueryOptions,
16+
) => QueryResult;

src/writer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
export function writeRendered(filePath: string, sql: string): void {
5+
const resolved = path.resolve(filePath);
6+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
7+
fs.writeFileSync(resolved, sql, 'utf-8');
8+
}

tests/define-query.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
13
import path from 'node:path';
2-
import { describe, it, expect } from 'vitest';
4+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
35
import { defineQuery, schema } from '../src/index';
46

57
const fixture = (name: string) => path.join(__dirname, 'fixtures', name);
@@ -240,6 +242,54 @@ describe('defineQuery — schema mode', () => {
240242
});
241243
});
242244

245+
describe('exportTo option', () => {
246+
let tmpDir: string;
247+
248+
beforeEach(() => {
249+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sql-render-'));
250+
});
251+
252+
afterEach(() => {
253+
fs.rmSync(tmpDir, { recursive: true, force: true });
254+
});
255+
256+
it('writes rendered sql to the given file', () => {
257+
const query = defineQuery(fixture('simple.sql'), {
258+
table: schema.identifier,
259+
id: schema.number,
260+
});
261+
const outPath = path.join(tmpDir, 'rendered.sql');
262+
const { sql } = query({ table: 'users', id: 33 }, { exportTo: outPath });
263+
expect(fs.readFileSync(outPath, 'utf-8')).toBe(sql);
264+
});
265+
266+
it('creates parent directories recursively', () => {
267+
const query = defineQuery(fixture('simple.sql'), {
268+
table: schema.identifier,
269+
id: schema.number,
270+
});
271+
const outPath = path.join(tmpDir, 'nested', 'deep', 'out.sql');
272+
query({ table: 'users', id: 1 }, { exportTo: outPath });
273+
expect(fs.existsSync(outPath)).toBe(true);
274+
});
275+
276+
it('does not write a file when exportTo is omitted', () => {
277+
const query = defineQuery(fixture('simple.sql'), {
278+
table: schema.identifier,
279+
id: schema.number,
280+
});
281+
query({ table: 'users', id: 1 });
282+
expect(fs.readdirSync(tmpDir)).toHaveLength(0);
283+
});
284+
285+
it('works in generic mode', () => {
286+
const query = defineQuery<{ table: string; id: number }>(fixture('simple.sql'));
287+
const outPath = path.join(tmpDir, 'generic.sql');
288+
const { sql } = query({ table: 'users', id: 7 }, { exportTo: outPath });
289+
expect(fs.readFileSync(outPath, 'utf-8')).toBe(sql);
290+
});
291+
});
292+
243293
describe('custom schema types', () => {
244294
it('accepts a custom type descriptor', () => {
245295
const prodTable = {

0 commit comments

Comments
 (0)