@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/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(operations: MigrationOperation[]) {
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
- warnings.push(
66
- `dropField ${operation.tableName}.${operation.fieldName} requires a table rebuild and is not emitted in v1`,
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({