ER/Schema Visualizer — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.
// schema-visualizer — pure CREATE TABLE DDL → layered ER diagram as SVG.
// Kotlin 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 kotlin.math.floor
object SchemaVisualizer {
private val LAYOUT = Layout(rowHeight = 24, charWidth = 7, padding = 8, layerGap = 60, columnGap = 40)
private data class Layout(val rowHeight: Int, val charWidth: Int, val padding: Int, val layerGap: Int, val columnGap: Int)
private val QUOTES = mapOf('\'' to '\'', '"' to '"', '`' to '`', '[' to ']')
private val PUNCT = setOf('(', ')', ',', '.')
private val WORD_STOP = PUNCT + QUOTES.keys // chars that always end a word
private val MODIFIERS = setOf(
"NOT", "NULL", "PRIMARY", "KEY", "UNIQUE", "DEFAULT", "REFERENCES",
"AUTO_INCREMENT", "AUTOINCREMENT", "ON", "COMMENT", "CHECK", "CONSTRAINT",
)
private val SKIP_WORDS = setOf("UNIQUE", "KEY", "INDEX", "CHECK", "EXCLUDE", "CONSTRAINT")
/** Sentinel used to abandon one statement without failing the whole parse. */
private object SkipStatement : RuntimeException()
// ---------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------
data class Column(val name: String, val type: String, var nullable: Boolean, var isPrimaryKey: Boolean)
data class ForeignKey(val fromTable: String, val fromColumn: String, val toTable: String, val toColumn: String?)
data class Table(val name: String, val columns: MutableList<Column>)
data class Schema(val tables: List<Table>, val foreignKeys: List<ForeignKey>, val notes: List<String>)
data class Box(var x: Int, var y: Int, val w: Int, val h: Int)
data class LaidTable(val table: Table, val box: Box, val titleBar: Box, val columnRows: List<Box>)
data class Edge(val fk: ForeignKey, val path: String, val label: String)
data class Geometry(val width: Int, val height: Int, val tables: List<LaidTable>, val edges: List<Edge>)
data class DdlResult(val svg: String, val schema: Schema)
// ---------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------
private enum class Kind { WORD, QIDENT, STRING, PUNCT }
private data class Tok(val text: String, val kind: Kind)
/** Round half up (JS Math.round parity). All geometry values are >= 0. */
private fun r(v: Double): Int = floor(v + 0.5).toInt()
/** Split on `;` outside strings/quoted identifiers (depth-agnostic: an
* unterminated paren cannot swallow the statements after it). */
private fun splitStatements(ddl: String): List<String> {
val out = mutableListOf<String>()
val cur = StringBuilder()
var i = 0
val n = ddl.length
while (i < n) {
val ch = ddl[i]
val close = QUOTES[ch]
if (close != null) {
cur.append(ch); i++
while (i < n) {
cur.append(ddl[i])
if (ddl[i] == close) {
if (close == '\'' && i + 1 < n && ddl[i + 1] == '\'') { cur.append(ddl[i + 1]); i += 2; continue }
break
}
i++
}
i++
continue
}
if (ch == ';') { out.add(cur.toString()); cur.setLength(0); i++; continue }
cur.append(ch); i++
}
if (cur.toString().trim().isNotEmpty()) out.add(cur.toString())
return out
}
/** Tokens: quoted identifiers/strings carry their text (quotes stripped);
* ( ) , . are punct; everything else is a word. */
private fun tokenize(s: String): List<Tok> {
val toks = mutableListOf<Tok>()
var i = 0
val n = s.length
while (i < n) {
val ch = s[i]
if (ch.isWhitespace()) { i++; continue }
val close = QUOTES[ch]
if (close != null) {
val text = StringBuilder()
i++
while (i < n) {
if (s[i] == close) {
if (close == '\'' && i + 1 < n && s[i + 1] == '\'') { text.append('\''); i += 2; continue }
break
}
text.append(s[i]); i++
}
i++ // consume the closer (or run off the end — tolerant)
toks.add(Tok(text.toString(), if (ch == '\'') Kind.STRING else Kind.QIDENT))
continue
}
if (ch in PUNCT) { toks.add(Tok(ch.toString(), Kind.PUNCT)); i++; continue }
val word = StringBuilder()
while (i < n && !s[i].isWhitespace() && s[i] !in WORD_STOP) { word.append(s[i]); i++ }
toks.add(Tok(word.toString(), Kind.WORD))
}
return toks
}
private fun isP(t: Tok?, p: String): Boolean = t != null && t.kind == Kind.PUNCT && t.text == p
private fun kw(t: Tok?, w: String): Boolean = t != null && t.kind == Kind.WORD && t.text.uppercase() == w
/** name ('.' name)* — returns the joined name and the next token index. */
private fun takeName(toks: List<Tok>, i: Int): Pair<String, Int>? {
val first = toks.getOrNull(i) ?: return null
if (first.kind != Kind.QIDENT && first.kind != Kind.WORD) return null
var name = first.text
var j = i + 1
while (isP(toks.getOrNull(j), ".")) {
val next = toks.getOrNull(j + 1) ?: break
if (next.kind != Kind.QIDENT && next.kind != Kind.WORD) break
name += "." + next.text
j += 2
}
return name to j
}
/** ( name (',' name)* ')' — returns the names and the index after ')'. */
private fun parenList(toks: List<Tok>, i: Int): Pair<List<String>, Int>? {
if (!isP(toks.getOrNull(i), "(")) return null
val names = mutableListOf<String>()
var j = i + 1
while (true) {
val name = takeName(toks, j) ?: return null
names.add(name.first)
j = name.second
if (isP(toks.getOrNull(j), ",")) { j++; continue }
if (isP(toks.getOrNull(j), ")")) return names to j + 1
return null
}
}
// ---------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------
/** Squeeze spaces around ( ) , inside a type, then normalize case. */
private fun joinType(toks: List<Tok>): String = toks.joinToString(" ") { it.text }
.replace(Regex("\\s*\\(\\s*"), "(")
.replace(Regex("\\s*\\)\\s*"), ")")
.replace(Regex("\\s*,\\s*"), ",")
.trim()
.uppercase()
private fun parseColumn(line: List<Tok>, tableName: String, fks: MutableList<ForeignKey>): Column? {
val name = takeName(line, 0) ?: return null
var i = name.second
val typeToks = mutableListOf<Tok>()
while (i < line.size && !(line[i].kind == Kind.WORD && line[i].text.uppercase() in MODIFIERS)) {
typeToks.add(line[i]); i++
}
val type = joinType(typeToks)
var nullable = true
var pk = false
while (i < line.size) {
val t = line[i]
if (kw(t, "NOT") && kw(line.getOrNull(i + 1), "NULL")) { nullable = false; i += 2; continue }
if (kw(t, "NULL")) { i++; continue }
if (kw(t, "PRIMARY") && kw(line.getOrNull(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.getOrNull(i), "(")) {
var d = 0
do {
if (isP(line.getOrNull(i), "(")) d++
if (isP(line.getOrNull(i), ")")) d--
i++
} while (i < line.size && d > 0)
} else if (i < line.size) i++
continue
}
if (kw(t, "COMMENT")) { i++; if (i < line.size && line[i].kind == Kind.STRING) i++; continue }
if (kw(t, "ON")) {
i += 2
if (kw(line.getOrNull(i), "SET") || kw(line.getOrNull(i), "NO")) i += 2
else if (i < line.size) i++
continue
}
if (kw(t, "REFERENCES")) {
i++
val target = takeName(line, i)
if (target != null) {
i = target.second
var toCol: String? = null
if (isP(line.getOrNull(i), "(")) {
val list = parenList(line, i)
if (list != null) { toCol = list.first.first(); i = list.second }
}
fks.add(ForeignKey(tableName, name.first, target.first, toCol))
}
continue
}
i++ // unknown modifier tolerated
}
return Column(name.first, type, nullable, pk)
}
fun parseDdl(ddl: String): Schema {
if (ddl.trim().isEmpty()) return Schema(emptyList(), emptyList(), listOf("No DDL input."))
val tables = mutableListOf<Table>()
val fks = mutableListOf<ForeignKey>()
val notes = mutableListOf<String>()
for (stmt in splitStatements(ddl)) {
if (stmt.trim().isEmpty()) continue
val toks = tokenize(stmt)
try {
var i = 0
if (!kw(toks.getOrNull(i), "CREATE")) throw SkipStatement
i++
while (kw(toks.getOrNull(i), "TEMP") || kw(toks.getOrNull(i), "TEMPORARY") || kw(toks.getOrNull(i), "UNLOGGED")) i++
if (!kw(toks.getOrNull(i), "TABLE")) { notes.add("Skipped non-table statement."); continue }
i++
if (kw(toks.getOrNull(i), "IF") && kw(toks.getOrNull(i + 1), "NOT") && kw(toks.getOrNull(i + 2), "EXISTS")) i += 3
val name = takeName(toks, i)
if (name == null || !isP(toks.getOrNull(name.second), "(")) throw SkipStatement
i = name.second + 1
// body = tokens until the matching close paren (depth-aware)
val body = mutableListOf<Tok>()
var depth = 0
while (i < toks.size) {
if (isP(toks[i], "(")) depth++
if (isP(toks[i], ")")) { if (depth == 0) break; depth-- }
body.add(toks[i]); i++
}
if (i >= toks.size) throw SkipStatement
// split body into top-level comma lines
val lines = mutableListOf<List<Tok>>()
var ln = mutableListOf<Tok>()
for (t in body) {
if (isP(t, "(")) depth++
if (isP(t, ")")) depth--
if (isP(t, ",") && depth == 0) { lines.add(ln); ln = mutableListOf(); continue }
ln.add(t)
}
if (ln.isNotEmpty()) lines.add(ln)
val table = Table(name.first, mutableListOf())
tables.add(table)
for (line in lines) {
if (line.isEmpty()) continue
val first = line[0]
val u = if (first.kind == Kind.WORD) first.text.uppercase() else ""
if (u == "PRIMARY" && kw(line.getOrNull(1), "KEY")) {
val list = parenList(line, 2)
if (list != null) for (cn in list.first) {
val col = table.columns.find { it.name == cn }
if (col != null) { col.isPrimaryKey = true; col.nullable = false }
}
continue
}
if (u == "FOREIGN" && kw(line.getOrNull(1), "KEY")) {
val from = parenList(line, 2)
if (from != null && kw(line.getOrNull(from.second), "REFERENCES")) {
val target = takeName(line, from.second + 1)
if (target != null) {
var toCols: List<String>? = null
if (isP(line.getOrNull(target.second), "(")) {
val to = parenList(line, target.second)
if (to != null) toCols = to.first
}
from.first.forEachIndexed { idx, fc ->
val toCol = if (toCols != null)
(if (idx < toCols.size) toCols[idx] else toCols.last()) else null
fks.add(ForeignKey(table.name, fc, target.first, toCol))
}
}
}
continue
}
if (u in SKIP_WORDS) continue
val col = parseColumn(line, table.name, fks)
if (col != null) table.columns.add(col)
}
} catch (_: SkipStatement) {
notes.add("Skipped unparseable statement.")
}
}
// resolve omitted FK target columns to the referenced table's first PK
val foreignKeys = fks.map { fk ->
if (fk.toColumn.isNullOrEmpty()) {
val target = tables.find { it.name == fk.toTable }
fk.copy(toColumn = target?.columns?.find { it.isPrimaryKey }?.name ?: "id")
} else fk
}
return Schema(tables, foreignKeys, notes)
}
// ---------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------
fun layoutSchema(schema: Schema): Geometry {
val o = LAYOUT
if (schema.tables.isEmpty()) return Geometry(0, 0, emptyList(), emptyList())
val index = HashMap<String, Int>()
schema.tables.forEachIndexed { i, t -> index.putIfAbsent(t.name, i) }
val boxes = schema.tables.map { t ->
val textLen = maxOf(t.name.length, t.columns.maxOfOrNull { "${it.name} ${it.type}".length } ?: 0, 1)
Box(0, 0, textLen * o.charWidth + 2 * o.padding, o.rowHeight * (1 + t.columns.size) + o.padding)
}.toMutableList()
// referenced tables end up above referencing ones (longest path wins)
val layerOf = IntArray(schema.tables.size)
for (pass in schema.tables.indices) {
var changed = false
for (fk in schema.foreignKeys) {
val ti = index[fk.fromTable]
val tj = index[fk.toTable]
if (ti == null || tj == null || ti == tj) continue
if (layerOf[ti] < layerOf[tj] + 1) { layerOf[ti] = layerOf[tj] + 1; changed = true }
}
if (!changed) break
}
val layers = List(layerOf.max() + 1) { mutableListOf<Int>() }
layerOf.forEachIndexed { i, l -> layers[l].add(i) }
var y = 0
var width = 0
var height = 0
for (layer in layers) {
var x = 0
var layerH = 0
for (i in layer) {
boxes[i].x = x
boxes[i].y = y
x += boxes[i].w + o.columnGap
layerH = maxOf(layerH, boxes[i].h)
}
width = maxOf(width, x - o.columnGap)
height = maxOf(height, y + layerH)
y += layerH + o.layerGap
}
val edges = schema.foreignKeys.mapNotNull { fk ->
val from = index[fk.fromTable]?.let { boxes[it] } ?: return@mapNotNull null
val to = index[fk.toTable]?.let { boxes[it] } ?: return@mapNotNull null
val x1 = to.x + r(to.w / 2.0)
val y1 = to.y + to.h
val x2 = from.x + r(from.w / 2.0)
val y2 = from.y
val midY = r((y1 + y2) / 2.0)
Edge(fk, "M $x1 $y1 V $midY H $x2 V $y2", "${fk.fromColumn} → ${fk.toColumn}")
}
val laid = schema.tables.mapIndexed { i, table ->
LaidTable(
table, boxes[i],
Box(boxes[i].x, boxes[i].y, boxes[i].w, o.rowHeight),
table.columns.mapIndexed { ci, _ ->
Box(boxes[i].x, boxes[i].y + o.rowHeight * (1 + ci), boxes[i].w, o.rowHeight)
},
)
}
return Geometry(width, height, laid, edges)
}
// ---------------------------------------------------------------------
// Renderer
// ---------------------------------------------------------------------
private fun esc(s: String): String = buildString(s.length) {
for (c in s) when (c) {
'&' -> append("&")
'<' -> append("<")
'>' -> append(">")
'"' -> append(""")
else -> append(c)
}
}
fun renderSvg(geo: Geometry): String {
val out = mutableListOf(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${geo.width} ${geo.height}\" " +
"class=\"sv-root\" role=\"img\"><title>Schema diagram</title>",
)
val boxOf = HashMap<String, Box>()
for (t in geo.tables) boxOf[t.table.name] = t.box
for (e in geo.edges) {
val from = boxOf[e.fk.fromTable] ?: continue
val ax = from.x + r(from.w / 2.0)
out.add(
"<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 (t in geo.tables) {
out.add(
"<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.forEachIndexed { ci, c ->
out.add(
"<text class=\"${if (c.isPrimaryKey) "sv-pk" else "sv-col"}\" x=\"${t.box.x + 8}\" " +
"y=\"${t.columnRows[ci].y + 17}\">${esc(c.name)} ${esc(c.type)}</text>",
)
}
out.add("</g>")
}
out.add("</svg>")
return out.joinToString("")
}
fun ddlToSvg(ddl: String): DdlResult {
val schema = parseDdl(ddl)
return DdlResult(renderSvg(layoutSchema(schema)), schema)
}
}
// Example:
// SchemaVisualizer.ddlToSvg(
// "CREATE TABLE users (id INT PRIMARY KEY);" +
// "CREATE TABLE posts (id INT PRIMARY KEY, user_id INT REFERENCES users(id), title TEXT);"
// ).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 →