@kequtech/tenon-orm 0.4.0 → 0.4.3
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 +22 -2
- package/README.md +93 -51
- package/dist/check.js +4 -2
- package/dist/cli.js +97 -40
- package/dist/config/init.d.ts +2 -0
- package/dist/config/init.js +34 -0
- package/dist/config/load.d.ts +7 -0
- package/dist/config/load.js +159 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +1 -0
- package/dist/connection.d.ts +5 -0
- package/dist/describe.js +3 -1
- package/dist/generation/output.js +0 -2
- package/dist/index.d.ts +2 -1
- package/dist/inspection/cli.d.ts +3 -4
- package/dist/inspection/cli.js +3 -13
- package/dist/inspection/connection.js +23 -19
- package/dist/inspection/options.d.ts +5 -7
- package/dist/inspection/options.js +49 -22
- package/dist/inspection/types.d.ts +3 -2
- package/dist/migrations/history.d.ts +1 -1
- package/dist/migrations/history.js +3 -2
- package/dist/validation.d.ts +2 -0
- package/dist/validation.js +6 -0
- package/docs/adoption-guide.md +94 -153
- package/docs/agent-guide.md +93 -176
- package/docs/configuration.md +146 -0
- package/docs/connections.md +187 -0
- package/docs/human-guide.md +150 -179
- package/docs/migrations.md +140 -0
- package/docs/mutations.md +104 -67
- package/docs/query-checking.md +122 -120
- package/docs/query-inspection.md +83 -76
- package/docs/reference.md +234 -474
- package/docs/schema-types.md +266 -0
- package/docs/sql.md +212 -0
- package/package.json +1 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Schema types and values
|
|
2
|
+
|
|
3
|
+
Tenon can generate TypeScript declarations from an existing PostgreSQL schema. Generation is
|
|
4
|
+
optional and works with any migration runner. It describes table storage; your query still defines
|
|
5
|
+
the selected columns, joins, and expressions.
|
|
6
|
+
|
|
7
|
+
## Generate and check a file
|
|
8
|
+
|
|
9
|
+
Configure `generate.out` in [shared configuration](configuration.md), then run after migrations
|
|
10
|
+
have committed:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npx tenon generate
|
|
14
|
+
npx tenon generate --check
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The first command writes declarations. The second rejects missing or stale output without rewriting
|
|
18
|
+
it. Both use the configured connection and codecs. Relative configured paths resolve from the config
|
|
19
|
+
file; `--out` paths resolve from the working directory. The generation namespace comes from
|
|
20
|
+
`generate.namespace`, then `migrations.namespace`, then `public`; `--namespace` overrides it. It is
|
|
21
|
+
independent of runtime `search_path`.
|
|
22
|
+
|
|
23
|
+
Keep exact output unchanged and exclude it from automatic formatting. See
|
|
24
|
+
[getting started](human-guide.md#generate-types) for npm commands.
|
|
25
|
+
|
|
26
|
+
Generation runs outside callback transactions. It uses a repeatable-read catalog snapshot and
|
|
27
|
+
shares the migration advisory lock; use a direct or session-pooled connection. It needs no migration
|
|
28
|
+
files or Tenon history.
|
|
29
|
+
|
|
30
|
+
Tenon creates parent directories and replaces the entire output file atomically. With `--check`,
|
|
31
|
+
it creates no directories, writes no files, and includes a bounded diff for stale output. The CLI
|
|
32
|
+
prints warnings to stderr. Generation failures preserve the existing file; filesystem errors propagate.
|
|
33
|
+
|
|
34
|
+
For source text without file handling, use
|
|
35
|
+
`generateDefinitions({ connection, namespace?, onWarning? }): Promise<string>`. Pass the configured
|
|
36
|
+
pool or connection options to retain runtime read mappings. Its optional `onWarning` callback may
|
|
37
|
+
be synchronous or asynchronous and defaults to `console.warn`; callback failures reject generation.
|
|
38
|
+
|
|
39
|
+
## Read the generated types
|
|
40
|
+
|
|
41
|
+
For the table from [Getting started](human-guide.md#create-and-apply-a-migration), the important
|
|
42
|
+
parts of the generated declaration look like this:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import type { Parameter } from '@kequtech/tenon-orm';
|
|
46
|
+
|
|
47
|
+
export type Database = {
|
|
48
|
+
users: {
|
|
49
|
+
row: {
|
|
50
|
+
id: number;
|
|
51
|
+
email: string;
|
|
52
|
+
active: boolean;
|
|
53
|
+
};
|
|
54
|
+
insert: {
|
|
55
|
+
id?: never;
|
|
56
|
+
email: string | Parameter;
|
|
57
|
+
active?: boolean | Parameter | undefined;
|
|
58
|
+
};
|
|
59
|
+
update: {
|
|
60
|
+
id?: never;
|
|
61
|
+
email?: string | Parameter | undefined;
|
|
62
|
+
active?: boolean | Parameter | undefined;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The real file also contains schema-summary comments for the tables, columns, keys, indexes,
|
|
69
|
+
constraints, and relevant domain definitions. Generation includes ordinary tables and partitioned
|
|
70
|
+
roots in one schema; it excludes attached partitions, views, materialized views, and Tenon's history
|
|
71
|
+
table. It does not generate models, relationship methods, migrations, or validators.
|
|
72
|
+
|
|
73
|
+
Use the types explicitly:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { sql } from '@kequtech/tenon-orm';
|
|
77
|
+
import type { Database } from './generated-schema.js';
|
|
78
|
+
|
|
79
|
+
type UserSummary = Pick<Database['users']['row'], 'id' | 'email'>;
|
|
80
|
+
const input = { email: 'reader@example.test' } satisfies Database['users']['insert'];
|
|
81
|
+
|
|
82
|
+
const user = await db.query(
|
|
83
|
+
sql<UserSummary>`INSERT INTO public.users ${sql.insertValues(input)} RETURNING id, email`,
|
|
84
|
+
{ returns: 'one' },
|
|
85
|
+
);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A table's `row` type describes its complete stored row. Use `Pick` for a projection and a separate
|
|
89
|
+
type for joins or aggregates. `sql<Row>` does not check that your SQL returns that shape. See
|
|
90
|
+
[query checking](query-checking.md) and [database tests](query-inspection.md#test-the-same-statement).
|
|
91
|
+
|
|
92
|
+
### Application aliases
|
|
93
|
+
|
|
94
|
+
Keep shorter names in a handwritten module so regeneration preserves them:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// src/schema-types.ts
|
|
98
|
+
import type { Database } from './generated-schema.js';
|
|
99
|
+
|
|
100
|
+
export type UserRow = Database['users']['row'];
|
|
101
|
+
export type UserInsert = Database['users']['insert'];
|
|
102
|
+
export type UserUpdate = Database['users']['update'];
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The generated `Database` is a schema map, distinct from Tenon's runtime `Database` interface. If a
|
|
106
|
+
module needs both, alias one of the imports.
|
|
107
|
+
|
|
108
|
+
## Insert and update inputs
|
|
109
|
+
|
|
110
|
+
Insert fields are required when writable, nonnullable, and without a default. Other writable insert
|
|
111
|
+
fields and all writable update fields are optional, with explicit undefined accepted even under
|
|
112
|
+
`exactOptionalPropertyTypes`.
|
|
113
|
+
|
|
114
|
+
- Computed columns and `GENERATED ALWAYS` identity columns use optional `never` fields.
|
|
115
|
+
- Serial and `GENERATED BY DEFAULT` identity fields are optional on insert and writable on update.
|
|
116
|
+
Other primary keys remain writable.
|
|
117
|
+
- Domain defaults count; column defaults take precedence. Write nullability respects the domain
|
|
118
|
+
chain as well as the column.
|
|
119
|
+
- Every writable field accepts an explicit `Parameter` in addition to its plain input types.
|
|
120
|
+
|
|
121
|
+
Apply types through an annotation, `satisfies`, or a helper generic. Merely generating a file does
|
|
122
|
+
not check writes. Use `Pick` or `Omit` for application restrictions, and validate external input.
|
|
123
|
+
The types do not enforce CHECK constraints, JSON members, authorization, or a nonempty patch.
|
|
124
|
+
|
|
125
|
+
The [mutation helpers](mutations.md) omit undefined fields for updates and use defaults for missing
|
|
126
|
+
insert values. A selected column list can discard a required input even when the full object is
|
|
127
|
+
well typed; PostgreSQL still enforces constraints.
|
|
128
|
+
|
|
129
|
+
## Value mappings
|
|
130
|
+
|
|
131
|
+
These are the default driver representations. SQL NULL is `null`; nullable generated columns add
|
|
132
|
+
null to their type. Input encoding and output decoding are separate choices.
|
|
133
|
+
|
|
134
|
+
| PostgreSQL value | Default read | Plain write input |
|
|
135
|
+
| ----------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------- |
|
|
136
|
+
| smallint, integer, serial, oid | `number` | `number` |
|
|
137
|
+
| bigint, bigserial, `count(*)` | Exact `string` | Exact string or bigint |
|
|
138
|
+
| numeric/decimal | `string` | String or bigint |
|
|
139
|
+
| real, double precision | `number` | Finite number |
|
|
140
|
+
| boolean | `boolean` | Boolean |
|
|
141
|
+
| text, UUID, other textual types | `string` | String |
|
|
142
|
+
| enum | Catalog label union in default-registry generated rows | Label union |
|
|
143
|
+
| date, timestamp, timestamptz | `string` | String, Date, or supported Temporal value |
|
|
144
|
+
| time, interval | `string` | String or supported Temporal value |
|
|
145
|
+
| timetz | `string` | String |
|
|
146
|
+
| json/jsonb | `unknown` | Encoded JSON string; use `json(value)` for a parameter |
|
|
147
|
+
| bytea | `Uint8Array` | Uint8Array |
|
|
148
|
+
| Supported built-in arrays | Recursive readonly `PgArray<T>` | Encoded PostgreSQL array text; use `param` for an array value |
|
|
149
|
+
| Unrecognized types and dynamic enum/domain/extension arrays | Wire-text `string` | Encoded string or explicit parameter |
|
|
150
|
+
|
|
151
|
+
Generated inputs conservatively describe column-compatible representations; an explicit `Parameter`
|
|
152
|
+
is accepted for every writable column. PostgreSQL checks ranges and text formats. Plain undefined,
|
|
153
|
+
objects, and arrays are rejected as bound parameters. See [JSON and arrays](mutations.md#json-and-postgresql-arrays).
|
|
154
|
+
|
|
155
|
+
Bigint and numeric arrays retain exact strings. Use `BigInt(value)` for exact integer arithmetic;
|
|
156
|
+
convert to number only after checking the safe integer range. Command `rowCount` is a number or null,
|
|
157
|
+
independent of SQL aggregate values; counts beyond the safe integer range reject rather than round.
|
|
158
|
+
|
|
159
|
+
Temporal strings preserve precision and infinity spellings. Date inputs encode in UTC; use a calendar
|
|
160
|
+
date string or corresponding Temporal plain value when an instant is not intended. Inputs do not
|
|
161
|
+
change output types. JSON numbers follow JavaScript number precision; parsed JSON null and SQL NULL
|
|
162
|
+
both become null. For raw JSON text or Date/bigint outputs, configure codecs.
|
|
163
|
+
|
|
164
|
+
## Custom codecs
|
|
165
|
+
|
|
166
|
+
Configure the registry on the application's connection. For example:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { builtin, createTenon, defaultCodecs } from '@kequtech/tenon-orm';
|
|
170
|
+
|
|
171
|
+
const codecs = defaultCodecs.with(builtin.int8BigInt).with(builtin.timestamptzDate);
|
|
172
|
+
const db = createTenon({ connection: { connectionString: connection, codecs } });
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Int8 now reads as bigint, and timestamptz reads as a millisecond-precision Date. Regenerate using this
|
|
176
|
+
instance. Scalar overrides affect derived built-in arrays; explicit array overrides win. Date and
|
|
177
|
+
Temporal output codecs reject infinity and values outside their supported ranges.
|
|
178
|
+
|
|
179
|
+
For Temporal, import `createTemporalCodecs` from Tenon and supply a native or compatible polyfill
|
|
180
|
+
implementation. Tenon does not install a polyfill or change globals. A polyfill can provide its own
|
|
181
|
+
generated type namespace:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
185
|
+
import { createTemporalCodecs, defaultCodecs } from '@kequtech/tenon-orm';
|
|
186
|
+
|
|
187
|
+
const temporal = createTemporalCodecs(Temporal, {
|
|
188
|
+
typescriptNamespace: 'import("@js-temporal/polyfill").Temporal',
|
|
189
|
+
});
|
|
190
|
+
const codecs = defaultCodecs.with(temporal.date).with(temporal.timestamptz);
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Install the polyfill only if the application needs it. With native Temporal, the consuming TypeScript
|
|
194
|
+
configuration needs the corresponding Temporal library declarations.
|
|
195
|
+
|
|
196
|
+
A custom text codec can declare `text.typescript` to describe its non-NULL output. Generation and
|
|
197
|
+
checking consume the registry's metadata. An unidentified decoder warns and becomes `unknown`;
|
|
198
|
+
a declared representation is trusted, not runtime validation. Under a custom registry, enum output
|
|
199
|
+
may remain string because a decoder can transform labels; enum write inputs retain catalog choices.
|
|
200
|
+
|
|
201
|
+
Place the registry in shared `connection.codecs` so runtime, CLI generation, inspection, and source
|
|
202
|
+
checking use it consistently. Programmatic inspection/checking inherit `connection.codecs` too. Relative type-import
|
|
203
|
+
expressions resolve from the generated file for saved declarations and from the tsconfig directory
|
|
204
|
+
for checking. Prefer a package or configured alias that resolves in both places. Resolving these
|
|
205
|
+
type expressions does not execute their referenced modules. See [custom-codec checking](query-checking.md#programmatic-use-and-custom-codecs).
|
|
206
|
+
|
|
207
|
+
Catalog queries use default result decoding on the same borrowed session, so application codecs do
|
|
208
|
+
not alter Tenon's metadata inputs. Output codec choices do not select input encoders.
|
|
209
|
+
|
|
210
|
+
## Persistence and domain values
|
|
211
|
+
|
|
212
|
+
Storage types can be broader than application types. Text CHECK constraints do not become unions,
|
|
213
|
+
and JSON reads stay unknown. Validate external data before writing and database data before narrowing
|
|
214
|
+
it to a domain type.
|
|
215
|
+
|
|
216
|
+
For an additional `public.sessions(id text PRIMARY KEY, data jsonb NOT NULL, expires_at timestamptz
|
|
217
|
+
NOT NULL)` table, a JSON validator can be shared by writes and reads:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
import { json, sql } from '@kequtech/tenon-orm';
|
|
221
|
+
|
|
222
|
+
function sessionData(value: unknown): { userId: string } {
|
|
223
|
+
if (
|
|
224
|
+
typeof value !== 'object' ||
|
|
225
|
+
value === null ||
|
|
226
|
+
Array.isArray(value) ||
|
|
227
|
+
!('userId' in value) ||
|
|
228
|
+
typeof value.userId !== 'string' ||
|
|
229
|
+
value.userId.length === 0
|
|
230
|
+
) {
|
|
231
|
+
throw new Error('Expected session data with a nonempty userId');
|
|
232
|
+
}
|
|
233
|
+
return { userId: value.userId };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const id = 'example-session';
|
|
237
|
+
const expiresAt = new Date(Date.now() + 60_000);
|
|
238
|
+
await db.query(sql`INSERT INTO public.sessions (id, data, expires_at)
|
|
239
|
+
VALUES (${id}, ${json(sessionData({ userId: 'user-42' }))}, ${expiresAt})`);
|
|
240
|
+
|
|
241
|
+
const row = await db.query(
|
|
242
|
+
sql<{ id: string; data: unknown; expires_at: string }>`
|
|
243
|
+
SELECT id, data, expires_at FROM public.sessions
|
|
244
|
+
WHERE id = ${id} AND isfinite(expires_at) AND expires_at > now()
|
|
245
|
+
`,
|
|
246
|
+
{ returns: 'oneOrNone' },
|
|
247
|
+
);
|
|
248
|
+
if (row) {
|
|
249
|
+
const data = sessionData(row.data);
|
|
250
|
+
const expiry = new Date(row.expires_at);
|
|
251
|
+
if (!Number.isFinite(expiry.getTime())) throw new Error('Expected a finite timestamp');
|
|
252
|
+
// data.userId and expiry now have validated application representations.
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`isfinite` excludes positive infinity, which would otherwise pass `expires_at > now()`. Keep the
|
|
257
|
+
same policy in expiry-sensitive updates/deletes. Test expired and infinite values, and retain the
|
|
258
|
+
application's authentication and authorization checks.
|
|
259
|
+
|
|
260
|
+
## What freshness checks establish
|
|
261
|
+
|
|
262
|
+
`tenon generate --check` compares the exact generated file with output from this database and codec registry.
|
|
263
|
+
It does not check query annotations, pending migrations, functions, privileges, or every database
|
|
264
|
+
object. A driver or generator update can change output without schema drift. Review generated diffs
|
|
265
|
+
after upgrades. Generated text and schema-summary formatting are not version-stable schema
|
|
266
|
+
fingerprints; see [baseline verification](adoption-guide.md#check-and-adopt-an-existing-database).
|
package/docs/sql.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Writing queries
|
|
2
|
+
|
|
3
|
+
Tenon executes PostgreSQL SQL. A query has SQL text, separately bound values, and an optional
|
|
4
|
+
TypeScript type for one returned row. Examples use the `public.users` table from
|
|
5
|
+
[Getting started](human-guide.md) and an existing `db` instance.
|
|
6
|
+
|
|
7
|
+
## Bind values
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { sql } from '@kequtech/tenon-orm';
|
|
11
|
+
|
|
12
|
+
const email = 'reader@example.test';
|
|
13
|
+
const query = sql<{ id: number; email: string }>`
|
|
14
|
+
SELECT id, email FROM public.users WHERE email = ${email}
|
|
15
|
+
`;
|
|
16
|
+
// query.text: SQL containing WHERE email = $1
|
|
17
|
+
// query.values: ['reader@example.test']
|
|
18
|
+
const user = await db.query(query, { returns: 'oneOrNone' });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Constructing `query` does not contact PostgreSQL. Execute it with `db.query` or `tx.query`. Do not
|
|
22
|
+
quote interpolations or write `$1` placeholders inside templates.
|
|
23
|
+
|
|
24
|
+
Plain inputs accept null, strings, finite numbers, bigint, booleans, valid Date values, supported
|
|
25
|
+
Temporal values, and Uint8Array. Arrays and objects need explicit encoders; undefined is not a bound
|
|
26
|
+
SQL value. See [JSON and arrays](mutations.md#json-and-postgresql-arrays). Bound data must remain
|
|
27
|
+
unchanged until the query settles.
|
|
28
|
+
|
|
29
|
+
## Choose the result you need
|
|
30
|
+
|
|
31
|
+
Use `one` for exactly one row, `oneOrNone` for zero or one, and `many` for an array. The default
|
|
32
|
+
`native` result provides rows and command metadata:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const result = await db.query(sql`UPDATE public.users SET active = false WHERE id = ${42}`);
|
|
36
|
+
console.log(result.command, result.rowCount); // UPDATE, number of affected rows
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The modes check rows actually returned. An `UPDATE` without `RETURNING` returns no rows even when
|
|
40
|
+
it changes data. Modes do not add `RETURNING` or `LIMIT`. See the
|
|
41
|
+
[result reference](reference.md#queries-and-result-modes) for exact types.
|
|
42
|
+
|
|
43
|
+
Reuse queries with functions that accept `QueryClient`, so the same function can receive `db` or
|
|
44
|
+
`tx`. The [getting-started example](human-guide.md#reuse-a-query) shows this pattern.
|
|
45
|
+
|
|
46
|
+
## Join related tables
|
|
47
|
+
|
|
48
|
+
Add this example table in a migration:
|
|
49
|
+
|
|
50
|
+
```sql
|
|
51
|
+
CREATE TABLE public.posts (
|
|
52
|
+
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
53
|
+
user_id integer NOT NULL REFERENCES public.users(id),
|
|
54
|
+
title text NOT NULL
|
|
55
|
+
);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Select and alias the columns you want. There is no relationship configuration or automatic loading:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
type PostWithAuthor = { id: number; title: string; authorEmail: string };
|
|
62
|
+
|
|
63
|
+
const posts = await db.query(
|
|
64
|
+
sql<PostWithAuthor>`
|
|
65
|
+
SELECT p.id, p.title, u.email AS "authorEmail"
|
|
66
|
+
FROM public.posts AS p
|
|
67
|
+
JOIN public.users AS u ON u.id = p.user_id
|
|
68
|
+
WHERE u.active = ${true}
|
|
69
|
+
ORDER BY p.id
|
|
70
|
+
`,
|
|
71
|
+
{ returns: 'many' },
|
|
72
|
+
);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use unique column names; selecting both `p.id` and `u.id` without aliases produces duplicate names,
|
|
76
|
+
which the driver rejects. Quoted aliases preserve camelCase. An outer join can introduce null even
|
|
77
|
+
for a source column declared `NOT NULL`; reflect that in the query's result type:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
type UserWithPost = { userId: number; title: string | null };
|
|
81
|
+
const rows = await db.query(
|
|
82
|
+
sql<UserWithPost>`
|
|
83
|
+
SELECT u.id AS "userId", p.title
|
|
84
|
+
FROM public.users AS u
|
|
85
|
+
LEFT JOIN public.posts AS p ON p.user_id = u.id
|
|
86
|
+
ORDER BY u.id, p.id
|
|
87
|
+
`,
|
|
88
|
+
{ returns: 'many' },
|
|
89
|
+
);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A user with several posts appears several times. Group rows in application code or use PostgreSQL
|
|
93
|
+
aggregation when a nested result is needed. JSON aggregate output is `unknown` by default; validate
|
|
94
|
+
it before narrowing to an application type.
|
|
95
|
+
|
|
96
|
+
## Aggregate values
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
const totals = await db.query(
|
|
100
|
+
sql<{ userId: number; total: string }>`
|
|
101
|
+
SELECT u.id AS "userId", count(p.id) AS total
|
|
102
|
+
FROM public.users AS u
|
|
103
|
+
LEFT JOIN public.posts AS p ON p.user_id = u.id
|
|
104
|
+
GROUP BY u.id
|
|
105
|
+
ORDER BY u.id
|
|
106
|
+
`,
|
|
107
|
+
{ returns: 'many' },
|
|
108
|
+
);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`count(p.id)` counts matched posts, including zero for a user without posts. `count(*)` would also
|
|
112
|
+
count that user's outer-join row. Both return PostgreSQL bigint, decoded as an exact string. Other
|
|
113
|
+
aggregate result types depend on PostgreSQL's selected function and input type; use
|
|
114
|
+
[inspection](query-inspection.md) when unsure.
|
|
115
|
+
|
|
116
|
+
## Paginate with an explicit order
|
|
117
|
+
|
|
118
|
+
For an integer ID cursor, validate `afterId` and `pageSize` in application code, then bind them:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
async function usersAfter(afterId: number, pageSize: number) {
|
|
122
|
+
if (!Number.isInteger(afterId) || afterId < 0) throw new Error('Invalid cursor');
|
|
123
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
|
|
124
|
+
throw new Error('Page size must be between 1 and 100');
|
|
125
|
+
}
|
|
126
|
+
return db.query(
|
|
127
|
+
sql<{ id: number; email: string }>`
|
|
128
|
+
SELECT id, email FROM public.users
|
|
129
|
+
WHERE id > ${afterId} ORDER BY id LIMIT ${pageSize}
|
|
130
|
+
`,
|
|
131
|
+
{ returns: 'many' },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Start with `afterId = 0`, then use the last returned ID for the next page. A unique ordering makes
|
|
137
|
+
page boundaries deterministic. For another sort column, include a unique tiebreaker and carry both
|
|
138
|
+
values in the cursor. Tenon does not add pagination or ordering.
|
|
139
|
+
|
|
140
|
+
## Compose SQL fragments
|
|
141
|
+
|
|
142
|
+
A fragment carries SQL and its bound values. It cannot execute alone and has no result type:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
const pattern = '%example%';
|
|
146
|
+
const condition = sql.fragment`active = ${true} AND email ILIKE ${pattern}`;
|
|
147
|
+
const page = sql<{ id: number; email: string }>`
|
|
148
|
+
SELECT id, email FROM public.users WHERE ${condition} ORDER BY id LIMIT ${20}
|
|
149
|
+
`;
|
|
150
|
+
const total = sql<{ total: string }>`SELECT count(*) AS total FROM public.users WHERE ${condition}`;
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Use `sql.join` for a list of conditions. It supports `'AND'`, `'OR'`, and `','`, without adding
|
|
154
|
+
parentheses:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const terms = ['reader', 'example'];
|
|
158
|
+
const filters = [sql.fragment`active = ${true}`];
|
|
159
|
+
for (const term of terms) {
|
|
160
|
+
filters.push(sql.fragment`email ILIKE ${'%' + term + '%'}`);
|
|
161
|
+
}
|
|
162
|
+
const query = sql<{ id: number }>`
|
|
163
|
+
SELECT id FROM public.users WHERE ${sql.join(filters, 'AND')} ORDER BY id
|
|
164
|
+
`;
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The initial condition keeps the list nonempty. If every condition is optional, choose a complete
|
|
168
|
+
query without `WHERE` when the list is empty. Retain required authorization conditions in every
|
|
169
|
+
branch. `sql.join([])` and blank fragments fail. Parenthesize groups when mixing `AND` and `OR`.
|
|
170
|
+
Parameters are numbered correctly across nested and repeated fragments.
|
|
171
|
+
|
|
172
|
+
Runtime loops and dynamic fragment lists execute normally but remain unchecked by the optional
|
|
173
|
+
[source checker](query-checking.md#supported-composition). Test their meaningful variants against
|
|
174
|
+
the database. Fixed queries are easier to follow when composition is unnecessary.
|
|
175
|
+
|
|
176
|
+
## Select names and SQL syntax safely
|
|
177
|
+
|
|
178
|
+
Values and identifiers are different. Use `identifier` for approved schema/table/column names:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { identifier } from '@kequtech/tenon-orm';
|
|
182
|
+
|
|
183
|
+
const table = identifier('public', 'users');
|
|
184
|
+
const query = sql<{ id: number }>`SELECT id FROM ${table} WHERE active = ${true}`;
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
This quotes `"public"."users"` as two components. It does not authorize access; map application
|
|
188
|
+
choices to approved names before using it. Each component must contain 1–63 UTF-8 bytes and no NUL.
|
|
189
|
+
|
|
190
|
+
Choose SQL syntax such as sort direction from fixed alternatives:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const descending = true;
|
|
194
|
+
const direction = descending ? sql.fragment`DESC` : sql.fragment`ASC`;
|
|
195
|
+
const query = sql<{ id: number }>`SELECT id FROM public.users ORDER BY id ${direction}`;
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Interpolating the string `'DESC'` binds a value and does not create SQL syntax. Complete `sql`
|
|
199
|
+
queries cannot be nested; use fragments for partial statements.
|
|
200
|
+
|
|
201
|
+
## Execute SQL supplied by another component
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
import { unsafeSql } from '@kequtech/tenon-orm';
|
|
205
|
+
|
|
206
|
+
const query = unsafeSql<{ id: number }>('SELECT id FROM public.users WHERE email = $1', [email]);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Use trusted SQL text and an explicit values array, including `[]` when there are no parameters.
|
|
210
|
+
Never concatenate request data into the text. Tenon executes one statement per query; migrations
|
|
211
|
+
have a separate multi-statement runner. For an adapter inside a transaction, see
|
|
212
|
+
[transactional result adapters](reference.md#adapting-transactional-results).
|