@kequtech/tenon-orm 0.4.5 → 0.4.7
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 +28 -0
- package/README.md +2 -1
- package/dist/check/contracts.d.ts +2 -2
- package/dist/check/contracts.js +12 -28
- package/dist/check/output.d.ts +3 -0
- package/dist/check/output.js +31 -0
- package/dist/check/source.d.ts +2 -2
- package/dist/check/source.js +105 -63
- package/dist/check/types.d.ts +7 -0
- package/dist/check.d.ts +1 -1
- package/dist/check.js +21 -3
- package/dist/cli.js +24 -10
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/inspection/catalog.d.ts +1 -0
- package/dist/inspection/catalog.js +5 -1
- package/dist/inspection/result.js +12 -2
- package/dist/inspection/types.d.ts +2 -0
- package/dist/migrations/connection.js +2 -2
- package/dist/schema/catalog.d.ts +4 -0
- package/dist/schema/catalog.js +69 -0
- package/dist/schema/types.d.ts +70 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +47 -0
- package/docs/adoption-guide.md +4 -1
- package/docs/configuration.md +2 -1
- package/docs/connections.md +45 -1
- package/docs/query-checking.md +64 -19
- package/docs/query-inspection.md +9 -0
- package/docs/reference.md +6 -2
- package/docs/schema-inspection.md +94 -0
- package/docs/schema-types.md +24 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.7
|
|
4
|
+
|
|
5
|
+
- Remove routine parameter-value and result-nullability checker notes. General limits are stated
|
|
6
|
+
once; remaining readable findings are grouped per query with their variant numbers. JSON retains
|
|
7
|
+
individual variant findings, without the removed routine notes.
|
|
8
|
+
- Reject enum result declarations whose string literals are disjoint from every catalog label.
|
|
9
|
+
Overlapping refinements and custom decoders retain conservative handling.
|
|
10
|
+
- Recover actual fields and value types from direct mutation object literals and fixed literal
|
|
11
|
+
batches, reducing needless expansion under broad generated shapes. Source checking and runtime
|
|
12
|
+
mutations share SQL assembly; dynamic inputs retain bounded shape analysis.
|
|
13
|
+
|
|
14
|
+
## 0.4.6
|
|
15
|
+
|
|
16
|
+
- Add `inspectSchema` and `tenon schema` for versioned, codec-independent catalog snapshots and
|
|
17
|
+
SHA-256 fingerprints. Version 1 includes scoped relation/type/sequence definitions and PostgreSQL
|
|
18
|
+
major version; functions, security policy, data and other excluded objects still need independent
|
|
19
|
+
checks. Existing adoption guards are unchanged. See [schema snapshots](docs/schema-inspection.md).
|
|
20
|
+
- Check bounded optional insert/update mutation shapes, including omission, DEFAULT and nullable
|
|
21
|
+
values. Empty runtime-rejected combinations remain explicit notes; unresolved SQL and queries
|
|
22
|
+
beyond the 16-variant budget still fail. Reports include recognized-site coverage counts.
|
|
23
|
+
Constrained generic fields retain their undefined/DEFAULT branches; zero-variant sites remain
|
|
24
|
+
unchecked and fail the report.
|
|
25
|
+
- Inspect default-decoded enum outputs as catalog label unions, including with unrelated codec
|
|
26
|
+
overrides. Source checking proves matching enum output unions while retaining narrower/custom
|
|
27
|
+
refinements and unknown nullability as unproven.
|
|
28
|
+
- Require pgclear `^0.3.4`, adding `builtin.uuidArray`, safe decode-error column/type context, and
|
|
29
|
+
a complete shared-listener ownership example. See [connections](docs/connections.md).
|
|
30
|
+
|
|
3
31
|
## 0.4.5
|
|
4
32
|
|
|
5
33
|
- Preserve generated enum and enum-domain label unions when unrelated codecs change, including
|
package/README.md
CHANGED
|
@@ -74,7 +74,8 @@ version. For work in an existing project, start with the [agent guide](docs/agen
|
|
|
74
74
|
- **SQL defines the operation.** Use PostgreSQL joins, aggregates, constraints, and upserts directly.
|
|
75
75
|
Reuse queries with ordinary functions; use fragments when parts of the SQL need to vary.
|
|
76
76
|
- **TypeScript describes the values.** Declare query projections with `sql<Row>`. Optionally generate
|
|
77
|
-
table read/insert/update types with `tenon generate`.
|
|
77
|
+
table read/insert/update types with `tenon generate`. Capture a versioned
|
|
78
|
+
[schema fingerprint](docs/schema-inspection.md) with `tenon schema` for application-owned guards.
|
|
78
79
|
- **Transactions define what commits together.** Use `db.transaction(async (tx) => { ... })` and run
|
|
79
80
|
every participating query through `tx`.
|
|
80
81
|
- **Write guards check affected rows.** `{ affectedRows: { exact: 1 } }` rolls back a write unless
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { QueryDescription } from '../inspection/types.js';
|
|
2
2
|
import { type Project } from './project.js';
|
|
3
|
-
import type { QueryNode
|
|
3
|
+
import type { QueryNode } from './source.js';
|
|
4
4
|
import type { CheckDiagnostic } from './types.js';
|
|
5
5
|
/** Compare non-NULL representations; nullability and value refinements remain explicit limitations. */
|
|
6
|
-
export declare function compareContracts(project: Project, query: QueryNode,
|
|
6
|
+
export declare function compareContracts(project: Project, query: QueryNode, description: QueryDescription, readTypes: Readonly<Record<number, string>>): CheckDiagnostic[];
|
package/dist/check/contracts.js
CHANGED
|
@@ -27,6 +27,10 @@ function kinds(project, type, seen = new Set()) {
|
|
|
27
27
|
function disjoint(project, actual, expected, depth = 0) {
|
|
28
28
|
if (depth > 8)
|
|
29
29
|
return false;
|
|
30
|
+
if (expected.isUnion())
|
|
31
|
+
return expected.types.every((member) => disjoint(project, actual, member, depth + 1));
|
|
32
|
+
if (actual.isStringLiteral() && expected.isStringLiteral())
|
|
33
|
+
return actual.value !== expected.value;
|
|
30
34
|
const a = kinds(project, actual), b = kinds(project, expected);
|
|
31
35
|
if (!b.size)
|
|
32
36
|
return true;
|
|
@@ -69,34 +73,10 @@ function rowType(project, query) {
|
|
|
69
73
|
: undefined;
|
|
70
74
|
}
|
|
71
75
|
/** Compare non-NULL representations; nullability and value refinements remain explicit limitations. */
|
|
72
|
-
export function compareContracts(project, query,
|
|
76
|
+
export function compareContracts(project, query, description, readTypes) {
|
|
73
77
|
const checker = project.checker;
|
|
74
78
|
const at = location(query);
|
|
75
79
|
const diagnostics = [];
|
|
76
|
-
for (const [index, parameter] of variant.parameters.entries()) {
|
|
77
|
-
const expected = description.parameters[index];
|
|
78
|
-
if (!expected)
|
|
79
|
-
continue;
|
|
80
|
-
const inputKinds = kinds(project, parameter.type);
|
|
81
|
-
if (inputKinds.has('string') || inputKinds.has('number')) {
|
|
82
|
-
diagnostics.push({
|
|
83
|
-
...location(parameter.node),
|
|
84
|
-
kind: 'unproven',
|
|
85
|
-
code: 'parameter-value-unproven',
|
|
86
|
-
parameter: index + 1,
|
|
87
|
-
expected: expected.postgres,
|
|
88
|
-
observed: checker.typeToString(parameter.type),
|
|
89
|
-
message: `Parameter ${index + 1}${parameter.field ? ` (${parameter.field})` : ''}: ${checker.typeToString(parameter.type)} does not prove PostgreSQL ranges or textual formats for ${expected.postgres}.`,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (description.columns.length)
|
|
94
|
-
diagnostics.push({
|
|
95
|
-
...at,
|
|
96
|
-
kind: 'unproven',
|
|
97
|
-
code: 'nullability-unproven',
|
|
98
|
-
message: 'Describe does not establish result nullability; column representation checks exclude SQL NULL.',
|
|
99
|
-
});
|
|
100
80
|
for (const issue of description.issues) {
|
|
101
81
|
if (issue.code === 'duplicate-column')
|
|
102
82
|
diagnostics.push({
|
|
@@ -144,13 +124,17 @@ export function compareContracts(project, query, variant, description, readTypes
|
|
|
144
124
|
continue; // Structural result types may omit additional selected columns.
|
|
145
125
|
const representation = readTypes[column.oid] ?? column.readType;
|
|
146
126
|
const actual = project.types.get(representation);
|
|
127
|
+
const actualTypes = column.enumLabels?.map((label) => checker.getStringLiteralType(label)) ??
|
|
128
|
+
(actual && !(actual.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.Any))
|
|
129
|
+
? [actual]
|
|
130
|
+
: undefined);
|
|
147
131
|
const common = {
|
|
148
132
|
...at,
|
|
149
133
|
resultColumn: column.name,
|
|
150
134
|
expected: checker.typeToString(expected),
|
|
151
135
|
observed: representation,
|
|
152
136
|
};
|
|
153
|
-
if (!
|
|
137
|
+
if (!actualTypes) {
|
|
154
138
|
findings.push({
|
|
155
139
|
...common,
|
|
156
140
|
kind: 'unproven',
|
|
@@ -168,8 +152,8 @@ export function compareContracts(project, query, variant, description, readTypes
|
|
|
168
152
|
message: `Column ${JSON.stringify(column.name)} is declared as any.`,
|
|
169
153
|
});
|
|
170
154
|
}
|
|
171
|
-
else if (!checker.isTypeAssignableTo(
|
|
172
|
-
const mismatch = disjoint(project,
|
|
155
|
+
else if (!actualTypes.every((type) => checker.isTypeAssignableTo(type, nonnull))) {
|
|
156
|
+
const mismatch = actualTypes.every((type) => disjoint(project, type, nonnull));
|
|
173
157
|
findings.push({
|
|
174
158
|
...common,
|
|
175
159
|
kind: mismatch ? 'mismatch' : 'unproven',
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function group(diagnostics) {
|
|
2
|
+
const groups = new Map();
|
|
3
|
+
for (const diagnostic of diagnostics) {
|
|
4
|
+
const { variant, ...finding } = diagnostic;
|
|
5
|
+
const key = JSON.stringify(finding);
|
|
6
|
+
let entry = groups.get(key);
|
|
7
|
+
if (!entry) {
|
|
8
|
+
entry = { diagnostic, variants: new Set() };
|
|
9
|
+
groups.set(key, entry);
|
|
10
|
+
}
|
|
11
|
+
if (variant !== undefined)
|
|
12
|
+
entry.variants.add(variant);
|
|
13
|
+
}
|
|
14
|
+
return [...groups.values()];
|
|
15
|
+
}
|
|
16
|
+
/** Group presentation only; JSON retains each finding's SQL variant. */
|
|
17
|
+
export function formatCheckReport(report) {
|
|
18
|
+
const queryFindings = new Set(report.queries.flatMap((query) => query.diagnostics));
|
|
19
|
+
const findings = [
|
|
20
|
+
...group(report.diagnostics.filter((finding) => !queryFindings.has(finding))),
|
|
21
|
+
...report.queries.flatMap((query) => group(query.diagnostics)),
|
|
22
|
+
];
|
|
23
|
+
const lines = findings.map(({ diagnostic: d, variants }) => {
|
|
24
|
+
const scope = variants.size
|
|
25
|
+
? ` [variant${variants.size === 1 ? '' : 's'} ${[...variants].join(', ')}]`
|
|
26
|
+
: '';
|
|
27
|
+
return `${d.file}:${d.line}:${d.column} [${d.kind}] ${d.code}${scope}: ${d.message}`;
|
|
28
|
+
});
|
|
29
|
+
lines.push(`${report.coverage.total} queries, ${report.coverage.analysed} analysed, ${report.coverage.unchecked} unchecked; ${findings.filter(({ diagnostic }) => diagnostic.kind === 'mismatch').length} definite mismatches, ${findings.filter(({ diagnostic }) => diagnostic.kind === 'unproven').length} unproven findings.`, 'Checks exclude result nullability and runtime value validation.');
|
|
30
|
+
return lines.join('\n') + '\n';
|
|
31
|
+
}
|
package/dist/check/source.d.ts
CHANGED
|
@@ -10,7 +10,6 @@ export interface Binding {
|
|
|
10
10
|
}
|
|
11
11
|
export interface QueryVariant {
|
|
12
12
|
readonly text: string;
|
|
13
|
-
readonly parameters: readonly Binding[];
|
|
14
13
|
readonly parameterTypes: readonly number[];
|
|
15
14
|
readonly uncertainParameters: readonly number[];
|
|
16
15
|
}
|
|
@@ -39,8 +38,9 @@ export declare class SourceAnalysis {
|
|
|
39
38
|
private combine;
|
|
40
39
|
private conditional;
|
|
41
40
|
private template;
|
|
41
|
+
/** Only direct data-property literals establish actual own keys and value expressions. */
|
|
42
|
+
private literalFields;
|
|
42
43
|
private mutation;
|
|
43
|
-
private containsUndefined;
|
|
44
44
|
private expression;
|
|
45
45
|
private codecOid;
|
|
46
46
|
private encoderOid;
|
package/dist/check/source.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { builtin } from '@kequtech/pgclear';
|
|
2
2
|
import ts from 'typescript';
|
|
3
|
+
import { assignmentParts, insertParts } from '../mutations.js';
|
|
3
4
|
import { joinSeparator, quote } from '../sql.js';
|
|
4
5
|
import { location, symbol } from './project.js';
|
|
5
6
|
export const variantLimit = 16;
|
|
@@ -247,6 +248,30 @@ export class SourceAnalysis {
|
|
|
247
248
|
}
|
|
248
249
|
return variants;
|
|
249
250
|
}
|
|
251
|
+
/** Only direct data-property literals establish actual own keys and value expressions. */
|
|
252
|
+
literalFields(input) {
|
|
253
|
+
const node = unwrap(input);
|
|
254
|
+
if (!ts.isObjectLiteralExpression(node))
|
|
255
|
+
return undefined;
|
|
256
|
+
const fields = new Map();
|
|
257
|
+
for (const property of node.properties) {
|
|
258
|
+
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property))
|
|
259
|
+
return undefined;
|
|
260
|
+
const name = property.name;
|
|
261
|
+
if (!ts.isIdentifier(name) && !ts.isStringLiteral(name) && !ts.isNumericLiteral(name))
|
|
262
|
+
return undefined;
|
|
263
|
+
// A colon-form __proto__ property changes the prototype rather than defining an own key.
|
|
264
|
+
if (name.text === '__proto__' && ts.isPropertyAssignment(property))
|
|
265
|
+
return undefined;
|
|
266
|
+
const value = ts.isPropertyAssignment(property) ? property.initializer : property.name;
|
|
267
|
+
fields.set(name.text, {
|
|
268
|
+
node: value,
|
|
269
|
+
type: this.project.checker.getTypeAtLocation(value),
|
|
270
|
+
field: name.text,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return fields;
|
|
274
|
+
}
|
|
250
275
|
mutation(node, helper) {
|
|
251
276
|
const shapeNode = node.typeArguments?.[0];
|
|
252
277
|
if (!shapeNode)
|
|
@@ -273,77 +298,93 @@ export class SourceAnalysis {
|
|
|
273
298
|
const input = node.arguments[0];
|
|
274
299
|
if (!input)
|
|
275
300
|
return this.fail(node, 'expansion-input-required', 'Object expansion requires an input.');
|
|
276
|
-
|
|
301
|
+
const explicit = node.arguments[1]
|
|
302
|
+
? this.array(node.arguments[1]).map((key) => this.text(key))
|
|
303
|
+
: undefined;
|
|
304
|
+
if (explicit && (!explicit.length || new Set(explicit).size !== explicit.length))
|
|
305
|
+
return this.fail(node, 'expansion-columns', 'Expansion columns must be nonempty and unique.');
|
|
306
|
+
for (const name of explicit ?? [])
|
|
307
|
+
if (!shape.getProperty(name))
|
|
308
|
+
return this.fail(node, 'expansion-columns', `Column ${name} is absent from the expansion shape.`);
|
|
309
|
+
const inputType = this.project.checker.getTypeAtLocation(input);
|
|
310
|
+
const rows = helper === 'sql.insertValues' &&
|
|
311
|
+
(this.project.checker.isArrayType(inputType) || this.project.checker.isTupleType(inputType))
|
|
312
|
+
? this.array(input)
|
|
313
|
+
: [input];
|
|
314
|
+
if (!rows.length)
|
|
315
|
+
return this.fail(node, 'empty-expansion', 'Expansion requires a nonempty batch.');
|
|
316
|
+
const literals = rows.map((row) => this.literalFields(row));
|
|
317
|
+
if (!explicit && literals.some((literal) => !literal))
|
|
277
318
|
this.issues.push({
|
|
278
319
|
...location(node),
|
|
279
320
|
kind: 'unproven',
|
|
280
321
|
code: 'expansion-shape-assumed',
|
|
281
322
|
message: 'The explicit shape is trusted to describe exactly the expanded own fields and their order. An explicit column list removes that assumption.',
|
|
282
323
|
});
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
324
|
+
let variants = [[]];
|
|
325
|
+
for (const [index, row] of rows.entries()) {
|
|
326
|
+
const literal = literals[index];
|
|
327
|
+
const columns = explicit ?? (literal ? [...literal.keys()] : shape.getProperties().map((p) => p.name));
|
|
328
|
+
let choices = [{}];
|
|
329
|
+
for (const name of columns) {
|
|
330
|
+
try {
|
|
331
|
+
quote(name);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return this.fail(node, 'expansion-columns', 'Expansion columns must be valid PostgreSQL identifiers.');
|
|
335
|
+
}
|
|
336
|
+
const property = shape.getProperty(name);
|
|
337
|
+
const binding = literal
|
|
338
|
+
? literal.get(name)
|
|
339
|
+
: property && {
|
|
340
|
+
node: row,
|
|
341
|
+
type: this.project.checker.getTypeOfSymbolAtLocation(property, shapeNode),
|
|
342
|
+
field: name,
|
|
343
|
+
};
|
|
344
|
+
const valueType = binding && (this.project.checker.getBaseConstraintOfType(binding.type) ?? binding.type);
|
|
345
|
+
const members = valueType ? (valueType.isUnion() ? valueType.types : [valueType]) : [];
|
|
346
|
+
const defined = members.some((member) => !(member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Never)));
|
|
347
|
+
const optional = !binding ||
|
|
348
|
+
(!literal && !!(property && property.flags & ts.SymbolFlags.Optional)) ||
|
|
349
|
+
members.some((member) => !!(member.flags & ts.TypeFlags.Undefined));
|
|
350
|
+
const values = [];
|
|
351
|
+
if (binding && defined)
|
|
352
|
+
values.push(binding);
|
|
353
|
+
if (optional || !defined)
|
|
354
|
+
values.push(undefined);
|
|
355
|
+
choices = choices.flatMap((choice) => values.map((value) => ({ ...choice, [name]: value })));
|
|
356
|
+
if (choices.length * variants.length > variantLimit)
|
|
357
|
+
return this.fail(node, 'variant-limit', `Mutation expansion exceeds the ${variantLimit}-variant budget.`);
|
|
316
358
|
}
|
|
359
|
+
variants = variants.flatMap((variant) => choices.map((choice) => [...variant, choice]));
|
|
317
360
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
}
|
|
361
|
+
const result = [];
|
|
362
|
+
let rejected = false;
|
|
363
|
+
for (const variant of variants) {
|
|
364
|
+
const hasValues = variant.some((row) => Object.values(row).some((value) => value !== undefined));
|
|
365
|
+
if (!hasValues && (helper === 'sql.assignments' || (!explicit && rows.length > 1))) {
|
|
366
|
+
rejected = true;
|
|
367
|
+
continue;
|
|
340
368
|
}
|
|
369
|
+
// These builders see only compiler-created bindings, never application objects or values.
|
|
370
|
+
const parts = helper === 'sql.assignments'
|
|
371
|
+
? assignmentParts(variant[0], explicit)
|
|
372
|
+
: insertParts(variant, explicit);
|
|
373
|
+
result.push({
|
|
374
|
+
parts: parts.map((part) => (typeof part === 'string' ? part : part.value)),
|
|
375
|
+
choices: new Map(),
|
|
376
|
+
});
|
|
341
377
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
378
|
+
if (!result.length)
|
|
379
|
+
return this.fail(node, 'empty-expansion', 'Every expansion is rejected by the runtime helper: assignments need a defined field and default-only inserts need one row.');
|
|
380
|
+
if (rejected)
|
|
381
|
+
this.issues.push({
|
|
382
|
+
...location(node),
|
|
383
|
+
kind: 'unproven',
|
|
384
|
+
code: 'empty-expansion-rejected',
|
|
385
|
+
message: 'The runtime helper rejects empty assignments or default-only batches before SQL is sent. Only SQL-producing variants are analysed; validate inputs if rejection is not intended.',
|
|
386
|
+
});
|
|
387
|
+
return result;
|
|
347
388
|
}
|
|
348
389
|
expression(input, depth) {
|
|
349
390
|
if (depth > 32)
|
|
@@ -461,6 +502,8 @@ export class SourceAnalysis {
|
|
|
461
502
|
const hints = new Set();
|
|
462
503
|
let uncertain = false;
|
|
463
504
|
const visit = (type) => {
|
|
505
|
+
if (binding.field !== undefined && type.flags & ts.TypeFlags.Undefined)
|
|
506
|
+
return;
|
|
464
507
|
if (type.isUnion()) {
|
|
465
508
|
type.types.forEach(visit);
|
|
466
509
|
return;
|
|
@@ -509,7 +552,7 @@ export class SourceAnalysis {
|
|
|
509
552
|
if (checker.isTypeAssignableTo(type, this.project.parameterType)) {
|
|
510
553
|
let oid;
|
|
511
554
|
try {
|
|
512
|
-
oid =
|
|
555
|
+
oid = this.encoderOid(binding.node);
|
|
513
556
|
}
|
|
514
557
|
catch (error) {
|
|
515
558
|
if (!(error instanceof Unsupported))
|
|
@@ -621,7 +664,6 @@ export class SourceAnalysis {
|
|
|
621
664
|
}
|
|
622
665
|
return lists.map((parameterTypes) => ({
|
|
623
666
|
text,
|
|
624
|
-
parameters: bindings,
|
|
625
667
|
parameterTypes,
|
|
626
668
|
uncertainParameters,
|
|
627
669
|
}));
|
package/dist/check/types.d.ts
CHANGED
|
@@ -32,5 +32,12 @@ export interface CheckReport {
|
|
|
32
32
|
readonly ok: boolean;
|
|
33
33
|
readonly queries: readonly CheckedQuery[];
|
|
34
34
|
readonly diagnostics: readonly CheckDiagnostic[];
|
|
35
|
+
/** Recognized query sites in the selected scope; analysed does not mean mismatch-free. */
|
|
36
|
+
readonly coverage: CheckCoverage;
|
|
35
37
|
readonly context?: InspectionContext;
|
|
36
38
|
}
|
|
39
|
+
export interface CheckCoverage {
|
|
40
|
+
readonly total: number;
|
|
41
|
+
readonly analysed: number;
|
|
42
|
+
readonly unchecked: number;
|
|
43
|
+
}
|
package/dist/check.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { CheckOptions, CheckReport } from './check/types.js';
|
|
2
|
-
export type { CheckOptions, CheckReport, CheckDiagnostic, CheckedQuery, SourceLocation, } from './check/types.js';
|
|
2
|
+
export type { CheckOptions, CheckReport, CheckCoverage, CheckDiagnostic, CheckedQuery, SourceLocation, } from './check/types.js';
|
|
3
3
|
/** Analyse authored source without importing the application, binding values or executing its SQL. */
|
|
4
4
|
export declare function checkQueries(options: CheckOptions): Promise<CheckReport>;
|
package/dist/check.js
CHANGED
|
@@ -47,11 +47,25 @@ export async function checkQueries(options) {
|
|
|
47
47
|
? 'Several queries occupy this line. Put their SQL tags on distinct lines.'
|
|
48
48
|
: 'No recognized complete Tenon query was found at the selected target.',
|
|
49
49
|
});
|
|
50
|
-
return {
|
|
50
|
+
return {
|
|
51
|
+
project: project.project,
|
|
52
|
+
ok: false,
|
|
53
|
+
queries: [],
|
|
54
|
+
diagnostics,
|
|
55
|
+
coverage: { total: selected.length, analysed: 0, unchecked: selected.length },
|
|
56
|
+
};
|
|
51
57
|
}
|
|
52
58
|
const recovered = selected.map((query) => {
|
|
53
59
|
const analysis = new SourceAnalysis(project, libraries);
|
|
54
60
|
const variants = analysis.recover(query);
|
|
61
|
+
if (!variants.length && !analysis.issues.some((issue) => issue.kind === 'unchecked')) {
|
|
62
|
+
analysis.issues.push({
|
|
63
|
+
...location(query),
|
|
64
|
+
kind: 'unchecked',
|
|
65
|
+
code: 'no-query-variants',
|
|
66
|
+
message: 'No complete SQL variant could be recovered for this query.',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
55
69
|
return { query, variants, diagnostics: analysis.issues };
|
|
56
70
|
});
|
|
57
71
|
const reports = [];
|
|
@@ -62,11 +76,15 @@ export async function checkQueries(options) {
|
|
|
62
76
|
}
|
|
63
77
|
const finish = () => {
|
|
64
78
|
const all = distinct([...diagnostics, ...reports.flatMap((report) => report.diagnostics)]);
|
|
79
|
+
const analysed = reports.filter((report) => report.status === 'analysed').length;
|
|
65
80
|
return {
|
|
66
81
|
project: project.project,
|
|
67
|
-
ok:
|
|
82
|
+
ok: analysed > 0 &&
|
|
83
|
+
analysed === selected.length &&
|
|
84
|
+
!all.some((diagnostic) => diagnostic.kind !== 'unproven'),
|
|
68
85
|
queries: reports,
|
|
69
86
|
diagnostics: all,
|
|
87
|
+
coverage: { total: selected.length, analysed, unchecked: selected.length - analysed },
|
|
70
88
|
};
|
|
71
89
|
};
|
|
72
90
|
if (!recovered.some(({ variants }) => variants.length)) {
|
|
@@ -93,7 +111,7 @@ export async function checkQueries(options) {
|
|
|
93
111
|
});
|
|
94
112
|
const description = queryDescription(metadata, context, codecs);
|
|
95
113
|
described++;
|
|
96
|
-
findings.push(...compareContracts(project, entry.query,
|
|
114
|
+
findings.push(...compareContracts(project, entry.query, description, readTypes).map((diagnostic) => variant.uncertainParameters.length && diagnostic.kind === 'mismatch'
|
|
97
115
|
? {
|
|
98
116
|
...diagnostic,
|
|
99
117
|
kind: 'unproven',
|
package/dist/cli.js
CHANGED
|
@@ -3,12 +3,14 @@ import { parseArgs } from 'node:util';
|
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
5
|
import { defaultCodecs } from '@kequtech/pgclear';
|
|
6
|
+
import { formatCheckReport } from './check/output.js';
|
|
6
7
|
import { adoptBaseline, captureBaseline } from './baseline.js';
|
|
7
8
|
import { describeFlags, inspectionSettings } from './inspection/cli.js';
|
|
8
9
|
import { loadConfig } from './config/load.js';
|
|
9
10
|
import { initConfig } from './config/init.js';
|
|
10
11
|
import { configConnection } from './config/connection.js';
|
|
11
12
|
import { generateDefinitions } from './generate.js';
|
|
13
|
+
import { inspectSchema } from './schema.js';
|
|
12
14
|
import { writeDefinitions } from './generation/output.js';
|
|
13
15
|
import { validatePath } from './validation.js';
|
|
14
16
|
import { describeQuery } from './describe.js';
|
|
@@ -20,9 +22,10 @@ tenon baseline capture|adopt [--directory migrations] [--namespace public]
|
|
|
20
22
|
baseline capture: [--schema name ...] [--connection native-uri] (default: whole database schema)
|
|
21
23
|
tenon new <name> [--directory migrations] (no database required)
|
|
22
24
|
tenon generate [--out path] [--namespace public] [--check] [--config path]
|
|
25
|
+
tenon schema [--namespace public] (versioned catalog snapshot and fingerprint as JSON)
|
|
23
26
|
tenon describe <query.sql> [--role name] [--search-path schemas] [--time-zone zone]
|
|
24
27
|
tenon check [file.ts[:line]] [--project tsconfig.json] [--json]
|
|
25
|
-
generate/check/describe require tenon.config.ts, .mts, .js or .mjs, or --config path.
|
|
28
|
+
generate/schema/check/describe require tenon.config.ts, .mts, .js or .mjs, or --config path.
|
|
26
29
|
describe: [--connect-timeout-ms 3000] [--statement-timeout-ms 5000] [--lock-timeout-ms 1000]
|
|
27
30
|
describe emits JSON metadata; it does not execute the query or verify result annotations.
|
|
28
31
|
check compares source contracts; mismatches, unchecked sites and no sites fail. Unproven types are notes.
|
|
@@ -84,7 +87,7 @@ async function main() {
|
|
|
84
87
|
// Validate explicitly supplied flags, not defaults. Silently ignoring --check
|
|
85
88
|
// on migrate, for example, would turn an intended dry run into a schema change.
|
|
86
89
|
const allowed = new Set(['help']);
|
|
87
|
-
if (!['describe', 'check', 'generate', 'init'].includes(command ?? ''))
|
|
90
|
+
if (!['describe', 'check', 'generate', 'schema', 'init'].includes(command ?? ''))
|
|
88
91
|
allowed.add('directory');
|
|
89
92
|
if (!['new', 'describe', 'check', 'init'].includes(command ?? '')) {
|
|
90
93
|
allowed.add('namespace');
|
|
@@ -141,12 +144,26 @@ async function main() {
|
|
|
141
144
|
: baseline || command === 'describe'
|
|
142
145
|
? positionals.length === 2
|
|
143
146
|
: positionals.length === 1 &&
|
|
144
|
-
['migrate', 'generate', 'status', 'rollback'].includes(command ?? '');
|
|
147
|
+
['migrate', 'generate', 'schema', 'status', 'rollback'].includes(command ?? '');
|
|
145
148
|
if (!valid) {
|
|
146
|
-
throw new Error('Choose init, new, baseline capture|adopt, migrate, generate, describe <query.sql>, check [file.ts[:line]], status, or rollback. Use --help for options.');
|
|
149
|
+
throw new Error('Choose init, new, baseline capture|adopt, migrate, generate, schema, describe <query.sql>, check [file.ts[:line]], status, or rollback. Use --help for options.');
|
|
147
150
|
}
|
|
148
|
-
if (command === 'generate' ||
|
|
151
|
+
if (command === 'generate' ||
|
|
152
|
+
command === 'schema' ||
|
|
153
|
+
command === 'check' ||
|
|
154
|
+
command === 'describe') {
|
|
149
155
|
const config = await loadConfig(values.config, values.database);
|
|
156
|
+
if (command === 'schema') {
|
|
157
|
+
const snapshot = await inspectSchema({
|
|
158
|
+
connection: configConnection(config.connection),
|
|
159
|
+
namespace: values.namespace ??
|
|
160
|
+
config.generate?.namespace ??
|
|
161
|
+
config.migrations?.namespace ??
|
|
162
|
+
'public',
|
|
163
|
+
});
|
|
164
|
+
process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
150
167
|
if (command === 'generate') {
|
|
151
168
|
const out = values.out === undefined ? config.generate?.out : resolve(values.out);
|
|
152
169
|
if (out === undefined)
|
|
@@ -181,11 +198,8 @@ async function main() {
|
|
|
181
198
|
});
|
|
182
199
|
if (values.json)
|
|
183
200
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
184
|
-
else
|
|
185
|
-
|
|
186
|
-
process.stdout.write(`${diagnostic.file}:${diagnostic.line}:${diagnostic.column} [${diagnostic.kind}] ${diagnostic.code}: ${diagnostic.message}\n`);
|
|
187
|
-
process.stdout.write(`${report.queries.length} queries, ${report.queries.filter((query) => query.status === 'analysed').length} analysed, ${report.queries.filter((query) => query.status === 'unchecked').length} unchecked, ${report.queries.reduce((count, query) => count + query.variants, 0)} variants described; ${report.diagnostics.filter((diagnostic) => diagnostic.kind === 'mismatch').length} mismatches, ${report.diagnostics.filter((diagnostic) => diagnostic.kind === 'unproven').length} unproven findings.\n`);
|
|
188
|
-
}
|
|
201
|
+
else
|
|
202
|
+
process.stdout.write(formatCheckReport(report));
|
|
189
203
|
if (!report.ok)
|
|
190
204
|
process.exitCode = 1;
|
|
191
205
|
return;
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export { adoptBaseline, captureBaseline } from './baseline.js';
|
|
|
15
15
|
export type { CaptureBaselineOptions, CapturedBaseline } from './baseline.js';
|
|
16
16
|
export { generateDefinitions } from './generate.js';
|
|
17
17
|
export type { GenerateOptions } from './generate.js';
|
|
18
|
+
export { inspectSchema } from './schema.js';
|
|
19
|
+
export type { SchemaOptions, SchemaMetadata, SchemaSnapshot, SchemaTable, SchemaColumn, SchemaEnum, SchemaDomain, SchemaSequence, SchemaDefinition, } from './schema.js';
|
|
18
20
|
export { migrate, migrationStatus, rollback } from './migrations.js';
|
|
19
21
|
export type { MigrationStatus } from './migrations.js';
|
|
20
22
|
export type { DatabaseToolOptions } from './migrations/connection.js';
|
package/dist/index.js
CHANGED
|
@@ -6,5 +6,6 @@ export { identifier, sql, unsafeSql } from './query.js';
|
|
|
6
6
|
export { describeQuery } from './describe.js';
|
|
7
7
|
export { adoptBaseline, captureBaseline } from './baseline.js';
|
|
8
8
|
export { generateDefinitions } from './generate.js';
|
|
9
|
+
export { inspectSchema } from './schema.js';
|
|
9
10
|
export { migrate, migrationStatus, rollback } from './migrations.js';
|
|
10
11
|
export { createMigration, readMigrations } from './migrations/files.js';
|
|
@@ -5,7 +5,11 @@ export async function readStatementMetadata(client, query) {
|
|
|
5
5
|
...new Set([...statement.parameterTypes, ...statement.fields.map((c) => c.dataTypeOid)]),
|
|
6
6
|
];
|
|
7
7
|
const types = (await client.queryClassic(`
|
|
8
|
-
SELECT t.oid, n.nspname AS schema, t.typname AS name
|
|
8
|
+
SELECT t.oid, n.nspname AS schema, t.typname AS name,
|
|
9
|
+
CASE WHEN t.typtype = 'e' THEN ARRAY(
|
|
10
|
+
SELECT e.enumlabel::text FROM pg_catalog.pg_enum e
|
|
11
|
+
WHERE e.enumtypid = t.oid ORDER BY e.enumsortorder
|
|
12
|
+
) END AS "enumLabels"
|
|
9
13
|
FROM pg_catalog.pg_type t
|
|
10
14
|
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
11
15
|
WHERE t.oid = ANY(pg_catalog.string_to_array($1, ',')::pg_catalog.oid[])
|
|
@@ -7,7 +7,12 @@ export function queryDescription({ statement, types, sources }, context, codecs)
|
|
|
7
7
|
if (!type)
|
|
8
8
|
throw new Error(`PostgreSQL type OID ${oid} was not found.`);
|
|
9
9
|
const representation = codecs.typescript(oid);
|
|
10
|
-
const
|
|
10
|
+
const enumLabels = type.enumLabels !== null && representation === 'string' && codecs.isDefaultDecoder(oid)
|
|
11
|
+
? type.enumLabels
|
|
12
|
+
: undefined;
|
|
13
|
+
const readType = enumLabels === undefined
|
|
14
|
+
? (representation ?? 'unknown')
|
|
15
|
+
: enumLabels.map((label) => JSON.stringify(label)).join(' | ') || 'never';
|
|
11
16
|
if (representation === undefined) {
|
|
12
17
|
issues.set(`type:${oid}`, {
|
|
13
18
|
code: 'unmapped-type',
|
|
@@ -16,7 +21,12 @@ export function queryDescription({ statement, types, sources }, context, codecs)
|
|
|
16
21
|
message: `The selected pgclear registry cannot identify the decoder representation for ${type.schema}.${type.name} (OID ${oid}). Supply an application type for this custom decoder.`,
|
|
17
22
|
});
|
|
18
23
|
}
|
|
19
|
-
return {
|
|
24
|
+
return {
|
|
25
|
+
oid,
|
|
26
|
+
postgres: `${type.schema}.${type.name}`,
|
|
27
|
+
readType,
|
|
28
|
+
...(enumLabels === undefined ? {} : { enumLabels }),
|
|
29
|
+
};
|
|
20
30
|
}
|
|
21
31
|
const sourceMap = new Map(sources.map((s) => [`${s.oid}:${s.number}`, s]));
|
|
22
32
|
const names = new Set();
|