sql-render 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bug3
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # sql-render
2
+
3
+ Type-safe `{{variable}}` templating for `.sql` files with built-in injection protection.
4
+
5
+ - Zero runtime dependencies
6
+ - Built-in SQL injection protection
7
+ - Schema-based validation with type inference
8
+ - Custom schema types for project-specific rules
9
+ - Works with any SQL engine (Athena, Trino, PostgreSQL, MySQL, etc.)
10
+ - Compatible with [sql-formatter](https://github.com/sql-formatter-org/sql-formatter)
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install sql-render
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ Create a SQL file with `{{variable}}` placeholders:
21
+
22
+ ```sql
23
+ -- queries/getEvents.sql
24
+ SELECT event_id, event_name
25
+ FROM {{tableName}}
26
+ WHERE status = '{{status}}'
27
+ AND created_at >= '{{startDate}}'
28
+ ORDER BY {{orderBy}}
29
+ LIMIT {{limit}}
30
+ ```
31
+
32
+ Define and use the query in TypeScript:
33
+
34
+ ```typescript
35
+ import { defineQuery } from 'sql-render';
36
+
37
+ const getEvents = defineQuery<{
38
+ tableName: string;
39
+ status: string;
40
+ startDate: string;
41
+ orderBy: string;
42
+ limit: number;
43
+ }>('./queries/getEvents.sql');
44
+
45
+ const { sql } = getEvents({
46
+ tableName: 'prod_events',
47
+ status: 'active',
48
+ startDate: '2024-01-01',
49
+ orderBy: 'created_at',
50
+ limit: 100,
51
+ });
52
+ ```
53
+
54
+ Result:
55
+
56
+ ```sql
57
+ SELECT event_id, event_name
58
+ FROM prod_events
59
+ WHERE status = 'active'
60
+ AND created_at >= '2024-01-01'
61
+ ORDER BY created_at
62
+ LIMIT 100
63
+ ```
64
+
65
+ ## Schema Validation
66
+
67
+ For stricter validation, define a schema instead of a generic type. Types are inferred automatically.
68
+
69
+ ```typescript
70
+ import { defineQuery, schema } from 'sql-render';
71
+
72
+ const getEvents = defineQuery('./queries/getEvents.sql', {
73
+ tableName: schema.identifier,
74
+ status: schema.enum('active', 'pending', 'done'),
75
+ startDate: schema.isoDate,
76
+ orderBy: schema.identifier,
77
+ limit: schema.positiveInt,
78
+ });
79
+
80
+ const { sql } = getEvents({
81
+ tableName: 'prod_events',
82
+ status: 'active',
83
+ startDate: '2024-01-01',
84
+ orderBy: 'created_at',
85
+ limit: 100,
86
+ });
87
+ ```
88
+
89
+ ### Available Schema Types
90
+
91
+ | Type | Format | Example |
92
+ |------|--------|---------|
93
+ | `schema.string` | Any string (with SQL injection check) | `'hello'` |
94
+ | `schema.number` | Finite number | `42`, `3.14` |
95
+ | `schema.boolean` | `true` / `false` | `true` |
96
+ | `schema.isoDate` | `YYYY-MM-DD` | `'2026-04-01'` |
97
+ | `schema.isoTimestamp` | ISO 8601 with timezone | `'2026-04-01T13:57:34.000Z'` |
98
+ | `schema.identifier` | SQL identifier (up to `db.schema.table`) | `'public.users'` |
99
+ | `schema.uuid` | RFC 4122 UUID | `'550e8400-e29b-41d4-a716-446655440000'` |
100
+ | `schema.positiveInt` | Positive integer | `100` |
101
+ | `schema.enum(...)` | Whitelist of allowed values | `schema.enum('asc', 'desc')` |
102
+ | `schema.s3Path` | S3 URI | `'s3://bucket/path/'` |
103
+
104
+ ### Custom Schema Types
105
+
106
+ Define your own type descriptors for project-specific validation:
107
+
108
+ ```typescript
109
+ import { defineQuery, schema } from 'sql-render';
110
+
111
+ const prodTable = {
112
+ validate: (val: unknown) => typeof val === 'string' && val.startsWith('prod_'),
113
+ };
114
+
115
+ const query = defineQuery('./query.sql', {
116
+ table: prodTable,
117
+ startDate: schema.isoDate,
118
+ limit: schema.positiveInt,
119
+ });
120
+ ```
121
+
122
+ A type descriptor is any object with a `validate(val: unknown) => boolean` method.
123
+
124
+ ## SQL Injection Protection
125
+
126
+ `schema.string` and the generic `string` type check values against built-in patterns:
127
+
128
+ | Pattern | Examples |
129
+ |---------|----------|
130
+ | Comments | `--`, `/*`, `*/` |
131
+ | Statement separator | `;` |
132
+ | DDL commands | `DROP`, `ALTER`, `TRUNCATE`, `CREATE` |
133
+ | UNION injection | `UNION SELECT`, `UNION ALL SELECT` |
134
+ | DML commands | `INSERT INTO`, `DELETE FROM`, `UPDATE ... SET` |
135
+ | Execution | `EXEC`, `EXECUTE` |
136
+ | Time-based | `SLEEP()`, `BENCHMARK()`, `WAITFOR DELAY` |
137
+ | System procedures | `xp_*`, `sp_*` |
138
+ | Privilege commands | `GRANT`, `REVOKE` |
139
+ | File operations | `LOAD_FILE()`, `INTO OUTFILE`, `INTO DUMPFILE` |
140
+ | Data loading | `LOAD DATA` |
141
+
142
+ Patterns use word boundaries to avoid false positives (e.g., "backdrop" won't trigger `DROP`).
143
+
144
+ Other schema types like `schema.identifier`, `schema.isoDate`, `schema.uuid` etc. are inherently safe due to their strict format validation.
145
+
146
+ The pattern list is exported for reference:
147
+
148
+ ```typescript
149
+ import { SQL_INJECTION_PATTERNS } from 'sql-render';
150
+ ```
151
+
152
+ ## Error Messages
153
+
154
+ | Scenario | Error |
155
+ |----------|-------|
156
+ | File not found | `File not found: ./query.sql` |
157
+ | Schema mismatch | `Schema missing definitions for template variables: [id]` |
158
+ | Missing params | `Missing variables in params: [tableName, limit]` |
159
+ | Extra params | `Extra variables not in template: [foo]` |
160
+ | Schema validation | `Schema validation failed for 'status'` |
161
+ | Type validation | `SQL injection pattern detected in 'status': ...` |
162
+ | Null/undefined | `Validation failed for 'key': value cannot be null or undefined` |
163
+ | Invalid descriptor | `Invalid schema descriptor for 'key': must have a validate(val) method` |
164
+
165
+ ## Security Model
166
+
167
+ sql-render protects against SQL injection using a **denylist + escape** strategy, not parameterized queries (prepared statements). Values are validated and escaped before being interpolated directly into the SQL string.
168
+
169
+ This is effective for engines that don't support parameterized queries (e.g., Athena, Trino DDL, ad-hoc SQL scripts). If your database driver supports parameterized queries, prefer using them as the primary defense and treat sql-render's protection as an additional layer.
170
+
171
+ The built-in denylist does not guarantee 100% protection against all SQL injection vectors. For stricter control, define [custom schema types](#custom-schema-types) tailored to your project's specific validation needs.
172
+
173
+ ## sql-formatter Compatibility
174
+
175
+ The `{{variable}}` syntax is fully compatible with [sql-formatter](https://github.com/sql-formatter-org/sql-formatter). A `paramTypes` custom regex is required so that `{{variables}}` containing SQL keywords (e.g. `{{limit}}`) are treated as parameters instead of being parsed as SQL.
176
+
177
+ ### VS Code
178
+
179
+ Install the [SQL Formatter VSCode](https://marketplace.visualstudio.com/items?itemName=ReneSaarsoo.sql-formatter-vsc) extension, then copy [`.vscode/settings.json`](.vscode/settings.json) into your project to enable format-on-save with the recommended settings:
180
+
181
+ ```json
182
+ {
183
+ "[sql]": {
184
+ "editor.defaultFormatter": "ReneSaarsoo.sql-formatter-vsc",
185
+ "editor.formatOnSave": true
186
+ },
187
+ "SQL-Formatter-VSCode.dialect": "trino",
188
+ "SQL-Formatter-VSCode.keywordCase": "upper",
189
+ "SQL-Formatter-VSCode.functionCase": "upper",
190
+ "SQL-Formatter-VSCode.dataTypeCase": "upper",
191
+ "SQL-Formatter-VSCode.paramTypes": {
192
+ "custom": [{ "regex": "\\{\\{[a-zA-Z_][a-zA-Z0-9_]*\\}\\}" }]
193
+ }
194
+ }
195
+ ```
196
+
197
+ ## License
198
+
199
+ [MIT](https://github.com/bug3/sql-render/blob/master/LICENSE)
package/dist/index.cjs ADDED
@@ -0,0 +1,239 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ SQL_INJECTION_PATTERNS: () => SQL_INJECTION_PATTERNS,
34
+ defineQuery: () => defineQuery,
35
+ schema: () => schema
36
+ });
37
+ module.exports = __toCommonJS(index_exports);
38
+
39
+ // src/loader.ts
40
+ var import_node_fs = __toESM(require("fs"));
41
+ var import_node_path = __toESM(require("path"));
42
+ var TOKEN_REGEX = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
43
+ function loadTemplate(filePath) {
44
+ const resolved = import_node_path.default.resolve(filePath);
45
+ if (!import_node_fs.default.existsSync(resolved)) {
46
+ throw new Error(`File not found: ${filePath}`);
47
+ }
48
+ const template = import_node_fs.default.readFileSync(resolved, "utf-8");
49
+ const seen = /* @__PURE__ */ new Set();
50
+ const tokens = [];
51
+ for (const match of template.matchAll(TOKEN_REGEX)) {
52
+ if (!seen.has(match[1])) {
53
+ seen.add(match[1]);
54
+ tokens.push(match[1]);
55
+ }
56
+ }
57
+ return { template, tokens };
58
+ }
59
+
60
+ // src/validator.ts
61
+ var SQL_INJECTION_PATTERNS = [
62
+ { name: "inline comment", regex: /--/ },
63
+ { name: "block comment open", regex: /\/\*/ },
64
+ { name: "block comment close", regex: /\*\// },
65
+ { name: "statement separator", regex: /;/ },
66
+ { name: "DDL command", regex: /\b(DROP|ALTER|TRUNCATE|CREATE)\b/i },
67
+ { name: "UNION injection", regex: /\bUNION\s+(ALL\s+)?SELECT\b/i },
68
+ { name: "INSERT", regex: /\bINSERT\s+INTO\b/i },
69
+ { name: "DELETE", regex: /\bDELETE\s+FROM\b/i },
70
+ { name: "UPDATE", regex: /\bUPDATE\s+\S+\s+SET\b/i },
71
+ { name: "EXEC", regex: /\b(EXEC|EXECUTE)\b/i },
72
+ { name: "time-based injection", regex: /\b(SLEEP|BENCHMARK)\s*\(/i },
73
+ { name: "WAITFOR", regex: /\bWAITFOR\s+DELAY\b/i },
74
+ { name: "system procedure", regex: /\b(xp_|sp_)\w+/i },
75
+ { name: "GRANT/REVOKE", regex: /\b(GRANT|REVOKE)\b/i },
76
+ { name: "file operation", regex: /\b(LOAD_FILE|INTO\s+OUTFILE|INTO\s+DUMPFILE)\b/i },
77
+ { name: "LOAD DATA", regex: /\bLOAD\s+DATA\b/i }
78
+ ];
79
+ function detectSqlInjection(key, value) {
80
+ for (const pattern of SQL_INJECTION_PATTERNS) {
81
+ if (pattern.regex.test(value)) {
82
+ throw new Error(
83
+ `SQL injection pattern detected in '${key}': value contains forbidden pattern (${pattern.name})`
84
+ );
85
+ }
86
+ }
87
+ }
88
+ function escapeValue(value) {
89
+ if (typeof value === "string") {
90
+ return value.replace(/'/g, "''");
91
+ }
92
+ return String(value);
93
+ }
94
+ function validateAndConvert(key, value) {
95
+ if (value === null || value === void 0) {
96
+ throw new Error(`Validation failed for '${key}': value cannot be null or undefined`);
97
+ }
98
+ switch (typeof value) {
99
+ case "number":
100
+ if (Number.isNaN(value) || !Number.isFinite(value)) {
101
+ throw new Error(`Validation failed for '${key}': expected a finite number`);
102
+ }
103
+ return String(value);
104
+ case "boolean":
105
+ return String(value);
106
+ case "string":
107
+ detectSqlInjection(key, value);
108
+ return escapeValue(value);
109
+ default:
110
+ throw new Error(
111
+ `Validation failed for '${key}': unsupported type '${typeof value}'`
112
+ );
113
+ }
114
+ }
115
+
116
+ // src/renderer.ts
117
+ function render(template, values) {
118
+ let result = template;
119
+ for (const [key, value] of Object.entries(values)) {
120
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
121
+ const pattern = new RegExp(`\\{\\{${escaped}\\}\\}`, "g");
122
+ result = result.replace(pattern, value);
123
+ }
124
+ return result;
125
+ }
126
+
127
+ // src/schema.ts
128
+ var ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
129
+ var ISO_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}:\d{2})$/;
130
+ var IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*){0,2}$/;
131
+ var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
132
+ var S3_PATH_REGEX = /^s3:\/\/[a-z0-9][a-z0-9.-]{1,61}[a-z0-9](\/[a-zA-Z0-9._\-/=]*)?$/;
133
+ function descriptor(validate) {
134
+ return { validate };
135
+ }
136
+ function isString(val) {
137
+ return typeof val === "string";
138
+ }
139
+ function isValidDate(year, month, day) {
140
+ const d = new Date(year, month - 1, day);
141
+ return d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day;
142
+ }
143
+ function isValidTime(hours, minutes, seconds) {
144
+ return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 && seconds >= 0 && seconds <= 59;
145
+ }
146
+ var schema = {
147
+ string: descriptor((val) => {
148
+ if (!isString(val)) return false;
149
+ return !SQL_INJECTION_PATTERNS.some((p) => p.regex.test(val));
150
+ }),
151
+ number: descriptor((val) => typeof val === "number" && Number.isFinite(val)),
152
+ boolean: descriptor((val) => typeof val === "boolean"),
153
+ isoDate: descriptor((val) => {
154
+ if (!isString(val) || !ISO_DATE_REGEX.test(val)) return false;
155
+ const [y, m, d] = val.split("-").map(Number);
156
+ return isValidDate(y, m, d);
157
+ }),
158
+ isoTimestamp: descriptor((val) => {
159
+ if (!isString(val) || !ISO_TIMESTAMP_REGEX.test(val)) return false;
160
+ const [datePart, rest] = val.split("T");
161
+ const [y, m, d] = datePart.split("-").map(Number);
162
+ if (!isValidDate(y, m, d)) return false;
163
+ const timePart = rest.replace(/[Z+-].*$/, "");
164
+ const [hh, mm, ssRaw] = timePart.split(":");
165
+ const ss = parseFloat(ssRaw);
166
+ return isValidTime(Number(hh), Number(mm), Math.floor(ss));
167
+ }),
168
+ identifier: descriptor((val) => isString(val) && IDENTIFIER_REGEX.test(val)),
169
+ uuid: descriptor((val) => isString(val) && UUID_REGEX.test(val)),
170
+ positiveInt: descriptor((val) => typeof val === "number" && Number.isInteger(val) && val > 0),
171
+ s3Path: descriptor((val) => isString(val) && S3_PATH_REGEX.test(val)),
172
+ enum: (...values) => descriptor(
173
+ (val) => isString(val) && values.includes(val)
174
+ )
175
+ };
176
+
177
+ // src/index.ts
178
+ function defineQuery(filePath, schemaDef) {
179
+ const { template, tokens } = loadTemplate(filePath);
180
+ if (schemaDef) {
181
+ const schemaKeys = Object.keys(schemaDef);
182
+ for (const key of schemaKeys) {
183
+ const desc = schemaDef[key];
184
+ if (!desc || typeof desc.validate !== "function") {
185
+ throw new Error(
186
+ `Invalid schema descriptor for '${key}': must have a validate(val) method`
187
+ );
188
+ }
189
+ }
190
+ const missingInSchema = tokens.filter((tok) => !schemaKeys.includes(tok));
191
+ if (missingInSchema.length > 0) {
192
+ throw new Error(
193
+ `Schema missing definitions for template variables: [${missingInSchema.join(", ")}]`
194
+ );
195
+ }
196
+ const extraInSchema = schemaKeys.filter((k) => !tokens.includes(k));
197
+ if (extraInSchema.length > 0) {
198
+ throw new Error(
199
+ `Schema defines variables not in template: [${extraInSchema.join(", ")}]`
200
+ );
201
+ }
202
+ }
203
+ return (params) => {
204
+ const paramKeys = Object.keys(params);
205
+ const missing = tokens.filter((tok) => !paramKeys.includes(tok));
206
+ if (missing.length > 0) {
207
+ throw new Error(`Missing variables in params: [${missing.join(", ")}]`);
208
+ }
209
+ const extra = paramKeys.filter((k) => !tokens.includes(k));
210
+ if (extra.length > 0) {
211
+ throw new Error(`Extra variables not in template: [${extra.join(", ")}]`);
212
+ }
213
+ const values = {};
214
+ for (const key of tokens) {
215
+ const value = params[key];
216
+ if (schemaDef) {
217
+ if (value === null || value === void 0) {
218
+ throw new Error(`Validation failed for '${key}': value cannot be null or undefined`);
219
+ }
220
+ const desc = schemaDef[key];
221
+ if (!desc.validate(value)) {
222
+ throw new Error(
223
+ `Schema validation failed for '${key}': received ${typeof value} (${JSON.stringify(value)})`
224
+ );
225
+ }
226
+ values[key] = escapeValue(value);
227
+ } else {
228
+ values[key] = validateAndConvert(key, value);
229
+ }
230
+ }
231
+ return { sql: render(template, values) };
232
+ };
233
+ }
234
+ // Annotate the CommonJS export names for ESM import in node:
235
+ 0 && (module.exports = {
236
+ SQL_INJECTION_PATTERNS,
237
+ defineQuery,
238
+ schema
239
+ });
@@ -0,0 +1,36 @@
1
+ interface TypeDescriptor<T = unknown> {
2
+ readonly __phantom?: T;
3
+ validate(val: unknown): boolean;
4
+ }
5
+ declare const schema: {
6
+ string: TypeDescriptor<string>;
7
+ number: TypeDescriptor<number>;
8
+ boolean: TypeDescriptor<boolean>;
9
+ isoDate: TypeDescriptor<string>;
10
+ isoTimestamp: TypeDescriptor<string>;
11
+ identifier: TypeDescriptor<string>;
12
+ uuid: TypeDescriptor<string>;
13
+ positiveInt: TypeDescriptor<number>;
14
+ s3Path: TypeDescriptor<string>;
15
+ enum: <T extends string>(...values: T[]) => TypeDescriptor<T>;
16
+ };
17
+ type SchemaDefinition = Record<string, TypeDescriptor>;
18
+ type InferParams<S extends SchemaDefinition> = {
19
+ [K in keyof S]: S[K] extends TypeDescriptor<infer T> ? T : never;
20
+ };
21
+
22
+ interface QueryResult {
23
+ sql: string;
24
+ }
25
+ type GenericQueryFn<T> = (params: T) => QueryResult;
26
+ type SchemaQueryFn<S extends SchemaDefinition> = (params: InferParams<S>) => QueryResult;
27
+
28
+ declare const SQL_INJECTION_PATTERNS: {
29
+ name: string;
30
+ regex: RegExp;
31
+ }[];
32
+
33
+ declare function defineQuery<S extends SchemaDefinition>(filePath: string, schemaDef: S): SchemaQueryFn<S>;
34
+ declare function defineQuery<T extends Record<string, string | number | boolean>>(filePath: string): GenericQueryFn<T>;
35
+
36
+ export { type GenericQueryFn, type InferParams, type QueryResult, SQL_INJECTION_PATTERNS, type SchemaDefinition, type SchemaQueryFn, type TypeDescriptor, defineQuery, schema };
@@ -0,0 +1,36 @@
1
+ interface TypeDescriptor<T = unknown> {
2
+ readonly __phantom?: T;
3
+ validate(val: unknown): boolean;
4
+ }
5
+ declare const schema: {
6
+ string: TypeDescriptor<string>;
7
+ number: TypeDescriptor<number>;
8
+ boolean: TypeDescriptor<boolean>;
9
+ isoDate: TypeDescriptor<string>;
10
+ isoTimestamp: TypeDescriptor<string>;
11
+ identifier: TypeDescriptor<string>;
12
+ uuid: TypeDescriptor<string>;
13
+ positiveInt: TypeDescriptor<number>;
14
+ s3Path: TypeDescriptor<string>;
15
+ enum: <T extends string>(...values: T[]) => TypeDescriptor<T>;
16
+ };
17
+ type SchemaDefinition = Record<string, TypeDescriptor>;
18
+ type InferParams<S extends SchemaDefinition> = {
19
+ [K in keyof S]: S[K] extends TypeDescriptor<infer T> ? T : never;
20
+ };
21
+
22
+ interface QueryResult {
23
+ sql: string;
24
+ }
25
+ type GenericQueryFn<T> = (params: T) => QueryResult;
26
+ type SchemaQueryFn<S extends SchemaDefinition> = (params: InferParams<S>) => QueryResult;
27
+
28
+ declare const SQL_INJECTION_PATTERNS: {
29
+ name: string;
30
+ regex: RegExp;
31
+ }[];
32
+
33
+ declare function defineQuery<S extends SchemaDefinition>(filePath: string, schemaDef: S): SchemaQueryFn<S>;
34
+ declare function defineQuery<T extends Record<string, string | number | boolean>>(filePath: string): GenericQueryFn<T>;
35
+
36
+ export { type GenericQueryFn, type InferParams, type QueryResult, SQL_INJECTION_PATTERNS, type SchemaDefinition, type SchemaQueryFn, type TypeDescriptor, defineQuery, schema };
package/dist/index.mjs ADDED
@@ -0,0 +1,200 @@
1
+ // src/loader.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ var TOKEN_REGEX = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
5
+ function loadTemplate(filePath) {
6
+ const resolved = path.resolve(filePath);
7
+ if (!fs.existsSync(resolved)) {
8
+ throw new Error(`File not found: ${filePath}`);
9
+ }
10
+ const template = fs.readFileSync(resolved, "utf-8");
11
+ const seen = /* @__PURE__ */ new Set();
12
+ const tokens = [];
13
+ for (const match of template.matchAll(TOKEN_REGEX)) {
14
+ if (!seen.has(match[1])) {
15
+ seen.add(match[1]);
16
+ tokens.push(match[1]);
17
+ }
18
+ }
19
+ return { template, tokens };
20
+ }
21
+
22
+ // src/validator.ts
23
+ var SQL_INJECTION_PATTERNS = [
24
+ { name: "inline comment", regex: /--/ },
25
+ { name: "block comment open", regex: /\/\*/ },
26
+ { name: "block comment close", regex: /\*\// },
27
+ { name: "statement separator", regex: /;/ },
28
+ { name: "DDL command", regex: /\b(DROP|ALTER|TRUNCATE|CREATE)\b/i },
29
+ { name: "UNION injection", regex: /\bUNION\s+(ALL\s+)?SELECT\b/i },
30
+ { name: "INSERT", regex: /\bINSERT\s+INTO\b/i },
31
+ { name: "DELETE", regex: /\bDELETE\s+FROM\b/i },
32
+ { name: "UPDATE", regex: /\bUPDATE\s+\S+\s+SET\b/i },
33
+ { name: "EXEC", regex: /\b(EXEC|EXECUTE)\b/i },
34
+ { name: "time-based injection", regex: /\b(SLEEP|BENCHMARK)\s*\(/i },
35
+ { name: "WAITFOR", regex: /\bWAITFOR\s+DELAY\b/i },
36
+ { name: "system procedure", regex: /\b(xp_|sp_)\w+/i },
37
+ { name: "GRANT/REVOKE", regex: /\b(GRANT|REVOKE)\b/i },
38
+ { name: "file operation", regex: /\b(LOAD_FILE|INTO\s+OUTFILE|INTO\s+DUMPFILE)\b/i },
39
+ { name: "LOAD DATA", regex: /\bLOAD\s+DATA\b/i }
40
+ ];
41
+ function detectSqlInjection(key, value) {
42
+ for (const pattern of SQL_INJECTION_PATTERNS) {
43
+ if (pattern.regex.test(value)) {
44
+ throw new Error(
45
+ `SQL injection pattern detected in '${key}': value contains forbidden pattern (${pattern.name})`
46
+ );
47
+ }
48
+ }
49
+ }
50
+ function escapeValue(value) {
51
+ if (typeof value === "string") {
52
+ return value.replace(/'/g, "''");
53
+ }
54
+ return String(value);
55
+ }
56
+ function validateAndConvert(key, value) {
57
+ if (value === null || value === void 0) {
58
+ throw new Error(`Validation failed for '${key}': value cannot be null or undefined`);
59
+ }
60
+ switch (typeof value) {
61
+ case "number":
62
+ if (Number.isNaN(value) || !Number.isFinite(value)) {
63
+ throw new Error(`Validation failed for '${key}': expected a finite number`);
64
+ }
65
+ return String(value);
66
+ case "boolean":
67
+ return String(value);
68
+ case "string":
69
+ detectSqlInjection(key, value);
70
+ return escapeValue(value);
71
+ default:
72
+ throw new Error(
73
+ `Validation failed for '${key}': unsupported type '${typeof value}'`
74
+ );
75
+ }
76
+ }
77
+
78
+ // src/renderer.ts
79
+ function render(template, values) {
80
+ let result = template;
81
+ for (const [key, value] of Object.entries(values)) {
82
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
83
+ const pattern = new RegExp(`\\{\\{${escaped}\\}\\}`, "g");
84
+ result = result.replace(pattern, value);
85
+ }
86
+ return result;
87
+ }
88
+
89
+ // src/schema.ts
90
+ var ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
91
+ var ISO_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?(Z|[+-]\d{2}:\d{2})$/;
92
+ var IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*){0,2}$/;
93
+ var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
94
+ var S3_PATH_REGEX = /^s3:\/\/[a-z0-9][a-z0-9.-]{1,61}[a-z0-9](\/[a-zA-Z0-9._\-/=]*)?$/;
95
+ function descriptor(validate) {
96
+ return { validate };
97
+ }
98
+ function isString(val) {
99
+ return typeof val === "string";
100
+ }
101
+ function isValidDate(year, month, day) {
102
+ const d = new Date(year, month - 1, day);
103
+ return d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day;
104
+ }
105
+ function isValidTime(hours, minutes, seconds) {
106
+ return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 && seconds >= 0 && seconds <= 59;
107
+ }
108
+ var schema = {
109
+ string: descriptor((val) => {
110
+ if (!isString(val)) return false;
111
+ return !SQL_INJECTION_PATTERNS.some((p) => p.regex.test(val));
112
+ }),
113
+ number: descriptor((val) => typeof val === "number" && Number.isFinite(val)),
114
+ boolean: descriptor((val) => typeof val === "boolean"),
115
+ isoDate: descriptor((val) => {
116
+ if (!isString(val) || !ISO_DATE_REGEX.test(val)) return false;
117
+ const [y, m, d] = val.split("-").map(Number);
118
+ return isValidDate(y, m, d);
119
+ }),
120
+ isoTimestamp: descriptor((val) => {
121
+ if (!isString(val) || !ISO_TIMESTAMP_REGEX.test(val)) return false;
122
+ const [datePart, rest] = val.split("T");
123
+ const [y, m, d] = datePart.split("-").map(Number);
124
+ if (!isValidDate(y, m, d)) return false;
125
+ const timePart = rest.replace(/[Z+-].*$/, "");
126
+ const [hh, mm, ssRaw] = timePart.split(":");
127
+ const ss = parseFloat(ssRaw);
128
+ return isValidTime(Number(hh), Number(mm), Math.floor(ss));
129
+ }),
130
+ identifier: descriptor((val) => isString(val) && IDENTIFIER_REGEX.test(val)),
131
+ uuid: descriptor((val) => isString(val) && UUID_REGEX.test(val)),
132
+ positiveInt: descriptor((val) => typeof val === "number" && Number.isInteger(val) && val > 0),
133
+ s3Path: descriptor((val) => isString(val) && S3_PATH_REGEX.test(val)),
134
+ enum: (...values) => descriptor(
135
+ (val) => isString(val) && values.includes(val)
136
+ )
137
+ };
138
+
139
+ // src/index.ts
140
+ function defineQuery(filePath, schemaDef) {
141
+ const { template, tokens } = loadTemplate(filePath);
142
+ if (schemaDef) {
143
+ const schemaKeys = Object.keys(schemaDef);
144
+ for (const key of schemaKeys) {
145
+ const desc = schemaDef[key];
146
+ if (!desc || typeof desc.validate !== "function") {
147
+ throw new Error(
148
+ `Invalid schema descriptor for '${key}': must have a validate(val) method`
149
+ );
150
+ }
151
+ }
152
+ const missingInSchema = tokens.filter((tok) => !schemaKeys.includes(tok));
153
+ if (missingInSchema.length > 0) {
154
+ throw new Error(
155
+ `Schema missing definitions for template variables: [${missingInSchema.join(", ")}]`
156
+ );
157
+ }
158
+ const extraInSchema = schemaKeys.filter((k) => !tokens.includes(k));
159
+ if (extraInSchema.length > 0) {
160
+ throw new Error(
161
+ `Schema defines variables not in template: [${extraInSchema.join(", ")}]`
162
+ );
163
+ }
164
+ }
165
+ return (params) => {
166
+ const paramKeys = Object.keys(params);
167
+ const missing = tokens.filter((tok) => !paramKeys.includes(tok));
168
+ if (missing.length > 0) {
169
+ throw new Error(`Missing variables in params: [${missing.join(", ")}]`);
170
+ }
171
+ const extra = paramKeys.filter((k) => !tokens.includes(k));
172
+ if (extra.length > 0) {
173
+ throw new Error(`Extra variables not in template: [${extra.join(", ")}]`);
174
+ }
175
+ const values = {};
176
+ for (const key of tokens) {
177
+ const value = params[key];
178
+ if (schemaDef) {
179
+ if (value === null || value === void 0) {
180
+ throw new Error(`Validation failed for '${key}': value cannot be null or undefined`);
181
+ }
182
+ const desc = schemaDef[key];
183
+ if (!desc.validate(value)) {
184
+ throw new Error(
185
+ `Schema validation failed for '${key}': received ${typeof value} (${JSON.stringify(value)})`
186
+ );
187
+ }
188
+ values[key] = escapeValue(value);
189
+ } else {
190
+ values[key] = validateAndConvert(key, value);
191
+ }
192
+ }
193
+ return { sql: render(template, values) };
194
+ };
195
+ }
196
+ export {
197
+ SQL_INJECTION_PATTERNS,
198
+ defineQuery,
199
+ schema
200
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "sql-render",
3
+ "version": "0.1.0",
4
+ "description": "Type-safe {{variable}} templating for .sql files with built-in injection protection",
5
+ "author": "bug3",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.mjs",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.cjs"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "lint": "eslint src/ tests/",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "sql",
27
+ "sql-template",
28
+ "sql-render",
29
+ "sql-injection",
30
+ "athena",
31
+ "aws-athena",
32
+ "trino",
33
+ "query-builder",
34
+ "type-safe",
35
+ "schema-validation",
36
+ "typescript"
37
+ ],
38
+ "license": "MIT",
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "devDependencies": {
43
+ "@eslint/js": "^9.39.4",
44
+ "@types/node": "^25.5.2",
45
+ "eslint": "^9.39.4",
46
+ "sql-formatter": "^15.7.3",
47
+ "tsup": "^8.5.1",
48
+ "typescript": "^6.0.2",
49
+ "typescript-eslint": "^8.58.0",
50
+ "vitest": "^4.1.2"
51
+ }
52
+ }