@casekit/sql 0.0.0-20250322230249

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.
@@ -0,0 +1 @@
1
+ export { SQLStatement, sql } from "./sql.js";
package/build/index.js ADDED
@@ -0,0 +1,3 @@
1
+ /* v8 ignore start */
2
+ export { SQLStatement, sql } from "./sql.js";
3
+ /* v8 ignore stop */
package/build/sql.d.ts ADDED
@@ -0,0 +1,94 @@
1
+ import pg from "pg";
2
+ import { ZodSchema, z } from "zod";
3
+ type SQLStatementTaggedTemplateLiteral<ResultType extends pg.QueryResultRow = pg.QueryResultRow> = (fragments: TemplateStringsArray, ...values: readonly unknown[]) => SQLStatement<ResultType>;
4
+ /**
5
+ * @function
6
+ * Tagged template literal that safely parameterizes an interpolated SQL query.
7
+ * @example
8
+ * pg.query(sql`SELECT * FROM users WHERE id = ${userId}`)
9
+ * // is the equivalent of
10
+ * pg.query("SELECT * FROM users WHERE id = $1", [userId])
11
+ *
12
+ * It's also possible to pass a zod schema to the sql tag
13
+ * to validate and type the result:
14
+ *
15
+ * const userSchema = sql(z.object({ id: z.number(), name: z.string() }))`
16
+ * SELECT id, name FROM users WHERE id = ${userId}
17
+ * `;
18
+ */
19
+ declare function sql<ResultType extends pg.QueryResultRow = pg.QueryResultRow>(fragments: TemplateStringsArray, ...values: readonly unknown[]): SQLStatement<ResultType>;
20
+ declare function sql<ResultType extends pg.QueryResultRow = pg.QueryResultRow>(schema: z.ZodSchema<ResultType>): SQLStatementTaggedTemplateLiteral<ResultType>;
21
+ declare namespace sql {
22
+ var array: (values: readonly unknown[]) => SQLValueArray;
23
+ var ident: (value: string) => SQLStatement<pg.QueryResultRow>;
24
+ var literal: (value: string) => SQLStatement<pg.QueryResultRow>;
25
+ var join: (values: SQLStatement[], separator?: string) => SQLStatement<pg.QueryResultRow>;
26
+ var value: (value: unknown) => SQLValueArray | SQLStatement<pg.QueryResultRow>;
27
+ }
28
+ /**
29
+ * Class representing a SQL statement created by the `sql` template tag.
30
+ * Stores an array of text fragments and an array of values to interpolate,
31
+ * such that the correct parameter placeholders can be generated when
32
+ * the query is complete.
33
+ * @ignore
34
+ * NB. The SQLStatement can be parameterized with a type parameter `ResultType' for the type of the row
35
+ * it returns. This isn't used in the class itself, but is used by the `orm.query` method
36
+ * when the SQLStatement is passed to it to determine the type of the row. However, if the
37
+ * call to orm.query is itself parameterized with a type, that type will take precedence.
38
+ */
39
+ export declare class SQLStatement<ResultType extends pg.QueryResultRow = pg.QueryResultRow> {
40
+ readonly _text: string[];
41
+ readonly _values: unknown[];
42
+ protected _schema?: ZodSchema<ResultType>;
43
+ constructor(text?: readonly string[] | string, values?: readonly unknown[]);
44
+ /**
45
+ * This accessor is called by node-postgres when passed as a query,
46
+ * allowing us to pass a SQLStatement to `client.query` directly.
47
+ */
48
+ get text(): string;
49
+ /**
50
+ * This accessor is called by node-postgres when passed as a query,
51
+ * allowing us to pass a SQLStatement to `client.query` directly.
52
+ *
53
+ * By default, when passed an array as a value, we auto-expand it into
54
+ * multiple values. Because there are some cases where we actually want to pass
55
+ * an array as a single value, we have a SQLValueArray class to indicate this.
56
+ * This class prevents the array from being expanded into multiple values.
57
+ *
58
+ * We do it this way round because it's more common to want to expand arrays
59
+ * than to want to pass them as a single value.
60
+ */
61
+ get values(): unknown[];
62
+ /**
63
+ * Get the pretty-printed version of the SQL statement.
64
+ * We don't automatically apply this for two reasons:
65
+ * 1. Speed
66
+ * 2. It increases the potential attack surface area
67
+ */
68
+ get pretty(): string;
69
+ append(fragments: TemplateStringsArray, ...values: unknown[]): this;
70
+ /**
71
+ * Allows appending other SQLStatements onto this one, combining them.
72
+ * Mainly used internally. Mutates the SQLStatement and returns `this`.
73
+ */
74
+ push(...others: SQLStatement[]): this;
75
+ /**
76
+ * The Zod schema that will be used to parse and validate
77
+ * the results of the query when it is passed to `orm.query`.
78
+ */
79
+ get schema(): ZodSchema<ResultType> | undefined;
80
+ /**
81
+ * Assign a zod schema to this SQLStatement, which will be used to validate
82
+ * the result of the query when it is passed to `orm.query`.
83
+ */
84
+ withSchema<Schema extends ZodSchema>(schema: Schema): SQLStatement<z.infer<Schema>>;
85
+ }
86
+ /**
87
+ * A class to wrap an array value that we don't want
88
+ * to auto-expand into multiple parameters.
89
+ */
90
+ export declare class SQLValueArray {
91
+ values: unknown[];
92
+ constructor(values: readonly unknown[]);
93
+ }
94
+ export { sql };
package/build/sql.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * This file is heavily inspired by felixfbecker/node-sql-template-strings,
3
+ * which has the following licence:
4
+ *
5
+ * ISC License
6
+ * Copyright (c) 2016, Felix Frederick Becker
7
+ *
8
+ * Permission to use, copy, modify, and/or distribute this software for any
9
+ * purpose with or without fee is hereby granted, provided that the above
10
+ * copyright notice and this permission notice appear in all copies.
11
+ *
12
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
13
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
14
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
15
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
16
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
17
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
18
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
19
+ */
20
+ import { zip } from "es-toolkit";
21
+ import pg from "pg";
22
+ import { format } from "sql-formatter";
23
+ import { z } from "zod";
24
+ import { interleave } from "@casekit/toolbox";
25
+ import { unindent } from "@casekit/unindent";
26
+ const joinFragments = (x, y) => {
27
+ const initX = x.text.slice(0, -1);
28
+ const lastX = x.text[x.text.length - 1];
29
+ const tailY = y.text.slice(1);
30
+ const firstY = y.text[0];
31
+ return {
32
+ text: [...initX, lastX + firstY, ...tailY],
33
+ values: [...x.values, ...y.values],
34
+ };
35
+ };
36
+ const expandFragment = ([fragment, value]) => {
37
+ if (value === undefined) {
38
+ return { text: [fragment], values: [] };
39
+ }
40
+ else if (value === null) {
41
+ return { text: [fragment + "NULL"], values: [] };
42
+ }
43
+ else if (value === true) {
44
+ return { text: [fragment + "TRUE"], values: [] };
45
+ }
46
+ else if (value === false) {
47
+ return { text: [fragment + "FALSE"], values: [] };
48
+ }
49
+ else if (value instanceof SQLStatement) {
50
+ const expanded = zip(value._text, value._values)
51
+ .map(expandFragment)
52
+ .reduce(joinFragments, {
53
+ text: [""],
54
+ values: [],
55
+ });
56
+ return {
57
+ text: [fragment + expanded.text[0], ...expanded.text.slice(1)],
58
+ values: [...expanded.values],
59
+ };
60
+ }
61
+ else if (value.constructor.name === "SQLStatement") {
62
+ console.error("It looks like you have multiple versions of the SQLStatement class in memory. ");
63
+ console.error("This can happen if you have multiple versions of the @casekit/orm or @casekit/sql packages installed.");
64
+ console.error("Please ensure you only have one version of these packages installed, and that related @casekit/orm2-* packages are all at the same version.");
65
+ throw new Error("Multiple versions of SQLStatement class detected");
66
+ }
67
+ else if (Array.isArray(value) && value.length === 0) {
68
+ return { text: [fragment + "NULL"], values: [] };
69
+ }
70
+ else if (Array.isArray(value) && value.length > 0) {
71
+ return value
72
+ .map((v, i) => expandFragment([i === 0 ? "" : ", ", v]))
73
+ .reduce(joinFragments, { text: [fragment], values: [] });
74
+ }
75
+ else {
76
+ return { text: [fragment, ""], values: [value] };
77
+ }
78
+ };
79
+ function sql(fragmentsOrSchema, ...values) {
80
+ if (fragmentsOrSchema instanceof z.ZodSchema) {
81
+ return (fragments, ...values) => sql(fragments, ...values).withSchema(fragmentsOrSchema);
82
+ }
83
+ const result = zip(fragmentsOrSchema, values)
84
+ .map(expandFragment)
85
+ .reduce(joinFragments, { text: [""], values: [] });
86
+ return new SQLStatement(result.text, result.values);
87
+ }
88
+ /**
89
+ * Class representing a SQL statement created by the `sql` template tag.
90
+ * Stores an array of text fragments and an array of values to interpolate,
91
+ * such that the correct parameter placeholders can be generated when
92
+ * the query is complete.
93
+ * @ignore
94
+ * NB. The SQLStatement can be parameterized with a type parameter `ResultType' for the type of the row
95
+ * it returns. This isn't used in the class itself, but is used by the `orm.query` method
96
+ * when the SQLStatement is passed to it to determine the type of the row. However, if the
97
+ * call to orm.query is itself parameterized with a type, that type will take precedence.
98
+ */
99
+ export class SQLStatement {
100
+ _text;
101
+ _values;
102
+ _schema;
103
+ constructor(text = [], values = []) {
104
+ this._text = typeof text === "string" ? [text] : [...text];
105
+ this._values = [...values];
106
+ }
107
+ // this weirdness deals with the case where we have multiple instantiations
108
+ // of this class, breaking instanceof. we want instanceof to work for
109
+ // any instantiation of this class in memory, so we encode a random
110
+ // uuid and override instanceof to check this in place of the usual check.
111
+ // see https://github.com/colinhacks/zod/issues/2241 for more info
112
+ // private static readonly __classId = Symbol.for(
113
+ // "@casekit/orm/sql/SQLStatement-1C9E8AD8-4B55-41F0-95D0-934891ADB0C0",
114
+ // );
115
+ // public readonly __classId = SQLStatement.__classId;
116
+ // // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ // static [Symbol.hasInstance](obj: any) {
118
+ // return (
119
+ // // eslint-disable-next-line
120
+ // !!obj && obj.__classId === SQLStatement.__classId
121
+ // );
122
+ // }
123
+ /**
124
+ * This accessor is called by node-postgres when passed as a query,
125
+ * allowing us to pass a SQLStatement to `client.query` directly.
126
+ */
127
+ get text() {
128
+ // prettier-ignore
129
+ const text = this._text.reduce(// NOSONAR
130
+ (prev, curr, i) => prev + "$" + i.toString() + curr);
131
+ return unindent `${text}`;
132
+ }
133
+ /**
134
+ * This accessor is called by node-postgres when passed as a query,
135
+ * allowing us to pass a SQLStatement to `client.query` directly.
136
+ *
137
+ * By default, when passed an array as a value, we auto-expand it into
138
+ * multiple values. Because there are some cases where we actually want to pass
139
+ * an array as a single value, we have a SQLValueArray class to indicate this.
140
+ * This class prevents the array from being expanded into multiple values.
141
+ *
142
+ * We do it this way round because it's more common to want to expand arrays
143
+ * than to want to pass them as a single value.
144
+ */
145
+ get values() {
146
+ return this._values.map((v) => v instanceof SQLValueArray ? v.values : v);
147
+ }
148
+ /**
149
+ * Get the pretty-printed version of the SQL statement.
150
+ * We don't automatically apply this for two reasons:
151
+ * 1. Speed
152
+ * 2. It increases the potential attack surface area
153
+ */
154
+ get pretty() {
155
+ return format(this.text, { language: "postgresql", tabWidth: 4 });
156
+ }
157
+ append(fragments, ...values) {
158
+ return this.push(sql(fragments, ...values));
159
+ }
160
+ /**
161
+ * Allows appending other SQLStatements onto this one, combining them.
162
+ * Mainly used internally. Mutates the SQLStatement and returns `this`.
163
+ */
164
+ push(...others) {
165
+ for (const other of others) {
166
+ if (other._text.length === 0) {
167
+ // do nothing - statement is empty
168
+ }
169
+ else if (this._text.length === 0) {
170
+ this._text.push(...other._text);
171
+ this._values.push(...other._values);
172
+ }
173
+ else {
174
+ this._text[this._text.length - 1] += other._text[0];
175
+ this._text.push(...other._text.slice(1));
176
+ this._values.push(...other._values);
177
+ }
178
+ }
179
+ return this;
180
+ }
181
+ /**
182
+ * The Zod schema that will be used to parse and validate
183
+ * the results of the query when it is passed to `orm.query`.
184
+ */
185
+ get schema() {
186
+ return this._schema;
187
+ }
188
+ /**
189
+ * Assign a zod schema to this SQLStatement, which will be used to validate
190
+ * the result of the query when it is passed to `orm.query`.
191
+ */
192
+ withSchema(schema) {
193
+ const self = this;
194
+ self._schema = schema;
195
+ return self;
196
+ }
197
+ }
198
+ /**
199
+ * A class to wrap an array value that we don't want
200
+ * to auto-expand into multiple parameters.
201
+ */
202
+ export class SQLValueArray {
203
+ values;
204
+ constructor(values) {
205
+ this.values = [...values];
206
+ }
207
+ }
208
+ const array = (values) => new SQLValueArray(values);
209
+ const ident = (value) => new SQLStatement(pg.escapeIdentifier(value));
210
+ const literal = (value) => new SQLStatement(pg.escapeLiteral(value));
211
+ const value = (value) => Array.isArray(value) ? array(value) : sql `${value}`;
212
+ const join = (values, separator = `, `) => {
213
+ return new SQLStatement("").push(...interleave(values, new SQLStatement(separator)));
214
+ };
215
+ sql.array = array;
216
+ sql.ident = ident;
217
+ sql.literal = literal;
218
+ sql.join = join;
219
+ sql.value = value;
220
+ export { sql };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,273 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { z } from "zod";
3
+ import { unindent } from "@casekit/unindent";
4
+ import { SQLStatement, sql } from "./sql.js";
5
+ describe("sql", () => {
6
+ test("simple string", () => {
7
+ const statement = sql `SELECT 1 FROM dual`;
8
+ expect(statement.text).toEqual("SELECT 1 FROM dual");
9
+ expect(statement.values).toEqual([]);
10
+ });
11
+ test("interpolating a variable", () => {
12
+ const statement = sql `SELECT ${1} FROM dual`;
13
+ expect(statement.text).toEqual("SELECT $1 FROM dual");
14
+ expect(statement.values).toEqual([1]);
15
+ });
16
+ test("interpolating null adds it literally rather than as a parameter", () => {
17
+ const statement = sql `SELECT ${null} FROM dual`;
18
+ expect(statement.text).toEqual("SELECT NULL FROM dual");
19
+ expect(statement.values).toEqual([]);
20
+ });
21
+ test("interpolating true and false adds them literally rather than as a parameter", () => {
22
+ const statement = sql `SELECT ${true}, ${false} FROM dual`;
23
+ expect(statement.text).toEqual("SELECT TRUE, FALSE FROM dual");
24
+ expect(statement.values).toEqual([]);
25
+ });
26
+ test("interpolating multiple variables", () => {
27
+ const statement = sql `
28
+ SELECT title FROM posts
29
+ WHERE author = ${`Stewart Home`}
30
+ AND likes > ${10}
31
+ `;
32
+ expect(statement.text).toEqual(unindent `
33
+ SELECT title FROM posts
34
+ WHERE author = $1
35
+ AND likes > $2
36
+ `);
37
+ expect(statement.values).toEqual(["Stewart Home", 10]);
38
+ });
39
+ test("interpolating an array", () => {
40
+ const statement = sql `
41
+ SELECT title FROM posts
42
+ WHERE author IN (${["Stewart Home", "Kathy Acker"]})
43
+ AND likes > ${10}
44
+ `;
45
+ expect(statement.values).toEqual(["Stewart Home", "Kathy Acker", 10]);
46
+ expect(statement.text).toEqual(unindent `
47
+ SELECT title FROM posts
48
+ WHERE author IN ($1, $2)
49
+ AND likes > $3
50
+ `);
51
+ });
52
+ test("interpolating an array with no values", () => {
53
+ const statement = sql `
54
+ SELECT title FROM posts
55
+ WHERE author IN (${[]})
56
+ AND likes > ${10}
57
+ `;
58
+ expect(statement.text).toEqual(unindent `
59
+ SELECT title FROM posts
60
+ WHERE author IN (NULL)
61
+ AND likes > $1
62
+ `);
63
+ expect(statement.values).toEqual([10]);
64
+ });
65
+ test("interpolating a sql fragment", () => {
66
+ const subquery = sql `
67
+ SELECT id, title FROM posts
68
+ WHERE author = ${"J.G. Ballard"}
69
+ `;
70
+ const statement = sql `
71
+ SELECT u.username, p.title
72
+ FROM users u
73
+ WHERE u.favourite_post_id IN (${subquery}) p
74
+ AND u.signed_up_at < ${new Date("2021-01-01")}
75
+ `;
76
+ expect(statement.text).toEqual(unindent `
77
+ SELECT u.username, p.title
78
+ FROM users u
79
+ WHERE u.favourite_post_id IN (
80
+ SELECT id, title FROM posts
81
+ WHERE author = $1
82
+ ) p
83
+ AND u.signed_up_at < $2
84
+ `);
85
+ expect(statement.values).toEqual([
86
+ "J.G. Ballard",
87
+ new Date("2021-01-01"),
88
+ ]);
89
+ });
90
+ test("interpolating an array of SQLStatements", () => {
91
+ const fragments = [sql `${"axolotl"}::text`, sql `${"binturong"}`];
92
+ const statement = sql `
93
+ INSERT INTO animals (name) VALUES (${fragments})
94
+ `;
95
+ expect(statement.text).toEqual(unindent `
96
+ INSERT INTO animals (name) VALUES ($1::text, $2)
97
+ `);
98
+ expect(statement.values).toEqual(["axolotl", "binturong"]);
99
+ });
100
+ test("interpolating an mixed array of SQLStatements and scalar values", () => {
101
+ const fragments = [sql `${"axolotl"}::text`, 3, sql `${"binturong"}`];
102
+ const statement = sql `
103
+ INSERT INTO animals (name) VALUES (${fragments})
104
+ `;
105
+ expect(statement.text).toEqual(unindent `
106
+ INSERT INTO animals (name) VALUES ($1::text, $2, $3)
107
+ `);
108
+ expect(statement.values).toEqual(["axolotl", 3, "binturong"]);
109
+ });
110
+ test("interpolating multiple levels of nesting of SQLStatements", () => {
111
+ const fragments = [sql `${"axolotl"}::text`, 3, sql `${"binturong"}`];
112
+ const subquery1 = sql `
113
+ SELECT id, name FROM animals WHERE name IN (${fragments})
114
+ `;
115
+ const subquery2 = sql `
116
+ SELECT id FROM animals
117
+ WHERE id IN (${subquery1})
118
+ AND name IN (${fragments})
119
+ `;
120
+ const statement = sql `
121
+ SELECT id FROM animals
122
+ WHERE id IN (${subquery2})
123
+ `;
124
+ expect(statement.pretty).toEqual(unindent `
125
+ SELECT
126
+ id
127
+ FROM
128
+ animals
129
+ WHERE
130
+ id IN (
131
+ SELECT
132
+ id
133
+ FROM
134
+ animals
135
+ WHERE
136
+ id IN (
137
+ SELECT
138
+ id,
139
+ name
140
+ FROM
141
+ animals
142
+ WHERE
143
+ name IN ($1::text, $2, $3)
144
+ )
145
+ AND name IN ($4::text, $5, $6)
146
+ )
147
+ `);
148
+ expect(statement.values).toEqual([
149
+ "axolotl",
150
+ 3,
151
+ "binturong",
152
+ "axolotl",
153
+ 3,
154
+ "binturong",
155
+ ]);
156
+ });
157
+ });
158
+ describe("sql.array", () => {
159
+ test("interpolating an array without expanding it", () => {
160
+ const statement = sql `
161
+ INSERT INTO animals (name) VALUES (${sql.array(["axolotl", "binturong"])})
162
+ `;
163
+ expect(statement.text).toEqual(unindent `
164
+ INSERT INTO animals (name) VALUES ($1)
165
+ `);
166
+ expect(statement.values).toEqual([["axolotl", "binturong"]]);
167
+ });
168
+ });
169
+ describe("sql.ident", () => {
170
+ test("interpolating a simple identifier", () => {
171
+ const statement = sql `
172
+ SELECT ${sql.ident("title")} FROM posts
173
+ `;
174
+ expect(statement.text).toEqual(unindent `
175
+ SELECT "title" FROM posts
176
+ `);
177
+ expect(statement.values).toEqual([]);
178
+ });
179
+ test("interpolating a mixed-case identifier", () => {
180
+ const statement = sql `
181
+ SELECT ${sql.ident("createdBy")} FROM posts
182
+ `;
183
+ expect(statement.text).toEqual(unindent `
184
+ SELECT "createdBy" FROM posts
185
+ `);
186
+ expect(statement.values).toEqual([]);
187
+ });
188
+ test("interpolating a malicious identifier", () => {
189
+ const statement = sql `
190
+ SELECT ${sql.ident("'; delete from users;\"delete from posts")} FROM posts
191
+ `;
192
+ expect(statement.text).toEqual(unindent `
193
+ SELECT "'; delete from users;""delete from posts" FROM posts
194
+ `);
195
+ expect(statement.values).toEqual([]);
196
+ });
197
+ });
198
+ describe("sql.literal", () => {
199
+ test("interpolating a simple literal", () => {
200
+ const statement = sql `
201
+ SELECT * FROM posts WHERE title = ${sql.literal("hello")}
202
+ `;
203
+ expect(statement.text).toEqual(unindent `
204
+ SELECT * FROM posts WHERE title = 'hello'
205
+ `);
206
+ expect(statement.values).toEqual([]);
207
+ });
208
+ test("interpolating a literal with quotes", () => {
209
+ const statement = sql `
210
+ SELECT ${sql.literal("O'Reilly")} FROM authors
211
+ `;
212
+ expect(statement.text).toEqual(unindent `
213
+ SELECT 'O''Reilly' FROM authors
214
+ `);
215
+ expect(statement.values).toEqual([]);
216
+ });
217
+ });
218
+ describe("sql.join", () => {
219
+ test("joining zero statements", () => {
220
+ const statement = sql.join([]);
221
+ expect(statement.text).toEqual("");
222
+ expect(statement.values).toEqual([]);
223
+ });
224
+ test("joining one statement", () => {
225
+ const statement = sql.join([sql `name = ${"axolotl"}`]);
226
+ expect(statement.text).toEqual("name = $1");
227
+ expect(statement.values).toEqual(["axolotl"]);
228
+ });
229
+ test("joining two statements", () => {
230
+ const statement = sql.join([sql `name = ${"axolotl"}`, sql `name = ${"binturong"}`], " OR ");
231
+ expect(statement.text).toEqual("name = $1 OR name = $2");
232
+ expect(statement.values).toEqual(["axolotl", "binturong"]);
233
+ });
234
+ test("joining identifiers", () => {
235
+ const statement = sql.join(["name", "age"].map(sql.ident), " AND ");
236
+ expect(statement.text).toEqual(`"name" AND "age"`);
237
+ expect(statement.values).toEqual([]);
238
+ });
239
+ test("passing a schema as an argument to the template tag", () => {
240
+ const statement = sql(z.object({ title: z.string() })) `SELECT ${sql.ident("title")} FROM ${sql.ident("posts")}`;
241
+ expect(statement.schema).toBeInstanceOf(z.ZodObject);
242
+ expect(statement.text).toEqual(unindent `
243
+ SELECT "title" FROM "posts"
244
+ `);
245
+ });
246
+ test("append allows appending another SQL fragment using the tagged template literal style", () => {
247
+ const statement = sql `
248
+ SELECT title FROM posts
249
+ WHERE likes > ${10}
250
+ `;
251
+ statement.append `AND author = ${"Stewart Home"}`;
252
+ expect(statement.pretty).toEqual(unindent `
253
+ SELECT
254
+ title
255
+ FROM
256
+ posts
257
+ WHERE
258
+ likes > $1
259
+ AND author = $2
260
+ `);
261
+ expect(statement.values).toEqual([10, "Stewart Home"]);
262
+ });
263
+ test("appending to an empty SQLStatement", () => {
264
+ const statement = new SQLStatement();
265
+ statement.append `SELECT title FROM posts`;
266
+ expect(statement.pretty).toEqual(unindent `
267
+ SELECT
268
+ title
269
+ FROM
270
+ posts
271
+ `);
272
+ });
273
+ });
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@casekit/sql",
3
+ "description": "",
4
+ "version": "0.0.0-20250322230249",
5
+ "author": "",
6
+ "dependencies": {
7
+ "@casekit/unindent": "^1.0.5",
8
+ "es-toolkit": "^1.33.0",
9
+ "sql-formatter": "^15.5.1",
10
+ "@casekit/toolbox": "0.0.0-20250322230249"
11
+ },
12
+ "devDependencies": {
13
+ "@trivago/prettier-plugin-sort-imports": "^5.2.2",
14
+ "@types/node": "^22.13.11",
15
+ "@types/pg": "^8.11.11",
16
+ "@vitest/coverage-v8": "^3.0.9",
17
+ "dotenv": "^16.4.7",
18
+ "prettier": "^3.5.3",
19
+ "prettier-plugin-svelte": "^3.3.3",
20
+ "vite-tsconfig-paths": "^5.1.4",
21
+ "vitest": "^3.0.9",
22
+ "@casekit/tsconfig": "0.0.0-20250322230249",
23
+ "@casekit/prettier-config": "0.0.0-20250322230249"
24
+ },
25
+ "exports": {
26
+ ".": "./build/index.js"
27
+ },
28
+ "files": [
29
+ "/build"
30
+ ],
31
+ "imports": {
32
+ "#*": "./build/*"
33
+ },
34
+ "keywords": [],
35
+ "license": "ISC",
36
+ "main": "index.js",
37
+ "peerDependencies": {
38
+ "pg": "^8.13.1",
39
+ "zod": "^3.24.2"
40
+ },
41
+ "prettier": "@casekit/prettier-config",
42
+ "type": "module",
43
+ "scripts": {
44
+ "build": "rm -rf ./build && tsc",
45
+ "build:watch": "tsc --watch",
46
+ "format:check": "prettier --check .",
47
+ "format": "prettier --write .",
48
+ "lint": "eslint src --max-warnings 0",
49
+ "test": "vitest --run --typecheck --coverage",
50
+ "test:watch": "vitest",
51
+ "typecheck": "tsc --noEmit"
52
+ }
53
+ }