ER/Schema Visualizer — Java 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 Java implementation — the same logic the interactive tool runs, in a shareable, citable form.
// schema-visualizer — pure CREATE TABLE DDL → layered ER diagram as SVG.
// Java 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.
//
// The class is deliberately package-private: the display filename is
// java.java, which differs from the class name, and a public top-level class
// may only be declared in a file named after itself.
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
final class SchemaVisualizer {
private SchemaVisualizer() {}
/** Layout constants shared by every port: 24/7/8/60/40. */
private static final int ROW_HEIGHT = 24, CHAR_WIDTH = 7, PADDING = 8, LAYER_GAP = 60, COLUMN_GAP = 40;
/** Keywords that end a column's type and start its modifiers. */
private static final Set<String> MODIFIERS = Set.of("NOT", "NULL", "PRIMARY", "KEY", "UNIQUE",
"DEFAULT", "REFERENCES", "AUTO_INCREMENT", "AUTOINCREMENT", "ON", "COMMENT", "CHECK",
"CONSTRAINT");
/** Opening quote -> closing quote (SQL Server [ident] included). */
private static final Map<Character, Character> QUOTES =
Map.of('\'', '\'', '"', '"', '`', '`', '[', ']');
/** Round half up: (int)(v + 0.5), JS Math.round parity. */
static int r(double v) { return (int) (v + 0.5); }
// ---- types ------------------------------------------------------------
/** Token: kind is "word" | "punct" | "string" | "qident"; quotes stripped. */
record Tok(String kind, String text) {}
/** takeName result: the dotted name plus the index just past it. */
record NameRef(String name, int next) {}
/** parenList result: the names plus the index just past the ')'. */
record ListRef(List<String> names, int next) {}
record ForeignKey(String fromTable, String fromColumn, String toTable, String toColumn) {}
static final class Column {
final String name;
final String type;
boolean nullable = true;
boolean isPrimaryKey;
Column(String name, String type, boolean nullable, boolean isPrimaryKey) {
this.name = name;
this.type = type;
this.nullable = nullable;
this.isPrimaryKey = isPrimaryKey;
}
}
static final class Table {
final String name;
final List<Column> columns = new ArrayList<>();
Table(String name) { this.name = name; }
}
record Schema(List<Table> tables, List<ForeignKey> foreignKeys, List<String> notes) {}
/** Mutable during layout, read-only afterwards. */
static final class Box {
int x, y, w, h;
Box(int x, int y, int w, int h) { this.x = x; this.y = y; this.w = w; this.h = h; }
}
record LaidTable(Table table, Box box, Box titleBar, List<Box> columnRows) {}
record Edge(ForeignKey fk, String path, String label) {}
record Geometry(int width, int height, List<LaidTable> tables, List<Edge> edges) {}
record Result(String svg, Schema schema) {}
/** Control-flow escape mirroring the ports' `throw 0` / `raise ValueError`. */
private static final class Unparseable extends RuntimeException {}
// ---- lexer ------------------------------------------------------------
/** JS /\s/ — ASCII whitespace is the practical DDL subset. */
private static boolean isSpace(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\f' || c == '\r' || c == 0x0B;
}
/** Split on `;` outside strings/quoted identifiers (depth-agnostic: an
* unterminated paren cannot swallow the statements after it). */
static List<String> splitStatements(String ddl) {
List<String> out = new ArrayList<>();
StringBuilder cur = new StringBuilder();
int i = 0, n = ddl.length();
while (i < n) {
char ch = ddl.charAt(i);
Character close = QUOTES.get(ch);
if (close != null) {
cur.append(ch);
i++;
while (i < n) {
cur.append(ddl.charAt(i));
if (ddl.charAt(i) == close) {
if (close == '\'' && i + 1 < n && ddl.charAt(i + 1) == '\'') {
cur.append(ddl.charAt(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().isEmpty()) out.add(cur.toString());
return out;
}
/** Tokens: quoted identifiers/strings carry their text (quotes stripped);
* ( ) , . are punct; everything else is a word. */
static List<Tok> tokenize(String s) {
List<Tok> toks = new ArrayList<>();
int i = 0, n = s.length();
while (i < n) {
char ch = s.charAt(i);
if (isSpace(ch)) { i++; continue; }
Character close = QUOTES.get(ch);
if (close != null) {
StringBuilder text = new StringBuilder();
i++;
while (i < n) {
if (s.charAt(i) == close) {
if (close == '\'' && i + 1 < n && s.charAt(i + 1) == '\'') {
text.append('\'');
i += 2;
continue;
}
break;
}
text.append(s.charAt(i));
i++;
}
i++;
toks.add(new Tok(ch == '\'' ? "string" : "qident", text.toString()));
continue;
}
if (ch == '(' || ch == ')' || ch == ',' || ch == '.') {
toks.add(new Tok("punct", String.valueOf(ch)));
i++;
continue;
}
StringBuilder word = new StringBuilder();
while (i < n && !isWordBreak(s.charAt(i))) word.append(s.charAt(i++));
toks.add(new Tok("word", word.toString()));
}
return toks;
}
private static boolean isWordBreak(char c) {
return isSpace(c) || c == '\'' || c == '"' || c == '(' || c == ')' || c == ','
|| c == '.' || c == '`' || c == '[' || c == ']';
}
// ---- token helpers ----------------------------------------------------
private static Tok g(List<Tok> toks, int i) { return i >= 0 && i < toks.size() ? toks.get(i) : null; }
private static boolean isP(Tok t, String p) {
return t != null && t.kind().equals("punct") && t.text().equals(p);
}
private static boolean kw(Tok t, String w) {
return t != null && t.kind().equals("word") && t.text().toUpperCase(Locale.ROOT).equals(w);
}
/** [schema.]name — dotted parts keep the port's textual join. */
static NameRef takeName(List<Tok> toks, int i) {
Tok first = g(toks, i);
if (first == null || !(first.kind().equals("qident") || first.kind().equals("word"))) return null;
StringBuilder name = new StringBuilder(first.text());
int j = i + 1;
while (isP(g(toks, j), ".") && g(toks, j + 1) != null
&& (g(toks, j + 1).kind().equals("qident") || g(toks, j + 1).kind().equals("word"))) {
name.append('.').append(toks.get(j + 1).text());
j += 2;
}
return new NameRef(name.toString(), j);
}
/** ( name [, name]* ) — anything else is unparseable (null). */
static ListRef parenList(List<Tok> toks, int i) {
if (!isP(g(toks, i), "(")) return null;
List<String> names = new ArrayList<>();
int j = i + 1;
for (;;) {
NameRef name = takeName(toks, j);
if (name == null) return null;
names.add(name.name());
j = name.next();
if (isP(g(toks, j), ",")) { j++; continue; }
if (isP(g(toks, j), ")")) return new ListRef(names, j + 1);
return null;
}
}
// ---- parser -----------------------------------------------------------
/** Join type tokens, squeezing the spaces around ( ) , then uppercasing. */
private static String joinType(List<Tok> toks) {
StringBuilder raw = new StringBuilder();
for (int k = 0; k < toks.size(); k++) {
if (k > 0) raw.append(' ');
raw.append(toks.get(k).text());
}
return raw.toString()
.replaceAll("\\s*\\(\\s*", "(")
.replaceAll("\\s*\\)\\s*", ")")
.replaceAll("\\s*,\\s*", ",")
.trim()
.toUpperCase(Locale.ROOT);
}
/** One column definition; also collects inline REFERENCES foreign keys. */
static Column parseColumn(List<Tok> line, String tableName, List<ForeignKey> fks) {
NameRef name = takeName(line, 0);
if (name == null) return null;
int i = name.next();
List<Tok> typeToks = new ArrayList<>();
while (i < line.size()
&& !(line.get(i).kind().equals("word") && MODIFIERS.contains(line.get(i).text().toUpperCase(Locale.ROOT)))) {
typeToks.add(line.get(i));
i++;
}
boolean nullable = true, pk = false;
while (i < line.size()) {
Tok t = line.get(i);
if (kw(t, "NOT") && kw(g(line, i + 1), "NULL")) { nullable = false; i += 2; continue; }
if (kw(t, "NULL")) { i++; 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++; continue; }
if (kw(t, "DEFAULT")) {
i++;
if (isP(g(line, i), "(")) { // swallow ( expr ) balanced
int d = 0;
do {
if (isP(g(line, i), "(")) d++;
if (isP(g(line, 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.get(i).kind().equals("string")) i++;
continue;
}
if (kw(t, "ON")) { // ON UPDATE/DELETE ...
i += 2;
if (kw(g(line, i), "SET") || kw(g(line, i), "NO")) i += 2;
else if (i < line.size()) i++;
continue;
}
if (kw(t, "REFERENCES")) {
i++;
NameRef target = takeName(line, i);
if (target != null) {
i = target.next();
String toCol = null;
if (isP(g(line, i), "(")) {
ListRef list = parenList(line, i);
if (list != null) { toCol = list.names().get(0); i = list.next(); }
}
fks.add(new ForeignKey(tableName, name.name(), target.name(), toCol));
}
continue;
}
i++; // unknown modifier tolerated
}
return new Column(name.name(), joinType(typeToks), nullable, pk);
}
/** DDL text -> schema (tables, foreign keys, notes). Never throws. */
static Schema parseDdl(String ddl) {
if (ddl.trim().isEmpty()) return new Schema(List.of(), List.of(), List.of("No DDL input."));
List<Table> tables = new ArrayList<>();
List<ForeignKey> fks = new ArrayList<>();
List<String> notes = new ArrayList<>();
for (String stmt : splitStatements(ddl)) {
if (stmt.trim().isEmpty()) continue;
List<Tok> toks = tokenize(stmt);
try {
int i = 0;
if (!kw(g(toks, i), "CREATE")) throw new Unparseable();
i++;
while (kw(g(toks, i), "TEMP") || kw(g(toks, i), "TEMPORARY") || kw(g(toks, i), "UNLOGGED")) i++;
if (!kw(g(toks, i), "TABLE")) { notes.add("Skipped non-table statement."); continue; }
i++;
if (kw(g(toks, i), "IF") && kw(g(toks, i + 1), "NOT") && kw(g(toks, i + 2), "EXISTS")) i += 3;
NameRef name = takeName(toks, i);
if (name == null || !isP(g(toks, name.next()), "(")) throw new Unparseable();
i = name.next() + 1;
// body = tokens until the matching close paren (depth-aware)
List<Tok> body = new ArrayList<>();
int depth = 0;
for (; i < toks.size(); i++) {
if (isP(toks.get(i), "(")) depth++;
if (isP(toks.get(i), ")")) { if (depth == 0) break; depth--; }
body.add(toks.get(i));
}
if (i >= toks.size()) throw new Unparseable();
// split body into top-level comma lines
List<List<Tok>> lines = new ArrayList<>();
List<Tok> line = new ArrayList<>();
depth = 0;
for (Tok t : body) {
if (isP(t, "(")) depth++;
if (isP(t, ")")) depth--;
if (isP(t, ",") && depth == 0) { lines.add(line); line = new ArrayList<>(); continue; }
line.add(t);
}
if (!line.isEmpty()) lines.add(line);
Table table = new Table(name.name());
tables.add(table);
for (List<Tok> toks2 : lines) {
if (toks2.isEmpty()) continue;
Tok first = toks2.get(0);
String u = first.kind().equals("word") ? first.text().toUpperCase(Locale.ROOT) : "";
if (u.equals("PRIMARY") && kw(g(toks2, 1), "KEY")) {
ListRef list = parenList(toks2, 2);
if (list != null) for (String cn : list.names()) {
for (Column col : table.columns) {
if (col.name.equals(cn)) { col.isPrimaryKey = true; col.nullable = false; break; }
}
}
continue;
}
if (u.equals("FOREIGN") && kw(g(toks2, 1), "KEY")) {
ListRef from = parenList(toks2, 2);
if (from != null && kw(g(toks2, from.next()), "REFERENCES")) {
NameRef target = takeName(toks2, from.next() + 1);
if (target != null) {
List<String> toCols = null;
if (isP(g(toks2, target.next()), "(")) {
ListRef to = parenList(toks2, target.next());
if (to != null) toCols = to.names();
}
for (int idx = 0; idx < from.names().size(); idx++) {
String toCol = null;
if (toCols != null) {
toCol = idx < toCols.size() ? toCols.get(idx) : toCols.get(toCols.size() - 1);
}
fks.add(new ForeignKey(table.name, from.names().get(idx),
target.name(), toCol));
}
}
}
continue;
}
if (u.equals("UNIQUE") || u.equals("KEY") || u.equals("INDEX") || u.equals("CHECK")
|| u.equals("EXCLUDE") || u.equals("CONSTRAINT")) continue;
Column col = parseColumn(toks2, table.name, fks);
if (col != null) table.columns.add(col);
}
} catch (Unparseable e) {
notes.add("Skipped unparseable statement.");
}
}
// resolve omitted FK target columns to the referenced table's first PK
List<ForeignKey> resolved = new ArrayList<>();
for (ForeignKey fk : fks) {
if (fk.toColumn() != null && !fk.toColumn().isEmpty()) { resolved.add(fk); continue; }
Table target = null;
for (Table t : tables) if (t.name.equals(fk.toTable())) { target = t; break; }
String pk = null;
if (target != null) for (Column c : target.columns) if (c.isPrimaryKey) { pk = c.name; break; }
resolved.add(new ForeignKey(fk.fromTable(), fk.fromColumn(), fk.toTable(), pk != null ? pk : "id"));
}
return new Schema(tables, resolved, notes);
}
// ---- layout -----------------------------------------------------------
/** Layered boxes + orthogonal edges; integer geometry throughout. */
static Geometry layoutSchema(Schema schema) {
if (schema.tables().isEmpty()) return new Geometry(0, 0, List.of(), List.of());
Map<String, Integer> index = new HashMap<>(); // first occurrence wins
for (int i = 0; i < schema.tables().size(); i++) index.putIfAbsent(schema.tables().get(i).name, i);
List<Box> boxes = new ArrayList<>();
for (Table t : schema.tables) {
int textLen = Math.max(t.name.length(), 1);
for (Column c : t.columns) textLen = Math.max(textLen, (c.name + " " + c.type).length());
boxes.add(new Box(0, 0,
r(textLen * CHAR_WIDTH + 2 * PADDING),
r(ROW_HEIGHT * (1 + t.columns.size()) + PADDING)));
}
// longest-path layering (iterate to fixpoint; cycles cap at n passes)
int[] layerOf = new int[schema.tables().size()];
for (int pass = 0; pass < schema.tables().size(); pass++) {
boolean changed = false;
for (ForeignKey fk : schema.foreignKeys()) {
Integer ti = index.get(fk.fromTable()), tj = index.get(fk.toTable());
if (ti == null || tj == null || ti.intValue() == tj.intValue()) continue;
if (layerOf[ti] < layerOf[tj] + 1) { layerOf[ti] = layerOf[tj] + 1; changed = true; }
}
if (!changed) break;
}
Map<Integer, List<Integer>> layers = new TreeMap<>();
for (int i = 0; i < layerOf.length; i++) layers.computeIfAbsent(layerOf[i], k -> new ArrayList<>()).add(i);
int y = 0, width = 0, height = 0;
for (List<Integer> layer : layers.values()) {
int x = 0, layerH = 0;
for (int i : layer) {
Box b = boxes.get(i);
b.x = x;
b.y = y;
x += b.w + COLUMN_GAP;
layerH = Math.max(layerH, b.h);
}
width = Math.max(width, x - COLUMN_GAP);
height = Math.max(height, y + layerH);
y += layerH + LAYER_GAP;
}
List<Edge> edges = new ArrayList<>();
for (ForeignKey fk : schema.foreignKeys()) {
Integer fi = index.get(fk.fromTable()), ti = index.get(fk.toTable());
if (fi == null || ti == null) continue;
Box from = boxes.get(fi), to = boxes.get(ti);
int x1 = to.x + r(to.w / 2.0), y1 = to.y + to.h;
int x2 = from.x + r(from.w / 2.0), y2 = from.y;
int midY = r((y1 + y2) / 2.0);
edges.add(new Edge(fk, "M " + x1 + " " + y1 + " V " + midY + " H " + x2 + " V " + y2,
fk.fromColumn() + " → " + fk.toColumn()));
}
List<LaidTable> laid = new ArrayList<>();
for (int i = 0; i < schema.tables().size(); i++) {
Table t = schema.tables().get(i);
Box b = boxes.get(i);
List<Box> rows = new ArrayList<>();
for (int ci = 0; ci < t.columns.size(); ci++) {
rows.add(new Box(b.x, b.y + ROW_HEIGHT * (1 + ci), b.w, ROW_HEIGHT));
}
laid.add(new LaidTable(t, b, new Box(b.x, b.y, b.w, ROW_HEIGHT), rows));
}
return new Geometry(width, height, laid, edges);
}
// ---- render -----------------------------------------------------------
/** XML-escape the four characters SVG text nodes care about. */
static String esc(String s) {
StringBuilder out = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
switch (s.charAt(i)) {
case '&' -> out.append("&");
case '<' -> out.append("<");
case '>' -> out.append(">");
case '"' -> out.append(""");
default -> out.append(s.charAt(i));
}
}
return out.toString();
}
static String renderSvg(Geometry geo) {
StringBuilder out = new StringBuilder();
out.append("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ").append(geo.width())
.append(' ').append(geo.height())
.append("\" class=\"sv-root\" role=\"img\"><title>Schema diagram</title>");
Map<String, Box> boxOf = new HashMap<>(); // last occurrence wins
for (LaidTable t : geo.tables()) boxOf.put(t.table().name, t.box());
for (Edge e : geo.edges()) {
Box from = boxOf.get(e.fk().fromTable());
if (from == null) continue;
int ax = from.x + r(from.w / 2.0);
out.append("<path class=\"sv-edge\" d=\"").append(e.path()).append("\"/>")
.append("<polygon class=\"sv-arrow\" points=\"")
.append(ax - 5).append(',').append(from.y - 8).append(' ')
.append(ax + 5).append(',').append(from.y - 8).append(' ')
.append(ax).append(',').append(from.y).append("\"/>");
}
for (LaidTable t : geo.tables()) {
Box b = t.box(), tb = t.titleBar();
out.append("<g class=\"sv-table\">")
.append("<rect class=\"sv-box\" x=\"").append(b.x).append("\" y=\"").append(b.y)
.append("\" width=\"").append(b.w).append("\" height=\"").append(b.h).append("\" rx=\"6\"/>")
.append("<rect class=\"sv-titlebar\" x=\"").append(tb.x).append("\" y=\"").append(tb.y)
.append("\" width=\"").append(tb.w).append("\" height=\"").append(tb.h).append("\" rx=\"6\"/>")
.append("<text class=\"sv-title\" x=\"").append(b.x + 8).append("\" y=\"").append(tb.y + 17)
.append("\">").append(esc(t.table().name)).append("</text>");
for (int ci = 0; ci < t.table().columns.size(); ci++) {
Column c = t.table().columns.get(ci);
Box row = t.columnRows().get(ci);
out.append("<text class=\"").append(c.isPrimaryKey ? "sv-pk" : "sv-col")
.append("\" x=\"").append(row.x + 8).append("\" y=\"").append(row.y + 17)
.append("\">").append(esc(c.name)).append(' ').append(esc(c.type)).append("</text>");
}
out.append("</g>");
}
out.append("</svg>");
return out.toString();
}
static Result ddlToSvg(String ddl) {
Schema schema = parseDdl(ddl);
return new Result(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 →