@goboost/scanner-typescript 1.2.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/package.json +19 -0
- package/src/controller-extractor.ts +364 -0
- package/src/dto-extractor.ts +363 -0
- package/src/guard-extractor.ts +206 -0
- package/src/index.ts +284 -0
- package/src/prisma-schema-extractor.ts +345 -0
- package/src/unsupported-detector.ts +253 -0
- package/tsconfig.json +20 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scanner entrypoint — orchestrates all extractors and outputs normalized IR JSON.
|
|
3
|
+
*
|
|
4
|
+
* Produces DUAL IR per the goBoost Decoupled Universal Extractor architecture:
|
|
5
|
+
* - goboost.api.json (API IR v2.0.0) — controllers, DTOs, guards
|
|
6
|
+
* - goboost.db.json (DB IR v1.0.0) — tables, columns, relations, enums (from Prisma)
|
|
7
|
+
*
|
|
8
|
+
* Constitution v1.2.0 compliance:
|
|
9
|
+
* - Principle V: Prisma as single source of truth for DB schema
|
|
10
|
+
* - Principle VIII: The Framework Rosetta Stone — JSII-compliant IR
|
|
11
|
+
*/
|
|
12
|
+
import { Project } from 'ts-morph';
|
|
13
|
+
import { extractControllers } from './controller-extractor';
|
|
14
|
+
import { extractDTOs, collectReferencedDTOs, markSharedDTOs } from './dto-extractor';
|
|
15
|
+
import { extractPrismaSchema, DBRoot } from './prisma-schema-extractor';
|
|
16
|
+
import { extractGuards } from './guard-extractor';
|
|
17
|
+
import { detectUnsupported, canGenerate } from './unsupported-detector';
|
|
18
|
+
|
|
19
|
+
const API_IR_VERSION = '2.0.0';
|
|
20
|
+
|
|
21
|
+
interface ScanOptions {
|
|
22
|
+
projectPath: string;
|
|
23
|
+
tsConfigPath?: string;
|
|
24
|
+
prismaSchemaPath?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** API IR root (goboost.api.json v2.0.0) — matches internal/ir/api_types.go */
|
|
28
|
+
interface APIRoot {
|
|
29
|
+
version: string;
|
|
30
|
+
sourceFramework: string;
|
|
31
|
+
projectRoot: string;
|
|
32
|
+
scannedAt: string;
|
|
33
|
+
controllers: any[];
|
|
34
|
+
dtos: Record<string, any>;
|
|
35
|
+
guards: Record<string, any>;
|
|
36
|
+
meta?: { generatorVersion?: string; scanDuration?: string };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ScanResult {
|
|
40
|
+
apiIR: APIRoot;
|
|
41
|
+
dbIR: DBRoot;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function scan(options: ScanOptions): Promise<ScanResult> {
|
|
45
|
+
const { projectPath, tsConfigPath } = options;
|
|
46
|
+
const startTime = Date.now();
|
|
47
|
+
|
|
48
|
+
// Initialize ts-morph project for API extraction
|
|
49
|
+
const project = new Project({
|
|
50
|
+
tsConfigFilePath: tsConfigPath || `${projectPath}/tsconfig.json`,
|
|
51
|
+
skipAddingFilesFromTsConfig: false,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---- API IR extraction ----
|
|
55
|
+
|
|
56
|
+
// Extract controllers
|
|
57
|
+
const controllers = extractControllers(project);
|
|
58
|
+
|
|
59
|
+
// Detect circular service dependencies
|
|
60
|
+
const circularDeps = detectCircularDependencies(project, controllers);
|
|
61
|
+
if (circularDeps.length > 0) {
|
|
62
|
+
console.error('Warning: Circular service dependencies detected:');
|
|
63
|
+
for (const dep of circularDeps) {
|
|
64
|
+
console.error(` - ${dep}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Collect and extract DTOs
|
|
69
|
+
const referencedDTOs = collectReferencedDTOs(controllers);
|
|
70
|
+
const dtos = extractDTOs(project, referencedDTOs);
|
|
71
|
+
markSharedDTOs(dtos, controllers);
|
|
72
|
+
|
|
73
|
+
// Extract guards
|
|
74
|
+
const guards = extractGuards(project);
|
|
75
|
+
|
|
76
|
+
// Detect unsupported features per route
|
|
77
|
+
for (const controller of controllers) {
|
|
78
|
+
const controllerUnsupported: string[] = [];
|
|
79
|
+
|
|
80
|
+
const controllerCyclicDeps = circularDeps.filter(d => d.startsWith(controller.name));
|
|
81
|
+
for (const dep of controllerCyclicDeps) {
|
|
82
|
+
if (!controllerUnsupported.includes('circular-dependency')) {
|
|
83
|
+
controllerUnsupported.push('circular-dependency');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const route of controller.routes) {
|
|
88
|
+
const unsupported = detectUnsupported(project, {
|
|
89
|
+
handlerName: route.handlerName,
|
|
90
|
+
unsupportedFeatures: [],
|
|
91
|
+
});
|
|
92
|
+
route.unsupportedFeatures = unsupported;
|
|
93
|
+
route.canGenerate = canGenerate(unsupported);
|
|
94
|
+
|
|
95
|
+
for (const feature of unsupported) {
|
|
96
|
+
if (!controllerUnsupported.includes(feature)) {
|
|
97
|
+
controllerUnsupported.push(feature);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
controller.unsupportedFeatures = controllerUnsupported;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const scanDuration = `${Date.now() - startTime}ms`;
|
|
106
|
+
|
|
107
|
+
// Build API IR
|
|
108
|
+
const apiIR: APIRoot = {
|
|
109
|
+
version: API_IR_VERSION,
|
|
110
|
+
sourceFramework: 'nestjs',
|
|
111
|
+
projectRoot: projectPath,
|
|
112
|
+
scannedAt: new Date().toISOString(),
|
|
113
|
+
controllers,
|
|
114
|
+
dtos,
|
|
115
|
+
guards,
|
|
116
|
+
meta: {
|
|
117
|
+
generatorVersion: '1.1.0',
|
|
118
|
+
scanDuration,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Normalize API IR for determinism
|
|
123
|
+
normalizeAPIIR(apiIR);
|
|
124
|
+
|
|
125
|
+
// ---- DB IR extraction (Prisma DMMF) ----
|
|
126
|
+
|
|
127
|
+
const dbIR = await extractPrismaSchema({
|
|
128
|
+
projectPath,
|
|
129
|
+
schemaPath: options.prismaSchemaPath,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return { apiIR, dbIR };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Detect circular dependencies between services in controllers
|
|
137
|
+
*/
|
|
138
|
+
function detectCircularDependencies(project: Project, controllers: any[]): string[] {
|
|
139
|
+
const circularDeps: string[] = [];
|
|
140
|
+
const visited = new Set<string>();
|
|
141
|
+
const dependencyGraph = new Map<string, Set<string>>();
|
|
142
|
+
|
|
143
|
+
for (const controller of controllers) {
|
|
144
|
+
if (controller.dependencies && controller.dependencies.length > 0) {
|
|
145
|
+
const deps = new Set<string>();
|
|
146
|
+
for (const dep of controller.dependencies) {
|
|
147
|
+
const serviceName = extractServiceName(dep);
|
|
148
|
+
if (serviceName) {
|
|
149
|
+
deps.add(serviceName);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
dependencyGraph.set(controller.name, deps);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function dfs(node: string, path: string[]): boolean {
|
|
157
|
+
if (visited.has(node)) {
|
|
158
|
+
const cycleStart = path.indexOf(node);
|
|
159
|
+
if (cycleStart !== -1) {
|
|
160
|
+
const cycle = [...path.slice(cycleStart), node].join(' -> ');
|
|
161
|
+
circularDeps.push(`${node}: ${cycle}`);
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
visited.add(node);
|
|
167
|
+
path.push(node);
|
|
168
|
+
|
|
169
|
+
const deps = dependencyGraph.get(node);
|
|
170
|
+
if (deps) {
|
|
171
|
+
for (const dep of deps) {
|
|
172
|
+
if (path.includes(dep) || dfs(dep, [...path])) {
|
|
173
|
+
// Continue to find all cycles
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
path.pop();
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const controller of controllers) {
|
|
183
|
+
visited.clear();
|
|
184
|
+
dfs(controller.name, []);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return circularDeps;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function extractServiceName(typeText: string): string | null {
|
|
191
|
+
const match = typeText.match(/(\w+Service)/);
|
|
192
|
+
return match ? match[1] : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Normalize API IR for deterministic output
|
|
197
|
+
*/
|
|
198
|
+
function normalizeAPIIR(ir: APIRoot): void {
|
|
199
|
+
for (const controller of ir.controllers) {
|
|
200
|
+
controller.routes.sort((a: any, b: any) => {
|
|
201
|
+
const methodCompare = a.httpMethod.localeCompare(b.httpMethod);
|
|
202
|
+
if (methodCompare !== 0) return methodCompare;
|
|
203
|
+
return a.path.localeCompare(b.path);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
ir.dtos = sortObjectKeys(ir.dtos);
|
|
208
|
+
ir.guards = sortObjectKeys(ir.guards);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sortObjectKeys<T>(obj: Record<string, T>): Record<string, T> {
|
|
212
|
+
const sorted: Record<string, T> = {};
|
|
213
|
+
const keys = Object.keys(obj).sort();
|
|
214
|
+
for (const key of keys) {
|
|
215
|
+
sorted[key] = obj[key];
|
|
216
|
+
}
|
|
217
|
+
return sorted;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* CLI entrypoint — outputs dual IR JSON to stdout or files
|
|
222
|
+
*/
|
|
223
|
+
if (require.main === module) {
|
|
224
|
+
const args = process.argv.slice(2);
|
|
225
|
+
|
|
226
|
+
let projectPath = process.cwd();
|
|
227
|
+
let tsConfigPath: string | undefined;
|
|
228
|
+
let prismaSchemaPath: string | undefined;
|
|
229
|
+
let outputApiPath: string | undefined;
|
|
230
|
+
let outputDbPath: string | undefined;
|
|
231
|
+
|
|
232
|
+
for (let i = 0; i < args.length; i++) {
|
|
233
|
+
if (args[i] === '--project' || args[i] === '-p') {
|
|
234
|
+
projectPath = args[++i];
|
|
235
|
+
} else if (args[i] === '--tsconfig' || args[i] === '-t') {
|
|
236
|
+
tsConfigPath = args[++i];
|
|
237
|
+
} else if (args[i] === '--prisma-schema') {
|
|
238
|
+
prismaSchemaPath = args[++i];
|
|
239
|
+
} else if (args[i] === '--output-api') {
|
|
240
|
+
outputApiPath = args[++i];
|
|
241
|
+
} else if (args[i] === '--output-db') {
|
|
242
|
+
outputDbPath = args[++i];
|
|
243
|
+
} else if (!args[i].startsWith('-') && i === 0) {
|
|
244
|
+
projectPath = args[i];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
scan({ projectPath, tsConfigPath, prismaSchemaPath })
|
|
249
|
+
.then(({ apiIR, dbIR }) => {
|
|
250
|
+
const apiJson = JSON.stringify(apiIR, null, 2);
|
|
251
|
+
const dbJson = JSON.stringify(dbIR, null, 2);
|
|
252
|
+
|
|
253
|
+
if (outputApiPath) {
|
|
254
|
+
const fs = require('fs');
|
|
255
|
+
const path = require('path');
|
|
256
|
+
fs.mkdirSync(path.dirname(outputApiPath), { recursive: true });
|
|
257
|
+
fs.writeFileSync(outputApiPath, apiJson, 'utf-8');
|
|
258
|
+
console.error(`API IR written to: ${outputApiPath}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (outputDbPath) {
|
|
262
|
+
const fs = require('fs');
|
|
263
|
+
const path = require('path');
|
|
264
|
+
fs.mkdirSync(path.dirname(outputDbPath), { recursive: true });
|
|
265
|
+
fs.writeFileSync(outputDbPath, dbJson, 'utf-8');
|
|
266
|
+
console.error(`DB IR written to: ${outputDbPath}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// If no output paths specified, write both to stdout as a combined object
|
|
270
|
+
if (!outputApiPath && !outputDbPath) {
|
|
271
|
+
console.log(JSON.stringify({ api: apiIR, db: dbIR }, null, 2));
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
.catch((err) => {
|
|
275
|
+
console.error(JSON.stringify({
|
|
276
|
+
error: true,
|
|
277
|
+
message: err.message,
|
|
278
|
+
stack: err.stack,
|
|
279
|
+
}));
|
|
280
|
+
process.exit(1);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export { scan as scanProject };
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma Schema Extractor — parses schema.prisma via @prisma/internals getDMMF()
|
|
3
|
+
* and produces goboost.db.json (DB IR v1.0.0) matching internal/ir/db_types.go.
|
|
4
|
+
*
|
|
5
|
+
* Constitution v1.2.0 Principle V: Single Shared Database — schema migrations
|
|
6
|
+
* managed from Prisma as the single source of truth. Performance Boosters use Gorm models
|
|
7
|
+
* generated from the canonical schema.
|
|
8
|
+
*/
|
|
9
|
+
import { getDMMF } from '@prisma/internals';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
|
|
13
|
+
// ---- DB IR types (mirrors internal/ir/db_types.go) ----
|
|
14
|
+
|
|
15
|
+
export interface DBRoot {
|
|
16
|
+
version: string;
|
|
17
|
+
source: string;
|
|
18
|
+
databaseType: string;
|
|
19
|
+
schemaName: string;
|
|
20
|
+
extractedAt: string;
|
|
21
|
+
tables: TableSpec[];
|
|
22
|
+
enums: EnumSpec[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TableSpec {
|
|
26
|
+
name: string;
|
|
27
|
+
schema: string;
|
|
28
|
+
columns: ColumnSpec[];
|
|
29
|
+
relations: RelationSpec[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ColumnSpec {
|
|
33
|
+
name: string;
|
|
34
|
+
goType: string;
|
|
35
|
+
dbType: string;
|
|
36
|
+
isPrimary: boolean;
|
|
37
|
+
isAutoIncrement: boolean;
|
|
38
|
+
isNullable: boolean;
|
|
39
|
+
isUnique: boolean;
|
|
40
|
+
defaultValue?: string;
|
|
41
|
+
gormTag?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RelationSpec {
|
|
45
|
+
name: string;
|
|
46
|
+
type: string;
|
|
47
|
+
fromTable: string;
|
|
48
|
+
toTable: string;
|
|
49
|
+
fromColumns?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface EnumSpec {
|
|
53
|
+
name: string;
|
|
54
|
+
values: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- Prisma type → Go type mapping ----
|
|
58
|
+
|
|
59
|
+
const PRISMA_TO_GO_TYPE: Record<string, string> = {
|
|
60
|
+
'String': 'string',
|
|
61
|
+
'Int': 'int64',
|
|
62
|
+
'BigInt': 'int64',
|
|
63
|
+
'Float': 'float64',
|
|
64
|
+
'Decimal': 'float64',
|
|
65
|
+
'Boolean': 'bool',
|
|
66
|
+
'DateTime': 'time.Time',
|
|
67
|
+
'Json': 'datatypes.JSON',
|
|
68
|
+
'Bytes': '[]byte',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const PRISMA_TO_DB_TYPE: Record<string, string> = {
|
|
72
|
+
'String': 'VARCHAR(255)',
|
|
73
|
+
'Int': 'INTEGER',
|
|
74
|
+
'BigInt': 'BIGINT',
|
|
75
|
+
'Float': 'DOUBLE PRECISION',
|
|
76
|
+
'Decimal': 'DECIMAL',
|
|
77
|
+
'Boolean': 'BOOLEAN',
|
|
78
|
+
'DateTime': 'TIMESTAMP',
|
|
79
|
+
'Json': 'JSONB',
|
|
80
|
+
'Bytes': 'BYTEA',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ---- Relation type mapping ----
|
|
84
|
+
|
|
85
|
+
function mapRelationType(kind: string, isList: boolean): string {
|
|
86
|
+
if (isList) {
|
|
87
|
+
return 'one-to-many';
|
|
88
|
+
}
|
|
89
|
+
// A non-list relation field on the side with the FK is many-to-one
|
|
90
|
+
return 'many-to-one';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Main extraction function ----
|
|
94
|
+
|
|
95
|
+
export interface ExtractOptions {
|
|
96
|
+
projectPath: string;
|
|
97
|
+
schemaPath?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Detect the database provider from the schema.prisma datasource block.
|
|
102
|
+
*/
|
|
103
|
+
function detectDatabaseType(schemaContent: string): string {
|
|
104
|
+
const providerMatch = schemaContent.match(
|
|
105
|
+
/datasource\s+\w+\s*\{[^}]*provider\s*=\s*"(\w+)"/s
|
|
106
|
+
);
|
|
107
|
+
if (providerMatch) {
|
|
108
|
+
const provider = providerMatch[1].toLowerCase();
|
|
109
|
+
switch (provider) {
|
|
110
|
+
case 'postgresql': return 'postgresql';
|
|
111
|
+
case 'mysql': return 'mysql';
|
|
112
|
+
case 'sqlite': return 'sqlite';
|
|
113
|
+
case 'sqlserver': return 'sqlserver';
|
|
114
|
+
case 'mongodb': return 'mongodb';
|
|
115
|
+
case 'cockroachdb': return 'cockroachdb';
|
|
116
|
+
default: return provider;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return 'postgresql'; // Default per Constitution (Principle V)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Extract DB IR from a Prisma schema file using @prisma/internals getDMMF().
|
|
124
|
+
* Output format matches internal/ir/db_types.go (DBRoot).
|
|
125
|
+
*/
|
|
126
|
+
export async function extractPrismaSchema(options: ExtractOptions): Promise<DBRoot> {
|
|
127
|
+
const { projectPath } = options;
|
|
128
|
+
|
|
129
|
+
// 1. Locate schema.prisma
|
|
130
|
+
const schemaPath = options.schemaPath || findPrismaSchema(projectPath);
|
|
131
|
+
if (!schemaPath) {
|
|
132
|
+
// No schema.prisma found — return empty DB IR (per contract: source="none")
|
|
133
|
+
return {
|
|
134
|
+
version: '1.0.0',
|
|
135
|
+
source: 'none',
|
|
136
|
+
databaseType: 'postgresql',
|
|
137
|
+
schemaName: 'public',
|
|
138
|
+
extractedAt: new Date().toISOString(),
|
|
139
|
+
tables: [],
|
|
140
|
+
enums: [],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 2. Read schema content
|
|
145
|
+
const schemaContent = fs.readFileSync(schemaPath, 'utf-8');
|
|
146
|
+
const databaseType = detectDatabaseType(schemaContent);
|
|
147
|
+
|
|
148
|
+
// 3. Parse via getDMMF (offline — no database connection required)
|
|
149
|
+
const dmmf = await getDMMF({ datamodel: schemaContent });
|
|
150
|
+
|
|
151
|
+
// 4. Map DMMF models → TableSpec[]
|
|
152
|
+
const tables: TableSpec[] = dmmf.datamodel.models.map((model) => {
|
|
153
|
+
const columns: ColumnSpec[] = [];
|
|
154
|
+
const relations: RelationSpec[] = [];
|
|
155
|
+
|
|
156
|
+
for (const field of model.fields) {
|
|
157
|
+
if (field.kind === 'scalar' || field.kind === 'enum') {
|
|
158
|
+
columns.push(mapFieldToColumn(field, model));
|
|
159
|
+
} else if (field.kind === 'object') {
|
|
160
|
+
relations.push(mapFieldToRelation(field, model));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
name: model.name,
|
|
166
|
+
schema: 'public',
|
|
167
|
+
columns,
|
|
168
|
+
relations: relations.length > 0 ? relations : [],
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// 5. Map DMMF enums → EnumSpec[]
|
|
173
|
+
const enums: EnumSpec[] = dmmf.datamodel.enums.map((e) => ({
|
|
174
|
+
name: e.name,
|
|
175
|
+
values: e.values.map((v) => v.name),
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
// 6. Sort for determinism
|
|
179
|
+
tables.sort((a, b) => a.name.localeCompare(b.name));
|
|
180
|
+
for (const table of tables) {
|
|
181
|
+
table.columns.sort((a, b) => {
|
|
182
|
+
// Primary keys first, then alphabetical
|
|
183
|
+
if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1;
|
|
184
|
+
return a.name.localeCompare(b.name);
|
|
185
|
+
});
|
|
186
|
+
table.relations.sort((a, b) => a.name.localeCompare(b.name));
|
|
187
|
+
}
|
|
188
|
+
enums.sort((a, b) => a.name.localeCompare(b.name));
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
version: '1.0.0',
|
|
192
|
+
source: 'prisma',
|
|
193
|
+
databaseType,
|
|
194
|
+
schemaName: 'public',
|
|
195
|
+
extractedAt: new Date().toISOString(),
|
|
196
|
+
tables,
|
|
197
|
+
enums,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- Field mappers ----
|
|
202
|
+
|
|
203
|
+
function mapFieldToColumn(field: any, model: any): ColumnSpec {
|
|
204
|
+
const isPrimary = field.isId || false;
|
|
205
|
+
const isAutoIncrement = hasDefaultAutoincrement(field);
|
|
206
|
+
const goType = field.kind === 'enum'
|
|
207
|
+
? 'string'
|
|
208
|
+
: (PRISMA_TO_GO_TYPE[field.type] || 'string');
|
|
209
|
+
const dbType = field.kind === 'enum'
|
|
210
|
+
? `VARCHAR(255)`
|
|
211
|
+
: (PRISMA_TO_DB_TYPE[field.type] || 'VARCHAR(255)');
|
|
212
|
+
|
|
213
|
+
// Apply @db.* native type overrides if present
|
|
214
|
+
const nativeType = field.nativeType;
|
|
215
|
+
let resolvedDbType = dbType;
|
|
216
|
+
if (nativeType) {
|
|
217
|
+
resolvedDbType = formatNativeType(nativeType);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const col: ColumnSpec = {
|
|
221
|
+
name: field.name,
|
|
222
|
+
goType,
|
|
223
|
+
dbType: resolvedDbType,
|
|
224
|
+
isPrimary,
|
|
225
|
+
isAutoIncrement,
|
|
226
|
+
isNullable: !field.isRequired,
|
|
227
|
+
isUnique: field.isUnique || false,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Default value
|
|
231
|
+
const defaultVal = extractDefaultValue(field);
|
|
232
|
+
if (defaultVal) {
|
|
233
|
+
col.defaultValue = defaultVal;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Build GORM tag
|
|
237
|
+
const gormTag = buildGormTag(col, field);
|
|
238
|
+
if (gormTag) {
|
|
239
|
+
col.gormTag = gormTag;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return col;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function mapFieldToRelation(field: any, model: any): RelationSpec {
|
|
246
|
+
const isList = field.isList;
|
|
247
|
+
const relationType = mapRelationType(field.relationName || '', isList);
|
|
248
|
+
|
|
249
|
+
const rel: RelationSpec = {
|
|
250
|
+
name: field.name,
|
|
251
|
+
type: relationType,
|
|
252
|
+
fromTable: model.name,
|
|
253
|
+
toTable: field.type,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Determine FK columns from the relation's fromFields
|
|
257
|
+
if (field.relationFromFields && field.relationFromFields.length > 0) {
|
|
258
|
+
rel.fromColumns = field.relationFromFields;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return rel;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- Helpers ----
|
|
265
|
+
|
|
266
|
+
function hasDefaultAutoincrement(field: any): boolean {
|
|
267
|
+
if (!field.default) return false;
|
|
268
|
+
if (typeof field.default === 'object' && field.default.name === 'autoincrement') {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function extractDefaultValue(field: any): string | undefined {
|
|
275
|
+
if (!field.default) return undefined;
|
|
276
|
+
|
|
277
|
+
if (typeof field.default === 'object') {
|
|
278
|
+
// Prisma function defaults: autoincrement(), now(), uuid(), cuid(), etc.
|
|
279
|
+
const name = field.default.name;
|
|
280
|
+
if (name === 'autoincrement') return undefined; // Handled by isAutoIncrement
|
|
281
|
+
if (name === 'now') return 'CURRENT_TIMESTAMP';
|
|
282
|
+
if (name === 'uuid') return 'gen_random_uuid()';
|
|
283
|
+
if (name === 'cuid') return 'gen_random_uuid()'; // Approximate
|
|
284
|
+
if (name === 'dbgenerated') {
|
|
285
|
+
const args = field.default.args;
|
|
286
|
+
return args && args.length > 0 ? String(args[0]) : undefined;
|
|
287
|
+
}
|
|
288
|
+
return name + '()';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Scalar defaults
|
|
292
|
+
return String(field.default);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function formatNativeType(nativeType: any): string {
|
|
296
|
+
if (!nativeType) return 'VARCHAR(255)';
|
|
297
|
+
// nativeType is [typeName, args[]]
|
|
298
|
+
const [typeName, args] = nativeType;
|
|
299
|
+
if (args && args.length > 0) {
|
|
300
|
+
return `${typeName.toUpperCase()}(${args.join(', ')})`;
|
|
301
|
+
}
|
|
302
|
+
return typeName.toUpperCase();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function buildGormTag(col: ColumnSpec, field: any): string {
|
|
306
|
+
const parts: string[] = [];
|
|
307
|
+
|
|
308
|
+
parts.push(`column:${col.name}`);
|
|
309
|
+
parts.push(`type:${col.dbType}`);
|
|
310
|
+
|
|
311
|
+
if (col.isPrimary) {
|
|
312
|
+
parts.push('primaryKey');
|
|
313
|
+
}
|
|
314
|
+
if (col.isAutoIncrement) {
|
|
315
|
+
parts.push('autoIncrement');
|
|
316
|
+
}
|
|
317
|
+
if (!col.isNullable) {
|
|
318
|
+
parts.push('not null');
|
|
319
|
+
}
|
|
320
|
+
if (col.isUnique) {
|
|
321
|
+
parts.push('uniqueIndex');
|
|
322
|
+
}
|
|
323
|
+
if (col.defaultValue) {
|
|
324
|
+
parts.push(`default:${col.defaultValue}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return parts.join(';');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function findPrismaSchema(projectPath: string): string | null {
|
|
331
|
+
// Standard Prisma schema locations
|
|
332
|
+
const candidates = [
|
|
333
|
+
path.join(projectPath, 'prisma', 'schema.prisma'),
|
|
334
|
+
path.join(projectPath, 'schema.prisma'),
|
|
335
|
+
path.join(projectPath, 'prisma', 'schema'),
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
for (const candidate of candidates) {
|
|
339
|
+
if (fs.existsSync(candidate)) {
|
|
340
|
+
return candidate;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return null;
|
|
345
|
+
}
|