@kequtech/tenon-orm 0.1.1 → 0.1.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/README.md +12 -10
- package/dist/cli.js +4 -2
- package/dist/generate.js +2 -2
- package/dist/generation/catalog.d.ts +3 -0
- package/dist/generation/catalog.js +5 -2
- package/dist/generation/diff.d.ts +2 -0
- package/dist/generation/diff.js +30 -0
- package/dist/generation/table-types.d.ts +2 -0
- package/dist/generation/table-types.js +146 -0
- package/dist/mutations.d.ts +1 -1
- package/dist/query.d.ts +2 -2
- package/dist/query.js +1 -1
- package/dist/safeql/composition.js +9 -0
- package/dist/safeql/eslint.js +16 -11
- package/docs/adoption-guide.md +97 -4
- package/docs/agent-guide.md +38 -56
- package/docs/human-guide.md +37 -37
- package/docs/mutations.md +49 -12
- package/docs/reference.md +128 -94
- package/package.json +1 -1
- package/dist/generation/row-types.d.ts +0 -2
- package/dist/generation/row-types.js +0 -115
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
A PostgreSQL ORM designed for coding agents. Built for Node.js and TypeScript.
|
|
4
4
|
|
|
5
5
|
Your coding agent already knows SQL. Tenon gives it a small API for parameterized queries,
|
|
6
|
-
transactions, and migrations, with optional tooling to check SQL and generate
|
|
6
|
+
transactions, and migrations, with optional tooling to check SQL and generate database types. Queries
|
|
7
7
|
stay readable, reusable operations are ordinary functions, and humans can review exactly what will
|
|
8
8
|
run against the database.
|
|
9
9
|
|
|
@@ -47,7 +47,7 @@ Tenon also provides:
|
|
|
47
47
|
|
|
48
48
|
- Transactions and affected-row limits that roll back rejected writes.
|
|
49
49
|
- SQL migration files and a CLI to apply, inspect, and roll back migrations.
|
|
50
|
-
- TypeScript
|
|
50
|
+
- TypeScript row, insert, and update types generated from your database.
|
|
51
51
|
- SQL fragments for sharing query parts or assembling variable conditions.
|
|
52
52
|
|
|
53
53
|
Joins, relationships, aggregates, and upserts use ordinary PostgreSQL SQL. Tenon does not introduce
|
|
@@ -68,7 +68,9 @@ migrations and table type generation are independent conveniences.
|
|
|
68
68
|
|
|
69
69
|
For object-based writes, `sql.insertValues(rows)` preserves database defaults across heterogeneous
|
|
70
70
|
rows and `sql.assignments(patch)` omits undefined fields. Keep the surrounding SQL explicit. Both
|
|
71
|
-
support selected columns and [SafeQL checking](docs/reference.md#
|
|
71
|
+
support selected columns and [SafeQL checking](docs/reference.md#checking-mutation-fragments).
|
|
72
|
+
Use [generated input types](docs/reference.md#insert-and-update-inputs) to check those objects
|
|
73
|
+
against your schema.
|
|
72
74
|
|
|
73
75
|
## Checking query results
|
|
74
76
|
|
|
@@ -79,12 +81,10 @@ For that, Tenon provides an optional integration with [SafeQL](https://safeql.de
|
|
|
79
81
|
SQL against your development database and can write or repair the result annotation. ESLint runs the
|
|
80
82
|
check during development and CI; it is not part of query execution.
|
|
81
83
|
|
|
82
|
-
Configure SafeQL in your application's ESLint setup
|
|
83
|
-
queries, constant fragments, and
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
[SQL checking setup](docs/reference.md#sql-checking-with-safeql) for installation and a complete
|
|
87
|
-
configuration, including multiple databases.
|
|
84
|
+
Configure SafeQL in your application's ESLint setup with Tenon's `safeqlPlugin` adapter and
|
|
85
|
+
`eslintPlugin` companion rule. The adapter supports queries, constant fragments, and mutation
|
|
86
|
+
helpers; the rule reports queries it cannot inspect. See the
|
|
87
|
+
[SQL checking setup](docs/reference.md#sql-checking-with-safeql).
|
|
88
88
|
|
|
89
89
|
Checking complements typed inputs and database tests. It does not validate request values or
|
|
90
90
|
arbitrary JSON, and runtime-built query variants need their own tests.
|
|
@@ -97,7 +97,9 @@ arbitrary JSON, and runtime-built query variants need their own tests.
|
|
|
97
97
|
- **[Reference](docs/reference.md)** — complete options, SQL checking setup, type mappings, and
|
|
98
98
|
limitations.
|
|
99
99
|
- **[Adoption guide](docs/adoption-guide.md)** — replace an existing ORM and, if needed, transfer
|
|
100
|
-
migration ownership.
|
|
100
|
+
migration ownership; upgrade local write helpers and adopt SQL checking incrementally.
|
|
101
|
+
- **[Mutation cookbook](docs/mutations.md)** — defaults, patches, upserts, arrays, batching, and
|
|
102
|
+
the boundaries between input typing and SQL checking.
|
|
101
103
|
|
|
102
104
|
The complete `docs/` folder ships with the npm package. Maintainer instructions are in
|
|
103
105
|
[CONTRIBUTING.md](https://github.com/kequtech/tenon-orm/blob/main/CONTRIBUTING.md), which is not
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, resolve } from 'node:path';
|
|
|
5
5
|
import { parseArgs } from 'node:util';
|
|
6
6
|
import { adoptBaseline, captureBaseline } from './baseline.js';
|
|
7
7
|
import { generateDefinitions } from './generate.js';
|
|
8
|
+
import { definitionDiff } from './generation/diff.js';
|
|
8
9
|
import { migrate, migrationStatus, rollback } from './migrations.js';
|
|
9
10
|
import { createMigration } from './migrations/files.js';
|
|
10
11
|
const help = `tenon migrate|status|rollback [--directory migrations] [--namespace public]
|
|
@@ -30,9 +31,10 @@ async function writeDefinitions(source, filename, check) {
|
|
|
30
31
|
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
|
|
31
32
|
throw error;
|
|
32
33
|
}
|
|
34
|
+
throw new Error(`Generated definitions file is missing: ${output}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
|
|
33
35
|
}
|
|
34
36
|
if (existing !== source) {
|
|
35
|
-
throw new Error(
|
|
37
|
+
throw new Error(`Generated definitions differ: ${output}\n${definitionDiff(existing, source)}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
|
|
36
38
|
}
|
|
37
39
|
process.stdout.write('Generated definitions are current.\n');
|
|
38
40
|
return;
|
|
@@ -152,7 +154,7 @@ async function main() {
|
|
|
152
154
|
}
|
|
153
155
|
}
|
|
154
156
|
main().catch((error) => {
|
|
155
|
-
//
|
|
157
|
+
// Report diagnostics without connection configuration or query parameter payloads.
|
|
156
158
|
process.stderr.write(`${error instanceof Error ? error.message : 'Database command failed.'}\n`);
|
|
157
159
|
process.exitCode = 1;
|
|
158
160
|
});
|
package/dist/generate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readTables } from './generation/catalog.js';
|
|
2
|
-
import {
|
|
2
|
+
import { renderTableTypes } from './generation/table-types.js';
|
|
3
3
|
import { domainSummaries, schemaSummary, tableSummary } from './generation/schema-summary.js';
|
|
4
4
|
import { withMigrationLock } from './migrations/connection.js';
|
|
5
5
|
import { quote } from './sql.js';
|
|
@@ -24,7 +24,7 @@ export async function generateDefinitions(options) {
|
|
|
24
24
|
summaries.push(await tableSummary(client, table.oid, table.name));
|
|
25
25
|
}
|
|
26
26
|
const domains = await domainSummaries(client, definitions.flatMap((table) => table.columns.map((column) => column.declaredOid)));
|
|
27
|
-
const types =
|
|
27
|
+
const types = renderTableTypes(definitions, namespace, onWarning);
|
|
28
28
|
await client.query('COMMIT');
|
|
29
29
|
return `// Generated by Tenon from the PostgreSQL schema. Do not edit.
|
|
30
30
|
// Commit this file and regenerate it after changing the schema.
|
|
@@ -11,16 +11,19 @@ export async function readTables(client, namespace) {
|
|
|
11
11
|
// Attached descendants are storage details, not separate generated models.
|
|
12
12
|
const columns = await client.query(`
|
|
13
13
|
WITH RECURSIVE resolved AS (
|
|
14
|
-
SELECT a.attnum, t.oid, t.typbasetype
|
|
14
|
+
SELECT a.attnum, t.oid, t.typbasetype, t.typnotnull AS domain_not_null
|
|
15
15
|
FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
|
|
16
16
|
WHERE a.attrelid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
17
17
|
UNION ALL
|
|
18
|
-
SELECT r.attnum, t.oid, t.typbasetype
|
|
18
|
+
SELECT r.attnum, t.oid, t.typbasetype, r.domain_not_null OR t.typnotnull
|
|
19
19
|
FROM resolved r JOIN pg_catalog.pg_type t ON t.oid = r.typbasetype
|
|
20
20
|
)
|
|
21
21
|
SELECT a.attname AS name, t.oid::text AS oid, t.typname AS type, tn.nspname AS "typeSchema", t.typtype AS "typeKind",
|
|
22
22
|
declared.oid::text AS "declaredOid", declared.typname AS "declaredType", dn.nspname AS "declaredSchema",
|
|
23
23
|
NOT a.attnotnull AS nullable,
|
|
24
|
+
NOT (a.attnotnull OR r.domain_not_null) AS "writeNullable",
|
|
25
|
+
(a.atthasdef OR declared.typdefaultbin IS NOT NULL OR a.attidentity <> '') AS "hasDefault",
|
|
26
|
+
(a.attgenerated = '' AND a.attidentity <> 'a') AS writable,
|
|
24
27
|
(SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder) FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid) AS choices
|
|
25
28
|
FROM pg_catalog.pg_attribute a
|
|
26
29
|
JOIN resolved r ON r.attnum = a.attnum AND r.typbasetype = 0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** A bounded text excerpt, not a schema comparison or an applicable patch. */
|
|
2
|
+
export function definitionDiff(saved, generated) {
|
|
3
|
+
const before = saved.split('\n');
|
|
4
|
+
const after = generated.split('\n');
|
|
5
|
+
let start = 0;
|
|
6
|
+
while (start < before.length && start < after.length && before[start] === after[start]) {
|
|
7
|
+
start++;
|
|
8
|
+
}
|
|
9
|
+
if (start === before.length && start === after.length)
|
|
10
|
+
return '';
|
|
11
|
+
let beforeEnd = before.length;
|
|
12
|
+
let afterEnd = after.length;
|
|
13
|
+
while (beforeEnd > start && afterEnd > start && before[beforeEnd - 1] === after[afterEnd - 1]) {
|
|
14
|
+
beforeEnd--;
|
|
15
|
+
afterEnd--;
|
|
16
|
+
}
|
|
17
|
+
function excerpt(lines, end, marker) {
|
|
18
|
+
const shown = lines.slice(start, Math.min(end, start + 8));
|
|
19
|
+
const output = shown.map((line) => `${marker} ${line.length > 200 ? line.slice(0, 200) + '…' : line}`);
|
|
20
|
+
if (end - start > shown.length) {
|
|
21
|
+
output.push(` … ${end - start - shown.length} more ${marker} lines omitted`);
|
|
22
|
+
}
|
|
23
|
+
return output;
|
|
24
|
+
}
|
|
25
|
+
return [
|
|
26
|
+
`Diff excerpt from line ${start + 1} (- saved, + generated):`,
|
|
27
|
+
...excerpt(before, beforeEnd, '-'),
|
|
28
|
+
...excerpt(after, afterEnd, '+'),
|
|
29
|
+
].join('\n');
|
|
30
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Every generated table remains accessible through Rows["exact SQL name"].
|
|
2
|
+
const reservedTypeNames = new Set(`
|
|
3
|
+
as await break case catch class const continue debugger default delete do else enum
|
|
4
|
+
export extends false finally for function if implements import in instanceof
|
|
5
|
+
interface let new null package private protected public return static super
|
|
6
|
+
switch this throw true try typeof var void while with yield any bigint boolean
|
|
7
|
+
intrinsic never number object string symbol undefined unknown
|
|
8
|
+
Rows globalThis PgArray
|
|
9
|
+
`
|
|
10
|
+
.trim()
|
|
11
|
+
.split(/\s+/));
|
|
12
|
+
function canExportRowName(name) {
|
|
13
|
+
return (/^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*$/u.test(name) && !reservedTypeNames.has(name));
|
|
14
|
+
}
|
|
15
|
+
// A string means the read and write representations match. Arrays use verified
|
|
16
|
+
// built-in OIDs because their parsers can differ from scalar parsers (numeric[]).
|
|
17
|
+
const arrayTypes = {
|
|
18
|
+
'1000': 'boolean',
|
|
19
|
+
'1001': 'import("node:buffer").Buffer',
|
|
20
|
+
'1005': 'number',
|
|
21
|
+
'1007': 'number',
|
|
22
|
+
'1021': 'number',
|
|
23
|
+
'1022': 'number',
|
|
24
|
+
'1028': 'number',
|
|
25
|
+
'1016': { read: 'string', write: 'string | bigint' },
|
|
26
|
+
'1231': { read: 'number', write: 'string | number' },
|
|
27
|
+
'1009': 'string',
|
|
28
|
+
'1014': 'string',
|
|
29
|
+
'1015': 'string',
|
|
30
|
+
'1183': 'string',
|
|
31
|
+
'1270': 'string',
|
|
32
|
+
'2951': 'string',
|
|
33
|
+
'1182': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
34
|
+
'1115': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
35
|
+
'1185': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
36
|
+
'199': { read: 'unknown', write: 'string' },
|
|
37
|
+
'3807': { read: 'unknown', write: 'string' },
|
|
38
|
+
};
|
|
39
|
+
const scalarTypes = {
|
|
40
|
+
int2: 'number',
|
|
41
|
+
int4: 'number',
|
|
42
|
+
oid: 'number',
|
|
43
|
+
float4: 'number',
|
|
44
|
+
float8: 'number',
|
|
45
|
+
bool: 'boolean',
|
|
46
|
+
text: 'string',
|
|
47
|
+
name: 'string',
|
|
48
|
+
varchar: 'string',
|
|
49
|
+
bpchar: 'string',
|
|
50
|
+
time: 'string',
|
|
51
|
+
timetz: 'string',
|
|
52
|
+
uuid: 'string',
|
|
53
|
+
// pg returns +/-Infinity as numbers. TypeScript has no infinity literal type.
|
|
54
|
+
date: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
55
|
+
timestamp: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
56
|
+
timestamptz: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
57
|
+
int8: { read: 'string', write: 'string | bigint' },
|
|
58
|
+
numeric: { read: 'string', write: 'string | number' },
|
|
59
|
+
json: { read: 'unknown', write: 'string' },
|
|
60
|
+
jsonb: { read: 'unknown', write: 'string' },
|
|
61
|
+
bytea: 'import("node:buffer").Buffer',
|
|
62
|
+
};
|
|
63
|
+
function valueTypes(mapping) {
|
|
64
|
+
return typeof mapping === 'string' ? { read: mapping, write: mapping } : mapping;
|
|
65
|
+
}
|
|
66
|
+
function isSupportedArray(value) {
|
|
67
|
+
return Object.hasOwn(arrayTypes, value.oid);
|
|
68
|
+
}
|
|
69
|
+
function columnTypes(value, column, onWarning) {
|
|
70
|
+
let types;
|
|
71
|
+
if (isSupportedArray(value)) {
|
|
72
|
+
const element = valueTypes(arrayTypes[value.oid]);
|
|
73
|
+
types = { read: `PgArray<${element.read}>`, write: `PgArray<${element.write}>` };
|
|
74
|
+
}
|
|
75
|
+
else if (value.typeKind === 'e') {
|
|
76
|
+
const choice = value.choices?.map((choice) => JSON.stringify(choice)).join(' | ') || 'never';
|
|
77
|
+
types = { read: choice, write: choice };
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const type = value.typeSchema === 'pg_catalog' && Object.hasOwn(scalarTypes, value.type)
|
|
81
|
+
? scalarTypes[value.type]
|
|
82
|
+
: undefined;
|
|
83
|
+
if (type) {
|
|
84
|
+
types = valueTypes(type);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const underlying = value.declaredOid === value.oid
|
|
88
|
+
? ''
|
|
89
|
+
: ` with underlying type ${JSON.stringify(value.typeSchema)}.${JSON.stringify(value.type)} (OID ${value.oid})`;
|
|
90
|
+
onWarning(`Column ${column} uses PostgreSQL type ${JSON.stringify(value.declaredSchema)}.${JSON.stringify(value.declaredType)} (OID ${value.declaredOid})${underlying} without a verified pg mapping; generated as unknown for reads and never for non-null writes.`);
|
|
91
|
+
types = { read: 'unknown', write: 'never' };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// unknown already includes null, including for nullable JSON and unmapped types.
|
|
95
|
+
return {
|
|
96
|
+
read: `${types.read}${value.nullable && types.read !== 'unknown' ? ' | null' : ''}`,
|
|
97
|
+
write: `${types.write}${value.writeNullable ? ' | null' : ''}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export function renderTableTypes(tables, namespace, onWarning) {
|
|
101
|
+
const definitions = tables.map((table) => {
|
|
102
|
+
const tableName = JSON.stringify(table.name);
|
|
103
|
+
const columns = table.columns.map((column) => {
|
|
104
|
+
const columnName = JSON.stringify(column.name);
|
|
105
|
+
const path = `${JSON.stringify(namespace)}.${tableName}.${columnName}`;
|
|
106
|
+
const types = columnTypes(column, path, onWarning);
|
|
107
|
+
const requiredOnInsert = column.writable && !column.hasDefault && !column.writeNullable;
|
|
108
|
+
const optionalInputType = column.writable ? `${types.write} | undefined` : 'never';
|
|
109
|
+
return {
|
|
110
|
+
row: ` ${columnName}: ${types.read};`,
|
|
111
|
+
insert: ` ${columnName}${requiredOnInsert ? '' : '?'}: ${requiredOnInsert ? types.write : optionalInputType};`,
|
|
112
|
+
update: ` ${columnName}?: ${optionalInputType};`,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
return { tableName, columns };
|
|
116
|
+
});
|
|
117
|
+
const render = (kind, indent = '') => definitions
|
|
118
|
+
.map(({ tableName, columns }) => `${indent} ${tableName}: {
|
|
119
|
+
${columns.map((column) => indent + column[kind]).join('\n')}
|
|
120
|
+
${indent} };`)
|
|
121
|
+
.join('\n');
|
|
122
|
+
const aliases = tables
|
|
123
|
+
.filter((table) => canExportRowName(table.name))
|
|
124
|
+
.map((table) => `export type ${table.name} = Rows[${JSON.stringify(table.name)}];`);
|
|
125
|
+
const arrays = tables.some((table) => table.columns.some(isSupportedArray))
|
|
126
|
+
? 'export type PgArray<T> = (T | null | PgArray<T>)[];\n\n'
|
|
127
|
+
: '';
|
|
128
|
+
return `${arrays}// Full database rows. Compose query types with Pick, indexed access, and intersections.
|
|
129
|
+
export type Rows = {
|
|
130
|
+
${render('row')}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export declare namespace Rows {
|
|
134
|
+
// Bound inputs for sql.insertValues. Missing/undefined fields use database defaults.
|
|
135
|
+
type Insert = {
|
|
136
|
+
${render('insert', ' ')}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Bound inputs for sql.assignments. Missing/undefined fields are omitted.
|
|
140
|
+
type Update = {
|
|
141
|
+
${render('update', ' ')}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
${aliases.join('\n')}
|
|
145
|
+
`;
|
|
146
|
+
}
|
package/dist/mutations.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type MutationPart = string | Readonly<{
|
|
2
2
|
value: unknown;
|
|
3
3
|
}>;
|
|
4
|
-
export type MutationColumns<
|
|
4
|
+
export type MutationColumns<Input> = Input extends object ? Extract<keyof Input, string> : never;
|
|
5
5
|
/** Validate and quote one explicit column list, shared by runtime and SQL checking. */
|
|
6
6
|
export declare function mutationColumns(columns: readonly string[]): string[];
|
|
7
7
|
/** SQL structure depends only on these columns and cells, never on JavaScript source. */
|
package/dist/query.d.ts
CHANGED
|
@@ -21,9 +21,9 @@ export interface SqlFragment {
|
|
|
21
21
|
declare function fragment(strings: TemplateStringsArray, ...values: readonly unknown[]): SqlFragment;
|
|
22
22
|
declare function join(fragments: readonly SqlFragment[], separator: 'AND' | 'OR' | ','): SqlFragment;
|
|
23
23
|
/** Object fields become bound values; absent insert cells use PostgreSQL defaults. */
|
|
24
|
-
declare function insertValues<
|
|
24
|
+
declare function insertValues<Input extends object>(input: Input | readonly Input[], columns?: readonly NoInfer<MutationColumns<Input>>[]): SqlFragment;
|
|
25
25
|
/** Omit undefined fields; null remains a value. Empty assignments reject. */
|
|
26
|
-
declare function assignments<
|
|
26
|
+
declare function assignments<Input extends object>(input: Input, columns?: readonly NoInfer<MutationColumns<Input>>[]): SqlFragment;
|
|
27
27
|
declare function createQuery<Row extends QueryResultRow = UnknownRow>(strings: TemplateStringsArray, ...values: readonly unknown[]): NoInfer<SqlQuery<Row>>;
|
|
28
28
|
/** Values bind parameters; identifiers quote names; fragments compose authored SQL. */
|
|
29
29
|
export declare const sql: typeof createQuery & {
|
package/dist/query.js
CHANGED
|
@@ -111,7 +111,7 @@ export function unsafeSql(text, values) {
|
|
|
111
111
|
if (!Array.isArray(values))
|
|
112
112
|
throw new Error('SQL input must be an explicit array.');
|
|
113
113
|
if (values.length > 65535)
|
|
114
|
-
throw new Error(
|
|
114
|
+
throw new Error(`A PostgreSQL query supports at most 65535 bound parameters; received ${values.length}. Split large batches explicitly.`);
|
|
115
115
|
return Object.freeze({ [queryTag]: true, text, values: Object.freeze([...values]) });
|
|
116
116
|
}
|
|
117
117
|
export function assertQuery(value) {
|
|
@@ -106,6 +106,15 @@ export function renderFragment(node, checker, seen = new Set()) {
|
|
|
106
106
|
}
|
|
107
107
|
return list.elements.map((item) => renderFragment(item, checker, visited)).join(between);
|
|
108
108
|
}
|
|
109
|
+
if (ts.isCallExpression(node)) {
|
|
110
|
+
throw new CompositionError(`Function-generated fragments from ${node.expression.getText()}() are not analyzed; the checker does not inspect function bodies.`);
|
|
111
|
+
}
|
|
112
|
+
if (ts.isConditionalExpression(node)) {
|
|
113
|
+
throw new CompositionError('Conditional fragments are not analyzed; the checker does not choose JavaScript branches, even for literal conditions.');
|
|
114
|
+
}
|
|
115
|
+
if (ts.isIdentifier(node)) {
|
|
116
|
+
throw new CompositionError(`Fragment "${node.text}" has no inspectable const initializer; mutable and runtime-supplied fragments are not analyzed.`);
|
|
117
|
+
}
|
|
109
118
|
throw new CompositionError('Only constant sql.fragment templates, literal sql.join arrays, and official mutation helpers are supported. Function-generated fragments and conditional SQL structure are not analyzed.');
|
|
110
119
|
}
|
|
111
120
|
export function renderIdentifier(node, checker, seen = new Set()) {
|
package/dist/safeql/eslint.js
CHANGED
|
@@ -22,21 +22,26 @@ export const eslintPlugin = {
|
|
|
22
22
|
!ts.isTaggedTemplateExpression(template) ||
|
|
23
23
|
!isExport(template.tag, checker, 'sql'))
|
|
24
24
|
return;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
if (node.quasi.quasis.some(({ value }) => value.raw !== value.cooked)) {
|
|
26
|
+
context.report({
|
|
27
|
+
node,
|
|
28
|
+
message: new CompositionError('JavaScript escapes in an outer sql template would be checked as different SQL. Use literal newlines and bound string values instead.').message,
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (ts.isTemplateExpression(template.template)) {
|
|
33
|
+
for (const [index, span] of template.template.templateSpans.entries()) {
|
|
34
|
+
try {
|
|
31
35
|
renderValue(span.expression, checker);
|
|
32
36
|
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (!(error instanceof CompositionError))
|
|
39
|
+
throw error;
|
|
40
|
+
context.report({ node: node.quasi.expressions[index], message: error.message });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
33
43
|
}
|
|
34
44
|
}
|
|
35
|
-
catch (error) {
|
|
36
|
-
if (!(error instanceof CompositionError))
|
|
37
|
-
throw error;
|
|
38
|
-
context.report({ node, message: error.message });
|
|
39
|
-
}
|
|
40
45
|
},
|
|
41
46
|
};
|
|
42
47
|
},
|
package/docs/adoption-guide.md
CHANGED
|
@@ -11,10 +11,10 @@ queries or generate types; keep the existing migration runner if that is the int
|
|
|
11
11
|
1. Locate the old ORM's connection setup, schema, migrations, and call sites. Identify behavior
|
|
12
12
|
supplied by the ORM: validation, IDs, timestamps, hooks, defaults, relation loading,
|
|
13
13
|
serialization, and transaction boundaries.
|
|
14
|
-
2. If
|
|
14
|
+
2. If row and mutation input types will help your application, generate them from the existing database:
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
|
-
npx tenon generate --out src/generated-schema.ts
|
|
17
|
+
npx tenon generate --out src/generated-schema.d.ts
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
Use `--namespace name` for a non-public schema. Generation reads the actual database and requires
|
|
@@ -25,14 +25,107 @@ queries or generate types; keep the existing migration runner if that is the int
|
|
|
25
25
|
Generated types describe pg values: check bigint/numeric strings, dates, JSON, and arrays when
|
|
26
26
|
the old ORM transformed them.
|
|
27
27
|
4. If using optional SQL checking, configure the
|
|
28
|
-
[SafeQL integration](reference.md#sql-checking-with-safeql)
|
|
29
|
-
|
|
28
|
+
[SafeQL integration](reference.md#sql-checking-with-safeql) and follow the
|
|
29
|
+
[incremental audit workflow](#adopt-sql-checking-incrementally) below before accepting generated
|
|
30
|
+
annotations. Run tests against a dedicated test database. Verify
|
|
30
31
|
writes and rollback, relationships, validation, and any hooks you moved into application code or
|
|
31
32
|
SQL. A schema-preserving first release makes application rollback easier.
|
|
32
33
|
5. Remove the old runtime dependency and unused generated code after its callers are gone. Keep the
|
|
33
34
|
migration tooling until the migration transition below is complete, if that transition is part of
|
|
34
35
|
the work.
|
|
35
36
|
|
|
37
|
+
## Replace local write helpers
|
|
38
|
+
|
|
39
|
+
Use `sql.insertValues(rows)` and `sql.assignments(patch)` in place of local object-to-SQL utilities:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { sql } from '@kequtech/tenon-orm';
|
|
43
|
+
|
|
44
|
+
const insert = sql`INSERT INTO profiles ${sql.insertValues(rows)} RETURNING id`;
|
|
45
|
+
const update = sql`UPDATE profiles SET ${sql.assignments(patch)} WHERE id = ${id}`;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Compare omission, defaults, nulls, and empty-input behavior with the old helpers. Keep expressions
|
|
49
|
+
in SQL and remove the local utilities after their callers move. Generated `Rows.Insert` and
|
|
50
|
+
`Rows.Update` types can replace handwritten table inputs; retain application-specific refinements
|
|
51
|
+
and validation. Run the existing write and rollback workflows. See the [mutation cookbook](mutations.md).
|
|
52
|
+
|
|
53
|
+
## Adopt SQL checking incrementally
|
|
54
|
+
|
|
55
|
+
For an existing application, including Next.js, SQL checking can run as a separate audit while the
|
|
56
|
+
normal lint/build commands keep their existing database requirements. Extend the existing flat
|
|
57
|
+
ESLint configuration rather than replacing it. After installing the
|
|
58
|
+
[checking dependencies](reference.md#install-and-configure), a single-database setup can use:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
// eslint.sql.config.mjs
|
|
62
|
+
import { defineConfig } from 'eslint/config';
|
|
63
|
+
import baseConfig from './eslint.config.mjs';
|
|
64
|
+
import safeql from '@ts-safeql/eslint-plugin/config';
|
|
65
|
+
import tseslint from 'typescript-eslint';
|
|
66
|
+
import { safeqlPlugin, eslintPlugin } from '@kequtech/tenon-orm/safeql';
|
|
67
|
+
|
|
68
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
69
|
+
if (!databaseUrl) throw new Error('DATABASE_URL is required for SQL checking');
|
|
70
|
+
|
|
71
|
+
export default defineConfig(baseConfig, {
|
|
72
|
+
files: ['src/**/*.ts', 'src/**/*.tsx'],
|
|
73
|
+
languageOptions: {
|
|
74
|
+
parser: tseslint.parser,
|
|
75
|
+
parserOptions: { projectService: true },
|
|
76
|
+
},
|
|
77
|
+
plugins: { tenon: eslintPlugin },
|
|
78
|
+
rules: { 'tenon/check-composition': 'error' },
|
|
79
|
+
extends: [
|
|
80
|
+
safeql.configs.connections({
|
|
81
|
+
databaseUrl,
|
|
82
|
+
plugins: [safeqlPlugin],
|
|
83
|
+
targets: [{ tag: { regex: '.*' } }],
|
|
84
|
+
}),
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Preserve existing type-aware parser settings where configured. The broad target above lets Tenon
|
|
90
|
+
recognize aliases; use distinct targets for [multiple databases](reference.md#configuration-choices-and-multiple-databases).
|
|
91
|
+
The example does not load environment files. Use the application's existing environment loader
|
|
92
|
+
and URL selection to supply its migrated development/test database through `DATABASE_URL`.
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"scripts": {
|
|
97
|
+
"lint:sql": "eslint --config eslint.sql.config.mjs \"src/**/*.{ts,tsx}\""
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Start with `npm run lint:sql`. Keep both checking rules enabled and distinguish the findings:
|
|
103
|
+
|
|
104
|
+
- SQL errors identify problems in reconstructed SQL; review them before accepting inferred types.
|
|
105
|
+
- Missing/mismatched annotations may reflect an outdated type, a deliberate application refinement,
|
|
106
|
+
or a checker limitation. They are not all demonstrated application bugs.
|
|
107
|
+
- `tenon/check-composition` means SQL analysis was skipped. Add inline selected columns for opaque
|
|
108
|
+
mutation inputs where useful. Keep reusable authorization policy centralized when its fragments
|
|
109
|
+
remain unsupported, with database tests for runtime variants.
|
|
110
|
+
|
|
111
|
+
Preview fixes on a small module first, without writing source. Invoke ESLint directly to select
|
|
112
|
+
one file, since the script above already supplies a source glob:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
npx eslint --config eslint.sql.config.mjs src/database/profiles.ts --fix-dry-run --format json
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Review the proposed annotations, apply selected changes deliberately, and run TypeScript and
|
|
119
|
+
database tests. Broader inferred types may require validation where raw database values enter the
|
|
120
|
+
application. For example, require a finite `Date` before calling `.getTime()`, validate structured
|
|
121
|
+
JSON from `unknown`, and justify CHECK-constrained string refinements with matching schema checks
|
|
122
|
+
or a reviewed column override. Do not globally map timestamps to `Date`: pg can return numeric
|
|
123
|
+
infinities. See [checker mappings](reference.md#supported-sql-and-limitations).
|
|
124
|
+
|
|
125
|
+
Unsupported queries have not had their SQL verified. Keep database tests for their runtime variants
|
|
126
|
+
and use explained suppressions only where appropriate. Once the intended scope passes, make SQL
|
|
127
|
+
checking a required CI step. Fix commands can also apply other enabled ESLint rules.
|
|
128
|
+
|
|
36
129
|
## Move migration ownership to Tenon
|
|
37
130
|
|
|
38
131
|
Choose one final schema checkpoint for the old runner and bring existing target databases to it.
|