turbine-orm 0.40.1 → 0.41.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 +22 -4
- package/dist/cjs/cli/config.js +3 -0
- package/dist/cjs/cli/index.js +179 -0
- package/dist/cjs/cli/prisma-report.js +216 -0
- package/dist/cjs/cli/prisma-resolve.js +335 -0
- package/dist/cjs/cli/prisma-schema.js +484 -0
- package/dist/cjs/client.js +1 -0
- package/dist/cjs/generate.js +279 -22
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/introspect.js +203 -26
- package/dist/cjs/mssql.js +9 -10
- package/dist/cjs/mysql.js +3 -9
- package/dist/cjs/powdb-introspect.js +5 -10
- package/dist/cjs/powql.js +13 -0
- package/dist/cjs/prisma-compat.js +1147 -0
- package/dist/cjs/query/aggregates.js +67 -7
- package/dist/cjs/query/builder.js +388 -17
- package/dist/cjs/query/compound-unique.js +0 -0
- package/dist/cjs/query/relations.js +7 -5
- package/dist/cjs/query/warn-registry.js +98 -0
- package/dist/cjs/query/writes.js +13 -5
- package/dist/cjs/schema.js +47 -0
- package/dist/cjs/sqlite.js +4 -9
- package/dist/cli/config.d.ts +26 -0
- package/dist/cli/config.js +3 -0
- package/dist/cli/index.d.ts +11 -0
- package/dist/cli/index.js +180 -1
- package/dist/cli/prisma-report.d.ts +19 -0
- package/dist/cli/prisma-report.js +211 -0
- package/dist/cli/prisma-resolve.d.ts +87 -0
- package/dist/cli/prisma-resolve.js +330 -0
- package/dist/cli/prisma-schema.d.ts +116 -0
- package/dist/cli/prisma-schema.js +479 -0
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +18 -2
- package/dist/client.js +1 -0
- package/dist/generate.d.ts +80 -1
- package/dist/generate.js +277 -25
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/introspect.d.ts +92 -2
- package/dist/introspect.js +198 -26
- package/dist/mssql.js +10 -11
- package/dist/mysql.js +4 -10
- package/dist/powdb-introspect.js +5 -10
- package/dist/powql.js +13 -0
- package/dist/prisma-compat.d.ts +281 -0
- package/dist/prisma-compat.js +1143 -0
- package/dist/query/aggregates.js +67 -7
- package/dist/query/builder.d.ts +77 -4
- package/dist/query/builder.js +390 -19
- package/dist/query/compound-unique.d.ts +49 -0
- package/dist/query/compound-unique.js +0 -0
- package/dist/query/deferred.d.ts +18 -0
- package/dist/query/relations.js +7 -5
- package/dist/query/types.d.ts +70 -9
- package/dist/query/warn-registry.d.ts +57 -0
- package/dist/query/warn-registry.js +92 -0
- package/dist/query/writes.js +13 -5
- package/dist/schema.d.ts +75 -0
- package/dist/schema.js +46 -0
- package/dist/sqlite.js +5 -10
- package/package.json +6 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a {@link ResolutionResult} into the `prisma-migration-report.md`
|
|
3
|
+
* artifact and a short console summary. Pure leaf - string in, string out.
|
|
4
|
+
*/
|
|
5
|
+
const CHECK = 'OK';
|
|
6
|
+
const CROSS = 'UNRESOLVED';
|
|
7
|
+
function modelDisplayStatus(m) {
|
|
8
|
+
if (m.status === 'parsed')
|
|
9
|
+
return 'parsed';
|
|
10
|
+
if (m.status === 'unresolved')
|
|
11
|
+
return CROSS;
|
|
12
|
+
return m.viaMap ? `${CHECK} (@@map)` : CHECK;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Build the full Markdown migration report.
|
|
16
|
+
*/
|
|
17
|
+
export function formatPrismaReport(result, options = {}) {
|
|
18
|
+
const L = [];
|
|
19
|
+
L.push('# Prisma to Turbine migration report');
|
|
20
|
+
L.push('');
|
|
21
|
+
if (options.schemaPath)
|
|
22
|
+
L.push(`Source: \`${options.schemaPath}\``);
|
|
23
|
+
if (!options.noTimestamp)
|
|
24
|
+
L.push(`Generated: ${new Date().toISOString()}`);
|
|
25
|
+
L.push(`Mode: ${result.noDb ? 'parse-only (--no-db, no database resolution)' : 'resolved against live database'}`);
|
|
26
|
+
L.push('');
|
|
27
|
+
// ---- Summary ----------------------------------------------------------
|
|
28
|
+
const modelCount = result.models.length;
|
|
29
|
+
const resolvedModels = result.models.filter((m) => m.status === 'resolved').length;
|
|
30
|
+
const unresolvedModels = result.models.filter((m) => m.status === 'unresolved').length;
|
|
31
|
+
L.push('## Summary');
|
|
32
|
+
L.push('');
|
|
33
|
+
if (result.noDb) {
|
|
34
|
+
L.push(`- Parsed ${modelCount} model(s), ${result.enums.length} enum(s).`);
|
|
35
|
+
L.push('- No database URL provided - names were not resolved. Re-run without `--no-db` to resolve.');
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
L.push(`- Models: ${resolvedModels}/${modelCount} resolved${unresolvedModels ? `, ${unresolvedModels} UNRESOLVED` : ''}.`);
|
|
39
|
+
L.push(`- Enums: ${result.enums.filter((e) => e.status === 'resolved').length}/${result.enums.length} resolved.`);
|
|
40
|
+
L.push(`- Overall: ${result.hasUnresolved ? 'INCOMPLETE (some items unresolved)' : 'complete (all items resolved)'}.`);
|
|
41
|
+
}
|
|
42
|
+
L.push('');
|
|
43
|
+
// ---- Model resolution table ------------------------------------------
|
|
44
|
+
L.push('## Models');
|
|
45
|
+
L.push('');
|
|
46
|
+
L.push('| Prisma model | Turbine accessor | Table | Status |');
|
|
47
|
+
L.push('| --- | --- | --- | --- |');
|
|
48
|
+
for (const m of result.models) {
|
|
49
|
+
L.push(`| ${m.prismaName} | ${m.accessor ?? '-'} | ${m.table ?? '-'} | ${modelDisplayStatus(m)} |`);
|
|
50
|
+
}
|
|
51
|
+
L.push('');
|
|
52
|
+
// ---- Per-model detail -------------------------------------------------
|
|
53
|
+
for (const m of result.models) {
|
|
54
|
+
L.push(`### ${m.prismaName}`);
|
|
55
|
+
L.push('');
|
|
56
|
+
if (m.status === 'unresolved') {
|
|
57
|
+
L.push(`> UNRESOLVED: ${m.reason ?? 'no matching table'}`);
|
|
58
|
+
L.push('');
|
|
59
|
+
}
|
|
60
|
+
if (m.fields.length > 0) {
|
|
61
|
+
L.push('Fields:');
|
|
62
|
+
L.push('');
|
|
63
|
+
for (const f of m.fields) {
|
|
64
|
+
if (f.status === 'resolved') {
|
|
65
|
+
L.push(`- \`${f.prismaName}\` -> \`${f.turbineField}\` (column \`${f.column}\`)`);
|
|
66
|
+
}
|
|
67
|
+
else if (f.status === 'parsed') {
|
|
68
|
+
L.push(`- \`${f.prismaName}\` (parsed)`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
L.push(`- \`${f.prismaName}\` UNRESOLVED: ${f.reason}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
L.push('');
|
|
75
|
+
}
|
|
76
|
+
if (m.relations.length > 0) {
|
|
77
|
+
L.push('Relations:');
|
|
78
|
+
L.push('');
|
|
79
|
+
for (const r of m.relations) {
|
|
80
|
+
if (r.status === 'resolved') {
|
|
81
|
+
const j = r.junction ? `, junction \`${r.junction}\`` : '';
|
|
82
|
+
L.push(`- \`${r.prismaName}\` -> \`${r.turbineName}\` (${r.cardinality}${j})`);
|
|
83
|
+
}
|
|
84
|
+
else if (r.status === 'parsed') {
|
|
85
|
+
L.push(`- \`${r.prismaName}\` -> ${r.targetModel} (parsed)`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
L.push(`- \`${r.prismaName}\` -> ${r.targetModel} UNRESOLVED: ${r.reason}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
L.push('');
|
|
92
|
+
}
|
|
93
|
+
if (m.compoundUniques.length > 0) {
|
|
94
|
+
L.push('Compound unique / id selectors:');
|
|
95
|
+
L.push('');
|
|
96
|
+
for (const c of m.compoundUniques) {
|
|
97
|
+
if (c.status === 'resolved') {
|
|
98
|
+
L.push(`- \`${c.selector}\` (@@${c.kind}) -> [${c.turbineFields.join(', ')}]`);
|
|
99
|
+
}
|
|
100
|
+
else if (c.status === 'parsed') {
|
|
101
|
+
L.push(`- \`${c.selector}\` (@@${c.kind}, parsed): [${c.prismaFields.join(', ')}]`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
L.push(`- \`${c.selector}\` (@@${c.kind}) UNRESOLVED: ${c.reason}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
L.push('');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// ---- Junction tables --------------------------------------------------
|
|
111
|
+
const junctions = new Set();
|
|
112
|
+
for (const m of result.models) {
|
|
113
|
+
for (const r of m.relations)
|
|
114
|
+
if (r.junction)
|
|
115
|
+
junctions.add(r.junction);
|
|
116
|
+
}
|
|
117
|
+
if (junctions.size > 0) {
|
|
118
|
+
L.push('## Junction tables (implicit m2m)');
|
|
119
|
+
L.push('');
|
|
120
|
+
for (const j of [...junctions].sort())
|
|
121
|
+
L.push(`- \`${j}\``);
|
|
122
|
+
L.push('');
|
|
123
|
+
}
|
|
124
|
+
// ---- Enums ------------------------------------------------------------
|
|
125
|
+
if (result.enums.length > 0) {
|
|
126
|
+
L.push('## Enums');
|
|
127
|
+
L.push('');
|
|
128
|
+
for (const e of result.enums) {
|
|
129
|
+
if (e.status === 'resolved')
|
|
130
|
+
L.push(`- \`${e.prismaName}\` -> \`${e.turbineName}\``);
|
|
131
|
+
else if (e.status === 'parsed')
|
|
132
|
+
L.push(`- \`${e.prismaName}\` (parsed)`);
|
|
133
|
+
else
|
|
134
|
+
L.push(`- \`${e.prismaName}\` UNRESOLVED: ${e.reason}`);
|
|
135
|
+
}
|
|
136
|
+
L.push('');
|
|
137
|
+
}
|
|
138
|
+
// ---- Unresolved roll-up ----------------------------------------------
|
|
139
|
+
const unresolved = collectUnresolved(result);
|
|
140
|
+
if (unresolved.length > 0) {
|
|
141
|
+
L.push('## Unresolved items');
|
|
142
|
+
L.push('');
|
|
143
|
+
for (const u of unresolved)
|
|
144
|
+
L.push(`- ${u}`);
|
|
145
|
+
L.push('');
|
|
146
|
+
}
|
|
147
|
+
// ---- Parser warnings --------------------------------------------------
|
|
148
|
+
if (result.parseWarnings.length > 0) {
|
|
149
|
+
L.push('## Parser notes');
|
|
150
|
+
L.push('');
|
|
151
|
+
for (const w of result.parseWarnings)
|
|
152
|
+
L.push(`- ${w}`);
|
|
153
|
+
L.push('');
|
|
154
|
+
}
|
|
155
|
+
// ---- Fixed semantic-divergence section --------------------------------
|
|
156
|
+
L.push(SEMANTIC_DIVERGENCE);
|
|
157
|
+
return `${L.join('\n')}\n`;
|
|
158
|
+
}
|
|
159
|
+
/** Flat list of unresolved item descriptions across the whole result. */
|
|
160
|
+
export function collectUnresolved(result) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const m of result.models) {
|
|
163
|
+
if (m.status === 'unresolved')
|
|
164
|
+
out.push(`Model ${m.prismaName}: ${m.reason ?? 'no matching table'}`);
|
|
165
|
+
for (const f of m.fields) {
|
|
166
|
+
if (f.status === 'unresolved')
|
|
167
|
+
out.push(`${m.prismaName}.${f.prismaName} (field): ${f.reason}`);
|
|
168
|
+
}
|
|
169
|
+
for (const r of m.relations) {
|
|
170
|
+
if (r.status === 'unresolved')
|
|
171
|
+
out.push(`${m.prismaName}.${r.prismaName} (relation): ${r.reason}`);
|
|
172
|
+
}
|
|
173
|
+
for (const c of m.compoundUniques) {
|
|
174
|
+
if (c.status === 'unresolved')
|
|
175
|
+
out.push(`${m.prismaName}.${c.selector} (@@${c.kind}): ${c.reason}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const e of result.enums) {
|
|
179
|
+
if (e.status === 'unresolved')
|
|
180
|
+
out.push(`Enum ${e.prismaName}: ${e.reason}`);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/** Static section documenting known Prisma-vs-Turbine behavior differences. */
|
|
185
|
+
const SEMANTIC_DIVERGENCE = `## Behavior notes (Prisma vs Turbine)
|
|
186
|
+
|
|
187
|
+
These are deliberate semantic differences to keep in mind when porting queries.
|
|
188
|
+
Phase 1 ships no runtime; it produces this report plus a typed name map. The
|
|
189
|
+
phase-2 \`turbine-orm/prisma-compat\` adapter handles most of these translations.
|
|
190
|
+
|
|
191
|
+
- Cursor pagination. Turbine cursors are EXCLUSIVE and the comparison direction
|
|
192
|
+
follows the \`orderBy\` entry for the cursor field. Prisma cursors are
|
|
193
|
+
INCLUSIVE and idiomatically paired with \`skip: 1\`. Port \`{ cursor, skip: n }\`
|
|
194
|
+
(n >= 1) to a Turbine cursor plus \`offset: n - 1\`.
|
|
195
|
+
- Aggregate / groupBy \`_count\`. Prisma returns \`_count\` as a record
|
|
196
|
+
(\`{ _all: n }\` / per-field counts). Turbine's scalar \`_count: true\` returns a
|
|
197
|
+
number. Reshape as needed (the phase-2 adapter does this both directions).
|
|
198
|
+
- Relation-array order. Without an \`orderBy\` on a \`with\`/\`include\` clause, the
|
|
199
|
+
order of a to-many relation array is unspecified in Turbine (\`json_agg\` order).
|
|
200
|
+
Add an explicit \`orderBy\` where order matters.
|
|
201
|
+
- Connection URL. Prefer an explicit \`sslmode\` in the connection URL (or the
|
|
202
|
+
future-proof \`uselibpqcompat\` form) to avoid a per-boot pg SSL security
|
|
203
|
+
warning.`;
|
|
204
|
+
/** A one-line-per-model console summary for the CLI. */
|
|
205
|
+
export function summaryLines(result) {
|
|
206
|
+
return result.models.map((m) => {
|
|
207
|
+
const status = modelDisplayStatus(m);
|
|
208
|
+
const target = m.accessor ? `${m.accessor} (${m.table})` : '-';
|
|
209
|
+
return `${m.prismaName} -> ${target} [${status}]`;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a parsed Prisma schema against live introspected Turbine metadata.
|
|
3
|
+
*
|
|
4
|
+
* Consumes a {@link PrismaSchemaAst} (from `cli/prisma-schema.ts`) plus a
|
|
5
|
+
* {@link SchemaMetadata} (from `introspect()`), and produces a
|
|
6
|
+
* {@link ResolutionResult}: a per-model/field/relation/compound-unique
|
|
7
|
+
* resolution report AND the typed {@link PrismaCompatMap} that only ever
|
|
8
|
+
* contains VERIFIED mappings. Anything that cannot be matched against the live
|
|
9
|
+
* database is reported UNRESOLVED with a reason and left out of the map - the
|
|
10
|
+
* database is the authority, and an ambiguous guess is worse than an honest gap.
|
|
11
|
+
*
|
|
12
|
+
* Pure leaf: no filesystem, database, or process access. Passing `schema: null`
|
|
13
|
+
* yields a parse-only report (every item `parsed`, no map) for `--no-db`.
|
|
14
|
+
*/
|
|
15
|
+
import { type PrismaCompatMap, type SchemaMetadata } from '../schema.js';
|
|
16
|
+
import type { PrismaSchemaAst } from './prisma-schema.js';
|
|
17
|
+
export { DEFAULT_EXCLUDED_TABLES } from '../introspect.js';
|
|
18
|
+
export type ResolveStatus = 'resolved' | 'unresolved' | 'parsed';
|
|
19
|
+
export interface ResolvedField {
|
|
20
|
+
prismaName: string;
|
|
21
|
+
/** Resolved Turbine field (camelCase), or null. */
|
|
22
|
+
turbineField: string | null;
|
|
23
|
+
/** Resolved snake_case database column, or null. */
|
|
24
|
+
column: string | null;
|
|
25
|
+
status: ResolveStatus;
|
|
26
|
+
reason?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ResolvedRelation {
|
|
29
|
+
prismaName: string;
|
|
30
|
+
/** Turbine relation name, or null. */
|
|
31
|
+
turbineName: string | null;
|
|
32
|
+
cardinality: 'one' | 'many' | null;
|
|
33
|
+
/** Target Prisma model this relation points at. */
|
|
34
|
+
targetModel: string;
|
|
35
|
+
/** Junction table for an m2m relation, if resolved. */
|
|
36
|
+
junction?: string;
|
|
37
|
+
status: ResolveStatus;
|
|
38
|
+
reason?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface ResolvedCompoundUnique {
|
|
41
|
+
/** Prisma selector name (explicit `name:`, else the field-name underscore-join). */
|
|
42
|
+
selector: string;
|
|
43
|
+
/** Prisma field names participating. */
|
|
44
|
+
prismaFields: string[];
|
|
45
|
+
/** Resolved Turbine field names (in order), or null. */
|
|
46
|
+
turbineFields: string[] | null;
|
|
47
|
+
kind: 'id' | 'unique';
|
|
48
|
+
status: ResolveStatus;
|
|
49
|
+
reason?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface ResolvedModel {
|
|
52
|
+
prismaName: string;
|
|
53
|
+
kind: 'model' | 'view' | 'type';
|
|
54
|
+
/** Resolved snake_case table name, or null. */
|
|
55
|
+
table: string | null;
|
|
56
|
+
/** camelCase client accessor, or null. */
|
|
57
|
+
accessor: string | null;
|
|
58
|
+
/** True when the table name came from an explicit `@@map`. */
|
|
59
|
+
viaMap: boolean;
|
|
60
|
+
status: ResolveStatus;
|
|
61
|
+
reason?: string;
|
|
62
|
+
fields: ResolvedField[];
|
|
63
|
+
relations: ResolvedRelation[];
|
|
64
|
+
compoundUniques: ResolvedCompoundUnique[];
|
|
65
|
+
}
|
|
66
|
+
export interface ResolvedEnum {
|
|
67
|
+
prismaName: string;
|
|
68
|
+
turbineName: string | null;
|
|
69
|
+
status: ResolveStatus;
|
|
70
|
+
reason?: string;
|
|
71
|
+
}
|
|
72
|
+
export interface ResolutionResult {
|
|
73
|
+
models: ResolvedModel[];
|
|
74
|
+
enums: ResolvedEnum[];
|
|
75
|
+
/** The verified name map. Empty `models`/`enums` in `--no-db` mode. */
|
|
76
|
+
map: PrismaCompatMap;
|
|
77
|
+
/** True if any model/field/relation/compound-unique/enum is UNRESOLVED. */
|
|
78
|
+
hasUnresolved: boolean;
|
|
79
|
+
/** Non-fatal parser notes (skipped blocks/attributes). */
|
|
80
|
+
parseWarnings: string[];
|
|
81
|
+
/** True when resolution was skipped (`--no-db`): the report is parse-only. */
|
|
82
|
+
noDb: boolean;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve `ast` against introspected `schema` (or `null` for parse-only).
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolvePrismaSchema(ast: PrismaSchemaAst, schema: SchemaMetadata | null): ResolutionResult;
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a parsed Prisma schema against live introspected Turbine metadata.
|
|
3
|
+
*
|
|
4
|
+
* Consumes a {@link PrismaSchemaAst} (from `cli/prisma-schema.ts`) plus a
|
|
5
|
+
* {@link SchemaMetadata} (from `introspect()`), and produces a
|
|
6
|
+
* {@link ResolutionResult}: a per-model/field/relation/compound-unique
|
|
7
|
+
* resolution report AND the typed {@link PrismaCompatMap} that only ever
|
|
8
|
+
* contains VERIFIED mappings. Anything that cannot be matched against the live
|
|
9
|
+
* database is reported UNRESOLVED with a reason and left out of the map - the
|
|
10
|
+
* database is the authority, and an ambiguous guess is worse than an honest gap.
|
|
11
|
+
*
|
|
12
|
+
* Pure leaf: no filesystem, database, or process access. Passing `schema: null`
|
|
13
|
+
* yields a parse-only report (every item `parsed`, no map) for `--no-db`.
|
|
14
|
+
*/
|
|
15
|
+
import { camelToSnake, snakeToCamel } from '../schema.js';
|
|
16
|
+
export { DEFAULT_EXCLUDED_TABLES } from '../introspect.js';
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Name-candidate helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/** PascalCase model → snake_case: lower the first letter, then camelToSnake. */
|
|
21
|
+
function pascalToSnake(name) {
|
|
22
|
+
if (name === '')
|
|
23
|
+
return name;
|
|
24
|
+
return camelToSnake(name[0].toLowerCase() + name.slice(1));
|
|
25
|
+
}
|
|
26
|
+
/** Naive pluralize for the snake candidate: mirrors `singularize` in schema.ts. */
|
|
27
|
+
function pluralize(s) {
|
|
28
|
+
if (/[^aeiou]y$/.test(s))
|
|
29
|
+
return `${s.slice(0, -1)}ies`;
|
|
30
|
+
if (/(s|x|z|ch|sh)$/.test(s))
|
|
31
|
+
return `${s}es`;
|
|
32
|
+
return `${s}s`;
|
|
33
|
+
}
|
|
34
|
+
function singularize(s) {
|
|
35
|
+
if (s.endsWith('ies'))
|
|
36
|
+
return `${s.slice(0, -3)}y`;
|
|
37
|
+
if (s.endsWith('ses') || s.endsWith('xes') || s.endsWith('zes'))
|
|
38
|
+
return s.slice(0, -2);
|
|
39
|
+
if (s.endsWith('s') && !s.endsWith('ss'))
|
|
40
|
+
return s.slice(0, -1);
|
|
41
|
+
return s;
|
|
42
|
+
}
|
|
43
|
+
/** Ordered, de-duplicated table-name candidates for a model without `@@map`. */
|
|
44
|
+
function tableCandidates(modelName) {
|
|
45
|
+
const snake = pascalToSnake(modelName);
|
|
46
|
+
const raw = modelName;
|
|
47
|
+
const set = new Set([
|
|
48
|
+
raw,
|
|
49
|
+
raw.toLowerCase(),
|
|
50
|
+
snake,
|
|
51
|
+
pluralize(snake),
|
|
52
|
+
singularize(snake),
|
|
53
|
+
pluralize(raw.toLowerCase()),
|
|
54
|
+
]);
|
|
55
|
+
return [...set].filter((s) => s !== '');
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Field/column resolution
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
/** The database column a Prisma field maps to (`@map` wins, else the field name). */
|
|
61
|
+
function fieldColumn(model, fieldName) {
|
|
62
|
+
const f = model.fields.find((x) => x.name === fieldName);
|
|
63
|
+
if (!f)
|
|
64
|
+
return fieldName;
|
|
65
|
+
const mapAttr = f.attrs.find((a) => a.name === 'map');
|
|
66
|
+
const arg = mapAttr?.args.find((a) => a.key === undefined);
|
|
67
|
+
return arg?.kind === 'string' && arg.value ? arg.value : f.name;
|
|
68
|
+
}
|
|
69
|
+
/** True when a field's type names a parsed model (so it is a relation/object field). */
|
|
70
|
+
function isRelationField(typeName, modelNames) {
|
|
71
|
+
return modelNames.has(typeName);
|
|
72
|
+
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Main entry
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
/**
|
|
77
|
+
* Resolve `ast` against introspected `schema` (or `null` for parse-only).
|
|
78
|
+
*/
|
|
79
|
+
export function resolvePrismaSchema(ast, schema) {
|
|
80
|
+
const noDb = schema === null;
|
|
81
|
+
const modelNames = new Set(ast.models.map((m) => m.name));
|
|
82
|
+
const tableNames = schema ? new Set(Object.keys(schema.tables)) : new Set();
|
|
83
|
+
// Pass 1 - resolve each model to a table so relation targets are known.
|
|
84
|
+
const modelTable = new Map();
|
|
85
|
+
for (const model of ast.models) {
|
|
86
|
+
modelTable.set(model.name, resolveTable(model, tableNames, noDb));
|
|
87
|
+
}
|
|
88
|
+
const result = {
|
|
89
|
+
models: [],
|
|
90
|
+
enums: [],
|
|
91
|
+
map: { models: {}, enums: {} },
|
|
92
|
+
hasUnresolved: false,
|
|
93
|
+
parseWarnings: ast.warnings,
|
|
94
|
+
noDb,
|
|
95
|
+
};
|
|
96
|
+
// Pass 2 - resolve fields, relations, and compound uniques.
|
|
97
|
+
for (const model of ast.models) {
|
|
98
|
+
const rt = modelTable.get(model.name);
|
|
99
|
+
const table = rt.table;
|
|
100
|
+
const tableMeta = table && schema ? schema.tables[table] : undefined;
|
|
101
|
+
const accessor = table ? snakeToCamel(table) : null;
|
|
102
|
+
const status = noDb ? 'parsed' : table ? 'resolved' : 'unresolved';
|
|
103
|
+
const resolved = {
|
|
104
|
+
prismaName: model.name,
|
|
105
|
+
kind: model.kind,
|
|
106
|
+
table,
|
|
107
|
+
accessor,
|
|
108
|
+
viaMap: rt.viaMap,
|
|
109
|
+
status,
|
|
110
|
+
reason: rt.reason,
|
|
111
|
+
fields: [],
|
|
112
|
+
relations: [],
|
|
113
|
+
compoundUniques: [],
|
|
114
|
+
};
|
|
115
|
+
for (const field of model.fields) {
|
|
116
|
+
if (isRelationField(field.type, modelNames)) {
|
|
117
|
+
resolved.relations.push(resolveRelation(model, field.name, field.type, field.isList, modelTable, schema, tableMeta, noDb));
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
resolved.fields.push(resolveScalarField(model, field.name, tableMeta, noDb));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const key of model.compoundKeys) {
|
|
124
|
+
// Prisma only synthesizes a compound selector object for multi-field keys
|
|
125
|
+
// or an explicitly named one; single-field keys are addressed flat.
|
|
126
|
+
if (key.fields.length < 2 && !key.name)
|
|
127
|
+
continue;
|
|
128
|
+
resolved.compoundUniques.push(resolveCompoundUnique(model, key, tableMeta, noDb));
|
|
129
|
+
}
|
|
130
|
+
result.models.push(resolved);
|
|
131
|
+
// Build the verified map entry (skip in --no-db and for unresolved tables).
|
|
132
|
+
if (!noDb && table && accessor) {
|
|
133
|
+
const fields = {};
|
|
134
|
+
for (const f of resolved.fields) {
|
|
135
|
+
if (f.status === 'resolved' && f.turbineField)
|
|
136
|
+
fields[f.prismaName] = f.turbineField;
|
|
137
|
+
}
|
|
138
|
+
const relations = {};
|
|
139
|
+
for (const r of resolved.relations) {
|
|
140
|
+
if (r.status === 'resolved' && r.turbineName && r.cardinality) {
|
|
141
|
+
relations[r.prismaName] = { name: r.turbineName, cardinality: r.cardinality };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const compoundUniques = {};
|
|
145
|
+
for (const c of resolved.compoundUniques) {
|
|
146
|
+
if (c.status === 'resolved' && c.turbineFields)
|
|
147
|
+
compoundUniques[c.selector] = c.turbineFields;
|
|
148
|
+
}
|
|
149
|
+
result.map.models[model.name] = { table, accessor, fields, relations, compoundUniques };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Enums.
|
|
153
|
+
for (const en of ast.enums) {
|
|
154
|
+
const r = resolveEnum(en.name, en.map, schema, noDb);
|
|
155
|
+
result.enums.push(r);
|
|
156
|
+
if (!noDb && r.status === 'resolved' && r.turbineName)
|
|
157
|
+
result.map.enums[en.name] = r.turbineName;
|
|
158
|
+
}
|
|
159
|
+
result.hasUnresolved = computeHasUnresolved(result);
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Per-construct resolvers
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
function resolveTable(model, tableNames, noDb) {
|
|
166
|
+
if (model.kind === 'type') {
|
|
167
|
+
return { table: null, viaMap: false, reason: 'composite/embedded type - not a table' };
|
|
168
|
+
}
|
|
169
|
+
if (noDb)
|
|
170
|
+
return { table: null, viaMap: !!model.map };
|
|
171
|
+
if (model.map) {
|
|
172
|
+
if (tableNames.has(model.map))
|
|
173
|
+
return { table: model.map, viaMap: true };
|
|
174
|
+
return { table: null, viaMap: true, reason: `@@map("${model.map}") target table not found in the database` };
|
|
175
|
+
}
|
|
176
|
+
const candidates = tableCandidates(model.name);
|
|
177
|
+
const matches = candidates.filter((c) => tableNames.has(c));
|
|
178
|
+
const distinct = [...new Set(matches)];
|
|
179
|
+
if (distinct.length === 1)
|
|
180
|
+
return { table: distinct[0], viaMap: false };
|
|
181
|
+
if (distinct.length > 1) {
|
|
182
|
+
return {
|
|
183
|
+
table: null,
|
|
184
|
+
viaMap: false,
|
|
185
|
+
reason: `ambiguous - multiple tables match (${distinct.join(', ')}); add @@map("<table>") to disambiguate`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
table: null,
|
|
190
|
+
viaMap: false,
|
|
191
|
+
reason: `no table matched (tried ${candidates.join(', ')}); add @@map("<table>")`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function resolveScalarField(model, fieldName, tableMeta, noDb) {
|
|
195
|
+
const column = fieldColumn(model, fieldName);
|
|
196
|
+
if (noDb || !tableMeta) {
|
|
197
|
+
return { prismaName: fieldName, turbineField: null, column: null, status: noDb ? 'parsed' : 'unresolved' };
|
|
198
|
+
}
|
|
199
|
+
const turbineField = tableMeta.reverseColumnMap[column];
|
|
200
|
+
if (turbineField) {
|
|
201
|
+
return { prismaName: fieldName, turbineField, column, status: 'resolved' };
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
prismaName: fieldName,
|
|
205
|
+
turbineField: null,
|
|
206
|
+
column: null,
|
|
207
|
+
status: 'unresolved',
|
|
208
|
+
reason: `column "${column}" not found on table "${tableMeta.name}"`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function resolveRelation(model, fieldName, targetModelName, isList, modelTable, schema, tableMeta, noDb) {
|
|
212
|
+
const cardinality = isList ? 'many' : 'one';
|
|
213
|
+
const base = {
|
|
214
|
+
prismaName: fieldName,
|
|
215
|
+
turbineName: null,
|
|
216
|
+
cardinality: noDb ? null : cardinality,
|
|
217
|
+
targetModel: targetModelName,
|
|
218
|
+
status: noDb ? 'parsed' : 'unresolved',
|
|
219
|
+
};
|
|
220
|
+
if (noDb)
|
|
221
|
+
return base;
|
|
222
|
+
if (!schema || !tableMeta) {
|
|
223
|
+
return { ...base, reason: 'model table unresolved' };
|
|
224
|
+
}
|
|
225
|
+
const targetTable = modelTable.get(targetModelName)?.table ?? null;
|
|
226
|
+
// Explicit @relation(fields: [...]) names the FK columns on THIS side.
|
|
227
|
+
const field = model.fields.find((f) => f.name === fieldName);
|
|
228
|
+
const relAttr = field?.attrs.find((a) => a.name === 'relation');
|
|
229
|
+
const fieldsArg = relAttr?.args.find((a) => a.key === 'fields' && a.kind === 'array');
|
|
230
|
+
const fkColumns = fieldsArg?.items?.map((pf) => fieldColumn(model, pf)) ?? null;
|
|
231
|
+
const candidates = Object.values(tableMeta.relations).filter((def) => {
|
|
232
|
+
if (targetTable && def.to !== targetTable)
|
|
233
|
+
return false;
|
|
234
|
+
if (cardinality === 'many')
|
|
235
|
+
return def.type === 'hasMany' || def.type === 'manyToMany';
|
|
236
|
+
return def.type === 'belongsTo' || def.type === 'hasOne';
|
|
237
|
+
});
|
|
238
|
+
let picked = candidates;
|
|
239
|
+
if (fkColumns && fkColumns.length > 0) {
|
|
240
|
+
const want = [...fkColumns].sort().join(',');
|
|
241
|
+
const byFk = candidates.filter((def) => {
|
|
242
|
+
const fk = Array.isArray(def.foreignKey) ? def.foreignKey : [def.foreignKey];
|
|
243
|
+
return [...fk].sort().join(',') === want;
|
|
244
|
+
});
|
|
245
|
+
if (byFk.length > 0)
|
|
246
|
+
picked = byFk;
|
|
247
|
+
}
|
|
248
|
+
if (picked.length === 1) {
|
|
249
|
+
const def = picked[0];
|
|
250
|
+
return {
|
|
251
|
+
...base,
|
|
252
|
+
turbineName: def.name,
|
|
253
|
+
cardinality,
|
|
254
|
+
junction: def.type === 'manyToMany' ? def.through?.table : undefined,
|
|
255
|
+
status: 'resolved',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (picked.length > 1) {
|
|
259
|
+
return {
|
|
260
|
+
...base,
|
|
261
|
+
reason: `ambiguous - ${picked.length} candidate relations match (${picked.map((d) => d.name).join(', ')})`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const targetNote = targetTable ? `table "${targetTable}"` : `model "${targetModelName}" (table unresolved)`;
|
|
265
|
+
return { ...base, reason: `no ${cardinality} relation to ${targetNote} on table "${tableMeta.name}"` };
|
|
266
|
+
}
|
|
267
|
+
function resolveCompoundUnique(model, key, tableMeta, noDb) {
|
|
268
|
+
const selector = key.name ?? key.fields.join('_');
|
|
269
|
+
const base = {
|
|
270
|
+
selector,
|
|
271
|
+
prismaFields: key.fields,
|
|
272
|
+
turbineFields: null,
|
|
273
|
+
kind: key.kind,
|
|
274
|
+
status: noDb ? 'parsed' : 'unresolved',
|
|
275
|
+
};
|
|
276
|
+
if (noDb || !tableMeta)
|
|
277
|
+
return base;
|
|
278
|
+
const columns = key.fields.map((f) => fieldColumn(model, f));
|
|
279
|
+
const missing = columns.filter((c) => !tableMeta.reverseColumnMap[c]);
|
|
280
|
+
if (missing.length > 0) {
|
|
281
|
+
return { ...base, reason: `column(s) not found: ${missing.join(', ')}` };
|
|
282
|
+
}
|
|
283
|
+
const turbineFields = columns.map((c) => tableMeta.reverseColumnMap[c]);
|
|
284
|
+
const want = [...columns].sort().join(',');
|
|
285
|
+
const matches = (setList) => setList.some((s) => [...s].sort().join(',') === want);
|
|
286
|
+
if (key.kind === 'id') {
|
|
287
|
+
if (matches([tableMeta.primaryKey]))
|
|
288
|
+
return { ...base, turbineFields, status: 'resolved' };
|
|
289
|
+
return { ...base, reason: `no compound primary key on "${tableMeta.name}" matches (${columns.join(', ')})` };
|
|
290
|
+
}
|
|
291
|
+
// Introspected metadata carries composite unique constraints in uniqueColumns.
|
|
292
|
+
if (matches(tableMeta.uniqueColumns))
|
|
293
|
+
return { ...base, turbineFields, status: 'resolved' };
|
|
294
|
+
return { ...base, reason: `no unique constraint on "${tableMeta.name}" matches (${columns.join(', ')})` };
|
|
295
|
+
}
|
|
296
|
+
function resolveEnum(name, map, schema, noDb) {
|
|
297
|
+
if (noDb || !schema)
|
|
298
|
+
return { prismaName: name, turbineName: null, status: noDb ? 'parsed' : 'unresolved' };
|
|
299
|
+
// An explicit @@map names the database enum type outright.
|
|
300
|
+
const candidates = map ? [map] : [name, name.toLowerCase(), pascalToSnake(name)];
|
|
301
|
+
const matches = [...new Set(candidates.filter((c) => Object.hasOwn(schema.enums, c)))];
|
|
302
|
+
if (matches.length === 1)
|
|
303
|
+
return { prismaName: name, turbineName: matches[0], status: 'resolved' };
|
|
304
|
+
if (matches.length > 1) {
|
|
305
|
+
return {
|
|
306
|
+
prismaName: name,
|
|
307
|
+
turbineName: null,
|
|
308
|
+
status: 'unresolved',
|
|
309
|
+
reason: `ambiguous enum match (${matches.join(', ')})`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return { prismaName: name, turbineName: null, status: 'unresolved', reason: 'no matching database enum type' };
|
|
313
|
+
}
|
|
314
|
+
function computeHasUnresolved(result) {
|
|
315
|
+
if (result.noDb)
|
|
316
|
+
return false;
|
|
317
|
+
for (const m of result.models) {
|
|
318
|
+
if (m.status === 'unresolved')
|
|
319
|
+
return true;
|
|
320
|
+
if (m.fields.some((f) => f.status === 'unresolved'))
|
|
321
|
+
return true;
|
|
322
|
+
if (m.relations.some((r) => r.status === 'unresolved'))
|
|
323
|
+
return true;
|
|
324
|
+
if (m.compoundUniques.some((c) => c.status === 'unresolved'))
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
if (result.enums.some((e) => e.status === 'unresolved'))
|
|
328
|
+
return true;
|
|
329
|
+
return false;
|
|
330
|
+
}
|