@kequtech/tenon-orm 0.1.0 → 0.1.1
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 +8 -2
- 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 +10 -45
- 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/agent-guide.md +27 -6
- package/docs/human-guide.md +20 -2
- package/docs/mutations.md +115 -0
- package/docs/reference.md +127 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,6 +64,12 @@ 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
|
+
|
|
67
73
|
## Checking query results
|
|
68
74
|
|
|
69
75
|
The type in `sql<{ id: number; email: string }>` describes what you expect back. TypeScript alone
|
|
@@ -74,8 +80,8 @@ SQL against your development database and can write or repair the result annotat
|
|
|
74
80
|
check during development and CI; it is not part of query execution.
|
|
75
81
|
|
|
76
82
|
Configure SafeQL in your application's ESLint setup. Tenon supplies a `safeqlPlugin` adapter for its
|
|
77
|
-
queries
|
|
78
|
-
check. SafeQL handles database analysis and result annotations; Tenon keeps the SQL and pg type
|
|
83
|
+
queries, constant fragments, and official mutation helpers, plus an `eslintPlugin` rule that flags
|
|
84
|
+
queries the adapter cannot check. SafeQL handles database analysis and result annotations; Tenon keeps the SQL and pg type
|
|
79
85
|
mappings consistent with execution. Both integrations are optional development tooling. See
|
|
80
86
|
[SQL checking setup](docs/reference.md#sql-checking-with-safeql) for installation and a complete
|
|
81
87
|
configuration, including multiple databases.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type MutationPart = string | Readonly<{
|
|
2
|
+
value: unknown;
|
|
3
|
+
}>;
|
|
4
|
+
export type MutationColumns<Row> = Row extends object ? Extract<keyof Row, 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<Row extends object>(input: Row | readonly Row[], columns?: readonly NoInfer<MutationColumns<Row>>[]): SqlFragment;
|
|
25
|
+
/** Omit undefined fields; null remains a value. Empty assignments reject. */
|
|
26
|
+
declare function assignments<Row extends object>(input: Row, columns?: readonly NoInfer<MutationColumns<Row>>[]): 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; 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,7 @@ 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
|
-
throw new CompositionError('Only sql.fragment templates,
|
|
109
|
+
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
110
|
}
|
|
146
111
|
export function renderIdentifier(node, checker, seen = new Set()) {
|
|
147
112
|
node = unwrap(node);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { assignmentLayout, insertLayout, mutationColumns } from '../mutations.js';
|
|
3
|
+
import { CompositionError, isUndefined, literalString, unwrap } from './static.js';
|
|
4
|
+
const parameter = 0;
|
|
5
|
+
function fields(input) {
|
|
6
|
+
const node = unwrap(input);
|
|
7
|
+
if (!ts.isObjectLiteralExpression(node)) {
|
|
8
|
+
throw new CompositionError('Mutation inputs need inline object literals or an explicit literal column list.');
|
|
9
|
+
}
|
|
10
|
+
const result = new Map();
|
|
11
|
+
for (const property of node.properties) {
|
|
12
|
+
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) {
|
|
13
|
+
throw new CompositionError('Mutation object spreads, accessors, and methods need an explicit literal column list.');
|
|
14
|
+
}
|
|
15
|
+
const name = property.name;
|
|
16
|
+
if (!ts.isIdentifier(name) && !ts.isStringLiteral(name) && !ts.isNumericLiteral(name)) {
|
|
17
|
+
throw new CompositionError('Mutation keys must be literal names; use an explicit literal column list.');
|
|
18
|
+
}
|
|
19
|
+
if (name.text === '__proto__')
|
|
20
|
+
throw new CompositionError('Prototype-setting object literals are unsupported mutation inputs.');
|
|
21
|
+
if (result.has(name.text))
|
|
22
|
+
throw new CompositionError('Duplicate mutation object keys are unsupported.');
|
|
23
|
+
result.set(name.text, ts.isPropertyAssignment(property) ? property.initializer : property.name);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
/** Understand the official mutation contract, never the implementation of a JavaScript helper. */
|
|
28
|
+
export function renderMutation(kind, node, checker) {
|
|
29
|
+
if (node.questionDotToken ||
|
|
30
|
+
node.arguments.some(ts.isSpreadElement) ||
|
|
31
|
+
node.arguments.length < 1 ||
|
|
32
|
+
node.arguments.length > 2) {
|
|
33
|
+
throw new CompositionError(`sql.${kind} requires an input and an optional literal column list.`);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const [argument, selected] = node.arguments;
|
|
37
|
+
let columns;
|
|
38
|
+
let rows;
|
|
39
|
+
if (selected && !isUndefined(selected, checker)) {
|
|
40
|
+
const list = unwrap(selected);
|
|
41
|
+
if (!ts.isArrayLiteralExpression(list))
|
|
42
|
+
throw new CompositionError('Mutation columns must be an inline literal array.');
|
|
43
|
+
columns = mutationColumns(list.elements.map((item) => {
|
|
44
|
+
const column = literalString(item, checker);
|
|
45
|
+
if (column === undefined)
|
|
46
|
+
throw new CompositionError('Mutation columns must be literal strings or const strings.');
|
|
47
|
+
return column;
|
|
48
|
+
}));
|
|
49
|
+
// A fixed column contract covers any nonempty batch and every omitted/default cell.
|
|
50
|
+
// Check all selected columns conservatively; no runtime data or JS types become SQL.
|
|
51
|
+
rows = [columns.map(() => parameter)];
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const value = unwrap(argument);
|
|
55
|
+
const objects = kind === 'insertValues' && ts.isArrayLiteralExpression(value)
|
|
56
|
+
? value.elements.map(fields)
|
|
57
|
+
: [fields(value)];
|
|
58
|
+
columns = [
|
|
59
|
+
...new Set(objects.flatMap((row) => [...row].filter(([, value]) => !isUndefined(value, checker)).map(([key]) => key))),
|
|
60
|
+
];
|
|
61
|
+
rows = objects.map((row) => columns.map((column) => {
|
|
62
|
+
const cell = row.get(column);
|
|
63
|
+
return !cell || isUndefined(cell, checker) ? undefined : parameter;
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
const parts = kind === 'insertValues' ? insertLayout(columns, rows) : assignmentLayout(columns, rows[0]);
|
|
67
|
+
return parts.map((part) => (typeof part === 'string' ? part : '$N')).join('');
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!(error instanceof Error))
|
|
71
|
+
throw error;
|
|
72
|
+
throw new CompositionError(`sql.${kind}: ${error instanceof CompositionError ? error.detail : error.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export declare class CompositionError extends Error {
|
|
3
|
+
readonly detail: string;
|
|
4
|
+
readonly name = "TenonCompositionError";
|
|
5
|
+
constructor(detail: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function unwrap(node: ts.Node): ts.Node;
|
|
8
|
+
export declare function initializer(node: ts.Identifier, checker: ts.TypeChecker): ts.Expression | undefined;
|
|
9
|
+
export declare function literalString(node: ts.Node, checker: ts.TypeChecker): string | undefined;
|
|
10
|
+
export declare function isUndefined(node: ts.Node, checker: ts.TypeChecker): boolean;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export class CompositionError extends Error {
|
|
3
|
+
detail;
|
|
4
|
+
name = 'TenonCompositionError';
|
|
5
|
+
constructor(detail) {
|
|
6
|
+
super(`Tenon cannot statically check this composition: ${detail} Use a static query/fragment, or an explicit lint suppression with database tests for every runtime variant.`);
|
|
7
|
+
this.detail = detail;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function unwrap(node) {
|
|
11
|
+
while (ts.isParenthesizedExpression(node) ||
|
|
12
|
+
ts.isAsExpression(node) ||
|
|
13
|
+
ts.isSatisfiesExpression(node) ||
|
|
14
|
+
ts.isNonNullExpression(node) ||
|
|
15
|
+
ts.isTypeAssertionExpression(node))
|
|
16
|
+
node = node.expression;
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
export function initializer(node, checker) {
|
|
20
|
+
let symbol = checker.getSymbolAtLocation(node);
|
|
21
|
+
if (symbol && symbol.flags & ts.SymbolFlags.Alias)
|
|
22
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
23
|
+
const declaration = symbol?.valueDeclaration;
|
|
24
|
+
return declaration &&
|
|
25
|
+
ts.isVariableDeclaration(declaration) &&
|
|
26
|
+
ts.isVariableDeclarationList(declaration.parent) &&
|
|
27
|
+
declaration.parent.flags & ts.NodeFlags.Const
|
|
28
|
+
? declaration.initializer
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
/** Follow immutable const aliases only. Never inspect functions or evaluate expressions. */
|
|
32
|
+
function constant(node, checker) {
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
node = unwrap(node);
|
|
35
|
+
while (ts.isIdentifier(node)) {
|
|
36
|
+
if (seen.has(node))
|
|
37
|
+
throw new CompositionError('Circular const reference.');
|
|
38
|
+
if (seen.size >= 1000)
|
|
39
|
+
throw new CompositionError('Const resolution exceeds 1000 references.');
|
|
40
|
+
seen.add(node);
|
|
41
|
+
const value = initializer(node, checker);
|
|
42
|
+
if (!value)
|
|
43
|
+
break;
|
|
44
|
+
node = unwrap(value);
|
|
45
|
+
}
|
|
46
|
+
return node;
|
|
47
|
+
}
|
|
48
|
+
export function literalString(node, checker) {
|
|
49
|
+
const value = constant(node, checker);
|
|
50
|
+
return ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)
|
|
51
|
+
? value.text
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
export function isUndefined(node, checker) {
|
|
55
|
+
const value = constant(node, checker);
|
|
56
|
+
return (ts.isIdentifier(value) &&
|
|
57
|
+
value.text === 'undefined' &&
|
|
58
|
+
!checker.getSymbolAtLocation(value)?.declarations?.length);
|
|
59
|
+
}
|
package/docs/agent-guide.md
CHANGED
|
@@ -108,12 +108,28 @@ const count = sql<{ count: string }>`SELECT count(*) AS count FROM public."User"
|
|
|
108
108
|
|
|
109
109
|
`pattern` and `limit` are validated application inputs. To compose existing predicates, use
|
|
110
110
|
`sql.join([first, second], 'AND')`. The checker supports literal join arrays, constant fragments,
|
|
111
|
-
|
|
112
|
-
runtime-
|
|
111
|
+
literal/const-string separators, and [official mutation helpers](reference.md#object-based-mutation-fragments).
|
|
112
|
+
It rejects mutable or runtime-dependent SQL structure. Prefer fixed SQL for simple optional filters, e.g.
|
|
113
113
|
`(${email}::text IS NULL OR email = ${email})`. If runtime composition is required, retain an
|
|
114
114
|
explicit result type, suppress `tenon/check-composition` only on that query with a reason, and test
|
|
115
115
|
each branch. Parameter binding alone does not establish result correctness.
|
|
116
116
|
|
|
117
|
+
## Object-based writes
|
|
118
|
+
|
|
119
|
+
Use official `sql.insertValues(rowOrRows, columns?)` and `sql.assignments(patch, columns?)` fragments
|
|
120
|
+
instead of application helpers that enumerate object keys. Keep tables, authorization predicates,
|
|
121
|
+
expressions, conflict behavior and `RETURNING` visible in SQL. Insert missing/undefined fields use
|
|
122
|
+
PostgreSQL defaults; update missing/undefined fields are omitted. Explicit null stays null. Empty
|
|
123
|
+
batches/updates throw; a single default-only insert works. Omit generated columns and batch large
|
|
124
|
+
writes explicitly below the query-wide limit of 65,535 parameters. Expressions and queries are
|
|
125
|
+
rejected as cell values; write arithmetic, `DEFAULT`, and `excluded.column` in the surrounding SQL.
|
|
126
|
+
|
|
127
|
+
SafeQL reads inline object/array literals directly. Runtime inputs, spreads and data-builder results
|
|
128
|
+
need explicit inline selected columns, e.g. `sql.insertValues(rows, ['name', 'score'])`. Checking
|
|
129
|
+
uses all selected columns conservatively, without interpreting application JavaScript. It verifies
|
|
130
|
+
SQL and result annotations, not input values, constraints, or nonempty runtime input. See the
|
|
131
|
+
[mutation contract](reference.md#object-based-mutation-fragments) and [cookbook](mutations.md).
|
|
132
|
+
|
|
117
133
|
## Configure and run SQL checking
|
|
118
134
|
|
|
119
135
|
SafeQL checks SQL against the development/test schema and generates or verifies result annotations.
|
|
@@ -205,10 +221,15 @@ the wrong type for an unqualified table despite the connection's `search_path`;
|
|
|
205
221
|
names avoid that ambiguity.
|
|
206
222
|
|
|
207
223
|
The checker recognizes Tenon imports, aliases, and typed object properties. It resolves constant
|
|
208
|
-
fragments and literal/const identifier names without executing application code. Imported
|
|
209
|
-
need inspectable source initializers.
|
|
210
|
-
|
|
211
|
-
|
|
224
|
+
fragments and literal/const identifier names without executing application code. Imported
|
|
225
|
+
fragments need inspectable source initializers. It recognizes the official mutation helpers
|
|
226
|
+
directly; it never inspects function bodies or substitutes helper arguments/defaults. Conditional
|
|
227
|
+
fragments, function-generated fragments, and runtime filter lists require narrow explained
|
|
228
|
+
suppressions and database tests. Ordinary functions containing complete `sql` templates remain
|
|
229
|
+
checkable. Keep reusable authorization policy centralized even when it is test-owned rather than
|
|
230
|
+
statically checked. Use literal newlines and bound string values: outer-template JavaScript escapes
|
|
231
|
+
are rejected because SafeQL reads raw text while Tenon executes decoded text. Bound values and
|
|
232
|
+
constant fragments are decoded consistently.
|
|
212
233
|
|
|
213
234
|
Checking verifies SQL and result annotations, not JavaScript input compatibility, authorization, or
|
|
214
235
|
JSON contents. PostgreSQL owns parameter inference; the checker adds no casts based on JavaScript
|
package/docs/human-guide.md
CHANGED
|
@@ -162,6 +162,24 @@ outside a callback transaction. The guard checks a count; you still need the cor
|
|
|
162
162
|
and application authorization. Checking returned rows with `returns` alone does not undo an
|
|
163
163
|
autocommitted write.
|
|
164
164
|
|
|
165
|
+
## Write objects without building column lists by hand
|
|
166
|
+
|
|
167
|
+
Use `sql.insertValues(rowOrRows)` inside an authored `INSERT` and `sql.assignments(patch)` after
|
|
168
|
+
`SET`. Inserts use database defaults for missing/undefined fields; updates omit those fields.
|
|
169
|
+
Explicit `null` stays null. Empty batches and updates throw. Heterogeneous insert rows remain
|
|
170
|
+
aligned, with `DEFAULT` for missing cells.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
await db.query(sql`INSERT INTO users ${sql.insertValues({ email, active: true })}`);
|
|
174
|
+
await db.query(sql`UPDATE users SET ${sql.assignments({ email, active })} WHERE id = ${id}`);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
These helpers bind values only. Keep expressions, authorization, conflict handling and `RETURNING`
|
|
178
|
+
in SQL. For SafeQL checking of runtime-built objects or batches, supply literal selected columns,
|
|
179
|
+
for example `sql.assignments(patch, ['email', 'active'])`. The checker validates all selected columns
|
|
180
|
+
without inspecting the code that built the patch. See the
|
|
181
|
+
[mutation contract and checking limits](reference.md#object-based-mutation-fragments).
|
|
182
|
+
|
|
165
183
|
## Check your SQL and result types
|
|
166
184
|
|
|
167
185
|
There are three different jobs here:
|
|
@@ -171,8 +189,8 @@ There are three different jobs here:
|
|
|
171
189
|
- SafeQL checks the SQL against your database and verifies or generates those types.
|
|
172
190
|
|
|
173
191
|
SafeQL is optional development tooling run by ESLint. Configure it normally, with Tenon's
|
|
174
|
-
`safeqlPlugin` adapter to translate queries and
|
|
175
|
-
`tenon/check-composition`, flags queries that cannot be checked statically. SafeQL performs the
|
|
192
|
+
`safeqlPlugin` adapter to translate queries, constant fragments, and official mutation helpers. A small
|
|
193
|
+
companion ESLint rule, `tenon/check-composition`, flags queries that cannot be checked statically. SafeQL performs the
|
|
176
194
|
database analysis and annotation fixes. Your application's ESLint configuration imports these tools;
|
|
177
195
|
Tenon's database runtime does not need them. Generated table types are a separate convenience and
|
|
178
196
|
are not required by SafeQL. Follow the one-time
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Mutation cookbook
|
|
2
|
+
|
|
3
|
+
Keep SQL structure visible and let the official helpers handle object fields. See the
|
|
4
|
+
[reference](reference.md#object-based-mutation-fragments) for the complete contract. Examples use
|
|
5
|
+
application-owned inputs and clients; add the authorization predicates required by your product.
|
|
6
|
+
|
|
7
|
+
## Defaults, undefined, and null in batches
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
await db.query(
|
|
11
|
+
sql`INSERT INTO profiles ${sql.insertValues([
|
|
12
|
+
{ name: 'Ada', bio: null },
|
|
13
|
+
{ name: 'Lin', score: 4, bio: undefined },
|
|
14
|
+
])} RETURNING id`,
|
|
15
|
+
);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The first row uses the score default. The second uses the bio default. Explicit null in the first
|
|
19
|
+
row stays null. Columns are aligned across rows. Omit generated columns and let PostgreSQL enforce
|
|
20
|
+
required fields and constraints.
|
|
21
|
+
|
|
22
|
+
For runtime-built rows, supply an inline column list for SafeQL:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
const rows: { name: string; bio?: string | null; score?: number }[] = buildRows();
|
|
26
|
+
await db.query(sql`INSERT INTO profiles ${sql.insertValues(rows, ['name', 'bio', 'score'])}`);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This list selects and orders fields; unlisted fields are ignored. It does not assert that the
|
|
30
|
+
entire input object contains only allowed keys. Keep narrower application input validation.
|
|
31
|
+
|
|
32
|
+
## Patches mixed with SQL expressions
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
await db.query(sql`UPDATE profiles
|
|
36
|
+
SET ${sql.assignments({ name, bio })}, revision = revision + 1, score = DEFAULT
|
|
37
|
+
WHERE id = ${id}`);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Undefined fields are omitted; null clears a field. Expressions, `DEFAULT`, and references to
|
|
41
|
+
`excluded.column` belong in SQL, not in mutation objects.
|
|
42
|
+
|
|
43
|
+
An empty patch throws even if the surrounding statement has other assignments. Choose an
|
|
44
|
+
expression-only query explicitly when that is the intended behavior:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const patch: { name?: string; bio?: string | null } = readPatch();
|
|
48
|
+
if (patch.name !== undefined || patch.bio !== undefined) {
|
|
49
|
+
await db.query(sql`UPDATE profiles
|
|
50
|
+
SET ${sql.assignments(patch, ['name', 'bio'])}, revision = revision + 1
|
|
51
|
+
WHERE id = ${id}`);
|
|
52
|
+
} else {
|
|
53
|
+
await db.query(sql`UPDATE profiles SET revision = revision + 1 WHERE id = ${id}`);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Preserve an omitted field during an upsert
|
|
58
|
+
|
|
59
|
+
Use complete query variants when an option changes conflict behavior. Both templates can be
|
|
60
|
+
checked independently; the checker need not choose a JavaScript branch:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
if (email !== undefined) {
|
|
64
|
+
await db.query(sql`INSERT INTO preferences ${sql.insertValues({ profile_id, in_app, email })}
|
|
65
|
+
ON CONFLICT (profile_id) DO UPDATE
|
|
66
|
+
SET in_app = excluded.in_app, email = excluded.email`);
|
|
67
|
+
} else {
|
|
68
|
+
await db.query(sql`INSERT INTO preferences ${sql.insertValues({ profile_id, in_app })}
|
|
69
|
+
ON CONFLICT (profile_id) DO UPDATE SET in_app = excluded.in_app`);
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The second query leaves an existing email value unchanged. Missing an insert field uses its
|
|
74
|
+
database default; that alone does not decide what an upsert should overwrite.
|
|
75
|
+
|
|
76
|
+
## JSONB arrays and PostgreSQL arrays
|
|
77
|
+
|
|
78
|
+
Tenon preserves pg's value conversion. A JavaScript array binds as a PostgreSQL array. To write an
|
|
79
|
+
array into a JSON/JSONB column, serialize it explicitly:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const handles = ['ada', 'lin'];
|
|
83
|
+
await db.query(
|
|
84
|
+
sql`INSERT INTO deliveries ${sql.insertValues({
|
|
85
|
+
recipients_json: JSON.stringify(handles), // jsonb column
|
|
86
|
+
recipient_handles: handles, // text[] column
|
|
87
|
+
})}`,
|
|
88
|
+
);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Objects used as JSON values retain pg's ordinary JSON conversion. The helper does not infer JSON
|
|
92
|
+
versus array encoding from the table schema. SafeQL's projection checks do not validate stored JSON
|
|
93
|
+
contents or runtime input values.
|
|
94
|
+
|
|
95
|
+
## Batches in one transaction
|
|
96
|
+
|
|
97
|
+
Choose the batch size in the caller and keep all writes on the transaction client:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
await db.transaction(async (tx) => {
|
|
101
|
+
for (let offset = 0; offset < rows.length; offset += 300) {
|
|
102
|
+
const batch = rows.slice(offset, offset + 300);
|
|
103
|
+
await tx.query(sql`INSERT INTO profiles ${sql.insertValues(batch, ['name', 'bio', 'score'])}`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A failure rolls back all batches. The helper rejects an empty batch; this loop makes no insert
|
|
109
|
+
calls for empty input. The query-wide limit is 65,535 bound parameters, including parameters outside
|
|
110
|
+
the mutation fragment. Defaults consume no parameters. Account for your selected column count and
|
|
111
|
+
surrounding query when choosing a batch size; Tenon never splits queries automatically.
|
|
112
|
+
|
|
113
|
+
Reusable authorization fragments remain valid at runtime. Keep shared policy in application
|
|
114
|
+
functions and test it rather than duplicating it for static coverage. Function-generated SQL,
|
|
115
|
+
conditional fragments, and dynamic filter lists remain outside static checking.
|
package/docs/reference.md
CHANGED
|
@@ -131,12 +131,105 @@ const query = sql<{ id: number }>`SELECT id FROM public."User" WHERE ${condition
|
|
|
131
131
|
Each fragment use receives correctly ordered parameters, including repeated and nested uses. SQL
|
|
132
132
|
strings and comments containing literal `$1` text are left intact.
|
|
133
133
|
|
|
134
|
-
Static checking supports constant fragments, literal join arrays,
|
|
135
|
-
separators. Runtime-built lists
|
|
136
|
-
|
|
134
|
+
Static checking supports constant fragments, literal join arrays, literal or const-string
|
|
135
|
+
separators, and [official mutation helpers](#object-based-mutation-fragments). Runtime-built lists
|
|
136
|
+
and selected fragments execute safely with respect to parameter binding, but the checker cannot
|
|
137
|
+
verify their SQL or result types. Prefer fixed SQL when it fits.
|
|
137
138
|
Otherwise keep an explicit result annotation, use a narrow explained lint suppression, and test
|
|
138
139
|
every variant.
|
|
139
140
|
|
|
141
|
+
## Object-based mutation fragments
|
|
142
|
+
|
|
143
|
+
`sql.insertValues(input, columns?)` and `sql.assignments(input, columns?)` return fragments. Keep the
|
|
144
|
+
operation, table, authorization predicate, conflict policy, expressions, and `RETURNING` in SQL:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const created = await db.query(
|
|
148
|
+
sql<{ id: number }>`
|
|
149
|
+
INSERT INTO profiles ${sql.insertValues([
|
|
150
|
+
{ name: 'Ada', bio: null },
|
|
151
|
+
{ name: 'Lin', score: 4 },
|
|
152
|
+
])}
|
|
153
|
+
ON CONFLICT (name) DO NOTHING
|
|
154
|
+
RETURNING id
|
|
155
|
+
`,
|
|
156
|
+
{ returns: 'many' },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
await db.query(sql`
|
|
160
|
+
UPDATE profiles SET ${sql.assignments({ name: input.name, bio: input.bio })},
|
|
161
|
+
revision = revision + 1
|
|
162
|
+
WHERE id = ${id} AND owner_id = ${viewer}
|
|
163
|
+
`);
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Both helpers accept plain objects (including null-prototype objects), quote each column as one
|
|
167
|
+
identifier, and bind cell values as data. Inputs are read when the fragment is created; replacing a
|
|
168
|
+
field afterward does not change its captured value. Nested values retain the driver's usual
|
|
169
|
+
serialization behavior. Only own data properties are read; accessors are rejected.
|
|
170
|
+
|
|
171
|
+
| Input | Insert behavior | Assignment behavior |
|
|
172
|
+
| -------------------------------------------- | ---------------------------------- | --------------------------------------- |
|
|
173
|
+
| Missing or `undefined` field | PostgreSQL `DEFAULT` for that cell | Omitted; the stored column is unchanged |
|
|
174
|
+
| Explicit `null` | Bound SQL null | Bound SQL null |
|
|
175
|
+
| Other value | Bound parameter | Bound parameter |
|
|
176
|
+
| SQL fragment, identifier, or query as a cell | Rejected | Rejected |
|
|
177
|
+
|
|
178
|
+
Without `columns`, inserts infer the union of enumerable keys with defined values across all rows,
|
|
179
|
+
in first-seen order. Heterogeneous rows stay aligned; missing cells use `DEFAULT`. One object or a
|
|
180
|
+
one-row batch with no defined fields emits `DEFAULT VALUES`. An empty batch throws. A multi-row
|
|
181
|
+
batch with no inferred columns throws; supply explicit columns to emit multiple default rows.
|
|
182
|
+
|
|
183
|
+
Assignments infer enumerable keys and omit undefined values. An empty object or a selection with
|
|
184
|
+
no defined values throws, so the helper never emits an empty `SET` clause. This also applies when
|
|
185
|
+
the surrounding statement contains arithmetic or other assignments: branch explicitly when the
|
|
186
|
+
intended update consists only of those expressions.
|
|
187
|
+
|
|
188
|
+
An optional nonempty column list selects and orders fields. It does not validate that the input
|
|
189
|
+
object has no additional keys. Unlisted fields are ignored; missing insert cells use `DEFAULT`,
|
|
190
|
+
while missing update fields are omitted. Duplicate or invalid column
|
|
191
|
+
names throw. TypeScript checks selected names against the input's known keys. The list is also the
|
|
192
|
+
way to make runtime objects and batches available to static SQL checking:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
const rows: { name: string; bio?: string | null }[] = buildRows();
|
|
196
|
+
const insert = sql`INSERT INTO profiles ${sql.insertValues(rows, ['name', 'bio'])} RETURNING id`;
|
|
197
|
+
|
|
198
|
+
const patch: { name?: string; bio?: string | null } = readPatch();
|
|
199
|
+
const update = sql`UPDATE profiles SET ${sql.assignments(patch, ['name', 'bio'])} WHERE id = ${id}`;
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The helpers have no schema metadata. Omit generated columns; PostgreSQL enforces identity,
|
|
203
|
+
generation, required-column, and constraint rules. They do not generate insert/update input types
|
|
204
|
+
or prove that the JavaScript input values match database column types. Retain application input
|
|
205
|
+
contracts and validation. To reset an update field to its default, write `column = DEFAULT` in the
|
|
206
|
+
surrounding SQL. Put arithmetic and `excluded.column` expressions there too; these helpers accept
|
|
207
|
+
values only, with no expression/default sentinel.
|
|
208
|
+
|
|
209
|
+
A compiled query, including surrounding interpolations, is limited to 65,535 bound parameters.
|
|
210
|
+
Tenon rejects larger queries and does not split them automatically. Choose batch sizes and
|
|
211
|
+
transaction boundaries in the caller. Helper validation errors identify invalid rows/columns
|
|
212
|
+
without printing cell values.
|
|
213
|
+
|
|
214
|
+
### Checking mutation fragments
|
|
215
|
+
|
|
216
|
+
SafeQL recognizes these official helpers directly through their Tenon function declarations,
|
|
217
|
+
including aliases. It never analyzes their implementation or executes application data builders.
|
|
218
|
+
Without an explicit column list, checking accepts inline object literals and inline insert arrays
|
|
219
|
+
of object literals. Keys must be literal names; object spreads, computed keys, methods, accessors,
|
|
220
|
+
and objects stored in variables require an explicit inline column list. Existing constant string
|
|
221
|
+
resolution works for entries of that list; column arrays stored in variables are unsupported.
|
|
222
|
+
|
|
223
|
+
For runtime inputs with explicit columns, the checker analyzes a representative nonempty insert
|
|
224
|
+
row or assignment list containing **all selected columns**. This checks column references, SQL
|
|
225
|
+
composition, conflict clauses, and `RETURNING` annotations. Runtime omission/default decisions and
|
|
226
|
+
batch length do not require interpreting JavaScript. The check is conservative: an optional
|
|
227
|
+
selected column is checked even when a particular runtime call omits it. It does not establish
|
|
228
|
+
nonempty input, valid runtime values, successful constraints, or every default's behavior; runtime
|
|
229
|
+
validation and database tests cover those contracts. Unknown inputs without explicit columns
|
|
230
|
+
produce `tenon/check-composition` diagnostics. See the [mutation cookbook](mutations.md) for
|
|
231
|
+
empty patches, conditional upserts, arrays, and batching.
|
|
232
|
+
|
|
140
233
|
## Transactions, guarded writes, and errors
|
|
141
234
|
|
|
142
235
|
```ts
|
|
@@ -230,8 +323,8 @@ Configure [SafeQL normally](https://safeql.dev/api/). Tenon's optional `safeql`
|
|
|
230
323
|
two objects:
|
|
231
324
|
|
|
232
325
|
- `safeqlPlugin` is a SafeQL plugin descriptor. Add it to each Tenon connection's `plugins` list. It
|
|
233
|
-
translates constant fragments and identifiers, preserves
|
|
234
|
-
pg result mappings. SafeQL owns database analysis and annotation fixes.
|
|
326
|
+
translates constant fragments, official mutation helpers, and identifiers, preserves parameter
|
|
327
|
+
behavior, and supplies pg result mappings. SafeQL owns database analysis and annotation fixes.
|
|
235
328
|
- `eslintPlugin` supplies `tenon/check-composition`. Register it in ESLint and enable the rule to
|
|
236
329
|
report queries that the adapter cannot resolve, including runtime-built fragments and escaped
|
|
237
330
|
outer templates.
|
|
@@ -381,16 +474,35 @@ avoid that ambiguity.
|
|
|
381
474
|
|
|
382
475
|
Within the configured targets, the adapter recognizes Tenon's `sql` export through imports, local
|
|
383
476
|
aliases, namespace imports, and object properties that retain its TypeScript type. It checks
|
|
384
|
-
constant fragments and `sql.join` with literal arrays, including nested fragments.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
477
|
+
constant fragments and `sql.join` with nonempty literal arrays, including nested fragments.
|
|
478
|
+
Separators and `identifier()` names must resolve to literal strings or const string initializers.
|
|
479
|
+
Values use the same parameter placeholders in static checking and execution, inside or outside
|
|
480
|
+
fragments. PostgreSQL infers their types from SQL context; Tenon does not add casts based on
|
|
481
|
+
JavaScript types.
|
|
482
|
+
|
|
483
|
+
The checker follows const fragment initializers, including imported constants with inspectable
|
|
484
|
+
source. It does not inspect function bodies, substitute helper arguments/defaults, or choose
|
|
485
|
+
JavaScript conditional branches. Function-generated fragments and conditional SQL structure are
|
|
486
|
+
unsupported even when a particular helper appears simple or a condition is a literal. Mutable
|
|
487
|
+
references, runtime filter lists, and dynamic identifiers are also unsupported. Ordinary value
|
|
488
|
+
expressions remain bound parameters; calls that produce data for an official mutation helper can
|
|
489
|
+
use its explicit column-list contract.
|
|
490
|
+
|
|
491
|
+
Reusable authorization and other application policy can still live in fragment-returning functions
|
|
492
|
+
at runtime. Keep that policy centralized and cover its behavior with database tests. Suppress
|
|
493
|
+
`tenon/check-composition` only on the particular query, with a reason, when its variants are tested.
|
|
494
|
+
Do not duplicate policy merely to obtain static coverage. SQL and annotation errors in checkable
|
|
495
|
+
queries continue to come from `@ts-safeql/check-sql`.
|
|
496
|
+
|
|
497
|
+
Ordinary functions containing complete `sql` templates remain checkable. Each template is analyzed
|
|
498
|
+
where it is written, including constant fragments defined in that function:
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
function findUsers(email: string) {
|
|
502
|
+
const predicate = sql.fragment`email = ${email}`;
|
|
503
|
+
return sql`SELECT id FROM users WHERE ${predicate}`;
|
|
504
|
+
}
|
|
505
|
+
```
|
|
394
506
|
|
|
395
507
|
Use literal line breaks in `sql` templates and bind string values with `${value}`. The composition
|
|
396
508
|
rule rejects JavaScript escapes such as `\n` or `\'` in the outer template: SafeQL reads raw
|