@kequtech/tenon-orm 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -9
- package/dist/generate.js +2 -2
- package/dist/generation/catalog.d.ts +3 -0
- package/dist/generation/catalog.js +5 -2
- package/dist/generation/table-types.d.ts +2 -0
- package/dist/generation/table-types.js +146 -0
- package/dist/mutations.d.ts +11 -0
- package/dist/mutations.js +87 -0
- package/dist/query.d.ts +7 -0
- package/dist/query.js +22 -3
- package/dist/safeql/composition.d.ts +1 -4
- package/dist/safeql/composition.js +19 -45
- package/dist/safeql/eslint.js +16 -11
- package/dist/safeql/mutations.d.ts +3 -0
- package/dist/safeql/mutations.js +74 -0
- package/dist/safeql/static.d.ts +10 -0
- package/dist/safeql/static.js +59 -0
- package/docs/adoption-guide.md +97 -4
- package/docs/agent-guide.md +39 -36
- package/docs/human-guide.md +33 -10
- package/docs/mutations.md +157 -0
- package/docs/reference.md +206 -51
- package/package.json +1 -1
- package/dist/generation/row-types.d.ts +0 -2
- package/dist/generation/row-types.js +0 -115
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
|
|
@@ -64,6 +64,14 @@ migrations and table type generation are independent conveniences.
|
|
|
64
64
|
>
|
|
65
65
|
> — Codex, after working on Tenon. An interested party, to put it mildly.
|
|
66
66
|
|
|
67
|
+
## Object-based writes
|
|
68
|
+
|
|
69
|
+
For object-based writes, `sql.insertValues(rows)` preserves database defaults across heterogeneous
|
|
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#object-based-mutation-fragments).
|
|
72
|
+
Use [generated input types](docs/reference.md#insert-and-update-inputs) to check those objects
|
|
73
|
+
against your schema.
|
|
74
|
+
|
|
67
75
|
## Checking query results
|
|
68
76
|
|
|
69
77
|
The type in `sql<{ id: number; email: string }>` describes what you expect back. TypeScript alone
|
|
@@ -73,12 +81,10 @@ For that, Tenon provides an optional integration with [SafeQL](https://safeql.de
|
|
|
73
81
|
SQL against your development database and can write or repair the result annotation. ESLint runs the
|
|
74
82
|
check during development and CI; it is not part of query execution.
|
|
75
83
|
|
|
76
|
-
Configure SafeQL in your application's ESLint setup
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
[SQL checking setup](docs/reference.md#sql-checking-with-safeql) for installation and a complete
|
|
81
|
-
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).
|
|
82
88
|
|
|
83
89
|
Checking complements typed inputs and database tests. It does not validate request values or
|
|
84
90
|
arbitrary JSON, and runtime-built query variants need their own tests.
|
|
@@ -91,7 +97,9 @@ arbitrary JSON, and runtime-built query variants need their own tests.
|
|
|
91
97
|
- **[Reference](docs/reference.md)** — complete options, SQL checking setup, type mappings, and
|
|
92
98
|
limitations.
|
|
93
99
|
- **[Adoption guide](docs/adoption-guide.md)** — replace an existing ORM and, if needed, transfer
|
|
94
|
-
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.
|
|
95
103
|
|
|
96
104
|
The complete `docs/` folder ships with the npm package. Maintainer instructions are in
|
|
97
105
|
[CONTRIBUTING.md](https://github.com/kequtech/tenon-orm/blob/main/CONTRIBUTING.md), which is not
|
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,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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type MutationPart = string | Readonly<{
|
|
2
|
+
value: unknown;
|
|
3
|
+
}>;
|
|
4
|
+
export type MutationColumns<Input> = Input extends object ? Extract<keyof Input, string> : never;
|
|
5
|
+
/** Validate and quote one explicit column list, shared by runtime and SQL checking. */
|
|
6
|
+
export declare function mutationColumns(columns: readonly string[]): string[];
|
|
7
|
+
/** SQL structure depends only on these columns and cells, never on JavaScript source. */
|
|
8
|
+
export declare function insertLayout(columns: readonly string[], rows: readonly (readonly unknown[])[]): MutationPart[];
|
|
9
|
+
export declare function assignmentLayout(columns: readonly string[], values: readonly unknown[]): MutationPart[];
|
|
10
|
+
export declare function insertParts(input: object | readonly object[], selected?: readonly string[]): MutationPart[];
|
|
11
|
+
export declare function assignmentParts(input: object, selected?: readonly string[]): MutationPart[];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { quote } from './sql.js';
|
|
2
|
+
import { plainObject } from './validation.js';
|
|
3
|
+
/** Validate and quote one explicit column list, shared by runtime and SQL checking. */
|
|
4
|
+
export function mutationColumns(columns) {
|
|
5
|
+
if (!Array.isArray(columns) || !columns.length)
|
|
6
|
+
throw new Error('Mutation columns must be a nonempty array.');
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
return Array.from(columns, (column) => {
|
|
9
|
+
if (typeof column !== 'string')
|
|
10
|
+
throw new Error('Mutation column names must be strings.');
|
|
11
|
+
quote(column);
|
|
12
|
+
if (seen.has(column))
|
|
13
|
+
throw new Error(`Duplicate mutation column ${JSON.stringify(column)}.`);
|
|
14
|
+
seen.add(column);
|
|
15
|
+
return column;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** SQL structure depends only on these columns and cells, never on JavaScript source. */
|
|
19
|
+
export function insertLayout(columns, rows) {
|
|
20
|
+
if (!rows.length)
|
|
21
|
+
throw new Error('sql.insertValues requires at least one row.');
|
|
22
|
+
if (!columns.length) {
|
|
23
|
+
if (rows.length !== 1)
|
|
24
|
+
throw new Error('Default-only inserts require exactly one row.');
|
|
25
|
+
return ['DEFAULT VALUES'];
|
|
26
|
+
}
|
|
27
|
+
const parts = [`(${columns.map(quote).join(', ')}) VALUES `];
|
|
28
|
+
for (const [index, row] of rows.entries()) {
|
|
29
|
+
if (index)
|
|
30
|
+
parts.push(', ');
|
|
31
|
+
parts.push('(');
|
|
32
|
+
for (const [column, value] of row.entries()) {
|
|
33
|
+
if (column)
|
|
34
|
+
parts.push(', ');
|
|
35
|
+
parts.push(value === undefined ? 'DEFAULT' : Object.freeze({ value }));
|
|
36
|
+
}
|
|
37
|
+
parts.push(')');
|
|
38
|
+
}
|
|
39
|
+
return parts;
|
|
40
|
+
}
|
|
41
|
+
export function assignmentLayout(columns, values) {
|
|
42
|
+
const parts = [];
|
|
43
|
+
for (const [index, column] of columns.entries()) {
|
|
44
|
+
const value = values[index];
|
|
45
|
+
if (value === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
if (parts.length)
|
|
48
|
+
parts.push(', ');
|
|
49
|
+
parts.push(`${quote(column)} = `, Object.freeze({ value }));
|
|
50
|
+
}
|
|
51
|
+
if (!parts.length)
|
|
52
|
+
throw new Error('sql.assignments requires at least one defined field.');
|
|
53
|
+
return parts;
|
|
54
|
+
}
|
|
55
|
+
function record(value, helper, index) {
|
|
56
|
+
return plainObject(value, `${helper} row ${index + 1}`);
|
|
57
|
+
}
|
|
58
|
+
function cell(row, column, helper, index) {
|
|
59
|
+
const property = Object.getOwnPropertyDescriptor(row, column);
|
|
60
|
+
if (!property)
|
|
61
|
+
return undefined;
|
|
62
|
+
if (!('value' in property))
|
|
63
|
+
throw new Error(`${helper} row ${index + 1} column ${JSON.stringify(column)} must be a data property.`);
|
|
64
|
+
const value = property.value;
|
|
65
|
+
if (value &&
|
|
66
|
+
typeof value === 'object' &&
|
|
67
|
+
['fragment', 'identifier', 'query'].some((kind) => Symbol.for(`@kequtech/tenon-orm/${kind}`) in value)) {
|
|
68
|
+
throw new Error(`${helper} row ${index + 1} column ${JSON.stringify(column)} expects a bound value; put SQL expressions in the surrounding statement.`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
export function insertParts(input, selected) {
|
|
73
|
+
const rows = Array.from(Array.isArray(input) ? input : [input], (row, index) => record(row, 'sql.insertValues', index));
|
|
74
|
+
if (!rows.length)
|
|
75
|
+
throw new Error('sql.insertValues requires at least one row.');
|
|
76
|
+
const columns = selected === undefined
|
|
77
|
+
? [
|
|
78
|
+
...new Set(rows.flatMap((row, index) => Object.keys(row).filter((column) => cell(row, column, 'sql.insertValues', index) !== undefined))),
|
|
79
|
+
]
|
|
80
|
+
: mutationColumns(selected);
|
|
81
|
+
return insertLayout(columns, rows.map((row, index) => columns.map((column) => cell(row, column, 'sql.insertValues', index))));
|
|
82
|
+
}
|
|
83
|
+
export function assignmentParts(input, selected) {
|
|
84
|
+
const row = record(input, 'sql.assignments', 0);
|
|
85
|
+
const columns = selected === undefined ? Object.keys(row) : mutationColumns(selected);
|
|
86
|
+
return assignmentLayout(columns, columns.map((column) => cell(row, column, 'sql.assignments', 0)));
|
|
87
|
+
}
|
package/dist/query.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { QueryResultRow } from 'pg';
|
|
2
|
+
import { type MutationColumns } from './mutations.js';
|
|
2
3
|
export type UnknownRow = Record<string, unknown>;
|
|
3
4
|
/** A parameterized statement. The row type describes one returned record. */
|
|
4
5
|
export interface SqlQuery<Row extends QueryResultRow = UnknownRow> {
|
|
@@ -19,11 +20,17 @@ export interface SqlFragment {
|
|
|
19
20
|
}
|
|
20
21
|
declare function fragment(strings: TemplateStringsArray, ...values: readonly unknown[]): SqlFragment;
|
|
21
22
|
declare function join(fragments: readonly SqlFragment[], separator: 'AND' | 'OR' | ','): SqlFragment;
|
|
23
|
+
/** Object fields become bound values; absent insert cells use PostgreSQL defaults. */
|
|
24
|
+
declare function insertValues<Input extends object>(input: Input | readonly Input[], columns?: readonly NoInfer<MutationColumns<Input>>[]): SqlFragment;
|
|
25
|
+
/** Omit undefined fields; null remains a value. Empty assignments reject. */
|
|
26
|
+
declare function assignments<Input extends object>(input: Input, columns?: readonly NoInfer<MutationColumns<Input>>[]): SqlFragment;
|
|
22
27
|
declare function createQuery<Row extends QueryResultRow = UnknownRow>(strings: TemplateStringsArray, ...values: readonly unknown[]): NoInfer<SqlQuery<Row>>;
|
|
23
28
|
/** Values bind parameters; identifiers quote names; fragments compose authored SQL. */
|
|
24
29
|
export declare const sql: typeof createQuery & {
|
|
25
30
|
fragment: typeof fragment;
|
|
26
31
|
join: typeof join;
|
|
32
|
+
insertValues: typeof insertValues;
|
|
33
|
+
assignments: typeof assignments;
|
|
27
34
|
};
|
|
28
35
|
/** Escape hatch for externally authored SQL. Never concatenate application input into text. */
|
|
29
36
|
export declare function unsafeSql<Row extends QueryResultRow = UnknownRow>(text: string, values: readonly unknown[]): NoInfer<SqlQuery<Row>>;
|
package/dist/query.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { joinSeparator, quote } from './sql.js';
|
|
2
|
+
import { assignmentParts, insertParts, } from './mutations.js';
|
|
2
3
|
// Shared symbols let separately installed copies exchange queries and identifiers.
|
|
3
4
|
const queryTag = Symbol.for('@kequtech/tenon-orm/query');
|
|
4
5
|
const identifierTag = Symbol.for('@kequtech/tenon-orm/identifier');
|
|
@@ -30,7 +31,8 @@ function templateParts(strings, values) {
|
|
|
30
31
|
const parts = [strings[0]];
|
|
31
32
|
for (const [i, value] of values.entries()) {
|
|
32
33
|
if (value && typeof value === 'object' && fragmentTag in value) {
|
|
33
|
-
|
|
34
|
+
for (const part of fragmentParts(value))
|
|
35
|
+
parts.push(part);
|
|
34
36
|
}
|
|
35
37
|
else if (value &&
|
|
36
38
|
typeof value === 'object' &&
|
|
@@ -67,10 +69,25 @@ function join(fragments, separator) {
|
|
|
67
69
|
for (const [i, item] of fragments.entries()) {
|
|
68
70
|
if (i)
|
|
69
71
|
parts.push(between);
|
|
70
|
-
|
|
72
|
+
for (const part of fragmentParts(item))
|
|
73
|
+
parts.push(part);
|
|
71
74
|
}
|
|
72
75
|
return Object.freeze({ __tenonFragment: true, [fragmentTag]: Object.freeze(parts) });
|
|
73
76
|
}
|
|
77
|
+
/** Object fields become bound values; absent insert cells use PostgreSQL defaults. */
|
|
78
|
+
function insertValues(input, columns) {
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
__tenonFragment: true,
|
|
81
|
+
[fragmentTag]: Object.freeze(insertParts(input, columns)),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** Omit undefined fields; null remains a value. Empty assignments reject. */
|
|
85
|
+
function assignments(input, columns) {
|
|
86
|
+
return Object.freeze({
|
|
87
|
+
__tenonFragment: true,
|
|
88
|
+
[fragmentTag]: Object.freeze(assignmentParts(input, columns)),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
74
91
|
function createQuery(strings, ...values) {
|
|
75
92
|
const parameters = [];
|
|
76
93
|
let text = '';
|
|
@@ -86,13 +103,15 @@ function createQuery(strings, ...values) {
|
|
|
86
103
|
return unsafeSql(text, parameters);
|
|
87
104
|
}
|
|
88
105
|
/** Values bind parameters; identifiers quote names; fragments compose authored SQL. */
|
|
89
|
-
export const sql = Object.assign(createQuery, { fragment, join });
|
|
106
|
+
export const sql = Object.assign(createQuery, { fragment, join, insertValues, assignments });
|
|
90
107
|
/** Escape hatch for externally authored SQL. Never concatenate application input into text. */
|
|
91
108
|
export function unsafeSql(text, values) {
|
|
92
109
|
if (typeof text !== 'string' || !text.trim())
|
|
93
110
|
throw new Error('SQL must be a nonempty string.');
|
|
94
111
|
if (!Array.isArray(values))
|
|
95
112
|
throw new Error('SQL input must be an explicit array.');
|
|
113
|
+
if (values.length > 65535)
|
|
114
|
+
throw new Error(`A PostgreSQL query supports at most 65535 bound parameters; received ${values.length}. Split large batches explicitly.`);
|
|
96
115
|
return Object.freeze({ [queryTag]: true, text, values: Object.freeze([...values]) });
|
|
97
116
|
}
|
|
98
117
|
export function assertQuery(value) {
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
|
-
export
|
|
3
|
-
readonly name = "TenonCompositionError";
|
|
4
|
-
constructor(detail: string);
|
|
5
|
-
}
|
|
2
|
+
export { CompositionError } from './static.js';
|
|
6
3
|
export declare function isExport(node: ts.Node, checker: ts.TypeChecker, name: string): boolean;
|
|
7
4
|
export declare function isFragment(type: ts.Type): boolean;
|
|
8
5
|
/** Reconstruct only immutable fragments and literal join lists. Never execute application code. */
|
|
@@ -2,12 +2,9 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { basename, dirname, join } from 'node:path';
|
|
3
3
|
import ts from 'typescript';
|
|
4
4
|
import { joinSeparator, quote } from '../sql.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
super(`Tenon cannot statically check this composition: ${detail} Use a static query/fragment, or an explicit lint suppression with database tests for every runtime variant.`);
|
|
9
|
-
}
|
|
10
|
-
}
|
|
5
|
+
import { renderMutation } from './mutations.js';
|
|
6
|
+
import { CompositionError, initializer, literalString, unwrap } from './static.js';
|
|
7
|
+
export { CompositionError } from './static.js';
|
|
11
8
|
/** Recognize Tenon's declarations without claiming another library's SQL tags. */
|
|
12
9
|
const tenonModules = new Map();
|
|
13
10
|
function isTenonModule(filename) {
|
|
@@ -46,49 +43,11 @@ export function isExport(node, checker, name) {
|
|
|
46
43
|
isTenonModule(declaration.getSourceFile().fileName));
|
|
47
44
|
});
|
|
48
45
|
}
|
|
49
|
-
function unwrap(node) {
|
|
50
|
-
while (ts.isParenthesizedExpression(node) ||
|
|
51
|
-
ts.isAsExpression(node) ||
|
|
52
|
-
ts.isSatisfiesExpression(node) ||
|
|
53
|
-
ts.isNonNullExpression(node) ||
|
|
54
|
-
ts.isTypeAssertionExpression(node)) {
|
|
55
|
-
node = node.expression;
|
|
56
|
-
}
|
|
57
|
-
return node;
|
|
58
|
-
}
|
|
59
|
-
function initializer(node, checker) {
|
|
60
|
-
let symbol = checker.getSymbolAtLocation(node);
|
|
61
|
-
if (symbol && symbol.flags & ts.SymbolFlags.Alias)
|
|
62
|
-
symbol = checker.getAliasedSymbol(symbol);
|
|
63
|
-
const declaration = symbol?.valueDeclaration;
|
|
64
|
-
if (declaration &&
|
|
65
|
-
ts.isVariableDeclaration(declaration) &&
|
|
66
|
-
ts.isVariableDeclarationList(declaration.parent) &&
|
|
67
|
-
declaration.parent.flags & ts.NodeFlags.Const) {
|
|
68
|
-
return declaration.initializer;
|
|
69
|
-
}
|
|
70
|
-
return undefined;
|
|
71
|
-
}
|
|
72
46
|
export function isFragment(type) {
|
|
73
47
|
return type.isUnion()
|
|
74
48
|
? type.types.some(isFragment)
|
|
75
49
|
: type.getProperty('__tenonFragment') !== undefined;
|
|
76
50
|
}
|
|
77
|
-
/** Follow actual const initializers, never assume a literal type proves a runtime value. */
|
|
78
|
-
function literalString(node, checker) {
|
|
79
|
-
let value = unwrap(node);
|
|
80
|
-
const seen = new Set();
|
|
81
|
-
while (ts.isIdentifier(value) && !seen.has(value)) {
|
|
82
|
-
seen.add(value);
|
|
83
|
-
const next = initializer(value, checker);
|
|
84
|
-
if (!next)
|
|
85
|
-
break;
|
|
86
|
-
value = unwrap(next);
|
|
87
|
-
}
|
|
88
|
-
return ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)
|
|
89
|
-
? value.text
|
|
90
|
-
: undefined;
|
|
91
|
-
}
|
|
92
51
|
/** Reconstruct only immutable fragments and literal join lists. Never execute application code. */
|
|
93
52
|
export function renderFragment(node, checker, seen = new Set()) {
|
|
94
53
|
node = unwrap(node);
|
|
@@ -113,6 +72,12 @@ export function renderFragment(node, checker, seen = new Set()) {
|
|
|
113
72
|
}
|
|
114
73
|
return text;
|
|
115
74
|
}
|
|
75
|
+
if (ts.isCallExpression(node)) {
|
|
76
|
+
for (const kind of ['insertValues', 'assignments']) {
|
|
77
|
+
if (isExport(node.expression, checker, kind))
|
|
78
|
+
return renderMutation(kind, node, checker);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
116
81
|
if (ts.isCallExpression(node) &&
|
|
117
82
|
ts.isPropertyAccessExpression(node.expression) &&
|
|
118
83
|
node.expression.name.text === 'join' &&
|
|
@@ -141,7 +106,16 @@ export function renderFragment(node, checker, seen = new Set()) {
|
|
|
141
106
|
}
|
|
142
107
|
return list.elements.map((item) => renderFragment(item, checker, visited)).join(between);
|
|
143
108
|
}
|
|
144
|
-
|
|
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
|
+
}
|
|
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.');
|
|
145
119
|
}
|
|
146
120
|
export function renderIdentifier(node, checker, seen = new Set()) {
|
|
147
121
|
node = unwrap(node);
|
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
|
},
|