@kuindji/typed-sql 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 +21 -0
- package/README.md +227 -0
- package/dist/builder/assemble.d.ts +13 -0
- package/dist/builder/assemble.d.ts.map +1 -0
- package/dist/builder/assemble.js +86 -0
- package/dist/builder/assemble.js.map +1 -0
- package/dist/builder/condition-tree.d.ts +27 -0
- package/dist/builder/condition-tree.d.ts.map +1 -0
- package/dist/builder/condition-tree.js +91 -0
- package/dist/builder/condition-tree.js.map +1 -0
- package/dist/builder/conditional-sql.d.ts +80 -0
- package/dist/builder/conditional-sql.d.ts.map +1 -0
- package/dist/builder/conditional-sql.js +88 -0
- package/dist/builder/conditional-sql.js.map +1 -0
- package/dist/builder/db.d.ts +76 -0
- package/dist/builder/db.d.ts.map +1 -0
- package/dist/builder/db.js +12 -0
- package/dist/builder/db.js.map +1 -0
- package/dist/builder/delete.d.ts +39 -0
- package/dist/builder/delete.d.ts.map +1 -0
- package/dist/builder/delete.js +33 -0
- package/dist/builder/delete.js.map +1 -0
- package/dist/builder/extract-params.d.ts +97 -0
- package/dist/builder/extract-params.d.ts.map +1 -0
- package/dist/builder/extract-params.js +2 -0
- package/dist/builder/extract-params.js.map +1 -0
- package/dist/builder/index.d.ts +23 -0
- package/dist/builder/index.d.ts.map +1 -0
- package/dist/builder/index.js +14 -0
- package/dist/builder/index.js.map +1 -0
- package/dist/builder/insert.d.ts +51 -0
- package/dist/builder/insert.d.ts.map +1 -0
- package/dist/builder/insert.js +39 -0
- package/dist/builder/insert.js.map +1 -0
- package/dist/builder/mutate.d.ts +28 -0
- package/dist/builder/mutate.d.ts.map +1 -0
- package/dist/builder/mutate.js +17 -0
- package/dist/builder/mutate.js.map +1 -0
- package/dist/builder/params.d.ts +22 -0
- package/dist/builder/params.d.ts.map +1 -0
- package/dist/builder/params.js +65 -0
- package/dist/builder/params.js.map +1 -0
- package/dist/builder/return-type.d.ts +39 -0
- package/dist/builder/return-type.d.ts.map +1 -0
- package/dist/builder/return-type.js +2 -0
- package/dist/builder/return-type.js.map +1 -0
- package/dist/builder/scanner.d.ts +49 -0
- package/dist/builder/scanner.d.ts.map +1 -0
- package/dist/builder/scanner.js +240 -0
- package/dist/builder/scanner.js.map +1 -0
- package/dist/builder/select.d.ts +76 -0
- package/dist/builder/select.d.ts.map +1 -0
- package/dist/builder/select.js +240 -0
- package/dist/builder/select.js.map +1 -0
- package/dist/builder/sql-tag.d.ts +319 -0
- package/dist/builder/sql-tag.d.ts.map +1 -0
- package/dist/builder/sql-tag.js +3 -0
- package/dist/builder/sql-tag.js.map +1 -0
- package/dist/builder/sql.d.ts +17 -0
- package/dist/builder/sql.d.ts.map +1 -0
- package/dist/builder/sql.js +36 -0
- package/dist/builder/sql.js.map +1 -0
- package/dist/builder/state.d.ts +53 -0
- package/dist/builder/state.d.ts.map +1 -0
- package/dist/builder/state.js +18 -0
- package/dist/builder/state.js.map +1 -0
- package/dist/builder/update.d.ts +60 -0
- package/dist/builder/update.d.ts.map +1 -0
- package/dist/builder/update.js +40 -0
- package/dist/builder/update.js.map +1 -0
- package/dist/builder/write-assemble.d.ts +5 -0
- package/dist/builder/write-assemble.d.ts.map +1 -0
- package/dist/builder/write-assemble.js +57 -0
- package/dist/builder/write-assemble.js.map +1 -0
- package/dist/builder/write-state.d.ts +39 -0
- package/dist/builder/write-state.d.ts.map +1 -0
- package/dist/builder/write-state.js +6 -0
- package/dist/builder/write-state.js.map +1 -0
- package/dist/builder/write-tag.d.ts +91 -0
- package/dist/builder/write-tag.d.ts.map +1 -0
- package/dist/builder/write-tag.js +2 -0
- package/dist/builder/write-tag.js.map +1 -0
- package/dist/columns.d.ts +33 -0
- package/dist/columns.d.ts.map +1 -0
- package/dist/columns.js +2 -0
- package/dist/columns.js.map +1 -0
- package/dist/expressions.d.ts +71 -0
- package/dist/expressions.d.ts.map +1 -0
- package/dist/expressions.js +2 -0
- package/dist/expressions.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/parsing/extract.d.ts +47 -0
- package/dist/parsing/extract.d.ts.map +1 -0
- package/dist/parsing/extract.js +2 -0
- package/dist/parsing/extract.js.map +1 -0
- package/dist/parsing/normalize.d.ts +44 -0
- package/dist/parsing/normalize.d.ts.map +1 -0
- package/dist/parsing/normalize.js +2 -0
- package/dist/parsing/normalize.js.map +1 -0
- package/dist/parsing/pg-literals.d.ts +37 -0
- package/dist/parsing/pg-literals.d.ts.map +1 -0
- package/dist/parsing/pg-literals.js +2 -0
- package/dist/parsing/pg-literals.js.map +1 -0
- package/dist/parsing/split.d.ts +100 -0
- package/dist/parsing/split.d.ts.map +1 -0
- package/dist/parsing/split.js +2 -0
- package/dist/parsing/split.js.map +1 -0
- package/dist/parsing/string-utils.d.ts +29 -0
- package/dist/parsing/string-utils.d.ts.map +1 -0
- package/dist/parsing/string-utils.js +2 -0
- package/dist/parsing/string-utils.js.map +1 -0
- package/dist/parsing/tokenize.d.ts +27 -0
- package/dist/parsing/tokenize.d.ts.map +1 -0
- package/dist/parsing/tokenize.js +2 -0
- package/dist/parsing/tokenize.js.map +1 -0
- package/dist/parsing.d.ts +7 -0
- package/dist/parsing.d.ts.map +1 -0
- package/dist/parsing.js +9 -0
- package/dist/parsing.js.map +1 -0
- package/dist/partial.d.ts +30 -0
- package/dist/partial.d.ts.map +1 -0
- package/dist/partial.js +10 -0
- package/dist/partial.js.map +1 -0
- package/dist/schema.d.ts +28 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +2 -0
- package/dist/schema.js.map +1 -0
- package/dist/tables.d.ts +34 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/tables.js +2 -0
- package/dist/tables.js.map +1 -0
- package/dist/utils.d.ts +13 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +3 -0
- package/dist/utils.js.map +1 -0
- package/dist/validation/cte.d.ts +54 -0
- package/dist/validation/cte.d.ts.map +1 -0
- package/dist/validation/cte.js +2 -0
- package/dist/validation/cte.js.map +1 -0
- package/dist/validation/dispatch.d.ts +31 -0
- package/dist/validation/dispatch.d.ts.map +1 -0
- package/dist/validation/dispatch.js +2 -0
- package/dist/validation/dispatch.js.map +1 -0
- package/dist/validation/joins.d.ts +16 -0
- package/dist/validation/joins.d.ts.map +1 -0
- package/dist/validation/joins.js +2 -0
- package/dist/validation/joins.js.map +1 -0
- package/dist/validation/return-derived.d.ts +67 -0
- package/dist/validation/return-derived.d.ts.map +1 -0
- package/dist/validation/return-derived.js +5 -0
- package/dist/validation/return-derived.js.map +1 -0
- package/dist/validation/return-types.d.ts +41 -0
- package/dist/validation/return-types.d.ts.map +1 -0
- package/dist/validation/return-types.js +2 -0
- package/dist/validation/return-types.js.map +1 -0
- package/dist/validation/validate-columns.d.ts +63 -0
- package/dist/validation/validate-columns.d.ts.map +1 -0
- package/dist/validation/validate-columns.js +2 -0
- package/dist/validation/validate-columns.js.map +1 -0
- package/dist/validation.d.ts +7 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +9 -0
- package/dist/validation.js.map +1 -0
- package/package.json +64 -0
- package/src/builder/assemble.ts +100 -0
- package/src/builder/condition-tree.ts +162 -0
- package/src/builder/conditional-sql.ts +325 -0
- package/src/builder/db.ts +281 -0
- package/src/builder/delete.ts +57 -0
- package/src/builder/extract-params.ts +507 -0
- package/src/builder/index.ts +58 -0
- package/src/builder/insert.ts +75 -0
- package/src/builder/mutate.ts +55 -0
- package/src/builder/params.ts +95 -0
- package/src/builder/return-type.ts +66 -0
- package/src/builder/scanner.ts +254 -0
- package/src/builder/select.ts +470 -0
- package/src/builder/sql-tag.ts +422 -0
- package/src/builder/sql.ts +51 -0
- package/src/builder/state.ts +55 -0
- package/src/builder/update.ts +77 -0
- package/src/builder/write-assemble.ts +52 -0
- package/src/builder/write-state.ts +43 -0
- package/src/builder/write-tag.ts +119 -0
- package/src/columns.ts +336 -0
- package/src/expressions.ts +745 -0
- package/src/index.ts +81 -0
- package/src/parsing/extract.ts +260 -0
- package/src/parsing/normalize.ts +243 -0
- package/src/parsing/pg-literals.ts +289 -0
- package/src/parsing/split.ts +288 -0
- package/src/parsing/string-utils.ts +172 -0
- package/src/parsing/tokenize.ts +321 -0
- package/src/parsing.ts +8 -0
- package/src/partial.ts +241 -0
- package/src/schema.ts +130 -0
- package/src/tables.ts +278 -0
- package/src/utils.ts +43 -0
- package/src/validation/cte.ts +198 -0
- package/src/validation/dispatch.ts +312 -0
- package/src/validation/joins.ts +198 -0
- package/src/validation/return-derived.ts +253 -0
- package/src/validation/return-types.ts +271 -0
- package/src/validation/validate-columns.ts +489 -0
- package/src/validation.ts +8 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/builder/mutate.ts
|
|
2
|
+
import type { DatabaseSchema } from "../schema.js";
|
|
3
|
+
import type { ExtractParams, ExtractReturning } from "./extract-params.js";
|
|
4
|
+
import { prepareScanned, type DriverParamValue } from "./scanner.js";
|
|
5
|
+
|
|
6
|
+
/** Driver contract: returns the RETURNING rows, or [] for a no-RETURNING mutation. */
|
|
7
|
+
export type MutationHandler = (
|
|
8
|
+
sql: string,
|
|
9
|
+
params: DriverParamValue[],
|
|
10
|
+
) => Promise<unknown[]>;
|
|
11
|
+
|
|
12
|
+
/** Row type a bound builder / createSql object yields. */
|
|
13
|
+
export type MutationReturnType<B> =
|
|
14
|
+
B extends { readonly __returning?: infer R } ? R : {};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Minimal structural shape both `BoundWrite` (builders) and `BoundSql`
|
|
18
|
+
* (`createSql`) satisfy. Used as the object-overload constraint instead of the
|
|
19
|
+
* deep `BoundWrite<S, any> | BoundSql<any, S>` union — matching a value against
|
|
20
|
+
* that union forces TS to compare the phantom `__returning` types of both arms,
|
|
21
|
+
* which recurses without bound. A shallow structural constraint avoids that; the
|
|
22
|
+
* row type is still derived from the value's own `__returning` via
|
|
23
|
+
* `MutationReturnType<B>`.
|
|
24
|
+
*/
|
|
25
|
+
interface Executable {
|
|
26
|
+
toString(): string;
|
|
27
|
+
getParams(): ReadonlyArray<DriverParamValue>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createMutateFn<S extends DatabaseSchema>(handler: MutationHandler) {
|
|
31
|
+
// Builder / createSql object overload.
|
|
32
|
+
function mutate<B extends Executable>(
|
|
33
|
+
query: B,
|
|
34
|
+
): Promise<MutationReturnType<B>[]>;
|
|
35
|
+
// Raw string + named params overload (brand-checked).
|
|
36
|
+
function mutate<Q extends string>(
|
|
37
|
+
query: Q,
|
|
38
|
+
params: ExtractParams<Q, S>,
|
|
39
|
+
): Promise<ExtractReturning<Q, S>[]>;
|
|
40
|
+
|
|
41
|
+
// async so a prep-time / assembly throw (missing live placeholder, empty
|
|
42
|
+
// INSERT/SET, etc.) surfaces as a rejected promise rather than a synchronous
|
|
43
|
+
// throw — consistent with the promise-returning executor contract.
|
|
44
|
+
async function mutate(query: Executable | string, params?: Record<string, DriverParamValue>) {
|
|
45
|
+
if (typeof query === "string") {
|
|
46
|
+
const { sql, values } = prepareScanned(query, params ?? {});
|
|
47
|
+
return handler(sql, values) as Promise<any>;
|
|
48
|
+
}
|
|
49
|
+
const sql = query.toString(); // already expanded + live-checked
|
|
50
|
+
const values = [...query.getParams()];
|
|
51
|
+
return handler(sql, values) as Promise<any>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return mutate;
|
|
55
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/builder/params.ts
|
|
2
|
+
|
|
3
|
+
/** Runtime parameter value type supported by query builders. */
|
|
4
|
+
export type QueryParamValue = string | number | boolean | null;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Input parameter value type — allows arrays (expanded to multiple
|
|
8
|
+
* placeholders, e.g. :ids with [1,2,3] → "$1, $2, $3") and undefined
|
|
9
|
+
* (throws at runtime only if the param is actually used).
|
|
10
|
+
*
|
|
11
|
+
* Array expansion is BUILDER-ONLY. Conditional SQL keeps a scalar-only
|
|
12
|
+
* signature (see conditional-sql.ts) for parity with the old package.
|
|
13
|
+
*/
|
|
14
|
+
export type QueryParamInput =
|
|
15
|
+
| QueryParamValue
|
|
16
|
+
| readonly QueryParamValue[]
|
|
17
|
+
| undefined;
|
|
18
|
+
|
|
19
|
+
// Ported verbatim from OLD: trailing negative lookahead stops a short param
|
|
20
|
+
// (:te) from clobbering a longer one (:text). Matching the second colon of a
|
|
21
|
+
// ::cast is intentional parity (pinned by params.test.ts).
|
|
22
|
+
const PARAM_REGEX = /:([a-zA-Z_][a-zA-Z0-9_]*)(?![a-zA-Z0-9_])/g;
|
|
23
|
+
|
|
24
|
+
/** Param names in order of first appearance that are present in `params`. */
|
|
25
|
+
function usedParamNames(
|
|
26
|
+
sql: string,
|
|
27
|
+
params: Record<string, QueryParamInput>,
|
|
28
|
+
): string[] {
|
|
29
|
+
const used: string[] = [];
|
|
30
|
+
let match: RegExpExecArray | null;
|
|
31
|
+
PARAM_REGEX.lastIndex = 0;
|
|
32
|
+
while ((match = PARAM_REGEX.exec(sql)) !== null) {
|
|
33
|
+
const name = match[1];
|
|
34
|
+
if (name in params && !used.includes(name)) {
|
|
35
|
+
used.push(name);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return used;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Replace :name placeholders with $n positional placeholders, ordered by
|
|
43
|
+
* first appearance. Array values expand to consecutive placeholders.
|
|
44
|
+
*/
|
|
45
|
+
export function expandNamedParams(
|
|
46
|
+
sql: string,
|
|
47
|
+
params: Record<string, QueryParamInput>,
|
|
48
|
+
): string {
|
|
49
|
+
const used = usedParamNames(sql, params);
|
|
50
|
+
let out = sql;
|
|
51
|
+
let position = 1;
|
|
52
|
+
for (const name of used) {
|
|
53
|
+
const value = params[name];
|
|
54
|
+
const regex = new RegExp(`:${name}(?![a-zA-Z0-9_])`, "g");
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
const placeholders = value
|
|
57
|
+
.map((_, i) => `$${position + i}`)
|
|
58
|
+
.join(", ");
|
|
59
|
+
out = out.replace(regex, placeholders);
|
|
60
|
+
position += value.length;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
out = out.replace(regex, `$${position}`);
|
|
64
|
+
position++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Flattened param values in placeholder order. Throws if a used param's
|
|
72
|
+
* value is undefined.
|
|
73
|
+
*/
|
|
74
|
+
export function collectParamValues(
|
|
75
|
+
sql: string,
|
|
76
|
+
params: Record<string, QueryParamInput>,
|
|
77
|
+
): QueryParamValue[] {
|
|
78
|
+
const used = usedParamNames(sql, params);
|
|
79
|
+
const result: QueryParamValue[] = [];
|
|
80
|
+
for (const name of used) {
|
|
81
|
+
const value = params[name];
|
|
82
|
+
if (value === undefined) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Query parameter ":${name}" is used but its value is undefined`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
result.push(...value);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
result.push(value as QueryParamValue);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/builder/return-type.ts
|
|
2
|
+
import type { DatabaseSchema } from "../schema.js";
|
|
3
|
+
import type { GetReturnType } from "../index.js";
|
|
4
|
+
import type { BuildSQL, SqlTag, SelFrag } from "./sql-tag.js";
|
|
5
|
+
|
|
6
|
+
/** Type-level canonical SQL: the maximal query (all select fragments present). */
|
|
7
|
+
export type BuilderSQLFor<Sql extends SqlTag> = BuildSQL<Sql, "max">;
|
|
8
|
+
|
|
9
|
+
/** True iff some select fragment is unconditional. */
|
|
10
|
+
type HasUncond<List extends readonly SelFrag[]> =
|
|
11
|
+
List extends readonly [infer H extends SelFrag, ...infer R extends readonly SelFrag[]]
|
|
12
|
+
? H["cond"] extends false ? true : HasUncond<R>
|
|
13
|
+
: false;
|
|
14
|
+
|
|
15
|
+
/** True iff NO select fragment is conditional (req-list === max-list). */
|
|
16
|
+
type AllUncond<List extends readonly SelFrag[]> =
|
|
17
|
+
List extends readonly [infer H extends SelFrag, ...infer R extends readonly SelFrag[]]
|
|
18
|
+
? H["cond"] extends false ? AllUncond<R> : false
|
|
19
|
+
: true;
|
|
20
|
+
|
|
21
|
+
// Merge a "required" row and a "max" row into the partition:
|
|
22
|
+
// keys in ReqRow are required; keys only in Row are optional.
|
|
23
|
+
type Partition<Row, ReqRow> =
|
|
24
|
+
& { [K in keyof Row as K extends keyof ReqRow ? K : never]: Row[K] }
|
|
25
|
+
& { [K in keyof Row as K extends keyof ReqRow ? never : K]?: Row[K] };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Required/optional partition over GetReturnType of MaxSQL / ReqSQL / ScopeSQL.
|
|
29
|
+
* - allUncond: every selected column is unconditional, so the req-list and the
|
|
30
|
+
* max-list are identical and every key is required. Skip the second parse and
|
|
31
|
+
* the Partition entirely — both are pure overhead here, and parsing a very
|
|
32
|
+
* wide SELECT twice can cross TS's instantiation limit (TS2589).
|
|
33
|
+
* - hasUncond (but some conditional): Row = GetReturnType<MaxSQL>;
|
|
34
|
+
* ReqRow = GetReturnType<ReqSQL>; partition keys: required iff in ReqRow.
|
|
35
|
+
* - else (no unconditional select → all-false runtime path is SELECT *):
|
|
36
|
+
* Partial<GetReturnType<MaxSQL> & GetReturnType<ScopeSQL>>.
|
|
37
|
+
*/
|
|
38
|
+
export type BuilderReturnTypeFor<Schema extends DatabaseSchema, Sql extends SqlTag> =
|
|
39
|
+
HasUncond<Sql["selects"]> extends true
|
|
40
|
+
? AllUncond<Sql["selects"]> extends true
|
|
41
|
+
? GetReturnType<BuildSQL<Sql, "max">, Schema>
|
|
42
|
+
: GetReturnType<BuildSQL<Sql, "max">, Schema> extends infer Row
|
|
43
|
+
? GetReturnType<BuildSQL<Sql, "req">, Schema> extends infer ReqRow
|
|
44
|
+
? Partition<Row, ReqRow>
|
|
45
|
+
: Row
|
|
46
|
+
: {}
|
|
47
|
+
: Partial<
|
|
48
|
+
& GetReturnType<BuildSQL<Sql, "max">, Schema>
|
|
49
|
+
& GetReturnType<BuildSQL<Sql, "scope">, Schema>
|
|
50
|
+
>;
|
|
51
|
+
|
|
52
|
+
/** Brand carried by toBrandedString(); not used at runtime. */
|
|
53
|
+
export interface BuilderResultBrand<Schema extends DatabaseSchema, Sql extends SqlTag> {
|
|
54
|
+
readonly __schema?: Schema;
|
|
55
|
+
readonly __sql?: Sql;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- B-keyed public aliases (extract Schema/Sql from a builder type) ---
|
|
59
|
+
import type { SelectQueryBuilder } from "./select.js";
|
|
60
|
+
|
|
61
|
+
/** Extract the Sql tag from a builder type. */
|
|
62
|
+
export type SqlOf<B> = B extends SelectQueryBuilder<any, infer Sql extends SqlTag> ? Sql : never;
|
|
63
|
+
type SchemaOf<B> = B extends SelectQueryBuilder<infer S extends DatabaseSchema, any> ? S : never;
|
|
64
|
+
|
|
65
|
+
export type BuilderSQL<B> = BuilderSQLFor<SqlOf<B>>;
|
|
66
|
+
export type BuilderReturnType<B> = BuilderReturnTypeFor<SchemaOf<B>, SqlOf<B>>;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// src/builder/scanner.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Value domain at the typed boundary (spec §6.5). Default `unknown` accepts
|
|
5
|
+
* scalars, branded scalars, arrays, dates, and JSON/object columns. The driver
|
|
6
|
+
* adapter is responsible for serialization.
|
|
7
|
+
*/
|
|
8
|
+
export type DriverParamValue = unknown;
|
|
9
|
+
|
|
10
|
+
/** One real `:name` occurrence found by the scanner. */
|
|
11
|
+
export interface PlaceholderOccurrence {
|
|
12
|
+
/** Param name without leading ":" and without any `::cast` suffix. */
|
|
13
|
+
readonly name: string;
|
|
14
|
+
/** True iff this occurrence sits directly inside an `IN (...)` / `NOT IN (...)` group. */
|
|
15
|
+
readonly inExpansion: boolean;
|
|
16
|
+
/** Index of the ":" in the source SQL. */
|
|
17
|
+
readonly start: number;
|
|
18
|
+
/** Index just past the last char of the name. */
|
|
19
|
+
readonly end: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isIdentStart = (c: string) => /[a-zA-Z_]/.test(c);
|
|
23
|
+
const isIdentChar = (c: string) => /[a-zA-Z0-9_]/.test(c);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Scan `sql` and return every real placeholder occurrence, skipping string
|
|
27
|
+
* literals (single-quote + dollar-quote), `--` line comments, and block
|
|
28
|
+
* comments, and treating `::type` casts as non-placeholders. Tracks, per paren
|
|
29
|
+
* group, whether the group opened immediately after `in` / `not in`, so each
|
|
30
|
+
* occurrence carries an accurate `inExpansion` flag (spec §6.3/§6.5).
|
|
31
|
+
*/
|
|
32
|
+
export function scanPlaceholders(sql: string): PlaceholderOccurrence[] {
|
|
33
|
+
const out: PlaceholderOccurrence[] = [];
|
|
34
|
+
// Stack of paren contexts: true = this "(" opened right after IN / NOT IN.
|
|
35
|
+
const parenStack: boolean[] = [];
|
|
36
|
+
let i = 0;
|
|
37
|
+
const n = sql.length;
|
|
38
|
+
|
|
39
|
+
// Returns true if the run of word chars ending just before `idx` (skipping
|
|
40
|
+
// trailing whitespace) is `in`, optionally preceded by `not`.
|
|
41
|
+
const opensInList = (idx: number): boolean => {
|
|
42
|
+
let j = idx - 1;
|
|
43
|
+
while (j >= 0 && /\s/.test(sql[j])) j--;
|
|
44
|
+
let end = j + 1;
|
|
45
|
+
while (j >= 0 && isIdentChar(sql[j])) j--;
|
|
46
|
+
const w1 = sql.slice(j + 1, end).toLowerCase();
|
|
47
|
+
return w1 === "in";
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
while (i < n) {
|
|
51
|
+
const c = sql[i];
|
|
52
|
+
|
|
53
|
+
// -- line comment
|
|
54
|
+
if (c === "-" && sql[i + 1] === "-") {
|
|
55
|
+
i += 2;
|
|
56
|
+
while (i < n && sql[i] !== "\n") i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// /* block comment */
|
|
60
|
+
if (c === "/" && sql[i + 1] === "*") {
|
|
61
|
+
i += 2;
|
|
62
|
+
while (i < n && !(sql[i] === "*" && sql[i + 1] === "/")) i++;
|
|
63
|
+
i += 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// double-quoted identifier (with "" escape) — a `:name`-looking run
|
|
67
|
+
// inside a quoted identifier (`"tenant:region"`) is part of the name,
|
|
68
|
+
// not a placeholder.
|
|
69
|
+
if (c === '"') {
|
|
70
|
+
i++;
|
|
71
|
+
while (i < n) {
|
|
72
|
+
if (sql[i] === '"' && sql[i + 1] === '"') { i += 2; continue; }
|
|
73
|
+
if (sql[i] === '"') { i++; break; }
|
|
74
|
+
i++;
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// PostgreSQL escape string E'...' — backslash escapes are active, so a
|
|
79
|
+
// `\'` (and the usual `''`) escapes a quote rather than closing the
|
|
80
|
+
// literal. A bare `'` scan would mis-read `\'` as the terminator and leak
|
|
81
|
+
// any `:name` that follows back into the placeholder stream. The `E` must
|
|
82
|
+
// be a standalone string prefix, not the tail of an identifier.
|
|
83
|
+
if ((c === "E" || c === "e") && sql[i + 1] === "'" &&
|
|
84
|
+
!(i > 0 && isIdentChar(sql[i - 1]))) {
|
|
85
|
+
i += 2; // skip the `E` and the opening quote
|
|
86
|
+
while (i < n) {
|
|
87
|
+
if (sql[i] === "\\") { i += 2; continue; } // backslash escapes next char
|
|
88
|
+
if (sql[i] === "'" && sql[i + 1] === "'") { i += 2; continue; }
|
|
89
|
+
if (sql[i] === "'") { i++; break; }
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// single-quoted string (with '' escape)
|
|
95
|
+
if (c === "'") {
|
|
96
|
+
i++;
|
|
97
|
+
while (i < n) {
|
|
98
|
+
if (sql[i] === "'" && sql[i + 1] === "'") { i += 2; continue; }
|
|
99
|
+
if (sql[i] === "'") { i++; break; }
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// dollar-quoted string: $tag$ ... $tag$
|
|
105
|
+
if (c === "$") {
|
|
106
|
+
const m = /^\$([a-zA-Z_]\w*)?\$/.exec(sql.slice(i));
|
|
107
|
+
if (m) {
|
|
108
|
+
const tag = m[0];
|
|
109
|
+
const close = sql.indexOf(tag, i + tag.length);
|
|
110
|
+
i = close === -1 ? n : close + tag.length;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// parens — track IN-list context
|
|
115
|
+
if (c === "(") {
|
|
116
|
+
parenStack.push(opensInList(i));
|
|
117
|
+
i++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (c === ")") {
|
|
121
|
+
parenStack.pop();
|
|
122
|
+
i++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// cast `::` — skip both colons, never a placeholder
|
|
126
|
+
if (c === ":" && sql[i + 1] === ":") {
|
|
127
|
+
i += 2;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// placeholder `:name`
|
|
131
|
+
if (c === ":" && isIdentStart(sql[i + 1] ?? "")) {
|
|
132
|
+
const start = i;
|
|
133
|
+
i++;
|
|
134
|
+
const from = i;
|
|
135
|
+
while (i < n && isIdentChar(sql[i])) i++;
|
|
136
|
+
const name = sql.slice(from, i);
|
|
137
|
+
const inExpansion = parenStack.length > 0 && parenStack[parenStack.length - 1];
|
|
138
|
+
out.push({ name, inExpansion, start, end: i });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Ordered unique names (first appearance) with their merged expansion flag. */
|
|
147
|
+
function uniqueNames(
|
|
148
|
+
occ: readonly PlaceholderOccurrence[],
|
|
149
|
+
): { name: string; inExpansion: boolean }[] {
|
|
150
|
+
const order: string[] = [];
|
|
151
|
+
const expand = new Map<string, boolean>();
|
|
152
|
+
const seenNonExpand = new Map<string, boolean>();
|
|
153
|
+
for (const o of occ) {
|
|
154
|
+
if (!order.includes(o.name)) order.push(o.name);
|
|
155
|
+
if (o.inExpansion) expand.set(o.name, true);
|
|
156
|
+
else seenNonExpand.set(o.name, true);
|
|
157
|
+
}
|
|
158
|
+
// Mixed IN / non-IN reuse is unsound (spec §6.5) — one value cannot be both
|
|
159
|
+
// N positional slots and one slot.
|
|
160
|
+
for (const name of order) {
|
|
161
|
+
if (expand.get(name) && seenNonExpand.get(name)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Query parameter ":${name}" is used in mixed IN and non-IN positions; ` +
|
|
164
|
+
`a name cannot be both an expanded IN list and a scalar.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return order.map(name => ({ name, inExpansion: expand.get(name) ?? false }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Replace `:name` with `$n` (first-appearance order; repeats reuse the same
|
|
173
|
+
* `$n`). Only IN-list occurrences with an array value expand to multiple slots;
|
|
174
|
+
* every other value (including array-VALUED columns and JSON objects) is a
|
|
175
|
+
* single slot. Driven entirely by the shared scanner (spec §6.5).
|
|
176
|
+
*/
|
|
177
|
+
export function expandScanned(
|
|
178
|
+
sql: string,
|
|
179
|
+
params: Record<string, DriverParamValue>,
|
|
180
|
+
): string {
|
|
181
|
+
const occ = scanPlaceholders(sql);
|
|
182
|
+
const names = uniqueNames(occ).filter(u => u.name in params);
|
|
183
|
+
// Assign starting positions in appearance order.
|
|
184
|
+
const startPos = new Map<string, number>();
|
|
185
|
+
let pos = 1;
|
|
186
|
+
for (const u of names) {
|
|
187
|
+
startPos.set(u.name, pos);
|
|
188
|
+
const v = params[u.name];
|
|
189
|
+
pos += u.inExpansion && Array.isArray(v) ? v.length : 1;
|
|
190
|
+
}
|
|
191
|
+
// Rewrite right-to-left so indices stay valid; skip occurrences whose name
|
|
192
|
+
// isn't supplied (left as a literal — caught by assertAllProvided when live).
|
|
193
|
+
let out = sql;
|
|
194
|
+
for (let k = occ.length - 1; k >= 0; k--) {
|
|
195
|
+
const o = occ[k];
|
|
196
|
+
if (!(o.name in params)) continue;
|
|
197
|
+
const p = startPos.get(o.name)!;
|
|
198
|
+
const v = params[o.name];
|
|
199
|
+
const replacement = o.inExpansion && Array.isArray(v)
|
|
200
|
+
? v.map((_, idx) => `$${p + idx}`).join(", ")
|
|
201
|
+
: `$${p}`;
|
|
202
|
+
out = out.slice(0, o.start) + replacement + out.slice(o.end);
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Flattened param values in placeholder order. IN-list arrays are spread; all
|
|
209
|
+
* other values pass through as one entry. Throws if a used value is undefined.
|
|
210
|
+
*/
|
|
211
|
+
export function collectScanned(
|
|
212
|
+
sql: string,
|
|
213
|
+
params: Record<string, DriverParamValue>,
|
|
214
|
+
): DriverParamValue[] {
|
|
215
|
+
const occ = scanPlaceholders(sql);
|
|
216
|
+
const names = uniqueNames(occ).filter(u => u.name in params);
|
|
217
|
+
const result: DriverParamValue[] = [];
|
|
218
|
+
for (const u of names) {
|
|
219
|
+
const v = params[u.name];
|
|
220
|
+
if (v === undefined) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Query parameter ":${u.name}" is used but its value is undefined`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (u.inExpansion && Array.isArray(v)) result.push(...v);
|
|
226
|
+
else result.push(v);
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Live-placeholder check (spec §6.3): throw for any real placeholder in the
|
|
233
|
+
* assembled SQL whose name is absent from `params`. Conditional fragments that
|
|
234
|
+
* were excluded contribute no placeholder, so they never trip this.
|
|
235
|
+
*/
|
|
236
|
+
export function assertAllProvided(
|
|
237
|
+
sql: string,
|
|
238
|
+
params: Record<string, DriverParamValue>,
|
|
239
|
+
): void {
|
|
240
|
+
for (const o of scanPlaceholders(sql)) {
|
|
241
|
+
if (!(o.name in params)) {
|
|
242
|
+
throw new Error(`Missing value for query parameter ":${o.name}"`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** One-shot: live-check then return `{ sql: expanded, values }`. */
|
|
248
|
+
export function prepareScanned(
|
|
249
|
+
sql: string,
|
|
250
|
+
params: Record<string, DriverParamValue>,
|
|
251
|
+
): { sql: string; values: DriverParamValue[] } {
|
|
252
|
+
assertAllProvided(sql, params);
|
|
253
|
+
return { sql: expandScanned(sql, params), values: collectScanned(sql, params) };
|
|
254
|
+
}
|