@kequtech/tenon-orm 0.3.1 → 0.4.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/CHANGELOG.md +66 -0
- package/README.md +81 -26
- package/dist/affected-rows.js +0 -1
- package/dist/baseline.js +3 -5
- package/dist/check/contracts.d.ts +6 -0
- package/dist/check/contracts.js +206 -0
- package/dist/check/project.d.ts +18 -0
- package/dist/check/project.js +224 -0
- package/dist/check/source.d.ts +51 -0
- package/dist/check/source.js +629 -0
- package/dist/check/types.d.ts +36 -0
- package/dist/check/types.js +1 -0
- package/dist/check.d.ts +4 -0
- package/dist/check.js +147 -0
- package/dist/cli.js +40 -27
- package/dist/connection.d.ts +5 -12
- package/dist/connection.js +12 -121
- package/dist/database.js +10 -8
- package/dist/describe.js +10 -3
- package/dist/generate.d.ts +13 -3
- package/dist/generate.js +52 -25
- package/dist/generation/catalog.d.ts +5 -6
- package/dist/generation/catalog.js +7 -9
- package/dist/generation/output.js +2 -4
- package/dist/generation/render.d.ts +3 -0
- package/dist/generation/render.js +60 -0
- package/dist/generation/schema-summary.d.ts +3 -3
- package/dist/generation/schema-summary.js +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/inspection/catalog.d.ts +3 -6
- package/dist/inspection/catalog.js +9 -18
- package/dist/inspection/cli.d.ts +12 -0
- package/dist/inspection/cli.js +9 -4
- package/dist/inspection/connection.d.ts +2 -3
- package/dist/inspection/connection.js +20 -22
- package/dist/inspection/options.js +0 -6
- package/dist/inspection/result.d.ts +3 -2
- package/dist/inspection/result.js +13 -26
- package/dist/inspection/types.d.ts +6 -4
- package/dist/migrations/connection.d.ts +3 -3
- package/dist/migrations/connection.js +14 -21
- package/dist/migrations/history.d.ts +4 -4
- package/dist/migrations/history.js +5 -5
- package/dist/migrations.js +7 -9
- package/dist/query-options.d.ts +1 -1
- package/dist/query.js +1 -1
- package/dist/transaction.d.ts +2 -2
- package/dist/transaction.js +21 -77
- package/dist/types.d.ts +4 -1
- package/docs/adoption-guide.md +88 -111
- package/docs/agent-guide.md +94 -147
- package/docs/connections.md +185 -0
- package/docs/human-guide.md +133 -146
- package/docs/migrations.md +133 -0
- package/docs/mutations.md +110 -60
- package/docs/query-checking.md +199 -0
- package/docs/query-inspection.md +89 -58
- package/docs/reference.md +232 -437
- package/docs/schema-types.md +263 -0
- package/docs/sql.md +212 -0
- package/package.json +11 -7
- package/dist/driver/types.d.ts +0 -10
- package/dist/driver/types.js +0 -49
- package/dist/generation/table-types.d.ts +0 -2
- package/dist/generation/table-types.js +0 -96
- package/dist/inspection/protocol.d.ts +0 -7
- package/dist/inspection/protocol.js +0 -45
package/docs/mutations.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Writing data
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Write `INSERT`, `UPDATE`, `DELETE`, and `ON CONFLICT` in SQL. Use `sql.insertValues` and
|
|
4
|
+
`sql.assignments` when you have an object of field values; they return fragments for the column/value
|
|
5
|
+
parts of your statement. The [API reference](reference.md#object-based-mutation-fragments) defines
|
|
6
|
+
edge cases and validation rules.
|
|
7
|
+
|
|
8
|
+
Examples use an existing `db`, import `sql` from Tenon, and assume this table:
|
|
6
9
|
|
|
7
10
|
```sql
|
|
8
|
-
CREATE TABLE profiles (
|
|
11
|
+
CREATE TABLE public.profiles (
|
|
9
12
|
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
10
13
|
name text NOT NULL UNIQUE,
|
|
11
14
|
bio text DEFAULT 'unwritten',
|
|
@@ -14,121 +17,168 @@ CREATE TABLE profiles (
|
|
|
14
17
|
);
|
|
15
18
|
```
|
|
16
19
|
|
|
17
|
-
##
|
|
20
|
+
## Insert and return a row
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { sql } from '@kequtech/tenon-orm';
|
|
24
|
+
|
|
25
|
+
const profile = await db.query(
|
|
26
|
+
sql<{ id: number; name: string }>`INSERT INTO public.profiles
|
|
27
|
+
${sql.insertValues({ name: 'Ada' })} RETURNING id, name`,
|
|
28
|
+
{ returns: 'one' },
|
|
29
|
+
);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The helper emits `("name") VALUES ($1)` and binds `'Ada'`. PostgreSQL fills defaults. You choose
|
|
33
|
+
`RETURNING` and the result mode explicitly.
|
|
34
|
+
|
|
35
|
+
## Defaults, undefined, and null
|
|
18
36
|
|
|
19
37
|
```ts
|
|
20
38
|
await db.query(
|
|
21
|
-
sql`INSERT INTO profiles ${sql.insertValues([
|
|
22
|
-
{ name: '
|
|
23
|
-
{ name: '
|
|
24
|
-
])}
|
|
25
|
-
{ returns: 'many' },
|
|
39
|
+
sql`INSERT INTO public.profiles ${sql.insertValues([
|
|
40
|
+
{ name: 'Lin', bio: null },
|
|
41
|
+
{ name: 'Jo', score: 4, bio: undefined },
|
|
42
|
+
])}`,
|
|
26
43
|
);
|
|
27
44
|
```
|
|
28
45
|
|
|
29
|
-
|
|
30
|
-
aligned
|
|
31
|
-
|
|
46
|
+
Lin gets score 0 and SQL null for bio. Jo gets score 4 and the bio default. Heterogeneous rows stay
|
|
47
|
+
aligned. Missing/undefined insert cells use `DEFAULT`; explicit null remains SQL NULL.
|
|
48
|
+
|
|
49
|
+
A single default-only object emits `DEFAULT VALUES`, which still fails if the table has an unmet
|
|
50
|
+
required column. Empty batches fail. Multiple default-only rows need an explicit column list.
|
|
51
|
+
|
|
52
|
+
## Type and select accepted fields
|
|
32
53
|
|
|
33
|
-
|
|
54
|
+
Generate `Database` from the same schema, then narrow the fields the application accepts:
|
|
34
55
|
|
|
35
56
|
```ts
|
|
36
57
|
import { sql, type QueryClient } from '@kequtech/tenon-orm';
|
|
37
|
-
import type {
|
|
58
|
+
import type { Database } from './generated-schema.js';
|
|
38
59
|
|
|
39
|
-
type ProfileInput = Pick<
|
|
60
|
+
type ProfileInput = Pick<Database['profiles']['insert'], 'name' | 'score'>;
|
|
40
61
|
|
|
41
62
|
function createProfile(client: QueryClient, input: ProfileInput) {
|
|
42
63
|
return client.query(
|
|
43
|
-
sql<{ id: number }>`INSERT INTO profiles
|
|
44
|
-
|
|
64
|
+
sql<{ id: number }>`INSERT INTO public.profiles
|
|
65
|
+
${sql.insertValues(input, ['name', 'score'])} RETURNING id`,
|
|
45
66
|
{ returns: 'one' },
|
|
46
67
|
);
|
|
47
68
|
}
|
|
48
69
|
```
|
|
49
70
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
71
|
+
The explicit column list selects and orders fields and ignores unlisted keys. Keep required insert
|
|
72
|
+
fields in that list: a well-typed object can still produce an invalid insert if selection discards
|
|
73
|
+
its required `name`. Helpers have no schema metadata; PostgreSQL enforces constraints.
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
Types apply through annotations, `satisfies`, or helper generic arguments. They do not validate
|
|
76
|
+
external data, authorization, JSON contents, or CHECK constraints. See
|
|
77
|
+
[generated inputs](schema-types.md#insert-and-update-inputs).
|
|
56
78
|
|
|
57
|
-
|
|
58
|
-
const input: Rows.Insert['profiles'] = { name: 'Ada' };
|
|
59
|
-
const query = sql`INSERT INTO profiles ${sql.insertValues(input, ['bio', 'score'])}`;
|
|
60
|
-
// Executing this fails: name was discarded and has no default.
|
|
61
|
-
```
|
|
79
|
+
## Patch fields and use expressions
|
|
62
80
|
|
|
63
|
-
|
|
81
|
+
For a validated `id` and patch:
|
|
64
82
|
|
|
65
83
|
```ts
|
|
66
|
-
const patch: Pick<
|
|
67
|
-
await db.query(
|
|
68
|
-
|
|
84
|
+
const patch: Pick<Database['profiles']['update'], 'name' | 'bio'> = { bio: null };
|
|
85
|
+
await db.query(
|
|
86
|
+
sql`UPDATE public.profiles SET ${sql.assignments(patch)}, revision = revision + 1
|
|
87
|
+
WHERE id = ${id}`,
|
|
88
|
+
{ affectedRows: { exact: 1 } },
|
|
89
|
+
);
|
|
69
90
|
```
|
|
70
91
|
|
|
71
|
-
|
|
72
|
-
`column = DEFAULT
|
|
92
|
+
Assignments omit missing/undefined fields. Null clears a nullable field. Put arithmetic,
|
|
93
|
+
`column = DEFAULT`, and other SQL expressions in the surrounding statement; helper cells accept
|
|
94
|
+
bound values, not SQL fragments or expression objects.
|
|
73
95
|
|
|
74
|
-
|
|
75
|
-
expression-only update is intended:
|
|
96
|
+
`sql.assignments` throws if no defined fields remain, even if the surrounding query has another
|
|
97
|
+
assignment. Skip an empty patch, or branch when an expression-only update is intended:
|
|
76
98
|
|
|
77
99
|
```ts
|
|
78
100
|
if (patch.name !== undefined || patch.bio !== undefined) {
|
|
79
|
-
await db.query(
|
|
80
|
-
|
|
101
|
+
await db.query(
|
|
102
|
+
sql`UPDATE public.profiles SET ${sql.assignments(patch)}, revision = revision + 1
|
|
103
|
+
WHERE id = ${id}`,
|
|
104
|
+
{ affectedRows: { exact: 1 } },
|
|
105
|
+
);
|
|
81
106
|
} else {
|
|
82
|
-
await db.query(sql`UPDATE profiles SET revision = revision + 1 WHERE id = ${id}
|
|
107
|
+
await db.query(sql`UPDATE public.profiles SET revision = revision + 1 WHERE id = ${id}`, {
|
|
108
|
+
affectedRows: { exact: 1 },
|
|
109
|
+
});
|
|
83
110
|
}
|
|
84
111
|
```
|
|
85
112
|
|
|
86
|
-
##
|
|
113
|
+
## Upsert without overwriting an omitted field
|
|
87
114
|
|
|
88
|
-
|
|
115
|
+
For validated `name`, `score`, and optional `bio`, choose conflict behavior explicitly:
|
|
89
116
|
|
|
90
117
|
```ts
|
|
91
118
|
if (bio !== undefined) {
|
|
92
|
-
await db.query(sql`INSERT INTO profiles ${sql.insertValues({ name, score, bio })}
|
|
119
|
+
await db.query(sql`INSERT INTO public.profiles ${sql.insertValues({ name, score, bio })}
|
|
93
120
|
ON CONFLICT (name) DO UPDATE SET score = excluded.score, bio = excluded.bio`);
|
|
94
121
|
} else {
|
|
95
|
-
await db.query(sql`INSERT INTO profiles ${sql.insertValues({ name, score })}
|
|
122
|
+
await db.query(sql`INSERT INTO public.profiles ${sql.insertValues({ name, score })}
|
|
96
123
|
ON CONFLICT (name) DO UPDATE SET score = excluded.score`);
|
|
97
124
|
}
|
|
98
125
|
```
|
|
99
126
|
|
|
100
|
-
The second
|
|
101
|
-
|
|
127
|
+
The second branch preserves an existing bio. Omitting an insert field uses its database default;
|
|
128
|
+
it does not decide whether an upsert overwrites that field.
|
|
129
|
+
|
|
130
|
+
## Delete with a bound
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
await db.query(sql`DELETE FROM public.profiles WHERE id = ${id}`, {
|
|
134
|
+
affectedRows: { max: 1 },
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Zero or one deletion is allowed; more causes rollback. Use `exact: 1` when absence is also an error.
|
|
139
|
+
Guards work without `RETURNING`. See [transactions and guards](reference.md#transactions-guarded-writes-and-errors).
|
|
102
140
|
|
|
103
141
|
## JSON and PostgreSQL arrays
|
|
104
142
|
|
|
105
|
-
For
|
|
106
|
-
array:
|
|
143
|
+
For an additional table with `payload jsonb` and `tags text[]`:
|
|
107
144
|
|
|
108
145
|
```ts
|
|
146
|
+
import { builtin, json, param } from '@kequtech/tenon-orm';
|
|
147
|
+
|
|
109
148
|
const tags = ['ada', 'lin'];
|
|
110
|
-
await db.query(
|
|
111
|
-
|
|
112
|
-
);
|
|
149
|
+
await db.query(sql`INSERT INTO public.deliveries
|
|
150
|
+
${sql.insertValues({ payload: json(tags), tags: param(tags, builtin.textArray) })}`);
|
|
113
151
|
```
|
|
114
152
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
153
|
+
`json(value)` encodes a JSON document; `param(value, codec)` chooses a PostgreSQL type and encoder.
|
|
154
|
+
A JSON array and a PostgreSQL array are separate formats. Generated writes accept encoded strings
|
|
155
|
+
or explicit parameters; plain objects/arrays reject. `json(null)` means JSON null, while plain
|
|
156
|
+
`null` means SQL NULL. `json('text')` encodes a JSON string, not an already-encoded document.
|
|
157
|
+
JSON encoding rejects bigint, including nested bigint values.
|
|
158
|
+
|
|
159
|
+
For int8, use exact strings, bigint, or `param('9007199254740993', builtin.int8)`. For an int8 array,
|
|
160
|
+
use `param(['9007199254740993', null], builtin.int8Array)`. Reads remain strings by default. Plain
|
|
161
|
+
Date and supported Temporal values work as scalar inputs; Date encodes in UTC. Neither input form
|
|
162
|
+
changes the output decoder. See [value mappings](schema-types.md#value-mappings).
|
|
118
163
|
|
|
119
|
-
##
|
|
164
|
+
## Batch within one transaction
|
|
120
165
|
|
|
121
|
-
For
|
|
166
|
+
For `rows` containing generated profile insert inputs:
|
|
122
167
|
|
|
123
168
|
```ts
|
|
124
169
|
await db.transaction(async (tx) => {
|
|
125
170
|
for (let offset = 0; offset < rows.length; offset += 300) {
|
|
126
171
|
const batch = rows.slice(offset, offset + 300);
|
|
127
|
-
await tx.query(sql`INSERT INTO profiles
|
|
172
|
+
await tx.query(sql`INSERT INTO public.profiles
|
|
173
|
+
${sql.insertValues(batch, ['name', 'bio', 'score'])}`);
|
|
128
174
|
}
|
|
129
175
|
});
|
|
130
176
|
```
|
|
131
177
|
|
|
132
|
-
A failure rolls back
|
|
133
|
-
query-wide 65,535-parameter limit,
|
|
134
|
-
Tenon does not split batches or choose transaction boundaries
|
|
178
|
+
A failure rolls back every batch; empty input makes no calls. Choose a batch size below the
|
|
179
|
+
query-wide 65,535-parameter limit, counting surrounding interpolations too. Default cells consume
|
|
180
|
+
no parameters. Tenon does not split batches or choose transaction boundaries.
|
|
181
|
+
|
|
182
|
+
These helpers support optional fields at runtime. The optional source checker needs explicit shape
|
|
183
|
+
types with required selected fields and fixed batch lengths; ordinary generated patch types can
|
|
184
|
+
remain unchecked. See [object expansion checking](query-checking.md#object-expansion).
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Checking query types
|
|
2
|
+
|
|
3
|
+
`tenon check` compares Tenon SQL in TypeScript source with declared input/result types and PostgreSQL
|
|
4
|
+
metadata. It can catch a missing result property, an int8 result declared as number, or an unsupported
|
|
5
|
+
bound input. It is optional; normal execution still trusts `sql<Row>` annotations.
|
|
6
|
+
|
|
7
|
+
The checker reads source without importing the application, evaluating input expressions, binding
|
|
8
|
+
values, or executing application SQL. PostgreSQL still parses/analyzes each recovered statement,
|
|
9
|
+
which can take locks. Use a representative migrated development/test database.
|
|
10
|
+
|
|
11
|
+
## Run a check
|
|
12
|
+
|
|
13
|
+
Set `DATABASE_URL`, then select a project, file, or one query:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npx tenon check
|
|
17
|
+
npx tenon check src/users.ts
|
|
18
|
+
npx tenon check src/users.ts:23
|
|
19
|
+
npx tenon check src/users.ts:23 --project tsconfig.json --json
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A one-based line selects the complete query containing it, including multiline templates and their
|
|
23
|
+
variable declaration line. A line with no query or multiple queries fails. A fragment alone is not
|
|
24
|
+
a complete query target.
|
|
25
|
+
|
|
26
|
+
The checker discovers `tsconfig.json` from the target's directory or current working directory.
|
|
27
|
+
Use `--project` for another config or project directory; targets must belong to that project.
|
|
28
|
+
It uses the bundled TypeScript 6 compiler and the project's imports, aliases, indexed access,
|
|
29
|
+
`Pick`, unions, and path mappings. `strictNullChecks` is required. Invalid configuration and compiler
|
|
30
|
+
errors in selected files fail the report.
|
|
31
|
+
|
|
32
|
+
Match runtime role, search path, timezone, and codecs. Session/timeout flags are the same as
|
|
33
|
+
[Describe](query-inspection.md#cli). CLI checks use default codecs; use the programmatic API for
|
|
34
|
+
a custom registry.
|
|
35
|
+
|
|
36
|
+
## What gets checked
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { sql } from '@kequtech/tenon-orm';
|
|
40
|
+
import type { Database } from './generated-schema.js';
|
|
41
|
+
|
|
42
|
+
type Summary = Pick<Database['users']['row'], 'id' | 'email'>;
|
|
43
|
+
export function userQuery(id: number) {
|
|
44
|
+
return sql<Summary>`SELECT id, email FROM public.users WHERE id = ${id}`;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Recognition follows Tenon's imported symbols through aliases, namespaces, barrel re-exports, and
|
|
49
|
+
const aliases. Unrelated functions named `sql` are ignored. Fixed-text `unsafeSql` calls with fixed
|
|
50
|
+
values lists are also supported. Each recoverable SQL variant goes through PostgreSQL Describe;
|
|
51
|
+
parameter OIDs and result columns are compared with the declared contracts.
|
|
52
|
+
|
|
53
|
+
| Finding | Meaning | Fails the command? |
|
|
54
|
+
| ----------- | --------------------------------------------------------------------------- | ------------------ |
|
|
55
|
+
| `mismatch` | A definite contradiction, invalid SQL, or invalid input type | Yes |
|
|
56
|
+
| `unchecked` | SQL cannot be recovered, a limit is exceeded, or inspection cannot complete | Yes |
|
|
57
|
+
| `unproven` | Metadata cannot prove or disprove the declaration | No |
|
|
58
|
+
|
|
59
|
+
Missing required result properties fail; extra selected columns are allowed by structural types.
|
|
60
|
+
Each variant must fit at least one member of a declared result union. Nullability, JSON members,
|
|
61
|
+
literal/branded refinements, and unfamiliar decoder types may remain unproven. A broader metadata
|
|
62
|
+
type alone does not reject a narrower application declaration.
|
|
63
|
+
|
|
64
|
+
Input and output contracts are separate. For example, an int8 input accepts exact text, bigint, or
|
|
65
|
+
an explicit encoder; its default output is a string. Strings, numbers, bigint, Date, and supported
|
|
66
|
+
Temporal inputs use OID 0/server inference. Booleans, bytes, and recoverable `json`/`param` calls
|
|
67
|
+
supply the same OID hints as execution. Unions with different hints create variants. Input types do
|
|
68
|
+
not prove value ranges, valid Dates, or textual formats.
|
|
69
|
+
|
|
70
|
+
A plain object, array, or undefined input contradicts the driver's bind API. An opaque `Parameter`
|
|
71
|
+
is trusted, with its unknown OID reported as unproven. Describe uses server inference in that case;
|
|
72
|
+
dependent result comparisons and inference errors remain unproven. This does not validate the encoder.
|
|
73
|
+
|
|
74
|
+
## Responding to findings
|
|
75
|
+
|
|
76
|
+
- **Mismatch:** correct the SQL or declared contract, then recheck. For int8 output, use string under
|
|
77
|
+
default codecs; for arrays/objects as inputs, choose the documented encoder.
|
|
78
|
+
- **Unchecked:** read the reason. An explicit shape or fixed composition may make it recoverable.
|
|
79
|
+
Runtime-dependent SQL needs database tests. The command fails while unchecked sites remain in
|
|
80
|
+
the selected scope; narrow the target to check supported sites separately and retain visibility
|
|
81
|
+
of the remaining coverage.
|
|
82
|
+
- **Unproven:** retain the declaration when justified, with validation/tests for what metadata cannot
|
|
83
|
+
establish. A note alone does not fail the command.
|
|
84
|
+
|
|
85
|
+
For example, generated update fields are optional and can disappear from SQL. If application
|
|
86
|
+
validation always supplies a boolean, declare that required contract:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
type ActivePatch = { active: boolean };
|
|
90
|
+
|
|
91
|
+
function setActive(id: number, patch: ActivePatch) {
|
|
92
|
+
return sql`UPDATE public.users SET ${sql.assignments<ActivePatch>(patch, ['active'])}
|
|
93
|
+
WHERE id = ${id}`;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
If omission is intended, keep optional fields and test the SQL/effects. An explicit column list does
|
|
98
|
+
not make optional fields required. Avoid assertions that merely hide this distinction.
|
|
99
|
+
|
|
100
|
+
Exit 0 means no mismatch or unchecked site in the selected scope. It does not establish JSON shape,
|
|
101
|
+
nullability, cardinality, or every runtime branch. No query sites also fails. Independent queries
|
|
102
|
+
continue after an unsupported site, and a recoverable branch can expose a mismatch even when another
|
|
103
|
+
branch is unchecked. Diagnostics include locations, codes, parameter/column identifiers, expected
|
|
104
|
+
and observed types, and variant numbers. `--json` returns the structured report.
|
|
105
|
+
|
|
106
|
+
## Supported composition
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const direction = descending ? sql.fragment`DESC` : sql.fragment`ASC`;
|
|
110
|
+
const predicates = [sql.fragment`active = ${true}`, sql.fragment`email <> ${''}`] as const;
|
|
111
|
+
const query = sql<Summary>`SELECT id, email FROM public.users
|
|
112
|
+
WHERE ${sql.join(predicates, 'AND')} ORDER BY email ${direction}`;
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The checker follows inline fragments and const bindings with recoverable initializers, fixed
|
|
116
|
+
identifiers, fixed join lists/separators, and ternaries with recoverable branches. Both branches are
|
|
117
|
+
checked without evaluating the condition.
|
|
118
|
+
|
|
119
|
+
Reusing a conditional fragment preserves its branch choice. Separate ternaries using the same const
|
|
120
|
+
condition or an unwritten function parameter also share that choice, including simple negation.
|
|
121
|
+
Repeated mutable guards with unresolved correlation are unchecked. General control-flow reachability
|
|
122
|
+
is not inferred.
|
|
123
|
+
|
|
124
|
+
Limits are 16 complete variants per query, including parameter OID alternatives, and 32 levels of
|
|
125
|
+
composition references. Arbitrary fragment factories, function-body reconstruction, loops, dynamic
|
|
126
|
+
identifiers, mutable/escaping fragment lists, and runtime joins are unsupported. These can be valid
|
|
127
|
+
runtime SQL, but need database tests. Unknown source is never replaced with invented SQL.
|
|
128
|
+
|
|
129
|
+
## Object expansion
|
|
130
|
+
|
|
131
|
+
For checking, `insertValues` and `assignments` need an explicit finite shape type:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
type NewUser = { email: string; active: boolean };
|
|
135
|
+
|
|
136
|
+
function createUser(input: NewUser) {
|
|
137
|
+
return sql<{ id: number }>`INSERT INTO public.users
|
|
138
|
+
${sql.insertValues<NewUser>(input, ['email', 'active'])} RETURNING id`;
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The shape is a trusted expansion contract; the checker does not read the object. Prefer an explicit
|
|
143
|
+
column list so runtime and checking select the same keys/order. Without one, the declared shape is
|
|
144
|
+
assumed to describe exactly the expanded fields. Structural typing cannot prove the absence of
|
|
145
|
+
extra own keys or their order, so that assumption is unproven.
|
|
146
|
+
|
|
147
|
+
Selected fields must be required and exclude undefined. Optional presence changes the SQL through
|
|
148
|
+
omission or DEFAULT and is unchecked. Unselected optional properties are fine. Index signatures,
|
|
149
|
+
unresolved generic key sets, shape unions, and unknown shapes are unsupported. Fixed literal batches
|
|
150
|
+
are supported; unknown batch lengths are not. Runtime helpers still accept inferred shapes and
|
|
151
|
+
optional fields.
|
|
152
|
+
|
|
153
|
+
## Programmatic use and custom codecs
|
|
154
|
+
|
|
155
|
+
The checking API has a separate entry point, so ordinary queries do not load TypeScript:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { checkQueries } from '@kequtech/tenon-orm/check';
|
|
159
|
+
|
|
160
|
+
const report = await checkQueries({
|
|
161
|
+
connection,
|
|
162
|
+
project: 'tsconfig.json',
|
|
163
|
+
target: 'src/users.ts',
|
|
164
|
+
});
|
|
165
|
+
if (!report.ok) process.exitCode = 1;
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The report has `ok`, `project`, `queries`, `diagnostics`, and available inspection `context`. Options
|
|
169
|
+
also accept `session`, `timeouts`, `codecs`, and `readTypes`. `connection` is a URI.
|
|
170
|
+
|
|
171
|
+
For a custom codec, pass the same registry as runtime queries. A text codec may declare its output:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { defaultCodecs } from '@kequtech/tenon-orm';
|
|
175
|
+
|
|
176
|
+
const codecs = defaultCodecs.with({
|
|
177
|
+
oid: 23,
|
|
178
|
+
text: {
|
|
179
|
+
encode: (value: { raw: string }) => value.raw,
|
|
180
|
+
decode: (raw) => ({ raw }),
|
|
181
|
+
typescript: 'import("./src/types.js").DecodedInteger',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const report = await checkQueries({ connection, project: 'tsconfig.json', codecs });
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Here `src/types.ts` must export `DecodedInteger = { raw: string }`. Registry metadata describes
|
|
188
|
+
non-NULL output, including derived arrays. Type expressions compile in the application's project
|
|
189
|
+
without importing/executing its modules; referenced dependencies/types must be available.
|
|
190
|
+
|
|
191
|
+
Use `readTypes: { 23: 'import("./src/types.js").DecodedInteger' }` only for a decoder with no known
|
|
192
|
+
registry representation. It cannot replace known metadata or claim JSON members for default JSON
|
|
193
|
+
decoding. Expressions resolve from the tsconfig directory. A codec declaration is trusted output
|
|
194
|
+
metadata, not data validation.
|
|
195
|
+
|
|
196
|
+
Custom configuration belongs in an application-owned checking script; the checker does not load
|
|
197
|
+
application configuration automatically. Generated-file freshness remains a separate
|
|
198
|
+
`db.generateTypes({ out, check: true })` operation. For behavior tests, see
|
|
199
|
+
[query inspection and testing](query-inspection.md).
|
package/docs/query-inspection.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Inspecting and testing queries
|
|
2
2
|
|
|
3
|
-
`describeQuery` asks PostgreSQL for a
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
`describeQuery` asks PostgreSQL for a finished statement's parameter and result metadata. It does
|
|
4
|
+
not bind values or execute the statement. Use it to inspect SQL and to support database tests. To
|
|
5
|
+
compare TypeScript source annotations instead, use [query checking](query-checking.md).
|
|
6
6
|
|
|
7
7
|
## Inspect completed SQL
|
|
8
8
|
|
|
@@ -18,13 +18,19 @@ const query = sql<{ id: number; total: string }>`
|
|
|
18
18
|
const description = await describeQuery({ connection, query });
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
`query` accepts SQL text or an object
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
Pass the same completed query to inspection and execution. `query` accepts SQL text or an object
|
|
22
|
+
with `text`, including a Tenon `SqlQuery`. Raw SQL uses `$1` placeholders; templates use `${value}`.
|
|
23
|
+
Inspection checks neither `query.values` nor its `sql<Row>` annotation.
|
|
24
|
+
|
|
25
|
+
Cast standalone parameters so PostgreSQL can infer their types without values. Alternatively pass
|
|
26
|
+
`query: { text, parameterTypes: [oid, ...] }` for explicit Describe hints. PostgreSQL still parses
|
|
27
|
+
and analyzes the statement, which can take locks.
|
|
25
28
|
|
|
26
29
|
## Session and timeouts
|
|
27
30
|
|
|
31
|
+
Inspection opens and closes its own short-lived session. `connection` must be a PostgreSQL URI;
|
|
32
|
+
it does not inherit a runtime pool or transaction:
|
|
33
|
+
|
|
28
34
|
```ts
|
|
29
35
|
const description = await describeQuery({
|
|
30
36
|
connection,
|
|
@@ -34,50 +40,73 @@ const description = await describeQuery({
|
|
|
34
40
|
});
|
|
35
41
|
```
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
Match runtime settings. Optional `session.role` uses SET ROLE when permitted; it does not load that
|
|
44
|
+
role's login defaults. Omitted settings use the inspection connection's defaults. Explicit undefined
|
|
45
|
+
behaves like omission. Unknown keys and invalid settings reject.
|
|
40
46
|
|
|
41
47
|
`context` reports login/effective roles, configured/resolved search paths, timezone, database, and
|
|
42
|
-
effective statement/lock timeouts.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
unknown.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
|
72
|
-
|
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
48
|
+
effective statement/lock timeouts. Mode is `separate-session`, read mapping profile `pgclear`.
|
|
49
|
+
Temporary tables, uncommitted changes, and transaction-local state are not inherited. PG environment
|
|
50
|
+
variables, pgpass, service files, and `.env` are not loaded.
|
|
51
|
+
|
|
52
|
+
| Timeout | Default | Scope |
|
|
53
|
+
| ------------- | -------- | ----------------------------- |
|
|
54
|
+
| `connectMs` | 3,000 ms | Connection and authentication |
|
|
55
|
+
| `statementMs` | 5,000 ms | Each server statement |
|
|
56
|
+
| `lockMs` | 1,000 ms | Lock wait |
|
|
57
|
+
|
|
58
|
+
Overrides are positive integer milliseconds up to 2,147,483,647. These are operation bounds, not a
|
|
59
|
+
single overall deadline. URI settings cannot disable statement/lock bounds. pg-specific
|
|
60
|
+
`query_timeout` and `options` URI parameters are unsupported; use the options above.
|
|
61
|
+
|
|
62
|
+
## Read the report
|
|
63
|
+
|
|
64
|
+
| Field | Meaning |
|
|
65
|
+
| ----------------------- | ----------------------------------------------------------------------------------------- |
|
|
66
|
+
| `parameters` | OIDs, qualified PostgreSQL names, and read representations; these are not input contracts |
|
|
67
|
+
| `columns` | Ordered names, PostgreSQL types, read representations, and available source metadata |
|
|
68
|
+
| `columns[].nullability` | Always `"unknown"`; source `notNull` is informational |
|
|
69
|
+
| `rowType` | Conservative `DescribedRow` declaration, or null for no columns/duplicate names |
|
|
70
|
+
| `issues` | Findings with stable `code` and descriptive messages |
|
|
71
|
+
| `context` | Effective inspection-session settings |
|
|
72
|
+
|
|
73
|
+
Known read types in `rowType` include null; unknown already includes it. A source column's NOT NULL
|
|
74
|
+
constraint cannot prove result nullability through outer joins, expressions, or grouping. JSON
|
|
75
|
+
members remain unknown.
|
|
76
|
+
|
|
77
|
+
| Finding code | Meaning |
|
|
78
|
+
| --------------------- | ----------------------------------------------------- |
|
|
79
|
+
| `nullability-unknown` | Result nullability is unresolved |
|
|
80
|
+
| `shape-unknown` | Listed columns have unknown shapes/read types |
|
|
81
|
+
| `unmapped-type` | A custom decoder has no known representation |
|
|
82
|
+
| `duplicate-column` | Repeated result names would fail object-row execution |
|
|
83
|
+
|
|
84
|
+
## Custom codecs
|
|
85
|
+
|
|
86
|
+
Pass the runtime registry as `codecs` to report its read representations; omission uses defaults:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { builtin, defaultCodecs } from '@kequtech/tenon-orm';
|
|
90
|
+
|
|
91
|
+
const codecs = defaultCodecs.with(builtin.jsonbText);
|
|
92
|
+
const description = await describeQuery({ connection, query, codecs });
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
That registry reports JSONB output as string. Scalar overrides affect derived built-in arrays;
|
|
96
|
+
explicit array overrides win. Custom `text.typescript` expressions appear in the conservative row
|
|
97
|
+
declaration and are shared with generation/source checking. Without known metadata, a custom
|
|
98
|
+
decoder remains unknown even for a familiar scalar/enum/domain OID. Catalog base types do not
|
|
99
|
+
replace the wire OID's decoder contract.
|
|
100
|
+
|
|
101
|
+
Inspection's own catalog/context queries use default decoding. Reported parameter read types are
|
|
102
|
+
not encoding rules: exact text can be a valid int8 input while its output also uses text by default.
|
|
103
|
+
See [schema mappings and codecs](schema-types.md#custom-codecs).
|
|
75
104
|
|
|
76
105
|
## Test the same statement
|
|
77
106
|
|
|
78
|
-
For
|
|
79
|
-
|
|
80
|
-
`{ id: 2, email: 'lin@example.test', active: false }
|
|
107
|
+
Use a dedicated test database. For `public.users` from [Getting started](human-guide.md), assume a
|
|
108
|
+
fixture contains `{ id: 1, email: 'ada@example.test', active: true }` and
|
|
109
|
+
`{ id: 2, email: 'lin@example.test', active: false }`. Here `db` and `connection` select that database:
|
|
81
110
|
|
|
82
111
|
```ts
|
|
83
112
|
import assert from 'node:assert/strict';
|
|
@@ -109,15 +138,15 @@ for (const activeOnly of [false, true]) {
|
|
|
109
138
|
}
|
|
110
139
|
```
|
|
111
140
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
rows
|
|
115
|
-
|
|
116
|
-
|
|
141
|
+
Describe establishes metadata; execution tests establish behavior. Describe may succeed even when
|
|
142
|
+
bound values are invalid, constraints would reject a write, or the role lacks execution permission.
|
|
143
|
+
Test actual rows, write effects, authorization, JSON validation, and rollback. Exercise meaningful
|
|
144
|
+
composition variants and keep unexpected findings visible. Do not test query behavior by matching
|
|
145
|
+
words in documentation or SQL strings.
|
|
117
146
|
|
|
118
147
|
## CLI
|
|
119
148
|
|
|
120
|
-
Save SQL
|
|
149
|
+
Save raw SQL in `query.sql`:
|
|
121
150
|
|
|
122
151
|
```sql
|
|
123
152
|
SELECT $1::integer AS id, count(*) AS total FROM pg_catalog.pg_class;
|
|
@@ -128,9 +157,11 @@ npx tenon describe query.sql
|
|
|
128
157
|
npx tenon describe query.sql --search-path app,public --time-zone UTC
|
|
129
158
|
```
|
|
130
159
|
|
|
131
|
-
The CLI
|
|
132
|
-
`--time-zone
|
|
133
|
-
`--lock-timeout-ms`.
|
|
160
|
+
The CLI reads `DATABASE_URL` and prints JSON using default codecs. Session flags are `--role`,
|
|
161
|
+
`--search-path`, and `--time-zone`; timeout flags are `--connect-timeout-ms`,
|
|
162
|
+
`--statement-timeout-ms`, and `--lock-timeout-ms`. Unsupported flags, including `--namespace` and
|
|
163
|
+
`--check`, reject.
|
|
134
164
|
|
|
135
|
-
Exit 0 means
|
|
136
|
-
|
|
165
|
+
Exit 0 means inspection succeeded, even with unresolved findings. Input, connection, timeout, or
|
|
166
|
+
PostgreSQL analysis failures exit 1. The inspected statement is never executed. Generated-file
|
|
167
|
+
freshness uses `db.generateTypes({ out, check: true })`, a separate operation.
|