ER/Schema Visualizer — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! schema-visualizer — pure CREATE TABLE DDL → layered ER diagram as SVG.
//!
//! Language: Zig (0.16, standard library only)
//! Source: CosmoDev polyglot showcase port of the `schema-visualizer` tool;
//! canonical TypeScript lives in src/lib/schema-visualizer.ts and the
//! verified ports in src/tool-sources/schema-visualizer/{javascript.js,
//! python.py, ruby.rb, php.php} + cli/schema-visualizer (Go twin).
//! License: display source — part of CosmoDev's polyglot tool pages.
//!
//! Tolerant common subset of Postgres/MySQL/SQLite: unparseable statements
//! degrade to notes, never error. Geometry is integer-only (half-up rounding,
//! JS Math.round parity), so this port draws the byte-identical diagram.
//!
//! Everything `ddlToSvg` allocates lives in one arena owned by the returned
//! `Result`; the caller frees everything with `result.deinit()` (which returns
//! the memory to the allocator passed in).
//!
//! Test: zig test zig.zig
const std = @import("std");
const Allocator = std.mem.Allocator;
/// Integer layout constants shared by every port (see LAYOUT in the TS source).
const row_height: i64 = 24;
const char_width: i64 = 7;
const padding: i64 = 8;
const layer_gap: i64 = 60;
const column_gap: i64 = 40;
/// Word tokens that end a column's type and start its modifiers.
const modifiers = [_][]const u8{
"NOT", "NULL", "PRIMARY", "KEY", "UNIQUE",
"DEFAULT", "REFERENCES", "AUTO_INCREMENT", "AUTOINCREMENT", "ON",
"COMMENT", "CHECK", "CONSTRAINT",
};
// ---------------------------------------------------------------------------
// Data model (mirrors the TS `Schema`, snake_case per Zig convention)
// ---------------------------------------------------------------------------
pub const Column = struct {
name: []const u8,
type: []const u8,
nullable: bool = true,
is_primary_key: bool = false,
};
pub const Table = struct {
name: []const u8,
columns: []const Column = &.{},
};
/// FK as emitted after resolution: `to_column` is always set.
pub const ForeignKey = struct {
from_table: []const u8,
from_column: []const u8,
to_table: []const u8,
to_column: []const u8,
};
/// FK as parsed: `to_column` may still be null (resolved to the target
/// table's first primary key, or `id`, after the whole DDL is parsed).
const RawFk = struct {
from_table: []const u8,
from_column: []const u8,
to_table: []const u8,
to_column: ?[]const u8,
};
pub const Schema = struct {
tables: []const Table = &.{},
foreign_keys: []const ForeignKey = &.{},
notes: []const []const u8 = &.{},
};
// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------
const TokenKind = enum { word, string, qident, punct };
const Token = struct {
kind: TokenKind,
/// Word/punct text aliases the input; quoted text is arena-owned with the
/// quotes stripped and SQL '' escapes folded to '.
text: []const u8,
};
/// Token at index i, or null past the end — the role `_g(toks, i)` plays in
/// the Python port.
fn g(toks: []const Token, i: usize) ?Token {
return if (i < toks.len) toks[i] else null;
}
fn isP(tok: ?Token, p: u8) bool {
const t = tok orelse return false;
return t.kind == .punct and t.text.len == 1 and t.text[0] == p;
}
/// Case-insensitive keyword test — `tok.text.toUpperCase() === w` in JS.
fn kw(tok: ?Token, w: []const u8) bool {
const t = tok orelse return false;
return t.kind == .word and std.ascii.eqlIgnoreCase(t.text, w);
}
fn isModifier(text: []const u8) bool {
for (modifiers) |m| if (std.ascii.eqlIgnoreCase(text, m)) return true;
return false;
}
fn isBlank(s: []const u8) bool {
for (s) |c| if (!std.ascii.isWhitespace(c)) return false;
return true;
}
/// Split on `;` outside strings/quoted identifiers. Statements are plain
/// substrings of the input (quotes are copied verbatim). Depth-agnostic: an
/// unterminated paren cannot swallow the statements after it.
fn splitStatements(arena: Allocator, ddl: []const u8) error{OutOfMemory}![]const []const u8 {
var out: std.ArrayList([]const u8) = .empty;
var start: usize = 0;
var i: usize = 0;
while (i < ddl.len) {
const ch = ddl[i];
if (ch == '\'' or ch == '"' or ch == '`' or ch == '[') {
const close: u8 = if (ch == '[') ']' else ch;
i += 1;
while (i < ddl.len) {
if (ddl[i] == close) {
if (close == '\'' and i + 1 < ddl.len and ddl[i + 1] == '\'') {
i += 2; // SQL doubled-quote escape
continue;
}
break;
}
i += 1;
}
i += 1; // past the closing quote
continue;
}
if (ch == ';') {
try out.append(arena, ddl[start..i]);
start = i + 1;
i += 1;
continue;
}
i += 1;
}
if (!isBlank(ddl[start..])) try out.append(arena, ddl[start..]);
return out.toOwnedSlice(arena);
}
/// Chars that terminate a word: whitespace, quotes, and the punct set. Note
/// `]` is a terminator too — a stray `]` would loop forever in the JS/Python
/// ports, so here it is swallowed as a one-char word to guarantee progress.
fn isWordStop(c: u8) bool {
return c == '"' or c == '\'' or c == ',' or c == '(' or c == ')' or
c == '.' or c == '`' or c == '[' or c == ']';
}
/// Tokens: quoted identifiers/strings carry their text (quotes stripped);
/// ( ) , . are punct; everything else is a word.
fn tokenize(arena: Allocator, s: []const u8) error{OutOfMemory}![]Token {
var toks: std.ArrayList(Token) = .empty;
var i: usize = 0;
while (i < s.len) {
const ch = s[i];
if (std.ascii.isWhitespace(ch)) {
i += 1;
continue;
}
if (ch == '\'' or ch == '"' or ch == '`' or ch == '[') {
const close: u8 = if (ch == '[') ']' else ch;
var text: std.ArrayList(u8) = .empty;
i += 1;
while (i < s.len) {
if (s[i] == close) {
if (close == '\'' and i + 1 < s.len and s[i + 1] == '\'') {
try text.append(arena, '\'');
i += 2;
continue;
}
break;
}
try text.append(arena, s[i]);
i += 1;
}
i += 1;
try toks.append(arena, .{
.kind = if (ch == '\'') .string else .qident,
.text = try text.toOwnedSlice(arena),
});
continue;
}
if (ch == '(' or ch == ')' or ch == ',' or ch == '.') {
try toks.append(arena, .{ .kind = .punct, .text = s[i .. i + 1] });
i += 1;
continue;
}
var j = i;
while (j < s.len and !std.ascii.isWhitespace(s[j]) and !isWordStop(s[j])) j += 1;
if (j == i) j = i + 1; // stray `]` — consume one byte, see isWordStop
try toks.append(arena, .{ .kind = .word, .text = s[i..j] });
i = j;
}
return toks.toOwnedSlice(arena);
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
const NameRef = struct { name: []const u8, end: usize };
/// `name` or `schema.name` (dotted segments are joined back with '.').
fn takeName(arena: Allocator, toks: []const Token, i: usize) error{OutOfMemory}!?NameRef {
const first = g(toks, i) orelse return null;
if (first.kind != .qident and first.kind != .word) return null;
var name: std.ArrayList(u8) = .empty;
try name.appendSlice(arena, first.text);
var j = i + 1;
while (true) {
if (!isP(g(toks, j), '.')) break;
const next = g(toks, j + 1) orelse break;
if (next.kind != .qident and next.kind != .word) break;
try name.append(arena, '.');
try name.appendSlice(arena, next.text);
j += 2;
}
return NameRef{ .name = try name.toOwnedSlice(arena), .end = j };
}
const NamesRef = struct { names: []const []const u8, end: usize };
/// `( a , b , c )` → the names plus the index just past the ')'.
fn parenList(arena: Allocator, toks: []const Token, i: usize) error{OutOfMemory}!?NamesRef {
if (!isP(g(toks, i), '(')) return null;
var names: std.ArrayList([]const u8) = .empty;
var j = i + 1;
while (true) {
const n = (try takeName(arena, toks, j)) orelse return null;
try names.append(arena, n.name);
j = n.end;
if (isP(g(toks, j), ',')) {
j += 1;
continue;
}
if (isP(g(toks, j), ')'))
return NamesRef{ .names = try names.toOwnedSlice(arena), .end = j + 1 };
return null;
}
}
/// Join type tokens with spaces, then collapse `\s*([(),])\s*` to the punct
/// char, trim, and uppercase — exactly the JS `.replace(...)` chain. The
/// whitespace run is buffered so inner runs (e.g. inside quoted type text)
/// survive verbatim, like the regex leaves them.
fn joinType(arena: Allocator, toks: []const Token) error{OutOfMemory}![]const u8 {
var joined: std.ArrayList(u8) = .empty;
for (toks, 0..) |t, ti| {
if (ti > 0) try joined.append(arena, ' ');
try joined.appendSlice(arena, t.text);
}
var out: std.ArrayList(u8) = .empty;
var pending: std.ArrayList(u8) = .empty;
var prev_punct = false; // whitespace right after ( ) , is swallowed too
for (joined.items) |c| {
if (std.ascii.isWhitespace(c)) {
if (!prev_punct) try pending.append(arena, c);
continue;
}
if (c == '(' or c == ')' or c == ',') {
pending.clearRetainingCapacity();
try out.append(arena, c);
prev_punct = true;
continue;
}
if (pending.items.len > 0 and out.items.len > 0)
try out.appendSlice(arena, pending.items);
pending.clearRetainingCapacity();
try out.append(arena, c);
prev_punct = false;
}
const up = try arena.alloc(u8, out.items.len);
return std.ascii.upperString(up, out.items);
}
/// Parse one column line; unknown modifiers are tolerated and skipped.
/// Inline `REFERENCES` clauses append to `raw_fks` as a side effect.
fn parseColumn(
arena: Allocator,
line: []const Token,
table_name: []const u8,
raw_fks: *std.ArrayList(RawFk),
) ParseError!?Column {
const name = (try takeName(arena, line, 0)) orelse return null;
var i = name.end;
var type_toks: std.ArrayList(Token) = .empty;
while (i < line.len and !(line[i].kind == .word and isModifier(line[i].text))) {
try type_toks.append(arena, line[i]);
i += 1;
}
var nullable = true;
var pk = false;
while (i < line.len) {
const t = line[i];
if (kw(t, "NOT") and kw(g(line, i + 1), "NULL")) {
nullable = false;
i += 2;
continue;
}
if (kw(t, "NULL")) {
i += 1;
continue;
}
if (kw(t, "PRIMARY") and kw(g(line, i + 1), "KEY")) {
pk = true;
nullable = false;
i += 2;
continue;
}
if (kw(t, "UNIQUE") or kw(t, "AUTO_INCREMENT") or kw(t, "AUTOINCREMENT")) {
i += 1;
continue;
}
if (kw(t, "DEFAULT")) {
i += 1;
if (isP(g(line, i), '(')) {
// Skip the balanced paren group (do-while in the JS port).
var d: i64 = 0;
while (true) {
if (isP(line[i], '(')) d += 1;
if (isP(line[i], ')')) d -= 1;
i += 1;
if (!(i < line.len and d > 0)) break;
}
} else if (i < line.len) {
i += 1; // DEFAULT <one token>
}
continue;
}
if (kw(t, "COMMENT")) {
i += 1;
if (g(line, i)) |nt| {
if (nt.kind == .string) i += 1;
}
continue;
}
if (kw(t, "ON")) {
i += 2; // ON UPDATE / ON DELETE ...
if (kw(g(line, i), "SET") or kw(g(line, i), "NO")) {
i += 2; // ... SET NULL / NO ACTION
} else if (i < line.len) {
i += 1; // ON <something else>
}
continue;
}
if (kw(t, "REFERENCES")) {
i += 1;
if (try takeName(arena, line, i)) |target| {
i = target.end;
var to_col: ?[]const u8 = null;
if (isP(g(line, i), '(')) {
if (try parenList(arena, line, i)) |lst| {
to_col = lst.names[0];
i = lst.end;
}
}
try raw_fks.append(arena, .{
.from_table = table_name,
.from_column = name.name,
.to_table = target.name,
.to_column = to_col,
});
}
continue;
}
i += 1; // unknown modifier tolerated
}
return Column{
.name = name.name,
.type = try joinType(arena, type_toks.items),
.nullable = nullable,
.is_primary_key = pk,
};
}
const ParseError = error{ Unparseable, OutOfMemory };
/// One `CREATE TABLE` statement: throws (returns error.Unparseable) exactly
/// where the JS port throws, degrades the rest to notes.
fn parseStatement(
arena: Allocator,
toks: []const Token,
tables: *std.ArrayList(Table),
raw_fks: *std.ArrayList(RawFk),
notes: *std.ArrayList([]const u8),
) ParseError!void {
var i: usize = 0;
if (!kw(g(toks, i), "CREATE")) return error.Unparseable;
i += 1;
while (kw(g(toks, i), "TEMP") or kw(g(toks, i), "TEMPORARY") or kw(g(toks, i), "UNLOGGED")) i += 1;
if (!kw(g(toks, i), "TABLE")) {
try notes.append(arena, "Skipped non-table statement.");
return;
}
i += 1;
if (kw(g(toks, i), "IF") and kw(g(toks, i + 1), "NOT") and kw(g(toks, i + 2), "EXISTS")) i += 3;
const name = (try takeName(arena, toks, i)) orelse return error.Unparseable;
if (!isP(g(toks, name.end), '(')) return error.Unparseable;
i = name.end + 1;
// Body = tokens until the matching close paren (depth-aware).
var body: std.ArrayList(Token) = .empty;
var depth: i64 = 0;
while (i < toks.len) : (i += 1) {
if (isP(toks[i], '(')) depth += 1;
if (isP(toks[i], ')')) {
if (depth == 0) break;
depth -= 1;
}
try body.append(arena, toks[i]);
}
if (i >= toks.len) return error.Unparseable; // unterminated
// Split the body into top-level comma lines (depth is 0 at the break).
var lines: std.ArrayList([]const Token) = .empty;
var line: std.ArrayList(Token) = .empty;
depth = 0;
for (body.items) |t| {
if (isP(t, '(')) depth += 1;
if (isP(t, ')')) depth -= 1;
if (isP(t, ',') and depth == 0) {
try lines.append(arena, try line.toOwnedSlice(arena));
line = .empty;
continue;
}
try line.append(arena, t);
}
if (line.items.len > 0) try lines.append(arena, try line.toOwnedSlice(arena));
// The table joins the list before its lines are parsed so that
// self-referencing FKs resolve later.
try tables.append(arena, .{ .name = name.name });
const table = &tables.items[tables.items.len - 1];
var cols: std.ArrayList(Column) = .empty;
for (lines.items) |toks2| {
if (toks2.len == 0) continue;
const first = toks2[0];
if (first.kind == .word and kw(first, "PRIMARY") and kw(g(toks2, 1), "KEY")) {
if (try parenList(arena, toks2, 2)) |lst| {
for (lst.names) |cn| {
for (cols.items) |*c| {
if (std.mem.eql(u8, c.name, cn)) {
c.is_primary_key = true;
c.nullable = false;
}
}
}
}
continue;
}
if (first.kind == .word and kw(first, "FOREIGN") and kw(g(toks2, 1), "KEY")) {
if (try parenList(arena, toks2, 2)) |frm| {
if (kw(g(toks2, frm.end), "REFERENCES")) {
if (try takeName(arena, toks2, frm.end + 1)) |target| {
var to_cols: ?[]const []const u8 = null;
if (isP(g(toks2, target.end), '(')) {
if (try parenList(arena, toks2, target.end)) |to| to_cols = to.names;
}
for (frm.names, 0..) |fc, idx| {
var to_col: ?[]const u8 = null;
if (to_cols) |tc|
to_col = if (idx < tc.len) tc[idx] else tc[tc.len - 1];
try raw_fks.append(arena, .{
.from_table = table.name,
.from_column = fc,
.to_table = target.name,
.to_column = to_col,
});
}
}
}
}
continue;
}
if (first.kind == .word and (kw(first, "UNIQUE") or kw(first, "KEY") or
kw(first, "INDEX") or kw(first, "CHECK") or kw(first, "EXCLUDE") or
kw(first, "CONSTRAINT"))) continue;
if (try parseColumn(arena, toks2, table.name, raw_fks)) |col|
try cols.append(arena, col);
}
table.columns = try cols.toOwnedSlice(arena);
}
/// Parse DDL text into tables, foreign keys (targets resolved), and notes.
/// Never fails on bad input — the worst outcome is a note.
pub fn parseDdl(arena: Allocator, ddl: []const u8) error{OutOfMemory}!Schema {
if (isBlank(ddl))
return .{ .notes = &.{"No DDL input."} };
var tables: std.ArrayList(Table) = .empty;
var raw_fks: std.ArrayList(RawFk) = .empty;
var notes: std.ArrayList([]const u8) = .empty;
for (try splitStatements(arena, ddl)) |stmt| {
if (isBlank(stmt)) continue;
const toks = try tokenize(arena, stmt);
parseStatement(arena, toks, &tables, &raw_fks, ¬es) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.Unparseable => try notes.append(arena, "Skipped unparseable statement."),
};
}
// Resolve omitted FK target columns to the referenced table's first PK.
const fks = try arena.alloc(ForeignKey, raw_fks.items.len);
for (raw_fks.items, 0..) |raw, fi| {
var to_col: ?[]const u8 = raw.to_column;
if (to_col == null) {
to_col = "id";
outer: for (tables.items) |t| {
if (std.mem.eql(u8, t.name, raw.to_table)) {
for (t.columns) |c| {
if (c.is_primary_key) {
to_col = c.name;
break;
}
}
break :outer;
}
}
}
fks[fi] = .{
.from_table = raw.from_table,
.from_column = raw.from_column,
.to_table = raw.to_table,
.to_column = to_col.?,
};
}
return .{
.tables = try tables.toOwnedSlice(arena),
.foreign_keys = fks,
.notes = try notes.toOwnedSlice(arena),
};
}
// ---------------------------------------------------------------------------
// Layout — integer geometry, half-up rounding (JS Math.round parity)
// ---------------------------------------------------------------------------
pub const Box = struct { x: i64 = 0, y: i64 = 0, w: i64, h: i64 };
pub const Row = struct { x: i64, y: i64, w: i64, h: i64 };
pub const LaidTable = struct {
table: Table,
box: Box,
title_bar: Box,
column_rows: []const Row,
};
pub const Edge = struct {
fk: ForeignKey,
/// SVG path data, e.g. "M 29 56 V 86 H 47 V 116".
path: []const u8,
/// "from_col → to_col" (kept for parity; not rendered).
label: []const u8,
};
pub const Geometry = struct {
width: i64,
height: i64,
tables: []const LaidTable = &.{},
edges: []const Edge = &.{},
};
/// v / 2 rounded half-up for v >= 0 — the only rounding the JS port's
/// Math.round ever does here (w/2 and (y1+y2)/2 of non-negative integers).
fn halfUpDiv2(v: i64) i64 {
return @divTrunc(v + 1, 2);
}
/// Longest-path layering (relaxation, at most n passes), then pack each
/// layer left-to-right and stack layers top-to-bottom.
pub fn layoutSchema(arena: Allocator, schema: Schema) error{OutOfMemory}!Geometry {
if (schema.tables.len == 0) return .{ .width = 0, .height = 0 };
const index = try buildIndex(arena, schema.tables);
const boxes = try arena.alloc(Box, schema.tables.len);
for (schema.tables, 0..) |t, i| {
var text_len: i64 = @intCast(t.name.len);
for (t.columns) |c|
text_len = @max(text_len, @as(i64, @intCast(c.name.len)) + 1 + @as(i64, @intCast(c.type.len)));
text_len = @max(text_len, 1);
// w/h are exact integers — Math.round is the identity here.
boxes[i] = .{
.w = text_len * char_width + 2 * padding,
.h = row_height * (1 + @as(i64, @intCast(t.columns.len))) + padding,
};
}
const layer_of = try arena.alloc(i64, schema.tables.len);
@memset(layer_of, 0);
var pass: usize = 0;
while (pass < schema.tables.len) : (pass += 1) {
var changed = false;
for (schema.foreign_keys) |fk| {
const ti = index.get(fk.from_table) orelse continue;
const tj = index.get(fk.to_table) orelse continue;
if (ti == tj) continue;
if (layer_of[ti] < layer_of[tj] + 1) {
layer_of[ti] = layer_of[tj] + 1;
changed = true;
}
}
if (!changed) break;
}
var layers: std.ArrayList(std.ArrayList(usize)) = .empty;
for (layer_of, 0..) |l, i| {
while (layers.items.len <= @as(usize, @intCast(l)))
try layers.append(arena, .empty);
try layers.items[@intCast(l)].append(arena, i);
}
var y: i64 = 0;
var width: i64 = 0;
var height: i64 = 0;
for (layers.items) |layer| {
var x: i64 = 0;
var layer_h: i64 = 0;
for (layer.items) |i| {
boxes[i].x = x;
boxes[i].y = y;
x += boxes[i].w + column_gap;
layer_h = @max(layer_h, boxes[i].h);
}
width = @max(width, x - column_gap);
height = @max(height, y + layer_h);
y += layer_h + layer_gap;
}
var edges: std.ArrayList(Edge) = .empty;
for (schema.foreign_keys) |fk| {
const fi = index.get(fk.from_table) orelse continue;
const ti = index.get(fk.to_table) orelse continue;
const from = boxes[fi];
const to = boxes[ti];
const x1 = to.x + halfUpDiv2(to.w);
const y1 = to.y + to.h;
const x2 = from.x + halfUpDiv2(from.w);
const y2 = from.y;
const mid_y = halfUpDiv2(y1 + y2);
try edges.append(arena, .{
.fk = fk,
.path = try std.fmt.allocPrint(
arena,
"M {d} {d} V {d} H {d} V {d}",
.{ x1, y1, mid_y, x2, y2 },
),
.label = try std.fmt.allocPrint(
arena,
"{s} → {s}",
.{ fk.from_column, fk.to_column },
),
});
}
var laid: std.ArrayList(LaidTable) = .empty;
for (schema.tables, 0..) |t, i| {
const rows = try arena.alloc(Row, t.columns.len);
for (t.columns, 0..) |_, ci| {
rows[ci] = .{
.x = boxes[i].x,
.y = boxes[i].y + row_height * (1 + @as(i64, @intCast(ci))),
.w = boxes[i].w,
.h = row_height,
};
}
try laid.append(arena, .{
.table = t,
.box = boxes[i],
.title_bar = .{ .x = boxes[i].x, .y = boxes[i].y, .w = boxes[i].w, .h = row_height },
.column_rows = rows,
});
}
return .{
.width = width,
.height = height,
.tables = try laid.toOwnedSlice(arena),
.edges = try edges.toOwnedSlice(arena),
};
}
/// table name → first index (setdefault semantics, like the other ports).
fn buildIndex(arena: Allocator, tables: []const Table) error{OutOfMemory}!std.StringHashMap(usize) {
var index = std.StringHashMap(usize).init(arena);
for (tables, 0..) |t, i| {
if (!index.contains(t.name)) try index.put(t.name, i);
}
return index;
}
// ---------------------------------------------------------------------------
// SVG rendering — byte-identical to the TS/Go/JS/Python/Ruby/PHP ports
// ---------------------------------------------------------------------------
/// XML-escape the four characters that matter inside attribute-free text.
fn esc(arena: Allocator, s: []const u8) error{OutOfMemory}![]const u8 {
var out: std.ArrayList(u8) = .empty;
for (s) |c| {
const rep: ?[]const u8 = switch (c) {
'&' => "&",
'<' => "<",
'>' => ">",
'"' => """,
else => null,
};
if (rep) |r| try out.appendSlice(arena, r) else try out.append(arena, c);
}
return out.items;
}
pub fn renderSvg(arena: Allocator, geo: Geometry) error{OutOfMemory}![]const u8 {
var out: std.ArrayList(u8) = .empty;
try out.appendSlice(arena, try std.fmt.allocPrint(
arena,
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {d} {d}\" " ++
"class=\"sv-root\" role=\"img\"><title>Schema diagram</title>",
.{ geo.width, geo.height },
));
// Name → box, last duplicate wins (JS Map / Python dict semantics).
var box_of = std.StringHashMap(Box).init(arena);
for (geo.tables) |t| try box_of.put(t.table.name, t.box);
for (geo.edges) |e| {
const from = box_of.get(e.fk.from_table) orelse continue;
const ax = from.x + halfUpDiv2(from.w);
try out.appendSlice(arena, try std.fmt.allocPrint(
arena,
"<path class=\"sv-edge\" d=\"{s}\"/>" ++
"<polygon class=\"sv-arrow\" points=\"{d},{d} {d},{d} {d},{d}\"/>",
.{ e.path, ax - 5, from.y - 8, ax + 5, from.y - 8, ax, from.y },
));
}
for (geo.tables) |t| {
const b = t.box;
const tb = t.title_bar;
try out.appendSlice(arena, try std.fmt.allocPrint(
arena,
"<g class=\"sv-table\"><rect class=\"sv-box\" x=\"{d}\" y=\"{d}\" width=\"{d}\" height=\"{d}\" rx=\"6\"/>" ++
"<rect class=\"sv-titlebar\" x=\"{d}\" y=\"{d}\" width=\"{d}\" height=\"{d}\" rx=\"6\"/>" ++
"<text class=\"sv-title\" x=\"{d}\" y=\"{d}\">{s}</text>",
.{ b.x, b.y, b.w, b.h, tb.x, tb.y, tb.w, tb.h, b.x + 8, tb.y + 17, try esc(arena, t.table.name) },
));
for (t.table.columns, t.column_rows) |c, row| {
try out.appendSlice(arena, try std.fmt.allocPrint(
arena,
"<text class=\"{s}\" x=\"{d}\" y=\"{d}\">{s} {s}</text>",
.{
if (c.is_primary_key) "sv-pk" else "sv-col",
row.x + 8,
row.y + 17,
try esc(arena, c.name),
try esc(arena, c.type),
},
));
}
try out.appendSlice(arena, "</g>");
}
try out.appendSlice(arena, "</svg>");
return out.items;
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
/// The parse + layout + render result. `deinit` frees the SVG, the schema,
/// and every scratch allocation (one arena over the caller's allocator).
pub const Result = struct {
svg: []const u8,
schema: Schema,
arena: std.heap.ArenaAllocator,
pub fn deinit(self: *Result) void {
self.arena.deinit();
}
};
/// Pure function: DDL text → { svg, schema }. Never fails on bad input.
pub fn ddlToSvg(allocator: Allocator, ddl: []const u8) error{OutOfMemory}!Result {
var arena = std.heap.ArenaAllocator.init(allocator);
errdefer arena.deinit();
const a = arena.allocator();
const schema = try parseDdl(a, ddl);
const svg = try renderSvg(a, try layoutSchema(a, schema));
return .{ .svg = svg, .schema = schema, .arena = arena };
}
// ---------------------------------------------------------------------------
// Tests — expected SVGs generated with the verified python.py port, so any
// divergence here means the byte-identical contract broke.
// ---------------------------------------------------------------------------
const testing = std.testing;
const example_svg =
\\<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93 220" class="sv-root" role="img"><title>Schema diagram</title><path class="sv-edge" d="M 29 56 V 86 H 47 V 116"/><polygon class="sv-arrow" points="42,108 52,108 47,116"/><g class="sv-table"><rect class="sv-box" x="0" y="0" width="58" height="56" rx="6"/><rect class="sv-titlebar" x="0" y="0" width="58" height="24" rx="6"/><text class="sv-title" x="8" y="17">users</text><text class="sv-pk" x="8" y="41">id INT</text></g><g class="sv-table"><rect class="sv-box" x="0" y="116" width="93" height="104" rx="6"/><rect class="sv-titlebar" x="0" y="116" width="93" height="24" rx="6"/><text class="sv-title" x="8" y="133">posts</text><text class="sv-pk" x="8" y="157">id INT</text><text class="sv-col" x="8" y="181">user_id INT</text><text class="sv-col" x="8" y="205">title TEXT</text></g></svg>
;
test "two-table example is byte-identical to the TS/Go/JS/Py ports" {
var r = try ddlToSvg(testing.allocator,
"CREATE TABLE users (id INT PRIMARY KEY);" ++
"CREATE TABLE posts (id INT PRIMARY KEY, user_id INT REFERENCES users(id), title TEXT);");
defer r.deinit();
try testing.expectEqualStrings(example_svg, r.svg);
try testing.expectEqual(@as(usize, 2), r.schema.tables.len);
try testing.expectEqual(@as(usize, 1), r.schema.foreign_keys.len);
try testing.expectEqualStrings("user_id", r.schema.foreign_keys[0].from_column);
try testing.expectEqualStrings("id", r.schema.foreign_keys[0].to_column);
}
test "blank DDL degrades to a note on an empty canvas" {
var r = try ddlToSvg(testing.allocator, " ");
defer r.deinit();
try testing.expectEqual(@as(usize, 1), r.schema.notes.len);
try testing.expectEqualStrings("No DDL input.", r.schema.notes[0]);
try testing.expectEqualStrings(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 0 0\" " ++
"class=\"sv-root\" role=\"img\"><title>Schema diagram</title></svg>",
r.svg,
);
}
test "skipped statements, PK constraints, FK resolution, layered layout" {
var r = try ddlToSvg(testing.allocator,
"CREATE INDEX idx ON t(c);" ++
"CREATE TABLE orders (oid BIGINT, customer_id INT REFERENCES customers, " ++
"note TEXT NOT NULL, amount NUMERIC( 10 , 2 ) DEFAULT 0, PRIMARY KEY (oid, note));" ++
"CREATE TABLE customers (id INT PRIMARY KEY, name VARCHAR(40));" ++
"CREATE BOGUS;" ++
"CREATE TABLE bad (unclosed;");
defer r.deinit();
try testing.expectEqual(@as(usize, 3), r.schema.notes.len);
// orders is defined before customers but FK layering puts it below.
try testing.expectEqualStrings("orders", r.schema.tables[0].name);
try testing.expect(r.schema.tables[0].columns[2].is_primary_key); // note TEXT via PK constraint
try testing.expectEqualStrings("NUMERIC(10,2)", r.schema.tables[0].columns[3].type);
// FK without target column resolves to the target's first PK.
try testing.expectEqualStrings("id", r.schema.foreign_keys[0].to_column);
try testing.expectEqualStrings(messy_svg, r.svg);
}
const messy_svg =
\\<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 156 268" class="sv-root" role="img"><title>Schema diagram</title><path class="sv-edge" d="M 64 80 V 110 H 78 V 140"/><polygon class="sv-arrow" points="73,132 83,132 78,140"/><g class="sv-table"><rect class="sv-box" x="0" y="140" width="156" height="128" rx="6"/><rect class="sv-titlebar" x="0" y="140" width="156" height="24" rx="6"/><text class="sv-title" x="8" y="157">orders</text><text class="sv-pk" x="8" y="181">oid BIGINT</text><text class="sv-col" x="8" y="205">customer_id INT</text><text class="sv-pk" x="8" y="229">note TEXT</text><text class="sv-col" x="8" y="253">amount NUMERIC(10,2)</text></g><g class="sv-table"><rect class="sv-box" x="0" y="0" width="128" height="80" rx="6"/><rect class="sv-titlebar" x="0" y="0" width="128" height="24" rx="6"/><text class="sv-title" x="8" y="17">customers</text><text class="sv-pk" x="8" y="41">id INT</text><text class="sv-col" x="8" y="65">name VARCHAR(40)</text></g></svg>
;
test "quoted names escape for XML; DEFAULT()/ON/COMMENT tolerated" {
var r = try ddlToSvg(testing.allocator,
"CREATE TABLE \"weird & <name>\" (id INT PRIMARY KEY, meta JSON DEFAULT ('{}'::jsonb), " ++
"tag VARCHAR(9) COMMENT 'hi' ON UPDATE CASCADE);" ++
"CREATE TABLE child (a INT, b INT, FOREIGN KEY (a, b) REFERENCES \"weird & <name>\" (id, nope));");
defer r.deinit();
try testing.expect(r.schema.notes.len == 0);
// multi-column FK: positional mapping a→id, b→nope
try testing.expectEqualStrings("id", r.schema.foreign_keys[0].to_column);
try testing.expectEqualStrings("nope", r.schema.foreign_keys[1].to_column);
try testing.expect(std.mem.indexOf(u8, r.svg, ">weird & <name></text>") != null);
try testing.expect(std.mem.indexOf(u8, r.svg, ">tag VARCHAR(9)</text>") != null);
}
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 →