Skip to content

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 += "&amp;"
        case "<": out += "&lt;"
        case ">": out += "&gt;"
        case "\"": out += "&quot;"
        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 →