Skip to content

Commit edd7d0f

Browse files
committed
feat: Add Prisma schema import support (Beta)
- Add IMPORT_FROM.PRISMA constant - Implement fromPrisma() parser for Prisma schema files - Parse models -> tables with fields, enums, and relationships - Detect datasource provider and auto-set diagram database (PostgreSQL, MySQL, SQLite, MariaDB, SQL Server) - Parse @id, @@id([...]), @unique, @default(), autoincrement - Parse @relation() for FK relationships with cardinality inference - Add "Prisma schema (Beta)" option in File > Import from menu - Support .prisma file upload in import modal - Auto-arrange imported tables on canvas - Reject MongoDB provider with clear error message Allows users to import Prisma ORM schemas and export to respective SQL databases.
1 parent 0ead55c commit edd7d0f

File tree

5 files changed

+236
-0
lines changed

5 files changed

+236
-0
lines changed

src/components/EditorHeader/ControlPanel.jsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,15 @@ export default function ControlPanel({
917917
name: "DBML",
918918
disabled: layout.readOnly,
919919
},
920+
{
921+
function: () => {
922+
setModal(MODAL.IMPORT);
923+
setImportFrom(IMPORT_FROM.PRISMA);
924+
},
925+
name: "Prisma schema",
926+
label: "Beta",
927+
disabled: layout.readOnly,
928+
},
920929
],
921930
},
922931
import_from_source: {

src/components/EditorHeader/Modal/ImportDiagram.jsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from "../../../hooks";
1414
import { useTranslation } from "react-i18next";
1515
import { fromDBML } from "../../../utils/importFrom/dbml";
16+
import { fromPrisma } from "../../../utils/importFrom/prisma";
1617

1718
export default function ImportDiagram({
1819
setImportData,
@@ -137,12 +138,33 @@ export default function ImportDiagram({
137138
}
138139
};
139140

141+
const loadPrismaData = (e) => {
142+
try {
143+
const result = fromPrisma(e.target.result);
144+
setImportData(result);
145+
if (diagramIsEmpty()) {
146+
setError({ type: STATUS.OK, message: "Everything looks good. You can now import." });
147+
} else {
148+
setError({
149+
type: STATUS.WARNING,
150+
message:
151+
"The current diagram is not empty. Importing a new diagram will overwrite the current changes.",
152+
});
153+
}
154+
} catch (error) {
155+
const message = error?.message || "Failed to parse Prisma schema.";
156+
setError({ type: STATUS.ERROR, message });
157+
}
158+
};
159+
140160
const getAcceptableFileTypes = () => {
141161
switch (importFrom) {
142162
case IMPORT_FROM.JSON:
143163
return "application/json,.ddb";
144164
case IMPORT_FROM.DBML:
145165
return ".dbml";
166+
case IMPORT_FROM.PRISMA:
167+
return ".prisma";
146168
default:
147169
return "";
148170
}
@@ -154,6 +176,8 @@ export default function ImportDiagram({
154176
return `${t("supported_types")} JSON, DDB`;
155177
case IMPORT_FROM.DBML:
156178
return `${t("supported_types")} DBML`;
179+
case IMPORT_FROM.PRISMA:
180+
return `${t("supported_types")} Prisma (.prisma)`;
157181
default:
158182
return "";
159183
}
@@ -172,6 +196,7 @@ export default function ImportDiagram({
172196
reader.onload = async (e) => {
173197
if (importFrom == IMPORT_FROM.JSON) loadJsonData(f, e);
174198
if (importFrom == IMPORT_FROM.DBML) loadDBMLData(e);
199+
if (importFrom == IMPORT_FROM.PRISMA) loadPrismaData(e);
175200
};
176201
reader.readAsText(f);
177202

src/components/EditorHeader/Modal/Modal.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const extensionToLanguage = {
4747
sql: "sql",
4848
dbml: "dbml",
4949
json: "json",
50+
prisma: "prisma",
5051
};
5152

5253
export default function Modal({
@@ -93,6 +94,9 @@ export default function Modal({
9394
setRelationships(importData.relationships);
9495
setAreas(importData.subjectAreas ?? []);
9596
setNotes(importData.notes ?? []);
97+
if (importData.database) {
98+
setDatabase(importData.database);
99+
}
96100
if (importData.title) {
97101
setTitle(importData.title);
98102
}

src/data/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,5 @@ export const DB = {
116116
export const IMPORT_FROM = {
117117
JSON: 0,
118118
DBML: 1,
119+
PRISMA: 2,
119120
};

src/utils/importFrom/prisma.js

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { nanoid } from "nanoid";
2+
import { Cardinality, Constraint, DB } from "../../data/constants";
3+
import { arrangeTables } from "../arrangeTables";
4+
5+
/*
6+
Minimal Prisma schema parser (Beta):
7+
Supports parsing:
8+
- model blocks -> tables & fields
9+
- @@id([...]) and @id for primary keys
10+
- @unique
11+
- @default(value)
12+
- enums -> enums
13+
- relations via @relation(fields: [fk], references: [id])
14+
Limitations:
15+
- Ignores datasources & generators
16+
- Does not parse composite types or views
17+
- Limited cardinality inference (ONE_TO_MANY if fk field lists a single id, ONE_TO_ONE otherwise)
18+
*/
19+
20+
const MODEL_BLOCK_REGEX = /model\s+(\w+)\s+{([\s\S]*?)}/g;
21+
const ENUM_BLOCK_REGEX = /enum\s+(\w+)\s+{([\s\S]*?)}/g;
22+
23+
function parseFieldLine(line) {
24+
const cleaned = line.split("//")[0].trim();
25+
if (!cleaned) return null;
26+
if (cleaned.startsWith("@@")) return { kind: "directive", raw: cleaned };
27+
const parts = cleaned.split(/\s+/);
28+
if (parts.length < 2) return null;
29+
const name = parts[0];
30+
const type = parts[1];
31+
const attributes = parts.slice(2).join(" ");
32+
return { kind: "field", name, type, attributes, raw: cleaned };
33+
}
34+
35+
function extractDefault(attributes) {
36+
const match = attributes.match(/@default\(([^)]*)\)/);
37+
return match ? match[1].trim() : "";
38+
}
39+
40+
function hasAttr(attributes, attr) {
41+
return attributes.includes(`@${attr}`);
42+
}
43+
44+
function parseRelation(attributes) {
45+
const relMatch = attributes.match(/@relation\(([^)]*)\)/);
46+
if (!relMatch) return null;
47+
const inside = relMatch[1];
48+
const fieldsMatch = inside.match(/fields:\s*\[([^\]]+)\]/);
49+
const refsMatch = inside.match(/references:\s*\[([^\]]+)\]/);
50+
const nameMatch = inside.match(/name:\s*"([^"]+)"/);
51+
return {
52+
name: nameMatch ? nameMatch[1] : null,
53+
fields: fieldsMatch ? fieldsMatch[1].split(/\s*,\s*/) : [],
54+
references: refsMatch ? refsMatch[1].split(/\s*,\s*/) : [],
55+
};
56+
}
57+
58+
export function fromPrisma(src) {
59+
if (typeof src !== "string") throw new Error("Source must be a string");
60+
61+
const tables = [];
62+
const enums = [];
63+
const relationships = [];
64+
65+
66+
let database;
67+
const dsMatch = src.match(/datasource\s+\w+\s*{([\s\S]*?)}/);
68+
if (dsMatch) {
69+
const body = dsMatch[1];
70+
const provider = (body.match(/provider\s*=\s*"([^"]+)"/) || [])[1];
71+
if (provider) {
72+
const map = {
73+
postgresql: DB.POSTGRES,
74+
postgres: DB.POSTGRES,
75+
mysql: DB.MYSQL,
76+
mariadb: DB.MARIADB,
77+
sqlite: DB.SQLITE,
78+
sqlserver: DB.MSSQL,
79+
cockroachdb: DB.POSTGRES,
80+
};
81+
if (provider === "mongodb") {
82+
throw new Error("MongoDB provider is not supported for SQL diagrams.");
83+
}
84+
database = map[provider];
85+
}
86+
}
87+
88+
// Parse enums
89+
for (const enumMatch of src.matchAll(ENUM_BLOCK_REGEX)) {
90+
const [, enumName, enumBody] = enumMatch;
91+
const values = enumBody
92+
.split(/\n+/)
93+
.map((l) => l.trim())
94+
.filter((l) => l && !l.startsWith("//"));
95+
if (values.length) {
96+
enums.push({ name: enumName, values });
97+
}
98+
}
99+
100+
// Model parsing
101+
for (const modelMatch of src.matchAll(MODEL_BLOCK_REGEX)) {
102+
const [, modelName, body] = modelMatch;
103+
const lines = body.split(/\n+/);
104+
const parsedTable = {
105+
id: nanoid(),
106+
name: modelName,
107+
comment: "",
108+
color: "#175e7a",
109+
fields: [],
110+
indices: [],
111+
};
112+
113+
const directives = [];
114+
for (const line of lines) {
115+
const fieldLine = parseFieldLine(line);
116+
if (!fieldLine) continue;
117+
if (fieldLine.kind === "directive") {
118+
directives.push(fieldLine.raw);
119+
continue;
120+
}
121+
let { name, type, attributes } = fieldLine;
122+
const isList = /\[\]/.test(type);
123+
type = type.replace(/\[\]/, "");
124+
const field = {
125+
id: nanoid(),
126+
name,
127+
type: type.toUpperCase(),
128+
default: extractDefault(attributes),
129+
check: "",
130+
primary: hasAttr(attributes, "id"),
131+
unique: hasAttr(attributes, "unique") || hasAttr(attributes, "id"),
132+
notNull: !/\?/.test(fieldLine.type),
133+
increment: attributes.includes("autoincrement"),
134+
comment: "",
135+
};
136+
parsedTable.fields.push(field);
137+
138+
const relation = parseRelation(attributes);
139+
if (relation && relation.fields.length && relation.references.length) {
140+
field._relationMeta = {
141+
relation,
142+
isList,
143+
targetModel: type,
144+
};
145+
}
146+
}
147+
148+
const idDirective = directives.find((d) => /@@id\(/.test(d));
149+
if (idDirective) {
150+
const fieldsSection = idDirective.match(/@@id\(([^)]*)\)/);
151+
if (fieldsSection) {
152+
const compositeFields = fieldsSection[1]
153+
.replace(/\[|\]/g, "")
154+
.split(/\s*,\s*/)
155+
.filter((x) => x);
156+
parsedTable.fields = parsedTable.fields.map((f) => ({
157+
...f,
158+
primary: compositeFields.includes(f.name) || f.primary,
159+
unique: compositeFields.includes(f.name) || f.unique,
160+
}));
161+
}
162+
}
163+
164+
tables.push(parsedTable);
165+
}
166+
167+
for (const table of tables) {
168+
for (const field of table.fields) {
169+
if (!field._relationMeta) continue;
170+
const { relation, targetModel, isList } = field._relationMeta;
171+
const targetTable = tables.find((t) => t.name === targetModel);
172+
if (!targetTable) continue;
173+
const fkFieldName = relation.fields[0];
174+
const refFieldName = relation.references[0];
175+
const fkField = table.fields.find((f) => f.name === fkFieldName);
176+
const refField = targetTable.fields.find((f) => f.name === refFieldName);
177+
if (!fkField || !refField) continue;
178+
179+
const relationship = {
180+
id: nanoid(),
181+
name: relation.name || `fk_${table.name}_${fkFieldName}_${targetTable.name}`,
182+
startTableId: table.id,
183+
endTableId: targetTable.id,
184+
startFieldId: fkField.id,
185+
endFieldId: refField.id,
186+
updateConstraint: Constraint.NONE,
187+
deleteConstraint: Constraint.NONE,
188+
cardinality: isList ? Cardinality.ONE_TO_MANY : Cardinality.MANY_TO_ONE,
189+
};
190+
relationships.push(relationship);
191+
}
192+
}
193+
194+
const diagram = { tables, enums, relationships, ...(database && { database }) };
195+
arrangeTables(diagram);
196+
return diagram;
197+
}

0 commit comments

Comments
 (0)