@kequtech/tenon-orm 0.3.0 → 0.4.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/CHANGELOG.md +54 -0
- package/README.md +27 -5
- package/dist/affected-rows.js +0 -1
- package/dist/baseline.js +3 -5
- package/dist/check/contracts.d.ts +6 -0
- package/dist/check/contracts.js +206 -0
- package/dist/check/project.d.ts +18 -0
- package/dist/check/project.js +224 -0
- package/dist/check/source.d.ts +51 -0
- package/dist/check/source.js +629 -0
- package/dist/check/types.d.ts +36 -0
- package/dist/check/types.js +1 -0
- package/dist/check.d.ts +4 -0
- package/dist/check.js +147 -0
- package/dist/cli.js +38 -9
- package/dist/connection.d.ts +5 -12
- package/dist/connection.js +12 -121
- package/dist/database.js +5 -8
- package/dist/describe.js +10 -3
- package/dist/generate.d.ts +2 -3
- package/dist/generate.js +26 -24
- package/dist/generation/catalog.d.ts +5 -6
- package/dist/generation/catalog.js +7 -9
- package/dist/generation/render.d.ts +3 -0
- package/dist/generation/render.js +60 -0
- package/dist/generation/schema-summary.d.ts +3 -3
- package/dist/generation/schema-summary.js +2 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/inspection/catalog.d.ts +3 -6
- package/dist/inspection/catalog.js +9 -18
- package/dist/inspection/cli.d.ts +12 -0
- package/dist/inspection/cli.js +9 -4
- package/dist/inspection/connection.d.ts +2 -3
- package/dist/inspection/connection.js +20 -22
- package/dist/inspection/options.js +0 -6
- package/dist/inspection/result.d.ts +3 -2
- package/dist/inspection/result.js +13 -26
- package/dist/inspection/types.d.ts +6 -4
- package/dist/migrations/connection.d.ts +3 -3
- package/dist/migrations/connection.js +14 -21
- package/dist/migrations/history.d.ts +4 -4
- package/dist/migrations/history.js +5 -5
- package/dist/migrations.js +7 -9
- package/dist/query-options.d.ts +1 -1
- package/dist/query.js +1 -1
- package/dist/transaction.d.ts +2 -2
- package/dist/transaction.js +21 -77
- package/dist/types.d.ts +1 -1
- package/docs/adoption-guide.md +64 -12
- package/docs/agent-guide.md +57 -27
- package/docs/human-guide.md +53 -18
- package/docs/mutations.md +22 -9
- package/docs/query-checking.md +200 -0
- package/docs/query-inspection.md +40 -13
- package/docs/reference.md +330 -108
- package/package.json +11 -7
- package/dist/driver/types.d.ts +0 -10
- package/dist/driver/types.js +0 -49
- package/dist/generation/table-types.d.ts +0 -2
- package/dist/generation/table-types.js +0 -96
- package/dist/inspection/protocol.d.ts +0 -7
- package/dist/inspection/protocol.js +0 -45
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { builtin } from '@kequtech/pgclear';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import { joinSeparator, quote } from '../sql.js';
|
|
4
|
+
import { location, symbol } from './project.js';
|
|
5
|
+
export const variantLimit = 16;
|
|
6
|
+
export class Unsupported extends Error {
|
|
7
|
+
node;
|
|
8
|
+
code;
|
|
9
|
+
constructor(node, code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.node = node;
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function unwrap(node) {
|
|
16
|
+
while (ts.isParenthesizedExpression(node) ||
|
|
17
|
+
ts.isAsExpression(node) ||
|
|
18
|
+
ts.isTypeAssertionExpression(node) ||
|
|
19
|
+
ts.isSatisfiesExpression(node) ||
|
|
20
|
+
ts.isNonNullExpression(node))
|
|
21
|
+
node = node.expression;
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
function variable(checker, node) {
|
|
25
|
+
const declaration = symbol(checker, node)?.valueDeclaration;
|
|
26
|
+
return declaration && ts.isVariableDeclaration(declaration) ? declaration : undefined;
|
|
27
|
+
}
|
|
28
|
+
function constant(declaration) {
|
|
29
|
+
return (ts.isVariableDeclarationList(declaration.parent) &&
|
|
30
|
+
!!(declaration.parent.flags & ts.NodeFlags.Const));
|
|
31
|
+
}
|
|
32
|
+
/** A bounded interpreter for SQL structure, never for application values or function bodies. */
|
|
33
|
+
export class SourceAnalysis {
|
|
34
|
+
project;
|
|
35
|
+
libraries;
|
|
36
|
+
issues = [];
|
|
37
|
+
immutableArrays = new Set();
|
|
38
|
+
stableParameters = new Map();
|
|
39
|
+
unstableConditions = new Map();
|
|
40
|
+
constructor(project, libraries) {
|
|
41
|
+
this.project = project;
|
|
42
|
+
this.libraries = libraries;
|
|
43
|
+
}
|
|
44
|
+
role(input, depth = 0) {
|
|
45
|
+
if (depth > 32)
|
|
46
|
+
return undefined;
|
|
47
|
+
const node = unwrap(input);
|
|
48
|
+
const target = symbol(this.project.checker, node);
|
|
49
|
+
for (const name of ['sql', 'unsafeSql', 'identifier']) {
|
|
50
|
+
if (target && target === this.libraries.get(`@kequtech/tenon-orm:${name}`))
|
|
51
|
+
return name;
|
|
52
|
+
}
|
|
53
|
+
for (const name of ['param', 'json', 'builtin']) {
|
|
54
|
+
if (target &&
|
|
55
|
+
['@kequtech/tenon-orm', '@kequtech/pgclear'].some((module) => target === this.libraries.get(`${module}:${name}`)))
|
|
56
|
+
return `pgclear.${name}`;
|
|
57
|
+
}
|
|
58
|
+
if (ts.isPropertyAccessExpression(node) && this.role(node.expression, depth + 1) === 'sql')
|
|
59
|
+
return `sql.${node.name.text}`;
|
|
60
|
+
const declaration = variable(this.project.checker, node);
|
|
61
|
+
return declaration?.initializer && constant(declaration)
|
|
62
|
+
? this.role(declaration.initializer, depth + 1)
|
|
63
|
+
: undefined;
|
|
64
|
+
}
|
|
65
|
+
query(node) {
|
|
66
|
+
return ((ts.isTaggedTemplateExpression(node) && this.role(node.tag) === 'sql') ||
|
|
67
|
+
(ts.isCallExpression(node) && this.role(node.expression) === 'unsafeSql'));
|
|
68
|
+
}
|
|
69
|
+
discover(source) {
|
|
70
|
+
const queries = [];
|
|
71
|
+
const visit = (node) => {
|
|
72
|
+
if (this.query(node))
|
|
73
|
+
queries.push(node);
|
|
74
|
+
ts.forEachChild(node, visit);
|
|
75
|
+
};
|
|
76
|
+
visit(source);
|
|
77
|
+
return queries;
|
|
78
|
+
}
|
|
79
|
+
fail(node, code, message) {
|
|
80
|
+
throw new Unsupported(node, code, message);
|
|
81
|
+
}
|
|
82
|
+
reference(node, depth) {
|
|
83
|
+
if (depth > 32)
|
|
84
|
+
return this.fail(node, 'composition-depth', 'Composition exceeds the 32-reference depth limit.');
|
|
85
|
+
node = unwrap(node);
|
|
86
|
+
const declaration = variable(this.project.checker, node);
|
|
87
|
+
if (!declaration)
|
|
88
|
+
return node;
|
|
89
|
+
if (!constant(declaration) || !declaration.initializer) {
|
|
90
|
+
return this.fail(node, 'dynamic-binding', 'SQL structure requires a const binding with a recoverable initializer.');
|
|
91
|
+
}
|
|
92
|
+
return this.reference(declaration.initializer, depth + 1);
|
|
93
|
+
}
|
|
94
|
+
text(input) {
|
|
95
|
+
const node = this.reference(input, 0);
|
|
96
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
97
|
+
return node.text;
|
|
98
|
+
return this.fail(input, 'dynamic-text', 'SQL text, identifiers and separators require fixed string literals or const aliases.');
|
|
99
|
+
}
|
|
100
|
+
array(input, depth = 0) {
|
|
101
|
+
if (depth > 32)
|
|
102
|
+
return this.fail(input, 'composition-depth', 'Array composition exceeds the depth limit.');
|
|
103
|
+
const node = unwrap(input);
|
|
104
|
+
const declaration = variable(this.project.checker, node);
|
|
105
|
+
if (declaration) {
|
|
106
|
+
if (!constant(declaration) || !declaration.initializer)
|
|
107
|
+
return this.fail(node, 'dynamic-array', 'A fixed array requires a const initializer.');
|
|
108
|
+
const target = symbol(this.project.checker, node);
|
|
109
|
+
if (!this.immutableArrays.has(target)) {
|
|
110
|
+
this.closedReference(declaration, target, (parent) => ['sql.join', 'sql.insertValues', 'sql.assignments', 'unsafeSql'].includes(this.role(parent.expression) ?? ''), 'mutable-array', 'This array can be mutated or escape to application code; use a fixed array directly in the Tenon helper.');
|
|
111
|
+
this.immutableArrays.add(target);
|
|
112
|
+
}
|
|
113
|
+
return this.array(declaration.initializer, depth + 1);
|
|
114
|
+
}
|
|
115
|
+
if (!ts.isArrayLiteralExpression(node))
|
|
116
|
+
return this.fail(input, 'dynamic-array', 'The checker requires a fixed array literal; runtime list lengths are unchecked.');
|
|
117
|
+
return node.elements.flatMap((element) => {
|
|
118
|
+
if (ts.isSpreadElement(element))
|
|
119
|
+
return [...this.array(element.expression, depth + 1)];
|
|
120
|
+
if (ts.isOmittedExpression(element))
|
|
121
|
+
return this.fail(element, 'array-hole', 'Sparse expansion arrays are unsupported.');
|
|
122
|
+
return [element];
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
closedReference(declaration, target, allowed, code, message) {
|
|
126
|
+
for (const source of this.project.files) {
|
|
127
|
+
const visit = (reference) => {
|
|
128
|
+
if (ts.isIdentifier(reference) &&
|
|
129
|
+
reference !== declaration.name &&
|
|
130
|
+
symbol(this.project.checker, reference) === target) {
|
|
131
|
+
let value = reference;
|
|
132
|
+
while (ts.isParenthesizedExpression(value.parent) ||
|
|
133
|
+
ts.isAsExpression(value.parent) ||
|
|
134
|
+
ts.isSatisfiesExpression(value.parent))
|
|
135
|
+
value = value.parent;
|
|
136
|
+
const parent = value.parent;
|
|
137
|
+
const safe = ts.isCallExpression(parent) &&
|
|
138
|
+
parent.arguments.includes(value) &&
|
|
139
|
+
allowed(parent, value);
|
|
140
|
+
const exportedAlias = ts.isExportSpecifier(parent) || ts.isImportSpecifier(parent);
|
|
141
|
+
if (!safe && !exportedAlias)
|
|
142
|
+
this.fail(reference, code, message);
|
|
143
|
+
}
|
|
144
|
+
ts.forEachChild(reference, visit);
|
|
145
|
+
};
|
|
146
|
+
visit(source);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
combine(left, right, at) {
|
|
150
|
+
const result = [];
|
|
151
|
+
for (const a of left)
|
|
152
|
+
for (const b of right) {
|
|
153
|
+
if ([...a.choices].some(([choice, selected]) => b.choices.has(choice) && b.choices.get(choice) !== selected))
|
|
154
|
+
continue;
|
|
155
|
+
result.push({
|
|
156
|
+
parts: [...a.parts, ...b.parts],
|
|
157
|
+
choices: new Map([...a.choices, ...b.choices]),
|
|
158
|
+
});
|
|
159
|
+
if (result.length > variantLimit)
|
|
160
|
+
this.fail(at, 'variant-limit', `The query exceeds the ${variantLimit}-variant budget.`);
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
conditional(node, depth) {
|
|
165
|
+
let condition = unwrap(node.condition);
|
|
166
|
+
let negated = false;
|
|
167
|
+
while (ts.isPrefixUnaryExpression(condition) &&
|
|
168
|
+
condition.operator === ts.SyntaxKind.ExclamationToken) {
|
|
169
|
+
negated = !negated;
|
|
170
|
+
condition = unwrap(condition.operand);
|
|
171
|
+
}
|
|
172
|
+
const target = symbol(this.project.checker, condition);
|
|
173
|
+
const declaration = variable(this.project.checker, condition);
|
|
174
|
+
const valueDeclaration = target?.valueDeclaration;
|
|
175
|
+
let stable = !!declaration && constant(declaration);
|
|
176
|
+
if (target && valueDeclaration && ts.isParameter(valueDeclaration)) {
|
|
177
|
+
if (!this.stableParameters.has(target)) {
|
|
178
|
+
let written = false;
|
|
179
|
+
const contains = (node) => (ts.isIdentifier(node) && symbol(this.project.checker, node) === target) ||
|
|
180
|
+
!!ts.forEachChild(node, contains);
|
|
181
|
+
const visit = (candidate) => {
|
|
182
|
+
if (ts.isBinaryExpression(candidate) &&
|
|
183
|
+
candidate.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
|
184
|
+
candidate.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
|
|
185
|
+
contains(candidate.left))
|
|
186
|
+
written = true;
|
|
187
|
+
if ((ts.isPrefixUnaryExpression(candidate) || ts.isPostfixUnaryExpression(candidate)) &&
|
|
188
|
+
[ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(candidate.operator) &&
|
|
189
|
+
contains(candidate.operand))
|
|
190
|
+
written = true;
|
|
191
|
+
if ((ts.isForOfStatement(candidate) || ts.isForInStatement(candidate)) &&
|
|
192
|
+
contains(candidate.initializer))
|
|
193
|
+
written = true;
|
|
194
|
+
ts.forEachChild(candidate, visit);
|
|
195
|
+
};
|
|
196
|
+
visit(valueDeclaration.getSourceFile());
|
|
197
|
+
this.stableParameters.set(target, !written);
|
|
198
|
+
}
|
|
199
|
+
stable = this.stableParameters.get(target);
|
|
200
|
+
}
|
|
201
|
+
if (target && !stable) {
|
|
202
|
+
const previous = this.unstableConditions.get(target);
|
|
203
|
+
if (previous && previous !== node)
|
|
204
|
+
return this.fail(node, 'branch-correlation-unresolved', 'Several ternaries share a mutable condition; the checker cannot establish their complete variant set.');
|
|
205
|
+
this.unstableConditions.set(target, node);
|
|
206
|
+
}
|
|
207
|
+
const choice = target && stable ? target : node;
|
|
208
|
+
const variants = [];
|
|
209
|
+
for (const [branch, selected] of [
|
|
210
|
+
[node.whenTrue, true],
|
|
211
|
+
[node.whenFalse, false],
|
|
212
|
+
]) {
|
|
213
|
+
try {
|
|
214
|
+
for (const expansion of this.expression(branch, depth + 1)) {
|
|
215
|
+
const value = negated ? !selected : selected;
|
|
216
|
+
if (expansion.choices.has(choice) && expansion.choices.get(choice) !== value)
|
|
217
|
+
continue;
|
|
218
|
+
variants.push({
|
|
219
|
+
parts: expansion.parts,
|
|
220
|
+
choices: new Map([...expansion.choices, [choice, value]]),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (!(error instanceof Unsupported))
|
|
226
|
+
throw error;
|
|
227
|
+
this.issues.push({
|
|
228
|
+
...location(error.node),
|
|
229
|
+
kind: 'unchecked',
|
|
230
|
+
code: error.code,
|
|
231
|
+
message: error.message,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (variants.length > variantLimit)
|
|
236
|
+
this.fail(node, 'variant-limit', `The query exceeds the ${variantLimit}-variant budget.`);
|
|
237
|
+
return variants;
|
|
238
|
+
}
|
|
239
|
+
template(node, depth) {
|
|
240
|
+
if (ts.isNoSubstitutionTemplateLiteral(node.template))
|
|
241
|
+
return [{ parts: [node.template.text], choices: new Map() }];
|
|
242
|
+
let variants = [{ parts: [node.template.head.text], choices: new Map() }];
|
|
243
|
+
for (const span of node.template.templateSpans) {
|
|
244
|
+
variants = this.combine(variants, this.expression(span.expression, depth + 1), span.expression);
|
|
245
|
+
for (const variant of variants)
|
|
246
|
+
variant.parts.push(span.literal.text);
|
|
247
|
+
}
|
|
248
|
+
return variants;
|
|
249
|
+
}
|
|
250
|
+
mutation(node, helper) {
|
|
251
|
+
const shapeNode = node.typeArguments?.[0];
|
|
252
|
+
if (!shapeNode)
|
|
253
|
+
return this.fail(node, 'expansion-type-required', 'Object expansion requires an explicit shape: sql.insertValues<Shape>(input) or sql.assignments<Shape>(input).');
|
|
254
|
+
const shape = this.project.checker.getTypeFromTypeNode(shapeNode);
|
|
255
|
+
const open = (type) => !!(type.flags & ts.TypeFlags.TypeParameter) || !!type.aliasTypeArguments?.some(open);
|
|
256
|
+
const unresolvedMapped = !!(shape.flags & ts.TypeFlags.Object) &&
|
|
257
|
+
!!(shape.objectFlags & ts.ObjectFlags.Mapped) &&
|
|
258
|
+
shape.getProperties().length === 0;
|
|
259
|
+
if (shape.flags &
|
|
260
|
+
(ts.TypeFlags.Any |
|
|
261
|
+
ts.TypeFlags.Unknown |
|
|
262
|
+
ts.TypeFlags.TypeParameter |
|
|
263
|
+
ts.TypeFlags.Union |
|
|
264
|
+
ts.TypeFlags.Conditional |
|
|
265
|
+
ts.TypeFlags.IndexedAccess) ||
|
|
266
|
+
open(shape) ||
|
|
267
|
+
unresolvedMapped ||
|
|
268
|
+
shape.getStringIndexType() ||
|
|
269
|
+
shape.getNumberIndexType() ||
|
|
270
|
+
shape.getCallSignatures().length) {
|
|
271
|
+
return this.fail(shapeNode, 'expansion-shape-unresolved', 'Expansion requires one finite object shape, without any, unions, open generics or index signatures.');
|
|
272
|
+
}
|
|
273
|
+
const input = node.arguments[0];
|
|
274
|
+
if (!input)
|
|
275
|
+
return this.fail(node, 'expansion-input-required', 'Object expansion requires an input.');
|
|
276
|
+
if (!node.arguments[1])
|
|
277
|
+
this.issues.push({
|
|
278
|
+
...location(node),
|
|
279
|
+
kind: 'unproven',
|
|
280
|
+
code: 'expansion-shape-assumed',
|
|
281
|
+
message: 'The explicit shape is trusted to describe exactly the expanded own fields and their order. An explicit column list removes that assumption.',
|
|
282
|
+
});
|
|
283
|
+
const columns = node.arguments[1]
|
|
284
|
+
? this.array(node.arguments[1]).map((key) => this.text(key))
|
|
285
|
+
: shape.getProperties().map((property) => property.name);
|
|
286
|
+
if (node.arguments[1] && !columns.length)
|
|
287
|
+
return this.fail(node, 'expansion-columns', 'An explicit column list must be nonempty.');
|
|
288
|
+
if (new Set(columns).size !== columns.length)
|
|
289
|
+
return this.fail(node, 'expansion-columns', 'Expansion column names must be unique.');
|
|
290
|
+
for (const name of columns) {
|
|
291
|
+
try {
|
|
292
|
+
quote(name);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return this.fail(node, 'expansion-columns', 'Expansion columns must be valid PostgreSQL identifiers.');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const fields = columns.map((name) => {
|
|
299
|
+
const property = shape.getProperty(name);
|
|
300
|
+
if (!property)
|
|
301
|
+
return this.fail(node, 'expansion-columns', `Column ${name} is absent from the expansion shape.`);
|
|
302
|
+
const type = this.project.checker.getTypeOfSymbolAtLocation(property, shapeNode);
|
|
303
|
+
if (property.flags & ts.SymbolFlags.Optional || this.containsUndefined(type)) {
|
|
304
|
+
return this.fail(shapeNode, 'optional-expansion', `Expansion field ${name} may be absent or undefined, changing the SQL. Select required, defined fields for checking.`);
|
|
305
|
+
}
|
|
306
|
+
return { name, binding: { node: input, type, field: name } };
|
|
307
|
+
});
|
|
308
|
+
const parts = [];
|
|
309
|
+
if (helper === 'sql.assignments') {
|
|
310
|
+
if (!fields.length)
|
|
311
|
+
return this.fail(node, 'empty-expansion', 'An assignment needs at least one field.');
|
|
312
|
+
for (const [index, field] of fields.entries()) {
|
|
313
|
+
if (index)
|
|
314
|
+
parts.push(', ');
|
|
315
|
+
parts.push(`${quote(field.name)} = `, field.binding);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const inputType = this.project.checker.getTypeAtLocation(input);
|
|
320
|
+
const rows = this.project.checker.isArrayType(inputType) || this.project.checker.isTupleType(inputType)
|
|
321
|
+
? this.array(input)
|
|
322
|
+
: [input];
|
|
323
|
+
if (!rows.length || (!fields.length && rows.length > 1))
|
|
324
|
+
return this.fail(node, 'empty-expansion', 'Expansion requires a nonempty batch; a default-only insert requires one row.');
|
|
325
|
+
if (!fields.length)
|
|
326
|
+
parts.push('DEFAULT VALUES');
|
|
327
|
+
else {
|
|
328
|
+
parts.push(`(${columns.map(quote).join(', ')}) VALUES `);
|
|
329
|
+
for (const [index, row] of rows.entries()) {
|
|
330
|
+
if (index)
|
|
331
|
+
parts.push(', ');
|
|
332
|
+
parts.push('(');
|
|
333
|
+
for (const [position, field] of fields.entries()) {
|
|
334
|
+
if (position)
|
|
335
|
+
parts.push(', ');
|
|
336
|
+
parts.push({ ...field.binding, node: row });
|
|
337
|
+
}
|
|
338
|
+
parts.push(')');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return [{ parts, choices: new Map() }];
|
|
343
|
+
}
|
|
344
|
+
containsUndefined(type) {
|
|
345
|
+
return (!!(type.flags & ts.TypeFlags.Undefined) ||
|
|
346
|
+
(!!type.isUnion() && type.types.some((part) => this.containsUndefined(part))));
|
|
347
|
+
}
|
|
348
|
+
expression(input, depth) {
|
|
349
|
+
if (depth > 32)
|
|
350
|
+
return this.fail(input, 'composition-depth', 'Composition exceeds the 32-reference depth limit.');
|
|
351
|
+
const node = unwrap(input);
|
|
352
|
+
if (ts.isConditionalExpression(node))
|
|
353
|
+
return this.conditional(node, depth);
|
|
354
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
355
|
+
if (this.role(node.tag) === 'sql.fragment') {
|
|
356
|
+
const variants = this.template(node, depth);
|
|
357
|
+
if (variants.some(({ parts }) => !parts.some((part) => typeof part !== 'string' || part.trim())))
|
|
358
|
+
return this.fail(node, 'empty-fragment', 'SQL fragments must not be empty.');
|
|
359
|
+
return variants;
|
|
360
|
+
}
|
|
361
|
+
if (this.role(node.tag) === 'sql')
|
|
362
|
+
return this.fail(node, 'nested-query', 'Executable SQL queries cannot be nested; use sql.fragment.');
|
|
363
|
+
}
|
|
364
|
+
if (ts.isCallExpression(node)) {
|
|
365
|
+
const role = this.role(node.expression);
|
|
366
|
+
if (role === 'identifier') {
|
|
367
|
+
if (!node.arguments.length)
|
|
368
|
+
return this.fail(node, 'invalid-identifier', 'An identifier requires at least one name.');
|
|
369
|
+
const names = node.arguments.map((argument) => this.text(argument));
|
|
370
|
+
try {
|
|
371
|
+
return [{ parts: [names.map(quote).join('.')], choices: new Map() }];
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return this.fail(node, 'invalid-identifier', 'Identifier names must contain 1–63 UTF-8 bytes and no NUL.');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (role === 'sql.insertValues' || role === 'sql.assignments')
|
|
378
|
+
return this.mutation(node, role);
|
|
379
|
+
if (role === 'sql.join') {
|
|
380
|
+
if (!node.arguments[0] || !node.arguments[1])
|
|
381
|
+
return this.fail(node, 'join-arguments', 'sql.join requires a fixed list and separator.');
|
|
382
|
+
const selected = this.text(node.arguments[1]);
|
|
383
|
+
if (!['AND', 'OR', ','].includes(selected))
|
|
384
|
+
return this.fail(node, 'invalid-separator', 'sql.join separators are AND, OR, or comma.');
|
|
385
|
+
const separator = joinSeparator(selected);
|
|
386
|
+
const fragments = this.array(node.arguments[0]);
|
|
387
|
+
if (!fragments.length)
|
|
388
|
+
return this.fail(node, 'empty-join', 'sql.join requires a nonempty list.');
|
|
389
|
+
let variants = [{ parts: [], choices: new Map() }];
|
|
390
|
+
for (const [index, fragment] of fragments.entries()) {
|
|
391
|
+
const type = this.project.checker.getTypeAtLocation(fragment);
|
|
392
|
+
if (!(type.isUnion() ? type.types : [type]).every((part) => part.getProperty('__tenonFragment')))
|
|
393
|
+
return this.fail(fragment, 'join-fragment-unresolved', 'sql.join requires recoverable SqlFragment values.');
|
|
394
|
+
if (index)
|
|
395
|
+
for (const variant of variants)
|
|
396
|
+
variant.parts.push(separator);
|
|
397
|
+
variants = this.combine(variants, this.expression(fragment, depth + 1), fragment);
|
|
398
|
+
}
|
|
399
|
+
return variants;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const type = this.project.checker.getTypeAtLocation(input);
|
|
403
|
+
const members = type.isUnion() ? type.types : [type];
|
|
404
|
+
if (members.some((member) => member.getProperty('__tenonFragment') || member.getProperty('names'))) {
|
|
405
|
+
const resolved = this.reference(node, depth);
|
|
406
|
+
if (resolved === node)
|
|
407
|
+
return this.fail(node, 'dynamic-fragment', 'The fragment or identifier has no recoverable source initializer; arbitrary function calls and mutable composition are unchecked.');
|
|
408
|
+
return this.expression(resolved, depth + 1);
|
|
409
|
+
}
|
|
410
|
+
return [{ parts: [{ node: input, type }], choices: new Map() }];
|
|
411
|
+
}
|
|
412
|
+
codecOid(input) {
|
|
413
|
+
const declaration = variable(this.project.checker, unwrap(input));
|
|
414
|
+
if (declaration)
|
|
415
|
+
this.closedReference(declaration, symbol(this.project.checker, unwrap(input)), (call, value) => this.role(call.expression) === 'pgclear.param' && call.arguments[1] === value, 'mutable-codec', 'The codec can be mutated or escape; its parameter OID is unresolved.');
|
|
416
|
+
let node;
|
|
417
|
+
try {
|
|
418
|
+
node = this.reference(input, 0);
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
if (error instanceof Unsupported)
|
|
422
|
+
return undefined;
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
if (ts.isPropertyAccessExpression(node) && this.role(node.expression) === 'pgclear.builtin') {
|
|
426
|
+
const codec = builtin[node.name.text];
|
|
427
|
+
return codec?.oid;
|
|
428
|
+
}
|
|
429
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
430
|
+
const property = node.properties.find((property) => ts.isPropertyAssignment(property) && property.name.getText() === 'oid');
|
|
431
|
+
if (property && ts.isPropertyAssignment(property)) {
|
|
432
|
+
const type = this.project.checker.getTypeAtLocation(property.initializer);
|
|
433
|
+
if (type.isNumberLiteral())
|
|
434
|
+
return type.value;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
encoderOid(input) {
|
|
440
|
+
let node;
|
|
441
|
+
try {
|
|
442
|
+
node = this.reference(input, 0);
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
if (error instanceof Unsupported)
|
|
446
|
+
return undefined;
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
if (!ts.isCallExpression(node))
|
|
450
|
+
return undefined;
|
|
451
|
+
if (this.role(node.expression) === 'pgclear.json') {
|
|
452
|
+
const type = node.arguments[1] ? this.text(node.arguments[1]) : 'jsonb';
|
|
453
|
+
return type === 'json' || type === 'jsonb' ? builtin[type].oid : undefined;
|
|
454
|
+
}
|
|
455
|
+
if (this.role(node.expression) === 'pgclear.param' && node.arguments[1])
|
|
456
|
+
return this.codecOid(node.arguments[1]);
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
hints(binding, parameter) {
|
|
460
|
+
const checker = this.project.checker;
|
|
461
|
+
const hints = new Set();
|
|
462
|
+
let uncertain = false;
|
|
463
|
+
const visit = (type) => {
|
|
464
|
+
if (type.isUnion()) {
|
|
465
|
+
type.types.forEach(visit);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (type.flags & ts.TypeFlags.TypeParameter) {
|
|
469
|
+
const constraint = checker.getBaseConstraintOfType(type);
|
|
470
|
+
if (constraint && constraint !== type) {
|
|
471
|
+
visit(constraint);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (type.flags &
|
|
476
|
+
(ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never | ts.TypeFlags.TypeParameter)) {
|
|
477
|
+
return this.fail(binding.node, 'parameter-type-unresolved', `Parameter ${binding.field ?? ''} has an unresolved input type (${checker.typeToString(type)}).`);
|
|
478
|
+
}
|
|
479
|
+
if (type.isNumberLiteral() &&
|
|
480
|
+
(!Number.isFinite(type.value) ||
|
|
481
|
+
(Number.isInteger(type.value) && !Number.isSafeInteger(type.value)))) {
|
|
482
|
+
this.issues.push({
|
|
483
|
+
...location(binding.node),
|
|
484
|
+
kind: 'mismatch',
|
|
485
|
+
code: 'parameter-value-mismatch',
|
|
486
|
+
parameter,
|
|
487
|
+
expected: 'finite number, with safe integer values',
|
|
488
|
+
observed: String(type.value),
|
|
489
|
+
message: `Parameter ${parameter} is a numeric literal pgclear rejects.`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
if (type.flags & ts.TypeFlags.Null ||
|
|
493
|
+
checker.isTypeAssignableTo(type, this.project.types.get('string')) ||
|
|
494
|
+
checker.isTypeAssignableTo(type, this.project.types.get('number')) ||
|
|
495
|
+
checker.isTypeAssignableTo(type, this.project.types.get('bigint')) ||
|
|
496
|
+
checker.isTypeAssignableTo(type, this.project.types.get('globalThis.Date')) ||
|
|
497
|
+
checker.isTypeAssignableTo(type, this.project.types.get('import("@kequtech/tenon-orm").TemporalInput'))) {
|
|
498
|
+
hints.add(0);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (checker.isTypeAssignableTo(type, this.project.types.get('boolean'))) {
|
|
502
|
+
hints.add(builtin.bool.oid);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (checker.isTypeAssignableTo(type, this.project.types.get('globalThis.Uint8Array'))) {
|
|
506
|
+
hints.add(builtin.bytea.oid);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (checker.isTypeAssignableTo(type, this.project.parameterType)) {
|
|
510
|
+
let oid;
|
|
511
|
+
try {
|
|
512
|
+
oid = binding.field === undefined ? this.encoderOid(binding.node) : undefined;
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
if (!(error instanceof Unsupported))
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
if (oid !== undefined && Number.isInteger(oid) && oid > 0 && oid <= 0xffffffff) {
|
|
519
|
+
hints.add(oid);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (oid !== undefined) {
|
|
523
|
+
this.issues.push({
|
|
524
|
+
...location(binding.node),
|
|
525
|
+
kind: 'mismatch',
|
|
526
|
+
code: 'encoder-oid-invalid',
|
|
527
|
+
parameter,
|
|
528
|
+
message: `The encoder declares invalid PostgreSQL OID ${oid}.`,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
uncertain = true;
|
|
533
|
+
this.issues.push({
|
|
534
|
+
...location(binding.node),
|
|
535
|
+
kind: 'unproven',
|
|
536
|
+
code: 'encoder-oid-unproven',
|
|
537
|
+
parameter,
|
|
538
|
+
message: `Input ${binding.field ?? parameter} is declared as a pgclear Parameter, but its encoder OID is unknown. Describe uses server inference; parameter and dependent result contracts remain unproven.`,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
hints.add(0);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
this.issues.push({
|
|
545
|
+
...location(binding.node),
|
|
546
|
+
kind: 'mismatch',
|
|
547
|
+
code: 'parameter-type-mismatch',
|
|
548
|
+
parameter,
|
|
549
|
+
message: `Input ${binding.field ?? 'parameter'} of type ${checker.typeToString(type)} is not a plain pgclear bind. Use null, a scalar, Date, Temporal, Uint8Array or an explicit encoder.`,
|
|
550
|
+
expected: 'pgclear InputValue',
|
|
551
|
+
observed: checker.typeToString(type),
|
|
552
|
+
});
|
|
553
|
+
hints.add(0);
|
|
554
|
+
};
|
|
555
|
+
visit(binding.type);
|
|
556
|
+
return { oids: [...hints], uncertain };
|
|
557
|
+
}
|
|
558
|
+
recover(node) {
|
|
559
|
+
let expansions;
|
|
560
|
+
try {
|
|
561
|
+
if (ts.isTaggedTemplateExpression(node))
|
|
562
|
+
expansions = this.template(node, 0);
|
|
563
|
+
else {
|
|
564
|
+
if (!node.arguments[0] || !node.arguments[1])
|
|
565
|
+
return this.fail(node, 'unsafe-sql-input', 'unsafeSql requires fixed SQL and a fixed values array.');
|
|
566
|
+
// Raw SQL already contains its own placeholders; these parts are not template interpolations.
|
|
567
|
+
const text = this.text(node.arguments[0]);
|
|
568
|
+
const bindings = this.array(node.arguments[1]).map((value) => ({
|
|
569
|
+
node: value,
|
|
570
|
+
type: this.project.checker.getTypeAtLocation(value),
|
|
571
|
+
}));
|
|
572
|
+
return this.withHints(text, bindings, node);
|
|
573
|
+
}
|
|
574
|
+
const variants = [];
|
|
575
|
+
for (const expansion of expansions) {
|
|
576
|
+
const bindings = [];
|
|
577
|
+
const text = expansion.parts
|
|
578
|
+
.map((part) => {
|
|
579
|
+
if (typeof part === 'string')
|
|
580
|
+
return part;
|
|
581
|
+
bindings.push(part);
|
|
582
|
+
return `$${bindings.length}`;
|
|
583
|
+
})
|
|
584
|
+
.join('');
|
|
585
|
+
variants.push(...this.withHints(text, bindings, node));
|
|
586
|
+
if (variants.length > variantLimit)
|
|
587
|
+
this.fail(node, 'variant-limit', `The query exceeds the ${variantLimit}-variant budget.`);
|
|
588
|
+
}
|
|
589
|
+
return variants;
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
if (!(error instanceof Unsupported))
|
|
593
|
+
throw error;
|
|
594
|
+
this.issues.push({
|
|
595
|
+
...location(error.node),
|
|
596
|
+
kind: 'unchecked',
|
|
597
|
+
code: error.code,
|
|
598
|
+
message: error.message,
|
|
599
|
+
});
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
withHints(text, bindings, node) {
|
|
604
|
+
if (!text.trim())
|
|
605
|
+
this.fail(node, 'empty-query', 'SQL queries must not be empty.');
|
|
606
|
+
if (bindings.length > 65535)
|
|
607
|
+
this.fail(node, 'parameter-limit', "The query exceeds PostgreSQL's 65,535-parameter limit.");
|
|
608
|
+
let lists = [[]];
|
|
609
|
+
const uncertainParameters = [];
|
|
610
|
+
for (const [index, binding] of bindings.entries()) {
|
|
611
|
+
const hints = this.hints(binding, index + 1);
|
|
612
|
+
if (hints.uncertain)
|
|
613
|
+
uncertainParameters.push(index + 1);
|
|
614
|
+
if (hints.oids.length === 1)
|
|
615
|
+
for (const list of lists)
|
|
616
|
+
list.push(hints.oids[0]);
|
|
617
|
+
else
|
|
618
|
+
lists = lists.flatMap((list) => hints.oids.map((oid) => [...list, oid]));
|
|
619
|
+
if (lists.length > variantLimit)
|
|
620
|
+
this.fail(node, 'variant-limit', `Parameter type alternatives exceed the ${variantLimit}-variant budget.`);
|
|
621
|
+
}
|
|
622
|
+
return lists.map((parameterTypes) => ({
|
|
623
|
+
text,
|
|
624
|
+
parameters: bindings,
|
|
625
|
+
parameterTypes,
|
|
626
|
+
uncertainParameters,
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DescribeOptions, InspectionContext } from '../inspection/types.js';
|
|
2
|
+
export interface CheckOptions extends Pick<DescribeOptions, 'connection' | 'session' | 'timeouts' | 'codecs'> {
|
|
3
|
+
/** File, file:line (one-based), or omission for the project's source files. */
|
|
4
|
+
readonly target?: string;
|
|
5
|
+
/** tsconfig path; otherwise discover it from the target file or working directory. */
|
|
6
|
+
readonly project?: string;
|
|
7
|
+
/** Explicit custom decoder representations, as TypeScript type expressions. */
|
|
8
|
+
readonly readTypes?: Readonly<Record<number, string>>;
|
|
9
|
+
}
|
|
10
|
+
export interface SourceLocation {
|
|
11
|
+
readonly file: string;
|
|
12
|
+
readonly line: number;
|
|
13
|
+
readonly column: number;
|
|
14
|
+
}
|
|
15
|
+
export interface CheckDiagnostic extends SourceLocation {
|
|
16
|
+
readonly kind: 'mismatch' | 'unchecked' | 'unproven';
|
|
17
|
+
readonly code: string;
|
|
18
|
+
readonly message: string;
|
|
19
|
+
readonly parameter?: number;
|
|
20
|
+
readonly resultColumn?: string;
|
|
21
|
+
readonly expected?: string;
|
|
22
|
+
readonly observed?: string;
|
|
23
|
+
readonly variant?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface CheckedQuery extends SourceLocation {
|
|
26
|
+
readonly status: 'analysed' | 'unchecked';
|
|
27
|
+
readonly variants: number;
|
|
28
|
+
readonly diagnostics: readonly CheckDiagnostic[];
|
|
29
|
+
}
|
|
30
|
+
export interface CheckReport {
|
|
31
|
+
readonly project: string;
|
|
32
|
+
readonly ok: boolean;
|
|
33
|
+
readonly queries: readonly CheckedQuery[];
|
|
34
|
+
readonly diagnostics: readonly CheckDiagnostic[];
|
|
35
|
+
readonly context?: InspectionContext;
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CheckOptions, CheckReport } from './check/types.js';
|
|
2
|
+
export type { CheckOptions, CheckReport, CheckDiagnostic, CheckedQuery, SourceLocation, } from './check/types.js';
|
|
3
|
+
/** Analyse authored source without importing the application, binding values or executing its SQL. */
|
|
4
|
+
export declare function checkQueries(options: CheckOptions): Promise<CheckReport>;
|