diff --git a/src/components/EditorHeader/ControlPanel.jsx b/src/components/EditorHeader/ControlPanel.jsx index cc1968d7..9c5d14cb 100644 --- a/src/components/EditorHeader/ControlPanel.jsx +++ b/src/components/EditorHeader/ControlPanel.jsx @@ -917,6 +917,15 @@ export default function ControlPanel({ name: "DBML", disabled: layout.readOnly, }, + { + function: () => { + setModal(MODAL.IMPORT); + setImportFrom(IMPORT_FROM.PRISMA); + }, + name: "Prisma schema", + label: "Beta", + disabled: layout.readOnly, + }, ], }, import_from_source: { diff --git a/src/components/EditorHeader/Modal/ImportDiagram.jsx b/src/components/EditorHeader/Modal/ImportDiagram.jsx index 3d744c48..e151dc35 100644 --- a/src/components/EditorHeader/Modal/ImportDiagram.jsx +++ b/src/components/EditorHeader/Modal/ImportDiagram.jsx @@ -13,6 +13,7 @@ import { } from "../../../hooks"; import { useTranslation } from "react-i18next"; import { fromDBML } from "../../../utils/importFrom/dbml"; +import { fromPrisma } from "../../../utils/importFrom/prisma"; export default function ImportDiagram({ setImportData, @@ -137,12 +138,33 @@ export default function ImportDiagram({ } }; + const loadPrismaData = (e) => { + try { + const result = fromPrisma(e.target.result); + setImportData(result); + if (diagramIsEmpty()) { + setError({ type: STATUS.OK, message: "Everything looks good. You can now import." }); + } else { + setError({ + type: STATUS.WARNING, + message: + "The current diagram is not empty. Importing a new diagram will overwrite the current changes.", + }); + } + } catch (error) { + const message = error?.message || "Failed to parse Prisma schema."; + setError({ type: STATUS.ERROR, message }); + } + }; + const getAcceptableFileTypes = () => { switch (importFrom) { case IMPORT_FROM.JSON: return "application/json,.ddb"; case IMPORT_FROM.DBML: return ".dbml"; + case IMPORT_FROM.PRISMA: + return ".prisma"; default: return ""; } @@ -154,6 +176,8 @@ export default function ImportDiagram({ return `${t("supported_types")} JSON, DDB`; case IMPORT_FROM.DBML: return `${t("supported_types")} DBML`; + case IMPORT_FROM.PRISMA: + return `${t("supported_types")} Prisma (.prisma)`; default: return ""; } @@ -172,6 +196,7 @@ export default function ImportDiagram({ reader.onload = async (e) => { if (importFrom == IMPORT_FROM.JSON) loadJsonData(f, e); if (importFrom == IMPORT_FROM.DBML) loadDBMLData(e); + if (importFrom == IMPORT_FROM.PRISMA) loadPrismaData(e); }; reader.readAsText(f); diff --git a/src/components/EditorHeader/Modal/Modal.jsx b/src/components/EditorHeader/Modal/Modal.jsx index c31e52ad..0065d323 100644 --- a/src/components/EditorHeader/Modal/Modal.jsx +++ b/src/components/EditorHeader/Modal/Modal.jsx @@ -47,6 +47,7 @@ const extensionToLanguage = { sql: "sql", dbml: "dbml", json: "json", + prisma: "prisma", }; export default function Modal({ @@ -93,6 +94,9 @@ export default function Modal({ setRelationships(importData.relationships); setAreas(importData.subjectAreas ?? []); setNotes(importData.notes ?? []); + if (importData.database) { + setDatabase(importData.database); + } if (importData.title) { setTitle(importData.title); } diff --git a/src/data/constants.js b/src/data/constants.js index 66eadaba..db934129 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -116,4 +116,5 @@ export const DB = { export const IMPORT_FROM = { JSON: 0, DBML: 1, + PRISMA: 2, }; diff --git a/src/utils/importFrom/prisma.js b/src/utils/importFrom/prisma.js new file mode 100644 index 00000000..73bf25af --- /dev/null +++ b/src/utils/importFrom/prisma.js @@ -0,0 +1,197 @@ +import { nanoid } from "nanoid"; +import { Cardinality, Constraint, DB } from "../../data/constants"; +import { arrangeTables } from "../arrangeTables"; + +/* + Minimal Prisma schema parser (Beta): + Supports parsing: + - model blocks -> tables & fields + - @@id([...]) and @id for primary keys + - @unique + - @default(value) + - enums -> enums + - relations via @relation(fields: [fk], references: [id]) + Limitations: + - Ignores datasources & generators + - Does not parse composite types or views + - Limited cardinality inference (ONE_TO_MANY if fk field lists a single id, ONE_TO_ONE otherwise) +*/ + +const MODEL_BLOCK_REGEX = /model\s+(\w+)\s+{([\s\S]*?)}/g; +const ENUM_BLOCK_REGEX = /enum\s+(\w+)\s+{([\s\S]*?)}/g; + +function parseFieldLine(line) { + const cleaned = line.split("//")[0].trim(); + if (!cleaned) return null; + if (cleaned.startsWith("@@")) return { kind: "directive", raw: cleaned }; + const parts = cleaned.split(/\s+/); + if (parts.length < 2) return null; + const name = parts[0]; + const type = parts[1]; + const attributes = parts.slice(2).join(" "); + return { kind: "field", name, type, attributes, raw: cleaned }; +} + +function extractDefault(attributes) { + const match = attributes.match(/@default\(([^)]*)\)/); + return match ? match[1].trim() : ""; +} + +function hasAttr(attributes, attr) { + return attributes.includes(`@${attr}`); +} + +function parseRelation(attributes) { + const relMatch = attributes.match(/@relation\(([^)]*)\)/); + if (!relMatch) return null; + const inside = relMatch[1]; + const fieldsMatch = inside.match(/fields:\s*\[([^\]]+)\]/); + const refsMatch = inside.match(/references:\s*\[([^\]]+)\]/); + const nameMatch = inside.match(/name:\s*"([^"]+)"/); + return { + name: nameMatch ? nameMatch[1] : null, + fields: fieldsMatch ? fieldsMatch[1].split(/\s*,\s*/) : [], + references: refsMatch ? refsMatch[1].split(/\s*,\s*/) : [], + }; +} + +export function fromPrisma(src) { + if (typeof src !== "string") throw new Error("Source must be a string"); + + const tables = []; + const enums = []; + const relationships = []; + + + let database; + const dsMatch = src.match(/datasource\s+\w+\s*{([\s\S]*?)}/); + if (dsMatch) { + const body = dsMatch[1]; + const provider = (body.match(/provider\s*=\s*"([^"]+)"/) || [])[1]; + if (provider) { + const map = { + postgresql: DB.POSTGRES, + postgres: DB.POSTGRES, + mysql: DB.MYSQL, + mariadb: DB.MARIADB, + sqlite: DB.SQLITE, + sqlserver: DB.MSSQL, + cockroachdb: DB.POSTGRES, + }; + if (provider === "mongodb") { + throw new Error("MongoDB provider is not supported for SQL diagrams."); + } + database = map[provider]; + } + } + + // Parse enums + for (const enumMatch of src.matchAll(ENUM_BLOCK_REGEX)) { + const [, enumName, enumBody] = enumMatch; + const values = enumBody + .split(/\n+/) + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("//")); + if (values.length) { + enums.push({ name: enumName, values }); + } + } + + // Model parsing + for (const modelMatch of src.matchAll(MODEL_BLOCK_REGEX)) { + const [, modelName, body] = modelMatch; + const lines = body.split(/\n+/); + const parsedTable = { + id: nanoid(), + name: modelName, + comment: "", + color: "#175e7a", + fields: [], + indices: [], + }; + + const directives = []; + for (const line of lines) { + const fieldLine = parseFieldLine(line); + if (!fieldLine) continue; + if (fieldLine.kind === "directive") { + directives.push(fieldLine.raw); + continue; + } + let { name, type, attributes } = fieldLine; + const isList = /\[\]/.test(type); + type = type.replace(/\[\]/, ""); + const field = { + id: nanoid(), + name, + type: type.toUpperCase(), + default: extractDefault(attributes), + check: "", + primary: hasAttr(attributes, "id"), + unique: hasAttr(attributes, "unique") || hasAttr(attributes, "id"), + notNull: !/\?/.test(fieldLine.type), + increment: attributes.includes("autoincrement"), + comment: "", + }; + parsedTable.fields.push(field); + + const relation = parseRelation(attributes); + if (relation && relation.fields.length && relation.references.length) { + field._relationMeta = { + relation, + isList, + targetModel: type, + }; + } + } + + const idDirective = directives.find((d) => /@@id\(/.test(d)); + if (idDirective) { + const fieldsSection = idDirective.match(/@@id\(([^)]*)\)/); + if (fieldsSection) { + const compositeFields = fieldsSection[1] + .replace(/\[|\]/g, "") + .split(/\s*,\s*/) + .filter((x) => x); + parsedTable.fields = parsedTable.fields.map((f) => ({ + ...f, + primary: compositeFields.includes(f.name) || f.primary, + unique: compositeFields.includes(f.name) || f.unique, + })); + } + } + + tables.push(parsedTable); + } + + for (const table of tables) { + for (const field of table.fields) { + if (!field._relationMeta) continue; + const { relation, targetModel, isList } = field._relationMeta; + const targetTable = tables.find((t) => t.name === targetModel); + if (!targetTable) continue; + const fkFieldName = relation.fields[0]; + const refFieldName = relation.references[0]; + const fkField = table.fields.find((f) => f.name === fkFieldName); + const refField = targetTable.fields.find((f) => f.name === refFieldName); + if (!fkField || !refField) continue; + + const relationship = { + id: nanoid(), + name: relation.name || `fk_${table.name}_${fkFieldName}_${targetTable.name}`, + startTableId: table.id, + endTableId: targetTable.id, + startFieldId: fkField.id, + endFieldId: refField.id, + updateConstraint: Constraint.NONE, + deleteConstraint: Constraint.NONE, + cardinality: isList ? Cardinality.ONE_TO_MANY : Cardinality.MANY_TO_ONE, + }; + relationships.push(relationship); + } + } + + const diagram = { tables, enums, relationships, ...(database && { database }) }; + arrangeTables(diagram); + return diagram; +}