Skip to content

ER/Schema Visualizer — JavaScript source

Paste CREATE TABLE DDL and get an ER diagram as SVG: tables with typed columns, primary keys, and foreign-key arrows in a deterministic layered layout. Pan and zoom the live diagram; export the SVG.

This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// schema-visualizer — pure CREATE TABLE DDL → layered ER diagram as SVG.
// JavaScript port (canonical TS: src/lib/schema-visualizer.ts; Go twin:
// cli/schema-visualizer). Tolerant common subset of Postgres/MySQL/SQLite:
// unparseable statements degrade to notes, never throw. Integer geometry
// only, so every port draws the identical diagram.
'use strict';

const LAYOUT = { rowHeight: 24, charWidth: 7, padding: 8, layerGap: 60, columnGap: 40 };
const MODIFIERS = new Set(['NOT', 'NULL', 'PRIMARY', 'KEY', 'UNIQUE', 'DEFAULT', 'REFERENCES',
  'AUTO_INCREMENT', 'AUTOINCREMENT', 'ON', 'COMMENT', 'CHECK', 'CONSTRAINT']);

function splitStatements(ddl) {
  const out = [];
  let cur = '';
  let i = 0;
  while (i < ddl.length) {
    const ch = ddl[i];
    if (ch === "'" || ch === '"' || ch === '`' || ch === '[') {
      const close = ch === '[' ? ']' : ch;
      cur += ch; i++;
      while (i < ddl.length) {
        cur += ddl[i];
        if (ddl[i] === close) {
          if (close === "'" && ddl[i + 1] === "'") { cur += ddl[i + 1]; i += 2; continue; }
          break;
        }
        i++;
      }
      i++; continue;
    }
    if (ch === ';') { out.push(cur); cur = ''; i++; continue; }
    cur += ch; i++;
  }
  if (cur.trim()) out.push(cur);
  return out;
}

function tokenize(s) {
  const toks = [];
  let i = 0;
  while (i < s.length) {
    const ch = s[i];
    if (/\s/.test(ch)) { i++; continue; }
    if (ch === "'" || ch === '"' || ch === '`' || ch === '[') {
      const close = ch === '[' ? ']' : ch;
      let text = ''; i++;
      while (i < s.length) {
        if (s[i] === close) {
          if (close === "'" && s[i + 1] === "'") { text += "'"; i += 2; continue; }
          break;
        }
        text += s[i++];
      }
      i++;
      toks.push({ text, kind: ch === "'" ? 'string' : 'qident' });
      continue;
    }
    if ('(),.'.includes(ch)) { toks.push({ text: ch, kind: 'punct' }); i++; continue; }
    let word = '';
    while (i < s.length && !/[\s"',().`\[\]]/.test(s[i])) word += s[i++];
    toks.push({ text: word, kind: 'word' });
  }
  return toks;
}

const isP = (t, p) => !!t && t.kind === 'punct' && t.text === p;
const kw = (t, w) => !!t && t.kind === 'word' && t.text.toUpperCase() === w;

function takeName(toks, i) {
  const first = toks[i];
  if (!first || (first.kind !== 'qident' && first.kind !== 'word')) return null;
  let name = first.text, j = i + 1;
  while (isP(toks[j], '.') && toks[j + 1] && (toks[j + 1].kind === 'qident' || toks[j + 1].kind === 'word')) {
    name += '.' + toks[j + 1].text; j += 2;
  }
  return [name, j];
}

function parenList(toks, i) {
  if (!isP(toks[i], '(')) return null;
  const names = []; let j = i + 1;
  for (;;) {
    const n = takeName(toks, j);
    if (!n) return null;
    names.push(n[0]); j = n[1];
    if (isP(toks[j], ',')) { j++; continue; }
    if (isP(toks[j], ')')) return [names, j + 1];
    return null;
  }
}

function parseColumn(line, table, fks) {
  const name = takeName(line, 0);
  if (!name) return;
  let i = name[1];
  const typeToks = [];
  while (i < line.length && !(line[i].kind === 'word' && MODIFIERS.has(line[i].text.toUpperCase()))) {
    typeToks.push(line[i++]);
  }
  const type = typeToks.map((t) => t.text).join(' ')
    .replace(/\s*\(\s*/g, '(').replace(/\s*\)\s*/g, ')').replace(/\s*,\s*/g, ',')
    .trim().toUpperCase();
  let nullable = true, pk = false;
  while (i < line.length) {
    const t = line[i];
    if (kw(t, 'NOT') && kw(line[i + 1], 'NULL')) { nullable = false; i += 2; continue; }
    if (kw(t, 'NULL')) { i++; continue; }
    if (kw(t, 'PRIMARY') && kw(line[i + 1], 'KEY')) { pk = true; nullable = false; i += 2; continue; }
    if (kw(t, 'UNIQUE') || kw(t, 'AUTO_INCREMENT') || kw(t, 'AUTOINCREMENT')) { i++; continue; }
    if (kw(t, 'DEFAULT')) {
      i++;
      if (isP(line[i], '(')) { let d = 0; do { if (isP(line[i], '(')) d++; if (isP(line[i], ')')) d--; i++; } while (i < line.length && d > 0); }
      else if (line[i]) i++;
      continue;
    }
    if (kw(t, 'COMMENT')) { i++; if (line[i] && line[i].kind === 'string') i++; continue; }
    if (kw(t, 'ON')) {
      i += 2;
      if (kw(line[i], 'SET') || kw(line[i], 'NO')) i += 2;
      else if (line[i]) i++;
      continue;
    }
    if (kw(t, 'REFERENCES')) {
      i++;
      const target = takeName(line, i);
      if (target) {
        i = target[1];
        let toCol = null;
        if (isP(line[i], '(')) { const list = parenList(line, i); if (list) { toCol = list[0][0]; i = list[1]; } }
        fks.push({ fromTable: table, fromColumn: name[0], toTable: target[0], toColumn: toCol });
      }
      continue;
    }
    i++; // unknown modifier tolerated
  }
  return { name: name[0], type, nullable, isPrimaryKey: pk };
}

function parseDdl(ddl) {
  if (!ddl.trim()) return { tables: [], foreignKeys: [], notes: ['No DDL input.'] };
  const tables = [], fks = [], notes = [];
  for (const stmt of splitStatements(ddl)) {
    if (!stmt.trim()) continue;
    const toks = tokenize(stmt);
    try {
      let i = 0;
      if (!kw(toks[i], 'CREATE')) throw 0;
      i++;
      while (kw(toks[i], 'TEMP') || kw(toks[i], 'TEMPORARY') || kw(toks[i], 'UNLOGGED')) i++;
      if (!kw(toks[i], 'TABLE')) { notes.push('Skipped non-table statement.'); continue; }
      i++;
      if (kw(toks[i], 'IF') && kw(toks[i + 1], 'NOT') && kw(toks[i + 2], 'EXISTS')) i += 3;
      const name = takeName(toks, i);
      if (!name || !isP(toks[name[1]], '(')) throw 0;
      i = name[1] + 1;
      // body = tokens until the matching close paren (depth-aware)
      const body = []; let depth = 0;
      for (; i < toks.length; i++) {
        if (isP(toks[i], '(')) depth++;
        if (isP(toks[i], ')')) { if (depth === 0) break; depth--; }
        body.push(toks[i]);
      }
      if (i >= toks.length) throw 0;
      // split body into top-level comma lines
      const lines = []; let line = [];
      for (const t of body) {
        if (isP(t, '(')) depth++;
        if (isP(t, ')')) depth--;
        if (isP(t, ',') && depth === 0) { lines.push(line); line = []; continue; }
        line.push(t);
      }
      if (line.length) lines.push(line);
      const table = { name: name[0], columns: [] };
      tables.push(table);
      for (const toks2 of lines) {
        if (!toks2.length) continue;
        const first = toks2[0];
        const U = first.kind === 'word' ? first.text.toUpperCase() : '';
        if (U === 'PRIMARY' && kw(toks2[1], 'KEY')) {
          const list = parenList(toks2, 2);
          if (list) for (const cn of list[0]) {
            const col = table.columns.find((c) => c.name === cn);
            if (col) { col.isPrimaryKey = true; col.nullable = false; }
          }
          continue;
        }
        if (U === 'FOREIGN' && kw(toks2[1], 'KEY')) {
          const from = parenList(toks2, 2);
          if (from && kw(toks2[from[1]], 'REFERENCES')) {
            const target = takeName(toks2, from[1] + 1);
            if (target) {
              let toCols = null;
              if (isP(toks2[target[1]], '(')) { const to = parenList(toks2, target[1]); if (to) toCols = to[0]; }
              from[0].forEach((fc, idx) => fks.push({
                fromTable: table.name, fromColumn: fc, toTable: target[0],
                toColumn: toCols ? (toCols[idx] ?? toCols[toCols.length - 1]) : null,
              }));
            }
          }
          continue;
        }
        if (['UNIQUE', 'KEY', 'INDEX', 'CHECK', 'EXCLUDE', 'CONSTRAINT'].includes(U)) continue;
        const col = parseColumn(toks2, table.name, fks);
        if (col) table.columns.push(col);
      }
    } catch { notes.push('Skipped unparseable statement.'); }
  }
  // resolve omitted FK target columns to the referenced table's first PK
  const foreignKeys = fks.map((fk) => {
    if (fk.toColumn) return fk;
    const target = tables.find((t) => t.name === fk.toTable);
    return { ...fk, toColumn: target?.columns.find((c) => c.isPrimaryKey)?.name ?? 'id' };
  });
  return { tables, foreignKeys, notes };
}

function layoutSchema(schema) {
  const o = LAYOUT;
  if (!schema.tables.length) return { width: 0, height: 0, tables: [], edges: [] };
  const index = new Map();
  schema.tables.forEach((t, i) => { if (!index.has(t.name)) index.set(t.name, i); });
  const boxes = schema.tables.map((t) => {
    const textLen = Math.max(t.name.length, ...t.columns.map((c) => `${c.name} ${c.type}`.length), 1);
    return { x: 0, y: 0, w: Math.round(textLen * o.charWidth + 2 * o.padding),
      h: Math.round(o.rowHeight * (1 + t.columns.length) + o.padding) };
  });
  const layerOf = schema.tables.map(() => 0);
  for (let pass = 0; pass < schema.tables.length; pass++) {
    let changed = false;
    for (const fk of schema.foreignKeys) {
      const ti = index.get(fk.fromTable), tj = index.get(fk.toTable);
      if (ti === undefined || tj === undefined || ti === tj) continue;
      if (layerOf[ti] < layerOf[tj] + 1) { layerOf[ti] = layerOf[tj] + 1; changed = true; }
    }
    if (!changed) break;
  }
  const layers = [];
  schema.tables.forEach((_, i) => (layers[layerOf[i]] ??= []).push(i));
  let y = 0, width = 0, height = 0;
  for (const layer of layers) {
    let x = 0, layerH = 0;
    for (const i of layer) {
      boxes[i].x = x; boxes[i].y = y;
      x += boxes[i].w + o.columnGap;
      layerH = Math.max(layerH, boxes[i].h);
    }
    width = Math.max(width, x - o.columnGap);
    height = Math.max(height, y + layerH);
    y += layerH + o.layerGap;
  }
  const edges = [];
  for (const fk of schema.foreignKeys) {
    const from = boxes[index.get(fk.fromTable)], to = boxes[index.get(fk.toTable)];
    if (!from || !to) continue;
    const x1 = to.x + Math.round(to.w / 2), y1 = to.y + to.h;
    const x2 = from.x + Math.round(from.w / 2), y2 = from.y;
    const midY = Math.round((y1 + y2) / 2);
    edges.push({ fk, path: `M ${x1} ${y1} V ${midY} H ${x2} V ${y2}`, label: `${fk.fromColumn} → ${fk.toColumn}` });
  }
  return {
    width, height, edges,
    tables: schema.tables.map((table, i) => ({
      table, box: boxes[i],
      titleBar: { x: boxes[i].x, y: boxes[i].y, w: boxes[i].w, h: o.rowHeight },
      columnRows: table.columns.map((_, ci) => ({
        x: boxes[i].x, y: boxes[i].y + o.rowHeight * (1 + ci), w: boxes[i].w, h: o.rowHeight })),
    })),
  };
}

const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

function renderSvg(geo) {
  const out = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${geo.width} ${geo.height}" class="sv-root" role="img"><title>Schema diagram</title>`];
  const boxOf = new Map(geo.tables.map((t) => [t.table.name, t.box]));
  for (const e of geo.edges) {
    const from = boxOf.get(e.fk.fromTable);
    if (!from) continue;
    const ax = from.x + Math.round(from.w / 2);
    out.push(`<path class="sv-edge" d="${e.path}"/><polygon class="sv-arrow" points="${ax - 5},${from.y - 8} ${ax + 5},${from.y - 8} ${ax},${from.y}"/>`);
  }
  for (const t of geo.tables) {
    out.push(`<g class="sv-table"><rect class="sv-box" x="${t.box.x}" y="${t.box.y}" width="${t.box.w}" height="${t.box.h}" rx="6"/>` +
      `<rect class="sv-titlebar" x="${t.titleBar.x}" y="${t.titleBar.y}" width="${t.titleBar.w}" height="${t.titleBar.h}" rx="6"/>` +
      `<text class="sv-title" x="${t.box.x + 8}" y="${t.titleBar.y + 17}">${esc(t.table.name)}</text>`);
    t.table.columns.forEach((c, ci) => {
      out.push(`<text class="${c.isPrimaryKey ? 'sv-pk' : 'sv-col'}" x="${t.box.x + 8}" y="${t.columnRows[ci].y + 17}">${esc(c.name)} ${esc(c.type)}</text>`);
    });
    out.push('</g>');
  }
  out.push('</svg>');
  return out.join('');
}

function ddlToSvg(ddl) {
  const schema = parseDdl(ddl);
  return { svg: renderSvg(layoutSchema(schema)), schema };
}

// Example:
// ddlToSvg('CREATE TABLE users (id INT PRIMARY KEY);' +
//          'CREATE TABLE posts (id INT PRIMARY KEY, user_id INT REFERENCES users(id), title TEXT);')
// → users box on layer 0, posts below, one FK edge — byte-identical to the
//   TS/Go/… ports (integer geometry, same defaults).

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →