@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.
@@ -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
+ }
@@ -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 table row types will help your application, generate them from the existing database:
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) to generate query annotations and
29
- check them against the intended schema. Run tests against a dedicated test database. Verify
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.
@@ -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 table row types. Use ordinary functions for reusable queries.
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
- and literal/const-string separators. It rejects mutable, conditional, function-generated, or
112
- runtime-built composition. Prefer fixed SQL for simple optional filters, e.g.
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 are optional development tooling; the database runtime does not import them. SafeQL,
128
- ESLint, and TypeScript are optional peers. The application owns the TypeScript parser configuration
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, replace the connection object with an array. Each connection has its own
197
- URL, `plugins: [safeqlPlugin]`, and distinct `targets`, for example `{ tag: 'usersSql' }` and
198
- `{ tag: 'billingSql' }`. Import `sql as usersSql` and `sql as billingSql` to select them, even
199
- within one file. Alternatively, scope separate SafeQL configurations with non-overlapping ESLint
200
- `files` patterns and keep the `sql` tag. Runtime connections remain independent: execute each query
201
- with the right database instance. The checker does not trace `db.query` destinations.
202
-
203
- Qualify tables with their schema when names repeat across schemas in a database. SafeQL can infer
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
  ```
@@ -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 table row types:
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. Configure it normally, with Tenon's
174
- `safeqlPlugin` adapter to translate queries and constant fragments. A small companion ESLint rule,
175
- `tenon/check-composition`, flags queries that cannot be checked statically. SafeQL performs the
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
- // eslint-disable-next-line tenon/check-composition -- Runtime filters are covered by database tests.
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.