@metaobjectsdev/migrate-ts 0.6.0 → 0.7.0-rc.1
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 +4 -2
- package/dist/diff/index.d.ts.map +1 -1
- package/dist/diff/index.js +23 -0
- package/dist/diff/index.js.map +1 -1
- package/dist/emit/index.d.ts.map +1 -1
- package/dist/emit/index.js +7 -3
- package/dist/emit/index.js.map +1 -1
- package/dist/emit/postgres.d.ts.map +1 -1
- package/dist/emit/postgres.js +22 -11
- package/dist/emit/postgres.js.map +1 -1
- package/dist/expected-schema.d.ts +7 -1
- package/dist/expected-schema.d.ts.map +1 -1
- package/dist/expected-schema.js +35 -30
- package/dist/expected-schema.js.map +1 -1
- package/dist/expected-views.d.ts +5 -0
- package/dist/expected-views.d.ts.map +1 -0
- package/dist/expected-views.js +147 -0
- package/dist/expected-views.js.map +1 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/diff/index.ts +29 -0
- package/src/emit/index.ts +9 -5
- package/src/emit/postgres.ts +25 -12
- package/src/expected-schema.ts +48 -27
- package/src/expected-views.ts +175 -0
- package/src/types.ts +11 -4
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// expected-views.ts — derive ViewDescriptor[] from projection metadata.
|
|
2
|
+
//
|
|
3
|
+
// Ports the shape of csharp/MetaObjects.Codegen/Schema/PostgresSchema.cs's
|
|
4
|
+
// CreateView. v1 supports:
|
|
5
|
+
//
|
|
6
|
+
// * passthrough origin (no @via) → plain column from the single base entity
|
|
7
|
+
// * aggregate origin (@agg/@of/@via) → correlated subquery over a to-many
|
|
8
|
+
//
|
|
9
|
+
// Deferred to v2 (returns a "-- TODO" view that the runner skips applying):
|
|
10
|
+
//
|
|
11
|
+
// * passthrough origin WITH @via → to-one correlated subquery
|
|
12
|
+
// * collection origin → json_agg over a to-many
|
|
13
|
+
// * multi-base projections → blocked
|
|
14
|
+
//
|
|
15
|
+
// Identifiers are quoted throughout so mixed-case column names (e.g.
|
|
16
|
+
// "programId") survive PG's case-folding pass.
|
|
17
|
+
|
|
18
|
+
import type { MetaObject, MetaRoot } from "@metaobjectsdev/metadata";
|
|
19
|
+
import {
|
|
20
|
+
type ColumnNamingStrategy,
|
|
21
|
+
MetaPassthroughOrigin,
|
|
22
|
+
MetaAggregateOrigin,
|
|
23
|
+
MetaCollectionOrigin,
|
|
24
|
+
MetaSource,
|
|
25
|
+
SOURCE_ROLE_PRIMARY,
|
|
26
|
+
TYPE_OBJECT,
|
|
27
|
+
TYPE_ORIGIN,
|
|
28
|
+
resolveColumnName,
|
|
29
|
+
resolveTableName,
|
|
30
|
+
stripPackage,
|
|
31
|
+
} from "@metaobjectsdev/metadata";
|
|
32
|
+
import type { ViewDescriptor } from "./types.js";
|
|
33
|
+
|
|
34
|
+
export function buildExpectedViews(root: MetaRoot, strategy: ColumnNamingStrategy): ViewDescriptor[] {
|
|
35
|
+
const out: ViewDescriptor[] = [];
|
|
36
|
+
for (const child of root.ownChildren()) {
|
|
37
|
+
if (child.type !== TYPE_OBJECT) continue;
|
|
38
|
+
const proj = child as MetaObject;
|
|
39
|
+
if (proj.isAbstract) continue;
|
|
40
|
+
if (!isReadOnlyProjection(proj)) continue;
|
|
41
|
+
const view = buildView(proj, root, strategy);
|
|
42
|
+
if (view !== null) out.push(view);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isReadOnlyProjection(entity: MetaObject): boolean {
|
|
48
|
+
const sources = entity.ownChildren().filter((c): c is MetaSource => c instanceof MetaSource);
|
|
49
|
+
if (sources.length === 0) return false;
|
|
50
|
+
const hasReadOnly = sources.some((s) => s.isReadOnly());
|
|
51
|
+
const hasWritable = sources.some((s) => !s.isReadOnly());
|
|
52
|
+
return hasReadOnly && !hasWritable;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildView(
|
|
56
|
+
projection: MetaObject, root: MetaRoot, strategy: ColumnNamingStrategy,
|
|
57
|
+
): ViewDescriptor | null {
|
|
58
|
+
const primarySource = projection.ownChildren().find(
|
|
59
|
+
(c): c is MetaSource => c instanceof MetaSource && c.role === SOURCE_ROLE_PRIMARY,
|
|
60
|
+
);
|
|
61
|
+
if (primarySource?.tableName === undefined) return null;
|
|
62
|
+
const viewName = primarySource.tableName;
|
|
63
|
+
|
|
64
|
+
const cols: string[] = [];
|
|
65
|
+
let baseEntity: string | undefined;
|
|
66
|
+
let blocked: string | undefined;
|
|
67
|
+
const T = "t"; // target alias in correlated subqueries
|
|
68
|
+
|
|
69
|
+
const baseTable = () => {
|
|
70
|
+
if (baseEntity === undefined) return undefined;
|
|
71
|
+
const found = root.findObject(baseEntity);
|
|
72
|
+
return found ? resolveTableName(found) : baseEntity;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (const f of projection.fields()) {
|
|
76
|
+
const origin = f.ownChildren().find((c) => c.type === TYPE_ORIGIN);
|
|
77
|
+
const fieldCol = resolveColumnName(f, strategy);
|
|
78
|
+
|
|
79
|
+
if (origin instanceof MetaPassthroughOrigin && origin.via === undefined &&
|
|
80
|
+
origin.from !== undefined && origin.from.includes(".")) {
|
|
81
|
+
const [ent, field] = splitDot(origin.from);
|
|
82
|
+
const bare = stripPackage(ent);
|
|
83
|
+
baseEntity ??= bare;
|
|
84
|
+
if (bare !== baseEntity) { blocked = "passthrough from multiple base entities"; break; }
|
|
85
|
+
const srcEntity = root.findObject(baseEntity);
|
|
86
|
+
const srcCol = resolveColumnByName(srcEntity, field, strategy);
|
|
87
|
+
cols.push(` "${srcCol}" AS "${fieldCol}"`);
|
|
88
|
+
} else if (origin instanceof MetaAggregateOrigin && origin.agg !== undefined &&
|
|
89
|
+
origin.of !== undefined && origin.via !== undefined &&
|
|
90
|
+
origin.of.includes(".") && origin.via.includes(".")) {
|
|
91
|
+
const [baseEnt, relName] = splitDot(origin.via);
|
|
92
|
+
const bareBase = stripPackage(baseEnt);
|
|
93
|
+
baseEntity ??= bareBase;
|
|
94
|
+
if (bareBase !== baseEntity) { blocked = "aggregate over a different base entity"; break; }
|
|
95
|
+
const fk = resolveToManyFk(root, baseEntity, relName, strategy);
|
|
96
|
+
if (fk === null) {
|
|
97
|
+
blocked = `unresolved to-many FK for @via "${origin.via}" (target needs an identity.reference back to ${baseEntity})`;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
const ofCol = resolveColumnByName(fk.target, splitDot(origin.of)[1], strategy);
|
|
101
|
+
cols.push(
|
|
102
|
+
` (SELECT ${origin.agg}(${T}."${ofCol}") ` +
|
|
103
|
+
`FROM "${fk.targetTable}" ${T} ` +
|
|
104
|
+
`WHERE ${T}."${fk.fkCol}" = "${baseTable()}"."${fk.parentCol}") AS "${fieldCol}"`,
|
|
105
|
+
);
|
|
106
|
+
} else if (origin instanceof MetaPassthroughOrigin || origin instanceof MetaCollectionOrigin) {
|
|
107
|
+
blocked = `field "${f.name}" uses an origin shape not yet supported by TS migrate-ts (passthrough-via / collection — deferred)`;
|
|
108
|
+
break;
|
|
109
|
+
} else {
|
|
110
|
+
blocked = `field "${f.name}" has no resolvable origin`;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (blocked !== undefined || baseEntity === undefined || cols.length === 0) {
|
|
116
|
+
// Caller decides what to do with a null result. For v1 the runner just
|
|
117
|
+
// omits it from expected.views — diff sees no expected view, no
|
|
118
|
+
// create-view change, and an actual leftover view (if any) gets dropped.
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const sql = `SELECT\n${cols.join(",\n")}\nFROM "${baseTable()}"`;
|
|
123
|
+
const view: ViewDescriptor = { name: viewName, sql };
|
|
124
|
+
return view;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function splitDot(s: string): [string, string] {
|
|
128
|
+
const i = s.indexOf(".");
|
|
129
|
+
return [s.slice(0, i), s.slice(i + 1)];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function resolveColumnByName(
|
|
133
|
+
owner: MetaObject | undefined, fieldName: string, strategy: ColumnNamingStrategy,
|
|
134
|
+
): string {
|
|
135
|
+
if (owner === undefined) return fieldName;
|
|
136
|
+
const field = owner.fields().find((f) => f.name === fieldName);
|
|
137
|
+
return field ? resolveColumnName(field, strategy) : fieldName;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface ToManyFk {
|
|
141
|
+
target: MetaObject;
|
|
142
|
+
targetTable: string;
|
|
143
|
+
/** Column on the target entity holding the FK back to the base. */
|
|
144
|
+
fkCol: string;
|
|
145
|
+
/** Column on the base entity the FK references (usually the base's PK). */
|
|
146
|
+
parentCol: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resolveToManyFk(
|
|
150
|
+
root: MetaRoot, baseEntityName: string, relName: string, strategy: ColumnNamingStrategy,
|
|
151
|
+
): ToManyFk | null {
|
|
152
|
+
const baseObj = root.findObject(baseEntityName);
|
|
153
|
+
if (baseObj === undefined) return null;
|
|
154
|
+
const rel = baseObj.relationships().find((r) => r.name === relName);
|
|
155
|
+
if (rel === undefined || rel.objectRef === undefined) return null;
|
|
156
|
+
const target = root.findObject(stripPackage(rel.objectRef));
|
|
157
|
+
if (target === undefined) return null;
|
|
158
|
+
|
|
159
|
+
const fkRef = target.referenceIdentities().find(
|
|
160
|
+
(r) => r.targetEntity !== undefined && stripPackage(r.targetEntity) === baseEntityName,
|
|
161
|
+
);
|
|
162
|
+
if (fkRef === undefined) return null;
|
|
163
|
+
const fkFields = fkRef.fields;
|
|
164
|
+
if (fkFields.length === 0) return null;
|
|
165
|
+
const fkCol = resolveColumnByName(target, fkFields[0]!, strategy);
|
|
166
|
+
|
|
167
|
+
const parentFieldName = fkRef.targetFields.length > 0
|
|
168
|
+
? fkRef.targetFields[0]!
|
|
169
|
+
: baseObj.primaryIdentity()?.fields[0];
|
|
170
|
+
if (parentFieldName === undefined) return null;
|
|
171
|
+
const parentCol = resolveColumnByName(baseObj, parentFieldName, strategy);
|
|
172
|
+
|
|
173
|
+
return { target, targetTable: resolveTableName(target), fkCol, parentCol };
|
|
174
|
+
}
|
|
175
|
+
|
package/src/types.ts
CHANGED
|
@@ -82,7 +82,14 @@ export interface ViewDescriptor {
|
|
|
82
82
|
name: string;
|
|
83
83
|
/** Same semantics as TableDescriptor.schema. */
|
|
84
84
|
schema?: string;
|
|
85
|
-
|
|
85
|
+
/**
|
|
86
|
+
* View body: everything between `CREATE VIEW <name> AS` and the trailing `;`
|
|
87
|
+
* (the SELECT clause through the FROM/WHERE/GROUP-BY tail). Populated by
|
|
88
|
+
* `buildExpectedSchema` from projection metadata; omitted by introspect
|
|
89
|
+
* (body-level comparison isn't implemented yet — diff matches by name only,
|
|
90
|
+
* so a body change does NOT trigger replace-view today).
|
|
91
|
+
*/
|
|
92
|
+
sql?: string;
|
|
86
93
|
}
|
|
87
94
|
|
|
88
95
|
// ---------------------------------------------------------------------------
|
|
@@ -118,9 +125,9 @@ export type Change =
|
|
|
118
125
|
| { kind: "add-fk"; table: string; schema?: string; fk: FkDescriptor; status: ChangeStatus }
|
|
119
126
|
| { kind: "drop-fk"; table: string; schema?: string; fk: string; status: ChangeStatus }
|
|
120
127
|
// Declared for v0.3, never produced in v0.1:
|
|
121
|
-
| { kind: "create-view"; view: ViewDescriptor; status: ChangeStatus }
|
|
122
|
-
| { kind: "drop-view"; view: string; status: ChangeStatus }
|
|
123
|
-
| { kind: "replace-view"; view: ViewDescriptor; status: ChangeStatus };
|
|
128
|
+
| { kind: "create-view"; view: ViewDescriptor; schema?: string; status: ChangeStatus }
|
|
129
|
+
| { kind: "drop-view"; view: string; schema?: string; status: ChangeStatus }
|
|
130
|
+
| { kind: "replace-view"; view: ViewDescriptor; schema?: string; status: ChangeStatus };
|
|
124
131
|
|
|
125
132
|
export type ChangeKind = Change["kind"];
|
|
126
133
|
|