@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 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 and constant fragments, plus an `eslintPlugin` rule that flags queries the adapter cannot
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
- parts.push(...fragmentParts(value));
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
- parts.push(...fragmentParts(item));
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 declare class CompositionError extends Error {
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
- export class CompositionError extends Error {
6
- name = 'TenonCompositionError';
7
- constructor(detail) {
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, const fragment references, and sql.join with literal arrays are supported.');
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,3 @@
1
+ import ts from 'typescript';
2
+ /** Understand the official mutation contract, never the implementation of a JavaScript helper. */
3
+ export declare function renderMutation(kind: 'insertValues' | 'assignments', node: ts.CallExpression, checker: ts.TypeChecker): string;
@@ -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
+ }
@@ -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
- and literal/const-string separators. It rejects mutable, conditional, function-generated, or
112
- runtime-built composition. Prefer fixed SQL for simple optional filters, e.g.
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 fragments
209
- need inspectable source initializers. Use literal newlines and bound string values: outer-template
210
- JavaScript escapes are rejected because SafeQL reads raw text while Tenon executes decoded text.
211
- Bound values and constant fragments are decoded consistently.
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
@@ -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 constant fragments. A small companion ESLint rule,
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, and literal or const-string
135
- separators. Runtime-built lists and selected fragments execute safely with respect to parameter
136
- binding, but the checker cannot verify their SQL or result types. Prefer fixed SQL when it fits.
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 runtime parameter behavior, and supplies
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. Separators must
385
- be literal strings or const strings. Literal `identifier()` names and const string names are
386
- supported. Values use the same parameter placeholders in static checking and execution, inside or
387
- outside fragments. PostgreSQL infers their types from the SQL context; Tenon does not add casts
388
- based on JavaScript types. Mutable references, runtime lists, conditional fragments,
389
- function-generated fragments, and dynamic identifiers produce `tenon/check-composition` diagnostics.
390
- Suppress only that rule on the particular query when runtime variants have database tests. SQL and
391
- annotation errors still come from `@ts-safeql/check-sql`. An imported fragment must have an
392
- inspectable source initializer; declarations alone cannot describe its SQL. It does not execute
393
- application code to discover SQL.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kequtech/tenon-orm",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "A PostgreSQL ORM designed for coding agents, with explicit SQL, typed queries, and migrations",