Generating SQL Diagrams with Golang
My team maintains a database that has close to one hundred tables, with hundreds of foreign key relationships between those tables. I asked around on Slack, but nobody knew of any schema diagrams hidden away in our documentation folders. There are tools online that can prepare a diagram for you from a sql dump such as [dbdiagram.io]. I was worried about pasting my team’s DB schema into this web interface, so I embarked on generating the diagram myself.
Getting a sql dump
Before we can generate a diagram, we need to dump our SQL schema to a file. If you/your team are
following good version control practices, you should already have a checked-in version of your schema
as a .sql
file, which you can use to recreate your production database.
If you can’t find this, run the following command to dump your schema to the file defs.sql
:
mysqldump --no-data <DB_NAME> > defs.sql
Generating a table diagram in Golang
To get started with a new Golang project, create a new directory.
$ export PROJECT = $HOME/go/src/github.com/<YOUR_USERNAME>/sql-graphviz`
$ mkdir $PROJECT
$ cd $PROJECT
First, let’s define a Golang representation of our sql schema.
Since we’re interested in exploring the
relationships between tables, we can keep our representation
simple and limited to tables and foreign
keys between tables. Create a new file, schema.go
, and define
a struct for our tables. A String()
method will assist with
future debugging.
// schema.go
package main
// Table defines a subset of the sql table schema
type Table struct {
name string
// columns maps from the column name to the column
// type (i.e. VARCHAR(11))
columns map[string]string
}
func (t Table) String() string {
cols := make([]string, 0)
for k := range t.columns {
cols = append(cols, k)
}
return fmt.Sprintf("<table name=%s, columns=%v>", t.name, cols)
}
Next define a struct for our foreign keys. A foreign key is the most common way of defining a relationhsip between two tables; it is a field “or collection of fields” in one table that refers to the PRIMARY KEY in another table. Include a String()
method for debugging.
We will also include an Equal()
method for comparing relationKet.
// schema.go
// RelationKey defines one half of a foreign key (either the
// child table or the referenced/parent table).
type RelationKey struct {
table string
columns []string
}
// Equal checks that RelationKeys are defined over the same table and column sets.
func (rk RelationKey) Equal(rk2 RelationKey) bool {
// Check if RelationKeys are over the same tables.
if rk.table != rk2.table {
return false
}
if len(rk.columns) != len(rk2.columns) {
return false
}
// Check if column sets are the same.
columnKeys := make(map[string]bool, 0)
for columnName, columnType := range rk.columns {
key := fmt.Sprintf("%v-%v", columnName, columnType)
cols[key] = true
}
for columnName, columnType := range rk2.columns {
key := fmt.Sprintf("%v-%v", columnName, columnType)
cols[key] = false
}
for _, val := range columnkeys {
if val {
return false
}
}
return true
}
// Relation is a directed mapping between sets of columns on
// two distinct tables.
type Relation struct {
a RelationKey
b RelationKey
}
func (r Relation) Equal(r2 Relation) bool {
return r.a.Equal(r2.a) && r.b.Equal(r2.b)
}
func (r Relation) String() string {
return fmt.Sprintf("<%s (%v) -> %s (%v)>", r.a.table, r.a.columns, r.b.table, r.b.columns)
}
Finally, let’s add a schema interface. Separating out interface and implementation is a good practice for adding unit tests, and allows us to think through our ideal API before diving into the internals of the component. Our schema interface will also check calls for duplicate tables and invalid relations (i.e. between non-existent tables/columns), and throw a nice error message if the user tries to define an invalid schema.
// schema.go
// Schema contains a set of tables with columns and relations
// between those columns (foreign keys).
type Schema interface {
AddTable(table Table) error
AddRelation(a, b RelationKey) error
String() string
}
Now we define a struct that implements our schema interface above. We map table names to table information and store our relations in a slice.
type schemaImpl struct {
Tables map[string]Table
Relations []Relation
}
// AddTable extends the schema with a new table. It validates
// that a table with the given name does not exist.
func (s *schemaImpl) AddTable(table Table) error {
name := table.name
if _, ok := s.Tables[name]; ok {
return fmt.Errorf("table with name %s already exists", name)
}
s.Tables[name] = table
return nil
}
// AddRelation extends the schema with a new relation/foreign
// key. It validates that the selected tables and columns exist
// in the schema, and that the relation does not already exist.
func (s *schemaImpl) AddRelation(a, b RelationKey) error {
// Check that both the tables exist.
if _, ok := s.Tables[a.table]; !ok {
return fmt.Errorf("table with name %s does not exist", a.table)
}
if _, ok := s.Tables[b.table]; !ok {
return fmt.Errorf("table with name %s does not exist", b.table)
}
// Check that all the columns exist.
for _, col := range a.columns {
if _, ok := s.Tables[a.table].columns[col]; !ok {
return fmt.Errorf("column with name %s does not exist on table with name %s", col, a.table)
}
}
for _, col := range b.columns {
if _, ok := s.Tables[b.table].columns[col]; !ok {
return fmt.Errorf("column with name %s does not exist on table with name %s", col, b.table)
}
}
newRel := NewRelation(a, b)
// Check whether the relation already exists.
for _, relation := range s.Relations {
if relation == newRel {
return fmt.Errorf("new relation already exists on schema %s", newRel.String())
}
}
s.Relations = append(s.Relations, newRel)
return nil
}
func (s *schemaImpl) String() string {
tables := make([]string, 0)
for name := range s.Tables {
tables = append(tables, name)
}
return fmt.Sprintf("<tables=%v relations=%v>", tables, s.Relations)
}
// NewSchema is a simple factory/constructor for schemaImpl.
func NewSchema() Schema {
return &schemaImpl{
Tables: make(map[string]Table, 0),
Relations: make([]Relation, 0),
}
}