@metaobjectsdev/cli 1.0.8 → 1.0.9-rc.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/dist/src/commands/eject.d.ts.map +1 -1
- package/dist/src/commands/eject.js +22 -4
- package/dist/src/commands/eject.js.map +1 -1
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +0 -3
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/migrate.d.ts +1 -1
- package/dist/src/commands/migrate.d.ts.map +1 -1
- package/dist/src/commands/migrate.js +35 -22
- package/dist/src/commands/migrate.js.map +1 -1
- package/dist/src/commands/verify.d.ts +12 -0
- package/dist/src/commands/verify.d.ts.map +1 -1
- package/dist/src/commands/verify.js +102 -20
- package/dist/src/commands/verify.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/advisory.d.ts +19 -0
- package/dist/src/lib/advisory.d.ts.map +1 -1
- package/dist/src/lib/advisory.js +27 -0
- package/dist/src/lib/advisory.js.map +1 -1
- package/dist/src/lib/allow.d.ts +21 -1
- package/dist/src/lib/allow.d.ts.map +1 -1
- package/dist/src/lib/allow.js +62 -1
- package/dist/src/lib/allow.js.map +1 -1
- package/dist/src/lib/catalog-listing.d.ts +2 -0
- package/dist/src/lib/catalog-listing.d.ts.map +1 -1
- package/dist/src/lib/catalog-listing.js +7 -0
- package/dist/src/lib/catalog-listing.js.map +1 -1
- package/dist/src/lib/file-provider.d.ts +12 -0
- package/dist/src/lib/file-provider.d.ts.map +1 -1
- package/dist/src/lib/file-provider.js +26 -1
- package/dist/src/lib/file-provider.js.map +1 -1
- package/dist/src/lib/fk-index-advisory.d.ts +17 -0
- package/dist/src/lib/fk-index-advisory.d.ts.map +1 -0
- package/dist/src/lib/fk-index-advisory.js +77 -0
- package/dist/src/lib/fk-index-advisory.js.map +1 -0
- package/dist/src/lib/output.d.ts +9 -0
- package/dist/src/lib/output.d.ts.map +1 -1
- package/dist/src/lib/output.js +17 -3
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/referential-action-advisory.d.ts +14 -0
- package/dist/src/lib/referential-action-advisory.d.ts.map +1 -0
- package/dist/src/lib/referential-action-advisory.js +38 -0
- package/dist/src/lib/referential-action-advisory.js.map +1 -0
- package/package.json +10 -10
- package/src/commands/eject.ts +25 -4
- package/src/commands/init.ts +0 -3
- package/src/commands/migrate.ts +32 -24
- package/src/commands/verify.ts +108 -18
- package/src/index.ts +5 -2
- package/src/lib/advisory.ts +33 -0
- package/src/lib/allow.ts +66 -2
- package/src/lib/catalog-listing.ts +7 -0
- package/src/lib/file-provider.ts +27 -1
- package/src/lib/fk-index-advisory.ts +112 -0
- package/src/lib/output.ts +25 -3
- package/src/lib/referential-action-advisory.ts +58 -0
package/src/index.ts
CHANGED
|
@@ -142,6 +142,8 @@ MIGRATE FLAGS:
|
|
|
142
142
|
drop-column,drop-table,type-change,drop-index,drop-fk,
|
|
143
143
|
drop-check,drop-view,drop-view-cascade,
|
|
144
144
|
adopt-view,nullable-to-not-null,drop-identity-default
|
|
145
|
+
drop-column DELETES the column's data; for a renamed
|
|
146
|
+
column use --rename-column, which keeps it
|
|
145
147
|
--on-ambiguous abort|rename|drop-add Default abort
|
|
146
148
|
--rename-table [schema.]old=new Declare a table rename (repeatable)
|
|
147
149
|
--rename-column [schema.]table.old=new Declare a column rename; keeps its data (repeatable)
|
|
@@ -151,8 +153,7 @@ MIGRATE FLAGS:
|
|
|
151
153
|
--yes Skip the --remote --apply confirmation pause
|
|
152
154
|
--dry-run Print SQL to stdout, don't write
|
|
153
155
|
|
|
154
|
-
|
|
155
|
-
ship in later sub-projects. See https://metaobjects.com for docs.
|
|
156
|
+
See https://metaobjects.com for docs.
|
|
156
157
|
`;
|
|
157
158
|
|
|
158
159
|
/** Focused per-subcommand usage slices shown by `<cmd> --help`. */
|
|
@@ -323,6 +324,8 @@ FLAGS:
|
|
|
323
324
|
A bare 'meta verify' also runs an ADVISORY anti-pattern pass: it scans your authored
|
|
324
325
|
source for hand-rolled aggregates, money-as-float, and CHECK-IN enums and points you
|
|
325
326
|
at the construct that models them (origin.aggregate / field.currency / field.enum).
|
|
327
|
+
The same pass names every foreign key with no index on its referencing column(s)
|
|
328
|
+
and the index.lookup that would cover it (migrate never adds one on its own).
|
|
326
329
|
Warnings only — it never fails the build. Opt out with --no-antipatterns or
|
|
327
330
|
META_NO_ANTIPATTERNS=1.
|
|
328
331
|
|
package/src/lib/advisory.ts
CHANGED
|
@@ -163,6 +163,39 @@ export function libraryPrefixRows(
|
|
|
163
163
|
}));
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Unindexed foreign keys in the same row shape. `construct` is `<entity FQN>.<reference>`,
|
|
168
|
+
* the node the author edits to declare the index. There is no line: the finding is about the
|
|
169
|
+
* loaded model, which does not carry source positions.
|
|
170
|
+
*/
|
|
171
|
+
export function unindexedFkRows(
|
|
172
|
+
findings: readonly { file: string; construct: string; message: string }[],
|
|
173
|
+
): AdvisoryFindingRow[] {
|
|
174
|
+
return findings.map((f) => ({
|
|
175
|
+
file: f.file,
|
|
176
|
+
line: 0,
|
|
177
|
+
rule: "unindexed-foreign-key",
|
|
178
|
+
construct: f.construct,
|
|
179
|
+
message: f.message,
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Relationships on both sides of one FK that disagree on its referential action, in the
|
|
185
|
+
* same row shape. `construct` is `<entity FQN>.<reference>` — the FK, where the fix goes.
|
|
186
|
+
*/
|
|
187
|
+
export function referentialActionConflictRows(
|
|
188
|
+
findings: readonly { file: string; construct: string; message: string }[],
|
|
189
|
+
): AdvisoryFindingRow[] {
|
|
190
|
+
return findings.map((f) => ({
|
|
191
|
+
file: f.file,
|
|
192
|
+
line: 0,
|
|
193
|
+
rule: "overridden-referential-action",
|
|
194
|
+
construct: f.construct,
|
|
195
|
+
message: f.message,
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
166
199
|
// ---------------------------------------------------------------------------
|
|
167
200
|
// text output
|
|
168
201
|
// ---------------------------------------------------------------------------
|
package/src/lib/allow.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// both `meta migrate` and `meta verify --db`. Keeping a single copy avoids the
|
|
3
3
|
// two commands drifting on which `--allow` tokens exist or how a change reads.
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import { allowOptionFor, suggestColumnRenames } from "@metaobjectsdev/migrate-ts";
|
|
6
|
+
import type { AllowOptions, Change, ColumnRenameSuggestion } from "@metaobjectsdev/migrate-ts";
|
|
7
|
+
import type { BlockedEntry } from "./output.js";
|
|
6
8
|
|
|
7
9
|
// Map CLI allow tokens → migrate-ts AllowOptions field names.
|
|
8
10
|
// Exported (not just module-local) so allow-tokens-pinned.test.ts can pin its
|
|
@@ -77,9 +79,71 @@ export function describeChange(c: Change): string {
|
|
|
77
79
|
case "drop-index": return `${c.table} idx ${c.index}`;
|
|
78
80
|
case "add-fk": return `${c.table} fk ${c.fk.name}`;
|
|
79
81
|
case "drop-fk": return `${c.table} fk ${c.fk}`;
|
|
82
|
+
case "add-check": return `${c.table} check ${c.check.name} (${c.check.expression})`;
|
|
83
|
+
case "drop-check":
|
|
84
|
+
return c.restore !== undefined
|
|
85
|
+
? `${c.table} check ${c.check} (${c.restore.expression})`
|
|
86
|
+
: `${c.table} check ${c.check}`;
|
|
80
87
|
case "create-view": return c.view.name;
|
|
81
88
|
case "replace-view": return c.view.name;
|
|
82
89
|
case "drop-view": return c.view;
|
|
83
|
-
default: return JSON.stringify(c);
|
|
84
90
|
}
|
|
91
|
+
// Exhaustive: a new Change kind fails to compile here rather than printing a JSON dump
|
|
92
|
+
// of the change object to a person (which is what a blocked drop-check used to do).
|
|
93
|
+
const unhandled: never = c;
|
|
94
|
+
return unhandled;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The `--allow` token that unblocks `c`. migrate-ts picks the permission by what blocked the
|
|
98
|
+
* change (a type change can be blocked by the auto-sequence default it carries), so this only
|
|
99
|
+
* maps that permission back to its CLI token. */
|
|
100
|
+
export function allowFlagFor(c: Change): string {
|
|
101
|
+
const option = allowOptionFor(c);
|
|
102
|
+
const token = Object.keys(ALLOW_TOKEN_MAP).find((t) => ALLOW_TOKEN_MAP[t] === option);
|
|
103
|
+
return token ?? c.kind;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `--rename-column [schema.]table.old=new`, spelled with the pair's real names. */
|
|
107
|
+
export function renameColumnFlag(r: ColumnRenameSuggestion): string {
|
|
108
|
+
return `--rename-column ${r.schema !== undefined ? `${r.schema}.` : ""}${r.table}.${r.from}=${r.to}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One reportable entry per blocked change. `changes` is the whole diff the blocked ones came
|
|
113
|
+
* from: a blocked drop-column that pairs with an add-column in the same table may be a column
|
|
114
|
+
* the author RENAMED, and `--allow drop-column` would delete its data — so that entry carries
|
|
115
|
+
* the declared-rename flag, which every renderer prints FIRST.
|
|
116
|
+
*/
|
|
117
|
+
export function blockedEntriesFor(blocked: readonly Change[], changes: readonly Change[]): BlockedEntry[] {
|
|
118
|
+
const renames = suggestColumnRenames(changes);
|
|
119
|
+
return blocked.map((c) => {
|
|
120
|
+
const entry: BlockedEntry = { kind: c.kind, description: describeChange(c), allowFlag: allowFlagFor(c) };
|
|
121
|
+
if (c.kind !== "drop-column") return entry;
|
|
122
|
+
const r = renames.find((x) => x.from === c.column && x.table === c.table && x.schema === c.schema);
|
|
123
|
+
if (r === undefined) return entry;
|
|
124
|
+
return {
|
|
125
|
+
...entry,
|
|
126
|
+
renameFlag: renameColumnFlag(r),
|
|
127
|
+
renameTo: `${r.table}.${r.to}`,
|
|
128
|
+
...(r.shapeChange !== undefined ? { shapeChange: r.shapeChange } : {}),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The hint for one blocked entry, as the lines a renderer prints in order. A rename-pairable
|
|
135
|
+
* drop leads with the declared rename and names `--allow drop-column` as what deletes the data;
|
|
136
|
+
* every other entry is the one-line `--allow` hint it always was.
|
|
137
|
+
*/
|
|
138
|
+
export function blockedHintLines(e: BlockedEntry): string[] {
|
|
139
|
+
if (e.renameFlag === undefined) {
|
|
140
|
+
return [`blocked '${e.kind}' on ${e.description} (allow with --allow ${e.allowFlag})`];
|
|
141
|
+
}
|
|
142
|
+
const shape = e.shapeChange !== undefined
|
|
143
|
+
? ` (its ${e.shapeChange} also changes: rename first with the old shape still declared, then change the shape in a second migration)`
|
|
144
|
+
: "";
|
|
145
|
+
return [
|
|
146
|
+
`${e.description} is dropped and ${e.renameTo ?? "a column"} is added in the same table. If it was renamed, keep its data: re-run with ${e.renameFlag}${shape}`,
|
|
147
|
+
` (--allow ${e.allowFlag} instead DELETES ${e.description} and all its data)`,
|
|
148
|
+
];
|
|
85
149
|
}
|
|
@@ -334,6 +334,11 @@ export const REFERENCE_HELPER_NOTE: readonly string[] = [
|
|
|
334
334
|
"render) is not a generator and is not listed here.",
|
|
335
335
|
];
|
|
336
336
|
|
|
337
|
+
/** The footer line explaining the `package-only` row mark. */
|
|
338
|
+
export const PACKAGE_ONLY_NOTE =
|
|
339
|
+
"package-only = no reference template ships for it yet, so `meta eject` cannot copy it; " +
|
|
340
|
+
"import it from its package instead.";
|
|
341
|
+
|
|
337
342
|
/** The human rendering: grouped by layer, because layer is the axis you select by. */
|
|
338
343
|
export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
|
|
339
344
|
const lines: string[] = [];
|
|
@@ -352,6 +357,7 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
|
|
|
352
357
|
const marks: string[] = [];
|
|
353
358
|
if (r.framework !== undefined) marks.push(r.framework);
|
|
354
359
|
if (r.tier === "neutral") marks.push("neutral");
|
|
360
|
+
if (!r.source.ejectable) marks.push("package-only");
|
|
355
361
|
if (r.project?.wired) marks.push("WIRED");
|
|
356
362
|
if (r.source.owned) marks.push("owned");
|
|
357
363
|
if (probed && r.project?.wouldEmit !== null && r.project?.wouldEmit !== undefined) {
|
|
@@ -372,6 +378,7 @@ export function renderCatalogText(rows: CatalogRow[], probed: boolean): string {
|
|
|
372
378
|
lines.push("");
|
|
373
379
|
lines.push("`meta eject <name...>` copies a generator into codegen/generators/ and prints");
|
|
374
380
|
lines.push("the import to add, the entry to wire, and what to install.");
|
|
381
|
+
if (generators.some((r) => !r.source.ejectable)) lines.push(PACKAGE_ONLY_NOTE);
|
|
375
382
|
if (!probed) {
|
|
376
383
|
lines.push("Add --probe to see how many files each would emit for YOUR model.");
|
|
377
384
|
}
|
package/src/lib/file-provider.ts
CHANGED
|
@@ -16,13 +16,23 @@ export class FileProvider implements Provider {
|
|
|
16
16
|
constructor(private readonly baseDir: string) {}
|
|
17
17
|
|
|
18
18
|
resolve(ref: string): string | undefined {
|
|
19
|
+
const path = this.pathOf(ref);
|
|
20
|
+
return path === undefined ? undefined : readFileSync(path, "utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The file `ref` resolves to, or undefined. `resolve` reads exactly this file, so a
|
|
25
|
+
* diagnostic that names it names the body that was checked.
|
|
26
|
+
*/
|
|
27
|
+
pathOf(ref: string): string | undefined {
|
|
19
28
|
// `group/source` → <baseDir>/group/source<ext>; node:path.join normalizes
|
|
20
29
|
// the embedded "/" separators for the host OS.
|
|
21
30
|
for (const ext of EXTENSIONS) {
|
|
22
31
|
const path = join(this.baseDir, ref) + ext;
|
|
23
32
|
if (existsSync(path)) {
|
|
24
33
|
try {
|
|
25
|
-
|
|
34
|
+
readFileSync(path, "utf8");
|
|
35
|
+
return path;
|
|
26
36
|
} catch {
|
|
27
37
|
// unreadable — fall through and try the next candidate
|
|
28
38
|
}
|
|
@@ -31,3 +41,19 @@ export class FileProvider implements Provider {
|
|
|
31
41
|
return undefined;
|
|
32
42
|
}
|
|
33
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The 1-based line of the first mustache tag naming `path` in `text` — `{{path}}`,
|
|
47
|
+
* `{{{path}}}`, `{{& path}}`, `{{#path}}`, `{{^path}}` — trying the full dotted path and then
|
|
48
|
+
* its head segment (a dotted drift is reported on the path, but the tag may open a section on
|
|
49
|
+
* the head). Undefined when no tag matches, e.g. the variable lives in a partial.
|
|
50
|
+
*/
|
|
51
|
+
export function mustacheTagLine(text: string, path: string): number | undefined {
|
|
52
|
+
const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
53
|
+
const head = path.split(".")[0] ?? path;
|
|
54
|
+
for (const name of path === head ? [path] : [path, head]) {
|
|
55
|
+
const m = new RegExp(`\\{\\{\\{?\\s*[#^&]?\\s*${esc(name)}\\s*\\}`).exec(text);
|
|
56
|
+
if (m !== null) return text.slice(0, m.index).split("\n").length;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Foreign keys with no index covering their columns — an advisory authoring lint.
|
|
2
|
+
//
|
|
3
|
+
// `meta migrate` emits a FOREIGN KEY constraint for every enforced `identity.reference`
|
|
4
|
+
// and no index on its columns: neither Postgres nor SQLite indexes the referencing side
|
|
5
|
+
// of a foreign key. Every join from the parent, and every ON DELETE cascade / set-null /
|
|
6
|
+
// restrict check from it, then scans the child table. Generating the index automatically
|
|
7
|
+
// would propose a migration to every existing adopter the release it shipped, so this is
|
|
8
|
+
// advice, never a gate: it names each such key and says how to declare the index.
|
|
9
|
+
//
|
|
10
|
+
// Coverage is read off the EXPECTED SCHEMA migrate itself builds (primary key, every
|
|
11
|
+
// `identity.secondary`, every `index.lookup`, every `@unique` field), not re-derived from
|
|
12
|
+
// the metadata — so "covered" means covered in the DDL migrate would emit. An index covers
|
|
13
|
+
// a key when its leading columns are exactly the key's columns, in any order. Expression
|
|
14
|
+
// indexes are not counted; partial ones are (bias to under-flagging).
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
INDEX_SUBTYPE_LOOKUP,
|
|
18
|
+
TYPE_INDEX,
|
|
19
|
+
TYPE_SUBTYPE_SEPARATOR,
|
|
20
|
+
} from "@metaobjectsdev/metadata/constants";
|
|
21
|
+
import {
|
|
22
|
+
isMetaObject,
|
|
23
|
+
resolveColumnName,
|
|
24
|
+
type ColumnNamingStrategy,
|
|
25
|
+
type MetaData,
|
|
26
|
+
} from "@metaobjectsdev/metadata";
|
|
27
|
+
import {
|
|
28
|
+
buildExpectedSchemaWithProvenance,
|
|
29
|
+
qualifiedDbName,
|
|
30
|
+
type Dialect,
|
|
31
|
+
type TableDescriptor,
|
|
32
|
+
} from "@metaobjectsdev/migrate-ts";
|
|
33
|
+
|
|
34
|
+
export interface UnindexedFkFinding {
|
|
35
|
+
/** The metadata file declaring the entity ("" when the loader recorded none). */
|
|
36
|
+
file: string;
|
|
37
|
+
/** `<entity FQN>.<reference name>`. */
|
|
38
|
+
construct: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UnindexedFkOptions {
|
|
43
|
+
dialect: Dialect;
|
|
44
|
+
columnNamingStrategy: ColumnNamingStrategy;
|
|
45
|
+
/** Skip an entity by FQN — imported (dependency) metadata is not the adopter's to index. */
|
|
46
|
+
skip?: (fqn: string) => boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const INDEX_LOOKUP = `${TYPE_INDEX}${TYPE_SUBTYPE_SEPARATOR}${INDEX_SUBTYPE_LOOKUP}`;
|
|
50
|
+
|
|
51
|
+
function covered(table: TableDescriptor, fkColumns: readonly string[]): boolean {
|
|
52
|
+
const want = new Set(fkColumns);
|
|
53
|
+
const prefixes = [
|
|
54
|
+
table.primaryKey,
|
|
55
|
+
...table.indexes.filter((i) => i.expr === undefined).map((i) => i.columns),
|
|
56
|
+
];
|
|
57
|
+
return prefixes.some((cols) =>
|
|
58
|
+
cols.length >= want.size && cols.slice(0, want.size).every((c) => want.has(c)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pascal(s: string): string {
|
|
62
|
+
return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function scanForUnindexedForeignKeys(root: MetaData, opts: UnindexedFkOptions): UnindexedFkFinding[] {
|
|
66
|
+
const { snapshot, provenance } = buildExpectedSchemaWithProvenance(root, {
|
|
67
|
+
dialect: opts.dialect,
|
|
68
|
+
columnNamingStrategy: opts.columnNamingStrategy,
|
|
69
|
+
});
|
|
70
|
+
// ADR-0039: effective children — resolve rather than rely on root being unextended.
|
|
71
|
+
const objectsByFqn = new Map(
|
|
72
|
+
root.children().filter(isMetaObject).map((o) => [o.resolutionKey(), o] as const),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const out: UnindexedFkFinding[] = [];
|
|
76
|
+
for (const table of snapshot.tables) {
|
|
77
|
+
const fqn = provenance.get(qualifiedDbName(table));
|
|
78
|
+
if (fqn === undefined || opts.skip?.(fqn) === true) continue;
|
|
79
|
+
const entity = objectsByFqn.get(fqn);
|
|
80
|
+
if (entity === undefined) continue;
|
|
81
|
+
const files = "files" in entity.source ? entity.source.files : [];
|
|
82
|
+
// A node a shipped library contributed is the library's design, not the adopter's.
|
|
83
|
+
if (files.some((f) => f.startsWith("library:"))) continue;
|
|
84
|
+
|
|
85
|
+
for (const fk of table.foreignKeys) {
|
|
86
|
+
if (covered(table, fk.columns)) continue;
|
|
87
|
+
// Name the key in the author's terms: the reference and its FIELDS, not columns.
|
|
88
|
+
// A TPH subtype's reference folds into its base's table and is not on `entity`;
|
|
89
|
+
// it is then reported by column, which is still enough to act on.
|
|
90
|
+
const ref = entity.referenceIdentities().find((r) => {
|
|
91
|
+
const cols = r.fields.map((f) => {
|
|
92
|
+
const field = entity.fields().find((x) => x.name === f);
|
|
93
|
+
return field !== undefined ? resolveColumnName(field, opts.columnNamingStrategy) : f;
|
|
94
|
+
});
|
|
95
|
+
return cols.length === fk.columns.length && cols.every((c, i) => c === fk.columns[i]);
|
|
96
|
+
});
|
|
97
|
+
const fields = ref !== undefined ? ref.fields : fk.columns;
|
|
98
|
+
const label = ref !== undefined ? `${entity.name}.${ref.name}` : `${entity.name} (${fk.name})`;
|
|
99
|
+
const indexName = `by${fields.map(pascal).join("")}`;
|
|
100
|
+
const fieldList = fields.length === 1 ? fields[0]! : `[${fields.join(", ")}]`;
|
|
101
|
+
out.push({
|
|
102
|
+
file: files[0] ?? "",
|
|
103
|
+
construct: `${fqn}.${ref?.name ?? fk.name}`,
|
|
104
|
+
message:
|
|
105
|
+
`${label}: foreign key (${fields.join(", ")}) → ${fk.refTable} has no index covering it, ` +
|
|
106
|
+
`so joins from ${fk.refTable} and its ON DELETE checks scan ${table.name}. ` +
|
|
107
|
+
`Declare one on ${entity.name}: ${INDEX_LOOKUP}: { name: ${indexName}, fields: ${fieldList} }`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
package/src/lib/output.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { Dialect } from "./kysely.js";
|
|
7
7
|
import { toonEncode } from "./format.js";
|
|
8
8
|
import { skippedSection, type AdvisoryFindingRow, type AdvisorySection } from "./advisory.js";
|
|
9
|
+
import { blockedHintLines } from "./allow.js";
|
|
9
10
|
|
|
10
11
|
export interface FormatOptions {
|
|
11
12
|
isTTY: boolean;
|
|
@@ -154,6 +155,15 @@ export interface BlockedEntry {
|
|
|
154
155
|
kind: string;
|
|
155
156
|
description: string;
|
|
156
157
|
allowFlag: string;
|
|
158
|
+
/**
|
|
159
|
+
* Set on a blocked drop-column that pairs with an add-column in the same table: the exact
|
|
160
|
+
* `--rename-column` flag that keeps the data. Printed BEFORE `--allow`, which deletes it.
|
|
161
|
+
*/
|
|
162
|
+
renameFlag?: string;
|
|
163
|
+
/** `table.column` the dropped column may have been renamed to. */
|
|
164
|
+
renameTo?: string;
|
|
165
|
+
/** The shape difference that forces the rename and the reshape into two migrations. */
|
|
166
|
+
shapeChange?: string;
|
|
157
167
|
}
|
|
158
168
|
|
|
159
169
|
export interface AmbiguousEntry {
|
|
@@ -204,9 +214,14 @@ export function formatMigrateResult(result: MigrateResultShape, _opts: FormatOpt
|
|
|
204
214
|
}
|
|
205
215
|
|
|
206
216
|
if (result.blocked.length > 0) {
|
|
207
|
-
|
|
217
|
+
const anyRename = result.blocked.some((b) => b.renameFlag !== undefined);
|
|
218
|
+
lines.push(anyRename ? " Blocked (re-run with the flag shown):" : " Blocked (re-run with --allow):");
|
|
208
219
|
for (const b of result.blocked) {
|
|
209
|
-
|
|
220
|
+
if (b.renameFlag === undefined) {
|
|
221
|
+
lines.push(` ${b.kind} ${b.description} (--allow ${b.allowFlag})`);
|
|
222
|
+
} else {
|
|
223
|
+
for (const l of blockedHintLines(b)) lines.push(` ${l}`);
|
|
224
|
+
}
|
|
210
225
|
}
|
|
211
226
|
lines.push("");
|
|
212
227
|
}
|
|
@@ -328,8 +343,15 @@ export function migrateResultToData(result: MigrateResultShape): {
|
|
|
328
343
|
let help: string[];
|
|
329
344
|
if (isBlocked) {
|
|
330
345
|
summary = `${changeSummary}; not applied`;
|
|
346
|
+
// A rename-pairable drop leads: its `--allow` would delete the data the rename keeps.
|
|
347
|
+
const renames = result.blocked.filter((b) => b.renameFlag !== undefined);
|
|
348
|
+
const others = result.blocked.filter((b) => b.renameFlag === undefined);
|
|
331
349
|
help = [
|
|
332
|
-
...
|
|
350
|
+
...renames.map((b) =>
|
|
351
|
+
`if ${b.description} was renamed to ${b.renameTo ?? "the added column"}, re-run with ${b.renameFlag} to keep its data` +
|
|
352
|
+
`${b.shapeChange !== undefined ? ` (its ${b.shapeChange} also changes: rename first, reshape in a second migration)` : ""}` +
|
|
353
|
+
`; --allow ${b.allowFlag} instead DELETES the column and all its data`),
|
|
354
|
+
...others.map((b) => `re-run with --allow ${b.allowFlag} to apply: ${b.description}`),
|
|
333
355
|
...result.ambiguous.map((a) => `re-run with --on-ambiguous to resolve: ${a.hint}`),
|
|
334
356
|
];
|
|
335
357
|
} else if (result.dryRun) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Two relationships over one foreign key that disagree on its referential action.
|
|
2
|
+
//
|
|
3
|
+
// A 1:N may be declared on either side — the parent (`Author` composition `books`) or the
|
|
4
|
+
// FK-owning child (`Book` association `author`) — and each side's subtype implies an
|
|
5
|
+
// ON DELETE default (composition → cascade, association → restrict). When BOTH are
|
|
6
|
+
// declared and disagree, the documented precedence (ADR-0047) lets the FK-owning side
|
|
7
|
+
// govern, so the parent's relationship has no effect on the constraint. That is a
|
|
8
|
+
// defensible rule and a silent one: an adopter declared a composition, read "cascade" in
|
|
9
|
+
// the docs, and got ON DELETE RESTRICT and a 409 with nothing saying why.
|
|
10
|
+
//
|
|
11
|
+
// Advisory, never a gate: changing the precedence would change the DDL of every model
|
|
12
|
+
// that relies on it. This names each such key, the action that governs, and the two
|
|
13
|
+
// one-line ways to say which one the author means. Read off the SAME walk that emits the
|
|
14
|
+
// FK (`buildExpectedSchema`'s `onReferentialActionConflict`), so it can only ever name a
|
|
15
|
+
// constraint migrate actually writes. `meta verify` lists them; `meta migrate` warns.
|
|
16
|
+
|
|
17
|
+
import type { MetaData } from "@metaobjectsdev/metadata";
|
|
18
|
+
import {
|
|
19
|
+
buildExpectedSchema,
|
|
20
|
+
describeReferentialActionConflict,
|
|
21
|
+
type ReferentialActionConflict,
|
|
22
|
+
} from "@metaobjectsdev/migrate-ts";
|
|
23
|
+
|
|
24
|
+
export interface ReferentialActionConflictFinding {
|
|
25
|
+
/** The metadata file declaring the FK-owning entity ("" when the loader recorded none). */
|
|
26
|
+
file: string;
|
|
27
|
+
/** `<entity FQN>.<reference name>` — the FK, which is where the fix is declared. */
|
|
28
|
+
construct: string;
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ReferentialActionConflictOptions {
|
|
33
|
+
/** Skip an entity by FQN — imported (dependency) metadata is not the adopter's to fix. */
|
|
34
|
+
skip?: (fqn: string) => boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function scanForReferentialActionConflicts(
|
|
38
|
+
root: MetaData,
|
|
39
|
+
opts: ReferentialActionConflictOptions = {},
|
|
40
|
+
): ReferentialActionConflictFinding[] {
|
|
41
|
+
const conflicts: ReferentialActionConflict[] = [];
|
|
42
|
+
buildExpectedSchema(root, { onReferentialActionConflict: (c) => conflicts.push(c) });
|
|
43
|
+
|
|
44
|
+
const out: ReferentialActionConflictFinding[] = [];
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
for (const c of conflicts) {
|
|
47
|
+
const fqn = c.entity.resolutionKey();
|
|
48
|
+
if (opts.skip?.(fqn) === true) continue;
|
|
49
|
+
const construct = `${fqn}.${c.ref.name}`;
|
|
50
|
+
if (seen.has(construct)) continue;
|
|
51
|
+
seen.add(construct);
|
|
52
|
+
const files = "files" in c.entity.source ? c.entity.source.files : [];
|
|
53
|
+
// A node a shipped library contributed is the library's design, not the adopter's.
|
|
54
|
+
if (files.some((f) => f.startsWith("library:"))) continue;
|
|
55
|
+
out.push({ file: files[0] ?? "", construct, message: describeReferentialActionConflict(c) });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|