@kequtech/tenon-orm 0.0.0-stage → 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/LICENSE +15 -0
- package/README.md +105 -2
- package/dist/affected-rows.d.ts +11 -0
- package/dist/affected-rows.js +30 -0
- package/dist/baseline.d.ts +19 -0
- package/dist/baseline.js +100 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +158 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +54 -0
- package/dist/connection.d.ts +15 -0
- package/dist/connection.js +51 -0
- package/dist/database.d.ts +7 -0
- package/dist/database.js +44 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +18 -0
- package/dist/generate.d.ts +9 -0
- package/dist/generate.js +34 -0
- package/dist/generation/catalog.d.ts +19 -0
- package/dist/generation/catalog.js +35 -0
- package/dist/generation/row-types.d.ts +2 -0
- package/dist/generation/row-types.js +115 -0
- package/dist/generation/schema-summary.d.ts +6 -0
- package/dist/generation/schema-summary.js +114 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +7 -0
- package/dist/migrations/baseline-sql.d.ts +3 -0
- package/dist/migrations/baseline-sql.js +38 -0
- package/dist/migrations/connection.d.ts +9 -0
- package/dist/migrations/connection.js +65 -0
- package/dist/migrations/files.d.ts +21 -0
- package/dist/migrations/files.js +131 -0
- package/dist/migrations/history.d.ts +12 -0
- package/dist/migrations/history.js +30 -0
- package/dist/migrations/sql.d.ts +2 -0
- package/dist/migrations/sql.js +94 -0
- package/dist/migrations.d.ts +10 -0
- package/dist/migrations.js +68 -0
- package/dist/mutations.d.ts +11 -0
- package/dist/mutations.js +87 -0
- package/dist/query-options.d.ts +15 -0
- package/dist/query-options.js +35 -0
- package/dist/query.d.ts +38 -0
- package/dist/query.js +129 -0
- package/dist/safeql/composition.d.ts +9 -0
- package/dist/safeql/composition.js +159 -0
- package/dist/safeql/eslint.d.ts +2 -0
- package/dist/safeql/eslint.js +45 -0
- package/dist/safeql/mutations.d.ts +3 -0
- package/dist/safeql/mutations.js +74 -0
- package/dist/safeql/plugin.d.ts +3 -0
- package/dist/safeql/plugin.js +43 -0
- package/dist/safeql/static.d.ts +10 -0
- package/dist/safeql/static.js +59 -0
- package/dist/safeql.d.ts +6 -0
- package/dist/safeql.js +7 -0
- package/dist/sql.d.ts +5 -0
- package/dist/sql.js +28 -0
- package/dist/transaction-sql.d.ts +2 -0
- package/dist/transaction-sql.js +67 -0
- package/dist/transaction.d.ts +4 -0
- package/dist/transaction.js +75 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +1 -0
- package/dist/validation.d.ts +1 -0
- package/dist/validation.js +10 -0
- package/docs/adoption-guide.md +91 -0
- package/docs/agent-guide.md +310 -0
- package/docs/human-guide.md +271 -0
- package/docs/mutations.md +115 -0
- package/docs/reference.md +568 -0
- package/package.json +92 -4
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { qualified } from '../sql.js';
|
|
2
|
+
export const migrationTable = '__tenon_migrations';
|
|
3
|
+
export async function migrationState(client, namespace, migrations) {
|
|
4
|
+
const exists = await client.query('SELECT to_regclass($1)::text AS name', [qualified(namespace, migrationTable)]);
|
|
5
|
+
if (exists.rows[0].name === null) {
|
|
6
|
+
return [];
|
|
7
|
+
}
|
|
8
|
+
const { rows: applied } = await client.query(`SELECT id, name, checksum FROM ${qualified(namespace, migrationTable)} ORDER BY id::numeric`);
|
|
9
|
+
for (const [i, record] of applied.entries()) {
|
|
10
|
+
const file = migrations[i];
|
|
11
|
+
if (!file ||
|
|
12
|
+
file.id !== record.id ||
|
|
13
|
+
file.name !== record.name ||
|
|
14
|
+
file.checksum !== record.checksum) {
|
|
15
|
+
throw new Error(`Migration history differs from files at version ${record.id}; restore applied files before continuing.`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return applied;
|
|
19
|
+
}
|
|
20
|
+
export async function createHistoryTable(client, namespace) {
|
|
21
|
+
await client.query(`CREATE TABLE IF NOT EXISTS ${qualified(namespace, migrationTable)} (
|
|
22
|
+
id TEXT PRIMARY KEY CHECK (id ~ '^[0-9]+$'),
|
|
23
|
+
name TEXT NOT NULL,
|
|
24
|
+
checksum TEXT NOT NULL,
|
|
25
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
26
|
+
)`);
|
|
27
|
+
}
|
|
28
|
+
export async function recordMigration(client, namespace, migration) {
|
|
29
|
+
await client.query(`INSERT INTO ${qualified(namespace, migrationTable)} (id, name, checksum) VALUES ($1, $2, $3)`, [migration.id, migration.name, migration.checksum]);
|
|
30
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Find statement starts without splitting quoted strings, DO bodies or comments. */
|
|
2
|
+
export function validateMigrationSql(sql, filename) {
|
|
3
|
+
let statement = '';
|
|
4
|
+
let quote;
|
|
5
|
+
let commentDepth = 0;
|
|
6
|
+
let lineComment = false;
|
|
7
|
+
let escapeString = false;
|
|
8
|
+
function check() {
|
|
9
|
+
const words = statement.trim().replace(/\s+/g, ' ').toUpperCase();
|
|
10
|
+
if (/^(BEGIN|COMMIT|END|ROLLBACK|ABORT|SAVEPOINT|RELEASE)\b|^START TRANSACTION\b|^PREPARE TRANSACTION\b/.test(words)) {
|
|
11
|
+
throw new Error(`Migration ${filename} must not contain transaction-control statements; Tenon owns the transaction.`);
|
|
12
|
+
}
|
|
13
|
+
statement = '';
|
|
14
|
+
}
|
|
15
|
+
for (let i = 0; i < sql.length; i++) {
|
|
16
|
+
const ch = sql[i];
|
|
17
|
+
const next = sql[i + 1];
|
|
18
|
+
if (lineComment) {
|
|
19
|
+
if (ch === '\n' || ch === '\r') {
|
|
20
|
+
lineComment = false;
|
|
21
|
+
statement += ' ';
|
|
22
|
+
}
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (commentDepth) {
|
|
26
|
+
if (ch === '/' && next === '*') {
|
|
27
|
+
commentDepth++;
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
else if (ch === '*' && next === '/') {
|
|
31
|
+
commentDepth--;
|
|
32
|
+
i++;
|
|
33
|
+
statement += ' ';
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (quote) {
|
|
38
|
+
if (quote.startsWith('$')) {
|
|
39
|
+
if (sql.startsWith(quote, i)) {
|
|
40
|
+
i += quote.length - 1;
|
|
41
|
+
quote = undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if (escapeString && ch === '\\') {
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
47
|
+
else if (ch === quote) {
|
|
48
|
+
if (next === quote) {
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
quote = undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (ch === '-' && next === '-') {
|
|
58
|
+
lineComment = true;
|
|
59
|
+
i++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (ch === '/' && next === '*') {
|
|
63
|
+
commentDepth = 1;
|
|
64
|
+
i++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (ch === "'" || ch === '"') {
|
|
68
|
+
escapeString = ch === "'" && /(?:^|\W)[eE]$/.test(sql.slice(0, i));
|
|
69
|
+
quote = ch;
|
|
70
|
+
statement += ' quoted ';
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// A dollar inside an unquoted identifier is not the start of a SQL body.
|
|
74
|
+
if (ch === '$' && (i === 0 || !/[A-Za-z_0-9$\u0080-\uFFFF]/.test(sql[i - 1]))) {
|
|
75
|
+
const match = /^\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$/.exec(sql.slice(i));
|
|
76
|
+
if (match) {
|
|
77
|
+
quote = match[0];
|
|
78
|
+
i += quote.length - 1;
|
|
79
|
+
statement += ' body ';
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (ch === ';') {
|
|
84
|
+
check();
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
statement += ch;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (quote || commentDepth) {
|
|
91
|
+
throw new Error(`Unterminated SQL string or comment in migration ${filename}.`);
|
|
92
|
+
}
|
|
93
|
+
check();
|
|
94
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DatabaseToolOptions } from './migrations/connection.js';
|
|
2
|
+
export interface MigrationStatus {
|
|
3
|
+
readonly applied: readonly string[];
|
|
4
|
+
readonly pending: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function migrationStatus(options: DatabaseToolOptions): Promise<MigrationStatus>;
|
|
7
|
+
export declare function migrate(options: DatabaseToolOptions): Promise<readonly string[]>;
|
|
8
|
+
export declare function rollback(options: DatabaseToolOptions & {
|
|
9
|
+
readonly steps?: number;
|
|
10
|
+
}): Promise<readonly string[]>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { withMigrationLock } from './migrations/connection.js';
|
|
2
|
+
import { readMigrations } from './migrations/files.js';
|
|
3
|
+
import { createHistoryTable, migrationState, migrationTable, recordMigration, } from './migrations/history.js';
|
|
4
|
+
import { qualified, quote } from './sql.js';
|
|
5
|
+
export async function migrationStatus(options) {
|
|
6
|
+
const files = await readMigrations(options.directory);
|
|
7
|
+
return withMigrationLock(options, 'shared', async (client, namespace) => {
|
|
8
|
+
const applied = await migrationState(client, namespace, files);
|
|
9
|
+
return {
|
|
10
|
+
applied: applied.map((m) => m.id),
|
|
11
|
+
pending: files.slice(applied.length).map((m) => m.id),
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
async function execute(options, direction, steps) {
|
|
16
|
+
if (!Number.isSafeInteger(steps) || steps < 1) {
|
|
17
|
+
throw new Error('Rollback steps must be a positive safe integer.');
|
|
18
|
+
}
|
|
19
|
+
const files = await readMigrations(options.directory);
|
|
20
|
+
return withMigrationLock(options, 'exclusive', async (client, namespace) => {
|
|
21
|
+
const applied = await migrationState(client, namespace, files);
|
|
22
|
+
const selected = direction === 'up'
|
|
23
|
+
? files.slice(applied.length)
|
|
24
|
+
: files.slice(Math.max(0, applied.length - steps), applied.length).reverse();
|
|
25
|
+
if (direction === 'down' && selected.some((file) => file.down === undefined)) {
|
|
26
|
+
throw new Error('A selected migration has no down SQL and cannot be rolled back.');
|
|
27
|
+
}
|
|
28
|
+
if (!selected.length) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS ${quote(namespace)}`);
|
|
32
|
+
await createHistoryTable(client, namespace);
|
|
33
|
+
const completed = [];
|
|
34
|
+
for (const file of selected) {
|
|
35
|
+
try {
|
|
36
|
+
await client.query('BEGIN');
|
|
37
|
+
// Match the string-literal rules used by the authored migration scanner.
|
|
38
|
+
await client.query('SET LOCAL standard_conforming_strings TO on');
|
|
39
|
+
await client.query(`SET LOCAL search_path TO ${quote(namespace)}, pg_catalog`);
|
|
40
|
+
await client.query(direction === 'up' ? file.up : file.down);
|
|
41
|
+
if (direction === 'up') {
|
|
42
|
+
await recordMigration(client, namespace, file);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
await client.query(`DELETE FROM ${qualified(namespace, migrationTable)} WHERE id = $1`, [
|
|
46
|
+
file.id,
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
const result = await client.query('COMMIT');
|
|
50
|
+
if (result.command !== 'COMMIT') {
|
|
51
|
+
throw new Error('The migration transaction was rolled back.');
|
|
52
|
+
}
|
|
53
|
+
completed.push(file.id);
|
|
54
|
+
}
|
|
55
|
+
catch (cause) {
|
|
56
|
+
const reason = cause instanceof Error ? cause.message : 'Unknown database error';
|
|
57
|
+
throw new Error(`Migration ${file.id}_${file.name} (${direction}) failed: ${reason}. Earlier migrations in this run may have committed.`, { cause });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return completed;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export function migrate(options) {
|
|
64
|
+
return execute(options, 'up', 1);
|
|
65
|
+
}
|
|
66
|
+
export function rollback(options) {
|
|
67
|
+
return execute(options, 'down', options.steps ?? 1);
|
|
68
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { QueryResult, QueryResultRow } from 'pg';
|
|
2
|
+
import type { AffectedRowsConstraint } from './affected-rows.js';
|
|
3
|
+
import type { UnknownRow } from './query.js';
|
|
4
|
+
export type QueryReturnMode = 'native' | 'many' | 'oneOrNone' | 'one';
|
|
5
|
+
export interface QueryOptions {
|
|
6
|
+
readonly returns?: QueryReturnMode;
|
|
7
|
+
readonly affectedRows?: AffectedRowsConstraint;
|
|
8
|
+
/** Optional diagnostic label. No SQL or input values are sent to observers. */
|
|
9
|
+
readonly label?: string;
|
|
10
|
+
}
|
|
11
|
+
export type QueryOutput<Row extends QueryResultRow, Mode extends QueryReturnMode> = Mode extends 'native' ? QueryResult<Row> : Mode extends 'many' ? Row[] : Mode extends 'oneOrNone' ? Row | undefined : Row;
|
|
12
|
+
export declare function validateOptions(options?: QueryOptions): Readonly<QueryOptions & {
|
|
13
|
+
returns: QueryReturnMode;
|
|
14
|
+
}>;
|
|
15
|
+
export declare function queryOutput(options: QueryOptions, result: QueryResult<UnknownRow>): object | undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { validateAffectedRows } from './affected-rows.js';
|
|
2
|
+
import { plainObject } from './validation.js';
|
|
3
|
+
export function validateOptions(options) {
|
|
4
|
+
const config = options === undefined ? {} : plainObject(options, 'Query options');
|
|
5
|
+
for (const key of Object.keys(config)) {
|
|
6
|
+
if (!['returns', 'affectedRows', 'label'].includes(key))
|
|
7
|
+
throw new Error(`Unknown query option: ${key}`);
|
|
8
|
+
}
|
|
9
|
+
const returns = config.returns ?? 'native';
|
|
10
|
+
if (config.returns === null ||
|
|
11
|
+
!['native', 'many', 'oneOrNone', 'one'].includes(returns)) {
|
|
12
|
+
throw new Error('Query returns must be native, many, oneOrNone, or one.');
|
|
13
|
+
}
|
|
14
|
+
const affectedRows = validateAffectedRows(config.affectedRows);
|
|
15
|
+
const label = config.label;
|
|
16
|
+
if (label !== undefined && (typeof label !== 'string' || !label.trim())) {
|
|
17
|
+
throw new Error('Query label must be a nonempty string.');
|
|
18
|
+
}
|
|
19
|
+
return Object.freeze({
|
|
20
|
+
returns: returns,
|
|
21
|
+
...(affectedRows && { affectedRows }),
|
|
22
|
+
...(typeof label === 'string' && { label }),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function queryOutput(options, result) {
|
|
26
|
+
if (options.returns === 'native')
|
|
27
|
+
return result;
|
|
28
|
+
if (options.returns === 'many')
|
|
29
|
+
return result.rows;
|
|
30
|
+
const expected = options.returns === 'oneOrNone' ? 'at most one row' : 'exactly one row';
|
|
31
|
+
if (result.rows.length > 1 || (options.returns === 'one' && result.rows.length === 0)) {
|
|
32
|
+
throw new Error(`${options.label ?? 'Query'} expected ${expected}, but returned ${result.rows.length} rows.`);
|
|
33
|
+
}
|
|
34
|
+
return result.rows[0];
|
|
35
|
+
}
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { QueryResultRow } from 'pg';
|
|
2
|
+
import { type MutationColumns } from './mutations.js';
|
|
3
|
+
export type UnknownRow = Record<string, unknown>;
|
|
4
|
+
/** A parameterized statement. The row type describes one returned record. */
|
|
5
|
+
export interface SqlQuery<Row extends QueryResultRow = UnknownRow> {
|
|
6
|
+
readonly text: string;
|
|
7
|
+
readonly values: readonly unknown[];
|
|
8
|
+
/** Type-only field; a stable key preserves row inference across package copies. */
|
|
9
|
+
readonly __rowType?: Row;
|
|
10
|
+
}
|
|
11
|
+
interface Identifier {
|
|
12
|
+
readonly names: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
/** Quote identifier components separately; ordinary interpolations always bind values. */
|
|
15
|
+
export declare function identifier(...names: string[]): Identifier;
|
|
16
|
+
/** A composable piece of SQL, not an executable query or a result-type declaration. */
|
|
17
|
+
export interface SqlFragment {
|
|
18
|
+
/** Stable across separately installed copies; runtime identity uses a shared symbol. */
|
|
19
|
+
readonly __tenonFragment: true;
|
|
20
|
+
}
|
|
21
|
+
declare function fragment(strings: TemplateStringsArray, ...values: readonly unknown[]): SqlFragment;
|
|
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;
|
|
27
|
+
declare function createQuery<Row extends QueryResultRow = UnknownRow>(strings: TemplateStringsArray, ...values: readonly unknown[]): NoInfer<SqlQuery<Row>>;
|
|
28
|
+
/** Values bind parameters; identifiers quote names; fragments compose authored SQL. */
|
|
29
|
+
export declare const sql: typeof createQuery & {
|
|
30
|
+
fragment: typeof fragment;
|
|
31
|
+
join: typeof join;
|
|
32
|
+
insertValues: typeof insertValues;
|
|
33
|
+
assignments: typeof assignments;
|
|
34
|
+
};
|
|
35
|
+
/** Escape hatch for externally authored SQL. Never concatenate application input into text. */
|
|
36
|
+
export declare function unsafeSql<Row extends QueryResultRow = UnknownRow>(text: string, values: readonly unknown[]): NoInfer<SqlQuery<Row>>;
|
|
37
|
+
export declare function assertQuery(value: unknown): asserts value is SqlQuery;
|
|
38
|
+
export {};
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { joinSeparator, quote } from './sql.js';
|
|
2
|
+
import { assignmentParts, insertParts, } from './mutations.js';
|
|
3
|
+
// Shared symbols let separately installed copies exchange queries and identifiers.
|
|
4
|
+
const queryTag = Symbol.for('@kequtech/tenon-orm/query');
|
|
5
|
+
const identifierTag = Symbol.for('@kequtech/tenon-orm/identifier');
|
|
6
|
+
/** Quote identifier components separately; ordinary interpolations always bind values. */
|
|
7
|
+
export function identifier(...names) {
|
|
8
|
+
if (!names.length)
|
|
9
|
+
throw new Error('An identifier needs at least one name.');
|
|
10
|
+
names.forEach(quote);
|
|
11
|
+
return Object.freeze({ [identifierTag]: true, names: Object.freeze([...names]) });
|
|
12
|
+
}
|
|
13
|
+
const fragmentTag = Symbol.for('@kequtech/tenon-orm/fragment');
|
|
14
|
+
function fragmentParts(value) {
|
|
15
|
+
if (!value ||
|
|
16
|
+
typeof value !== 'object' ||
|
|
17
|
+
!(fragmentTag in value) ||
|
|
18
|
+
!Array.isArray(value[fragmentTag])) {
|
|
19
|
+
throw new Error('Expected a fragment created with sql.fragment or sql.join.');
|
|
20
|
+
}
|
|
21
|
+
return value[fragmentTag];
|
|
22
|
+
}
|
|
23
|
+
/** Preserve SQL text separately from parameters; never rewrite dollar placeholders. */
|
|
24
|
+
function templateParts(strings, values) {
|
|
25
|
+
if (!Array.isArray(strings) ||
|
|
26
|
+
!Array.isArray(strings.raw) ||
|
|
27
|
+
strings.length !== values.length + 1 ||
|
|
28
|
+
strings.some((chunk) => typeof chunk !== 'string')) {
|
|
29
|
+
throw new Error('sql and sql.fragment must be used as template tags.');
|
|
30
|
+
}
|
|
31
|
+
const parts = [strings[0]];
|
|
32
|
+
for (const [i, value] of values.entries()) {
|
|
33
|
+
if (value && typeof value === 'object' && fragmentTag in value) {
|
|
34
|
+
for (const part of fragmentParts(value))
|
|
35
|
+
parts.push(part);
|
|
36
|
+
}
|
|
37
|
+
else if (value &&
|
|
38
|
+
typeof value === 'object' &&
|
|
39
|
+
identifierTag in value &&
|
|
40
|
+
value[identifierTag] === true) {
|
|
41
|
+
if (!('names' in value) || !Array.isArray(value.names) || !value.names.length) {
|
|
42
|
+
throw new Error('An identifier needs at least one name.');
|
|
43
|
+
}
|
|
44
|
+
parts.push(value.names.map(quote).join('.'));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
if (value && typeof value === 'object' && queryTag in value && value[queryTag] === true) {
|
|
48
|
+
throw new Error('Nested SQL queries are not supported; use sql.fragment for composition.');
|
|
49
|
+
}
|
|
50
|
+
parts.push(Object.freeze({ value }));
|
|
51
|
+
}
|
|
52
|
+
parts.push(strings[i + 1]);
|
|
53
|
+
}
|
|
54
|
+
return Object.freeze(parts);
|
|
55
|
+
}
|
|
56
|
+
function fragment(strings, ...values) {
|
|
57
|
+
const parts = templateParts(strings, values);
|
|
58
|
+
if (!parts.some((part) => typeof part !== 'string' || part.trim())) {
|
|
59
|
+
throw new Error('SQL fragments must not be empty.');
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze({ __tenonFragment: true, [fragmentTag]: parts });
|
|
62
|
+
}
|
|
63
|
+
function join(fragments, separator) {
|
|
64
|
+
if (!Array.isArray(fragments) || !fragments.length) {
|
|
65
|
+
throw new Error('sql.join requires at least one fragment; handle an empty list explicitly.');
|
|
66
|
+
}
|
|
67
|
+
const between = joinSeparator(separator);
|
|
68
|
+
const parts = [];
|
|
69
|
+
for (const [i, item] of fragments.entries()) {
|
|
70
|
+
if (i)
|
|
71
|
+
parts.push(between);
|
|
72
|
+
for (const part of fragmentParts(item))
|
|
73
|
+
parts.push(part);
|
|
74
|
+
}
|
|
75
|
+
return Object.freeze({ __tenonFragment: true, [fragmentTag]: Object.freeze(parts) });
|
|
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
|
+
}
|
|
91
|
+
function createQuery(strings, ...values) {
|
|
92
|
+
const parameters = [];
|
|
93
|
+
let text = '';
|
|
94
|
+
for (const part of templateParts(strings, values)) {
|
|
95
|
+
if (typeof part === 'string') {
|
|
96
|
+
text += part;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
parameters.push(part.value);
|
|
100
|
+
text += '$' + parameters.length;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return unsafeSql(text, parameters);
|
|
104
|
+
}
|
|
105
|
+
/** Values bind parameters; identifiers quote names; fragments compose authored SQL. */
|
|
106
|
+
export const sql = Object.assign(createQuery, { fragment, join, insertValues, assignments });
|
|
107
|
+
/** Escape hatch for externally authored SQL. Never concatenate application input into text. */
|
|
108
|
+
export function unsafeSql(text, values) {
|
|
109
|
+
if (typeof text !== 'string' || !text.trim())
|
|
110
|
+
throw new Error('SQL must be a nonempty string.');
|
|
111
|
+
if (!Array.isArray(values))
|
|
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.');
|
|
115
|
+
return Object.freeze({ [queryTag]: true, text, values: Object.freeze([...values]) });
|
|
116
|
+
}
|
|
117
|
+
export function assertQuery(value) {
|
|
118
|
+
if (!value ||
|
|
119
|
+
typeof value !== 'object' ||
|
|
120
|
+
!(queryTag in value) ||
|
|
121
|
+
value[queryTag] !== true ||
|
|
122
|
+
!('text' in value) ||
|
|
123
|
+
typeof value.text !== 'string' ||
|
|
124
|
+
!value.text.trim() ||
|
|
125
|
+
!('values' in value) ||
|
|
126
|
+
!Array.isArray(value.values)) {
|
|
127
|
+
throw new Error('Expected a query created with sql or unsafeSql.');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export { CompositionError } from './static.js';
|
|
3
|
+
export declare function isExport(node: ts.Node, checker: ts.TypeChecker, name: string): boolean;
|
|
4
|
+
export declare function isFragment(type: ts.Type): boolean;
|
|
5
|
+
/** Reconstruct only immutable fragments and literal join lists. Never execute application code. */
|
|
6
|
+
export declare function renderFragment(node: ts.Node, checker: ts.TypeChecker, seen?: Set<ts.Node>): string;
|
|
7
|
+
export declare function renderIdentifier(node: ts.Node, checker: ts.TypeChecker, seen?: Set<ts.Node>): string;
|
|
8
|
+
export declare function isIdentifierType(type: ts.Type): boolean;
|
|
9
|
+
export declare function renderValue(node: ts.Node, checker: ts.TypeChecker, seen?: Set<ts.Node>): string;
|