@sedrino/db-schema 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -6
- package/dist/cli.js +1904 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +365 -85
- package/dist/index.js +1108 -187
- package/dist/index.js.map +1 -1
- package/docs/cli.md +93 -0
- package/docs/expressions-and-transforms.md +165 -0
- package/docs/index.md +5 -2
- package/docs/migrations.md +183 -3
- package/docs/planning-and-apply.md +200 -0
- package/docs/relations.md +130 -0
- package/docs/schema-document.md +62 -0
- package/package.json +3 -2
- package/src/apply.ts +67 -0
- package/src/cli.ts +105 -7
- package/src/drizzle.ts +348 -1
- package/src/index.ts +38 -1
- package/src/migration.ts +315 -3
- package/src/operations.ts +278 -0
- package/src/planner.ts +7 -190
- package/src/project.ts +157 -1
- package/src/sql-expression.ts +123 -0
- package/src/sqlite.ts +150 -9
- package/src/transforms.ts +94 -0
- package/src/utils.ts +54 -0
package/src/sqlite.ts
CHANGED
|
@@ -8,6 +8,12 @@ import type {
|
|
|
8
8
|
UniqueSpec,
|
|
9
9
|
} from "./types";
|
|
10
10
|
import type { MigrationOperation } from "./migration";
|
|
11
|
+
import { createEmptySchema, findTable } from "./schema";
|
|
12
|
+
import {
|
|
13
|
+
applyOperationToSchema,
|
|
14
|
+
fieldNullabilityTightened,
|
|
15
|
+
fieldStorageStrategyChanged,
|
|
16
|
+
} from "./operations";
|
|
11
17
|
import { quoteIdentifier, sqliteEpochMsNowSql, toSnakeCase } from "./utils";
|
|
12
18
|
|
|
13
19
|
export function compileSchemaToSqlite(schema: DatabaseSchemaDocument) {
|
|
@@ -21,11 +27,18 @@ export function compileSchemaToSqlite(schema: DatabaseSchemaDocument) {
|
|
|
21
27
|
return `${statements.join("\n\n")}\n`;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
export function renderSqliteMigration(
|
|
30
|
+
export function renderSqliteMigration(
|
|
31
|
+
operations: MigrationOperation[],
|
|
32
|
+
options: { currentSchema?: DatabaseSchemaDocument } = {},
|
|
33
|
+
) {
|
|
25
34
|
const statements: string[] = [];
|
|
26
35
|
const warnings: string[] = [];
|
|
36
|
+
let workingSchema = options.currentSchema ?? createEmptySchema();
|
|
37
|
+
let rebuildSequence = 0;
|
|
27
38
|
|
|
28
39
|
for (const operation of operations) {
|
|
40
|
+
const nextSchema = applyOperationToSchema(workingSchema, operation);
|
|
41
|
+
|
|
29
42
|
switch (operation.kind) {
|
|
30
43
|
case "createTable":
|
|
31
44
|
statements.push(renderCreateTableStatement(operation.table));
|
|
@@ -39,11 +52,6 @@ export function renderSqliteMigration(operations: MigrationOperation[]) {
|
|
|
39
52
|
`ALTER TABLE ${quoteIdentifier(operation.from)} RENAME TO ${quoteIdentifier(operation.to)};`,
|
|
40
53
|
);
|
|
41
54
|
break;
|
|
42
|
-
case "addField":
|
|
43
|
-
statements.push(
|
|
44
|
-
`ALTER TABLE ${quoteIdentifier(operation.tableName)} ADD COLUMN ${renderColumnDefinition(operation.field)};`,
|
|
45
|
-
);
|
|
46
|
-
break;
|
|
47
55
|
case "renameField":
|
|
48
56
|
statements.push(
|
|
49
57
|
`ALTER TABLE ${quoteIdentifier(operation.tableName)} RENAME COLUMN ${quoteIdentifier(toSnakeCase(operation.from))} TO ${quoteIdentifier(toSnakeCase(operation.to))};`,
|
|
@@ -61,12 +69,34 @@ export function renderSqliteMigration(operations: MigrationOperation[]) {
|
|
|
61
69
|
case "dropUnique":
|
|
62
70
|
statements.push(`DROP INDEX ${quoteIdentifier(operation.uniqueName)};`);
|
|
63
71
|
break;
|
|
72
|
+
case "addField":
|
|
64
73
|
case "dropField":
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
);
|
|
74
|
+
case "alterField": {
|
|
75
|
+
const tableName = operation.tableName;
|
|
76
|
+
const beforeTable = findTable(workingSchema, tableName);
|
|
77
|
+
const afterTable = findTable(nextSchema, tableName);
|
|
78
|
+
|
|
79
|
+
if (!beforeTable || !afterTable) {
|
|
80
|
+
warnings.push(
|
|
81
|
+
`operation ${operation.kind} on ${tableName} could not be emitted because the table state was incomplete`,
|
|
82
|
+
);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const rebuild = renderTableRebuild({
|
|
87
|
+
beforeTable,
|
|
88
|
+
afterTable,
|
|
89
|
+
operation,
|
|
90
|
+
sequence: rebuildSequence,
|
|
91
|
+
});
|
|
92
|
+
rebuildSequence += 1;
|
|
93
|
+
statements.push(...rebuild.statements);
|
|
94
|
+
warnings.push(...rebuild.warnings);
|
|
68
95
|
break;
|
|
96
|
+
}
|
|
69
97
|
}
|
|
98
|
+
|
|
99
|
+
workingSchema = nextSchema;
|
|
70
100
|
}
|
|
71
101
|
|
|
72
102
|
return {
|
|
@@ -170,3 +200,114 @@ function resolveColumnName(table: TableSpec | undefined, fieldName: string) {
|
|
|
170
200
|
const field = table?.fields.find((candidate) => candidate.name === fieldName);
|
|
171
201
|
return field?.storage.column ?? toSnakeCase(fieldName);
|
|
172
202
|
}
|
|
203
|
+
|
|
204
|
+
function renderTableRebuild(args: {
|
|
205
|
+
beforeTable: TableSpec;
|
|
206
|
+
afterTable: TableSpec;
|
|
207
|
+
operation: Extract<MigrationOperation, { kind: "addField" | "dropField" | "alterField" }>;
|
|
208
|
+
sequence: number;
|
|
209
|
+
}) {
|
|
210
|
+
const warnings: string[] = [];
|
|
211
|
+
const tempName = `__sedrino_rebuild_${args.afterTable.name}_${args.sequence}`;
|
|
212
|
+
const insertColumns = args.afterTable.fields.map((field) => quoteIdentifier(field.storage.column));
|
|
213
|
+
const selectExpressions = args.afterTable.fields.map((field) =>
|
|
214
|
+
renderRebuildSelectExpression({
|
|
215
|
+
beforeTable: args.beforeTable,
|
|
216
|
+
afterTable: args.afterTable,
|
|
217
|
+
operation: args.operation,
|
|
218
|
+
targetField: field,
|
|
219
|
+
warnings,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const statements = [
|
|
224
|
+
"PRAGMA foreign_keys = OFF;",
|
|
225
|
+
`ALTER TABLE ${quoteIdentifier(args.beforeTable.name)} RENAME TO ${quoteIdentifier(tempName)};`,
|
|
226
|
+
renderCreateTableStatement(args.afterTable),
|
|
227
|
+
...renderCreateIndexStatements(args.afterTable),
|
|
228
|
+
`INSERT INTO ${quoteIdentifier(args.afterTable.name)} (${insertColumns.join(", ")}) SELECT ${selectExpressions.join(", ")} FROM ${quoteIdentifier(tempName)};`,
|
|
229
|
+
`DROP TABLE ${quoteIdentifier(tempName)};`,
|
|
230
|
+
"PRAGMA foreign_keys = ON;",
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
statements,
|
|
235
|
+
warnings,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function renderRebuildSelectExpression(args: {
|
|
240
|
+
beforeTable: TableSpec;
|
|
241
|
+
afterTable: TableSpec;
|
|
242
|
+
operation: Extract<MigrationOperation, { kind: "addField" | "dropField" | "alterField" }>;
|
|
243
|
+
targetField: FieldSpec;
|
|
244
|
+
warnings: string[];
|
|
245
|
+
}) {
|
|
246
|
+
const sourceField =
|
|
247
|
+
args.beforeTable.fields.find((candidate) => candidate.id === args.targetField.id) ?? null;
|
|
248
|
+
|
|
249
|
+
if (!sourceField) {
|
|
250
|
+
if (
|
|
251
|
+
args.operation.kind === "addField" &&
|
|
252
|
+
args.operation.field.id === args.targetField.id &&
|
|
253
|
+
args.operation.backfill
|
|
254
|
+
) {
|
|
255
|
+
return `(${args.operation.backfill.sql}) AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const defaultSql = renderSqlDefault(args.targetField.default, args.targetField);
|
|
259
|
+
if (defaultSql) return `${defaultSql} AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
260
|
+
if (args.targetField.nullable) {
|
|
261
|
+
return `NULL AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
args.warnings.push(
|
|
265
|
+
`addField ${args.afterTable.name}.${args.targetField.name} is required with no default and cannot be backfilled safely`,
|
|
266
|
+
);
|
|
267
|
+
return `NULL AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (fieldStorageStrategyChanged(sourceField, args.targetField)) {
|
|
271
|
+
if (
|
|
272
|
+
args.operation.kind === "alterField" &&
|
|
273
|
+
args.operation.transform &&
|
|
274
|
+
args.operation.fieldName === args.targetField.name
|
|
275
|
+
) {
|
|
276
|
+
return `(${args.operation.transform.sql}) AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
args.warnings.push(
|
|
280
|
+
`alterField ${args.afterTable.name}.${args.targetField.name} changes storage strategy from ${sourceField.storage.strategy} to ${args.targetField.storage.strategy}; explicit data transforms are not supported in v1`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let expression = quoteIdentifier(sourceField.storage.column);
|
|
285
|
+
|
|
286
|
+
if (
|
|
287
|
+
args.operation.kind === "alterField" &&
|
|
288
|
+
args.operation.transform &&
|
|
289
|
+
args.operation.fieldName === args.targetField.name
|
|
290
|
+
) {
|
|
291
|
+
expression = `(${args.operation.transform.sql})`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (
|
|
295
|
+
fieldNullabilityTightened(sourceField, args.targetField) &&
|
|
296
|
+
!(
|
|
297
|
+
args.operation.kind === "alterField" &&
|
|
298
|
+
args.operation.transform &&
|
|
299
|
+
args.operation.fieldName === args.targetField.name
|
|
300
|
+
)
|
|
301
|
+
) {
|
|
302
|
+
const defaultSql = renderSqlDefault(args.targetField.default, args.targetField);
|
|
303
|
+
if (defaultSql) {
|
|
304
|
+
expression = `COALESCE(${expression}, ${defaultSql})`;
|
|
305
|
+
} else {
|
|
306
|
+
args.warnings.push(
|
|
307
|
+
`alterField ${args.afterTable.name}.${args.targetField.name} makes a nullable field required without a default; existing NULL rows would fail during rebuild`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return `${expression} AS ${quoteIdentifier(args.targetField.storage.column)}`;
|
|
313
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { MigrationSqlExpression } from "./migration";
|
|
2
|
+
import { cast, coalesce, column, concat, date, literal, lower, multiply, replace, trim, unixepoch } from "./sql-expression";
|
|
3
|
+
|
|
4
|
+
type Source = string | MigrationSqlExpression;
|
|
5
|
+
type Fallback = string | number | boolean | null | MigrationSqlExpression;
|
|
6
|
+
|
|
7
|
+
export function copy(fieldName: string): MigrationSqlExpression {
|
|
8
|
+
return column(fieldName);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function lowercase(source: Source): MigrationSqlExpression {
|
|
12
|
+
return lower(resolveSource(source));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function trimmed(source: Source): MigrationSqlExpression {
|
|
16
|
+
return trim(resolveSource(source));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function slugFrom(source: Source, options: { separator?: string } = {}): MigrationSqlExpression {
|
|
20
|
+
const separator = options.separator ?? "-";
|
|
21
|
+
return lower(replace(trim(resolveSource(source)), " ", separator));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function concatFields(
|
|
25
|
+
fieldNames: string[],
|
|
26
|
+
options: { separator?: string } = {},
|
|
27
|
+
): MigrationSqlExpression {
|
|
28
|
+
if (fieldNames.length === 0) {
|
|
29
|
+
throw new Error("concatFields requires at least one field name");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const separator = options.separator ?? "";
|
|
33
|
+
const expressions: MigrationSqlExpression[] = [];
|
|
34
|
+
|
|
35
|
+
for (const [index, fieldName] of fieldNames.entries()) {
|
|
36
|
+
if (index > 0 && separator.length > 0) {
|
|
37
|
+
expressions.push(literal(separator));
|
|
38
|
+
}
|
|
39
|
+
expressions.push(column(fieldName));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return concat(...expressions);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function coalesceFields(
|
|
46
|
+
fieldNames: string[],
|
|
47
|
+
fallback?: Fallback,
|
|
48
|
+
): MigrationSqlExpression {
|
|
49
|
+
if (fieldNames.length === 0) {
|
|
50
|
+
throw new Error("coalesceFields requires at least one field name");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const expressions = fieldNames.map((fieldName) => column(fieldName));
|
|
54
|
+
if (fallback !== undefined) expressions.push(resolveFallback(fallback));
|
|
55
|
+
return coalesce(...expressions);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function epochMsFromIsoString(source: Source): MigrationSqlExpression {
|
|
59
|
+
return multiply(cast(unixepoch(resolveSource(source)), "INTEGER"), 1000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function plainDateFromIsoString(source: Source): MigrationSqlExpression {
|
|
63
|
+
return date(resolveSource(source));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function integerFromText(source: Source): MigrationSqlExpression {
|
|
67
|
+
return cast(trim(resolveSource(source)), "INTEGER");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function realFromText(source: Source): MigrationSqlExpression {
|
|
71
|
+
return cast(trim(resolveSource(source)), "REAL");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const transforms = {
|
|
75
|
+
copy,
|
|
76
|
+
lowercase,
|
|
77
|
+
trimmed,
|
|
78
|
+
slugFrom,
|
|
79
|
+
concatFields,
|
|
80
|
+
coalesceFields,
|
|
81
|
+
epochMsFromIsoString,
|
|
82
|
+
plainDateFromIsoString,
|
|
83
|
+
integerFromText,
|
|
84
|
+
realFromText,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function resolveSource(source: Source) {
|
|
88
|
+
return typeof source === "string" ? column(source) : source;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveFallback(fallback: Fallback) {
|
|
92
|
+
if (typeof fallback === "object" && fallback !== null && "sql" in fallback) return fallback;
|
|
93
|
+
return literal(fallback);
|
|
94
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -32,6 +32,32 @@ export function toPascalCase(value: string) {
|
|
|
32
32
|
.join("");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export function toCamelCase(value: string) {
|
|
36
|
+
const pascal = toPascalCase(value);
|
|
37
|
+
return pascal.length > 0 ? `${pascal[0]!.toLowerCase()}${pascal.slice(1)}` : pascal;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function pluralize(value: string) {
|
|
41
|
+
if (value.endsWith("s")) return `${value}es`;
|
|
42
|
+
if (value.endsWith("y") && !/[aeiou]y$/i.test(value)) {
|
|
43
|
+
return `${value.slice(0, -1)}ies`;
|
|
44
|
+
}
|
|
45
|
+
return `${value}s`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function singularize(value: string) {
|
|
49
|
+
if (value.endsWith("ies") && value.length > 3) {
|
|
50
|
+
return `${value.slice(0, -3)}y`;
|
|
51
|
+
}
|
|
52
|
+
if (value.endsWith("ses") && value.length > 3) {
|
|
53
|
+
return value.slice(0, -2);
|
|
54
|
+
}
|
|
55
|
+
if (value.endsWith("s") && !value.endsWith("ss") && value.length > 1) {
|
|
56
|
+
return value.slice(0, -1);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
35
61
|
export function quoteIdentifier(value: string) {
|
|
36
62
|
return `"${value.replace(/"/g, '""')}"`;
|
|
37
63
|
}
|
|
@@ -132,6 +158,7 @@ export function validateSchemaDocumentCompatibility(
|
|
|
132
158
|
): SchemaValidationIssue[] {
|
|
133
159
|
const issues: SchemaValidationIssue[] = [];
|
|
134
160
|
const tableNames = new Set<string>();
|
|
161
|
+
const indexNames = new Set<string>();
|
|
135
162
|
|
|
136
163
|
for (const table of schema.tables) {
|
|
137
164
|
if (tableNames.has(table.name)) {
|
|
@@ -143,6 +170,7 @@ export function validateSchemaDocumentCompatibility(
|
|
|
143
170
|
tableNames.add(table.name);
|
|
144
171
|
|
|
145
172
|
const fieldNames = new Set<string>();
|
|
173
|
+
const columnNames = new Set<string>();
|
|
146
174
|
let primaryKeyCount = 0;
|
|
147
175
|
|
|
148
176
|
for (const field of table.fields) {
|
|
@@ -154,6 +182,14 @@ export function validateSchemaDocumentCompatibility(
|
|
|
154
182
|
}
|
|
155
183
|
fieldNames.add(field.name);
|
|
156
184
|
|
|
185
|
+
if (columnNames.has(field.storage.column)) {
|
|
186
|
+
issues.push({
|
|
187
|
+
path: `tables.${table.name}.fields.${field.name}`,
|
|
188
|
+
message: `Duplicate column name ${field.storage.column}`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
columnNames.add(field.storage.column);
|
|
192
|
+
|
|
157
193
|
if (field.primaryKey) primaryKeyCount += 1;
|
|
158
194
|
if (field.primaryKey && field.nullable) {
|
|
159
195
|
issues.push({
|
|
@@ -217,6 +253,15 @@ export function validateSchemaDocumentCompatibility(
|
|
|
217
253
|
}
|
|
218
254
|
|
|
219
255
|
for (const index of table.indexes) {
|
|
256
|
+
const indexName = index.name ?? `${table.name}_${index.fields.join("_")}_idx`;
|
|
257
|
+
if (indexNames.has(indexName)) {
|
|
258
|
+
issues.push({
|
|
259
|
+
path: `tables.${table.name}.indexes.${indexName}`,
|
|
260
|
+
message: `Duplicate index name ${indexName}`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
indexNames.add(indexName);
|
|
264
|
+
|
|
220
265
|
for (const fieldName of index.fields) {
|
|
221
266
|
if (!fieldNames.has(fieldName)) {
|
|
222
267
|
issues.push({
|
|
@@ -228,6 +273,15 @@ export function validateSchemaDocumentCompatibility(
|
|
|
228
273
|
}
|
|
229
274
|
|
|
230
275
|
for (const unique of table.uniques) {
|
|
276
|
+
const uniqueName = unique.name ?? `${table.name}_${unique.fields.join("_")}_unique`;
|
|
277
|
+
if (indexNames.has(uniqueName)) {
|
|
278
|
+
issues.push({
|
|
279
|
+
path: `tables.${table.name}.uniques.${uniqueName}`,
|
|
280
|
+
message: `Duplicate index name ${uniqueName}`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
indexNames.add(uniqueName);
|
|
284
|
+
|
|
231
285
|
for (const fieldName of unique.fields) {
|
|
232
286
|
if (!fieldNames.has(fieldName)) {
|
|
233
287
|
issues.push({
|