@kequtech/tenon-orm 0.0.0-stage → 0.1.0
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 +99 -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/query-options.d.ts +15 -0
- package/dist/query-options.js +35 -0
- package/dist/query.d.ts +31 -0
- package/dist/query.js +110 -0
- package/dist/safeql/composition.d.ts +12 -0
- package/dist/safeql/composition.js +194 -0
- package/dist/safeql/eslint.d.ts +2 -0
- package/dist/safeql/eslint.js +45 -0
- package/dist/safeql/plugin.d.ts +3 -0
- package/dist/safeql/plugin.js +43 -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 +289 -0
- package/docs/human-guide.md +253 -0
- package/docs/reference.md +456 -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,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,31 @@
|
|
|
1
|
+
import type { QueryResultRow } from 'pg';
|
|
2
|
+
export type UnknownRow = Record<string, unknown>;
|
|
3
|
+
/** A parameterized statement. The row type describes one returned record. */
|
|
4
|
+
export interface SqlQuery<Row extends QueryResultRow = UnknownRow> {
|
|
5
|
+
readonly text: string;
|
|
6
|
+
readonly values: readonly unknown[];
|
|
7
|
+
/** Type-only field; a stable key preserves row inference across package copies. */
|
|
8
|
+
readonly __rowType?: Row;
|
|
9
|
+
}
|
|
10
|
+
interface Identifier {
|
|
11
|
+
readonly names: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
/** Quote identifier components separately; ordinary interpolations always bind values. */
|
|
14
|
+
export declare function identifier(...names: string[]): Identifier;
|
|
15
|
+
/** A composable piece of SQL, not an executable query or a result-type declaration. */
|
|
16
|
+
export interface SqlFragment {
|
|
17
|
+
/** Stable across separately installed copies; runtime identity uses a shared symbol. */
|
|
18
|
+
readonly __tenonFragment: true;
|
|
19
|
+
}
|
|
20
|
+
declare function fragment(strings: TemplateStringsArray, ...values: readonly unknown[]): SqlFragment;
|
|
21
|
+
declare function join(fragments: readonly SqlFragment[], separator: 'AND' | 'OR' | ','): SqlFragment;
|
|
22
|
+
declare function createQuery<Row extends QueryResultRow = UnknownRow>(strings: TemplateStringsArray, ...values: readonly unknown[]): NoInfer<SqlQuery<Row>>;
|
|
23
|
+
/** Values bind parameters; identifiers quote names; fragments compose authored SQL. */
|
|
24
|
+
export declare const sql: typeof createQuery & {
|
|
25
|
+
fragment: typeof fragment;
|
|
26
|
+
join: typeof join;
|
|
27
|
+
};
|
|
28
|
+
/** Escape hatch for externally authored SQL. Never concatenate application input into text. */
|
|
29
|
+
export declare function unsafeSql<Row extends QueryResultRow = UnknownRow>(text: string, values: readonly unknown[]): NoInfer<SqlQuery<Row>>;
|
|
30
|
+
export declare function assertQuery(value: unknown): asserts value is SqlQuery;
|
|
31
|
+
export {};
|
package/dist/query.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { joinSeparator, quote } from './sql.js';
|
|
2
|
+
// Shared symbols let separately installed copies exchange queries and identifiers.
|
|
3
|
+
const queryTag = Symbol.for('@kequtech/tenon-orm/query');
|
|
4
|
+
const identifierTag = Symbol.for('@kequtech/tenon-orm/identifier');
|
|
5
|
+
/** Quote identifier components separately; ordinary interpolations always bind values. */
|
|
6
|
+
export function identifier(...names) {
|
|
7
|
+
if (!names.length)
|
|
8
|
+
throw new Error('An identifier needs at least one name.');
|
|
9
|
+
names.forEach(quote);
|
|
10
|
+
return Object.freeze({ [identifierTag]: true, names: Object.freeze([...names]) });
|
|
11
|
+
}
|
|
12
|
+
const fragmentTag = Symbol.for('@kequtech/tenon-orm/fragment');
|
|
13
|
+
function fragmentParts(value) {
|
|
14
|
+
if (!value ||
|
|
15
|
+
typeof value !== 'object' ||
|
|
16
|
+
!(fragmentTag in value) ||
|
|
17
|
+
!Array.isArray(value[fragmentTag])) {
|
|
18
|
+
throw new Error('Expected a fragment created with sql.fragment or sql.join.');
|
|
19
|
+
}
|
|
20
|
+
return value[fragmentTag];
|
|
21
|
+
}
|
|
22
|
+
/** Preserve SQL text separately from parameters; never rewrite dollar placeholders. */
|
|
23
|
+
function templateParts(strings, values) {
|
|
24
|
+
if (!Array.isArray(strings) ||
|
|
25
|
+
!Array.isArray(strings.raw) ||
|
|
26
|
+
strings.length !== values.length + 1 ||
|
|
27
|
+
strings.some((chunk) => typeof chunk !== 'string')) {
|
|
28
|
+
throw new Error('sql and sql.fragment must be used as template tags.');
|
|
29
|
+
}
|
|
30
|
+
const parts = [strings[0]];
|
|
31
|
+
for (const [i, value] of values.entries()) {
|
|
32
|
+
if (value && typeof value === 'object' && fragmentTag in value) {
|
|
33
|
+
parts.push(...fragmentParts(value));
|
|
34
|
+
}
|
|
35
|
+
else if (value &&
|
|
36
|
+
typeof value === 'object' &&
|
|
37
|
+
identifierTag in value &&
|
|
38
|
+
value[identifierTag] === true) {
|
|
39
|
+
if (!('names' in value) || !Array.isArray(value.names) || !value.names.length) {
|
|
40
|
+
throw new Error('An identifier needs at least one name.');
|
|
41
|
+
}
|
|
42
|
+
parts.push(value.names.map(quote).join('.'));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
if (value && typeof value === 'object' && queryTag in value && value[queryTag] === true) {
|
|
46
|
+
throw new Error('Nested SQL queries are not supported; use sql.fragment for composition.');
|
|
47
|
+
}
|
|
48
|
+
parts.push(Object.freeze({ value }));
|
|
49
|
+
}
|
|
50
|
+
parts.push(strings[i + 1]);
|
|
51
|
+
}
|
|
52
|
+
return Object.freeze(parts);
|
|
53
|
+
}
|
|
54
|
+
function fragment(strings, ...values) {
|
|
55
|
+
const parts = templateParts(strings, values);
|
|
56
|
+
if (!parts.some((part) => typeof part !== 'string' || part.trim())) {
|
|
57
|
+
throw new Error('SQL fragments must not be empty.');
|
|
58
|
+
}
|
|
59
|
+
return Object.freeze({ __tenonFragment: true, [fragmentTag]: parts });
|
|
60
|
+
}
|
|
61
|
+
function join(fragments, separator) {
|
|
62
|
+
if (!Array.isArray(fragments) || !fragments.length) {
|
|
63
|
+
throw new Error('sql.join requires at least one fragment; handle an empty list explicitly.');
|
|
64
|
+
}
|
|
65
|
+
const between = joinSeparator(separator);
|
|
66
|
+
const parts = [];
|
|
67
|
+
for (const [i, item] of fragments.entries()) {
|
|
68
|
+
if (i)
|
|
69
|
+
parts.push(between);
|
|
70
|
+
parts.push(...fragmentParts(item));
|
|
71
|
+
}
|
|
72
|
+
return Object.freeze({ __tenonFragment: true, [fragmentTag]: Object.freeze(parts) });
|
|
73
|
+
}
|
|
74
|
+
function createQuery(strings, ...values) {
|
|
75
|
+
const parameters = [];
|
|
76
|
+
let text = '';
|
|
77
|
+
for (const part of templateParts(strings, values)) {
|
|
78
|
+
if (typeof part === 'string') {
|
|
79
|
+
text += part;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
parameters.push(part.value);
|
|
83
|
+
text += '$' + parameters.length;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return unsafeSql(text, parameters);
|
|
87
|
+
}
|
|
88
|
+
/** Values bind parameters; identifiers quote names; fragments compose authored SQL. */
|
|
89
|
+
export const sql = Object.assign(createQuery, { fragment, join });
|
|
90
|
+
/** Escape hatch for externally authored SQL. Never concatenate application input into text. */
|
|
91
|
+
export function unsafeSql(text, values) {
|
|
92
|
+
if (typeof text !== 'string' || !text.trim())
|
|
93
|
+
throw new Error('SQL must be a nonempty string.');
|
|
94
|
+
if (!Array.isArray(values))
|
|
95
|
+
throw new Error('SQL input must be an explicit array.');
|
|
96
|
+
return Object.freeze({ [queryTag]: true, text, values: Object.freeze([...values]) });
|
|
97
|
+
}
|
|
98
|
+
export function assertQuery(value) {
|
|
99
|
+
if (!value ||
|
|
100
|
+
typeof value !== 'object' ||
|
|
101
|
+
!(queryTag in value) ||
|
|
102
|
+
value[queryTag] !== true ||
|
|
103
|
+
!('text' in value) ||
|
|
104
|
+
typeof value.text !== 'string' ||
|
|
105
|
+
!value.text.trim() ||
|
|
106
|
+
!('values' in value) ||
|
|
107
|
+
!Array.isArray(value.values)) {
|
|
108
|
+
throw new Error('Expected a query created with sql or unsafeSql.');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
export declare class CompositionError extends Error {
|
|
3
|
+
readonly name = "TenonCompositionError";
|
|
4
|
+
constructor(detail: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function isExport(node: ts.Node, checker: ts.TypeChecker, name: string): boolean;
|
|
7
|
+
export declare function isFragment(type: ts.Type): boolean;
|
|
8
|
+
/** Reconstruct only immutable fragments and literal join lists. Never execute application code. */
|
|
9
|
+
export declare function renderFragment(node: ts.Node, checker: ts.TypeChecker, seen?: Set<ts.Node>): string;
|
|
10
|
+
export declare function renderIdentifier(node: ts.Node, checker: ts.TypeChecker, seen?: Set<ts.Node>): string;
|
|
11
|
+
export declare function isIdentifierType(type: ts.Type): boolean;
|
|
12
|
+
export declare function renderValue(node: ts.Node, checker: ts.TypeChecker, seen?: Set<ts.Node>): string;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import ts from 'typescript';
|
|
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
|
+
}
|
|
11
|
+
/** Recognize Tenon's declarations without claiming another library's SQL tags. */
|
|
12
|
+
const tenonModules = new Map();
|
|
13
|
+
function isTenonModule(filename) {
|
|
14
|
+
const cached = tenonModules.get(filename);
|
|
15
|
+
if (cached !== undefined)
|
|
16
|
+
return cached;
|
|
17
|
+
let matches = false;
|
|
18
|
+
if (['query.ts', 'query.d.ts'].includes(basename(filename)) &&
|
|
19
|
+
['src', 'dist'].includes(basename(dirname(filename)))) {
|
|
20
|
+
try {
|
|
21
|
+
const manifest = JSON.parse(readFileSync(join(dirname(filename), '..', 'package.json'), 'utf8'));
|
|
22
|
+
matches =
|
|
23
|
+
typeof manifest === 'object' &&
|
|
24
|
+
manifest !== null &&
|
|
25
|
+
'name' in manifest &&
|
|
26
|
+
manifest.name === '@kequtech/tenon-orm';
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// An unrelated or unavailable declaration is not a Tenon export.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
tenonModules.set(filename, matches);
|
|
33
|
+
return matches;
|
|
34
|
+
}
|
|
35
|
+
export function isExport(node, checker, name) {
|
|
36
|
+
// Call signatures retain their declaration through local aliases, object properties,
|
|
37
|
+
// and typeof annotations. Symbol names alone only recognize direct imports.
|
|
38
|
+
return checker
|
|
39
|
+
.getTypeAtLocation(node)
|
|
40
|
+
.getCallSignatures()
|
|
41
|
+
.some((signature) => {
|
|
42
|
+
const declaration = signature.declaration;
|
|
43
|
+
return (declaration &&
|
|
44
|
+
ts.isFunctionDeclaration(declaration) &&
|
|
45
|
+
declaration.name?.text === (name === 'sql' ? 'createQuery' : name) &&
|
|
46
|
+
isTenonModule(declaration.getSourceFile().fileName));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
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
|
+
export function isFragment(type) {
|
|
73
|
+
return type.isUnion()
|
|
74
|
+
? type.types.some(isFragment)
|
|
75
|
+
: type.getProperty('__tenonFragment') !== undefined;
|
|
76
|
+
}
|
|
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
|
+
/** Reconstruct only immutable fragments and literal join lists. Never execute application code. */
|
|
93
|
+
export function renderFragment(node, checker, seen = new Set()) {
|
|
94
|
+
node = unwrap(node);
|
|
95
|
+
if (seen.has(node))
|
|
96
|
+
throw new CompositionError('Circular fragment reference.');
|
|
97
|
+
const visited = new Set(seen).add(node);
|
|
98
|
+
if (ts.isIdentifier(node)) {
|
|
99
|
+
const value = initializer(node, checker);
|
|
100
|
+
if (value)
|
|
101
|
+
return renderFragment(value, checker, visited);
|
|
102
|
+
}
|
|
103
|
+
if (ts.isTaggedTemplateExpression(node) &&
|
|
104
|
+
ts.isPropertyAccessExpression(node.tag) &&
|
|
105
|
+
node.tag.name.text === 'fragment' &&
|
|
106
|
+
isExport(node.tag.expression, checker, 'sql')) {
|
|
107
|
+
const template = node.template;
|
|
108
|
+
if (ts.isNoSubstitutionTemplateLiteral(template))
|
|
109
|
+
return template.text;
|
|
110
|
+
let text = template.head.text;
|
|
111
|
+
for (const span of template.templateSpans) {
|
|
112
|
+
text += renderValue(span.expression, checker, visited) + span.literal.text;
|
|
113
|
+
}
|
|
114
|
+
return text;
|
|
115
|
+
}
|
|
116
|
+
if (ts.isCallExpression(node) &&
|
|
117
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
118
|
+
node.expression.name.text === 'join' &&
|
|
119
|
+
isExport(node.expression.expression, checker, 'sql')) {
|
|
120
|
+
const [items, separator] = node.arguments;
|
|
121
|
+
const list = items && unwrap(items);
|
|
122
|
+
if (!list ||
|
|
123
|
+
!ts.isArrayLiteralExpression(list) ||
|
|
124
|
+
!list.elements.length ||
|
|
125
|
+
!separator ||
|
|
126
|
+
node.arguments.length !== 2) {
|
|
127
|
+
throw new CompositionError('sql.join needs a nonempty literal array and a separator for static checking.');
|
|
128
|
+
}
|
|
129
|
+
const value = literalString(separator, checker);
|
|
130
|
+
if (value === undefined) {
|
|
131
|
+
throw new CompositionError('sql.join separators must be literal strings or const strings.');
|
|
132
|
+
}
|
|
133
|
+
let between;
|
|
134
|
+
try {
|
|
135
|
+
between = joinSeparator(value);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (!(error instanceof Error))
|
|
139
|
+
throw error;
|
|
140
|
+
throw new CompositionError(error.message);
|
|
141
|
+
}
|
|
142
|
+
return list.elements.map((item) => renderFragment(item, checker, visited)).join(between);
|
|
143
|
+
}
|
|
144
|
+
throw new CompositionError('Only sql.fragment templates, const fragment references, and sql.join with literal arrays are supported.');
|
|
145
|
+
}
|
|
146
|
+
export function renderIdentifier(node, checker, seen = new Set()) {
|
|
147
|
+
node = unwrap(node);
|
|
148
|
+
if (seen.has(node))
|
|
149
|
+
throw new CompositionError('Circular identifier reference.');
|
|
150
|
+
const visited = new Set(seen).add(node);
|
|
151
|
+
if (ts.isIdentifier(node)) {
|
|
152
|
+
const value = initializer(node, checker);
|
|
153
|
+
if (value)
|
|
154
|
+
return renderIdentifier(value, checker, visited);
|
|
155
|
+
}
|
|
156
|
+
if (ts.isCallExpression(node) &&
|
|
157
|
+
isExport(node.expression, checker, 'identifier') &&
|
|
158
|
+
node.arguments.length) {
|
|
159
|
+
return node.arguments
|
|
160
|
+
.map((part) => {
|
|
161
|
+
const value = literalString(part, checker);
|
|
162
|
+
if (value === undefined) {
|
|
163
|
+
throw new CompositionError('Identifier names must be literal strings or const strings.');
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
return quote(value);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (!(error instanceof Error))
|
|
170
|
+
throw error;
|
|
171
|
+
throw new CompositionError(error.message);
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
.join('.');
|
|
175
|
+
}
|
|
176
|
+
throw new CompositionError('The identifier is selected at runtime.');
|
|
177
|
+
}
|
|
178
|
+
export function isIdentifierType(type) {
|
|
179
|
+
if (type.isUnion())
|
|
180
|
+
return type.types.some(isIdentifierType);
|
|
181
|
+
const symbol = type.getSymbol();
|
|
182
|
+
return (symbol?.getName() === 'Identifier' &&
|
|
183
|
+
Boolean(symbol.declarations?.some((declaration) => isTenonModule(declaration.getSourceFile().fileName))));
|
|
184
|
+
}
|
|
185
|
+
export function renderValue(node, checker, seen = new Set()) {
|
|
186
|
+
const type = checker.getTypeAtLocation(node);
|
|
187
|
+
if (isFragment(type))
|
|
188
|
+
return renderFragment(node, checker, seen);
|
|
189
|
+
if (isIdentifierType(type))
|
|
190
|
+
return renderIdentifier(node, checker, seen);
|
|
191
|
+
// Match runtime SQL: PostgreSQL infers parameter types from SQL context, never
|
|
192
|
+
// from JavaScript types. SafeQL numbers these tokens, respecting quotes/comments.
|
|
193
|
+
return '$N';
|
|
194
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { CompositionError, isExport, renderValue } from './composition.js';
|
|
3
|
+
export const eslintPlugin = {
|
|
4
|
+
rules: {
|
|
5
|
+
'check-composition': {
|
|
6
|
+
meta: {
|
|
7
|
+
type: 'problem',
|
|
8
|
+
docs: { description: 'Report Tenon queries that cannot be statically checked.' },
|
|
9
|
+
schema: [],
|
|
10
|
+
},
|
|
11
|
+
create(context) {
|
|
12
|
+
// ESLint leaves parser services untyped; require the TypeScript parser's public services.
|
|
13
|
+
const { program, esTreeNodeToTSNodeMap: nodes } = context.sourceCode.parserServices;
|
|
14
|
+
if (!program || !nodes) {
|
|
15
|
+
throw new Error('tenon/check-composition requires type-aware TypeScript parsing. Enable parserOptions.project or projectService.');
|
|
16
|
+
}
|
|
17
|
+
const checker = program.getTypeChecker();
|
|
18
|
+
return {
|
|
19
|
+
TaggedTemplateExpression(node) {
|
|
20
|
+
const template = nodes.get(node);
|
|
21
|
+
if (!template ||
|
|
22
|
+
!ts.isTaggedTemplateExpression(template) ||
|
|
23
|
+
!isExport(template.tag, checker, 'sql'))
|
|
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) {
|
|
31
|
+
renderValue(span.expression, checker);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (!(error instanceof CompositionError))
|
|
37
|
+
throw error;
|
|
38
|
+
context.report({ node, message: error.message });
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|