metal-orm 1.0.11 → 1.0.13
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 +21 -18
- package/dist/decorators/index.cjs +317 -34
- package/dist/decorators/index.cjs.map +1 -1
- package/dist/decorators/index.d.cts +1 -1
- package/dist/decorators/index.d.ts +1 -1
- package/dist/decorators/index.js +317 -34
- package/dist/decorators/index.js.map +1 -1
- package/dist/index.cjs +1965 -267
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +273 -23
- package/dist/index.d.ts +273 -23
- package/dist/index.js +1947 -267
- package/dist/index.js.map +1 -1
- package/dist/{select-654m4qy8.d.cts → select-CCp1oz9p.d.cts} +254 -4
- package/dist/{select-654m4qy8.d.ts → select-CCp1oz9p.d.ts} +254 -4
- package/package.json +3 -2
- package/src/core/ast/query.ts +40 -22
- package/src/core/ddl/dialects/base-schema-dialect.ts +48 -0
- package/src/core/ddl/dialects/index.ts +5 -0
- package/src/core/ddl/dialects/mssql-schema-dialect.ts +97 -0
- package/src/core/ddl/dialects/mysql-schema-dialect.ts +109 -0
- package/src/core/ddl/dialects/postgres-schema-dialect.ts +99 -0
- package/src/core/ddl/dialects/sqlite-schema-dialect.ts +103 -0
- package/src/core/ddl/introspect/mssql.ts +149 -0
- package/src/core/ddl/introspect/mysql.ts +99 -0
- package/src/core/ddl/introspect/postgres.ts +154 -0
- package/src/core/ddl/introspect/sqlite.ts +66 -0
- package/src/core/ddl/introspect/types.ts +19 -0
- package/src/core/ddl/introspect/utils.ts +27 -0
- package/src/core/ddl/schema-diff.ts +179 -0
- package/src/core/ddl/schema-generator.ts +229 -0
- package/src/core/ddl/schema-introspect.ts +32 -0
- package/src/core/ddl/schema-types.ts +39 -0
- package/src/core/dialect/abstract.ts +122 -37
- package/src/core/dialect/base/sql-dialect.ts +204 -0
- package/src/core/dialect/mssql/index.ts +125 -80
- package/src/core/dialect/mysql/index.ts +18 -112
- package/src/core/dialect/postgres/index.ts +29 -126
- package/src/core/dialect/sqlite/index.ts +28 -129
- package/src/index.ts +4 -0
- package/src/orm/execute.ts +25 -16
- package/src/orm/orm-context.ts +60 -55
- package/src/orm/query-logger.ts +38 -0
- package/src/orm/relations/belongs-to.ts +42 -26
- package/src/orm/relations/has-many.ts +41 -25
- package/src/orm/relations/many-to-many.ts +43 -27
- package/src/orm/unit-of-work.ts +60 -23
- package/src/query-builder/hydration-manager.ts +229 -25
- package/src/query-builder/query-ast-service.ts +27 -12
- package/src/query-builder/select-query-state.ts +24 -12
- package/src/query-builder/select.ts +58 -14
- package/src/schema/column.ts +206 -27
- package/src/schema/table.ts +89 -32
- package/src/schema/types.ts +8 -5
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { SchemaIntrospector, IntrospectOptions } from './types.js';
|
|
2
|
+
import { queryRows, shouldIncludeTable } from './utils.js';
|
|
3
|
+
import { DatabaseSchema, DatabaseTable, DatabaseIndex, DatabaseColumn } from '../schema-types.js';
|
|
4
|
+
import { DbExecutor } from '../../orm/db-executor.js';
|
|
5
|
+
|
|
6
|
+
export const postgresIntrospector: SchemaIntrospector = {
|
|
7
|
+
async introspect(executor: DbExecutor, options: IntrospectOptions): Promise<DatabaseSchema> {
|
|
8
|
+
const schema = options.schema || 'public';
|
|
9
|
+
const tables: DatabaseTable[] = [];
|
|
10
|
+
|
|
11
|
+
const columnRows = await queryRows(
|
|
12
|
+
executor,
|
|
13
|
+
`
|
|
14
|
+
SELECT table_schema, table_name, column_name, data_type, is_nullable, column_default
|
|
15
|
+
FROM information_schema.columns
|
|
16
|
+
WHERE table_schema = $1
|
|
17
|
+
ORDER BY table_name, ordinal_position
|
|
18
|
+
`,
|
|
19
|
+
[schema]
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const pkRows = await queryRows(
|
|
23
|
+
executor,
|
|
24
|
+
`
|
|
25
|
+
SELECT
|
|
26
|
+
ns.nspname AS table_schema,
|
|
27
|
+
tbl.relname AS table_name,
|
|
28
|
+
array_agg(att.attname ORDER BY arr.idx) AS pk_columns
|
|
29
|
+
FROM pg_index i
|
|
30
|
+
JOIN pg_class tbl ON tbl.oid = i.indrelid
|
|
31
|
+
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
|
|
32
|
+
JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS arr(attnum, idx) ON TRUE
|
|
33
|
+
LEFT JOIN pg_attribute att ON att.attrelid = tbl.oid AND att.attnum = arr.attnum
|
|
34
|
+
WHERE i.indisprimary AND ns.nspname = $1
|
|
35
|
+
GROUP BY ns.nspname, tbl.relname
|
|
36
|
+
`,
|
|
37
|
+
[schema]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const pkMap = new Map<string, string[]>();
|
|
41
|
+
pkRows.forEach(r => {
|
|
42
|
+
pkMap.set(`${r.table_schema}.${r.table_name}`, r.pk_columns || []);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const fkRows = await queryRows(
|
|
46
|
+
executor,
|
|
47
|
+
`
|
|
48
|
+
SELECT
|
|
49
|
+
tc.table_schema,
|
|
50
|
+
tc.table_name,
|
|
51
|
+
kcu.column_name,
|
|
52
|
+
ccu.table_schema AS foreign_table_schema,
|
|
53
|
+
ccu.table_name AS foreign_table_name,
|
|
54
|
+
ccu.column_name AS foreign_column_name,
|
|
55
|
+
rc.update_rule AS on_update,
|
|
56
|
+
rc.delete_rule AS on_delete
|
|
57
|
+
FROM information_schema.table_constraints AS tc
|
|
58
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
59
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
60
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
61
|
+
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
|
62
|
+
JOIN information_schema.referential_constraints rc
|
|
63
|
+
ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.table_schema
|
|
64
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1
|
|
65
|
+
`,
|
|
66
|
+
[schema]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const fkMap = new Map<string, any[]>();
|
|
70
|
+
fkRows.forEach(r => {
|
|
71
|
+
const key = `${r.table_schema}.${r.table_name}.${r.column_name}`;
|
|
72
|
+
fkMap.set(key, [{
|
|
73
|
+
table: `${r.foreign_table_schema}.${r.foreign_table_name}`,
|
|
74
|
+
column: r.foreign_column_name,
|
|
75
|
+
onDelete: r.on_delete?.toUpperCase(),
|
|
76
|
+
onUpdate: r.on_update?.toUpperCase()
|
|
77
|
+
}]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const indexRows = await queryRows(
|
|
81
|
+
executor,
|
|
82
|
+
`
|
|
83
|
+
SELECT
|
|
84
|
+
ns.nspname AS table_schema,
|
|
85
|
+
tbl.relname AS table_name,
|
|
86
|
+
idx.relname AS index_name,
|
|
87
|
+
i.indisunique AS is_unique,
|
|
88
|
+
pg_get_expr(i.indpred, i.indrelid) AS predicate,
|
|
89
|
+
array_agg(att.attname ORDER BY arr.idx) AS column_names
|
|
90
|
+
FROM pg_index i
|
|
91
|
+
JOIN pg_class tbl ON tbl.oid = i.indrelid
|
|
92
|
+
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
|
|
93
|
+
JOIN pg_class idx ON idx.oid = i.indexrelid
|
|
94
|
+
JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS arr(attnum, idx) ON TRUE
|
|
95
|
+
LEFT JOIN pg_attribute att ON att.attrelid = tbl.oid AND att.attnum = arr.attnum
|
|
96
|
+
WHERE ns.nspname = $1 AND NOT i.indisprimary
|
|
97
|
+
GROUP BY ns.nspname, tbl.relname, idx.relname, i.indisunique, i.indpred
|
|
98
|
+
`,
|
|
99
|
+
[schema]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const tablesByKey = new Map<string, DatabaseTable>();
|
|
103
|
+
|
|
104
|
+
columnRows.forEach(r => {
|
|
105
|
+
const key = `${r.table_schema}.${r.table_name}`;
|
|
106
|
+
if (!shouldIncludeTable(r.table_name, options)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!tablesByKey.has(key)) {
|
|
110
|
+
tablesByKey.set(key, {
|
|
111
|
+
name: r.table_name,
|
|
112
|
+
schema: r.table_schema,
|
|
113
|
+
columns: [],
|
|
114
|
+
primaryKey: pkMap.get(key) || [],
|
|
115
|
+
indexes: []
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const cols = tablesByKey.get(key)!;
|
|
119
|
+
const fk = fkMap.get(`${r.table_schema}.${r.table_name}.${r.column_name}`)?.[0];
|
|
120
|
+
const column: DatabaseColumn = {
|
|
121
|
+
name: r.column_name,
|
|
122
|
+
type: r.data_type,
|
|
123
|
+
notNull: r.is_nullable === 'NO',
|
|
124
|
+
default: r.column_default ?? undefined,
|
|
125
|
+
references: fk
|
|
126
|
+
? {
|
|
127
|
+
table: fk.table,
|
|
128
|
+
column: fk.column,
|
|
129
|
+
onDelete: fk.onDelete,
|
|
130
|
+
onUpdate: fk.onUpdate
|
|
131
|
+
}
|
|
132
|
+
: undefined
|
|
133
|
+
};
|
|
134
|
+
cols.columns.push(column);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
indexRows.forEach(r => {
|
|
138
|
+
const key = `${r.table_schema}.${r.table_name}`;
|
|
139
|
+
const table = tablesByKey.get(key);
|
|
140
|
+
if (!table) return;
|
|
141
|
+
const idx: DatabaseIndex = {
|
|
142
|
+
name: r.index_name,
|
|
143
|
+
columns: (r.column_names || []).map((c: string) => ({ column: c })),
|
|
144
|
+
unique: !!r.is_unique,
|
|
145
|
+
where: r.predicate || undefined
|
|
146
|
+
};
|
|
147
|
+
table.indexes = table.indexes || [];
|
|
148
|
+
table.indexes.push(idx);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
tables.push(...tablesByKey.values());
|
|
152
|
+
return { tables };
|
|
153
|
+
}
|
|
154
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { SchemaIntrospector, IntrospectOptions } from './types.js';
|
|
2
|
+
import { queryRows, shouldIncludeTable } from './utils.js';
|
|
3
|
+
import { DatabaseSchema, DatabaseTable, DatabaseIndex } from '../schema-types.js';
|
|
4
|
+
import { DbExecutor } from '../../orm/db-executor.js';
|
|
5
|
+
|
|
6
|
+
const escapeSingleQuotes = (name: string) => name.replace(/'/g, "''");
|
|
7
|
+
|
|
8
|
+
export const sqliteIntrospector: SchemaIntrospector = {
|
|
9
|
+
async introspect(executor: DbExecutor, options: IntrospectOptions): Promise<DatabaseSchema> {
|
|
10
|
+
const tables: DatabaseTable[] = [];
|
|
11
|
+
const tableRows = await queryRows(
|
|
12
|
+
executor,
|
|
13
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%';`
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
for (const row of tableRows) {
|
|
17
|
+
const name = row.name as string;
|
|
18
|
+
if (!shouldIncludeTable(name, options)) continue;
|
|
19
|
+
const table: DatabaseTable = { name, columns: [], primaryKey: [], indexes: [] };
|
|
20
|
+
|
|
21
|
+
const cols = await queryRows(executor, `PRAGMA table_info('${escapeSingleQuotes(name)}');`);
|
|
22
|
+
cols.forEach(c => {
|
|
23
|
+
table.columns.push({
|
|
24
|
+
name: c.name,
|
|
25
|
+
type: c.type,
|
|
26
|
+
notNull: c.notnull === 1,
|
|
27
|
+
default: c.dflt_value ?? undefined,
|
|
28
|
+
autoIncrement: false
|
|
29
|
+
});
|
|
30
|
+
if (c.pk && c.pk > 0) {
|
|
31
|
+
table.primaryKey = table.primaryKey || [];
|
|
32
|
+
table.primaryKey.push(c.name);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const fkRows = await queryRows(executor, `PRAGMA foreign_key_list('${escapeSingleQuotes(name)}');`);
|
|
37
|
+
fkRows.forEach(fk => {
|
|
38
|
+
const col = table.columns.find(c => c.name === fk.from);
|
|
39
|
+
if (col) {
|
|
40
|
+
col.references = {
|
|
41
|
+
table: fk.table,
|
|
42
|
+
column: fk.to,
|
|
43
|
+
onDelete: fk.on_delete?.toUpperCase(),
|
|
44
|
+
onUpdate: fk.on_update?.toUpperCase()
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const idxList = await queryRows(executor, `PRAGMA index_list('${escapeSingleQuotes(name)}');`);
|
|
50
|
+
for (const idx of idxList) {
|
|
51
|
+
const idxName = idx.name as string;
|
|
52
|
+
const columnsInfo = await queryRows(executor, `PRAGMA index_info('${escapeSingleQuotes(idxName)}');`);
|
|
53
|
+
const idxEntry: DatabaseIndex = {
|
|
54
|
+
name: idxName,
|
|
55
|
+
columns: columnsInfo.map(ci => ({ column: ci.name as string })),
|
|
56
|
+
unique: idx.unique === 1
|
|
57
|
+
};
|
|
58
|
+
table.indexes!.push(idxEntry);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
tables.push(table);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { tables };
|
|
65
|
+
}
|
|
66
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DbExecutor } from '../../../orm/db-executor.js';
|
|
2
|
+
import { DatabaseSchema } from '../schema-types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dialect-agnostic options for schema introspection.
|
|
6
|
+
*/
|
|
7
|
+
export interface IntrospectOptions {
|
|
8
|
+
/** Dialect-specific schema/catalog. Postgres: schema; MySQL: database; MSSQL: schema. */
|
|
9
|
+
schema?: string;
|
|
10
|
+
includeTables?: string[];
|
|
11
|
+
excludeTables?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Strategy interface implemented per dialect to introspect an existing database schema.
|
|
16
|
+
*/
|
|
17
|
+
export interface SchemaIntrospector {
|
|
18
|
+
introspect(executor: DbExecutor, options: IntrospectOptions): Promise<DatabaseSchema>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DbExecutor, QueryResult } from '../../orm/db-executor.js';
|
|
2
|
+
import { IntrospectOptions } from './types.js';
|
|
3
|
+
|
|
4
|
+
export const toRows = (result: QueryResult | undefined): Record<string, any>[] => {
|
|
5
|
+
if (!result) return [];
|
|
6
|
+
return result.values.map(row =>
|
|
7
|
+
result.columns.reduce<Record<string, any>>((acc, col, idx) => {
|
|
8
|
+
acc[col] = row[idx];
|
|
9
|
+
return acc;
|
|
10
|
+
}, {})
|
|
11
|
+
);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const queryRows = async (
|
|
15
|
+
executor: DbExecutor,
|
|
16
|
+
sql: string,
|
|
17
|
+
params: unknown[] = []
|
|
18
|
+
): Promise<Record<string, any>[]> => {
|
|
19
|
+
const [first] = await executor.executeSql(sql, params);
|
|
20
|
+
return toRows(first);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const shouldIncludeTable = (name: string, options: IntrospectOptions): boolean => {
|
|
24
|
+
if (options.includeTables && !options.includeTables.includes(name)) return false;
|
|
25
|
+
if (options.excludeTables && options.excludeTables.includes(name)) return false;
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { TableDef } from '../../schema/table.js';
|
|
2
|
+
import { DbExecutor } from '../../orm/db-executor.js';
|
|
3
|
+
import {
|
|
4
|
+
SchemaDialect,
|
|
5
|
+
deriveIndexName,
|
|
6
|
+
generateCreateTableSql,
|
|
7
|
+
renderColumnDefinition
|
|
8
|
+
} from './schema-generator.js';
|
|
9
|
+
import { DatabaseSchema, DatabaseTable } from './schema-types.js';
|
|
10
|
+
|
|
11
|
+
export type SchemaChangeKind =
|
|
12
|
+
| 'createTable'
|
|
13
|
+
| 'dropTable'
|
|
14
|
+
| 'addColumn'
|
|
15
|
+
| 'dropColumn'
|
|
16
|
+
| 'alterColumn'
|
|
17
|
+
| 'addIndex'
|
|
18
|
+
| 'dropIndex';
|
|
19
|
+
|
|
20
|
+
export interface SchemaChange {
|
|
21
|
+
kind: SchemaChangeKind;
|
|
22
|
+
table: string;
|
|
23
|
+
description: string;
|
|
24
|
+
statements: string[];
|
|
25
|
+
safe: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SchemaPlan {
|
|
29
|
+
changes: SchemaChange[];
|
|
30
|
+
warnings: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SchemaDiffOptions {
|
|
34
|
+
/** Allow destructive operations (drops) */
|
|
35
|
+
allowDestructive?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tableKey = (name: string, schema?: string) => (schema ? `${schema}.${name}` : name);
|
|
39
|
+
|
|
40
|
+
const mapTables = (schema: DatabaseSchema) => {
|
|
41
|
+
const map = new Map<string, DatabaseTable>();
|
|
42
|
+
for (const table of schema.tables) {
|
|
43
|
+
map.set(tableKey(table.name, table.schema), table);
|
|
44
|
+
}
|
|
45
|
+
return map;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const buildAddColumnSql = (table: TableDef, colName: string, dialect: SchemaDialect): string => {
|
|
49
|
+
const column = table.columns[colName];
|
|
50
|
+
const rendered = renderColumnDefinition(table, column, dialect);
|
|
51
|
+
return `ALTER TABLE ${dialect.formatTableName(table)} ADD ${rendered.sql};`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const diffSchema = (
|
|
55
|
+
expectedTables: TableDef[],
|
|
56
|
+
actualSchema: DatabaseSchema,
|
|
57
|
+
dialect: SchemaDialect,
|
|
58
|
+
options: SchemaDiffOptions = {}
|
|
59
|
+
): SchemaPlan => {
|
|
60
|
+
const allowDestructive = options.allowDestructive ?? false;
|
|
61
|
+
const plan: SchemaPlan = { changes: [], warnings: [] };
|
|
62
|
+
|
|
63
|
+
const actualMap = mapTables(actualSchema);
|
|
64
|
+
|
|
65
|
+
// Create missing tables and indexes
|
|
66
|
+
for (const table of expectedTables) {
|
|
67
|
+
const key = tableKey(table.name, table.schema);
|
|
68
|
+
const actual = actualMap.get(key);
|
|
69
|
+
if (!actual) {
|
|
70
|
+
const { tableSql, indexSql } = generateCreateTableSql(table, dialect);
|
|
71
|
+
plan.changes.push({
|
|
72
|
+
kind: 'createTable',
|
|
73
|
+
table: key,
|
|
74
|
+
description: `Create table ${key}`,
|
|
75
|
+
statements: [tableSql, ...indexSql],
|
|
76
|
+
safe: true
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Columns
|
|
82
|
+
const actualCols = new Map(actual.columns.map(c => [c.name, c]));
|
|
83
|
+
for (const colName of Object.keys(table.columns)) {
|
|
84
|
+
if (!actualCols.has(colName)) {
|
|
85
|
+
plan.changes.push({
|
|
86
|
+
kind: 'addColumn',
|
|
87
|
+
table: key,
|
|
88
|
+
description: `Add column ${colName} to ${key}`,
|
|
89
|
+
statements: [buildAddColumnSql(table, colName, dialect)],
|
|
90
|
+
safe: true
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const colName of actualCols.keys()) {
|
|
95
|
+
if (!table.columns[colName]) {
|
|
96
|
+
plan.changes.push({
|
|
97
|
+
kind: 'dropColumn',
|
|
98
|
+
table: key,
|
|
99
|
+
description: `Drop column ${colName} from ${key}`,
|
|
100
|
+
statements: allowDestructive ? dialect.dropColumnSql(actual, colName) : [],
|
|
101
|
+
safe: false
|
|
102
|
+
});
|
|
103
|
+
const warning = dialect.warnDropColumn?.(actual, colName);
|
|
104
|
+
if (warning) plan.warnings.push(warning);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Indexes (naive: based on name or derived name)
|
|
109
|
+
const expectedIndexes = table.indexes ?? [];
|
|
110
|
+
const actualIndexes = actual.indexes ?? [];
|
|
111
|
+
const actualIndexMap = new Map(actualIndexes.map(idx => [idx.name, idx]));
|
|
112
|
+
|
|
113
|
+
for (const idx of expectedIndexes) {
|
|
114
|
+
const name = idx.name || deriveIndexName(table, idx);
|
|
115
|
+
if (!actualIndexMap.has(name)) {
|
|
116
|
+
plan.changes.push({
|
|
117
|
+
kind: 'addIndex',
|
|
118
|
+
table: key,
|
|
119
|
+
description: `Create index ${name} on ${key}`,
|
|
120
|
+
statements: [dialect.renderIndex(table, { ...idx, name })],
|
|
121
|
+
safe: true
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const idx of actualIndexes) {
|
|
127
|
+
if (idx.name && !expectedIndexes.find(expected => (expected.name || deriveIndexName(table, expected)) === idx.name)) {
|
|
128
|
+
plan.changes.push({
|
|
129
|
+
kind: 'dropIndex',
|
|
130
|
+
table: key,
|
|
131
|
+
description: `Drop index ${idx.name} on ${key}`,
|
|
132
|
+
statements: allowDestructive ? dialect.dropIndexSql(actual, idx.name) : [],
|
|
133
|
+
safe: false
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Extra tables
|
|
140
|
+
for (const actual of actualSchema.tables) {
|
|
141
|
+
const key = tableKey(actual.name, actual.schema);
|
|
142
|
+
if (!expectedTables.find(t => tableKey(t.name, t.schema) === key)) {
|
|
143
|
+
plan.changes.push({
|
|
144
|
+
kind: 'dropTable',
|
|
145
|
+
table: key,
|
|
146
|
+
description: `Drop table ${key}`,
|
|
147
|
+
statements: allowDestructive ? dialect.dropTableSql(actual) : [],
|
|
148
|
+
safe: false
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return plan;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export interface SynchronizeOptions extends SchemaDiffOptions {
|
|
157
|
+
dryRun?: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const synchronizeSchema = async (
|
|
161
|
+
expectedTables: TableDef[],
|
|
162
|
+
actualSchema: DatabaseSchema,
|
|
163
|
+
dialect: SchemaDialect,
|
|
164
|
+
executor: DbExecutor,
|
|
165
|
+
options: SynchronizeOptions = {}
|
|
166
|
+
): Promise<SchemaPlan> => {
|
|
167
|
+
const plan = diffSchema(expectedTables, actualSchema, dialect, options);
|
|
168
|
+
if (options.dryRun) return plan;
|
|
169
|
+
|
|
170
|
+
for (const change of plan.changes) {
|
|
171
|
+
if (!change.statements.length) continue;
|
|
172
|
+
if (!change.safe && !options.allowDestructive) continue;
|
|
173
|
+
for (const stmt of change.statements) {
|
|
174
|
+
if (!stmt.trim()) continue;
|
|
175
|
+
await executor.executeSql(stmt);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return plan;
|
|
179
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { ColumnDef, ForeignKeyReference, RawDefaultValue } from '../../schema/column.js';
|
|
2
|
+
import { IndexDef, IndexColumn, TableDef } from '../../schema/table.js';
|
|
3
|
+
import { DatabaseTable } from './schema-types.js';
|
|
4
|
+
export { BaseSchemaDialect } from './dialects/base-schema-dialect.js';
|
|
5
|
+
export {
|
|
6
|
+
PostgresSchemaDialect,
|
|
7
|
+
MySqlSchemaDialect,
|
|
8
|
+
SQLiteSchemaDialect,
|
|
9
|
+
MSSqlSchemaDialect
|
|
10
|
+
} from './dialects/index.js';
|
|
11
|
+
|
|
12
|
+
export type DialectName = 'postgres' | 'mysql' | 'sqlite' | 'mssql';
|
|
13
|
+
|
|
14
|
+
export interface SchemaDialect {
|
|
15
|
+
name: DialectName;
|
|
16
|
+
quoteIdentifier(id: string): string;
|
|
17
|
+
formatTableName(table: TableDef | DatabaseTable): string;
|
|
18
|
+
renderColumnType(column: ColumnDef): string;
|
|
19
|
+
renderDefault(value: unknown, column: ColumnDef): string;
|
|
20
|
+
renderAutoIncrement(column: ColumnDef, table: TableDef): string | undefined;
|
|
21
|
+
renderReference(ref: ForeignKeyReference, table: TableDef): string;
|
|
22
|
+
renderIndex(table: TableDef, index: IndexDef): string;
|
|
23
|
+
renderTableOptions(table: TableDef): string | undefined;
|
|
24
|
+
supportsPartialIndexes(): boolean;
|
|
25
|
+
preferInlinePkAutoincrement?(column: ColumnDef, table: TableDef, pk: string[]): boolean;
|
|
26
|
+
dropColumnSql(table: DatabaseTable, column: string): string[];
|
|
27
|
+
dropIndexSql(table: DatabaseTable, index: string): string[];
|
|
28
|
+
dropTableSql(table: DatabaseTable): string[];
|
|
29
|
+
warnDropColumn?(table: DatabaseTable, column: string): string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SchemaGenerateResult {
|
|
33
|
+
tableSql: string;
|
|
34
|
+
indexSql: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const escapeLiteral = (value: string): string => value.replace(/'/g, "''");
|
|
38
|
+
|
|
39
|
+
const isRawDefault = (value: unknown): value is RawDefaultValue => {
|
|
40
|
+
return !!value && typeof value === 'object' && 'raw' in (value as any) && typeof (value as any).raw === 'string';
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const formatLiteral = (value: unknown, dialect: DialectName): string => {
|
|
44
|
+
if (isRawDefault(value)) return value.raw;
|
|
45
|
+
if (value === null) return 'NULL';
|
|
46
|
+
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
|
|
47
|
+
if (typeof value === 'boolean') {
|
|
48
|
+
if (dialect === 'mysql' || dialect === 'sqlite' || dialect === 'mssql') {
|
|
49
|
+
return value ? '1' : '0';
|
|
50
|
+
}
|
|
51
|
+
return value ? 'TRUE' : 'FALSE';
|
|
52
|
+
}
|
|
53
|
+
if (value instanceof Date) return `'${escapeLiteral(value.toISOString())}'`;
|
|
54
|
+
if (typeof value === 'string') return `'${escapeLiteral(value)}'`;
|
|
55
|
+
return `'${escapeLiteral(JSON.stringify(value))}'`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const resolvePrimaryKey = (table: TableDef): string[] => {
|
|
59
|
+
if (table.primaryKey && table.primaryKey.length > 0) {
|
|
60
|
+
return table.primaryKey;
|
|
61
|
+
}
|
|
62
|
+
const cols = Object.values(table.columns);
|
|
63
|
+
return cols.filter(c => c.primary).map(c => c.name);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const quoteQualified = (dialect: SchemaDialect, identifier: string): string => {
|
|
67
|
+
if (identifier.includes('.')) {
|
|
68
|
+
return identifier
|
|
69
|
+
.split('.')
|
|
70
|
+
.map(part => dialect.quoteIdentifier(part))
|
|
71
|
+
.join('.');
|
|
72
|
+
}
|
|
73
|
+
return dialect.quoteIdentifier(identifier);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const renderIndexColumns = (dialect: SchemaDialect, columns: (string | IndexColumn)[]) => {
|
|
77
|
+
return columns
|
|
78
|
+
.map(col => {
|
|
79
|
+
if (typeof col === 'string') return dialect.quoteIdentifier(col);
|
|
80
|
+
const parts = [dialect.quoteIdentifier(col.column)];
|
|
81
|
+
if (col.order) parts.push(col.order);
|
|
82
|
+
if (col.nulls) parts.push(`NULLS ${col.nulls}`);
|
|
83
|
+
return parts.join(' ');
|
|
84
|
+
})
|
|
85
|
+
.join(', ');
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const deriveIndexName = (table: TableDef, index: IndexDef): string => {
|
|
89
|
+
const base = (index.columns || [])
|
|
90
|
+
.map(col => (typeof col === 'string' ? col : col.column))
|
|
91
|
+
.join('_');
|
|
92
|
+
const suffix = index.unique ? 'uniq' : 'idx';
|
|
93
|
+
return `${table.name}_${base}_${suffix}`;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export interface RenderColumnOptions {
|
|
97
|
+
includePrimary?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const renderColumnDefinition = (
|
|
101
|
+
table: TableDef,
|
|
102
|
+
col: ColumnDef,
|
|
103
|
+
dialect: SchemaDialect,
|
|
104
|
+
options: RenderColumnOptions = {}
|
|
105
|
+
): { sql: string; inlinePrimary: boolean } => {
|
|
106
|
+
const parts: string[] = [];
|
|
107
|
+
parts.push(dialect.quoteIdentifier(col.name));
|
|
108
|
+
parts.push(dialect.renderColumnType(col));
|
|
109
|
+
|
|
110
|
+
const autoInc = dialect.renderAutoIncrement(col, table);
|
|
111
|
+
if (autoInc) parts.push(autoInc);
|
|
112
|
+
|
|
113
|
+
if (col.notNull) parts.push('NOT NULL');
|
|
114
|
+
if (col.unique) parts.push('UNIQUE');
|
|
115
|
+
if (col.default !== undefined) {
|
|
116
|
+
parts.push(`DEFAULT ${dialect.renderDefault(col.default, col)}`);
|
|
117
|
+
}
|
|
118
|
+
if (options.includePrimary && col.primary) {
|
|
119
|
+
parts.push('PRIMARY KEY');
|
|
120
|
+
}
|
|
121
|
+
if (col.check) {
|
|
122
|
+
parts.push(`CHECK (${col.check})`);
|
|
123
|
+
}
|
|
124
|
+
if (col.references) {
|
|
125
|
+
parts.push(dialect.renderReference(col.references, table));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { sql: parts.join(' '), inlinePrimary: !!(options.includePrimary && col.primary) };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const generateCreateTableSql = (
|
|
132
|
+
table: TableDef,
|
|
133
|
+
dialect: SchemaDialect
|
|
134
|
+
): SchemaGenerateResult => {
|
|
135
|
+
const pk = resolvePrimaryKey(table);
|
|
136
|
+
const inlinePkColumns = new Set<string>();
|
|
137
|
+
|
|
138
|
+
const columnLines = Object.values(table.columns).map(col => {
|
|
139
|
+
const includePk = dialect.preferInlinePkAutoincrement?.(col, table, pk) && pk.includes(col.name);
|
|
140
|
+
if (includePk) {
|
|
141
|
+
inlinePkColumns.add(col.name);
|
|
142
|
+
}
|
|
143
|
+
return renderColumnDefinition(table, col, dialect, { includePrimary: includePk }).sql;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const constraintLines: string[] = [];
|
|
147
|
+
|
|
148
|
+
if (pk.length > 0 && !(pk.length === 1 && inlinePkColumns.has(pk[0]))) {
|
|
149
|
+
const cols = pk.map(c => dialect.quoteIdentifier(c)).join(', ');
|
|
150
|
+
constraintLines.push(`PRIMARY KEY (${cols})`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (table.checks) {
|
|
154
|
+
table.checks.forEach(check => {
|
|
155
|
+
const name = check.name ? `${dialect.quoteIdentifier(check.name)} ` : '';
|
|
156
|
+
constraintLines.push(`CONSTRAINT ${name}CHECK (${check.expression})`);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const allLines = [...columnLines, ...constraintLines];
|
|
161
|
+
const body = allLines.map(line => ` ${line}`).join(',\n');
|
|
162
|
+
const tableOptions = dialect.renderTableOptions(table);
|
|
163
|
+
const tableSql = `CREATE TABLE ${dialect.formatTableName(table)} (\n${body}\n)${tableOptions ? ' ' + tableOptions : ''};`;
|
|
164
|
+
|
|
165
|
+
const indexSql: string[] = [];
|
|
166
|
+
if (table.indexes && table.indexes.length > 0) {
|
|
167
|
+
for (const idx of table.indexes) {
|
|
168
|
+
if (idx.where && !dialect.supportsPartialIndexes()) {
|
|
169
|
+
throw new Error(`Dialect ${dialect.name} does not support partial/filtered indexes (${idx.name || idx.columns.join('_')}).`);
|
|
170
|
+
}
|
|
171
|
+
indexSql.push(dialect.renderIndex(table, idx));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { tableSql, indexSql };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const generateSchemaSql = (
|
|
179
|
+
tables: TableDef[],
|
|
180
|
+
dialect: SchemaDialect
|
|
181
|
+
): string[] => {
|
|
182
|
+
const ordered = orderTablesByDependencies(tables);
|
|
183
|
+
const statements: string[] = [];
|
|
184
|
+
ordered.forEach(table => {
|
|
185
|
+
const { tableSql, indexSql } = generateCreateTableSql(table, dialect);
|
|
186
|
+
statements.push(tableSql, ...indexSql);
|
|
187
|
+
});
|
|
188
|
+
return statements;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const orderTablesByDependencies = (tables: TableDef[]): TableDef[] => {
|
|
192
|
+
const map = new Map<string, TableDef>();
|
|
193
|
+
tables.forEach(t => map.set(t.name, t));
|
|
194
|
+
|
|
195
|
+
const deps = new Map<string, Set<string>>();
|
|
196
|
+
for (const table of tables) {
|
|
197
|
+
const refTables = new Set<string>();
|
|
198
|
+
Object.values(table.columns).forEach(col => {
|
|
199
|
+
if (col.references?.table) {
|
|
200
|
+
refTables.add(col.references.table);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
deps.set(table.name, refTables);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const visited = new Set<string>();
|
|
207
|
+
const ordered: TableDef[] = [];
|
|
208
|
+
|
|
209
|
+
const visit = (name: string, stack: Set<string>) => {
|
|
210
|
+
if (visited.has(name)) return;
|
|
211
|
+
const table = map.get(name);
|
|
212
|
+
if (!table) return;
|
|
213
|
+
if (stack.has(name)) {
|
|
214
|
+
ordered.push(table);
|
|
215
|
+
visited.add(name);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
stack.add(name);
|
|
219
|
+
for (const dep of deps.get(name) || []) {
|
|
220
|
+
visit(dep, stack);
|
|
221
|
+
}
|
|
222
|
+
stack.delete(name);
|
|
223
|
+
visited.add(name);
|
|
224
|
+
ordered.push(table);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
tables.forEach(t => visit(t.name, new Set()));
|
|
228
|
+
return ordered;
|
|
229
|
+
};
|