From 37d5d85370333bb989f7039e0967932d90a97eed Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sun, 27 Sep 2026 21:58:36 +0800 Subject: [PATCH] Import Prisma and Drizzle schemas, and add isketch import Models and tables become entity diagrams with keys and relations. The CLI imports any format, told from the file name and content. --- README.md | 14 +- ...-85 - Import-Prisma-and-Drizzle-schemas.md | 40 +++- e2e/schemas.spec.js | 48 +++++ src/cli/__tests__/run.spec.js | 40 ++++ src/cli/run.js | 63 +++++- src/domain/__tests__/drizzle.spec.js | 74 +++++++ src/domain/__tests__/importers.spec.js | 28 +++ src/domain/__tests__/prisma.spec.js | 81 ++++++++ src/domain/drizzle.js | 189 ++++++++++++++++++ src/domain/importers.js | 46 +++++ src/domain/prisma.js | 173 ++++++++++++++++ src/domain/sql.js | 10 +- 12 files changed, 791 insertions(+), 15 deletions(-) create mode 100644 e2e/schemas.spec.js create mode 100644 src/domain/__tests__/drizzle.spec.js create mode 100644 src/domain/__tests__/importers.spec.js create mode 100644 src/domain/__tests__/prisma.spec.js create mode 100644 src/domain/drizzle.js create mode 100644 src/domain/prisma.js diff --git a/README.md b/README.md index ee6de7f..a3f46e3 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ isketch aims at that gap: - **Git native.** Files diff cleanly, a CLI renders SVG with no browser, and pull requests get a visual diff, so the design and the code stop drifting apart. - **Start from real files.** Your existing `.drawio` and `.excalidraw` diagrams, - `docker-compose.yml`, OpenAPI and SQL DDL, with re-import that keeps your layout. And a `.drawio` - or `.excalidraw` back out whenever you want one. + `docker-compose.yml`, OpenAPI, SQL DDL, Prisma and Drizzle schemas, with re-import that keeps + your layout. And a `.drawio` or `.excalidraw` back out whenever you want one. - **Local first.** No account, no server, works offline, shareable as a link. - **Written for AI readers.** [/llms.txt](https://isketch.online/llms.txt) and [/docs/format](https://isketch.online/docs/format) describe the format, the tools and the CLI @@ -124,11 +124,12 @@ nobody hosts it yet: run it yourself, or hand over the `.flow` file or the brief - **Edit as text.** Open the text pane beside the canvas and edit the diagram in the [`.flow` format](#the-flow-format): typing redraws the canvas, and changes on the canvas rewrite the text. Errors are listed by line, and the canvas keeps the last valid diagram meanwhile. -- **Import.** Paste or open a Mermaid flowchart, a `docker-compose.yml`, an OpenAPI spec or - SQL `CREATE TABLE` statements. Compose services +- **Import.** Paste or open a Mermaid flowchart, a `docker-compose.yml`, an OpenAPI spec, + SQL `CREATE TABLE` statements, a Prisma schema or Drizzle table definitions. Compose services become shapes that fit their image (Postgres a database, RabbitMQ a queue), and dependencies - become connections. An OpenAPI spec becomes a map of its tags and the schemas they use, and SQL an entity diagram - with keys marked and foreign keys as labelled connections. + become connections. An OpenAPI spec becomes a map of its tags and the schemas they use, and SQL, + Prisma and Drizzle an entity diagram with keys marked and foreign keys (or relations) as labelled + connections. `isketch import` does the same from the command line. Re-importing updates the diagram and keeps its layout and anything added by hand. Anything skipped is listed by line. - **draw.io, both ways.** Import a `.drawio` file, compressed or not, or the XML from Extras > Edit @@ -252,6 +253,7 @@ npm run isketch -- render diagram.flow -o diagram.svg # draw it, add --dark fo npm run isketch -- check docs/*.flow # file:line errors, exit 1 if any npm run isketch -- diff old.flow new.flow -o diff.svg # what changed, listed and drawn npm run isketch -- brief diagram.flow # a Markdown brief for a coding agent +npm run isketch -- import prisma/schema.prisma -o db.flow # any import format, told from the name npm run isketch -- mcp docs # an MCP server for the diagrams in docs/ npm run examples # redraw every SVG in examples/ ``` diff --git a/backlog/tasks/fl-85 - Import-Prisma-and-Drizzle-schemas.md b/backlog/tasks/fl-85 - Import-Prisma-and-Drizzle-schemas.md index 697d5a2..64d85d2 100644 --- a/backlog/tasks/fl-85 - Import-Prisma-and-Drizzle-schemas.md +++ b/backlog/tasks/fl-85 - Import-Prisma-and-Drizzle-schemas.md @@ -1,9 +1,11 @@ --- id: FL-85 title: Import Prisma and Drizzle schemas -status: To Do -assignee: [] +status: Done +assignee: + - '@raj-khan' created_date: '2026-09-25 17:39' +updated_date: '2026-09-27 13:58' labels: - import milestone: m-3 @@ -24,8 +26,36 @@ The schema files most TypeScript projects keep, as entity diagrams, like the SQL -- [ ] #1 schema.prisma models become table shapes with their fields; relations become edges -- [ ] #2 Drizzle table definitions import the same way -- [ ] #3 Available in the import dialog and the CLI +- [x] #1 schema.prisma models become table shapes with their fields; relations become edges +- [x] #2 Drizzle table definitions import the same way +- [x] #3 Available in the import dialog and the CLI + +## Implementation Plan + + + +1. src/domain/prisma.js: models (and views) become tables listing their fields, @id and @@id as PK; relations with fields: [...] become edges from the holder to the target labelled with the key, marking it FK; lists on both sides with no key become one two-way many-to-many edge; unknown field types are warned by line. +2. src/domain/drizzle.js: pgTable, mysqlTable and sqliteTable calls become tables named as in the database; .primaryKey() and primaryKey({ columns }) mark keys; .references(), foreignKey() and one(..., { fields }) become edges; references to unknown tables are warned. +3. Shared describeColumns with SQL; both in IMPORT_FORMATS (dialog). +4. CLI: isketch import [--from ] [-o out.flow] for every import format, with detectFormat from name and content. +5. Unit tests for each reader, detectFormat and the CLI; e2e for both in the dialog. + + + +## Implementation Notes + + + +Both readers are line and brace aware rather than full parsers: comments are blanked first, and Drizzle constraints are read only inside their own table call, found by matching its parentheses (a schema without semicolons otherwise leaked one tables foreign keys into another). Prisma one-to-many back relations add nothing; implicit many-to-many is one two-way edge. Both carry an origin, so a re-import updates in place. + +Verified: prisma.spec (5), drizzle.spec (4), importers.spec for detectFormat (3), CLI run.spec for isketch import (3: name detection with warnings and summary, --from with an unknown format, unreadable and undetectable files); e2e/schemas.spec.js (2, both formats through the Import dialog). e2e 140, vitest 294, lint and typecheck. + + +## Final Summary + + + +Prisma schemas and Drizzle table definitions import as entity diagrams, with keys marked and relations as labelled edges, from the Import dialog and from a new isketch import command that handles every import format, told from the file name and content. Verified with unit, CLI and e2e tests. + diff --git a/e2e/schemas.spec.js b/e2e/schemas.spec.js new file mode 100644 index 0000000..e720074 --- /dev/null +++ b/e2e/schemas.spec.js @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test' + +import { fromMenu } from './helpers.js' + +const shapes = (page) => page.locator('.vue-flow__node') + +const PRISMA = `model User { + id Int @id + email String + posts Post[] +} + +model Post { + id Int @id + author User @relation(fields: [authorId], references: [id]) + authorId Int +}` + +const DRIZZLE = `export const users = pgTable('users', { + id: serial('id').primaryKey(), +}) + +export const posts = pgTable('posts', { + id: serial('id').primaryKey(), + authorId: integer('author_id').references(() => users.id), +})` + +for (const { label, text, key } of [ + { label: 'Prisma', text: PRISMA, key: 'authorId' }, + { label: 'Drizzle', text: DRIZZLE, key: 'author_id' }, +]) { + test(`draws a ${label} schema as tables, with its relations`, async ({ page }) => { + await page.goto('/new') + await expect(shapes(page)).toHaveCount(5) + + await fromMenu(page, 'Import') + const dialog = page.getByRole('dialog', { name: 'Import' }) + await dialog.getByRole('radio', { name: label }).click() + await dialog.getByLabel(`${label} to import`).fill(text) + await dialog.getByRole('radio', { name: /A new diagram/ }).check() + await expect(dialog.getByRole('status')).toContainText('2 shapes and 1 connection') + await dialog.getByRole('button', { name: 'Import' }).click() + + await expect(shapes(page)).toHaveCount(2) + await expect(shapes(page).locator('[data-shape="table"]')).toHaveCount(2) + await expect(page.getByTestId('edge-label').filter({ hasText: key })).toBeVisible() + }) +} diff --git a/src/cli/__tests__/run.spec.js b/src/cli/__tests__/run.spec.js index 7e11ab9..0d22077 100644 --- a/src/cli/__tests__/run.spec.js +++ b/src/cli/__tests__/run.spec.js @@ -113,3 +113,43 @@ describe('usage', () => { expect(await run(['render', 'a.flow', '-o'], fakeIo().io)).toBe(2) }) }) + +describe('isketch import', () => { + const PRISMA = + 'model User {\n id Int @id\n}\n\nmodel Post {\n id Int @id\n author User @relation(fields: [authorId], references: [id])\n authorId Int\n tag Tagg\n}\n' + + it('reads the format from the name, and writes .flow to a file or stdout', async () => { + const toFile = fakeIo({ 'schema.prisma': PRISMA }) + expect(await run(['import', 'schema.prisma', '-o', 'db.flow'], toFile.io)).toBe(0) + expect(toFile.written['db.flow']).toContain('User = table "User" -- id PK') + expect(toFile.written['db.flow']).toContain('Post -> User : authorId') + expect(toFile.err.join('')).toContain('schema.prisma:9: Post.tag is a Tagg') + expect(toFile.err.join('')).toContain( + 'Imported schema.prisma (Prisma) to db.flow: 2 shapes, 1 connection', + ) + + const toStdout = fakeIo({ + 'docker-compose.yml': 'services:\n api:\n depends_on: [db]\n db:\n image: postgres\n', + }) + expect(await run(['import', 'docker-compose.yml'], toStdout.io)).toBe(0) + expect(toStdout.out.join('')).toMatch(/api -> db/) + }) + + it('takes the format outright with --from, and says which there are', async () => { + const named = fakeIo({ 'db.txt': 'CREATE TABLE a (id int PRIMARY KEY);' }) + expect(await run(['import', '--from', 'sql', 'db.txt'], named.io)).toBe(0) + expect(named.out.join('')).toContain('a = table "a"') + + const unknown = fakeIo({ 'db.txt': '' }) + expect(await run(['import', 'db.txt', '--from', 'yaml'], unknown.io)).toBe(2) + expect(unknown.err.join('')).toContain('there is no "yaml" import. Try one of: mermaid') + }) + + it('fails when it cannot tell the format, or cannot read the file', async () => { + const vague = fakeIo({ 'notes.txt': 'hello' }) + expect(await run(['import', 'notes.txt'], vague.io)).toBe(1) + expect(vague.err.join('')).toContain('Say it with --from') + expect(await run(['import', 'missing.prisma'], fakeIo().io)).toBe(1) + expect(await run(['import'], fakeIo().io)).toBe(2) + }) +}) diff --git a/src/cli/run.js b/src/cli/run.js index 4e16af1..da1e34c 100644 --- a/src/cli/run.js +++ b/src/cli/run.js @@ -1,4 +1,5 @@ -import { parseFlow } from '../domain/flowText.js' +import { parseFlow, serialiseFlow } from '../domain/flowText.js' +import { detectFormat, IMPORT_FORMATS, importFormat } from '../domain/importers.js' import { describeDiff, diffDocuments, isUnchanged, mergeForDiff } from '../domain/diff.js' import { renderSvg } from '../domain/renderSvg.js' import { toBrief } from '../domain/brief.js' @@ -8,6 +9,9 @@ export const USAGE = `Usage: isketch render [-o ] [--dark] Draw a diagram as SVG isketch check ... Report errors, exit 1 if any isketch brief A Markdown brief for a coding agent + isketch import [--from ] [-o ] + Turn a schema, spec or drawing into .flow + (${IMPORT_FORMATS.map((format) => format.id).join(', ')}) isketch mcp [folder] An MCP server for the .flow files in a folder isketch diff [-o ] [--dark] List what changed, and draw it @@ -33,6 +37,7 @@ export async function run(argv, io) { if (command === 'check') return check(rest, io) if (command === 'diff') return diff(rest, io) if (command === 'brief') return brief(rest, io) + if (command === 'import') return importFile(rest, io) io.stderr(USAGE) return command === undefined || command === '--help' || command === '-h' ? 0 : 2 @@ -118,6 +123,62 @@ async function diff(args, io) { return 0 } +/** + * A file in any import format, as `.flow` text: to stdout, or to `-o`. The + * format is read from the name, and from the content where names are shared; + * `--from` says it outright. What was skipped is reported like a check error. + * @param {string[]} args + * @param {Parameters[1]} io + */ +async function importFile(args, io) { + const fromIndex = args.findIndex((arg) => arg === '--from') + const from = fromIndex === -1 ? '' : (args[fromIndex + 1] ?? '') + const { files, out } = options( + args.filter((_, index) => fromIndex === -1 || (index !== fromIndex && index !== fromIndex + 1)), + ) + const [input] = files + if (!input || out === '' || (fromIndex !== -1 && !from)) { + io.stderr(USAGE) + return 2 + } + + let text + try { + text = await io.readFile(input) + } catch { + io.stderr(`${input}: cannot be read\n`) + return 1 + } + + const format = from ? importFormat(from) : detectFormat(input, text) + if (!format) { + io.stderr( + from + ? `${input}: there is no "${from}" import. Try one of: ${IMPORT_FORMATS.map((each) => each.id).join(', ')}\n` + : `${input}: the format cannot be told from the name. Say it with --from.\n`, + ) + return from ? 2 : 1 + } + + const { document, warnings } = format.read(text) + warnings.forEach(({ line, message }) => + io.stderr(line ? `${input}:${line}: ${message}\n` : `${input}: ${message}\n`), + ) + if (!document) return 1 + + const flow = serialiseFlow(document) + if (out) { + await io.writeFile(out, flow) + io.stderr( + `Imported ${input} (${format.label}) to ${out}: ${count(document.nodes.length, 'shape')}, ${count(document.edges.length, 'connection')}\n`, + ) + } else io.stdout(flow) + return 0 +} + +/** @param {number} n @param {string} noun */ +const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}` + /** * The font to embed, read only when a sketch needs it. * @param {import('../domain/types.js').FlowDocument} document diff --git a/src/domain/__tests__/drizzle.spec.js b/src/domain/__tests__/drizzle.spec.js new file mode 100644 index 0000000..61589ae --- /dev/null +++ b/src/domain/__tests__/drizzle.spec.js @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import { fromDrizzle } from '../drizzle.js' + +const SCHEMA = `import { integer, pgTable, primaryKey, serial, text, foreignKey } from 'drizzle-orm/pg-core' +import { relations } from 'drizzle-orm' + +// People who sign in. +export const users = pgTable('users', { + id: serial('id').primaryKey(), + email: text('email').notNull().unique(), + bio: text('bio', { length: 280 }), +}) + +export const posts = pgTable('posts', { + id: serial('id').primaryKey(), + title: text('title').notNull(), + authorId: integer('author_id').references(() => users.id, { onDelete: 'cascade' }), +}) + +export const likes = pgTable( + 'likes', + { + userId: integer('user_id').notNull(), + postId: integer('post_id').notNull(), + }, + (t) => [ + primaryKey({ columns: [t.userId, t.postId] }), + foreignKey({ columns: [t.postId], foreignColumns: [posts.id] }), + ], +) + +export const comments = pgTable('comments', { + id: serial().primaryKey(), + postId: integer('post_id'), + tagId: integer('tag_id').references(() => tags.id), +}) + +export const commentsRelations = relations(comments, ({ one }) => ({ + post: one(posts, { fields: [comments.postId], references: [posts.id] }), +})) +` + +describe('fromDrizzle', () => { + const { document, warnings } = fromDrizzle(SCHEMA) + const byId = Object.fromEntries(document.nodes.map((node) => [node.id, node])) + + it('makes each table a table shape, named as in the database, with its columns', () => { + expect(document.nodes.map((node) => node.id)).toEqual(['users', 'posts', 'likes', 'comments']) + expect(byId.users).toMatchObject({ type: 'table', name: 'users' }) + expect(byId.users.data.description).toBe('id PK, email, bio') + expect(byId.posts.data.description).toBe('id PK, title, author_id FK') + expect(byId.likes.data.description).toBe('user_id PK, post_id PK FK') + expect(byId.comments.data.description).toBe('id PK, post_id FK, tag_id') + }) + + it('makes references, foreign keys and relations edges, labelled with the column', () => { + expect( + document.edges.map(({ source, target, label }) => `${source}->${target}:${label}`), + ).toEqual(['posts->users:author_id', 'likes->posts:post_id', 'comments->posts:post_id']) + }) + + it('says which references point at tables it does not have', () => { + expect(warnings).toEqual([ + expect.objectContaining({ + message: 'comments.tagId refers to tags, which is not defined here.', + }), + ]) + }) + + it('refuses text with no tables', () => { + expect(fromDrizzle('const x = 1').document).toBeNull() + }) +}) diff --git a/src/domain/__tests__/importers.spec.js b/src/domain/__tests__/importers.spec.js new file mode 100644 index 0000000..ee39f44 --- /dev/null +++ b/src/domain/__tests__/importers.spec.js @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { detectFormat } from '../importers.js' + +const id = (name, text = '') => detectFormat(name, text)?.id ?? null + +describe('detectFormat', () => { + it('knows a format from its own extension', () => { + expect(id('prisma/schema.prisma')).toBe('prisma') + expect(id('db/migrations/001.sql')).toBe('sql') + expect(id('architecture.drawio')).toBe('drawio') + expect(id('sketch.excalidraw')).toBe('excalidraw') + expect(id('flow.mmd')).toBe('mermaid') + expect(id('src/db/schema.ts')).toBe('drizzle') + }) + + it('tells YAML and JSON apart by what is in them', () => { + expect(id('docker-compose.yml', 'services:\n api: {}')).toBe('compose') + expect(id('openapi.yaml', 'openapi: 3.1.0\ninfo: {}')).toBe('openapi') + expect(id('spec.json', '{\n "openapi": "3.0.0"\n}')).toBe('openapi') + expect(id('board.json', '{"type":"excalidraw","elements":[]}')).toBe('excalidraw') + expect(id('package.json', '{"name":"x"}')).toBeNull() + }) + + it('has nothing to say about other files', () => { + expect(id('notes.txt', 'hello')).toBeNull() + }) +}) diff --git a/src/domain/__tests__/prisma.spec.js b/src/domain/__tests__/prisma.spec.js new file mode 100644 index 0000000..b1250c7 --- /dev/null +++ b/src/domain/__tests__/prisma.spec.js @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' + +import { fromPrisma } from '../prisma.js' + +const SCHEMA = ` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum Role { + USER + ADMIN +} + +model User { + id Int @id @default(autoincrement()) + email String @unique // who they are + role Role @default(USER) + posts Post[] + groups Group[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int + tag Tagg +} + +model Group { + id Int @id + members User[] +} + +model Like { + userId Int + postId Int + post Post @relation(fields: [postId], references: [id]) + @@id([userId, postId]) +} +` + +describe('fromPrisma', () => { + const { document, warnings } = fromPrisma(SCHEMA) + const byId = Object.fromEntries(document.nodes.map((node) => [node.id, node])) + + it('makes each model a table listing its fields, keys marked', () => { + expect(document.nodes.map((node) => node.id)).toEqual(['User', 'Post', 'Group', 'Like']) + expect(byId.User).toMatchObject({ type: 'table', name: 'User' }) + expect(byId.User.data.description).toBe('id PK, email, role') + expect(byId.Post.data.description).toBe('id PK, title, authorId FK, tag') + expect(byId.Like.data.description).toBe('userId PK, postId PK FK') + }) + + it('makes a relation with a key an edge to what it points at, labelled with the key', () => { + expect(document.edges).toContainEqual( + expect.objectContaining({ source: 'Post', target: 'User', label: 'authorId' }), + ) + expect(document.edges).toContainEqual( + expect.objectContaining({ source: 'Like', target: 'Post', label: 'postId' }), + ) + }) + + it('makes a list on both sides one two-way edge, and adds nothing for a back relation', () => { + const between = document.edges.filter((edge) => [edge.source, edge.target].includes('Group')) + expect(between).toEqual([expect.objectContaining({ label: 'many to many', both: true })]) + expect(document.edges).toHaveLength(3) + }) + + it('says which field types it does not know', () => { + expect(warnings).toEqual([ + { line: 25, message: 'Post.tag is a Tagg, which is not defined here.' }, + ]) + }) + + it('refuses text with no models', () => { + expect(fromPrisma('generator client {}').document).toBeNull() + }) +}) diff --git a/src/domain/drizzle.js b/src/domain/drizzle.js new file mode 100644 index 0000000..490cad8 --- /dev/null +++ b/src/domain/drizzle.js @@ -0,0 +1,189 @@ +import { SHAPE } from './constants.js' +import { DOCUMENT_VERSION, edgeIdFor } from './document.js' +import { describeColumns } from './sql.js' + +export const DRIZZLE_ORIGIN = 'drizzle' + +/** `export const users = pgTable('users', {` and its MySQL and SQLite kin, or a schema's. */ +const TABLE = + /(?:export\s+)?const\s+(\w+)\s*=\s*(?:\w+\.)?(?:pg|mysql|sqlite)?[Tt]able\s*\(\s*(['"`])([^'"`]+)\2\s*,\s*\{/g +/** `.references(() => users.id)` */ +const REFERENCES = /\.references\s*\(\s*(?:\(\s*\)\s*(?::\s*\w+\s*)?=>\s*)?(\w+)\.(\w+)/ +/** `foreignKey({ columns: [t.authorId], foreignColumns: [users.id] })` */ +const FOREIGN_KEY = + /foreignKey\s*\(\s*\{[^}]*columns:\s*\[([^\]]*)\][^}]*foreignColumns:\s*\[\s*(\w+)\.\w+/g +/** `primaryKey({ columns: [t.userId, t.postId] })` or `primaryKey(t.userId, t.postId)` */ +const PRIMARY_KEY = /primaryKey\s*\(\s*(?:\{[^}]*columns:\s*\[([^\]]*)\][^}]*\}|([^)]*))\)/g +/** `one(users, { fields: [posts.authorId], references: [users.id] })` in relations() */ +const ONE = /one\s*\(\s*(\w+)\s*,\s*\{[^}]*fields:\s*\[\s*(\w+)\.(\w+)/g + +/** + * The text between a `{` at `start` and its partner, strings and comments + * skipped, so a column's options cannot end the table early. + * @param {string} text + * @param {number} start index of the opening brace + */ +function block(text, start) { + let depth = 0 + for (let at = start; at < text.length; at += 1) { + const char = text[at] + if (char === '"' || char === "'" || char === '`') { + const end = text.indexOf(char, at + 1) + at = end === -1 ? text.length : end + } else if (char === '{' || char === '(' || char === '[') depth += 1 + else if (char === '}' || char === ')' || char === ']') { + depth -= 1 + if (depth === 0) return { inside: text.slice(start + 1, at), end: at } + } + } + return { inside: text.slice(start + 1), end: text.length } +} + +/** Top-level entries of an object literal's body. @param {string} inside */ +function entries(inside) { + const parts = [] + let depth = 0 + let from = 0 + for (let at = 0; at < inside.length; at += 1) { + const char = inside[at] + if (char === '"' || char === "'" || char === '`') { + const end = inside.indexOf(char, at + 1) + at = end === -1 ? inside.length : end + } else if ('{(['.includes(char)) depth += 1 + else if ('})]'.includes(char)) depth -= 1 + else if (char === ',' && depth === 0) { + parts.push(inside.slice(from, at)) + from = at + 1 + } + } + parts.push(inside.slice(from)) + return parts.map((part) => part.trim()).filter(Boolean) +} + +/** The property names in `[t.a, table.b]`. @param {string} list */ +const fieldsOf = (list) => + String(list ?? '') + .split(',') + .map((item) => item.trim().split('.').pop() ?? '') + .filter(Boolean) + +/** @param {string} text @param {number} index */ +const lineAt = (text, index) => text.slice(0, index).split('\n').length + +/** + * Drizzle table definitions as an entity diagram, as SQL DDL is: each table + * listing its columns, keys marked, and each reference an edge from the table + * that holds it to the one it points at, labelled with the column. References + * come from `.references()`, `foreignKey()` and `one(..., { fields })`. + * + * @param {string} text + * @returns {{ document: import('./types.js').FlowDocument | null, warnings: { line: number, message: string }[] }} + */ +export function fromDrizzle(text) { + const source = String(text ?? '').replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (comment) => + comment.replace(/[^\n]/g, ' '), + ) + /** @type {{ line: number, message: string }[]} */ + const warnings = [] + + /** @type {Map} */ + const tables = new Map() + /** @type {{ from: string, column: string, to: string, line: number }[]} */ + const references = [] + + for (const match of source.matchAll(TABLE)) { + const [whole, variable, , name] = match + const opening = (match.index ?? 0) + whole.length - 1 + const { inside, end } = block(source, opening) + const columns = entries(inside).flatMap((entry) => { + const found = /^(?:(\w+)|(['"`])([^'"`]+)\2)\s*:\s*(.*)$/s.exec(entry) + if (!found) return [] + const key = found[1] ?? found[3] + const expression = found[4] + const named = /^\w+\s*\(\s*(['"`])([^'"`]+)\1/.exec(expression) + const reference = REFERENCES.exec(expression) + if (reference) { + references.push({ + from: variable, + column: key, + to: reference[1], + line: lineAt(source, (match.index ?? 0) + whole.length), + }) + } + return [{ key, name: named?.[2] ?? key, pk: /\.primaryKey\s*\(/.test(expression), fk: false }] + }) + + // Constraints come after the columns: `(t) => ({ ... })` or `(t) => [ ... ]`. + const call = block(source, (match.index ?? 0) + whole.lastIndexOf('(')) + const after = source.slice(end + 1, call.end) + for (const key of after.matchAll(PRIMARY_KEY)) { + fieldsOf(key[1] ?? key[2]).forEach((field) => { + const column = columns.find((each) => each.key === field) + if (column) column.pk = true + }) + } + for (const foreign of after.matchAll(FOREIGN_KEY)) { + fieldsOf(foreign[1]).forEach((field) => + references.push({ + from: variable, + column: field, + to: foreign[2], + line: lineAt(source, end), + }), + ) + } + + tables.set(variable, { name, columns }) + } + + if (!tables.size) { + return { + document: null, + warnings: [ + { line: 1, message: 'No tables here. Paste a Drizzle schema, such as schema.ts.' }, + ], + } + } + + // `one(users, { fields: [posts.authorId] })` names the holder by its fields. + for (const one of source.matchAll(ONE)) { + references.push({ + from: one[2], + column: one[3], + to: one[1], + line: lineAt(source, one.index ?? 0), + }) + } + + /** @type {import('./types.js').FlowEdge[]} */ + const edges = [] + references.forEach(({ from, column, to, line }) => { + const holder = tables.get(from) + const target = tables.get(to) + if (!holder || !target) { + warnings.push({ + line, + message: `${from}.${column} refers to ${to}, which is not defined here.`, + }) + return + } + const marked = holder.columns.find((each) => each.key === column) + if (marked) marked.fk = true + if (from === to) return + const id = edgeIdFor(from, to) + const label = marked?.name ?? column + const existing = edges.find((edge) => edge.id === id) + if (!existing) edges.push({ id, source: from, target: to, label, origin: DRIZZLE_ORIGIN }) + else if (!existing.label?.split(', ').includes(label)) + existing.label = `${existing.label}, ${label}` + }) + + const nodes = [...tables.entries()].map(([variable, table]) => ({ + id: variable, + type: SHAPE.TABLE, + name: table.name, + data: { description: describeColumns(table.columns), origin: DRIZZLE_ORIGIN }, + })) + + return { document: { version: DOCUMENT_VERSION, title: 'Database', nodes, edges }, warnings } +} diff --git a/src/domain/importers.js b/src/domain/importers.js index 832f709..e73b2d5 100644 --- a/src/domain/importers.js +++ b/src/domain/importers.js @@ -1,8 +1,10 @@ import { COMPOSE_ORIGIN, fromCompose } from './compose.js' import { DRAWIO_ORIGIN, fromDrawio } from './drawio.js' +import { DRIZZLE_ORIGIN, fromDrizzle } from './drizzle.js' import { fromExcalidraw } from './excalidraw.js' import { fromMermaid } from './mermaid.js' import { fromOpenApi, OPENAPI_ORIGIN } from './openapi.js' +import { fromPrisma, PRISMA_ORIGIN } from './prisma.js' import { fromSql, SQL_ORIGIN } from './sql.js' /** @@ -53,6 +55,24 @@ export const IMPORT_FORMATS = Object.freeze([ origin: SQL_ORIGIN, read: fromSql, }, + { + id: 'prisma', + label: 'Prisma', + accept: '.prisma', + placeholder: + 'model User {\n id Int @id\n posts Post[]\n}\n\nmodel Post {\n id Int @id\n author User @relation(fields: [authorId], references: [id])\n authorId Int\n}', + origin: PRISMA_ORIGIN, + read: fromPrisma, + }, + { + id: 'drizzle', + label: 'Drizzle', + accept: '.ts,.js,.mjs', + placeholder: + "export const users = pgTable('users', {\n id: serial('id').primaryKey(),\n})\n\nexport const posts = pgTable('posts', {\n id: serial('id').primaryKey(),\n authorId: integer('author_id').references(() => users.id),\n})", + origin: DRIZZLE_ORIGIN, + read: fromDrizzle, + }, { id: 'drawio', label: 'draw.io', @@ -75,3 +95,29 @@ export const IMPORT_FORMATS = Object.freeze([ /** @param {string} id */ export const importFormat = (id) => IMPORT_FORMATS.find((format) => format.id === id) ?? null + +/** + * Which import a file is, from its name and, where a name is shared, what is + * in it. Null when it is none of them. + * @param {string} fileName + * @param {string} text + * @returns {ImportFormat | null} + */ +export function detectFormat(fileName, text) { + const name = fileName.toLowerCase() + const byExtension = (/** @type {string} */ id) => importFormat(id) + if (name.endsWith('.prisma')) return byExtension('prisma') + if (name.endsWith('.sql') || name.endsWith('.ddl')) return byExtension('sql') + if (name.endsWith('.drawio')) return byExtension('drawio') + if (name.endsWith('.excalidraw')) return byExtension('excalidraw') + if (/\.(mmd|mermaid)$/.test(name)) return byExtension('mermaid') + if (/\.(ts|js|mjs)$/.test(name)) return byExtension('drizzle') + if (/\.(ya?ml|json)$/.test(name)) { + if (/^\s*\{[\s\S]*"type"\s*:\s*"excalidraw/.test(text)) return byExtension('excalidraw') + if (/^\s*["']?(openapi|swagger)["']?\s*:/m.test(text)) return byExtension('openapi') + if (/^\s*["']?services["']?\s*:/m.test(text)) return byExtension('compose') + return null + } + if (name.endsWith('.xml')) return byExtension('drawio') + return null +} diff --git a/src/domain/prisma.js b/src/domain/prisma.js new file mode 100644 index 0000000..903d7da --- /dev/null +++ b/src/domain/prisma.js @@ -0,0 +1,173 @@ +import { SHAPE } from './constants.js' +import { DOCUMENT_VERSION, edgeIdFor } from './document.js' +import { describeColumns } from './sql.js' + +export const PRISMA_ORIGIN = 'prisma' + +/** Prisma's own field types: a column, never a relation. */ +const SCALARS = new Set([ + 'String', + 'Boolean', + 'Int', + 'BigInt', + 'Float', + 'Decimal', + 'DateTime', + 'Json', + 'Bytes', + 'Unsupported', +]) + +const BLOCK = /^\s*(model|view|enum|type)\s+(\w+)\s*\{/ +const FIELD = /^\s*(\w+)\s+(\w+)(\[\])?(\?)?(.*)$/ + +/** + * A Prisma schema as an entity diagram, as SQL DDL is: each model a table + * listing its fields, keys marked, and each relation with `fields` an edge + * from the model that holds the key to the one it points at, labelled with it. + * A relation listed on both sides with no key (many to many) is one two-way + * edge. The other side of a one-to-many adds nothing. + * + * @param {string} text + * @returns {{ document: import('./types.js').FlowDocument | null, warnings: { line: number, message: string }[] }} + */ +export function fromPrisma(text) { + const lines = String(text ?? '') + .replace(/\/\/.*$/gm, '') + .split('\n') + /** @type {{ line: number, message: string }[]} */ + const warnings = [] + + /** @type {Map} */ + const models = new Map() + const enums = new Set() + /** @type {{ name: string, line: number, fields: { name: string, type: string, list: boolean, rest: string, line: number }[], ids: string[] } | null} */ + let current = null + let kind = '' + + lines.forEach((raw, index) => { + const line = index + 1 + const block = BLOCK.exec(raw) + if (block) { + kind = block[1] + if (kind === 'enum') enums.add(block[2]) + else { + current = { name: block[2], line, fields: [], ids: [] } + models.set(block[2], current) + } + return + } + if (/^\s*\}/.test(raw)) { + current = null + kind = '' + return + } + if (!current || kind === 'enum') return + + const compound = /^\s*@@id\(\s*(?:fields:\s*)?\[([^\]]*)\]/.exec(raw) + if (compound) { + current.ids.push( + ...compound[1] + .split(',') + .map((name) => name.trim()) + .filter(Boolean), + ) + return + } + const field = FIELD.exec(raw) + if (field) { + current.fields.push({ + name: field[1], + type: field[2], + list: Boolean(field[3]), + rest: field[5], + line, + }) + } + }) + + if (!models.size) { + return { + document: null, + warnings: [{ line: 1, message: 'No models here. Paste a schema.prisma, or open one.' }], + } + } + + /** @type {import('./types.js').FlowEdge[]} */ + const edges = [] + const pairs = new Set() + + const nodes = [...models.values()].map((model) => { + /** @type {{ name: string, pk: boolean, fk: boolean }[]} */ + const columns = [] + const keys = new Set() + + model.fields.forEach((field) => { + const related = models.has(field.type) + if (!related) { + if (!SCALARS.has(field.type) && !enums.has(field.type)) { + warnings.push({ + line: field.line, + message: `${model.name}.${field.name} is a ${field.type}, which is not defined here.`, + }) + } + columns.push({ + name: field.name, + pk: /@id\b/.test(field.rest) || model.ids.includes(field.name), + fk: false, + }) + return + } + + const keyed = /@relation\([^)]*fields:\s*\[([^\]]*)\]/.exec(field.rest) + if (keyed) { + const held = keyed[1] + .split(',') + .map((name) => name.trim()) + .filter(Boolean) + held.forEach((name) => keys.add(name)) + if (field.type === model.name) return + const id = edgeIdFor(model.name, field.type) + const existing = edges.find((edge) => edge.id === id) + if (existing) existing.label = `${existing.label}, ${held.join(', ')}` + else { + edges.push({ + id, + source: model.name, + target: field.type, + label: held.join(', '), + origin: PRISMA_ORIGIN, + }) + } + return + } + + // Lists on both sides and no key anywhere: an implicit many to many. + const other = models.get(field.type) + const back = other?.fields.find((each) => each.type === model.name) + if (field.list && back?.list) { + const pair = [model.name, field.type].sort().join(' ') + if (pairs.has(pair) || field.type === model.name) return + pairs.add(pair) + edges.push({ + id: edgeIdFor(model.name, field.type), + source: model.name, + target: field.type, + label: 'many to many', + both: true, + origin: PRISMA_ORIGIN, + }) + } + }) + + columns.forEach((column) => (column.fk = keys.has(column.name))) + return { + id: model.name, + type: SHAPE.TABLE, + name: model.name, + data: { description: describeColumns(columns), origin: PRISMA_ORIGIN }, + } + }) + + return { document: { version: DOCUMENT_VERSION, title: 'Database', nodes, edges }, warnings } +} diff --git a/src/domain/sql.js b/src/domain/sql.js index f3f3a14..d167d4d 100644 --- a/src/domain/sql.js +++ b/src/domain/sql.js @@ -106,7 +106,7 @@ export function fromSql(text) { id: table.id, type: SHAPE.TABLE, name: table.name, - data: { description: describe(table.columns), origin: SQL_ORIGIN }, + data: { description: describeColumns(table.columns), origin: SQL_ORIGIN }, })) return { document: { version: DOCUMENT_VERSION, title: 'Database', nodes, edges }, warnings } @@ -158,8 +158,12 @@ function readColumns(inside, table, line, references) { return columns } -/** @param {{ name: string, pk: boolean, fk: boolean }[]} columns */ -function describe(columns) { +/** + * A table's columns as its description, keys marked, shared by every schema + * import so they all read the same. + * @param {{ name: string, pk: boolean, fk: boolean }[]} columns + */ +export function describeColumns(columns) { const shown = columns .slice(0, LISTED) .map(({ name, pk, fk }) => `${name}${pk ? ' PK' : ''}${fk ? ' FK' : ''}`)