diff --git a/artefact/lib/artefact.ex b/artefact/lib/artefact.ex index 34ea4ce..85eee85 100644 --- a/artefact/lib/artefact.ex +++ b/artefact/lib/artefact.ex @@ -31,13 +31,13 @@ defmodule Artefact do """ defmacro new(attrs \\ []) do caller = __CALLER__.module - caller_name = caller |> Module.split() |> List.last() + caller_name = caller && (caller |> Module.split() |> List.last()) + default_base_label = caller_name && String.replace(caller_name, ~r/[^A-Za-z0-9]/, "") quote do attrs = unquote(attrs) - name = unquote(caller_name) - title = Keyword.get(attrs, :title, name) - base_label = Keyword.get(attrs, :base_label, name |> String.replace(~r/[^A-Za-z0-9]/, "")) metadata = %{provenance: %{source: :struct, module: unquote(caller)}} + title = Keyword.get(attrs, :title, unquote(caller_name)) + base_label = Keyword.get(attrs, :base_label, unquote(default_base_label)) Artefact.build([{:title, title}, {:base_label, base_label}, {:metadata, metadata} | Keyword.drop(attrs, [:title, :base_label, :metadata])]) end end @@ -158,9 +158,51 @@ defmodule Artefact do @doc false def build(attrs) do + {node_specs, attrs} = Keyword.pop(attrs, :nodes, []) + {rel_specs, attrs} = Keyword.pop(attrs, :relationships, []) + + attrs = + if node_specs != [] or rel_specs != [] do + Keyword.put(attrs, :graph, build_graph(node_specs, rel_specs)) + else + attrs + end + struct!(__MODULE__, [{:id, Artefact.UUID.generate_v7()}, {:uuid, Artefact.UUID.generate_v7()} | attrs]) end + defp build_graph(node_specs, rel_specs) do + {nodes, key_map} = + node_specs + |> Enum.with_index() + |> Enum.map_reduce(%{}, fn {{key, opts}, i}, acc -> + id = "n#{i}" + node = %Artefact.Node{ + id: id, + uuid: Keyword.get(opts, :uuid, Artefact.UUID.generate_v7()), + labels: Keyword.get(opts, :labels, []), + properties: Keyword.get(opts, :properties, %{}), + position: Keyword.get(opts, :position) + } + {node, Map.put(acc, key, id)} + end) + + relationships = + rel_specs + |> Enum.with_index() + |> Enum.map(fn {spec, i} -> + %Artefact.Relationship{ + id: "r#{i}", + from_id: Map.fetch!(key_map, Keyword.fetch!(spec, :from)), + to_id: Map.fetch!(key_map, Keyword.fetch!(spec, :to)), + type: Keyword.fetch!(spec, :type), + properties: Keyword.get(spec, :properties, %{}) + } + end) + + %Artefact.Graph{nodes: nodes, relationships: relationships} + end + defp deduplicate_rels(rels_a, rels_b) do index = Map.new(rels_a, fn rel -> {{rel.from_id, rel.type, rel.to_id}, rel} end) diff --git a/artefact/mix.exs b/artefact/mix.exs index 7da820d..2050b40 100644 --- a/artefact/mix.exs +++ b/artefact/mix.exs @@ -5,7 +5,7 @@ defmodule Artefact.MixProject do @moduledoc false use Mix.Project - @version "0.1.0" + @version "0.1.1" @github_url "https://github.com/diffo-dev/artefactory" def project do diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs index fbf4dd6..ed7e97f 100644 --- a/artefact/test/artefact_test.exs +++ b/artefact/test/artefact_test.exs @@ -68,6 +68,184 @@ defmodule ArtefactTest do end end + describe "Artefact.new/1 — inline nodes and relationships" do + test "builds nodes with sequential ids" do + a = Artefact.new( + nodes: [ + matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}], + claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}] + ], + relationships: [] + ) + by_id = Map.new(a.graph.nodes, &{&1.id, &1}) + assert map_size(by_id) == 2 + assert Map.has_key?(by_id, "n0") + assert Map.has_key?(by_id, "n1") + end + + test "nodes have correct labels and properties" do + a = Artefact.new( + nodes: [ + matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}], + claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}] + ], + relationships: [] + ) + by_id = Map.new(a.graph.nodes, &{&1.id, &1}) + assert by_id["n0"].labels == ["Agent", "Me"] + assert by_id["n0"].properties == %{"name" => "Matt"} + assert by_id["n1"].labels == ["Agent", "You"] + assert by_id["n1"].properties == %{"name" => "Claude"} + end + + test "nodes get auto-generated uuids" do + a = Artefact.new(nodes: [n: [labels: ["X"]]], relationships: []) + [node] = a.graph.nodes + assert is_binary(node.uuid) + assert String.length(node.uuid) == 36 + end + + test "uuid option is preserved" do + fixed_uuid = "019da897-f2de-77ca-b5a4-40f0c3730943" + a = Artefact.new(nodes: [n: [labels: [], uuid: fixed_uuid]], relationships: []) + [node] = a.graph.nodes + assert node.uuid == fixed_uuid + end + + test "builds relationship resolving atom keys to ids" do + a = Artefact.new( + nodes: [ + matt: [labels: ["Agent"]], + claude: [labels: ["Agent"]] + ], + relationships: [ + [from: :matt, type: "US_TWO", to: :claude] + ] + ) + [rel] = a.graph.relationships + assert rel.from_id == "n0" + assert rel.to_id == "n1" + assert rel.type == "US_TWO" + end + + test "relationship properties default to empty map" do + a = Artefact.new( + nodes: [a: [labels: []], b: [labels: []]], + relationships: [[from: :a, type: "KNOWS", to: :b]] + ) + [rel] = a.graph.relationships + assert rel.properties == %{} + end + + test "relationship properties are set when provided" do + a = Artefact.new( + nodes: [a: [labels: []], b: [labels: []]], + relationships: [[from: :a, type: "KNOWS", to: :b, properties: %{"since" => "2024"}]] + ) + [rel] = a.graph.relationships + assert rel.properties == %{"since" => "2024"} + end + + test "empty nodes and relationships produces empty graph" do + a = Artefact.new(title: "Empty", nodes: [], relationships: []) + assert a.graph.nodes == [] + assert a.graph.relationships == [] + end + + test "no nodes or relationships key leaves graph as default" do + a = Artefact.new(title: "NoGraph") + assert a.graph == %Artefact.Graph{} + end + end + + describe "Artefact.new/1 — inline nodes and relationships — multiple relationships" do + setup do + a = Artefact.new( + nodes: [x: [labels: ["X"]], y: [labels: ["Y"]], z: [labels: ["Z"]]], + relationships: [ + [from: :x, type: "NEXT", to: :y], + [from: :y, type: "NEXT", to: :z] + ] + ) + %{artefact: a} + end + + test "all relationships built", %{artefact: a} do + assert length(a.graph.relationships) == 2 + end + + test "relationship ids are sequential", %{artefact: a} do + ids = Enum.map(a.graph.relationships, & &1.id) + assert ids == ["r0", "r1"] + end + + test "chain resolves correctly", %{artefact: a} do + by_id = Map.new(a.graph.nodes, &{&1.id, &1}) + [r0, r1] = a.graph.relationships + assert r0.from_id == "n0" and r0.to_id == "n1" + assert r1.from_id == "n1" and r1.to_id == "n2" + assert by_id["n0"].labels == ["X"] + assert by_id["n2"].labels == ["Z"] + end + end + + describe "Artefact.new/1 — us_two inline vs JSON fixture" do + setup do + json = File.read!(Path.join([@fixtures, "us_two", "arrows.json"])) + from_json = Artefact.Arrows.from_json!(json) + + from_struct = Artefact.new( + title: "UsTwo", + base_label: "UsTwo", + nodes: [ + matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}, + uuid: "019da897-f2de-77ca-b5a4-40f0c3730943"], + claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}, + uuid: "019da897-f2de-768c-94e2-3005f2431f37"] + ], + relationships: [ + [from: :matt, type: "US_TWO", to: :claude] + ] + ) + + %{from_json: from_json, from_struct: from_struct} + end + + test "same title and base_label", %{from_json: j, from_struct: s} do + assert s.title == j.title + assert s.base_label == j.base_label + end + + test "same number of nodes and relationships", %{from_json: j, from_struct: s} do + assert length(s.graph.nodes) == length(j.graph.nodes) + assert length(s.graph.relationships) == length(j.graph.relationships) + end + + test "node labels match", %{from_json: j, from_struct: s} do + labels = fn a -> a.graph.nodes |> Enum.map(& &1.labels) |> Enum.sort() end + assert labels.(s) == labels.(j) + end + + test "node properties match", %{from_json: j, from_struct: s} do + props = fn a -> a.graph.nodes |> Enum.map(& &1.properties) |> Enum.sort_by(& &1["name"]) end + assert props.(s) == props.(j) + end + + test "node uuids match", %{from_json: j, from_struct: s} do + uuids = fn a -> a.graph.nodes |> Enum.map(& &1.uuid) |> Enum.sort() end + assert uuids.(s) == uuids.(j) + end + + test "relationship type and direction match", %{from_json: j, from_struct: s} do + [sr] = s.graph.relationships + [jr] = j.graph.relationships + assert sr.type == jr.type + from_uuid = fn a, rel_id -> Enum.find(a.graph.nodes, &(&1.id == rel_id)).uuid end + assert from_uuid.(s, sr.from_id) == from_uuid.(j, jr.from_id) + assert from_uuid.(s, sr.to_id) == from_uuid.(j, jr.to_id) + end + end + describe "Artefact.Arrows.from_json!/2 — us_two" do setup do json = File.read!(Path.join([@fixtures, "us_two", "arrows.json"])) diff --git a/artefact_kino/artefact_kino.livemd b/artefact_kino/artefact_kino.livemd index 1419d62..c565e46 100644 --- a/artefact_kino/artefact_kino.livemd +++ b/artefact_kino/artefact_kino.livemd @@ -7,7 +7,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install([ - {:artefact_kino, "~> 0.1"}, + {:artefact_kino, "~> 0.1.1"}, {:req, "~> 0.5"} ]) ``` @@ -35,9 +35,8 @@ I've drawn it, so from my perspective (Matt - Me) I've got an US_TWO relationshi Try highlighting nodes and relationships in the graph. Explore the Artefact struct, and have examine the create and merge cypher. ```elixir -us_two = - Req.get!("https://raw.githubusercontent.com/diffo-dev/artefactory/dev/artefact/test/data/us_two/arrows.json", decode_body: false).body - |> Artefact.Arrows.from_json!() + + ArtefactKino.new(us_two) ``` diff --git a/artefact_kino/mix.exs b/artefact_kino/mix.exs index 9d609d2..c248baa 100644 --- a/artefact_kino/mix.exs +++ b/artefact_kino/mix.exs @@ -5,7 +5,7 @@ defmodule ArtefactKino.MixProject do @moduledoc false use Mix.Project - @version "0.1.0" + @version "0.1.1" @github_url "https://github.com/diffo-dev/artefactory" def project do