@apso/cli 0.43.3 → 0.44.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.
Files changed (38) hide show
  1. package/README.md +7 -11
  2. package/dist/commands/deploy.js +1 -1
  3. package/dist/commands/generate.js +22 -12
  4. package/dist/commands/migrate/generate.d.ts +3 -1
  5. package/dist/commands/migrate/generate.js +42 -8
  6. package/dist/commands/migrate/plan.d.ts +12 -0
  7. package/dist/commands/migrate/plan.js +88 -0
  8. package/dist/commands/migrate/sandbox.d.ts +11 -0
  9. package/dist/commands/migrate/sandbox.js +132 -0
  10. package/dist/commands/migrate/verify.d.ts +5 -2
  11. package/dist/commands/migrate/verify.js +44 -9
  12. package/dist/commands/migrate.d.ts +16 -3
  13. package/dist/commands/migrate.js +171 -96
  14. package/dist/lib/ddl/catalog.d.ts +21 -0
  15. package/dist/lib/ddl/catalog.js +58 -0
  16. package/dist/lib/ddl/change.d.ts +19 -0
  17. package/dist/lib/ddl/change.js +393 -0
  18. package/dist/lib/ddl/index.d.ts +80 -0
  19. package/dist/lib/ddl/index.js +309 -0
  20. package/dist/lib/ddl/migration-file.d.ts +42 -0
  21. package/dist/lib/ddl/migration-file.js +222 -0
  22. package/dist/lib/generators/go.js +3 -2
  23. package/dist/lib/generators/python.js +3 -2
  24. package/dist/lib/plan/baseline.d.ts +22 -0
  25. package/dist/lib/plan/baseline.js +100 -0
  26. package/dist/lib/plan/index.d.ts +52 -0
  27. package/dist/lib/plan/index.js +272 -0
  28. package/dist/lib/plan/prompts.d.ts +18 -0
  29. package/dist/lib/plan/prompts.js +77 -0
  30. package/dist/lib/utils/file-system.d.ts +8 -0
  31. package/dist/lib/utils/file-system.js +31 -1
  32. package/dist/lib/verify/index.d.ts +27 -9
  33. package/dist/lib/verify/index.js +249 -21
  34. package/dist/lib/verify/review.d.ts +14 -0
  35. package/dist/lib/verify/review.js +98 -0
  36. package/npm-shrinkwrap.json +2 -2
  37. package/oclif.manifest.json +176 -23
  38. package/package.json +1 -1
@@ -0,0 +1,309 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.schemaDdl = exports.createTableSql = exports.addConstraintSql = exports.createIndexSql = exports.createTypeSql = exports.schemaModel = exports.columnSpec = exports.enumTypeName = exports.tableName = exports.literal = exports.q = exports.constraintName = void 0;
4
+ /**
5
+ * Apso's own DDL writer: the CREATE statements for a whole .apsorc schema.
6
+ *
7
+ * Column rules mirror the TypeScript entity templates (templates/entities),
8
+ * and names follow TypeORM's DefaultNamingStrategy (a prefix plus a sha1 of
9
+ * "table_columns"). A database built from this SQL therefore matches the one
10
+ * TypeORM builds for the generated entities, and services already deployed
11
+ * see no drift. test/lib/ddl/project-parity.test.ts checks that against a
12
+ * real scaffolded service.
13
+ */
14
+ const crypto_1 = require("crypto");
15
+ const field_1 = require("../utils/field");
16
+ const casing_1 = require("../utils/casing");
17
+ const relationships_1 = require("../utils/relationships");
18
+ // TypeORM's own casing, used for join table and join column names.
19
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
20
+ const typeormCase = require("typeorm/util/StringUtils");
21
+ const sha1 = (s) => (0, crypto_1.createHash)("sha1").update(s).digest("hex");
22
+ const key = (table, columns) => `${table.replace(".", "_")}_${[...columns].sort().join("_")}`;
23
+ exports.constraintName = {
24
+ primaryKey: (t, cols) => "PK_" + sha1(key(t, cols)).slice(0, 27),
25
+ unique: (t, cols) => "UQ_" + sha1(key(t, cols)).slice(0, 27),
26
+ relation: (t, cols) => "REL_" + sha1(key(t, cols)).slice(0, 26),
27
+ foreignKey: (t, cols) => "FK_" + sha1(key(t, cols)).slice(0, 27),
28
+ index: (t, cols) => "IDX_" + sha1(key(t, cols)).slice(0, 26),
29
+ };
30
+ const q = (name) => `"${name.replace(/"/g, '""')}"`;
31
+ exports.q = q;
32
+ const literal = (v) => `'${v.replace(/'/g, "''")}'`;
33
+ exports.literal = literal;
34
+ const tableName = (e) => e.table || (0, casing_1.snakeCase)(e.name);
35
+ exports.tableName = tableName;
36
+ const enumTypeName = (table, column) => `${table}_${column}_enum`;
37
+ exports.enumTypeName = enumTypeName;
38
+ const hasDefault = (f) => f.default !== null && f.default !== undefined;
39
+ // TypeORM quotes every default it is given except booleans.
40
+ const quoted = (f) => hasDefault(f) ? (0, exports.literal)(String(f.default)) : undefined;
41
+ /** One column as its entity template declares it. */
42
+ function columnSpec(table, f) {
43
+ const textLike = (type) => [
44
+ type,
45
+ quoted(f),
46
+ Boolean(f.unique),
47
+ ];
48
+ const { length: size } = f;
49
+ let type;
50
+ let def;
51
+ let unique = false;
52
+ switch (f.type) {
53
+ case "text":
54
+ case "string":
55
+ [type, def, unique] = textLike("text");
56
+ break;
57
+ case "varchar":
58
+ [type, def, unique] = textLike(`character varying(${size || 255})`);
59
+ break;
60
+ case "char":
61
+ [type, def, unique] = textLike(`character(${size || 1})`);
62
+ break;
63
+ case "enum":
64
+ type = `"public".${(0, exports.q)((0, exports.enumTypeName)(table, f.name))}`;
65
+ def = quoted(f);
66
+ break;
67
+ case "integer":
68
+ case "bigint":
69
+ case "smallint":
70
+ type = f.type;
71
+ def = quoted(f);
72
+ break;
73
+ case "decimal":
74
+ case "numeric":
75
+ type = `numeric(${f.precision || 10},${f.scale || 2})`;
76
+ def = quoted(f);
77
+ break;
78
+ case "float":
79
+ type = "numeric";
80
+ def = quoted(f);
81
+ break;
82
+ case "double":
83
+ type = "double precision";
84
+ def = quoted(f);
85
+ break;
86
+ case "boolean":
87
+ type = "boolean";
88
+ def = String(String(f.default).toLowerCase() === "true");
89
+ break;
90
+ case "uuid":
91
+ type = "uuid";
92
+ def = hasDefault(f) ? String(f.default) : undefined;
93
+ break;
94
+ case "date":
95
+ type = "date";
96
+ break;
97
+ case "timestamp":
98
+ type = "TIMESTAMP";
99
+ break;
100
+ case "timestamptz":
101
+ type = "TIMESTAMP WITH TIME ZONE";
102
+ break;
103
+ case "json":
104
+ case "jsonb":
105
+ type = "jsonb";
106
+ break;
107
+ case "json-plain":
108
+ type = "json";
109
+ break;
110
+ default:
111
+ // ponytail: spatial, range and other special templates; add each with a parity case.
112
+ throw new Error(`The DDL writer does not support field type "${f.type}" yet (${table}.${f.name}).`);
113
+ }
114
+ let sql = `${(0, exports.q)(f.name)} ${type}`;
115
+ if (!f.nullable)
116
+ sql += " NOT NULL";
117
+ if (def !== undefined)
118
+ sql += ` DEFAULT ${def}`;
119
+ return {
120
+ sql,
121
+ type,
122
+ notNull: !f.nullable,
123
+ default: def,
124
+ unique,
125
+ index: Boolean(f.index),
126
+ };
127
+ }
128
+ exports.columnSpec = columnSpec;
129
+ const cols = (c) => c.map((x) => (0, exports.q)(x)).join(", ");
130
+ const column = (name, type, notNull, def, serial) => ({
131
+ name,
132
+ sql: `${(0, exports.q)(name)} ${serial ? "SERIAL" : type}${notNull ? " NOT NULL" : ""}${def && !serial ? ` DEFAULT ${def}` : ""}`,
133
+ type,
134
+ notNull,
135
+ default: def,
136
+ serial,
137
+ });
138
+ const pkColumn = (e) => { var _a, _b, _c; return (_c = (_b = ((_a = e.fields) !== null && _a !== void 0 ? _a : []).find((f) => f.primary)) === null || _b === void 0 ? void 0 : _b.name) !== null && _c !== void 0 ? _c : "id"; };
139
+ const fkType = (target) => (target === null || target === void 0 ? void 0 : target.primaryKeyType) === "uuid" ? "uuid" : "integer";
140
+ function schemaModel(input) {
141
+ var _a, _b, _c, _d, _e;
142
+ const { entities, relationshipMap } = input;
143
+ const byName = new Map(entities.map((e) => [e.name, e]));
144
+ const model = {
145
+ extensions: [],
146
+ enums: [],
147
+ tables: [],
148
+ constraints: [],
149
+ indexes: [],
150
+ };
151
+ const entityTables = [];
152
+ const joinTables = [];
153
+ const inline = [];
154
+ const foreignKeys = [];
155
+ const index = (table, columns, unique = false) => model.indexes.push({
156
+ key: `index:${table}(${columns.join(",")})`,
157
+ name: exports.constraintName.index(table, columns),
158
+ table,
159
+ columns,
160
+ unique,
161
+ });
162
+ const unique = (table, columns, name, list = inline) => list.push({
163
+ key: `unique:${table}(${columns.join(",")})`,
164
+ name,
165
+ table,
166
+ kind: "unique",
167
+ columns,
168
+ body: `UNIQUE (${cols(columns)})`,
169
+ });
170
+ const primaryKey = (table, columns, list = inline) => list.push({
171
+ key: `pk:${table}`,
172
+ name: exports.constraintName.primaryKey(table, columns),
173
+ table,
174
+ kind: "pk",
175
+ columns,
176
+ body: `PRIMARY KEY (${cols(columns)})`,
177
+ });
178
+ const foreignKey = (table, col, target, targetCol, actions) => foreignKeys.push({
179
+ key: `fk:${table}(${col})->${target}`,
180
+ name: exports.constraintName.foreignKey(table, [col]),
181
+ table,
182
+ kind: "fk",
183
+ columns: [col],
184
+ references: target,
185
+ body: `FOREIGN KEY (${(0, exports.q)(col)}) REFERENCES ${(0, exports.q)(target)}(${(0, exports.q)(targetCol)}) ${actions}`,
186
+ });
187
+ for (const e of entities) {
188
+ const table = (0, exports.tableName)(e);
189
+ const t = { name: table, columns: [] };
190
+ const customPk = ((_a = e.fields) !== null && _a !== void 0 ? _a : []).some((f) => f.primary);
191
+ if (!customPk) {
192
+ const pkType = e.primaryKeyType || "serial";
193
+ if (pkType === "uuid") {
194
+ model.extensions.push(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
195
+ t.columns.push(column("id", "uuid", true, "uuid_generate_v4()"));
196
+ }
197
+ else if (pkType === "text")
198
+ t.columns.push(column("id", "text", true));
199
+ else
200
+ t.columns.push(column("id", "integer", true, `nextval('${table}_id_seq'::regclass)`, true));
201
+ }
202
+ if (e.created_at)
203
+ t.columns.push(column("created_at", "TIMESTAMP", true, "now()"));
204
+ if (e.updated_at)
205
+ t.columns.push(column("updated_at", "TIMESTAMP", true, "now()"));
206
+ for (const f of (0, field_1.getFieldForTemplate)((_b = e.fields) !== null && _b !== void 0 ? _b : [], e.name)) {
207
+ if (!customPk && f.name === "id")
208
+ continue;
209
+ if (e.created_at && f.name === "created_at")
210
+ continue;
211
+ if (e.updated_at && f.name === "updated_at")
212
+ continue;
213
+ if (f.type === "enum" && f.values)
214
+ model.enums.push({
215
+ name: (0, exports.enumTypeName)(table, f.name),
216
+ values: [...f.values],
217
+ });
218
+ const spec = columnSpec(table, f);
219
+ t.columns.push(column(f.name, spec.type, spec.notNull, spec.default));
220
+ if (spec.unique)
221
+ unique(table, [f.name], exports.constraintName.unique(table, [f.name]));
222
+ if (spec.index)
223
+ index(table, [f.name]);
224
+ }
225
+ for (const i of (_c = e.indexes) !== null && _c !== void 0 ? _c : [])
226
+ index(table, i.fields, i.unique);
227
+ for (const u of (_d = e.uniques) !== null && _d !== void 0 ? _d : [])
228
+ unique(table, u.fields, u.name || exports.constraintName.unique(table, u.fields));
229
+ for (const r of (0, relationships_1.getRelationshipForTemplate)(e.name, relationshipMap[e.name] || [], entities)) {
230
+ const target = byName.get(r.name);
231
+ if (!target)
232
+ continue;
233
+ const targetTable = (0, exports.tableName)(target);
234
+ // The template gives OneToOne onDelete CASCADE and a nullable join column.
235
+ const oneToOne = r.type === "OneToOne";
236
+ const onDelete = r.cascadeDelete || oneToOne ? "CASCADE" : "NO ACTION";
237
+ if (r.type === "ManyToOne" || (oneToOne && r.joinTable)) {
238
+ const col = r.type === "ManyToOne"
239
+ ? r.camelCasedId
240
+ : typeormCase.camelCase(`${r.relationshipName}_id`);
241
+ t.columns.push(column(col, fkType(target), !(r.nullable || oneToOne)));
242
+ if (oneToOne)
243
+ unique(table, [col], exports.constraintName.relation(table, [col]));
244
+ if (r.type === "ManyToOne" && r.index)
245
+ index(table, [col]);
246
+ foreignKey(table, col, targetTable, pkColumn(target), `ON DELETE ${onDelete} ON UPDATE NO ACTION`);
247
+ }
248
+ if (r.type === "ManyToMany" && r.joinTable) {
249
+ const join = r.joinTableName ||
250
+ typeormCase.snakeCase(`${table}_${r.pluralizedRelationshipName}_${targetTable}`);
251
+ const ownCol = r.joinColumnName || typeormCase.camelCase(`${table}_${pkColumn(e)}`);
252
+ const otherCol = r.inverseJoinColumnName ||
253
+ typeormCase.camelCase(`${targetTable}_${pkColumn(target)}`);
254
+ joinTables.push({
255
+ name: join,
256
+ columns: [
257
+ column(ownCol, fkType(e), true),
258
+ column(otherCol, fkType(target), true),
259
+ ],
260
+ });
261
+ primaryKey(join, [ownCol, otherCol]);
262
+ index(join, [ownCol]);
263
+ index(join, [otherCol]);
264
+ // TypeORM cascades the inverse key only when no inverse relation exists.
265
+ const inverse = r.biDirectional ? "NO ACTION" : "CASCADE";
266
+ foreignKey(join, ownCol, table, pkColumn(e), "ON DELETE CASCADE ON UPDATE CASCADE");
267
+ foreignKey(join, otherCol, targetTable, pkColumn(target), `ON DELETE ${inverse} ON UPDATE ${inverse}`);
268
+ }
269
+ }
270
+ primaryKey(table, customPk
271
+ ? ((_e = e.fields) !== null && _e !== void 0 ? _e : []).filter((f) => f.primary).map((f) => f.name)
272
+ : ["id"]);
273
+ entityTables.unshift(t);
274
+ }
275
+ model.extensions = [...new Set(model.extensions)];
276
+ model.tables = [...entityTables, ...joinTables];
277
+ model.constraints = [...inline, ...foreignKeys];
278
+ return model;
279
+ }
280
+ exports.schemaModel = schemaModel;
281
+ const createTypeSql = (e) => `CREATE TYPE "public".${(0, exports.q)(e.name)} AS ENUM(${e.values
282
+ .map((v) => (0, exports.literal)(v))
283
+ .join(", ")})`;
284
+ exports.createTypeSql = createTypeSql;
285
+ const createIndexSql = (i) => `CREATE ${i.unique ? "UNIQUE " : ""}INDEX ${(0, exports.q)(i.name)} ON ${(0, exports.q)(i.table)} (${cols(i.columns)})`;
286
+ exports.createIndexSql = createIndexSql;
287
+ const addConstraintSql = (c) => `ALTER TABLE ${(0, exports.q)(c.table)} ADD CONSTRAINT ${(0, exports.q)(c.name)} ${c.body}`;
288
+ exports.addConstraintSql = addConstraintSql;
289
+ const createTableSql = (t, constraints) => `CREATE TABLE ${(0, exports.q)(t.name)} (${[
290
+ ...t.columns.map((c) => c.sql),
291
+ ...constraints
292
+ .filter((c) => c.table === t.name && c.kind !== "fk")
293
+ .map((c) => `CONSTRAINT ${(0, exports.q)(c.name)} ${c.body}`),
294
+ ].join(", ")})`;
295
+ exports.createTableSql = createTableSql;
296
+ /** CREATE statements for every enum type, table, index and foreign key. */
297
+ function schemaDdl(input) {
298
+ const m = schemaModel(input);
299
+ return [
300
+ ...m.extensions,
301
+ ...m.enums.map((e) => (0, exports.createTypeSql)(e)),
302
+ ...m.tables.map((t) => (0, exports.createTableSql)(t, m.constraints)),
303
+ ...m.indexes.map((i) => (0, exports.createIndexSql)(i)),
304
+ ...m.constraints
305
+ .filter((c) => c.kind === "fk")
306
+ .map((c) => (0, exports.addConstraintSql)(c)),
307
+ ];
308
+ }
309
+ exports.schemaDdl = schemaDdl;
@@ -0,0 +1,42 @@
1
+ import { ApsorcType } from "../apsorc-parser";
2
+ import { Question } from "../plan";
3
+ import { Finding } from "../verify/review";
4
+ import { DownDdl } from "./change";
5
+ export declare function typeormMigration(className: string, up: string[], down: DownDdl): string;
6
+ /**
7
+ * A Python string literal for any SQL. A JSON string is one (same escapes),
8
+ * where """...""" breaks on SQL that ends in a quoted identifier (#148).
9
+ */
10
+ export declare const pyString: (s: string) => string;
11
+ export declare function alembicMigration(revision: string, downRevision: string | undefined, up: string[], down: DownDdl): string;
12
+ /**
13
+ * Statements for a goose SQL file. goose splits on lines ending in ";", so a
14
+ * statement with a ";" of its own is fenced with StatementBegin/End.
15
+ */
16
+ export declare const gooseStatements: (statements: string[]) => string;
17
+ export declare function gooseMigration(up: string[], down: DownDdl): string;
18
+ /** The newest Alembic revision: the one no other revision names as its down_revision. */
19
+ export declare function alembicHead(dir: string): string | undefined;
20
+ /**
21
+ * The up and down SQL of an Alembic or goose migration, for running it on a
22
+ * database without the framework. down is undefined when it refuses to run.
23
+ */
24
+ export declare function migrationSql(file: string, source: string): {
25
+ up: string;
26
+ down?: string;
27
+ };
28
+ export interface PlannedMigration {
29
+ ok: boolean;
30
+ needed: boolean;
31
+ /** Questions still open in the plan; nothing is written while any are. */
32
+ open?: Question[];
33
+ file?: string;
34
+ planFile?: string;
35
+ findings?: Finding[];
36
+ steps: {
37
+ name: string;
38
+ ok: boolean;
39
+ output?: string;
40
+ }[];
41
+ }
42
+ export declare function writePlannedMigration(projectDir: string, before: ApsorcType, name: string, planPath: string, now?: number): PlannedMigration;
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.writePlannedMigration = exports.migrationSql = exports.alembicHead = exports.gooseMigration = exports.gooseStatements = exports.alembicMigration = exports.pyString = exports.typeormMigration = void 0;
4
+ const tslib_1 = require("tslib");
5
+ /**
6
+ * Write a migration from an answered plan: up is Apso's change DDL, down the
7
+ * reverse, and the plan is saved beside the migration so the answers that
8
+ * produced it stay with it. The SQL is the same for every language; only the
9
+ * wrapper differs: a TypeORM class, an Alembic revision, or a goose SQL file.
10
+ */
11
+ const fs = tslib_1.__importStar(require("fs"));
12
+ const path = tslib_1.__importStar(require("path"));
13
+ const plan_1 = require("../plan");
14
+ const baseline_1 = require("../plan/baseline");
15
+ const casing_1 = require("../utils/casing");
16
+ const review_1 = require("../verify/review");
17
+ const change_1 = require("./change");
18
+ const _1 = require(".");
19
+ const template = (sql) => "`" +
20
+ sql.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\${/g, "\\${") +
21
+ "`";
22
+ const queries = (statements) => statements
23
+ .map((s) => ` await queryRunner.query(${template(s)});`)
24
+ .join("\n");
25
+ function typeormMigration(className, up, down) {
26
+ const downBody = "down" in down
27
+ ? queries(down.down)
28
+ : ` throw new Error(${JSON.stringify(irreversible(down))});`;
29
+ return `import { MigrationInterface, QueryRunner } from "typeorm";
30
+
31
+ // Generated by apso migrate generate from the answered migration plan saved beside this file.
32
+ export class ${className} implements MigrationInterface {
33
+ name = '${className}'
34
+
35
+ public async up(queryRunner: QueryRunner): Promise<void> {
36
+ ${queries(up)}
37
+ }
38
+
39
+ public async down(queryRunner: QueryRunner): Promise<void> {
40
+ ${downBody}
41
+ }
42
+
43
+ }
44
+ `;
45
+ }
46
+ exports.typeormMigration = typeormMigration;
47
+ const irreversible = (down) => `This migration can't be reversed automatically: ${down.irreversible.join("; ")}`;
48
+ /**
49
+ * A Python string literal for any SQL. A JSON string is one (same escapes),
50
+ * where """...""" breaks on SQL that ends in a quoted identifier (#148).
51
+ */
52
+ const pyString = (s) => JSON.stringify(s);
53
+ exports.pyString = pyString;
54
+ const executes = (statements) => statements.map((s) => ` op.execute(${(0, exports.pyString)(s)})`).join("\n");
55
+ function alembicMigration(revision, downRevision, up, down) {
56
+ const downBody = "down" in down
57
+ ? executes(down.down) || " pass"
58
+ : ` raise NotImplementedError(${(0, exports.pyString)(irreversible(down))})`;
59
+ return `"""Generated by apso migrate generate from the answered migration plan saved beside this file."""
60
+ from alembic import op
61
+
62
+ revision = ${(0, exports.pyString)(revision)}
63
+ down_revision = ${downRevision === undefined ? "None" : (0, exports.pyString)(downRevision)}
64
+ branch_labels = None
65
+ depends_on = None
66
+
67
+
68
+ def upgrade() -> None:
69
+ ${executes(up)}
70
+
71
+
72
+ def downgrade() -> None:
73
+ ${downBody}
74
+ `;
75
+ }
76
+ exports.alembicMigration = alembicMigration;
77
+ /**
78
+ * Statements for a goose SQL file. goose splits on lines ending in ";", so a
79
+ * statement with a ";" of its own is fenced with StatementBegin/End.
80
+ */
81
+ const gooseStatements = (statements) => statements
82
+ .map((s) => s.includes(";")
83
+ ? `-- +goose StatementBegin\n${s};\n-- +goose StatementEnd`
84
+ : `${s};`)
85
+ .join("\n");
86
+ exports.gooseStatements = gooseStatements;
87
+ function gooseMigration(up, down) {
88
+ // goose has no way to refuse a down, so the SQL refuses it instead.
89
+ const downBody = "down" in down
90
+ ? (0, exports.gooseStatements)(down.down)
91
+ : (0, exports.gooseStatements)([
92
+ `DO $$ BEGIN RAISE EXCEPTION ${(0, _1.literal)(irreversible(down).replace(/%/g, "%%"))}; END $$`,
93
+ ]);
94
+ return `-- Generated by apso migrate generate from the answered migration plan saved beside this file.
95
+ -- +goose Up
96
+ ${(0, exports.gooseStatements)(up)}
97
+
98
+ -- +goose Down
99
+ ${downBody}
100
+ `;
101
+ }
102
+ exports.gooseMigration = gooseMigration;
103
+ /** The newest Alembic revision: the one no other revision names as its down_revision. */
104
+ function alembicHead(dir) {
105
+ var _a;
106
+ if (!fs.existsSync(dir))
107
+ return undefined;
108
+ const revisions = [];
109
+ const downs = new Set();
110
+ for (const f of fs
111
+ .readdirSync(dir)
112
+ .filter((f) => f.endsWith(".py"))
113
+ .sort()) {
114
+ const source = fs.readFileSync(path.join(dir, f)).toString();
115
+ const rev = source.match(/^revision\b[^\n=]*=\s*["']([^"']+)["']/m);
116
+ if (rev)
117
+ revisions.push(rev[1]);
118
+ const down = source.match(/^down_revision\b[^\n=]*=(.*)$/m);
119
+ for (const d of (_a = down === null || down === void 0 ? void 0 : down[1].matchAll(/["']([^"']+)["']/g)) !== null && _a !== void 0 ? _a : [])
120
+ downs.add(d[1]);
121
+ }
122
+ return revisions.filter((r) => !downs.has(r)).pop();
123
+ }
124
+ exports.alembicHead = alembicHead;
125
+ const calls = (body) => [...body.matchAll(/^\s*op\.execute\((".*")\)\s*$/gm)]
126
+ .map((m) => JSON.parse(m[1]))
127
+ .join(";\n");
128
+ /**
129
+ * The up and down SQL of an Alembic or goose migration, for running it on a
130
+ * database without the framework. down is undefined when it refuses to run.
131
+ */
132
+ function migrationSql(file, source) {
133
+ if (file.endsWith(".sql")) {
134
+ const [up, down = ""] = source
135
+ .split(/^-- \+goose Down\b.*$/m)
136
+ .map((s) => s.replace(/^[\S\s]*?^-- \+goose Up\b.*$/m, ""));
137
+ return { up, down: /RAISE EXCEPTION/.test(down) ? undefined : down };
138
+ }
139
+ const [upBody, downBody = ""] = source.split(/^def downgrade\b/m);
140
+ return {
141
+ up: calls(upBody),
142
+ down: /raise NotImplementedError/.test(downBody) ? undefined : calls(downBody),
143
+ };
144
+ }
145
+ exports.migrationSql = migrationSql;
146
+ function writePlannedMigration(projectDir, before, name, planPath, now = Date.now()) {
147
+ const after = JSON.parse(fs.readFileSync(path.join(projectDir, ".apsorc")).toString());
148
+ const previous = fs.existsSync(planPath)
149
+ ? JSON.parse(fs.readFileSync(planPath).toString())
150
+ : undefined;
151
+ const plan = (0, plan_1.planMigration)(before, after, previous);
152
+ fs.mkdirSync(path.dirname(planPath), { recursive: true });
153
+ fs.writeFileSync(planPath, JSON.stringify({ ...plan, before }, null, 2) + "\n");
154
+ const open = (0, plan_1.openQuestions)(plan);
155
+ if (open.length > 0)
156
+ return {
157
+ ok: false,
158
+ needed: true,
159
+ open,
160
+ steps: [
161
+ {
162
+ name: "plan",
163
+ ok: false,
164
+ output: `${open.length} question(s) in ${path.relative(projectDir, planPath)} are open. Answer them with apso migrate plan --answer, then generate again.`,
165
+ },
166
+ ],
167
+ };
168
+ let up;
169
+ let down;
170
+ try {
171
+ up = (0, change_1.changeDdl)(before, after, plan).up;
172
+ down = (0, change_1.downDdl)(before, after, plan);
173
+ }
174
+ catch (error) {
175
+ const output = error instanceof Error ? error.message : String(error);
176
+ return {
177
+ ok: false,
178
+ needed: true,
179
+ steps: [
180
+ {
181
+ name: "render",
182
+ ok: false,
183
+ output: `${output}\nTo use TypeORM's generator instead, rerun with --typeorm.`,
184
+ },
185
+ ],
186
+ };
187
+ }
188
+ if (up.length === 0)
189
+ return { ok: true, needed: false, steps: [{ name: "render", ok: true }] };
190
+ const className = `${(0, casing_1.pascalCase)(name)}Migration${now}`;
191
+ const dir = (0, baseline_1.migrationsDir)(projectDir, after);
192
+ fs.mkdirSync(dir, { recursive: true });
193
+ // goose orders by the number before "_", and its own files use
194
+ // YYYYMMDDHHMMSS, so Python and Go use that too.
195
+ const stamp = new Date(now).toISOString().replace(/\D/g, "").slice(0, 14);
196
+ const base = after.language === "python" || after.language === "go"
197
+ ? `${stamp}_${(0, casing_1.snakeCase)(name)}`
198
+ : `${now}-${name}-migration`;
199
+ const file = base + (0, baseline_1.migrationExtension)(after);
200
+ const source = after.language === "python"
201
+ ? alembicMigration(stamp, alembicHead(dir), up, down)
202
+ : after.language === "go"
203
+ ? gooseMigration(up, down)
204
+ : typeormMigration(className, up, down);
205
+ fs.writeFileSync(path.join(dir, file), source);
206
+ fs.writeFileSync(path.join(dir, `${base}.plan.json`),
207
+ // The schemas let later runs find the baseline without being told.
208
+ JSON.stringify({ ...plan, before, after }, null, 2) + "\n");
209
+ // The answers now live beside the migration; the next change starts fresh.
210
+ fs.rmSync(planPath, { force: true });
211
+ const rel = (f) => path.relative(projectDir, path.join(dir, f));
212
+ return {
213
+ ok: true,
214
+ needed: true,
215
+ file: rel(file),
216
+ planFile: rel(`${base}.plan.json`),
217
+ // The review reads TypeORM's wrapper; the SQL is the same in all three.
218
+ findings: (0, review_1.reviewMigration)(typeormMigration(className, up, down)),
219
+ steps: [{ name: "render", ok: true }],
220
+ };
221
+ }
222
+ exports.writePlannedMigration = writePlannedMigration;
@@ -9,6 +9,7 @@ const field_1 = require("../utils/field");
9
9
  const relationships_1 = require("../utils/relationships");
10
10
  const casing_1 = require("../utils/casing");
11
11
  const events_1 = require("../events");
12
+ const migration_file_1 = require("../ddl/migration-file");
12
13
  const pluralize_1 = tslib_1.__importDefault(require("pluralize"));
13
14
  /**
14
15
  * Go/Gin generator that produces GORM models, DTOs, Gin handlers,
@@ -293,8 +294,8 @@ class GoGenerator extends base_1.BaseGenerator {
293
294
  .slice(0, 14);
294
295
  const name = migrationName || "auto";
295
296
  const filename = `${timestamp}_${name}.sql`;
296
- const upStatements = upSql.map((sql) => `${sql};`).join("\n");
297
- const downStatements = (downSql || []).map((sql) => `${sql};`).join("\n");
297
+ const upStatements = (0, migration_file_1.gooseStatements)(upSql);
298
+ const downStatements = (0, migration_file_1.gooseStatements)(downSql || []);
298
299
  const content = `-- +goose Up
299
300
  ${upStatements}
300
301
 
@@ -9,6 +9,7 @@ const field_1 = require("../utils/field");
9
9
  const relationships_1 = require("../utils/relationships");
10
10
  const casing_1 = require("../utils/casing");
11
11
  const events_1 = require("../events");
12
+ const migration_file_1 = require("../ddl/migration-file");
12
13
  const pluralize_1 = tslib_1.__importDefault(require("pluralize"));
13
14
  /**
14
15
  * Python/FastAPI generator that produces SQLAlchemy models, Pydantic schemas,
@@ -225,10 +226,10 @@ class PythonGenerator extends base_1.BaseGenerator {
225
226
  const name = migrationName || "auto";
226
227
  const filename = `${timestamp}_${name}.py`;
227
228
  const upStatements = upSql
228
- .map((sql) => ` op.execute("""${sql}""")`)
229
+ .map((sql) => ` op.execute(${(0, migration_file_1.pyString)(sql)})`)
229
230
  .join("\n");
230
231
  const downStatements = (downSql || [])
231
- .map((sql) => ` op.execute("""${sql}""")`)
232
+ .map((sql) => ` op.execute(${(0, migration_file_1.pyString)(sql)})`)
232
233
  .join("\n");
233
234
  const content = `"""${name}"""
234
235
  from alembic import op
@@ -0,0 +1,22 @@
1
+ import { ApsorcType } from "../apsorc-parser";
2
+ export interface SavedPlan {
3
+ before: ApsorcType;
4
+ after: ApsorcType;
5
+ }
6
+ export interface Baseline {
7
+ schema: ApsorcType;
8
+ /** Where the schema came from, for messages. */
9
+ source: string;
10
+ }
11
+ /** Where each language's migrations live, and the extension of one. */
12
+ export declare const migrationsDir: (projectDir: string, apsorc?: ApsorcType) => string;
13
+ export declare const migrationExtension: (apsorc?: ApsorcType) => ".ts" | ".py" | ".sql";
14
+ /** The newest migration with a saved plan, by its timestamp prefix. */
15
+ export declare function latestPlannedMigration(projectDir: string, apsorc?: ApsorcType): {
16
+ file: string;
17
+ planFile: string;
18
+ saved: SavedPlan;
19
+ } | undefined;
20
+ export declare function resolveBaseline(projectDir: string, apsorc: ApsorcType, explicit?: string, planPath?: string): Baseline;
21
+ /** A baseline as a file, for steps that scaffold it into a project copy. */
22
+ export declare function baselineFile(baseline: Baseline): string;