Skip to content

Commit 60c22e5

Browse files
talionwarclaude
andcommitted
fix(schema): brace-balanced parsing + two-pass enum detection
- Replace [^}]+ regex with extractBlocks() for brace-balanced parsing (handles @default({}), nested blocks correctly) - Two-pass parsing: collect ALL enum names first, then parse models (fixes phantom relations from forward-referenced enums) - Extract parseModelBlock() as dedicated function with enumNames param - Move PRISMA_SCALAR_TYPES to module-level constant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a86729c commit 60c22e5

1 file changed

Lines changed: 130 additions & 106 deletions

File tree

src/schema/prisma-parser.ts

Lines changed: 130 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -8,135 +8,159 @@ export interface PrismaParseResult {
88
setNullCount: number;
99
}
1010

11-
export function parsePrismaSchema(schemaPath: string): PrismaParseResult {
12-
const content = readFileSync(schemaPath, 'utf-8');
13-
const models: SchemaModel[] = [];
14-
const enums: SchemaEnum[] = [];
15-
16-
// Parse enums
17-
const enumRegex = /enum\s+(\w+)\s*\{([^}]+)\}/g;
18-
let enumMatch;
19-
while ((enumMatch = enumRegex.exec(content)) !== null) {
20-
const name = enumMatch[1];
21-
const body = enumMatch[2];
22-
const values = body
23-
.split('\n')
24-
.map((line) => line.trim())
25-
.filter((line) => line && !line.startsWith('//'));
26-
enums.push({ name, values });
11+
const PRISMA_SCALAR_TYPES = new Set([
12+
'String', 'Int', 'Float', 'Boolean', 'DateTime', 'Json', 'BigInt', 'Decimal', 'Bytes',
13+
]);
14+
15+
/**
16+
* Extract brace-balanced blocks for a given keyword (model, enum, etc.)
17+
* Handles nested braces correctly — unlike [^}]+ regex which fails on @default({})
18+
*/
19+
function extractBlocks(content: string, keyword: string): { name: string; body: string }[] {
20+
const blocks: { name: string; body: string }[] = [];
21+
const headerRegex = new RegExp(`${keyword}\\s+(\\w+)\\s*\\{`, 'g');
22+
let match;
23+
while ((match = headerRegex.exec(content)) !== null) {
24+
let depth = 1;
25+
let i = match.index + match[0].length;
26+
while (i < content.length && depth > 0) {
27+
if (content[i] === '{') depth++;
28+
else if (content[i] === '}') depth--;
29+
i++;
30+
}
31+
if (depth === 0) {
32+
blocks.push({ name: match[1], body: content.substring(match.index + match[0].length, i - 1) });
33+
}
2734
}
35+
return blocks;
36+
}
2837

29-
// Parse models
30-
const modelRegex = /model\s+(\w+)\s*\{([^}]+)\}/g;
31-
let modelMatch;
32-
while ((modelMatch = modelRegex.exec(content)) !== null) {
33-
const name = modelMatch[1];
34-
const body = modelMatch[2];
35-
const fields: SchemaField[] = [];
36-
const relations: SchemaRelation[] = [];
37-
const indexes: string[] = [];
38-
const uniqueConstraints: string[] = [];
39-
40-
const allLines = body.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('//'));
41-
42-
// Extract @@index and @@unique before filtering out @@ lines
43-
for (const line of allLines) {
44-
const indexMatch = line.match(/@@index\(\[([^\]]+)\]/);
45-
if (indexMatch) {
46-
indexes.push(indexMatch[1].replace(/"/g, '').trim());
47-
}
48-
const uniqueMatch = line.match(/@@unique\(\[([^\]]+)\]/);
49-
if (uniqueMatch) {
50-
uniqueConstraints.push(uniqueMatch[1].replace(/"/g, '').trim());
51-
}
38+
/**
39+
* Parse a single model block into a SchemaModel
40+
*/
41+
function parseModelBlock(name: string, body: string, enumNames: Set<string>): SchemaModel {
42+
const fields: SchemaField[] = [];
43+
const relations: SchemaRelation[] = [];
44+
const indexes: string[] = [];
45+
const uniqueConstraints: string[] = [];
46+
47+
const allLines = body.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('//'));
48+
49+
// Extract @@index and @@unique before filtering out @@ lines
50+
for (const line of allLines) {
51+
const indexMatch = line.match(/@@index\(\[([^\]]+)\]/);
52+
if (indexMatch) {
53+
indexes.push(indexMatch[1].replace(/"/g, '').trim());
54+
}
55+
const uniqueMatch = line.match(/@@unique\(\[([^\]]+)\]/);
56+
if (uniqueMatch) {
57+
uniqueConstraints.push(uniqueMatch[1].replace(/"/g, '').trim());
5258
}
59+
}
5360

54-
const lines = allLines.filter((l) => !l.startsWith('@@'));
61+
const lines = allLines.filter((l) => !l.startsWith('@@'));
5562

56-
for (const line of lines) {
57-
const parts = line.split(/\s+/);
58-
if (parts.length < 2) continue;
63+
for (const line of lines) {
64+
const parts = line.split(/\s+/);
65+
if (parts.length < 2) continue;
5966

60-
const fieldName = parts[0];
61-
let fieldType = parts[1];
67+
const fieldName = parts[0];
68+
let fieldType = parts[1];
6269

63-
// Skip directives
64-
if (fieldName.startsWith('@')) continue;
70+
// Skip directives
71+
if (fieldName.startsWith('@')) continue;
6572

66-
const constraints: string[] = [];
73+
const constraints: string[] = [];
6774

68-
// Check for optional
69-
if (fieldType.endsWith('?')) {
70-
fieldType = fieldType.slice(0, -1);
71-
constraints.push('optional');
72-
}
75+
// Check for optional
76+
if (fieldType.endsWith('?')) {
77+
fieldType = fieldType.slice(0, -1);
78+
constraints.push('optional');
79+
}
7380

74-
// Check for array
75-
if (fieldType.endsWith('[]')) {
76-
fieldType = fieldType.slice(0, -2);
77-
constraints.push('array');
78-
}
81+
// Check for array
82+
if (fieldType.endsWith('[]')) {
83+
fieldType = fieldType.slice(0, -2);
84+
constraints.push('array');
85+
}
7986

80-
// Extract decorators
81-
const decorators = line.match(/@\w+(\([^)]*\))?/g) ?? [];
82-
for (const d of decorators) {
83-
if (d.startsWith('@id')) constraints.push('primary key');
84-
if (d.startsWith('@unique')) constraints.push('unique');
85-
if (d.startsWith('@default')) constraints.push(d);
86-
if (d.startsWith('@map')) constraints.push(d);
87-
if (d.startsWith('@updatedAt')) constraints.push('auto-updated');
88-
}
87+
// Extract decorators
88+
const decorators = line.match(/@\w+(\([^)]*\))?/g) ?? [];
89+
for (const d of decorators) {
90+
if (d.startsWith('@id')) constraints.push('primary key');
91+
if (d.startsWith('@unique')) constraints.push('unique');
92+
if (d.startsWith('@default')) constraints.push(d);
93+
if (d.startsWith('@map')) constraints.push(d);
94+
if (d.startsWith('@updatedAt')) constraints.push('auto-updated');
95+
}
8996

90-
fields.push({ name: fieldName, type: fieldType, constraints });
97+
fields.push({ name: fieldName, type: fieldType, constraints });
98+
99+
// Detect explicit relations
100+
const relationMatch = line.match(/@relation\(([^)]*)\)/);
101+
if (relationMatch) {
102+
const relBody = relationMatch[1];
103+
const isArray = line.includes('[]');
104+
const onDeleteMatch = relBody.match(/onDelete:\s*(\w+)/);
105+
const fkFieldsMatch = relBody.match(/fields:\s*\[([^\]]+)\]/);
106+
const referencesMatch = relBody.match(/references:\s*\[([^\]]+)\]/);
107+
108+
const isManyToMany = isArray && !fkFieldsMatch;
109+
relations.push({
110+
field: fieldName,
111+
target: fieldType,
112+
type: isManyToMany ? 'many-to-many' : isArray ? 'one-to-many' : 'one-to-one',
113+
onDelete: onDeleteMatch?.[1],
114+
fkFields: fkFieldsMatch?.[1].split(',').map((s) => s.trim()),
115+
references: referencesMatch?.[1].split(',').map((s) => s.trim()),
116+
});
117+
}
91118

92-
// Detect relations
93-
const relationMatch = line.match(/@relation\(([^)]*)\)/);
94-
if (relationMatch) {
95-
const relBody = relationMatch[1];
119+
// Detect implicit relations — exclude scalars AND enums
120+
if (
121+
fieldType[0] === fieldType[0].toUpperCase() &&
122+
!PRISMA_SCALAR_TYPES.has(fieldType) &&
123+
!enumNames.has(fieldType)
124+
) {
125+
if (!relations.some((r) => r.field === fieldName)) {
96126
const isArray = line.includes('[]');
97-
const onDeleteMatch = relBody.match(/onDelete:\s*(\w+)/);
98-
const fkFieldsMatch = relBody.match(/fields:\s*\[([^\]]+)\]/);
99-
const referencesMatch = relBody.match(/references:\s*\[([^\]]+)\]/);
100-
101-
// Detect M:N: array field without explicit FK fields (implicit many-to-many)
102-
const isManyToMany = isArray && !fkFieldsMatch;
127+
const isManyToMany = isArray && !line.includes('@relation');
103128
relations.push({
104129
field: fieldName,
105130
target: fieldType,
106131
type: isManyToMany ? 'many-to-many' : isArray ? 'one-to-many' : 'one-to-one',
107-
onDelete: onDeleteMatch?.[1],
108-
fkFields: fkFieldsMatch?.[1].split(',').map((s) => s.trim()),
109-
references: referencesMatch?.[1].split(',').map((s) => s.trim()),
110132
});
111133
}
112-
113-
// Also detect implicit relations (type is another model name)
114-
if (
115-
fieldType[0] === fieldType[0].toUpperCase() &&
116-
!['String', 'Int', 'Float', 'Boolean', 'DateTime', 'Json', 'BigInt', 'Decimal', 'Bytes'].includes(fieldType) &&
117-
!enums.some((e) => e.name === fieldType)
118-
) {
119-
if (!relations.some((r) => r.field === fieldName)) {
120-
const isArray = line.includes('[]');
121-
const isManyToMany = isArray && !line.includes('@relation');
122-
relations.push({
123-
field: fieldName,
124-
target: fieldType,
125-
type: isManyToMany ? 'many-to-many' : isArray ? 'one-to-many' : 'one-to-one',
126-
});
127-
}
128-
}
129134
}
130-
131-
models.push({
132-
name,
133-
fields,
134-
relations,
135-
...(indexes.length > 0 && { indexes }),
136-
...(uniqueConstraints.length > 0 && { uniqueConstraints }),
137-
});
138135
}
139136

137+
return {
138+
name,
139+
fields,
140+
relations,
141+
...(indexes.length > 0 && { indexes }),
142+
...(uniqueConstraints.length > 0 && { uniqueConstraints }),
143+
};
144+
}
145+
146+
export function parsePrismaSchema(schemaPath: string): PrismaParseResult {
147+
const content = readFileSync(schemaPath, 'utf-8');
148+
149+
// First pass: collect ALL enum names (handles forward references)
150+
const enumBlocks = extractBlocks(content, 'enum');
151+
const enumNames = new Set(enumBlocks.map((b) => b.name));
152+
const enums: SchemaEnum[] = enumBlocks.map((b) => ({
153+
name: b.name,
154+
values: b.body
155+
.split('\n')
156+
.map((line) => line.trim())
157+
.filter((line) => line && !line.startsWith('//')),
158+
}));
159+
160+
// Second pass: parse models with enum awareness
161+
const modelBlocks = extractBlocks(content, 'model');
162+
const models = modelBlocks.map((b) => parseModelBlock(b.name, b.body, enumNames));
163+
140164
// Compute cascade/setNull stats
141165
let cascadeCount = 0;
142166
let setNullCount = 0;

0 commit comments

Comments
 (0)