From 4a0f15209f695585034cd5c1ad2a4b3244a74645 Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Sun, 5 May 2024 20:40:37 +0900 Subject: [PATCH] Introduce mermaid entity relationship diagram --- README.md | 179 ++++++++++++++++++++++++- doc/er/generated.md | 21 +++ doc/er/main.go | 128 ++++++++++++++++++ doc/piechart/main.go | 2 +- mermaid/er/config.go | 12 ++ mermaid/er/entity.go | 105 +++++++++++++++ mermaid/er/entity_relationship.go | 85 ++++++++++++ mermaid/er/entity_relationship_test.go | 163 ++++++++++++++++++++++ mermaid/er/identify.go | 24 ++++ mermaid/er/relationship.go | 51 +++++++ mermaid/er/relationship_test.go | 77 +++++++++++ 11 files changed, 845 insertions(+), 2 deletions(-) create mode 100644 doc/er/generated.md create mode 100644 doc/er/main.go create mode 100644 mermaid/er/config.go create mode 100644 mermaid/er/entity.go create mode 100644 mermaid/er/entity_relationship.go create mode 100644 mermaid/er/entity_relationship_test.go create mode 100644 mermaid/er/identify.go create mode 100644 mermaid/er/relationship.go create mode 100644 mermaid/er/relationship_test.go diff --git a/README.md b/README.md index ee31d46..1a0473a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ # What is markdown package The Package markdown is a simple markdown builder in golang. The markdown package assembles Markdown using method chaining, not uses a template engine like [html/template](https://pkg.go.dev/html/template). The syntax of Markdown follows **GitHub Markdown**. -The markdown package was initially developed to save test results in [nao1215/spectest](https://github.com/nao1215/spectest). Therefore, the markdown package implements the features required by spectest. For example, the markdown package supports **mermaid sequence diagrams**, which was a necessary feature in spectest. +The markdown package was initially developed to save test results in [nao1215/spectest](https://github.com/nao1215/spectest). Therefore, the markdown package implements the features required by spectest. For example, the markdown package supports **mermaid sequence diagrams (entity relationship diagram, sequence diagram, pie chart)**, which was a necessary feature in spectest. Additionally, complex code that increases the complexity of the library, such as generating nested lists, will not be added. I want to keep this library as simple as possible. @@ -33,6 +33,7 @@ Additionally, complex code that increases the complexity of the library, such as - [x] Details - [x] Alerts; NOTE, TIP, IMPORTANT, CAUTION, WARNING - [x] mermaid sequence diagram +- [x] mermaid entity relationship diagram - [x] mermaid pie chart ### Features not in Markdown syntax @@ -411,6 +412,182 @@ pie showData "C" : 30 ``` +### Entity Relationship Diagram syntax + +```go +package main + +import ( + "os" + + "github.com/nao1215/markdown" + "github.com/nao1215/markdown/mermaid/er" +) + +//go:generate go run main.go + +func main() { + f, err := os.Create("generated.md") + if err != nil { + panic(err) + } + + teachers := er.NewEntity( + "teachers", + []*er.Attribute{ + { + Type: "int", + Name: "id", + IsPrimaryKey: true, + IsForeignKey: false, + IsUniqueKey: true, + Comment: "Teacher ID", + }, + { + Type: "string", + Name: "name", + IsPrimaryKey: false, + IsForeignKey: false, + IsUniqueKey: false, + Comment: "Teacher Name", + }, + }, + ) + students := er.NewEntity( + "students", + []*er.Attribute{ + { + Type: "int", + Name: "id", + IsPrimaryKey: true, + IsForeignKey: false, + IsUniqueKey: true, + Comment: "Student ID", + }, + { + Type: "string", + Name: "name", + IsPrimaryKey: false, + IsForeignKey: false, + IsUniqueKey: false, + Comment: "Student Name", + }, + { + Type: "int", + Name: "teacher_id", + IsPrimaryKey: false, + IsForeignKey: true, + IsUniqueKey: true, + Comment: "Teacher ID", + }, + }, + ) + schools := er.NewEntity( + "schools", + []*er.Attribute{ + { + Type: "int", + Name: "id", + IsPrimaryKey: true, + IsForeignKey: false, + IsUniqueKey: true, + Comment: "School ID", + }, + { + Type: "string", + Name: "name", + IsPrimaryKey: false, + IsForeignKey: false, + IsUniqueKey: false, + Comment: "School Name", + }, + { + Type: "int", + Name: "teacher_id", + IsPrimaryKey: false, + IsForeignKey: true, + IsUniqueKey: true, + Comment: "Teacher ID", + }, + }, + ) + + erString := er.NewDiagram(f). + Relationship( + teachers, + students, + er.ExactlyOneRelationship, // "||" + er.ZeroToMoreRelationship, // "}o" + er.Identifying, // "--" + "Teacher has many students", + ). + Relationship( + teachers, + schools, + er.OneToMoreRelationship, // "|}" + er.ExactlyOneRelationship, // "||" + er.NonIdentifying, // ".." + "School has many teachers", + ). + String() + + err = markdown.NewMarkdown(f). + H2("Entity Relationship Diagram"). + CodeBlocks(markdown.SyntaxHighlightMermaid, erString). + Build() + + if err != nil { + panic(err) + } +} +``` + +Plain text output: [markdown is here](./doc/er/generated.md) +```` +## Entity Relationship Diagram +```mermaid +erDiagram + teachers ||--o{ students : "Teacher has many students" + teachers }|..|| schools : "School has many teachers" + schools { + int id PK,UK "School ID" + string name "School Name" + int teacher_id FK,UK "Teacher ID" + } + students { + int id PK,UK "Student ID" + string name "Student Name" + int teacher_id FK,UK "Teacher ID" + } + teachers { + int id PK,UK "Teacher ID" + string name "Teacher Name" + } + +``` +```` + +Mermaid output: +```mermaid +erDiagram + teachers ||--o{ students : "Teacher has many students" + teachers }|..|| schools : "School has many teachers" + schools { + int id PK,UK "School ID" + string name "School Name" + int teacher_id FK,UK "Teacher ID" + } + students { + int id PK,UK "Student ID" + string name "Student Name" + int teacher_id FK,UK "Teacher ID" + } + teachers { + int id PK,UK "Teacher ID" + string name "Teacher Name" + } +``` + ## Creating an index for a directory full of markdown files The markdown package can create an index for Markdown files within the specified directory. This feature was added to generate indexes for Markdown documents produced by [nao1215/spectest](https://github.com/nao1215/spectest). diff --git a/doc/er/generated.md b/doc/er/generated.md new file mode 100644 index 0000000..bbbdaa8 --- /dev/null +++ b/doc/er/generated.md @@ -0,0 +1,21 @@ +## Entity Relationship Diagram +```mermaid +erDiagram + teachers ||--o{ students : "Teacher has many students" + teachers }|..|| schools : "School has many teachers" + schools { + int id PK,UK "School ID" + string name "School Name" + int teacher_id FK,UK "Teacher ID" + } + students { + int id PK,UK "Student ID" + string name "Student Name" + int teacher_id FK,UK "Teacher ID" + } + teachers { + int id PK,UK "Teacher ID" + string name "Teacher Name" + } + +``` \ No newline at end of file diff --git a/doc/er/main.go b/doc/er/main.go new file mode 100644 index 0000000..a7ac35b --- /dev/null +++ b/doc/er/main.go @@ -0,0 +1,128 @@ +//go:build linux || darwin + +// Package main is generating entity relationship diagram. +package main + +import ( + "os" + + "github.com/nao1215/markdown" + "github.com/nao1215/markdown/mermaid/er" +) + +//go:generate go run main.go + +func main() { + f, err := os.Create("generated.md") + if err != nil { + panic(err) + } + + teachers := er.NewEntity( + "teachers", + []*er.Attribute{ + { + Type: "int", + Name: "id", + IsPrimaryKey: true, + IsForeignKey: false, + IsUniqueKey: true, + Comment: "Teacher ID", + }, + { + Type: "string", + Name: "name", + IsPrimaryKey: false, + IsForeignKey: false, + IsUniqueKey: false, + Comment: "Teacher Name", + }, + }, + ) + students := er.NewEntity( + "students", + []*er.Attribute{ + { + Type: "int", + Name: "id", + IsPrimaryKey: true, + IsForeignKey: false, + IsUniqueKey: true, + Comment: "Student ID", + }, + { + Type: "string", + Name: "name", + IsPrimaryKey: false, + IsForeignKey: false, + IsUniqueKey: false, + Comment: "Student Name", + }, + { + Type: "int", + Name: "teacher_id", + IsPrimaryKey: false, + IsForeignKey: true, + IsUniqueKey: true, + Comment: "Teacher ID", + }, + }, + ) + schools := er.NewEntity( + "schools", + []*er.Attribute{ + { + Type: "int", + Name: "id", + IsPrimaryKey: true, + IsForeignKey: false, + IsUniqueKey: true, + Comment: "School ID", + }, + { + Type: "string", + Name: "name", + IsPrimaryKey: false, + IsForeignKey: false, + IsUniqueKey: false, + Comment: "School Name", + }, + { + Type: "int", + Name: "teacher_id", + IsPrimaryKey: false, + IsForeignKey: true, + IsUniqueKey: true, + Comment: "Teacher ID", + }, + }, + ) + + erString := er.NewDiagram(f). + Relationship( + teachers, + students, + er.ExactlyOneRelationship, // "||" + er.ZeroToMoreRelationship, // "}o" + er.Identifying, // "--" + "Teacher has many students", + ). + Relationship( + teachers, + schools, + er.OneToMoreRelationship, // "|}" + er.ExactlyOneRelationship, // "||" + er.NonIdentifying, // ".." + "School has many teachers", + ). + String() + + err = markdown.NewMarkdown(f). + H2("Entity Relationship Diagram"). + CodeBlocks(markdown.SyntaxHighlightMermaid, erString). + Build() + + if err != nil { + panic(err) + } +} diff --git a/doc/piechart/main.go b/doc/piechart/main.go index a457b3d..6e5ae67 100644 --- a/doc/piechart/main.go +++ b/doc/piechart/main.go @@ -1,6 +1,6 @@ //go:build linux || darwin -// Package main is generating mermaid sequence diagram. +// Package main is generating pie chart. package main import ( diff --git a/mermaid/er/config.go b/mermaid/er/config.go new file mode 100644 index 0000000..e81a3c1 --- /dev/null +++ b/mermaid/er/config.go @@ -0,0 +1,12 @@ +package er + +// config is the configuration for the entity relationship diagram. +type config struct{} + +// newConfig returns a new config. +func newConfig() *config { + return &config{} +} + +// Option sets the options for the PieChart struct. +type Option func(*config) diff --git a/mermaid/er/entity.go b/mermaid/er/entity.go new file mode 100644 index 0000000..45a593e --- /dev/null +++ b/mermaid/er/entity.go @@ -0,0 +1,105 @@ +package er + +import ( + "fmt" + "strings" +) + +// Entity is a entity of entity relationship. +type Entity struct { + // Name is the name of the entity. + Name string + // Attributes is the attributes of the entity. + Attributes []*Attribute +} + +// string returns the string representation of the Entity. +func (e *Entity) string() string { + var attrs []string + for _, a := range e.Attributes { + attrs = append(attrs, a.string()) + } + + return fmt.Sprintf( + "%s%s {%s%s%s%s}", + " ", // indent + e.Name, + lineFeed(), + strings.Join(attrs, lineFeed()), + lineFeed(), + " ", // indent + ) +} + +// NewEntity returns a new Entity. +func NewEntity(name string, attrs []*Attribute) Entity { + return Entity{ + Name: name, + Attributes: attrs, + } +} + +// Attribute is a attribute of the entity. +type Attribute struct { + // Type is the type of the attribute. + Type string + // Name is the name of the attribute. + Name string + // IsPrimaryKey is the flag that indicates whether the attribute is a primary key. + IsPrimaryKey bool + // IsForeignKey is the flag that indicates whether the attribute is a foreign key. + IsForeignKey bool + // IsUniqueKey is the flag that indicates whether the attribute is a unique key. + IsUniqueKey bool + // Comment is the comment of the attribute. + Comment string +} + +// string returns the string representation of the Attribute. +func (a *Attribute) string() string { + var keys []string + if a.IsPrimaryKey { + keys = append(keys, "PK") + } + if a.IsForeignKey { + keys = append(keys, "FK") + } + if a.IsUniqueKey { + keys = append(keys, "UK") + } + + s := fmt.Sprintf(" %s %s %s \"%s\"", a.Type, a.Name, strings.Join(keys, ","), a.Comment) + s = strings.TrimSuffix(s, " ") + return strings.ReplaceAll(s, "\"\"", "") +} + +// Relationship is a relationship of entity relationship. +// leftE: left entity +// rightE: right entity +// leftR: left relationship. You choice from Relationship constants (e.g. ZeroToOneRelationship) +// rightR: right relationship. You choice from Relationship constants (e.g. ZeroToOneRelationship) +// identidy: identify of the relationship. You choice from Identify constants (e.g. Identifying) +func (d *Diagram) Relationship(leftE, rightE Entity, leftR, rightR Relationship, identidy Identify, comment string) *Diagram { + d.body = append( + d.body, + fmt.Sprintf(" %s %s%s%s %s : \"%s\"", + leftE.Name, + leftR.string(left), + identidy.string(), + rightR.string(right), + rightE.Name, + comment, + ), + ) + + d.entities.Store(leftE.Name, leftE) + d.entities.Store(rightE.Name, rightE) + + return d +} + +// NoRelationship adds an entity that has no relationships. +func (d *Diagram) NoRelationship(e Entity) *Diagram { + d.entities.Store(e.Name, e) + return d +} diff --git a/mermaid/er/entity_relationship.go b/mermaid/er/entity_relationship.go new file mode 100644 index 0000000..b6976f1 --- /dev/null +++ b/mermaid/er/entity_relationship.go @@ -0,0 +1,85 @@ +// Package er is mermaid entity relationship diagram builder. +package er + +import ( + "fmt" + "io" + "runtime" + "sort" + "strings" + "sync" +) + +// Diagram is a entity relationship diagram builder. +type Diagram struct { + // body is entity relationship diagram body. + body []string + // config is the configuration for the entity relationship diagram. + config *config + // dest is output destination for entity relationship diagram body. + dest io.Writer + // err manages errors that occur in all parts of the entity relationship building. + err error + // entities is the list of entities in the diagram. + entities sync.Map +} + +// NewDiagram returns a new Diagram. +func NewDiagram(w io.Writer, opts ...Option) *Diagram { + c := newConfig() + + for _, opt := range opts { + opt(c) + } + + return &Diagram{ + body: []string{"erDiagram"}, + dest: w, + config: c, + entities: sync.Map{}, + } +} + +// String returns the entity relationship diagram body. +func (d *Diagram) String() string { + s := strings.Join(d.body, lineFeed()) + s += lineFeed() + + entities := make([]Entity, 0) + d.entities.Range(func(_, value interface{}) bool { + e, ok := value.(Entity) + if !ok { + return false + } + entities = append(entities, e) + return true + }) + + sort.Slice(entities, func(i, j int) bool { + return entities[i].Name < entities[j].Name + }) + + for _, e := range entities { + s += e.string() + lineFeed() + } + return s +} + +// Build writes the entity relationship body to the output destination. +func (d *Diagram) Build() error { + if _, err := fmt.Fprint(d.dest, d.String()); err != nil { + if d.err != nil { + return fmt.Errorf("failed to write: %w: %s", err, d.err.Error()) //nolint:wrapcheck + } + return fmt.Errorf("failed to write: %w", err) + } + return nil +} + +// lineFeed return line feed for current OS. +func lineFeed() string { + if runtime.GOOS == "windows" { + return "\r\n" + } + return "\n" +} diff --git a/mermaid/er/entity_relationship_test.go b/mermaid/er/entity_relationship_test.go new file mode 100644 index 0000000..a337e8e --- /dev/null +++ b/mermaid/er/entity_relationship_test.go @@ -0,0 +1,163 @@ +// Package er is mermaid entity relationship diagram builder. +package er + +import ( + "bytes" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestDiagram_Build(t *testing.T) { + t.Parallel() + + t.Run("should write the entity relationship diagram body to the output destination", func(t *testing.T) { + t.Parallel() + + teachers := NewEntity( + "teachers", + []*Attribute{ + { + Type: "int", + Name: "id", + IsPrimaryKey: true, + IsForeignKey: false, + IsUniqueKey: true, + Comment: "Teacher ID", + }, + { + Type: "string", + Name: "name", + IsPrimaryKey: false, + IsForeignKey: false, + IsUniqueKey: false, + Comment: "Teacher Name", + }, + }, + ) + students := NewEntity( + "students", + []*Attribute{ + { + Type: "int", + Name: "id", + IsPrimaryKey: true, + IsForeignKey: false, + IsUniqueKey: true, + Comment: "Student ID", + }, + { + Type: "string", + Name: "name", + IsPrimaryKey: false, + IsForeignKey: false, + IsUniqueKey: false, + Comment: "Student Name", + }, + { + Type: "int", + Name: "teacher_id", + IsPrimaryKey: false, + IsForeignKey: true, + IsUniqueKey: true, + Comment: "Teacher ID", + }, + }, + ) + schools := NewEntity( + "schools", + []*Attribute{ + { + Type: "int", + Name: "id", + IsPrimaryKey: true, + IsForeignKey: false, + IsUniqueKey: true, + Comment: "School ID", + }, + { + Type: "string", + Name: "name", + IsPrimaryKey: false, + IsForeignKey: false, + IsUniqueKey: false, + Comment: "School Name", + }, + { + Type: "int", + Name: "teacher_id", + IsPrimaryKey: false, + IsForeignKey: true, + IsUniqueKey: true, + Comment: "Teacher ID", + }, + }, + ) + personalComputers := NewEntity( + "personal_computers", + []*Attribute{ + { + Type: "int", + Name: "id", + IsPrimaryKey: true, + IsForeignKey: false, + IsUniqueKey: true, + Comment: "Personal Computer ID", + }, + }, + ) + + b := new(bytes.Buffer) + d := NewDiagram(b). + Relationship( + teachers, + students, + ExactlyOneRelationship, + ZeroToMoreRelationship, + Identifying, + "Teacher has many students", + ). + Relationship( + teachers, + schools, + OneToMoreRelationship, + ExactlyOneRelationship, + NonIdentifying, + "School has many teachers", + ). + NoRelationship(personalComputers) + + if err := d.Build(); err != nil { + t.Fatalf("error should be nil: %v", err) + } + + want := `erDiagram + teachers ||--o{ students : "Teacher has many students" + teachers }|..|| schools : "School has many teachers" + personal_computers { + int id PK,UK "Personal Computer ID" + } + schools { + int id PK,UK "School ID" + string name "School Name" + int teacher_id FK,UK "Teacher ID" + } + students { + int id PK,UK "Student ID" + string name "Student Name" + int teacher_id FK,UK "Teacher ID" + } + teachers { + int id PK,UK "Teacher ID" + string name "Teacher Name" + } +` + want = strings.ReplaceAll(want, "\r\n", "\n") + got := strings.ReplaceAll(b.String(), "\r\n", "\n") + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("value is mismatch (-want +got):%s", diff) + } + }) +} diff --git a/mermaid/er/identify.go b/mermaid/er/identify.go new file mode 100644 index 0000000..7dd5b67 --- /dev/null +++ b/mermaid/er/identify.go @@ -0,0 +1,24 @@ +package er + +// Identify is a type that represents the relationship between entities +// in an entity relationship diagram. Relationships may be classified as +// either identifying or non-identifying and these are rendered with either +// solid or dashed lines respectively. +type Identify bool + +const ( + // Identifying is a constant that represents an identifying relationship. + // It represents "--" in the entity relationship diagram. + Identifying Identify = true + // NonIdentifying is a constant that represents a non-identifying relationship. + // It represents ".." in the entity relationship diagram. + NonIdentifying Identify = false +) + +// string converts the relationship to a mermaid synatax string. +func (i Identify) string() string { + if i == Identifying { + return "--" + } + return ".." +} diff --git a/mermaid/er/relationship.go b/mermaid/er/relationship.go new file mode 100644 index 0000000..7479c93 --- /dev/null +++ b/mermaid/er/relationship.go @@ -0,0 +1,51 @@ +package er + +// Relationship is a type that represents the relationship between entities. +type Relationship string + +const ( + // ZeroToOneRelationship is a constant that represents a zero to one relationship. + // e.g. "|o" or "o|" + ZeroToOneRelationship Relationship = "zero_to_one" + // ExactlyOneRelationship is a constant that represents an exactly one relationship. + // e.g. "||" + ExactlyOneRelationship Relationship = "exactly_one" + // ZeroToMoreRelationship is a constant that represents a zero to more relationship. + // e.g. "}o" or "o{}" + ZeroToMoreRelationship Relationship = "zero_to_more" + // OneToMoreRelationship is a constant that represents a one to more relationship. + // e.g. "}|" or "|}" + OneToMoreRelationship Relationship = "one_to_more" +) + +const ( + // left is a constant that represents the left side of the relationship. + left = true + // right is a constant that represents the right side of the relationship. + right = false +) + +// string converts the relationship to a mermaid synatax string. +func (r Relationship) string(lr bool) string { + switch r { + case ZeroToOneRelationship: + if lr == left { + return "|o" + } + return "o|" + case ExactlyOneRelationship: + return "||" + case ZeroToMoreRelationship: + if lr == left { + return "}o" + } + return "o{" + case OneToMoreRelationship: + if lr == left { + return "}|" + } + return "|{" + default: + return "" + } +} diff --git a/mermaid/er/relationship_test.go b/mermaid/er/relationship_test.go new file mode 100644 index 0000000..da23282 --- /dev/null +++ b/mermaid/er/relationship_test.go @@ -0,0 +1,77 @@ +package er + +import "testing" + +func TestRelationship_string(t *testing.T) { + type args struct { + lr bool + } + tests := []struct { + name string + r Relationship + args args + want string + }{ + { + name: "ZeroToOneRelationship, left", + r: ZeroToOneRelationship, + args: args{lr: left}, + want: "|o", + }, + { + name: "ZeroToOneRelationship, right", + r: ZeroToOneRelationship, + args: args{lr: right}, + want: "o|", + }, + { + name: "ExactlyOneRelationship, left", + r: ExactlyOneRelationship, + args: args{lr: left}, + want: "||", + }, + { + name: "ExactlyOneRelationship, right", + r: ExactlyOneRelationship, + args: args{lr: right}, + want: "||", + }, + { + name: "ZeroToMoreRelationship, left", + r: ZeroToMoreRelationship, + args: args{lr: left}, + want: "}o", + }, + { + name: "ZeroToMoreRelationship, right", + r: ZeroToMoreRelationship, + args: args{lr: right}, + want: "o{", + }, + { + name: "OneToMoreRelationship, left", + r: OneToMoreRelationship, + args: args{lr: left}, + want: "}|", + }, + { + name: "OneToMoreRelationship, right", + r: OneToMoreRelationship, + args: args{lr: right}, + want: "|{", + }, + { + name: "default", + r: "default", + args: args{lr: left}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.r.string(tt.args.lr); got != tt.want { + t.Errorf("Relationship.string() = %v, want %v", got, tt.want) + } + }) + } +}