diff --git a/README.md b/README.md index e2ce1ff..17e5531 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,14 @@ Supported formats: * [DBML](https://www.dbml.org/) * [QuickDBD](https://www.quickdatabasediagrams.com) * [Mermaid](https://mermaid-js.github.io/mermaid/#/entityRelationshipDiagram) +* [D2](https://d2lang.com) ![Simple blog demo](assets/simple_blog_dot_demo.png) + +The **D2** format renders the rich tier — clusters, `Ecto.Enum` values, embedded schemas, and primary/foreign keys: + +![Simple blog demo (D2)](assets/simple_blog_d2_demo.svg) +
Definition of schemas diff --git a/assets/simple_blog_d2_demo.svg b/assets/simple_blog_d2_demo.svg new file mode 100644 index 0000000..c5ca49a --- /dev/null +++ b/assets/simple_blog_d2_demo.svg @@ -0,0 +1,95 @@ +Blog.Comment:id:id PK:text:string:inserted_at:naive_datetime:updated_at:naive_datetime:post_id:idFK:user_id:idFKBlog.Post:id:id PK:title:string:text:string:inserted_at:naive_datetime:updated_at:naive_datetime:user_id:idFKBlog.User:id:id PK:email:string + + + diff --git a/examples_generator.exs b/examples_generator.exs index b9f64ef..81b90ce 100644 --- a/examples_generator.exs +++ b/examples_generator.exs @@ -2,10 +2,10 @@ defmodule Ecto.ERD.ExamplesGenerator do require Logger @shared_examples [ - [name: "Default", formats: [:dbml, :dot, :qdbd, :puml, :mmd]], + [name: "Default", formats: [:dbml, :dot, :qdbd, :puml, :mmd, :d2]], [ name: "No fields", - formats: [:dot, :mmd], + formats: [:dot, :mmd, :d2], config: """ [ columns: [] @@ -14,7 +14,7 @@ defmodule Ecto.ERD.ExamplesGenerator do ], [ name: "Contexts as clusters", - formats: [:dbml, :dot, :puml], + formats: [:dbml, :dot, :puml, :d2], config: """ alias Ecto.ERD.Node @@ -32,7 +32,7 @@ defmodule Ecto.ERD.ExamplesGenerator do ], [ name: "Contexts as clusters (no fields)", - formats: [:dot, :puml], + formats: [:dot, :puml, :d2], config: """ alias Ecto.ERD.Node @@ -150,7 +150,7 @@ defmodule Ecto.ERD.ExamplesGenerator do ], [ name: "Only embedded schemas", - formats: [:dot, :puml], + formats: [:dot, :puml, :d2], config: """ alias Ecto.ERD.Node @@ -167,11 +167,12 @@ defmodule Ecto.ERD.ExamplesGenerator do } @formats %{ - dot: %{examples_dir: "examples/dot", name: "DOT", image?: true}, - dbml: %{examples_dir: "examples/dbml", name: "DBML", image?: false}, - qdbd: %{examples_dir: "examples/quick_dbd", name: "QuickDBD", image?: false}, - puml: %{examples_dir: "examples/plantuml", name: "PlantUML", image?: true}, - mmd: %{examples_dir: "examples/mermaid", name: "Mermaid", image?: false} + dot: %{examples_dir: "examples/dot", name: "DOT", image_ext: "png"}, + dbml: %{examples_dir: "examples/dbml", name: "DBML"}, + qdbd: %{examples_dir: "examples/quick_dbd", name: "QuickDBD"}, + puml: %{examples_dir: "examples/plantuml", name: "PlantUML", image_ext: "png"}, + mmd: %{examples_dir: "examples/mermaid", name: "Mermaid"}, + d2: %{examples_dir: "examples/d2", name: "D2", image_ext: "svg"} } def run(source_url_root) do @@ -235,8 +236,8 @@ defmodule Ecto.ERD.ExamplesGenerator do ]) image_url = - if @formats[format].image? do - Path.rootname(document_url) <> ".png" + if image_ext = @formats[format][:image_ext] do + Path.rootname(document_url) <> "." <> image_ext end [ @@ -313,10 +314,11 @@ defmodule Ecto.ERD.ExamplesGenerator do end {:ok, new_output_content} = File.read(output_path) + image_ext = @formats[format][:image_ext] - if @formats[format].image? and + if image_ext && (old_output_content != new_output_content or - not File.exists?(Path.rootname(output_path) <> ".png")) do + not File.exists?(Path.rootname(output_path) <> "." <> image_ext)) do Logger.debug("Generating image from #{output_path}") generate_image(format, output_path) end @@ -333,6 +335,10 @@ defmodule Ecto.ERD.ExamplesGenerator do System.cmd("plantuml", [file], env: %{"PLANTUML_LIMIT_SIZE" => "8192"}) end + defp generate_image(:d2, file) do + System.cmd("d2", [file, Path.rootname(file) <> ".svg"]) + end + defp init_project(project_name, repo, commit) do Logger.debug("#{project_name}: clone repo") System.cmd("git", ["clone", repo, project_name], cd: "tmp/repos") diff --git a/lib/ecto/erd/document.ex b/lib/ecto/erd/document.ex index 2ce4a19..3e82853 100644 --- a/lib/ecto/erd/document.ex +++ b/lib/ecto/erd/document.ex @@ -7,6 +7,7 @@ defmodule Ecto.ERD.Document do when is_function(map_node_callback, 1) do document_module = case format do + ".d2" -> Ecto.ERD.Document.D2 ".dbml" -> Ecto.ERD.Document.DBML ".dot" -> Ecto.ERD.Document.Dot ".mmd" -> Ecto.ERD.Document.Mermaid diff --git a/lib/ecto/erd/document/d2.ex b/lib/ecto/erd/document/d2.ex new file mode 100644 index 0000000..f2ceaf7 --- /dev/null +++ b/lib/ecto/erd/document/d2.ex @@ -0,0 +1,287 @@ +defmodule Ecto.ERD.Document.D2 do + @moduledoc false + alias Ecto.ERD.{Node, Field, Edge, Graph, Render} + @behaviour Ecto.ERD.Document + + # Use the ELK layout engine: it routes connections orthogonally, so crow's-foot + # arrowheads sit flush against sql_table borders. dagre (d2's default) tilts them + # on angled approaches, e.g. across cluster containers. Overridable via `d2 --layout`. + @layout_config """ + vars: { + d2-config: { + layout-engine: elk + } + }\ + """ + + @impl true + def schemaless?, do: false + + @impl true + def render(%Graph{nodes: nodes, edges: edges}, opts) do + skip_port? = skip_port?(opts[:columns] || [:name, :type]) + edges = if skip_port?, do: dedup_node_pairs(edges), else: edges + fk_fields = foreign_key_fields(edges) + + clusters = Enum.group_by(nodes, & &1.cluster) + {global_nodes, clustered} = Map.pop(clusters, nil) + global_nodes = List.wrap(global_nodes) + + global_node_keys = node_keys(global_nodes) + container_keys = container_keys(Map.values(global_node_keys), Map.keys(clustered)) + node_paths = node_paths(global_nodes, clustered, global_node_keys, container_keys) + + parts = + [@layout_config, "direction: right"] ++ + Enum.map(global_nodes, fn node -> + {nil, node_key} = Map.fetch!(node_paths, node_ref(node)) + render_node(node, node_key, nil, fk_fields, opts, skip_port?) + end) ++ + Enum.flat_map(clustered, fn {cluster, cluster_nodes} -> + container_key = Map.fetch!(container_keys, cluster) + + [ + render_container(cluster, container_key) + | Enum.map(cluster_nodes, fn node -> + {^container_key, node_key} = Map.fetch!(node_paths, node_ref(node)) + render_node(node, node_key, container_key, fk_fields, opts, skip_port?) + end) + ] + end) ++ + Enum.map(edges, &render_edge(&1, node_paths, skip_port?)) + + Enum.join(parts, "\n\n") <> "\n" + end + + # `sql_table` rows are fixed name/type pairs + defp skip_port?([]), do: true + defp skip_port?([:name, :type]), do: false + + defp skip_port?(_columns) do + raise """ + D2 supports only the default [:name, :type] columns or [] to hide fields. + sql_table rows are fixed name/type pairs, so [:type] or reordered subsets + are impossible; [:name] alone would compile but is unsupported for parity + with Mermaid. + """ + end + + # Without field ports, edges between the same node pair collapse to identical + # `A <-> B` connections, which d2 draws as duplicate lines. Keep the first + # edge per ordered pair (mirrors PlantUML's uniq_by). + defp dedup_node_pairs(edges) do + Enum.uniq_by(edges, fn %Edge{ + from: {from_source, from_schema, _}, + to: {to_source, to_schema, _} + } -> + {{from_source, from_schema}, {to_source, to_schema}} + end) + end + + # A field is a foreign key when it is the `to` endpoint of an edge (mirrors + # QuickDBD's mapping). Header-port edges (embeds) are skipped by the pattern. + defp foreign_key_fields(edges) do + for %Edge{to: {to_source, to_schema, {:field, to_field}}} <- edges, + into: MapSet.new() do + {{to_source, to_schema}, to_field} + end + end + + # D2 folds keys case-insensitively, even when quoted. Allocate a distinct + # internal key for nodes which would otherwise merge, while rendering their + # original id through `label`. Keys are scoped like D2: global nodes share the + # top-level namespace, while nodes in each container have their own namespace. + defp node_keys(nodes) do + {keys, _used} = + Enum.map_reduce(nodes, MapSet.new(), fn node, used -> + key = unique_key(Node.id(node.source, node.schema_module), used, "node_") + {{{node.source, node.schema_module}, key}, MapSet.put(used, String.downcase(key))} + end) + + Map.new(keys) + end + + # Container keys need a prefix: a cluster named like a global node's internal + # key makes d2 fail to compile with "sql_table columns cannot have children" + # (or silently merge bare nodes). Escalate the shared prefix until those keys + # clear the global namespace, then uniquify case-only cluster collisions. + defp container_keys(global_node_keys, cluster_names) do + prefix = cluster_prefix(global_node_keys, cluster_names) + used = MapSet.new(global_node_keys, &String.downcase/1) + + {keys, _used} = + cluster_names + |> Enum.sort() + |> Enum.map_reduce(used, fn cluster, used -> + key = unique_key(prefix <> cluster, used, "cluster_") + {{cluster, key}, MapSet.put(used, String.downcase(key))} + end) + + Map.new(keys) + end + + # A static "cluster_" prefix only moves a global-node collision onto nodes + # literally named e.g. "cluster_Accounts". Terminates because the prefix + # eventually outgrows every global node key. + defp cluster_prefix(global_node_keys, cluster_names) do + node_keys = MapSet.new(global_node_keys, &String.downcase/1) + + "cluster_" + |> Stream.iterate(&("cluster_" <> &1)) + |> Enum.find(fn prefix -> + cluster_names + |> MapSet.new(&String.downcase(prefix <> &1)) + |> MapSet.disjoint?(node_keys) + end) + end + + defp unique_key(candidate, used, prefix) do + candidate + |> Stream.iterate(&(prefix <> &1)) + |> Enum.find(&(not MapSet.member?(used, String.downcase(&1)))) + end + + defp node_paths(global_nodes, clustered, global_node_keys, container_keys) do + global_paths = + Enum.map(global_nodes, fn node -> + {node_ref(node), {nil, Map.fetch!(global_node_keys, node_ref(node))}} + end) + + clustered_paths = + Enum.flat_map(clustered, fn {cluster, nodes} -> + container_key = Map.fetch!(container_keys, cluster) + keys = node_keys(nodes) + + Enum.map(nodes, fn node -> + {node_ref(node), {container_key, Map.fetch!(keys, node_ref(node))}} + end) + end) + + Map.new(global_paths ++ clustered_paths) + end + + defp render_container(cluster, container_key) do + "#{quoted(container_key)}: {\n label: #{quoted(cluster)}\n style.fill: #{quoted(Ecto.ERD.Color.get(cluster))}\n}" + end + + defp render_node( + %Node{source: source, schema_module: schema_module}, + node_key, + container_key, + _fk, + _opts, + true + ) do + node_id = Node.id(source, schema_module) + path = qualified_key(node_key, container_key) + if node_key == node_id, do: path, else: path <> ": " <> quoted(node_id) + end + + defp render_node( + %Node{source: source, schema_module: schema_module, fields: fields}, + node_key, + container_key, + fk_fields, + opts, + false + ) do + node_id = Node.id(source, schema_module) + + rows = + Enum.map(fields, fn %Field{name: name, type: type, primary?: primary?} -> + constraint = + constraint(primary?, MapSet.member?(fk_fields, {{source, schema_module}, name})) + + name_text = inspect(name) + + # d2 suppresses a sql_table row's value when it equals the row key + # (verified v0.7.1), blanking the type cell — e.g. an `:id` field of + # type `:id`. Pad with a trailing space, which is invisible in the + # rendered SVG. Compare case-insensitively: d2 keys fold case, a + # false-positive pad is harmless while a false negative blanks the cell. + type_text = Render.elixir_type(type, opts) + + type_text = + if String.downcase(type_text) == String.downcase(name_text), + do: type_text <> " ", + else: type_text + + " #{quoted(name_text)}: #{quoted(type_text)}" <> constraint + end) + + label = if node_key == node_id, do: [], else: [" label: #{quoted(node_id)}"] + + ([qualified_key(node_key, container_key) <> ": {", " shape: sql_table"] ++ + label ++ rows ++ ["}"]) + |> Enum.join("\n") + end + + defp constraint(primary?, foreign?) do + case {primary?, foreign?} do + {true, true} -> " {constraint: [primary_key; foreign_key]}" + {true, false} -> " {constraint: primary_key}" + {false, true} -> " {constraint: foreign_key}" + {false, false} -> "" + end + end + + defp render_edge(%Edge{from: from, to: to, assoc_types: assoc_types}, node_paths, skip_port?) do + # d2 ignores source-arrowhead on `->` but renders both ends on `<->`, so we + # use `<->` and mark both: the primary-key end (`from`) as exactly-one + # (cf-one-required), the foreign-key end (`to`) as cf-one for has_one, else + # cf-many-required. + target_shape = if {:has, :one} in assoc_types, do: "cf-one", else: "cf-many-required" + + from_endpoint = render_endpoint(from, node_paths, skip_port?) + to_endpoint = render_endpoint(to, node_paths, skip_port?) + + [ + "#{from_endpoint} <-> #{to_endpoint}: {", + " source-arrowhead.shape: cf-one-required", + " target-arrowhead.shape: #{target_shape}", + "}" + ] + |> Enum.join("\n") + end + + defp render_endpoint({source, schema_module, {:field, field}}, node_paths, skip_port?) do + {container_key, node_key} = Map.fetch!(node_paths, node_ref(source, schema_module)) + base = qualified_key(node_key, container_key) + if skip_port?, do: base, else: base <> "." <> quoted(inspect(field)) + end + + # Embeds point at the embedded schema's table header, so there is no field port. + defp render_endpoint({source, schema_module, {:header, _}}, node_paths, _skip_port?) do + {container_key, node_key} = Map.fetch!(node_paths, node_ref(source, schema_module)) + qualified_key(node_key, container_key) + end + + # The container key already carries its escalated prefix (see + # cluster_prefix/2); the displayed name comes from the container's `label`, + # mirroring DOT's subgraph naming. + defp qualified_key(node_id, nil), do: quoted(node_id) + + defp qualified_key(node_id, container_key), + do: quoted(container_key) <> "." <> quoted(node_id) + + defp node_ref(%Node{source: source, schema_module: schema_module}), + do: node_ref(source, schema_module) + + defp node_ref(source, schema_module), do: {source, schema_module} + + # Escape order is load-bearing: backslashes first, or the backslashes added + # for `"` and `${` would themselves get doubled. `${` triggers d2 variable + # substitution inside double-quoted strings (undefined vars fail to compile) + # and a raw newline terminates the string at the parser level. + defp quoted(value) do + escaped = + value + |> to_string() + |> String.replace("\\", "\\\\") + |> String.replace("\"", "\\\"") + |> String.replace("${", "\\${") + |> String.replace("\n", "\\n") + + "\"" <> escaped <> "\"" + end +end diff --git a/lib/ecto/erd/document/dot.ex b/lib/ecto/erd/document/dot.ex index 4b29783..b621d88 100644 --- a/lib/ecto/erd/document/dot.ex +++ b/lib/ecto/erd/document/dot.ex @@ -1,6 +1,6 @@ defmodule Ecto.ERD.Document.Dot do @moduledoc false - alias Ecto.ERD.{HTML, Edge, Node, Field, Graph, Render, EnumValues} + alias Ecto.ERD.{HTML, Edge, Node, Field, Graph, Render} @behaviour Ecto.ERD.Document @impl true @@ -145,31 +145,5 @@ defmodule Ecto.ERD.Document.Dot do defp format_field(%Field{name: name}, :name, _opts), do: inspect(name) - defp format_field(%Field{type: type}, :type, opts), do: format_type(type, opts) - - defp format_type({:parameterized, {Ecto.Enum, %{on_dump: on_dump}}}, opts) do - opts = - opts - |> Keyword.put_new(:enum_values_order, :asc) - |> Keyword.put_new(:enum_values_limit, 10) - - {values, truncated?} = EnumValues.prepare(Map.keys(on_dump), opts) - rendered = values |> Enum.map(&inspect/1) |> Enum.join(", ") - suffix = if truncated?, do: ", ...", else: "" - "#Enum<[#{rendered}#{suffix}]>" - end - - defp format_type( - {:parameterized, - {Ecto.Embedded, %Ecto.Embedded{cardinality: cardinality, related: related}}}, - _opts - ) do - "#Ecto.Embedded<#{inspect([{cardinality, related}])}>" - end - - defp format_type({:array, type}, opts) do - "{:array, #{format_type(type, opts)}}" - end - - defp format_type(type, _opts), do: inspect(type) + defp format_field(%Field{type: type}, :type, opts), do: Render.elixir_type(type, opts) end diff --git a/lib/ecto/erd/render.ex b/lib/ecto/erd/render.ex index 193c8d1..cc19294 100644 --- a/lib/ecto/erd/render.ex +++ b/lib/ecto/erd/render.ex @@ -1,5 +1,6 @@ defmodule Ecto.ERD.Render do @moduledoc false + alias Ecto.ERD.EnumValues def in_quotes(value, pattern \\ ~r/^[a-z\d_]+$/i) do value = to_string(value) @@ -11,4 +12,38 @@ defmodule Ecto.ERD.Render do "\"" <> String.replace(value, "\"", "\\\"") <> "\"" end end + + @doc """ + Formats an Ecto type as Elixir-flavored inspect output. + + Enums render as `#Enum<[...]>` honoring `:enum_values_order` and + `:enum_values_limit` (defaulting to `:asc` and `10` when unset), embeds as + `#Ecto.Embedded<...>`, and arrays recurse into the element type. + """ + @spec elixir_type(term(), keyword()) :: String.t() + def elixir_type({:parameterized, {Ecto.Enum, %{on_dump: on_dump}}}, opts) do + opts = + opts + |> Keyword.put_new(:enum_values_order, :asc) + |> Keyword.put_new(:enum_values_limit, 10) + + {values, truncated?} = EnumValues.prepare(Map.keys(on_dump), opts) + rendered = values |> Enum.map(&inspect/1) |> Enum.join(", ") + suffix = if truncated?, do: ", ...", else: "" + "#Enum<[#{rendered}#{suffix}]>" + end + + def elixir_type( + {:parameterized, + {Ecto.Embedded, %Ecto.Embedded{cardinality: cardinality, related: related}}}, + _opts + ) do + "#Ecto.Embedded<#{inspect([{cardinality, related}])}>" + end + + def elixir_type({:array, type}, opts) do + "{:array, #{elixir_type(type, opts)}}" + end + + def elixir_type(type, _opts), do: inspect(type) end diff --git a/lib/mix/tasks/ecto.gen.erd.ex b/lib/mix/tasks/ecto.gen.erd.ex index b0da4a7..7a1a223 100644 --- a/lib/mix/tasks/ecto.gen.erd.ex +++ b/lib/mix/tasks/ecto.gen.erd.ex @@ -8,6 +8,7 @@ defmodule Mix.Tasks.Ecto.Gen.Erd do * [DBML](#module-dbml) * [QuickDBD](#module-quickdbd) * [Mermaid](#module-mermaid) + * [D2](#module-d2) Configuration examples and sample output for a few open-source projects can be found in the PAGES section under EXAMPLES. @@ -78,9 +79,34 @@ defmodule Mix.Tasks.Ecto.Gen.Erd do ``` The default `-w` and `-H` values are small (`800` and `600`, respectively), so it's better to provide larger values from the start. + ## D2 + + [D2](https://d2lang.com) is a diagramming language. Like [DOT](#module-dot) and [PlantUML](#module-plantuml), + it can represent all available entity types: + * schemas + * embedded schemas + * schemaless tables (automatically derived from many-to-many relations) + + Tables use the `sql_table` shape with crow's-foot relationships, and primary and foreign keys are + marked with native constraints. Clusters are supported as containers and can be set via the + `:map_node` option using `Ecto.ERD.Node.set_cluster/2`. + + Install [d2](https://d2lang.com) to convert a `*.d2` file to an image. SVG output is native and + dependency-free: + + ``` + $ mix ecto.gen.erd --output-path=ecto_erd.d2 + $ mix ecto.gen.erd --output-path=ecto_erd.d2 && d2 ecto_erd.d2 erd.svg + ``` + + The generated file requests d2's bundled ELK layout engine, which routes connections orthogonally + so crow's-foot relationships sit flush against the tables. Pass `d2 --layout dagre` to override. + + *Note: `:fontname` is not supported for `d2`, and `:columns` accepts only `[]` (to hide fields) or the default `[:name, :type]`.* + ## Command line options - * `--output-path` - path to the output file. Defaults to `ecto_erd.dot`. Supported file extensions: `dot`, `puml`, `dbml`, `qdbd`. + * `--output-path` - path to the output file. Defaults to `ecto_erd.dot`. Supported file extensions: `dot`, `puml`, `dbml`, `qdbd`, `mmd`, `d2`. * `--config-path` - path to the config file. Defaults to `.ecto_erd.exs`. ## Configuration file @@ -93,24 +119,24 @@ defmodule Mix.Tasks.Ecto.Gen.Erd do * `:fontname` - font name. Defaults to `Roboto Mono`. Must be a monospaced font if the output format is `dot` and more than one column is displayed. Supported only for `dot` and `puml` files. * `:columns` - list of columns displayed for each node (schema/source). Set to `[]` to hide fields completely. - Available columns: `:name`, `:type`. Supported for `dot`, `puml`, and `mmd` (`mmd` allows only `[]` or the default value). + Available columns: `:name`, `:type`. Supported for `dot`, `puml`, `mmd`, and `d2` (`mmd` and `d2` allow only `[]` or the default value). Default values: - * `[:name, :type]` for `dot` and `puml` + * `[:name, :type]` for `dot`, `puml`, and `d2` * `[:type, :name]` for `mmd` * `:map_node` - a function that removes a node from the diagram or assigns it to a cluster. Defaults to `Function.identity/1`, which means all nodes are displayed outside any cluster by default. Use `Ecto.ERD.Node.set_cluster/2` in this function to set a cluster. Supported only by [DOT](#module-dot), [PlantUML](#module-plantuml), - and [DBML](#module-dbml). + [DBML](#module-dbml), and [D2](#module-d2). Return `nil` to remove a node from the diagram. * `:otp_app` - the application to scan (along with its dependencies) to collect Ecto schemas. Defaults to `Mix.Project.config()[:app]`. Configure this only when running the task from an umbrella root. * `:enum_values_limit` - maximum number of values to display for `Ecto.Enum` fields. Set to a non-negative integer or `:infinity` to display all values. When omitted, each format keeps its historical default: - `dot` and `puml` truncate at `10`, `dbml` and `qdbd` list all values (`:infinity`). When the actual list + `dot`, `puml`, and `d2` truncate at `10`, `dbml` and `qdbd` list all values (`:infinity`). When the actual list is larger than the limit, the rendered output is suffixed with `...` to indicate truncation. * `:enum_values_order` - sort order applied to `Ecto.Enum` values. Either `:asc` or `:desc`. When omitted, - each format keeps its historical default: `dot` and `dbml` sort ascending, `puml` and `qdbd` preserve the - original order from the schema definition. + each format keeps its historical default: `dot`, `dbml`, and `d2` sort ascending, `puml` and `qdbd` preserve + the original order from the schema definition. A configuration file with default values for `dot` can look like this: diff --git a/test/ecto/erd_test.exs b/test/ecto/erd_test.exs index edee64e..94c0e8b 100644 --- a/test/ecto/erd_test.exs +++ b/test/ecto/erd_test.exs @@ -1,7 +1,7 @@ defmodule Ecto.ERDTest do use ExUnit.Case - alias Ecto.ERD.{Node, Field, Graph} - alias Ecto.ERD.Document.{DBML, Dot, PlantUML, QuickDBD} + alias Ecto.ERD.{Node, Field, Graph, Edge} + alias Ecto.ERD.Document.{D2, DBML, Dot, PlantUML, QuickDBD} defp enum_field(values) do Field.new(%{ @@ -116,6 +116,15 @@ defmodule Ecto.ERDTest do "#Enum<[:c, :b, :a]>" end + test "D2 defaults to :asc" do + assert D2.render(enum_graph([:b, :a, :c]), []) =~ ~s("#Enum<[:a, :b, :c]>") + end + + test "D2 respects :desc" do + assert D2.render(enum_graph([:b, :a, :c]), enum_values_order: :desc) =~ + ~s("#Enum<[:c, :b, :a]>") + end + test "PlantUML preserves on_dump order by default" do values = [:b, :a, :c] %{on_dump: on_dump} = Ecto.Enum.init(values: values) @@ -170,6 +179,30 @@ defmodule Ecto.ERDTest do assert result =~ "#Enum<[:a, :b, :c]>" end + test "D2 truncates at 10 by default with `...` suffix" do + result = D2.render(enum_graph(@values_11), []) + assert result =~ "..." + refute result =~ ":v9," + end + + test "D2 lists everything with :infinity" do + result = D2.render(enum_graph(@values_11), enum_values_limit: :infinity) + refute result =~ "..." + assert result =~ ":v11" + assert result =~ ":v9" + end + + test "D2 honors a custom integer limit" do + result = D2.render(enum_graph([:a, :b, :c, :d]), enum_values_limit: 2) + assert result =~ ~s("#Enum<[:a, :b, ...]>") + end + + test "D2 does not append `...` when limit equals length" do + result = D2.render(enum_graph([:a, :b, :c]), enum_values_limit: 3) + refute result =~ "..." + assert result =~ ~s("#Enum<[:a, :b, :c]>") + end + test "PlantUML truncates at 10 by default" do assert PlantUML.render(enum_graph(@values_11), []) =~ "enum(v1,v2,v3,v4,v5,v6,v7,v8,v9,v10,...)" @@ -210,4 +243,665 @@ defmodule Ecto.ERDTest do assert result =~ "..." end end + + describe "D2 format" do + test "renders a schema as a quoted sql_table with left-to-right direction" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [ + Field.new(%{name: :id, type: :integer, primary?: true}), + Field.new(%{name: :email, type: :string, primary?: false}) + ] + } + ], + edges: [] + } + + result = D2.render(graph, []) + + assert result =~ "direction: right" + assert result =~ ~s("MyApp.User": {) + assert result =~ "shape: sql_table" + assert result =~ ~s(":id": ":integer") + assert result =~ ~s(":email": ":string") + end + + test "configures the ELK layout engine so crow's-foot arrowheads sit flush" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + } + ], + edges: [] + } + + assert D2.render(graph, []) =~ "layout-engine: elk" + end + + test "marks a primary key field with a primary_key constraint" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [ + Field.new(%{name: :id, type: :integer, primary?: true}), + Field.new(%{name: :email, type: :string, primary?: false}) + ] + } + ], + edges: [] + } + + result = D2.render(graph, []) + + assert result =~ ~s(":id": ":integer" {constraint: primary_key}) + refute result =~ ~s(":email": ":string" {constraint) + end + + test "pads the type with a trailing space when it equals the field name" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [Field.new(%{name: :id, type: :id, primary?: true})] + } + ], + edges: [] + } + + # without the pad, d2 suppresses the value and blanks the type cell + assert D2.render(graph, []) =~ ~s(":id": ":id " {constraint: primary_key}) + end + + test "does not pad a type that differs from the field name" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [Field.new(%{name: :email, type: :string})] + } + ], + edges: [] + } + + assert D2.render(graph, []) =~ ~s(":email": ":string"\n) + end + + test "pads on a case-only match because d2 keys fold case" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [Field.new(%{name: :ID, type: :id})] + } + ], + edges: [] + } + + assert D2.render(graph, []) =~ ~s(":ID": ":id ") + end + + test "renders array types Elixir-style, recursing into the element type" do + enum_type = {:parameterized, {Ecto.Enum, Ecto.Enum.init(values: [:a, :b])}} + + graph = %Graph{ + nodes: [ + %Node{ + source: "posts", + schema_module: MyApp.Post, + fields: [Field.new(%{name: :tags, type: {:array, enum_type}})] + } + ], + edges: [] + } + + assert D2.render(graph, []) =~ ~s(":tags": "{:array, #Enum<[:a, :b]>}") + end + + test "renders a has_many edge as crow's-foot between field ports" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "posts", + schema_module: MyApp.Post, + fields: [ + Field.new(%{name: :id, type: :integer, primary?: true}), + Field.new(%{name: :user_id, type: :integer}) + ] + } + ], + edges: [ + %Edge{ + from: {"users", MyApp.User, {:field, :id}}, + to: {"posts", MyApp.Post, {:field, :user_id}}, + assoc_types: [has: :many] + } + ] + } + + result = D2.render(graph, []) + + assert result =~ ~s("MyApp.User".":id" <-> "MyApp.Post".":user_id") + assert result =~ "source-arrowhead.shape: cf-one-required" + assert result =~ "target-arrowhead.shape: cf-many-required" + end + + test "renders a has_one edge with a cf-one target arrowhead" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "avatars", + schema_module: MyApp.Avatar, + fields: [Field.new(%{name: :user_id, type: :integer})] + } + ], + edges: [ + %Edge{ + from: {"users", MyApp.User, {:field, :id}}, + to: {"avatars", MyApp.Avatar, {:field, :user_id}}, + assoc_types: [has: :one] + } + ] + } + + result = D2.render(graph, []) + + assert result =~ "source-arrowhead.shape: cf-one-required" + assert result =~ "target-arrowhead.shape: cf-one\n" + refute result =~ "cf-many" + end + + test "marks an edge-target field with a foreign_key constraint" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "posts", + schema_module: MyApp.Post, + fields: [ + Field.new(%{name: :id, type: :integer, primary?: true}), + Field.new(%{name: :user_id, type: :integer}) + ] + } + ], + edges: [ + %Edge{ + from: {"users", MyApp.User, {:field, :id}}, + to: {"posts", MyApp.Post, {:field, :user_id}}, + assoc_types: [has: :many] + } + ] + } + + assert D2.render(graph, []) =~ ~s(":user_id": ":integer" {constraint: foreign_key}) + end + + test "marks a field that is both primary and foreign with both constraints" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "user_settings", + schema_module: MyApp.UserSettings, + fields: [Field.new(%{name: :user_id, type: :integer, primary?: true})] + } + ], + edges: [ + %Edge{ + from: {"users", MyApp.User, {:field, :id}}, + to: {"user_settings", MyApp.UserSettings, {:field, :user_id}}, + assoc_types: [has: :one] + } + ] + } + + assert D2.render(graph, []) =~ + ~s(":user_id": ":integer" {constraint: [primary_key; foreign_key]}) + end + + test "places clustered nodes in a colored container and qualifies edge endpoints" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + cluster: "Accounts", + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "posts", + schema_module: MyApp.Post, + fields: [Field.new(%{name: :user_id, type: :integer})] + } + ], + edges: [ + %Edge{ + from: {"users", MyApp.User, {:field, :id}}, + to: {"posts", MyApp.Post, {:field, :user_id}}, + assoc_types: [has: :many] + } + ] + } + + result = D2.render(graph, []) + + assert result =~ ~s("cluster_Accounts": {) + assert result =~ ~s(label: "Accounts") + assert result =~ ~s(style.fill: "#{Ecto.ERD.Color.get("Accounts")}") + assert result =~ ~s("cluster_Accounts"."MyApp.User": {) + assert result =~ ~s("cluster_Accounts"."MyApp.User".":id" <-> "MyApp.Post".":user_id") + end + + test "a global node named like a cluster does not collide with the container key" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + cluster: "Accounts", + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "accounts", + schema_module: Accounts, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + } + ], + edges: [] + } + + result = D2.render(graph, []) + + # container key is prefixed, so the global "Accounts" sql_table keeps its key + assert result =~ ~s("cluster_Accounts": {) + assert result =~ ~s("Accounts": {\n shape: sql_table) + end + + test "case-only global node ids keep distinct keys, labels, and edge endpoints" do + graph = %Graph{ + nodes: [ + %Node{ + source: "User", + schema_module: nil, + fields: [Field.new(%{name: :upper_id, type: :integer, primary?: true})] + }, + %Node{ + source: "user", + schema_module: nil, + fields: [Field.new(%{name: :lower_id, type: :integer})] + } + ], + edges: [ + %Edge{ + from: {"User", nil, {:field, :upper_id}}, + to: {"user", nil, {:field, :lower_id}}, + assoc_types: [has: :many] + } + ] + } + + rich = D2.render(graph, []) + + assert rich =~ ~s("User": {\n shape: sql_table) + assert rich =~ ~s("node_user": {\n shape: sql_table\n label: "user") + assert rich =~ ~s("User".":upper_id" <-> "node_user".":lower_id") + + bare = D2.render(graph, columns: []) + + assert bare =~ ~s(\n\n"User"\n\n) + assert bare =~ ~s(\n\n"node_user": "user"\n\n) + assert bare =~ ~s("User" <-> "node_user": {) + end + + test "case-only cluster names keep distinct containers and edge endpoints" do + graph = %Graph{ + nodes: [ + %Node{ + source: "A", + schema_module: nil, + cluster: "Accounts", + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "B", + schema_module: nil, + cluster: "accounts", + fields: [Field.new(%{name: :a_id, type: :integer})] + } + ], + edges: [ + %Edge{ + from: {"A", nil, {:field, :id}}, + to: {"B", nil, {:field, :a_id}}, + assoc_types: [has: :many] + } + ] + } + + result = D2.render(graph, []) + + assert result =~ ~s("cluster_Accounts": {\n label: "Accounts") + assert result =~ ~s("cluster_cluster_accounts": {\n label: "accounts") + + assert result =~ + ~s("cluster_Accounts"."A".":id" <-> "cluster_cluster_accounts"."B".":a_id") + end + + test "escalates the prefix when a global node is named like the container key" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + cluster: "Accounts", + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "cluster_Accounts", + schema_module: nil, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + } + ], + edges: [] + } + + result = D2.render(graph, []) + + assert result =~ ~s("cluster_cluster_Accounts": {) + assert result =~ ~s(label: "Accounts") + assert result =~ ~s("cluster_Accounts": {\n shape: sql_table) + end + + test "escalates on a case-only container-key collision" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + cluster: "Accounts", + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "CLUSTER_ACCOUNTS", + schema_module: nil, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + } + ], + edges: [] + } + + result = D2.render(graph, []) + + # d2 keys are case-insensitive, so a case-only match still merges + assert result =~ ~s("cluster_cluster_Accounts": {) + assert result =~ ~s("CLUSTER_ACCOUNTS": {\n shape: sql_table) + end + + test "edge endpoints use the escalated container key" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + cluster: "Accounts", + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "cluster_Accounts", + schema_module: nil, + fields: [Field.new(%{name: :user_id, type: :integer})] + } + ], + edges: [ + %Edge{ + from: {"users", MyApp.User, {:field, :id}}, + to: {"cluster_Accounts", nil, {:field, :user_id}}, + assoc_types: [has: :many] + } + ] + } + + result = D2.render(graph, []) + + assert result =~ + ~s("cluster_cluster_Accounts"."MyApp.User".":id" <-> "cluster_Accounts".":user_id") + end + + test "keeps escalating while the longer prefix still collides" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + cluster: "Accounts", + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "cluster_Accounts", + schema_module: nil, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "cluster_cluster_Accounts", + schema_module: nil, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + } + ], + edges: [] + } + + assert D2.render(graph, []) =~ ~s("cluster_cluster_cluster_Accounts": {) + end + + test "columns: [] keeps a colliding global node distinct from the container" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + cluster: "Accounts", + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "cluster_Accounts", + schema_module: nil, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + } + ], + edges: [] + } + + result = D2.render(graph, columns: []) + + # bare node statement and container must not silently merge + assert result =~ ~s(\n\n"cluster_Accounts"\n\n) + assert result =~ ~s("cluster_cluster_Accounts": {) + end + + test "columns: [] renders bare nodes and node-level edges" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "posts", + schema_module: MyApp.Post, + fields: [Field.new(%{name: :user_id, type: :integer})] + } + ], + edges: [ + %Edge{ + from: {"users", MyApp.User, {:field, :id}}, + to: {"posts", MyApp.Post, {:field, :user_id}}, + assoc_types: [has: :many] + } + ] + } + + result = D2.render(graph, columns: []) + + refute result =~ "shape: sql_table" + refute result =~ ":id" + assert result =~ ~s("MyApp.User" <-> "MyApp.Post": {) + assert result =~ "target-arrowhead.shape: cf-many-required" + end + + test "columns: [] dedups multiple edges between the same node pair" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + }, + %Node{ + source: "posts", + schema_module: MyApp.Post, + fields: [ + Field.new(%{name: :author_id, type: :integer}), + Field.new(%{name: :editor_id, type: :integer}) + ] + } + ], + edges: [ + %Edge{ + from: {"users", MyApp.User, {:field, :id}}, + to: {"posts", MyApp.Post, {:field, :author_id}}, + assoc_types: [has: :many] + }, + %Edge{ + from: {"users", MyApp.User, {:field, :id}}, + to: {"posts", MyApp.Post, {:field, :editor_id}}, + assoc_types: [has: :many] + } + ] + } + + bare = D2.render(graph, columns: []) + # split yields occurrences + 1 parts, so 2 parts == exactly one edge + assert bare |> String.split(~s("MyApp.User" <-> "MyApp.Post")) |> length() == 2 + + rich = D2.render(graph, []) + assert rich =~ ~s("MyApp.User".":id" <-> "MyApp.Post".":author_id") + assert rich =~ ~s("MyApp.User".":id" <-> "MyApp.Post".":editor_id") + end + + test "raises a clear error for unsupported column subsets" do + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [Field.new(%{name: :id, type: :integer, primary?: true})] + } + ], + edges: [] + } + + assert_raise RuntimeError, ~r/only the default \[:name, :type\] columns or \[\]/, fn -> + D2.render(graph, columns: [:name]) + end + end + + test "escapes backslashes before quotes in D2 strings" do + graph = %Graph{ + nodes: [ + %Node{source: ~S(back\slash), schema_module: nil, fields: []}, + %Node{source: ~S(precede\"), schema_module: nil, fields: []} + ], + edges: [] + } + + result = D2.render(graph, []) + + assert result =~ ~S("back\\slash") + assert result =~ ~S("precede\\\"") + end + + test "escapes ${ to prevent d2 variable substitution" do + graph = %Graph{ + nodes: [%Node{source: "dollar${var}", schema_module: nil, fields: []}], + edges: [] + } + + assert D2.render(graph, []) =~ ~S("dollar\${var}") + end + + test "escapes raw newlines so the document still parses" do + graph = %Graph{ + nodes: [%Node{source: "new\nline", schema_module: nil, fields: []}], + edges: [] + } + + assert D2.render(graph, []) =~ ~S("new\nline") + end + + test "renders an embedded schema as a header-port edge with an Elixir-style type" do + embed_type = + {:parameterized, + {Ecto.Embedded, %Ecto.Embedded{cardinality: :one, related: MyApp.Profile}}} + + graph = %Graph{ + nodes: [ + %Node{ + source: "users", + schema_module: MyApp.User, + fields: [ + Field.new(%{name: :id, type: :integer, primary?: true}), + Field.new(%{name: :profile, type: embed_type}) + ] + }, + %Node{ + source: nil, + schema_module: MyApp.Profile, + fields: [Field.new(%{name: :name, type: :string})] + } + ], + edges: [ + %Edge{ + from: {"users", MyApp.User, {:field, :profile}}, + to: {nil, MyApp.Profile, {:header, :schema_module}}, + assoc_types: [has: :one] + } + ] + } + + result = D2.render(graph, []) + + assert result =~ ~s(":profile": "#Ecto.Embedded<[one: MyApp.Profile]>") + assert result =~ ~s("MyApp.User".":profile" <-> "MyApp.Profile": {) + refute result =~ ~s(<-> "MyApp.Profile".") + end + end end