defmodule Ash.Domain.Info.Diagram do @moduledoc """ Generate Mermaid diagrams from a specified domain. ## Limitations We can't easily model Ash relationships with Mermaid diagrams because they are unidirectional and could be asymmetric. Mermaid assumes symmetrical, bidirectional relationships. If we try to model all unidirectional relationships as separate lines in the diagram it gets very hard to read very quickly. """ @indent " " @show_private? false @argument_print_limit 4 @default_opts indent: @indent, show_private?: @show_private? defp resource_name(resource, opts) do if opts[:name] == :full do inspect(resource) else resource |> Ash.Resource.Info.short_name() |> to_string() |> Macro.camelize() end end defp domain_name(domain, opts) do if opts[:name] == :full do inspect(domain) else domain |> Ash.Domain.Info.short_name() |> to_string() |> Macro.camelize() end end defp short_module(module, opts) do if opts[:name] == :full do inspect(module) else module |> Module.split() |> List.last() end end defp normalise_relationships(domain) do for resource <- Ash.Domain.Info.resources(domain) 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, %{kind: :custom, type: type}, opts) do short_type(type, opts) end defp aggregate_type(resource, aggregate, opts) do attribute = if aggregate.field do related = Ash.Resource.Info.related(resource, aggregate.relationship_path) Ash.Resource.Info.attribute(related, aggregate.field) end attribute_type = if attribute do attribute.type end attribute_constraints = if attribute do attribute.constraints end {:ok, type, _constraints} = Ash.Query.Aggregate.kind_to_type(aggregate.kind, attribute_type, attribute_constraints) short_type(type, opts) end # default to one to one to just show connection defp rel_type, do: "||--||" defp short_type({:array, t}, opts), do: "ArrayOf#{short_type(t, opts)}" defp short_type(t, opts), do: short_module(t, opts) @doc """ Generates a Mermaid Entity Relationship Diagram for a given domain or list of domains. 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(domains, opts \\ @default_opts) do """ erDiagram #{domains |> List.wrap() |> Enum.map(&mermaid_er_domain_section(&1, opts))}\ """ end defp mermaid_er_domain_section(domain, opts) do indent = opts[:indent] || @indent show_private? = Access.get(opts, :show_private?, @show_private?) resources = for resource <- Ash.Domain.Info.resources(domain) do {attrs, calcs, aggs} = if show_private? do { Ash.Resource.Info.attributes(resource), Ash.Resource.Info.calculations(resource), Ash.Resource.Info.aggregates(resource) } else { Ash.Resource.Info.public_attributes(resource), Ash.Resource.Info.public_calculations(resource), Ash.Resource.Info.public_aggregates(resource) } end 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}#{inspect(resource_name(resource, opts))} { #{contents} #{indent}} """ end |> Enum.join() relationships = for {src, dest} <- normalise_relationships(domain) do ~s(#{indent}#{inspect(resource_name(src, opts))} #{rel_type()} #{inspect(resource_name(dest, opts))} : "") end |> Enum.join("\n") """ #{resources} #{relationships} """ end defp class_short_type({:array, t}, opts), do: "#{short_module(t, opts)}[]" defp class_short_type(t, opts), do: short_module(t, opts) defp join_template(list, indent, template_fn) do Enum.map_join(list, "\n", fn item -> "#{indent}#{indent}#{template_fn.(item)}" end) end defp list_arguments_and_attributes(resource, action, opts) do arguments = action.arguments attributes = case action do %{type: :action} -> [] %{type: :read} -> [] %{type: :destroy, soft?: false} -> [] %{accept: accept} -> accept |> Enum.map(&Ash.Resource.Info.attribute(resource, &1)) end list_arguments(Enum.uniq_by(arguments ++ attributes, & &1.name), opts) end defp list_arguments(arguments, opts) when length(arguments) > @argument_print_limit do {displayed_args, _rest} = Enum.split(arguments, @argument_print_limit) list_arguments(displayed_args, opts) <> ", ..." end defp list_arguments(arguments, opts) do arguments |> Enum.map_join(", ", &"#{class_short_type(&1.type, opts)} #{&1.name}") end @doc """ Generates a Mermaid Class Diagram for a given domain. 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(domain, opts \\ @default_opts) def mermaid_class_diagram(domains, opts) when is_list(domains) do indent = opts[:indent] || @indent show_private? = Access.get(opts, :show_private?, @show_private?) resources = for domain <- domains, resource <- Ash.Domain.Info.resources(domain) do actions = Ash.Resource.Info.actions(resource) {attrs, calcs, aggs, relationships} = if show_private? do { Ash.Resource.Info.attributes(resource), Ash.Resource.Info.calculations(resource), Ash.Resource.Info.aggregates(resource), Ash.Resource.Info.relationships(resource) } else {Ash.Resource.Info.public_attributes(resource), Ash.Resource.Info.public_calculations(resource), Ash.Resource.Info.public_aggregates(resource), Ash.Resource.Info.public_relationships(resource)} end contents = [ join_template(attrs, indent, &"#{class_short_type(&1.type, opts)} #{&1.name}"), join_template(calcs, indent, &"#{class_short_type(&1.type, opts)} #{&1.name}"), join_template(aggs, indent, &"#{aggregate_type(resource, &1, opts)} #{&1.name}"), join_template( relationships, indent, &"#{resource_name(&1.destination, opts)}#{if &1.cardinality == :many, do: "[]", else: ""} #{&1.name}" ), join_template( actions, indent, &"#{&1.name}(#{list_arguments_and_attributes(resource, &1, opts)})" ) ] |> Enum.reject(&(&1 == "")) |> Enum.join("\n") """ #{indent}class #{resource_name(resource, opts)} { #{indent}#{indent}Domain: #{domain_name(domain, opts)}#{source_link(resource, indent, opts)} #{contents} #{indent}} """ end |> Enum.join() relationships = for domain <- domains, {src, dest} <- normalise_relationships(domain) do ~s(#{indent}#{resource_name(src, opts)} -- #{resource_name(dest, opts)}) end |> Enum.join("\n") domains = Enum.map_join(domains, "\n", fn domain -> ~s(#{indent}#{domain_name(domain, opts)}) end) """ classDiagram #{domains} #{resources} #{relationships} """ end def mermaid_class_diagram(domain, opts) do indent = opts[:indent] || @indent show_private? = Access.get(opts, :show_private?, @show_private?) resources = for resource <- Ash.Domain.Info.resources(domain) do actions = Ash.Resource.Info.actions(resource) {attrs, calcs, aggs, relationships} = if show_private? do { Ash.Resource.Info.attributes(resource), Ash.Resource.Info.calculations(resource), Ash.Resource.Info.aggregates(resource), Ash.Resource.Info.relationships(resource) } else {Ash.Resource.Info.public_attributes(resource), Ash.Resource.Info.public_calculations(resource), Ash.Resource.Info.public_aggregates(resource), Ash.Resource.Info.public_relationships(resource)} end contents = [ join_template(attrs, indent, &"#{class_short_type(&1.type, opts)} #{&1.name}"), join_template(calcs, indent, &"#{class_short_type(&1.type, opts)} #{&1.name}"), join_template(aggs, indent, &"#{aggregate_type(resource, &1, opts)} #{&1.name}"), join_template( relationships, indent, &"#{resource_name(&1.destination, opts)}#{if &1.cardinality == :many, do: "[]", else: ""} #{&1.name}" ), join_template( actions, indent, &"#{&1.name}(#{list_arguments_and_attributes(resource, &1, opts)})" ) ] |> Enum.reject(&(&1 == "")) |> Enum.join("\n") """ #{indent}class #{resource_name(resource, opts)} { #{contents} #{indent}} """ end |> Enum.join() relationships = for {src, dest} <- normalise_relationships(domain) do ~s(#{indent}#{resource_name(src, opts)} -- #{resource_name(dest, opts)}) end |> Enum.join("\n") """ classDiagram #{resources} #{relationships} """ end defp source_link(module, indent, opts) do if opts[:include_source_links?] do module.module_info(:compile)[:source] |> List.to_string() |> Path.relative_to_cwd() |> then(&"\n#{indent}#{indent}Source: #{&1}\n") end rescue _ -> nil end end