ER/Schema Visualizer — Swift 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 Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.
// schema-visualizer — pure CREATE TABLE DDL → layered ER diagram as SVG.
// Swift 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
// (half-up rounding — JS Math.round parity), so every port draws the
// byte-identical diagram.
import Foundation
struct ColumnDef { var name: String; var type_: String; var nullable: Bool; var isPrimaryKey: Bool }
struct ForeignKey { var fromTable: String; var fromColumn: String; var toTable: String; var toColumn: String? }
struct TableDef { var name: String; var columns: [ColumnDef] }
struct ParsedSchema { var tables: [TableDef]; var foreignKeys: [ForeignKey]; var notes: [String] }
struct Box { var x: Int; var y: Int; var w: Int; var h: Int }
struct LaidOutTable { var table: TableDef; var box: Box; var titleBar: Box; var columnRows: [Box] }
struct Edge { var fk: ForeignKey; var path: String; var label: String }
struct Geometry { var width: Int; var height: Int; var tables: [LaidOutTable]; var edges: [Edge] }
let LAYOUT = (rowHeight: 24, charWidth: 7, padding: 8, layerGap: 60, columnGap: 40)
let MODIFIERS: Set<String> = ["NOT", "NULL", "PRIMARY", "KEY", "UNIQUE", "DEFAULT", "REFERENCES",
"AUTO_INCREMENT", "AUTOINCREMENT", "ON", "COMMENT", "CHECK", "CONSTRAINT"]
let QUOTES: [Character: Character] = ["'": "'", "\"": "\"", "`": "`", "[": "]"]
func rHalfUp(_ v: Double) -> Int { Int(v + 0.5) } // JS Math.round parity
enum TokenKind { case word, qident, string, punct }
struct Token { var kind: TokenKind; var text: String }
// Split on `;` outside strings/quoted identifiers (depth-agnostic: an
// unterminated paren cannot swallow the statements after it).
func splitStatements(_ ddl: String) -> [String] {
var out: [String] = []
var cur = ""
let chars = Array(ddl)
var i = 0
let n = chars.count
while i < n {
let ch = chars[i]
if let close = QUOTES[ch] {
cur.append(ch)
i += 1
while i < n {
cur.append(chars[i])
if chars[i] == close {
if close == "'" && i + 1 < n && chars[i + 1] == "'" {
cur.append(chars[i + 1])
i += 2
continue
}
break
}
i += 1
}
i += 1
continue
}
if ch == ";" { out.append(cur); cur = ""; i += 1; continue }
cur.append(ch)
i += 1
}
if !cur.trimmingCharacters(in: .whitespaces).isEmpty { out.append(cur) }
return out
}
func tokenize(_ s: String) -> [Token] {
var toks: [Token] = []
let chars = Array(s)
var i = 0
let n = chars.count
while i < n {
let ch = chars[i]
if ch.isWhitespace { i += 1; continue }
if let close = QUOTES[ch] {
var text = ""
i += 1
while i < n {
if chars[i] == close {
if close == "'" && i + 1 < n && chars[i + 1] == "'" {
text.append("'")
i += 2
continue
}
break
}
text.append(chars[i])
i += 1
}
i += 1
toks.append(Token(kind: ch == "'" ? .string : .qident, text: text))
continue
}
if ch == "(" || ch == ")" || ch == "," || ch == "." {
toks.append(Token(kind: .punct, text: String(ch)))
i += 1
continue
}
var word = ""
while i < n {
let c = chars[i]
if c.isWhitespace || c == "'" || c == "\"" || c == "," || c == "(" || c == ")" || c == "." || c == "`" || c == "[" || c == "]" { break }
word.append(c)
i += 1
}
toks.append(Token(kind: .word, text: word))
}
return toks
}
func isP(_ t: Token?, _ p: String) -> Bool { t?.kind == .punct && t?.text == p }
func kw(_ t: Token?, _ w: String) -> Bool { t?.kind == .word && t?.text.uppercased() == w }
func g(_ toks: [Token], _ i: Int) -> Token? { i < toks.count ? toks[i] : nil }
func takeName(_ toks: [Token], _ i: Int) -> (String, Int)? {
guard let first = g(toks, i), first.kind == .qident || first.kind == .word else { return nil }
var name = first.text
var j = i + 1
while isP(g(toks, j), "."),
let nxt = g(toks, j + 1), nxt.kind == .qident || nxt.kind == .word {
name += "." + nxt.text
j += 2
}
return (name, j)
}
func parenList(_ toks: [Token], _ i: Int) -> ([String], Int)? {
guard isP(g(toks, i), "(") else { return nil }
var names: [String] = []
var j = i + 1
while true {
guard let name = takeName(toks, j) else { return nil }
names.append(name.0)
j = name.1
if isP(g(toks, j), ",") { j += 1; continue }
if isP(g(toks, j), ")") { return (names, j + 1) }
return nil
}
}
func joinType(_ toks: [Token]) -> String {
let raw = toks.map { $0.text }.joined(separator: " ")
return raw
.replacingOccurrences(of: " ( ", with: "(")
.replacingOccurrences(of: " (", with: "(")
.replacingOccurrences(of: "( ", with: "(")
.replacingOccurrences(of: " ) ", with: ")")
.replacingOccurrences(of: " )", with: ")")
.replacingOccurrences(of: ") ", with: ")")
.trimmingCharacters(in: .whitespaces)
.uppercased()
}
func parseColumn(_ line: [Token], _ tableName: String, _ fks: inout [ForeignKey]) -> ColumnDef? {
guard let name = takeName(line, 0) else { return nil }
var i = name.1
var typeToks: [Token] = []
while i < line.count, !(line[i].kind == .word && MODIFIERS.contains(line[i].text.uppercased())) {
typeToks.append(line[i])
i += 1
}
var nullable = true
var pk = false
while i < line.count {
let t = line[i]
if kw(t, "NOT") && kw(g(line, i + 1), "NULL") { nullable = false; i += 2; continue }
if kw(t, "NULL") { i += 1; continue }
if kw(t, "PRIMARY") && kw(g(line, i + 1), "KEY") { pk = true; nullable = false; i += 2; continue }
if kw(t, "UNIQUE") || kw(t, "AUTO_INCREMENT") || kw(t, "AUTOINCREMENT") { i += 1; continue }
if kw(t, "DEFAULT") {
i += 1
if isP(g(line, i), "(") {
var depth = 0
while i < line.count {
if isP(line[i], "(") { depth += 1 }
if isP(line[i], ")") { depth -= 1 }
i += 1
if depth == 0 { break }
}
} else if i < line.count { i += 1 }
continue
}
if kw(t, "COMMENT") {
i += 1
if g(line, i)?.kind == .string { i += 1 }
continue
}
if kw(t, "ON") {
i += 2
if kw(g(line, i), "SET") || kw(g(line, i), "NO") { i += 2 }
else if i < line.count { i += 1 }
continue
}
if kw(t, "REFERENCES") {
i += 1
if let target = takeName(line, i) {
i = target.1
var toCol: String? = nil
if isP(g(line, i), "(") {
if let list = parenList(line, i) { toCol = list.0.first; i = list.1 }
}
fks.append(ForeignKey(fromTable: tableName, fromColumn: name.0, toTable: target.0, toColumn: toCol))
}
continue
}
i += 1 // unknown modifier tolerated
}
return ColumnDef(name: name.0, type_: joinType(typeToks), nullable: nullable, isPrimaryKey: pk)
}
func parseDdl(_ ddl: String) -> ParsedSchema {
if ddl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return ParsedSchema(tables: [], foreignKeys: [], notes: ["No DDL input."])
}
var tables: [TableDef] = []
var fks: [ForeignKey] = []
var notes: [String] = []
for stmt in splitStatements(ddl) {
if stmt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue }
let toks = tokenize(stmt)
var ok = false
if kw(g(toks, 0), "CREATE") {
var i = 1
while kw(g(toks, i), "TEMP") || kw(g(toks, i), "TEMPORARY") || kw(g(toks, i), "UNLOGGED") { i += 1 }
if kw(g(toks, i), "TABLE") {
i += 1
if kw(g(toks, i), "IF") && kw(g(toks, i + 1), "NOT") && kw(g(toks, i + 2), "EXISTS") { i += 3 }
if let name = takeName(toks, i), isP(g(toks, name.1), "(") {
i = name.1 + 1
var body: [Token] = []
var depth = 0
var closed = false
while i < toks.count {
if isP(toks[i], "(") { depth += 1 }
if isP(toks[i], ")") {
if depth == 0 { closed = true; break }
depth -= 1
}
body.append(toks[i])
i += 1
}
if closed {
var lines: [[Token]] = []
var line: [Token] = []
var d2 = 0
for t in body {
if isP(t, "(") { d2 += 1 }
if isP(t, ")") { d2 -= 1 }
if isP(t, ",") && d2 == 0 { lines.append(line); line = []; continue }
line.append(t)
}
if !line.isEmpty { lines.append(line) }
var table = TableDef(name: name.0, columns: [])
for toks2 in lines {
if toks2.isEmpty { continue }
let first = toks2[0]
let u = first.kind == .word ? first.text.uppercased() : ""
if u == "PRIMARY" && kw(g(toks2, 1), "KEY") {
if let list = parenList(toks2, 2) {
for cn in list.0 {
if let idx = table.columns.firstIndex(where: { $0.name == cn }) {
table.columns[idx].isPrimaryKey = true
table.columns[idx].nullable = false
}
}
}
continue
}
if u == "FOREIGN" && kw(g(toks2, 1), "KEY") {
if let from = parenList(toks2, 2), kw(g(toks2, from.1), "REFERENCES"),
let target = takeName(toks2, from.1 + 1) {
var toCols: [String]? = nil
if isP(g(toks2, target.1), "(") {
if let to = parenList(toks2, target.1) { toCols = to.0 }
}
for (idx, fc) in from.0.enumerated() {
var toCol: String? = nil
if let tc = toCols { toCol = idx < tc.count ? tc[idx] : tc.last }
fks.append(ForeignKey(fromTable: table.name, fromColumn: fc,
toTable: target.0, toColumn: toCol))
}
}
continue
}
if ["UNIQUE", "KEY", "INDEX", "CHECK", "EXCLUDE", "CONSTRAINT"].contains(u) { continue }
if let col = parseColumn(toks2, table.name, &fks) { table.columns.append(col) }
}
tables.append(table)
ok = true
}
}
} else {
notes.append("Skipped non-table statement.")
ok = true
}
}
if !ok { notes.append("Skipped unparseable statement.") }
}
let resolved = fks.map { fk -> ForeignKey in
guard fk.toColumn == nil else { return fk }
let target = tables.first { $0.name == fk.toTable }
let pk = target?.columns.first { $0.isPrimaryKey }
return ForeignKey(fromTable: fk.fromTable, fromColumn: fk.fromColumn,
toTable: fk.toTable, toColumn: pk?.name ?? "id")
}
return ParsedSchema(tables: tables, foreignKeys: resolved, notes: notes)
}
func layoutSchema(_ schema: ParsedSchema) -> Geometry {
let o = LAYOUT
if schema.tables.isEmpty { return Geometry(width: 0, height: 0, tables: [], edges: []) }
var index: [String: Int] = [:]
for (i, t) in schema.tables.enumerated() where index[t.name] == nil { index[t.name] = i }
var boxes: [Box] = schema.tables.map { t in
var lens = [t.name.count] + t.columns.map { ("\($0.name) \($0.type_)").count }
lens.append(1)
return Box(x: 0, y: 0,
w: rHalfUp(Double(lens.max()!) * Double(o.charWidth) + 2 * Double(o.padding)),
h: rHalfUp(Double(o.rowHeight) * Double(1 + t.columns.count) + Double(o.padding)))
}
var layerOf = [Int](repeating: 0, count: schema.tables.count)
for _ in 0..<schema.tables.count {
var changed = false
for fk in schema.foreignKeys {
guard let ti = index[fk.fromTable], let tj = index[fk.toTable], ti != tj else { continue }
if layerOf[ti] < layerOf[tj] + 1 { layerOf[ti] = layerOf[tj] + 1; changed = true }
}
if !changed { break }
}
var layers: [Int: [Int]] = [:]
for (i, l) in layerOf.enumerated() { layers[l, default: []].append(i) }
var y = 0, width = 0, height = 0
for li in layers.keys.sorted() {
var x = 0, layerH = 0
for i in layers[li]! {
boxes[i].x = x
boxes[i].y = y
x += boxes[i].w + o.columnGap
layerH = max(layerH, boxes[i].h)
}
width = max(width, x - o.columnGap)
height = max(height, y + layerH)
y += layerH + o.layerGap
}
var edges: [Edge] = []
for fk in schema.foreignKeys {
guard let frm = index[fk.fromTable], let to = index[fk.toTable] else { continue }
let x1 = boxes[to].x + rHalfUp(Double(boxes[to].w) / 2)
let y1 = boxes[to].y + boxes[to].h
let x2 = boxes[frm].x + rHalfUp(Double(boxes[frm].w) / 2)
let y2 = boxes[frm].y
let midY = rHalfUp(Double(y1 + y2) / 2)
edges.append(Edge(fk: fk, path: "M \(x1) \(y1) V \(midY) H \(x2) V \(y2)",
label: "\(fk.fromColumn) → \(fk.toColumn ?? "id")"))
}
var laid: [LaidOutTable] = []
for (i, t) in schema.tables.enumerated() {
let rows: [Box] = t.columns.enumerated().map { ci, _ in
Box(x: boxes[i].x, y: boxes[i].y + o.rowHeight * (1 + ci), w: boxes[i].w, h: o.rowHeight)
}
laid.append(LaidOutTable(table: t, box: boxes[i],
titleBar: Box(x: boxes[i].x, y: boxes[i].y, w: boxes[i].w, h: o.rowHeight),
columnRows: rows))
}
return Geometry(width: width, height: height, tables: laid, edges: edges)
}
func esc(_ s: String) -> String {
var out = ""
for c in s {
switch c {
case "&": out += "&"
case "<": out += "<"
case ">": out += ">"
case "\"": out += """
default: out.append(c)
}
}
return out
}
func renderSvg(_ geo: Geometry) -> String {
var 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>"
var boxOf: [String: Box] = [:]
for t in geo.tables where boxOf[t.table.name] == nil { boxOf[t.table.name] = t.box }
for e in geo.edges {
guard let frm = boxOf[e.fk.fromTable] else { continue }
let ax = frm.x + rHalfUp(Double(frm.w) / 2)
out += "<path class=\"sv-edge\" d=\"\(e.path)\"/>"
out += "<polygon class=\"sv-arrow\" points=\"\(ax - 5),\(frm.y - 8) \(ax + 5),\(frm.y - 8) \(ax),\(frm.y)\"/>"
}
for t in geo.tables {
let b = t.box
let tb = t.titleBar
out += "<g class=\"sv-table\"><rect class=\"sv-box\" x=\"\(b.x)\" y=\"\(b.y)\" width=\"\(b.w)\" height=\"\(b.h)\" rx=\"6\"/>"
out += "<rect class=\"sv-titlebar\" x=\"\(tb.x)\" y=\"\(tb.y)\" width=\"\(tb.w)\" height=\"\(tb.h)\" rx=\"6\"/>"
out += "<text class=\"sv-title\" x=\"\(b.x + 8)\" y=\"\(tb.y + 17)\">\(esc(t.table.name))</text>"
for (ci, c) in t.table.columns.enumerated() {
let row = t.columnRows[ci]
let cls = c.isPrimaryKey ? "sv-pk" : "sv-col"
out += "<text class=\"\(cls)\" x=\"\(row.x + 8)\" y=\"\(row.y + 17)\">\(esc(c.name)) \(esc(c.type_))</text>"
}
out += "</g>"
}
out += "</svg>"
return out
}
func ddlToSvg(_ ddl: String) -> (svg: String, schema: ParsedSchema) {
let schema = parseDdl(ddl)
return (renderSvg(layoutSchema(schema)), schema)
}
// Example:
// let r = ddlToSvg("CREATE TABLE users (id INT PRIMARY KEY);" +
// "CREATE TABLE posts (id INT PRIMARY KEY, user_id INT REFERENCES users(id), title TEXT);")
// print(r.svg)
// → 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 →