@kequtech/tenon-orm 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  A PostgreSQL ORM designed for coding agents. Built for Node.js and TypeScript.
4
4
 
5
5
  Your coding agent already knows SQL. Tenon gives it a small API for parameterized queries,
6
- transactions, and migrations, with optional tooling to check SQL and generate result types. Queries
6
+ transactions, and migrations, with optional tooling to check SQL and generate database types. Queries
7
7
  stay readable, reusable operations are ordinary functions, and humans can review exactly what will
8
8
  run against the database.
9
9
 
@@ -47,7 +47,7 @@ Tenon also provides:
47
47
 
48
48
  - Transactions and affected-row limits that roll back rejected writes.
49
49
  - SQL migration files and a CLI to apply, inspect, and roll back migrations.
50
- - TypeScript table row types generated from your database.
50
+ - TypeScript row, insert, and update types generated from your database.
51
51
  - SQL fragments for sharing query parts or assembling variable conditions.
52
52
 
53
53
  Joins, relationships, aggregates, and upserts use ordinary PostgreSQL SQL. Tenon does not introduce
@@ -64,6 +64,14 @@ migrations and table type generation are independent conveniences.
64
64
  >
65
65
  > — Codex, after working on Tenon. An interested party, to put it mildly.
66
66
 
67
+ ## Object-based writes
68
+
69
+ For object-based writes, `sql.insertValues(rows)` preserves database defaults across heterogeneous
70
+ rows and `sql.assignments(patch)` omits undefined fields. Keep the surrounding SQL explicit. Both
71
+ support selected columns and [SafeQL checking](docs/reference.md#object-based-mutation-fragments).
72
+ Use [generated input types](docs/reference.md#insert-and-update-inputs) to check those objects
73
+ against your schema.
74
+
67
75
  ## Checking query results
68
76
 
69
77
  The type in `sql<{ id: number; email: string }>` describes what you expect back. TypeScript alone
@@ -73,12 +81,10 @@ For that, Tenon provides an optional integration with [SafeQL](https://safeql.de
73
81
  SQL against your development database and can write or repair the result annotation. ESLint runs the
74
82
  check during development and CI; it is not part of query execution.
75
83
 
76
- Configure SafeQL in your application's ESLint setup. 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
79
- mappings consistent with execution. Both integrations are optional development tooling. See
80
- [SQL checking setup](docs/reference.md#sql-checking-with-safeql) for installation and a complete
81
- configuration, including multiple databases.
84
+ Configure SafeQL in your application's ESLint setup with Tenon's `safeqlPlugin` adapter and
85
+ `eslintPlugin` companion rule. The adapter supports queries, constant fragments, and mutation
86
+ helpers; the rule reports queries it cannot inspect. See the
87
+ [SQL checking setup](docs/reference.md#sql-checking-with-safeql).
82
88
 
83
89
  Checking complements typed inputs and database tests. It does not validate request values or
84
90
  arbitrary JSON, and runtime-built query variants need their own tests.
@@ -91,7 +97,9 @@ arbitrary JSON, and runtime-built query variants need their own tests.
91
97
  - **[Reference](docs/reference.md)** — complete options, SQL checking setup, type mappings, and
92
98
  limitations.
93
99
  - **[Adoption guide](docs/adoption-guide.md)** — replace an existing ORM and, if needed, transfer
94
- migration ownership.
100
+ migration ownership; upgrade local write helpers and adopt SQL checking incrementally.
101
+ - **[Mutation cookbook](docs/mutations.md)** — defaults, patches, upserts, arrays, batching, and
102
+ the boundaries between input typing and SQL checking.
95
103
 
96
104
  The complete `docs/` folder ships with the npm package. Maintainer instructions are in
97
105
  [CONTRIBUTING.md](https://github.com/kequtech/tenon-orm/blob/main/CONTRIBUTING.md), which is not
package/dist/generate.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readTables } from './generation/catalog.js';
2
- import { renderRowTypes } from './generation/row-types.js';
2
+ import { renderTableTypes } from './generation/table-types.js';
3
3
  import { domainSummaries, schemaSummary, tableSummary } from './generation/schema-summary.js';
4
4
  import { withMigrationLock } from './migrations/connection.js';
5
5
  import { quote } from './sql.js';
@@ -24,7 +24,7 @@ export async function generateDefinitions(options) {
24
24
  summaries.push(await tableSummary(client, table.oid, table.name));
25
25
  }
26
26
  const domains = await domainSummaries(client, definitions.flatMap((table) => table.columns.map((column) => column.declaredOid)));
27
- const types = renderRowTypes(definitions, namespace, onWarning);
27
+ const types = renderTableTypes(definitions, namespace, onWarning);
28
28
  await client.query('COMMIT');
29
29
  return `// Generated by Tenon from the PostgreSQL schema. Do not edit.
30
30
  // Commit this file and regenerate it after changing the schema.
@@ -9,6 +9,9 @@ export interface ColumnRow {
9
9
  declaredType: string;
10
10
  declaredSchema: string;
11
11
  nullable: boolean;
12
+ writeNullable: boolean;
13
+ hasDefault: boolean;
14
+ writable: boolean;
12
15
  choices: string[] | null;
13
16
  }
14
17
  export interface TableRow {
@@ -11,16 +11,19 @@ export async function readTables(client, namespace) {
11
11
  // Attached descendants are storage details, not separate generated models.
12
12
  const columns = await client.query(`
13
13
  WITH RECURSIVE resolved AS (
14
- SELECT a.attnum, t.oid, t.typbasetype
14
+ SELECT a.attnum, t.oid, t.typbasetype, t.typnotnull AS domain_not_null
15
15
  FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
16
16
  WHERE a.attrelid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped
17
17
  UNION ALL
18
- SELECT r.attnum, t.oid, t.typbasetype
18
+ SELECT r.attnum, t.oid, t.typbasetype, r.domain_not_null OR t.typnotnull
19
19
  FROM resolved r JOIN pg_catalog.pg_type t ON t.oid = r.typbasetype
20
20
  )
21
21
  SELECT a.attname AS name, t.oid::text AS oid, t.typname AS type, tn.nspname AS "typeSchema", t.typtype AS "typeKind",
22
22
  declared.oid::text AS "declaredOid", declared.typname AS "declaredType", dn.nspname AS "declaredSchema",
23
23
  NOT a.attnotnull AS nullable,
24
+ NOT (a.attnotnull OR r.domain_not_null) AS "writeNullable",
25
+ (a.atthasdef OR declared.typdefaultbin IS NOT NULL OR a.attidentity <> '') AS "hasDefault",
26
+ (a.attgenerated = '' AND a.attidentity <> 'a') AS writable,
24
27
  (SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder) FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid) AS choices
25
28
  FROM pg_catalog.pg_attribute a
26
29
  JOIN resolved r ON r.attnum = a.attnum AND r.typbasetype = 0
@@ -0,0 +1,2 @@
1
+ import type { TableRow } from './catalog.js';
2
+ export declare function renderTableTypes(tables: readonly TableRow[], namespace: string, onWarning: (message: string) => void): string;
@@ -0,0 +1,146 @@
1
+ // Every generated table remains accessible through Rows["exact SQL name"].
2
+ const reservedTypeNames = new Set(`
3
+ as await break case catch class const continue debugger default delete do else enum
4
+ export extends false finally for function if implements import in instanceof
5
+ interface let new null package private protected public return static super
6
+ switch this throw true try typeof var void while with yield any bigint boolean
7
+ intrinsic never number object string symbol undefined unknown
8
+ Rows globalThis PgArray
9
+ `
10
+ .trim()
11
+ .split(/\s+/));
12
+ function canExportRowName(name) {
13
+ return (/^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*$/u.test(name) && !reservedTypeNames.has(name));
14
+ }
15
+ // A string means the read and write representations match. Arrays use verified
16
+ // built-in OIDs because their parsers can differ from scalar parsers (numeric[]).
17
+ const arrayTypes = {
18
+ '1000': 'boolean',
19
+ '1001': 'import("node:buffer").Buffer',
20
+ '1005': 'number',
21
+ '1007': 'number',
22
+ '1021': 'number',
23
+ '1022': 'number',
24
+ '1028': 'number',
25
+ '1016': { read: 'string', write: 'string | bigint' },
26
+ '1231': { read: 'number', write: 'string | number' },
27
+ '1009': 'string',
28
+ '1014': 'string',
29
+ '1015': 'string',
30
+ '1183': 'string',
31
+ '1270': 'string',
32
+ '2951': 'string',
33
+ '1182': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
34
+ '1115': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
35
+ '1185': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
36
+ '199': { read: 'unknown', write: 'string' },
37
+ '3807': { read: 'unknown', write: 'string' },
38
+ };
39
+ const scalarTypes = {
40
+ int2: 'number',
41
+ int4: 'number',
42
+ oid: 'number',
43
+ float4: 'number',
44
+ float8: 'number',
45
+ bool: 'boolean',
46
+ text: 'string',
47
+ name: 'string',
48
+ varchar: 'string',
49
+ bpchar: 'string',
50
+ time: 'string',
51
+ timetz: 'string',
52
+ uuid: 'string',
53
+ // pg returns +/-Infinity as numbers. TypeScript has no infinity literal type.
54
+ date: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
55
+ timestamp: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
56
+ timestamptz: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
57
+ int8: { read: 'string', write: 'string | bigint' },
58
+ numeric: { read: 'string', write: 'string | number' },
59
+ json: { read: 'unknown', write: 'string' },
60
+ jsonb: { read: 'unknown', write: 'string' },
61
+ bytea: 'import("node:buffer").Buffer',
62
+ };
63
+ function valueTypes(mapping) {
64
+ return typeof mapping === 'string' ? { read: mapping, write: mapping } : mapping;
65
+ }
66
+ function isSupportedArray(value) {
67
+ return Object.hasOwn(arrayTypes, value.oid);
68
+ }
69
+ function columnTypes(value, column, onWarning) {
70
+ let types;
71
+ if (isSupportedArray(value)) {
72
+ const element = valueTypes(arrayTypes[value.oid]);
73
+ types = { read: `PgArray<${element.read}>`, write: `PgArray<${element.write}>` };
74
+ }
75
+ else if (value.typeKind === 'e') {
76
+ const choice = value.choices?.map((choice) => JSON.stringify(choice)).join(' | ') || 'never';
77
+ types = { read: choice, write: choice };
78
+ }
79
+ else {
80
+ const type = value.typeSchema === 'pg_catalog' && Object.hasOwn(scalarTypes, value.type)
81
+ ? scalarTypes[value.type]
82
+ : undefined;
83
+ if (type) {
84
+ types = valueTypes(type);
85
+ }
86
+ else {
87
+ const underlying = value.declaredOid === value.oid
88
+ ? ''
89
+ : ` with underlying type ${JSON.stringify(value.typeSchema)}.${JSON.stringify(value.type)} (OID ${value.oid})`;
90
+ onWarning(`Column ${column} uses PostgreSQL type ${JSON.stringify(value.declaredSchema)}.${JSON.stringify(value.declaredType)} (OID ${value.declaredOid})${underlying} without a verified pg mapping; generated as unknown for reads and never for non-null writes.`);
91
+ types = { read: 'unknown', write: 'never' };
92
+ }
93
+ }
94
+ // unknown already includes null, including for nullable JSON and unmapped types.
95
+ return {
96
+ read: `${types.read}${value.nullable && types.read !== 'unknown' ? ' | null' : ''}`,
97
+ write: `${types.write}${value.writeNullable ? ' | null' : ''}`,
98
+ };
99
+ }
100
+ export function renderTableTypes(tables, namespace, onWarning) {
101
+ const definitions = tables.map((table) => {
102
+ const tableName = JSON.stringify(table.name);
103
+ const columns = table.columns.map((column) => {
104
+ const columnName = JSON.stringify(column.name);
105
+ const path = `${JSON.stringify(namespace)}.${tableName}.${columnName}`;
106
+ const types = columnTypes(column, path, onWarning);
107
+ const requiredOnInsert = column.writable && !column.hasDefault && !column.writeNullable;
108
+ const optionalInputType = column.writable ? `${types.write} | undefined` : 'never';
109
+ return {
110
+ row: ` ${columnName}: ${types.read};`,
111
+ insert: ` ${columnName}${requiredOnInsert ? '' : '?'}: ${requiredOnInsert ? types.write : optionalInputType};`,
112
+ update: ` ${columnName}?: ${optionalInputType};`,
113
+ };
114
+ });
115
+ return { tableName, columns };
116
+ });
117
+ const render = (kind, indent = '') => definitions
118
+ .map(({ tableName, columns }) => `${indent} ${tableName}: {
119
+ ${columns.map((column) => indent + column[kind]).join('\n')}
120
+ ${indent} };`)
121
+ .join('\n');
122
+ const aliases = tables
123
+ .filter((table) => canExportRowName(table.name))
124
+ .map((table) => `export type ${table.name} = Rows[${JSON.stringify(table.name)}];`);
125
+ const arrays = tables.some((table) => table.columns.some(isSupportedArray))
126
+ ? 'export type PgArray<T> = (T | null | PgArray<T>)[];\n\n'
127
+ : '';
128
+ return `${arrays}// Full database rows. Compose query types with Pick, indexed access, and intersections.
129
+ export type Rows = {
130
+ ${render('row')}
131
+ };
132
+
133
+ export declare namespace Rows {
134
+ // Bound inputs for sql.insertValues. Missing/undefined fields use database defaults.
135
+ type Insert = {
136
+ ${render('insert', ' ')}
137
+ };
138
+
139
+ // Bound inputs for sql.assignments. Missing/undefined fields are omitted.
140
+ type Update = {
141
+ ${render('update', ' ')}
142
+ };
143
+ }
144
+ ${aliases.join('\n')}
145
+ `;
146
+ }
@@ -0,0 +1,11 @@
1
+ export type MutationPart = string | Readonly<{
2
+ value: unknown;
3
+ }>;
4
+ export type MutationColumns<Input> = Input extends object ? Extract<keyof Input, string> : never;
5
+ /** Validate and quote one explicit column list, shared by runtime and SQL checking. */
6
+ export declare function mutationColumns(columns: readonly string[]): string[];
7
+ /** SQL structure depends only on these columns and cells, never on JavaScript source. */
8
+ export declare function insertLayout(columns: readonly string[], rows: readonly (readonly unknown[])[]): MutationPart[];
9
+ export declare function assignmentLayout(columns: readonly string[], values: readonly unknown[]): MutationPart[];
10
+ export declare function insertParts(input: object | readonly object[], selected?: readonly string[]): MutationPart[];
11
+ export declare function assignmentParts(input: object, selected?: readonly string[]): MutationPart[];
@@ -0,0 +1,87 @@
1
+ import { quote } from './sql.js';
2
+ import { plainObject } from './validation.js';
3
+ /** Validate and quote one explicit column list, shared by runtime and SQL checking. */
4
+ export function mutationColumns(columns) {
5
+ if (!Array.isArray(columns) || !columns.length)
6
+ throw new Error('Mutation columns must be a nonempty array.');
7
+ const seen = new Set();
8
+ return Array.from(columns, (column) => {
9
+ if (typeof column !== 'string')
10
+ throw new Error('Mutation column names must be strings.');
11
+ quote(column);
12
+ if (seen.has(column))
13
+ throw new Error(`Duplicate mutation column ${JSON.stringify(column)}.`);
14
+ seen.add(column);
15
+ return column;
16
+ });
17
+ }
18
+ /** SQL structure depends only on these columns and cells, never on JavaScript source. */
19
+ export function insertLayout(columns, rows) {
20
+ if (!rows.length)
21
+ throw new Error('sql.insertValues requires at least one row.');
22
+ if (!columns.length) {
23
+ if (rows.length !== 1)
24
+ throw new Error('Default-only inserts require exactly one row.');
25
+ return ['DEFAULT VALUES'];
26
+ }
27
+ const parts = [`(${columns.map(quote).join(', ')}) VALUES `];
28
+ for (const [index, row] of rows.entries()) {
29
+ if (index)
30
+ parts.push(', ');
31
+ parts.push('(');
32
+ for (const [column, value] of row.entries()) {
33
+ if (column)
34
+ parts.push(', ');
35
+ parts.push(value === undefined ? 'DEFAULT' : Object.freeze({ value }));
36
+ }
37
+ parts.push(')');
38
+ }
39
+ return parts;
40
+ }
41
+ export function assignmentLayout(columns, values) {
42
+ const parts = [];
43
+ for (const [index, column] of columns.entries()) {
44
+ const value = values[index];
45
+ if (value === undefined)
46
+ continue;
47
+ if (parts.length)
48
+ parts.push(', ');
49
+ parts.push(`${quote(column)} = `, Object.freeze({ value }));
50
+ }
51
+ if (!parts.length)
52
+ throw new Error('sql.assignments requires at least one defined field.');
53
+ return parts;
54
+ }
55
+ function record(value, helper, index) {
56
+ return plainObject(value, `${helper} row ${index + 1}`);
57
+ }
58
+ function cell(row, column, helper, index) {
59
+ const property = Object.getOwnPropertyDescriptor(row, column);
60
+ if (!property)
61
+ return undefined;
62
+ if (!('value' in property))
63
+ throw new Error(`${helper} row ${index + 1} column ${JSON.stringify(column)} must be a data property.`);
64
+ const value = property.value;
65
+ if (value &&
66
+ typeof value === 'object' &&
67
+ ['fragment', 'identifier', 'query'].some((kind) => Symbol.for(`@kequtech/tenon-orm/${kind}`) in value)) {
68
+ throw new Error(`${helper} row ${index + 1} column ${JSON.stringify(column)} expects a bound value; put SQL expressions in the surrounding statement.`);
69
+ }
70
+ return value;
71
+ }
72
+ export function insertParts(input, selected) {
73
+ const rows = Array.from(Array.isArray(input) ? input : [input], (row, index) => record(row, 'sql.insertValues', index));
74
+ if (!rows.length)
75
+ throw new Error('sql.insertValues requires at least one row.');
76
+ const columns = selected === undefined
77
+ ? [
78
+ ...new Set(rows.flatMap((row, index) => Object.keys(row).filter((column) => cell(row, column, 'sql.insertValues', index) !== undefined))),
79
+ ]
80
+ : mutationColumns(selected);
81
+ return insertLayout(columns, rows.map((row, index) => columns.map((column) => cell(row, column, 'sql.insertValues', index))));
82
+ }
83
+ export function assignmentParts(input, selected) {
84
+ const row = record(input, 'sql.assignments', 0);
85
+ const columns = selected === undefined ? Object.keys(row) : mutationColumns(selected);
86
+ return assignmentLayout(columns, columns.map((column) => cell(row, column, 'sql.assignments', 0)));
87
+ }
package/dist/query.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { QueryResultRow } from 'pg';
2
+ import { type MutationColumns } from './mutations.js';
2
3
  export type UnknownRow = Record<string, unknown>;
3
4
  /** A parameterized statement. The row type describes one returned record. */
4
5
  export interface SqlQuery<Row extends QueryResultRow = UnknownRow> {
@@ -19,11 +20,17 @@ export interface SqlFragment {
19
20
  }
20
21
  declare function fragment(strings: TemplateStringsArray, ...values: readonly unknown[]): SqlFragment;
21
22
  declare function join(fragments: readonly SqlFragment[], separator: 'AND' | 'OR' | ','): SqlFragment;
23
+ /** Object fields become bound values; absent insert cells use PostgreSQL defaults. */
24
+ declare function insertValues<Input extends object>(input: Input | readonly Input[], columns?: readonly NoInfer<MutationColumns<Input>>[]): SqlFragment;
25
+ /** Omit undefined fields; null remains a value. Empty assignments reject. */
26
+ declare function assignments<Input extends object>(input: Input, columns?: readonly NoInfer<MutationColumns<Input>>[]): SqlFragment;
22
27
  declare function createQuery<Row extends QueryResultRow = UnknownRow>(strings: TemplateStringsArray, ...values: readonly unknown[]): NoInfer<SqlQuery<Row>>;
23
28
  /** Values bind parameters; identifiers quote names; fragments compose authored SQL. */
24
29
  export declare const sql: typeof createQuery & {
25
30
  fragment: typeof fragment;
26
31
  join: typeof join;
32
+ insertValues: typeof insertValues;
33
+ assignments: typeof assignments;
27
34
  };
28
35
  /** Escape hatch for externally authored SQL. Never concatenate application input into text. */
29
36
  export declare function unsafeSql<Row extends QueryResultRow = UnknownRow>(text: string, values: readonly unknown[]): NoInfer<SqlQuery<Row>>;
package/dist/query.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { joinSeparator, quote } from './sql.js';
2
+ import { assignmentParts, insertParts, } from './mutations.js';
2
3
  // Shared symbols let separately installed copies exchange queries and identifiers.
3
4
  const queryTag = Symbol.for('@kequtech/tenon-orm/query');
4
5
  const identifierTag = Symbol.for('@kequtech/tenon-orm/identifier');
@@ -30,7 +31,8 @@ function templateParts(strings, values) {
30
31
  const parts = [strings[0]];
31
32
  for (const [i, value] of values.entries()) {
32
33
  if (value && typeof value === 'object' && fragmentTag in value) {
33
- 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; received ${values.length}. Split large batches explicitly.`);
96
115
  return Object.freeze({ [queryTag]: true, text, values: Object.freeze([...values]) });
97
116
  }
98
117
  export function assertQuery(value) {
@@ -1,8 +1,5 @@
1
1
  import ts from 'typescript';
2
- export 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,16 @@ export function renderFragment(node, checker, seen = new Set()) {
141
106
  }
142
107
  return list.elements.map((item) => renderFragment(item, checker, visited)).join(between);
143
108
  }
144
- throw new CompositionError('Only sql.fragment templates, const fragment references, and sql.join with literal arrays are supported.');
109
+ if (ts.isCallExpression(node)) {
110
+ throw new CompositionError(`Function-generated fragments from ${node.expression.getText()}() are not analyzed; the checker does not inspect function bodies.`);
111
+ }
112
+ if (ts.isConditionalExpression(node)) {
113
+ throw new CompositionError('Conditional fragments are not analyzed; the checker does not choose JavaScript branches, even for literal conditions.');
114
+ }
115
+ if (ts.isIdentifier(node)) {
116
+ throw new CompositionError(`Fragment "${node.text}" has no inspectable const initializer; mutable and runtime-supplied fragments are not analyzed.`);
117
+ }
118
+ throw new CompositionError('Only constant sql.fragment templates, literal sql.join arrays, and official mutation helpers are supported. Function-generated fragments and conditional SQL structure are not analyzed.');
145
119
  }
146
120
  export function renderIdentifier(node, checker, seen = new Set()) {
147
121
  node = unwrap(node);
@@ -22,21 +22,26 @@ export const eslintPlugin = {
22
22
  !ts.isTaggedTemplateExpression(template) ||
23
23
  !isExport(template.tag, checker, 'sql'))
24
24
  return;
25
- try {
26
- if (node.quasi.quasis.some(({ value }) => value.raw !== value.cooked)) {
27
- throw new CompositionError('JavaScript escapes in an outer sql template would be checked as different SQL. Use literal newlines and bound string values instead.');
28
- }
29
- if (ts.isTemplateExpression(template.template)) {
30
- for (const span of template.template.templateSpans) {
25
+ if (node.quasi.quasis.some(({ value }) => value.raw !== value.cooked)) {
26
+ context.report({
27
+ node,
28
+ message: new CompositionError('JavaScript escapes in an outer sql template would be checked as different SQL. Use literal newlines and bound string values instead.').message,
29
+ });
30
+ return;
31
+ }
32
+ if (ts.isTemplateExpression(template.template)) {
33
+ for (const [index, span] of template.template.templateSpans.entries()) {
34
+ try {
31
35
  renderValue(span.expression, checker);
32
36
  }
37
+ catch (error) {
38
+ if (!(error instanceof CompositionError))
39
+ throw error;
40
+ context.report({ node: node.quasi.expressions[index], message: error.message });
41
+ return;
42
+ }
33
43
  }
34
44
  }
35
- catch (error) {
36
- if (!(error instanceof CompositionError))
37
- throw error;
38
- context.report({ node, message: error.message });
39
- }
40
45
  },
41
46
  };
42
47
  },
@@ -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;