@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.
- package/build/index.d.ts +1 -0
- package/build/index.js +3 -0
- package/build/sql.d.ts +94 -0
- package/build/sql.js +220 -0
- package/build/sql.test.d.ts +1 -0
- package/build/sql.test.js +273 -0
- package/package.json +53 -0
package/build/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SQLStatement, sql } from "./sql.js";
|
package/build/index.js
ADDED
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
|
+
}
|