@kequtech/tenon-orm 0.1.0 → 0.1.2
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/README.md +17 -9
- package/dist/generate.js +2 -2
- package/dist/generation/catalog.d.ts +3 -0
- package/dist/generation/catalog.js +5 -2
- package/dist/generation/table-types.d.ts +2 -0
- package/dist/generation/table-types.js +146 -0
- package/dist/mutations.d.ts +11 -0
- package/dist/mutations.js +87 -0
- package/dist/query.d.ts +7 -0
- package/dist/query.js +22 -3
- package/dist/safeql/composition.d.ts +1 -4
- package/dist/safeql/composition.js +19 -45
- package/dist/safeql/eslint.js +16 -11
- package/dist/safeql/mutations.d.ts +3 -0
- package/dist/safeql/mutations.js +74 -0
- package/dist/safeql/static.d.ts +10 -0
- package/dist/safeql/static.js +59 -0
- package/docs/adoption-guide.md +97 -4
- package/docs/agent-guide.md +39 -36
- package/docs/human-guide.md +33 -10
- package/docs/mutations.md +157 -0
- package/docs/reference.md +206 -51
- package/package.json +1 -1
- package/dist/generation/row-types.d.ts +0 -2
- package/dist/generation/row-types.js +0 -115
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { assignmentLayout, insertLayout, mutationColumns } from '../mutations.js';
|
|
3
|
+
import { CompositionError, isUndefined, literalString, unwrap } from './static.js';
|
|
4
|
+
const parameter = 0;
|
|
5
|
+
function fields(input) {
|
|
6
|
+
const node = unwrap(input);
|
|
7
|
+
if (!ts.isObjectLiteralExpression(node)) {
|
|
8
|
+
throw new CompositionError('Mutation inputs need inline object literals or an explicit literal column list.');
|
|
9
|
+
}
|
|
10
|
+
const result = new Map();
|
|
11
|
+
for (const property of node.properties) {
|
|
12
|
+
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
|
|
13
|
+
throw new CompositionError('Mutation object spreads, accessors, and methods need an explicit literal column list.');
|
|
14
|
+
}
|
|
15
|
+
const name = property.name;
|
|
16
|
+
if (!ts.isIdentifier(name) && !ts.isStringLiteral(name) && !ts.isNumericLiteral(name)) {
|
|
17
|
+
throw new CompositionError('Mutation keys must be literal names; use an explicit literal column list.');
|
|
18
|
+
}
|
|
19
|
+
if (name.text === '__proto__')
|
|
20
|
+
throw new CompositionError('Prototype-setting object literals are unsupported mutation inputs.');
|
|
21
|
+
if (result.has(name.text))
|
|
22
|
+
throw new CompositionError('Duplicate mutation object keys are unsupported.');
|
|
23
|
+
result.set(name.text, ts.isPropertyAssignment(property) ? property.initializer : property.name);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
/** Understand the official mutation contract, never the implementation of a JavaScript helper. */
|
|
28
|
+
export function renderMutation(kind, node, checker) {
|
|
29
|
+
if (node.questionDotToken ||
|
|
30
|
+
node.arguments.some(ts.isSpreadElement) ||
|
|
31
|
+
node.arguments.length < 1 ||
|
|
32
|
+
node.arguments.length > 2) {
|
|
33
|
+
throw new CompositionError(`sql.${kind} requires an input and an optional literal column list.`);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const [argument, selected] = node.arguments;
|
|
37
|
+
let columns;
|
|
38
|
+
let rows;
|
|
39
|
+
if (selected && !isUndefined(selected, checker)) {
|
|
40
|
+
const list = unwrap(selected);
|
|
41
|
+
if (!ts.isArrayLiteralExpression(list))
|
|
42
|
+
throw new CompositionError('Mutation columns must be an inline literal array.');
|
|
43
|
+
columns = mutationColumns(list.elements.map((item) => {
|
|
44
|
+
const column = literalString(item, checker);
|
|
45
|
+
if (column === undefined)
|
|
46
|
+
throw new CompositionError('Mutation columns must be literal strings or const strings.');
|
|
47
|
+
return column;
|
|
48
|
+
}));
|
|
49
|
+
// A fixed column contract covers any nonempty batch and every omitted/default cell.
|
|
50
|
+
// Check all selected columns conservatively; no runtime data or JS types become SQL.
|
|
51
|
+
rows = [columns.map(() => parameter)];
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const value = unwrap(argument);
|
|
55
|
+
const objects = kind === 'insertValues' && ts.isArrayLiteralExpression(value)
|
|
56
|
+
? value.elements.map(fields)
|
|
57
|
+
: [fields(value)];
|
|
58
|
+
columns = [
|
|
59
|
+
...new Set(objects.flatMap((row) => [...row].filter(([, value]) => !isUndefined(value, checker)).map(([key]) => key))),
|
|
60
|
+
];
|
|
61
|
+
rows = objects.map((row) => columns.map((column) => {
|
|
62
|
+
const cell = row.get(column);
|
|
63
|
+
return !cell || isUndefined(cell, checker) ? undefined : parameter;
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
const parts = kind === 'insertValues' ? insertLayout(columns, rows) : assignmentLayout(columns, rows[0]);
|
|
67
|
+
return parts.map((part) => (typeof part === 'string' ? part : '$N')).join('');
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!(error instanceof Error))
|
|
71
|
+
throw error;
|
|
72
|
+
throw new CompositionError(`sql.${kind}: ${error instanceof CompositionError ? error.detail : error.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export declare class CompositionError extends Error {
|
|
3
|
+
readonly detail: string;
|
|
4
|
+
readonly name = "TenonCompositionError";
|
|
5
|
+
constructor(detail: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function unwrap(node: ts.Node): ts.Node;
|
|
8
|
+
export declare function initializer(node: ts.Identifier, checker: ts.TypeChecker): ts.Expression | undefined;
|
|
9
|
+
export declare function literalString(node: ts.Node, checker: ts.TypeChecker): string | undefined;
|
|
10
|
+
export declare function isUndefined(node: ts.Node, checker: ts.TypeChecker): boolean;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export class CompositionError extends Error {
|
|
3
|
+
detail;
|
|
4
|
+
name = 'TenonCompositionError';
|
|
5
|
+
constructor(detail) {
|
|
6
|
+
super(`Tenon cannot statically check this composition: ${detail} Use a static query/fragment, or an explicit lint suppression with database tests for every runtime variant.`);
|
|
7
|
+
this.detail = detail;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function unwrap(node) {
|
|
11
|
+
while (ts.isParenthesizedExpression(node) ||
|
|
12
|
+
ts.isAsExpression(node) ||
|
|
13
|
+
ts.isSatisfiesExpression(node) ||
|
|
14
|
+
ts.isNonNullExpression(node) ||
|
|
15
|
+
ts.isTypeAssertionExpression(node))
|
|
16
|
+
node = node.expression;
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
export function initializer(node, checker) {
|
|
20
|
+
let symbol = checker.getSymbolAtLocation(node);
|
|
21
|
+
if (symbol && symbol.flags & ts.SymbolFlags.Alias)
|
|
22
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
23
|
+
const declaration = symbol?.valueDeclaration;
|
|
24
|
+
return declaration &&
|
|
25
|
+
ts.isVariableDeclaration(declaration) &&
|
|
26
|
+
ts.isVariableDeclarationList(declaration.parent) &&
|
|
27
|
+
declaration.parent.flags & ts.NodeFlags.Const
|
|
28
|
+
? declaration.initializer
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
/** Follow immutable const aliases only. Never inspect functions or evaluate expressions. */
|
|
32
|
+
function constant(node, checker) {
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
node = unwrap(node);
|
|
35
|
+
while (ts.isIdentifier(node)) {
|
|
36
|
+
if (seen.has(node))
|
|
37
|
+
throw new CompositionError('Circular const reference.');
|
|
38
|
+
if (seen.size >= 1000)
|
|
39
|
+
throw new CompositionError('Const resolution exceeds 1000 references.');
|
|
40
|
+
seen.add(node);
|
|
41
|
+
const value = initializer(node, checker);
|
|
42
|
+
if (!value)
|
|
43
|
+
break;
|
|
44
|
+
node = unwrap(value);
|
|
45
|
+
}
|
|
46
|
+
return node;
|
|
47
|
+
}
|
|
48
|
+
export function literalString(node, checker) {
|
|
49
|
+
const value = constant(node, checker);
|
|
50
|
+
return ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)
|
|
51
|
+
? value.text
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
export function isUndefined(node, checker) {
|
|
55
|
+
const value = constant(node, checker);
|
|
56
|
+
return (ts.isIdentifier(value) &&
|
|
57
|
+
value.text === 'undefined' &&
|
|
58
|
+
!checker.getSymbolAtLocation(value)?.declarations?.length);
|
|
59
|
+
}
|
package/docs/adoption-guide.md
CHANGED
|
@@ -11,10 +11,10 @@ queries or generate types; keep the existing migration runner if that is the int
|
|
|
11
11
|
1. Locate the old ORM's connection setup, schema, migrations, and call sites. Identify behavior
|
|
12
12
|
supplied by the ORM: validation, IDs, timestamps, hooks, defaults, relation loading,
|
|
13
13
|
serialization, and transaction boundaries.
|
|
14
|
-
2. If
|
|
14
|
+
2. If row and mutation input types will help your application, generate them from the existing database:
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
|
-
npx tenon generate --out src/generated-schema.ts
|
|
17
|
+
npx tenon generate --out src/generated-schema.d.ts
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
Use `--namespace name` for a non-public schema. Generation reads the actual database and requires
|
|
@@ -25,14 +25,107 @@ queries or generate types; keep the existing migration runner if that is the int
|
|
|
25
25
|
Generated types describe pg values: check bigint/numeric strings, dates, JSON, and arrays when
|
|
26
26
|
the old ORM transformed them.
|
|
27
27
|
4. If using optional SQL checking, configure the
|
|
28
|
-
[SafeQL integration](reference.md#sql-checking-with-safeql)
|
|
29
|
-
|
|
28
|
+
[SafeQL integration](reference.md#sql-checking-with-safeql) and follow the
|
|
29
|
+
[incremental audit workflow](#adopt-sql-checking-incrementally) below before accepting generated
|
|
30
|
+
annotations. Run tests against a dedicated test database. Verify
|
|
30
31
|
writes and rollback, relationships, validation, and any hooks you moved into application code or
|
|
31
32
|
SQL. A schema-preserving first release makes application rollback easier.
|
|
32
33
|
5. Remove the old runtime dependency and unused generated code after its callers are gone. Keep the
|
|
33
34
|
migration tooling until the migration transition below is complete, if that transition is part of
|
|
34
35
|
the work.
|
|
35
36
|
|
|
37
|
+
## Replace local write helpers
|
|
38
|
+
|
|
39
|
+
Use `sql.insertValues(rows)` and `sql.assignments(patch)` in place of local object-to-SQL utilities:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { sql } from '@kequtech/tenon-orm';
|
|
43
|
+
|
|
44
|
+
const insert = sql`INSERT INTO profiles ${sql.insertValues(rows)} RETURNING id`;
|
|
45
|
+
const update = sql`UPDATE profiles SET ${sql.assignments(patch)} WHERE id = ${id}`;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Compare omission, defaults, nulls, and empty-input behavior with the old helpers. Keep expressions
|
|
49
|
+
in SQL and remove the local utilities after their callers move. Generated `Rows.Insert` and
|
|
50
|
+
`Rows.Update` types can replace handwritten table inputs; retain application-specific refinements
|
|
51
|
+
and validation. Run the existing write and rollback workflows. See the [mutation cookbook](mutations.md).
|
|
52
|
+
|
|
53
|
+
## Adopt SQL checking incrementally
|
|
54
|
+
|
|
55
|
+
For an existing application, including Next.js, SQL checking can run as a separate audit while the
|
|
56
|
+
normal lint/build commands keep their existing database requirements. Extend the existing flat
|
|
57
|
+
ESLint configuration rather than replacing it. After installing the
|
|
58
|
+
[checking dependencies](reference.md#install-and-configure), a single-database setup can use:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
// eslint.sql.config.mjs
|
|
62
|
+
import { defineConfig } from 'eslint/config';
|
|
63
|
+
import baseConfig from './eslint.config.mjs';
|
|
64
|
+
import safeql from '@ts-safeql/eslint-plugin/config';
|
|
65
|
+
import tseslint from 'typescript-eslint';
|
|
66
|
+
import { safeqlPlugin, eslintPlugin } from '@kequtech/tenon-orm/safeql';
|
|
67
|
+
|
|
68
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
69
|
+
if (!databaseUrl) throw new Error('DATABASE_URL is required for SQL checking');
|
|
70
|
+
|
|
71
|
+
export default defineConfig(baseConfig, {
|
|
72
|
+
files: ['src/**/*.ts', 'src/**/*.tsx'],
|
|
73
|
+
languageOptions: {
|
|
74
|
+
parser: tseslint.parser,
|
|
75
|
+
parserOptions: { projectService: true },
|
|
76
|
+
},
|
|
77
|
+
plugins: { tenon: eslintPlugin },
|
|
78
|
+
rules: { 'tenon/check-composition': 'error' },
|
|
79
|
+
extends: [
|
|
80
|
+
safeql.configs.connections({
|
|
81
|
+
databaseUrl,
|
|
82
|
+
plugins: [safeqlPlugin],
|
|
83
|
+
targets: [{ tag: { regex: '.*' } }],
|
|
84
|
+
}),
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Preserve existing type-aware parser settings where configured. The broad target above lets Tenon
|
|
90
|
+
recognize aliases; use distinct targets for [multiple databases](reference.md#configuration-choices-and-multiple-databases).
|
|
91
|
+
The example does not load environment files. Use the application's existing environment loader
|
|
92
|
+
and URL selection to supply its migrated development/test database through `DATABASE_URL`.
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"scripts": {
|
|
97
|
+
"lint:sql": "eslint --config eslint.sql.config.mjs \"src/**/*.{ts,tsx}\""
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Start with `npm run lint:sql`. Keep both checking rules enabled and distinguish the findings:
|
|
103
|
+
|
|
104
|
+
- SQL errors identify problems in reconstructed SQL; review them before accepting inferred types.
|
|
105
|
+
- Missing/mismatched annotations may reflect an outdated type, a deliberate application refinement,
|
|
106
|
+
or a checker limitation. They are not all demonstrated application bugs.
|
|
107
|
+
- `tenon/check-composition` means SQL analysis was skipped. Add inline selected columns for opaque
|
|
108
|
+
mutation inputs where useful. Keep reusable authorization policy centralized when its fragments
|
|
109
|
+
remain unsupported, with database tests for runtime variants.
|
|
110
|
+
|
|
111
|
+
Preview fixes on a small module first, without writing source. Invoke ESLint directly to select
|
|
112
|
+
one file, since the script above already supplies a source glob:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
npx eslint --config eslint.sql.config.mjs src/database/profiles.ts --fix-dry-run --format json
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Review the proposed annotations, apply selected changes deliberately, and run TypeScript and
|
|
119
|
+
database tests. Broader inferred types may require validation where raw database values enter the
|
|
120
|
+
application. For example, require a finite `Date` before calling `.getTime()`, validate structured
|
|
121
|
+
JSON from `unknown`, and justify CHECK-constrained string refinements with matching schema checks
|
|
122
|
+
or a reviewed column override. Do not globally map timestamps to `Date`: pg can return numeric
|
|
123
|
+
infinities. See [checker mappings](reference.md#supported-sql-and-limitations).
|
|
124
|
+
|
|
125
|
+
Unsupported queries have not had their SQL verified. Keep database tests for their runtime variants
|
|
126
|
+
and use explained suppressions only where appropriate. Once the intended scope passes, make SQL
|
|
127
|
+
checking a required CI step. Fix commands can also apply other enabled ESLint rules.
|
|
128
|
+
|
|
36
129
|
## Move migration ownership to Tenon
|
|
37
130
|
|
|
38
131
|
Choose one final schema checkpoint for the old runner and bring existing target databases to it.
|
package/docs/agent-guide.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Tenon agent guide
|
|
2
2
|
|
|
3
3
|
Tenon executes PostgreSQL SQL through `pg`, with parameter binding, transactions, optional write
|
|
4
|
-
limits, SQL migrations, and generated
|
|
4
|
+
limits, SQL migrations, and generated row and mutation input types. Use ordinary functions for reusable queries.
|
|
5
5
|
This guide contains the setup and contracts needed for routine implementation; the
|
|
6
6
|
[reference](reference.md) covers additional options.
|
|
7
7
|
|
|
@@ -108,12 +108,33 @@ const count = sql<{ count: string }>`SELECT count(*) AS count FROM public."User"
|
|
|
108
108
|
|
|
109
109
|
`pattern` and `limit` are validated application inputs. To compose existing predicates, use
|
|
110
110
|
`sql.join([first, second], 'AND')`. The checker supports literal join arrays, constant fragments,
|
|
111
|
-
|
|
112
|
-
runtime-
|
|
111
|
+
literal/const-string separators, and [official mutation helpers](reference.md#object-based-mutation-fragments).
|
|
112
|
+
It rejects mutable or runtime-dependent SQL structure. Prefer fixed SQL for simple optional filters, e.g.
|
|
113
113
|
`(${email}::text IS NULL OR email = ${email})`. If runtime composition is required, retain an
|
|
114
114
|
explicit result type, suppress `tenon/check-composition` only on that query with a reason, and test
|
|
115
115
|
each branch. Parameter binding alone does not establish result correctness.
|
|
116
116
|
|
|
117
|
+
## Object-based writes
|
|
118
|
+
|
|
119
|
+
Use official `sql.insertValues(rowOrRows, columns?)` and `sql.assignments(patch, columns?)` fragments
|
|
120
|
+
instead of application helpers that enumerate object keys. Keep tables, authorization predicates,
|
|
121
|
+
expressions, conflict behavior and `RETURNING` visible in SQL. Insert missing/undefined fields use
|
|
122
|
+
PostgreSQL defaults; update missing/undefined fields are omitted. Explicit null stays null. Empty
|
|
123
|
+
batches/updates throw; a single default-only insert works. Omit generated columns and batch large
|
|
124
|
+
writes explicitly below the query-wide limit of 65,535 parameters. Expressions and queries are
|
|
125
|
+
rejected as cell values; write arithmetic, `DEFAULT`, and `excluded.column` in the surrounding SQL.
|
|
126
|
+
|
|
127
|
+
Use generated `Rows.Insert['table']` and `Rows.Update['table']` for mutation inputs when available;
|
|
128
|
+
narrow editable fields with `Pick`. They account for defaults, nullability, domains, and generated
|
|
129
|
+
columns. Write mappings differ from returned rows: timestamps use `Date | string`, bigint uses
|
|
130
|
+
`string | bigint`, and JSON uses serialized text. See the [input types](reference.md#insert-and-update-inputs).
|
|
131
|
+
|
|
132
|
+
SafeQL reads inline object/array literals directly. Runtime inputs, spreads and data-builder results
|
|
133
|
+
need explicit inline selected columns, e.g. `sql.insertValues(rows, ['name', 'score'])`. Checking
|
|
134
|
+
uses all selected columns conservatively, without interpreting application JavaScript. It verifies
|
|
135
|
+
SQL and result annotations, not input values, constraints, or nonempty runtime input. See the
|
|
136
|
+
[mutation contract](reference.md#object-based-mutation-fragments) and [cookbook](mutations.md).
|
|
137
|
+
|
|
117
138
|
## Configure and run SQL checking
|
|
118
139
|
|
|
119
140
|
SafeQL checks SQL against the development/test schema and generates or verifies result annotations.
|
|
@@ -124,10 +145,8 @@ Configure it normally and add both exports from `@kequtech/tenon-orm/safeql`:
|
|
|
124
145
|
- `eslintPlugin` goes in ESLint's `plugins`, with `tenon/check-composition` enabled. It reports
|
|
125
146
|
unsupported forms that the adapter skips. It does not wrap SafeQL's rule.
|
|
126
147
|
|
|
127
|
-
These tools
|
|
128
|
-
|
|
129
|
-
and imports SafeQL and typescript-eslint itself. Use a TypeScript version supported by the lint
|
|
130
|
-
toolchain. Generated table types are optional and not required by SafeQL.
|
|
148
|
+
These tools run during development and CI. Reuse the application's TypeScript parser settings and
|
|
149
|
+
use a TypeScript version supported by the lint toolchain. Generated table types are optional.
|
|
131
150
|
|
|
132
151
|
Reuse existing configuration and scripts. For a new setup:
|
|
133
152
|
|
|
@@ -186,6 +205,9 @@ Reuse existing lint scripts or add:
|
|
|
186
205
|
SQL and annotation errors come from `@ts-safeql/check-sql`; unsupported composition errors come from
|
|
187
206
|
`tenon/check-composition`. Keep both rules enabled.
|
|
188
207
|
|
|
208
|
+
For existing applications, use the [incremental audit workflow](adoption-guide.md#adopt-sql-checking-incrementally)
|
|
209
|
+
to preview fixes and review database types versus application refinements.
|
|
210
|
+
|
|
189
211
|
1. Run the development `db:migrate` script below against the intended development/test database.
|
|
190
212
|
2. Run `npm run lint`. Fix SQL mistakes before accepting generated types.
|
|
191
213
|
3. Run `npm run lint:fix` for missing/stale annotations. It can add
|
|
@@ -193,33 +215,14 @@ SQL and annotation errors come from `@ts-safeql/check-sql`; unsupported composit
|
|
|
193
215
|
enabled ESLint rules may also fix files.
|
|
194
216
|
4. Run the application's typecheck and database tests. CI runs `lint` without `--fix`.
|
|
195
217
|
|
|
196
|
-
For multiple databases,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
the wrong type for an unqualified table despite the connection's `search_path`; explicit schema
|
|
205
|
-
names avoid that ambiguity.
|
|
206
|
-
|
|
207
|
-
The checker recognizes Tenon imports, aliases, and typed object properties. It resolves constant
|
|
208
|
-
fragments and literal/const identifier names without executing application code. Imported fragments
|
|
209
|
-
need inspectable source initializers. Use literal newlines and bound string values: outer-template
|
|
210
|
-
JavaScript escapes are rejected because SafeQL reads raw text while Tenon executes decoded text.
|
|
211
|
-
Bound values and constant fragments are decoded consistently.
|
|
212
|
-
|
|
213
|
-
Checking verifies SQL and result annotations, not JavaScript input compatibility, authorization, or
|
|
214
|
-
JSON contents. PostgreSQL owns parameter inference; the checker adds no casts based on JavaScript
|
|
215
|
-
types. `unsafeSql` is unchecked. Never use `any` to hide an unsupported query.
|
|
216
|
-
|
|
217
|
-
Default scalar mappings: bigint/numeric are strings; JSON is `unknown`; temporal values are
|
|
218
|
-
`Date | number` because infinities are numbers. Generated `PgArray<T>` supports recursive dimensions
|
|
219
|
-
and null elements. Numeric arrays contain numbers and can lose precision. Custom pg parsers and
|
|
220
|
-
recursive array projections need matching checker overrides and tests, for example
|
|
221
|
-
`overrides: { columns: { 'User.tags': 'PgArray<string>' } }` on the SafeQL connection. Import
|
|
222
|
-
`PgArray` from the generated definitions when using that annotation.
|
|
218
|
+
For multiple databases, give each connection distinct tag targets or ESLint file scopes. Qualify
|
|
219
|
+
same-named tables with their schemas. See the [connection reference](reference.md#configuration-choices-and-multiple-databases).
|
|
220
|
+
|
|
221
|
+
Checking verifies SQL and result annotations. Input validation, authorization, JSON contents, and
|
|
222
|
+
runtime query variants still need application checks and database tests. Keep shared policy in
|
|
223
|
+
ordinary functions even when its fragments cannot be checked statically. Use literal newlines in
|
|
224
|
+
SQL templates and bind string values. The [reference](reference.md#supported-sql-and-limitations)
|
|
225
|
+
covers supported composition and parser overrides.
|
|
223
226
|
|
|
224
227
|
## Transactions and writes
|
|
225
228
|
|
|
@@ -252,7 +255,7 @@ development script in `package.json`:
|
|
|
252
255
|
```json
|
|
253
256
|
{
|
|
254
257
|
"scripts": {
|
|
255
|
-
"db:migrate": "tenon migrate && tenon generate --out src/generated-schema.ts"
|
|
258
|
+
"db:migrate": "tenon migrate && tenon generate --out src/generated-schema.d.ts"
|
|
256
259
|
}
|
|
257
260
|
}
|
|
258
261
|
```
|
|
@@ -261,7 +264,7 @@ development script in `package.json`:
|
|
|
261
264
|
npx tenon new create_users
|
|
262
265
|
# Fill the generated up/down SQL before migrating.
|
|
263
266
|
npm run db:migrate
|
|
264
|
-
npx tenon generate --out src/generated-schema.ts --check
|
|
267
|
+
npx tenon generate --out src/generated-schema.d.ts --check
|
|
265
268
|
npx tenon status
|
|
266
269
|
npx tenon rollback --steps 1
|
|
267
270
|
```
|
package/docs/human-guide.md
CHANGED
|
@@ -59,12 +59,12 @@ CREATE TABLE public."User" (
|
|
|
59
59
|
DROP TABLE public."User";
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
For development, add a script to `package.json` that applies migrations and updates
|
|
62
|
+
For development, add a script to `package.json` that applies migrations and updates generated types:
|
|
63
63
|
|
|
64
64
|
```json
|
|
65
65
|
{
|
|
66
66
|
"scripts": {
|
|
67
|
-
"db:migrate": "tenon migrate && tenon generate --out src/generated-schema.ts"
|
|
67
|
+
"db:migrate": "tenon migrate && tenon generate --out src/generated-schema.d.ts"
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
```
|
|
@@ -162,6 +162,32 @@ outside a callback transaction. The guard checks a count; you still need the cor
|
|
|
162
162
|
and application authorization. Checking returned rows with `returns` alone does not undo an
|
|
163
163
|
autocommitted write.
|
|
164
164
|
|
|
165
|
+
## Write objects without building column lists by hand
|
|
166
|
+
|
|
167
|
+
Use `sql.insertValues(rowOrRows)` inside an authored `INSERT` and `sql.assignments(patch)` after
|
|
168
|
+
`SET`. Inserts use database defaults for missing/undefined fields; updates omit those fields.
|
|
169
|
+
Explicit `null` stays null. Empty batches and updates throw. Heterogeneous insert rows remain
|
|
170
|
+
aligned, with `DEFAULT` for missing cells.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import type { Rows } from './generated-schema.js';
|
|
174
|
+
|
|
175
|
+
const input: Rows.Insert['User'] = { email: 'another@example.test' };
|
|
176
|
+
await db.query(sql`INSERT INTO public."User" ${sql.insertValues(input, ['email'])}`);
|
|
177
|
+
|
|
178
|
+
const patch: Pick<Rows.Update['User'], 'active'> = { active: false };
|
|
179
|
+
await db.query(sql`UPDATE public."User" SET ${sql.assignments(patch, ['active'])}
|
|
180
|
+
WHERE email = ${input.email}`);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Generated insert types distinguish required fields from defaults and nullable fields. Update types
|
|
184
|
+
make writable fields optional; use `Pick` to limit what an action may edit. See the
|
|
185
|
+
[input-type reference](reference.md#insert-and-update-inputs) for write-value mappings.
|
|
186
|
+
|
|
187
|
+
Keep expressions, authorization, conflict handling and `RETURNING` in SQL. The selected column lists
|
|
188
|
+
above also let the optional SQL checker inspect runtime-built inputs. They select fields rather
|
|
189
|
+
than validate the complete object. See the [mutation cookbook](mutations.md) for batches and upserts.
|
|
190
|
+
|
|
165
191
|
## Check your SQL and result types
|
|
166
192
|
|
|
167
193
|
There are three different jobs here:
|
|
@@ -170,13 +196,9 @@ There are three different jobs here:
|
|
|
170
196
|
- TypeScript checks the application code and the result types you declare.
|
|
171
197
|
- SafeQL checks the SQL against your database and verifies or generates those types.
|
|
172
198
|
|
|
173
|
-
SafeQL is optional development tooling run by ESLint.
|
|
174
|
-
`
|
|
175
|
-
|
|
176
|
-
database analysis and annotation fixes. Your application's ESLint configuration imports these tools;
|
|
177
|
-
Tenon's database runtime does not need them. Generated table types are a separate convenience and
|
|
178
|
-
are not required by SafeQL. Follow the one-time
|
|
179
|
-
[SQL checking setup](reference.md#sql-checking-with-safeql).
|
|
199
|
+
SafeQL is optional development tooling run by ESLint. Tenon's adapter supports queries, constant
|
|
200
|
+
fragments, and mutation helpers; `tenon/check-composition` reports queries it cannot inspect.
|
|
201
|
+
Follow the one-time [SQL checking setup](reference.md#sql-checking-with-safeql).
|
|
180
202
|
|
|
181
203
|
Use the application's existing lint commands. If it has none, the setup shows `lint` and `lint:fix`.
|
|
182
204
|
They run ESLint, including all other enabled rules. Once configured, the normal workflow is:
|
|
@@ -237,9 +259,10 @@ works, but it does not prove that every variant is valid SQL or returns the decl
|
|
|
237
259
|
const filters = [sql.fragment`active = ${true}`];
|
|
238
260
|
if (email !== undefined) filters.push(sql.fragment`email = ${email}`);
|
|
239
261
|
|
|
240
|
-
|
|
262
|
+
/* eslint-disable tenon/check-composition -- Runtime filters are covered by database tests. */
|
|
241
263
|
const query = sql<UserSummary>`SELECT id, email FROM public."User"
|
|
242
264
|
WHERE ${sql.join(filters, 'AND')} ORDER BY id`;
|
|
265
|
+
/* eslint-enable tenon/check-composition */
|
|
243
266
|
```
|
|
244
267
|
|
|
245
268
|
The initial condition keeps this list nonempty. Empty lists throw so that missing filters cannot
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Mutation cookbook
|
|
2
|
+
|
|
3
|
+
Keep SQL structure visible and let the official helpers handle object fields. See the
|
|
4
|
+
[reference](reference.md#object-based-mutation-fragments) for the complete contract. Examples use
|
|
5
|
+
application-owned inputs and clients; add the authorization predicates required by your product.
|
|
6
|
+
|
|
7
|
+
## Defaults, undefined, and null in batches
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
await db.query(
|
|
11
|
+
sql`INSERT INTO profiles ${sql.insertValues([
|
|
12
|
+
{ name: 'Ada', bio: null },
|
|
13
|
+
{ name: 'Lin', score: 4, bio: undefined },
|
|
14
|
+
])} RETURNING id`,
|
|
15
|
+
);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The first row uses the score default. The second uses the bio default. Explicit null in the first
|
|
19
|
+
row stays null. Columns are aligned across rows. Omit generated columns and let PostgreSQL enforce
|
|
20
|
+
required fields and constraints.
|
|
21
|
+
|
|
22
|
+
For runtime-built rows, supply an inline column list for SafeQL:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import type { Rows } from './generated-schema.js';
|
|
26
|
+
|
|
27
|
+
const rows: Rows.Insert['profiles'][] = buildRows();
|
|
28
|
+
await db.query(sql`INSERT INTO profiles ${sql.insertValues(rows, ['name', 'bio', 'score'])}`);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This list selects and orders fields; unlisted fields are ignored. It does not assert that the
|
|
32
|
+
entire input object contains only allowed keys. Keep narrower application input validation.
|
|
33
|
+
|
|
34
|
+
`tenon generate` also provides `Rows.Update['profiles']`. Use `Pick` to limit a patch to fields
|
|
35
|
+
the caller may edit, for example `Pick<Rows.Update['profiles'], 'name' | 'bio'>`. Generated inputs
|
|
36
|
+
understand defaults, nullability, domains, and generated columns. They use write representations:
|
|
37
|
+
timestamps accept `Date | string`, bigint accepts `string | bigint`, and JSON uses serialized text.
|
|
38
|
+
See the [input-type reference](reference.md#insert-and-update-inputs) for the complete mapping.
|
|
39
|
+
|
|
40
|
+
## Input types, SQL checking, and runtime validation
|
|
41
|
+
|
|
42
|
+
Apply input types with an annotation, `satisfies`, or a helper generic argument. Generating the
|
|
43
|
+
file alone does not check every write. Narrow the fields each operation accepts:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { sql, type QueryClient } from '@kequtech/tenon-orm';
|
|
47
|
+
import type { Rows } from './generated-schema.js';
|
|
48
|
+
|
|
49
|
+
type ProfileInput = Pick<Rows.Insert['profiles'], 'name' | 'score'>;
|
|
50
|
+
|
|
51
|
+
function createProfile(client: QueryClient, input: ProfileInput) {
|
|
52
|
+
return client.query(sql<{ id: number }>`INSERT INTO profiles
|
|
53
|
+
${sql.insertValues(input, ['name', 'score'])} RETURNING id`);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Here `id` and `score` are assumed to be integers. TypeScript rejects an incompatible typed input
|
|
58
|
+
such as `{ name: 'Ada', score: true }`. SafeQL checks selected columns and `RETURNING`; it does not
|
|
59
|
+
inspect runtime values. Validate external inputs before calling this function and keep database
|
|
60
|
+
constraints and application authorization in place.
|
|
61
|
+
|
|
62
|
+
**Selected columns can discard required fields.** For a table requiring integer `id` and text
|
|
63
|
+
`name` and `location`, this input is valid but the query fails:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const row: Rows.Insert['profiles'] = { id: 1, name: 'Ada', location: 'London' };
|
|
67
|
+
const query = sql`INSERT INTO profiles ${sql.insertValues(row, ['id', 'name'])}`;
|
|
68
|
+
// PostgreSQL rejects the insert: location was dropped and has no default.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A column list selects fields; it does not prove that all required fields will be written.
|
|
72
|
+
|
|
73
|
+
## Patches mixed with SQL expressions
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
await db.query(sql`UPDATE profiles
|
|
77
|
+
SET ${sql.assignments({ name, bio })}, revision = revision + 1, score = DEFAULT
|
|
78
|
+
WHERE id = ${id}`);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Undefined fields are omitted; null clears a field. Expressions, `DEFAULT`, and references to
|
|
82
|
+
`excluded.column` belong in SQL, not in mutation objects.
|
|
83
|
+
|
|
84
|
+
An empty patch throws even if the surrounding statement has other assignments. Choose an
|
|
85
|
+
expression-only query explicitly when that is the intended behavior:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const patch: { name?: string; bio?: string | null } = readPatch();
|
|
89
|
+
if (patch.name !== undefined || patch.bio !== undefined) {
|
|
90
|
+
await db.query(sql`UPDATE profiles
|
|
91
|
+
SET ${sql.assignments(patch, ['name', 'bio'])}, revision = revision + 1
|
|
92
|
+
WHERE id = ${id}`);
|
|
93
|
+
} else {
|
|
94
|
+
await db.query(sql`UPDATE profiles SET revision = revision + 1 WHERE id = ${id}`);
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Preserve an omitted field during an upsert
|
|
99
|
+
|
|
100
|
+
Use complete query variants when an option changes conflict behavior. Both templates can be
|
|
101
|
+
checked independently; the checker need not choose a JavaScript branch:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
if (email !== undefined) {
|
|
105
|
+
await db.query(sql`INSERT INTO preferences ${sql.insertValues({ profile_id, in_app, email })}
|
|
106
|
+
ON CONFLICT (profile_id) DO UPDATE
|
|
107
|
+
SET in_app = excluded.in_app, email = excluded.email`);
|
|
108
|
+
} else {
|
|
109
|
+
await db.query(sql`INSERT INTO preferences ${sql.insertValues({ profile_id, in_app })}
|
|
110
|
+
ON CONFLICT (profile_id) DO UPDATE SET in_app = excluded.in_app`);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The second query leaves an existing email value unchanged. Missing an insert field uses its
|
|
115
|
+
database default; that alone does not decide what an upsert should overwrite.
|
|
116
|
+
|
|
117
|
+
## JSONB arrays and PostgreSQL arrays
|
|
118
|
+
|
|
119
|
+
Tenon preserves pg's value conversion. A JavaScript array binds as a PostgreSQL array. To write an
|
|
120
|
+
array into a JSON/JSONB column, serialize it explicitly:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
const handles = ['ada', 'lin'];
|
|
124
|
+
await db.query(
|
|
125
|
+
sql`INSERT INTO deliveries ${sql.insertValues({
|
|
126
|
+
recipients_json: JSON.stringify(handles), // jsonb column
|
|
127
|
+
recipient_handles: handles, // text[] column
|
|
128
|
+
})}`,
|
|
129
|
+
);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Objects used as JSON values retain pg's ordinary JSON conversion. The helper does not infer JSON
|
|
133
|
+
versus array encoding from the table schema. Generated JSON input types use serialized text for
|
|
134
|
+
consistent handling of objects and arrays. Validate JSON contents in the application.
|
|
135
|
+
|
|
136
|
+
## Batches in one transaction
|
|
137
|
+
|
|
138
|
+
Choose the batch size in the caller and keep all writes on the transaction client:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
await db.transaction(async (tx) => {
|
|
142
|
+
for (let offset = 0; offset < rows.length; offset += 300) {
|
|
143
|
+
const batch = rows.slice(offset, offset + 300);
|
|
144
|
+
await tx.query(sql`INSERT INTO profiles ${sql.insertValues(batch, ['name', 'bio', 'score'])}`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
A failure rolls back all batches. The helper rejects an empty batch; this loop makes no insert
|
|
150
|
+
calls for empty input. The query-wide limit is 65,535 bound parameters, including parameters outside
|
|
151
|
+
the mutation fragment. An oversized query reports its actual count, for example
|
|
152
|
+
`at most 65535 bound parameters; received 65536`. Defaults consume no parameters. Account for the
|
|
153
|
+
selected columns and surrounding query when choosing a batch size; Tenon never splits queries automatically.
|
|
154
|
+
|
|
155
|
+
Reusable authorization fragments remain valid at runtime. Keep shared policy in application
|
|
156
|
+
functions and test it rather than duplicating it for static coverage. Function-generated SQL,
|
|
157
|
+
conditional fragments, and dynamic filter lists remain outside static checking.
|