feat: generate mermaid entity relationship diagrams from a given api (#376)

This commit is contained in:
Josh Price 2022-10-02 13:21:24 +11:00 committed by Zach Daniel
parent 6f5518de5c
commit 3d2f8277ad
3 changed files with 221 additions and 1 deletions

View file

@ -0,0 +1,166 @@
defmodule Ash.Api.Info.Diagram do
@moduledoc """
Generate Mermaid diagrams from a specified API.
## Limitations
We can't easily model Ash relationships with Mermaid diagrams
because they are unidirectional and could be asymmetric.
Mermaid assumes symmetrical, biredirectional relationships.
If we try to model all unidirectional realtionships as separate
lines in the diagram it gets very hard to read very quickly.
"""
@indent " "
@default_opts indent: @indent
defp resource_name(resource) do
resource
|> Ash.Resource.Info.short_name()
|> to_string()
|> Macro.camelize()
end
defp short_module(module) do
module
|> Module.split()
|> List.last()
end
defp normalise_relationships(api) do
for resource <- Ash.Api.Info.resources(api) do
for relationship <- Ash.Resource.Info.relationships(resource) do
[relationship.source, relationship.destination]
|> Enum.sort()
|> List.to_tuple()
end
end
|> Enum.flat_map(& &1)
|> Enum.uniq()
|> Enum.sort()
end
defp aggregate_type(resource, aggregate) do
attribute_type =
if aggregate.field do
related = Ash.Resource.Info.related(resource, aggregate.relationship_path)
Ash.Resource.Info.attribute(related, aggregate.field).type
end
Ash.Query.Aggregate.kind_to_type(aggregate.kind, attribute_type)
end
# default to one to one to just show connection
defp rel_type, do: "||--||"
defp short_type({:array, t}), do: "ArrayOf#{short_module(t)}"
defp short_type(t), do: short_module(t)
@doc """
Generates a Mermaid Entity Relationship Diagram for a given API.
Shows only public attributes, calculations, aggregates and actions.
Shows a one-to-one line for relationships as enumerating all unidirectional
relationships is far too noisy.
"""
def mermaid_er_diagram(api, opts \\ @default_opts) do
indent = opts[:indent] || @indent
resources =
for resource <- Ash.Api.Info.resources(api) do
attrs = Ash.Resource.Info.public_attributes(resource)
calcs = Ash.Resource.Info.public_calculations(resource)
aggs = Ash.Resource.Info.public_aggregates(resource)
contents =
[
join_template(attrs, indent, &"#{short_type(&1.type)} #{&1.name}"),
join_template(calcs, indent, &"#{short_type(&1.type)} #{&1.name}"),
join_template(aggs, indent, &"#{aggregate_type(resource, &1)} #{&1.name}")
]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
"""
#{indent}#{resource_name(resource)} {
#{contents}
#{indent}}
"""
end
|> Enum.join()
relationships =
for {src, dest} <- normalise_relationships(api) do
~s(#{indent}#{resource_name(src)} #{rel_type()} #{resource_name(dest)} : "")
end
|> Enum.join("\n")
"""
erDiagram
#{resources}
#{relationships}
"""
end
defp class_short_type({:array, t}), do: "#{short_module(t)}[]"
defp class_short_type(t), do: short_module(t)
defp join_template(list, indent, template_fn) do
Enum.map_join(list, "\n", fn item -> "#{indent}#{indent}#{template_fn.(item)}" end)
end
@doc """
Generates a Mermaid Class Diagram for a given API.
Shows only public attributes, calculations, aggregates and actions.
Shows a connecting line for relationships with the type of relationship
indicated in the attribute list.
"""
def mermaid_class_diagram(api, opts \\ @default_opts) do
indent = opts[:indent] || @indent
resources =
for resource <- Ash.Api.Info.resources(api) do
attrs = Ash.Resource.Info.public_attributes(resource)
calcs = Ash.Resource.Info.public_calculations(resource)
aggs = Ash.Resource.Info.public_aggregates(resource)
actions = Ash.Resource.Info.actions(resource)
relationships = Ash.Resource.Info.public_relationships(resource)
contents =
[
join_template(attrs, indent, &"#{class_short_type(&1.type)} #{&1.name}"),
join_template(calcs, indent, &"#{class_short_type(&1.type)} #{&1.name}"),
join_template(aggs, indent, &"#{aggregate_type(resource, &1)} #{&1.name}"),
join_template(
relationships,
indent,
&"#{resource_name(&1.destination)}#{if &1.cardinality == :many, do: "[]", else: ""} #{&1.name}"
),
join_template(actions, indent, &"#{&1.name}()")
]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
"""
#{indent}class #{resource_name(resource)} {
#{contents}
#{indent}}
"""
end
|> Enum.join()
relationships =
for {src, dest} <- normalise_relationships(api) do
~s(#{indent}#{resource_name(src)} -- #{resource_name(dest)})
end
|> Enum.join("\n")
"""
classDiagram
#{resources}
#{relationships}
"""
end
end

View file

@ -11,7 +11,7 @@ defmodule Ash.Resource.Change.RelateActor do
type: :atom
],
allow_nil?: [
doc: "Wether or not to allow the actor to be nil, in which case nothing will happen.",
doc: "Whether or not to allow the actor to be nil, in which case nothing will happen.",
type: :boolean,
default: false
]

54
test/api/diagram_test.exs Normal file
View file

@ -0,0 +1,54 @@
defmodule Ash.Test.Api.Info.DiagramTest do
@moduledoc false
use ExUnit.Case, async: true
test "generate a mermaid entity relationship diagram from an Api" do
assert Ash.Api.Info.Diagram.mermaid_er_diagram(Ash.Test.Support.Flow.Api) == """
erDiagram
User {
UUID id
String first_name
String last_name
String email
}
Org {
UUID id
String name
}
Org ||--|| User : ""
"""
end
test "generate a mermaid class diagram from an Api" do
assert Ash.Api.Info.Diagram.mermaid_class_diagram(Ash.Test.Support.Flow.Api) == """
classDiagram
class User {
UUID id
String first_name
String last_name
String email
Org org
destroy()
read()
for_org()
create()
update()
approve()
unapprove()
}
class Org {
UUID id
String name
User[] users
destroy()
update()
read()
create()
by_name()
}
Org -- User
"""
end
end