Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions artefact/lib/artefact.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion artefact/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 178 additions & 0 deletions artefact/test/artefact_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]))
Expand Down
7 changes: 3 additions & 4 deletions artefact_kino/artefact_kino.livemd
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ SPDX-License-Identifier: MIT

```elixir
Mix.install([
{:artefact_kino, "~> 0.1"},
{:artefact_kino, "~> 0.1.1"},
{:req, "~> 0.5"}
])
```
Expand Down Expand Up @@ -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)
```
Expand Down
2 changes: 1 addition & 1 deletion artefact_kino/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading