@apso/cli 0.43.4 → 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.
- package/README.md +7 -11
- package/dist/commands/deploy.js +1 -1
- package/dist/commands/migrate/generate.d.ts +3 -1
- package/dist/commands/migrate/generate.js +42 -8
- package/dist/commands/migrate/plan.d.ts +12 -0
- package/dist/commands/migrate/plan.js +88 -0
- package/dist/commands/migrate/sandbox.d.ts +11 -0
- package/dist/commands/migrate/sandbox.js +132 -0
- package/dist/commands/migrate/verify.d.ts +5 -2
- package/dist/commands/migrate/verify.js +44 -9
- package/dist/commands/migrate.d.ts +16 -3
- package/dist/commands/migrate.js +171 -96
- package/dist/lib/ddl/catalog.d.ts +21 -0
- package/dist/lib/ddl/catalog.js +58 -0
- package/dist/lib/ddl/change.d.ts +19 -0
- package/dist/lib/ddl/change.js +393 -0
- package/dist/lib/ddl/index.d.ts +80 -0
- package/dist/lib/ddl/index.js +309 -0
- package/dist/lib/ddl/migration-file.d.ts +42 -0
- package/dist/lib/ddl/migration-file.js +222 -0
- package/dist/lib/generators/go.js +3 -2
- package/dist/lib/generators/python.js +3 -2
- package/dist/lib/plan/baseline.d.ts +22 -0
- package/dist/lib/plan/baseline.js +100 -0
- package/dist/lib/plan/index.d.ts +52 -0
- package/dist/lib/plan/index.js +272 -0
- package/dist/lib/plan/prompts.d.ts +18 -0
- package/dist/lib/plan/prompts.js +77 -0
- package/dist/lib/verify/index.d.ts +27 -9
- package/dist/lib/verify/index.js +249 -21
- package/dist/lib/verify/review.d.ts +14 -0
- package/dist/lib/verify/review.js +98 -0
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +176 -23
- 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 =
|
|
297
|
-
const downStatements = (downSql || [])
|
|
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(
|
|
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(
|
|
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;
|