@kequtech/tenon-orm 0.4.6 → 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 CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 0.4.6
4
15
 
5
16
  - Add `inspectSchema` and `tenon schema` for versioned, codec-independent catalog snapshots and
@@ -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, QueryVariant } from './source.js';
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, variant: QueryVariant, description: QueryDescription, readTypes: Readonly<Record<number, string>>): CheckDiagnostic[];
6
+ export declare function compareContracts(project: Project, query: QueryNode, description: QueryDescription, readTypes: Readonly<Record<number, string>>): CheckDiagnostic[];
@@ -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, variant, description, readTypes) {
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({
@@ -0,0 +1,3 @@
1
+ import type { CheckReport } from './types.js';
2
+ /** Group presentation only; JSON retains each finding's SQL variant. */
3
+ export declare function formatCheckReport(report: CheckReport): string;
@@ -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
+ }
@@ -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,6 +38,8 @@ 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
44
  private expression;
44
45
  private codecOid;
@@ -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,44 +298,14 @@ 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
- 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]
301
+ const explicit = node.arguments[1]
284
302
  ? 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)
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))
301
308
  return this.fail(node, 'expansion-columns', `Column ${name} is absent from the expansion shape.`);
302
- const type = this.project.checker.getTypeOfSymbolAtLocation(property, shapeNode);
303
- // Presence follows the same constrained value type used to derive bind hints.
304
- const valueType = this.project.checker.getBaseConstraintOfType(type) ?? type;
305
- const members = valueType.isUnion() ? valueType.types : [valueType];
306
- return {
307
- name,
308
- type,
309
- optional: !!(property.flags & ts.SymbolFlags.Optional) ||
310
- members.some((member) => !!(member.flags & ts.TypeFlags.Undefined)),
311
- defined: members.some((member) => !(member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Never))),
312
- };
313
- });
314
309
  const inputType = this.project.checker.getTypeAtLocation(input);
315
310
  const rows = helper === 'sql.insertValues' &&
316
311
  (this.project.checker.isArrayType(inputType) || this.project.checker.isTupleType(inputType))
@@ -318,16 +313,46 @@ export class SourceAnalysis {
318
313
  : [input];
319
314
  if (!rows.length)
320
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))
318
+ this.issues.push({
319
+ ...location(node),
320
+ kind: 'unproven',
321
+ code: 'expansion-shape-assumed',
322
+ message: 'The explicit shape is trusted to describe exactly the expanded own fields and their order. An explicit column list removes that assumption.',
323
+ });
321
324
  let variants = [[]];
322
- for (const row of rows) {
323
- let choices = [[]];
324
- for (const field of fields) {
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));
325
350
  const values = [];
326
- if (field.defined)
327
- values.push({ node: row, type: field.type, field: field.name });
328
- if (field.optional || !field.defined)
351
+ if (binding && defined)
352
+ values.push(binding);
353
+ if (optional || !defined)
329
354
  values.push(undefined);
330
- choices = choices.flatMap((choice) => values.map((value) => [...choice, value]));
355
+ choices = choices.flatMap((choice) => values.map((value) => ({ ...choice, [name]: value })));
331
356
  if (choices.length * variants.length > variantLimit)
332
357
  return this.fail(node, 'variant-limit', `Mutation expansion exceeds the ${variantLimit}-variant budget.`);
333
358
  }
@@ -336,47 +361,19 @@ export class SourceAnalysis {
336
361
  const result = [];
337
362
  let rejected = false;
338
363
  for (const variant of variants) {
339
- const selected = node.arguments[1]
340
- ? columns.map((_, index) => index)
341
- : [
342
- ...new Set(variant.flatMap((row) => row.flatMap((cell, index) => (cell ? [index] : [])))),
343
- ];
344
- const parts = [];
345
- if (helper === 'sql.assignments') {
346
- for (const [index, cell] of variant[0].entries()) {
347
- if (!cell)
348
- continue;
349
- if (parts.length)
350
- parts.push(', ');
351
- parts.push(`${quote(columns[index])} = `, cell);
352
- }
353
- if (!parts.length) {
354
- rejected = true;
355
- continue;
356
- }
357
- }
358
- else if (!selected.length) {
359
- if (rows.length > 1) {
360
- rejected = true;
361
- continue;
362
- }
363
- parts.push('DEFAULT VALUES');
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;
364
368
  }
365
- else {
366
- parts.push(`(${selected.map((index) => quote(columns[index])).join(', ')}) VALUES `);
367
- for (const [index, row] of variant.entries()) {
368
- if (index)
369
- parts.push(', ');
370
- parts.push('(');
371
- for (const [position, column] of selected.entries()) {
372
- if (position)
373
- parts.push(', ');
374
- parts.push(row[column] ?? 'DEFAULT');
375
- }
376
- parts.push(')');
377
- }
378
- }
379
- result.push({ parts, choices: new Map() });
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
+ });
380
377
  }
381
378
  if (!result.length)
382
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.');
@@ -555,7 +552,7 @@ export class SourceAnalysis {
555
552
  if (checker.isTypeAssignableTo(type, this.project.parameterType)) {
556
553
  let oid;
557
554
  try {
558
- oid = binding.field === undefined ? this.encoderOid(binding.node) : undefined;
555
+ oid = this.encoderOid(binding.node);
559
556
  }
560
557
  catch (error) {
561
558
  if (!(error instanceof Unsupported))
@@ -667,7 +664,6 @@ export class SourceAnalysis {
667
664
  }
668
665
  return lists.map((parameterTypes) => ({
669
666
  text,
670
- parameters: bindings,
671
667
  parameterTypes,
672
668
  uncertainParameters,
673
669
  }));
package/dist/check.js CHANGED
@@ -111,7 +111,7 @@ export async function checkQueries(options) {
111
111
  });
112
112
  const description = queryDescription(metadata, context, codecs);
113
113
  described++;
114
- findings.push(...compareContracts(project, entry.query, variant, description, readTypes).map((diagnostic) => variant.uncertainParameters.length && diagnostic.kind === 'mismatch'
114
+ findings.push(...compareContracts(project, entry.query, description, readTypes).map((diagnostic) => variant.uncertainParameters.length && diagnostic.kind === 'mismatch'
115
115
  ? {
116
116
  ...diagnostic,
117
117
  kind: 'unproven',
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ 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';
@@ -197,11 +198,8 @@ async function main() {
197
198
  });
198
199
  if (values.json)
199
200
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
200
- else {
201
- for (const diagnostic of report.diagnostics)
202
- process.stdout.write(`${diagnostic.file}:${diagnostic.line}:${diagnostic.column} [${diagnostic.kind}] ${diagnostic.code}: ${diagnostic.message}\n`);
203
- process.stdout.write(`${report.coverage.total} queries, ${report.coverage.analysed} analysed, ${report.coverage.unchecked} 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`);
204
- }
201
+ else
202
+ process.stdout.write(formatCheckReport(report));
205
203
  if (!report.ok)
206
204
  process.exitCode = 1;
207
205
  return;
@@ -65,13 +65,16 @@ type alone does not reject a narrower application declaration.
65
65
  For enum results with unchanged default decoding, catalog labels establish exact or superset string
66
66
  unions, including when unrelated Date/bigint codecs change. Narrower label subsets and branded
67
67
  strings remain unproven. Custom enum decoders keep their registry representations; catalog labels
68
- never narrow a decoder that transforms them. Result nullability remains unproven.
68
+ never narrow a decoder that transforms them. Disjoint string-literal declarations fail as definite
69
+ mismatches; a declaration with some matching labels remains unproven. Result nullability is outside
70
+ the check.
69
71
 
70
72
  Input and output contracts are separate. For example, an int8 input accepts exact text, bigint, or
71
73
  an explicit encoder; its default output is a string. Strings, numbers, bigint, Date, and supported
72
74
  Temporal inputs use OID 0/server inference. Booleans, bytes, and recoverable `json`/`param` calls
73
75
  supply the same OID hints as execution. Unions with different hints create variants. Input types do
74
- not prove value ranges, valid Dates, or textual formats.
76
+ not prove value ranges, valid Dates, or textual formats. These general limits are stated once in
77
+ readable output, rather than repeated as diagnostics for ordinary queries.
75
78
 
76
79
  A plain object, array, or undefined input contradicts the driver's bind API. An opaque `Parameter`
77
80
  is trusted, with its unknown OID reported as unproven. Describe uses server inference in that case;
@@ -108,7 +111,10 @@ Exit 0 means no mismatch or unchecked site in the selected scope. It does not es
108
111
  nullability, cardinality, or every runtime branch. No query sites also fails. Independent queries
109
112
  continue after an unsupported site, and a recoverable branch can expose a mismatch even when another
110
113
  branch is unchecked. Diagnostics include locations, codes, parameter/column identifiers, expected
111
- and observed types, and variant numbers. `--json` returns the structured report.
114
+ and observed types, and variant numbers. Readable output groups equivalent findings within each
115
+ query and counts those groups, preserving distinct columns, parameters and types. `--json` retains
116
+ the individual variant findings in the structured report. Ordinary scalar bindings and unknown
117
+ result nullability do not generate per-query findings.
112
118
 
113
119
  ## Supported composition
114
120
 
@@ -146,10 +152,17 @@ function createUser(input: NewUser) {
146
152
  }
147
153
  ```
148
154
 
149
- The shape is a trusted expansion contract; the checker does not read the object. Prefer an explicit
150
- column list so runtime and checking select the same keys/order. Without one, the declared shape is
151
- assumed to describe exactly the expanded fields. Structural typing cannot prove the absence of
152
- extra own keys or their order, so that assumption is unproven.
155
+ For a direct object literal, the checker recovers its actual own fields and value expressions without
156
+ evaluating them. This includes shorthand properties and literals within fixed batches. A broad
157
+ generated shape therefore does not multiply variants for fields absent from the literal. An
158
+ undefined-valued expression still creates defined/omitted branches when its type permits both.
159
+
160
+ For other inputs, the shape is a trusted expansion contract. Prefer an explicit column list so
161
+ runtime and checking select the same keys/order. Without one, the declared shape is assumed to
162
+ describe exactly the expanded fields, unless the input is a recovered literal. Structural typing
163
+ cannot prove the absence of extra own keys or their order, so that assumption is unproven. Object
164
+ aliases, spreads, computed keys and accessors retain this conservative shape analysis; the checker
165
+ does not reconstruct object mutation or evaluate getters.
153
166
 
154
167
  Optional or undefined-valued fields create bounded SQL variants. With explicit insert columns, an
155
168
  absent/undefined cell becomes `DEFAULT`; with inferred columns, a field absent from every row is
package/docs/reference.md CHANGED
@@ -265,6 +265,8 @@ The bundled TypeScript compiler loads only through that path or the check comman
265
265
  Options are `{ connection, target?, project?, session?, timeouts?, codecs?, readTypes? }`.
266
266
  Connection is a URI or pool-option values; connection codecs are inherited unless `codecs` is explicit. The report contains `ok`, `project`, `queries`, `diagnostics`, and available
267
267
  inspection `context`. Mismatches, unchecked sites, and no sites fail; unproven findings alone do not.
268
+ Readable output groups equivalent findings per query; JSON retains their variant records. General
269
+ limits on runtime values and result nullability do not generate individual diagnostics.
268
270
  See [query checking](query-checking.md) for selection, supported composition, and custom decoders.
269
271
 
270
272
  ## Query inspection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kequtech/tenon-orm",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "A PostgreSQL ORM designed for coding agents, with explicit SQL, typed queries, and migrations",