Skip to content

Commit

Permalink
add tree node function
Browse files Browse the repository at this point in the history
  • Loading branch information
electronicbites committed Feb 17, 2024
1 parent ec82624 commit 1bd0a00
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 26 deletions.
88 changes: 88 additions & 0 deletions lib/radiator/outline.ex
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,88 @@ defmodule Radiator.Outline do
|> Repo.get(id)
end

@doc """
Gets all nodes of an episode as a tree.
Uses a Common Table Expression (CTE) to recursively query the database.
Sets the level of each node in the tree. Level 0 are the root nodes (without a parent)
Returns a list with all nodes of the episode sorted by the level.
## Examples
iex> get_node_tree(123)
[%Node{}, %Node{}, ..]
SQL:
WITH RECURSIVE node_tree AS (
SELECT uuid, content, parent_id, prev_id, 0 AS level
FROM outline_nodes
WHERE episode_id = ?::integer and parent_id is NULL
UNION ALL
SELECT outline_nodes.uuid, outline_nodes.content, outline_nodes.parent_id, outline_nodes.prev_id, node_tree.level + 1
FROM outline_nodes
JOIN node_tree ON outline_nodes.parent_id = node_tree.uuid
)
SELECT * FROM node_tree;
"""
def get_node_tree(episode_id) do
node_tree_initial_query =
Node
|> where([n], is_nil(n.parent_id))
|> where([n], n.episode_id == ^episode_id)
|> select([n], %{
uuid: n.uuid,
content: n.content,
parent_id: n.parent_id,
prev_id: n.prev_id,
level: 0
})

node_tree_recursion_query =
from outline_node in "outline_nodes",
join: node_tree in "node_tree",
on: outline_node.parent_id == node_tree.uuid,
select: [
outline_node.uuid,
outline_node.content,
outline_node.parent_id,
outline_node.prev_id,
node_tree.level + 1
]

node_tree_query =
node_tree_initial_query
|> union_all(^node_tree_recursion_query)

tree =
"node_tree"
|> recursive_ctes(true)
|> with_cte("node_tree", as: ^node_tree_query)
|> select([n], %{
uuid: n.uuid,
content: n.content,
parent_id: n.parent_id,
prev_id: n.prev_id,
level: n.level
})
|> Repo.all()
|> Enum.map(fn %{
uuid: uuid,
content: content,
parent_id: parent_id,
prev_id: prev_id,
level: level
} ->
%Node{
uuid: binaray_uuid_to_ecto_uuid(uuid),
content: content,
parent_id: binaray_uuid_to_ecto_uuid(parent_id),
prev_id: binaray_uuid_to_ecto_uuid(prev_id),
level: level
}
end)

{:ok, tree}
end

@doc """
Creates a node.
Expand Down Expand Up @@ -147,4 +229,10 @@ defmodule Radiator.Outline do
end

defp broadcast_node_action({:error, error}, _action), do: {:error, error}

defp binaray_uuid_to_ecto_uuid(nil), do: nil

defp binaray_uuid_to_ecto_uuid(uuid) do
Ecto.UUID.load!(uuid)
end
end
11 changes: 1 addition & 10 deletions lib/radiator/outline/node.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ defmodule Radiator.Outline.Node do
@derive {Jason.Encoder, only: [:uuid, :content, :creator_id, :parent_id, :prev_id]}

@primary_key {:uuid, :binary_id, autogenerate: true}

schema "outline_nodes" do
field :content, :string
field :creator_id, :integer
field :parent_id, Ecto.UUID
field :prev_id, Ecto.UUID
field :level, :integer, virtual: true

belongs_to :episode, Episode

Expand All @@ -37,15 +37,6 @@ defmodule Radiator.Outline.Node do
|> validate_required([:content, :episode_id])
end

@doc """
Changeset for moving a node
Only the parent_id is allowed and expected to be changed
"""
def move_changeset(node, attrs) do
node
|> cast(attrs, [:parent_id])
end

@doc """
Changeset for updating the content of a node
"""
Expand Down
5 changes: 4 additions & 1 deletion priv/repo/seeds.exs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ alias Radiator.{Accounts, Outline, Podcast}
{:ok, show} =
Podcast.create_show(%{title: "Tech Weekly", network_id: network.id})

{:ok, _episode} =
{:ok, past_episode} =
Podcast.create_episode(%{title: "past episode", show_id: show.id})

{:ok, current_episode} =
Expand Down Expand Up @@ -60,3 +60,6 @@ alias Radiator.{Accounts, Outline, Podcast}
episode_id: current_episode.id,
prev_id: node211.uuid
})

{:ok, past_parent_node} =
Outline.create_node(%{content: "Old Content", episode_id: past_episode.id})
123 changes: 108 additions & 15 deletions test/radiator/outline_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ defmodule Radiator.OutlineTest do
use Radiator.DataCase

alias Radiator.Outline
alias Radiator.Outline.Node
alias Radiator.PodcastFixtures
alias Radiator.Repo

describe "outline_nodes" do
alias Radiator.Outline.Node
import Radiator.OutlineFixtures
import Ecto.Query, warn: false

import Radiator.OutlineFixtures
alias Radiator.PodcastFixtures
@invalid_attrs %{episode_id: nil}

@invalid_attrs %{episode_id: nil}

test "list_nodes/0 returns all nodes" do
describe "list_nodes/0" do
test "returns all nodes" do
node1 = node_fixture()
node2 = node_fixture()

Expand All @@ -25,50 +26,142 @@ defmodule Radiator.OutlineTest do
assert Outline.list_nodes_by_episode(node1.episode_id) == [node1]
assert Outline.list_nodes_by_episode(node2.episode_id) == [node2]
end
end

test "get_node!/1 returns the node with given id" do
describe "get_node!/1" do
test "returns the node with given id" do
node = node_fixture()
assert Outline.get_node!(node.uuid) == node
end
end

test "create_node/1 with valid data creates a node" do
describe "create_node/1" do
test "with valid data creates a node" do
episode = PodcastFixtures.episode_fixture()
valid_attrs = %{content: "some content", episode_id: episode.id}

assert {:ok, %Node{} = node} = Outline.create_node(valid_attrs)
assert node.content == "some content"
end

test "create_node/1 trims whitespace from content" do
test "trims whitespace from content" do
episode = PodcastFixtures.episode_fixture()
valid_attrs = %{content: " some content ", episode_id: episode.id}

assert {:ok, %Node{} = node} = Outline.create_node(valid_attrs)
assert node.content == "some content"
end

test "create_node/1 with invalid data returns error changeset" do
test "can have a creator" do
episode = PodcastFixtures.episode_fixture()
user = %{id: 2}
valid_attrs = %{content: "some content", episode_id: episode.id}

assert {:ok, %Node{} = node} = Outline.create_node(valid_attrs, user)
assert node.content == "some content"
assert node.creator_id == user.id
end

test "with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Outline.create_node(@invalid_attrs)
end
end

test "update_node_content/2 with valid data updates the node" do
describe "update_node_content/2" do
test "with valid data updates the node" do
node = node_fixture()
update_attrs = %{content: "some updated content"}

assert {:ok, %Node{} = node} = Outline.update_node_content(node, update_attrs)
assert node.content == "some updated content"
end

test "update_node_content/2 with invalid data returns error changeset" do
test "with invalid data returns error changeset" do
node = node_fixture()
assert {:error, %Ecto.Changeset{}} = Outline.update_node_content(node, @invalid_attrs)
assert {:error, %Ecto.Changeset{}} = Outline.update_node_content(node, %{content: nil})
assert node == Outline.get_node!(node.uuid)
end
end

test "delete_node/1 deletes the node" do
describe "delete_node/1" do
test "deletes the node" do
node = node_fixture()
assert {:ok, %Node{}} = Outline.delete_node(node)
assert_raise Ecto.NoResultsError, fn -> Outline.get_node!(node.uuid) end
end
end

describe "get_node_tree/1" do
setup :complex_node_fixture

test "returns all nodes from a episode", %{parent: parent} do
episode_id = parent.episode_id
assert {:ok, tree} = Outline.get_node_tree(episode_id)

all_nodes =
Node
|> where([n], n.episode_id == ^episode_id)
|> Repo.all()

assert Enum.count(tree) == Enum.count(all_nodes)

Enum.each(tree, fn node ->
assert node.uuid ==
List.first(Enum.filter(all_nodes, fn n -> n.uuid == node.uuid end)).uuid
end)
end

test "does not return a node from another episode", %{
parent: parent
} do
episode_id = parent.episode_id
other_node = node_fixture(parent_id: nil, prev_id: nil, content: "other content")
assert other_node.episode_id != episode_id
{:ok, tree} = Outline.get_node_tree(episode_id)
assert Enum.filter(tree, fn n -> n.uuid == other_node.uuid end) == []
end

test "returns nodes sorted by level", %{parent: parent} do
episode_id = parent.episode_id
{:ok, tree} = Outline.get_node_tree(episode_id)

Enum.reduce(tree, 0, fn node, current_level ->
if node.parent_id != nil do
parent = Enum.find(tree, fn n -> n.uuid == node.parent_id end)
assert parent.level + 1 == node.level
end

assert node.level >= current_level
node.level
end)
end
end

defp complex_node_fixture(_) do
episode = PodcastFixtures.episode_fixture()
parent = node_fixture(episode_id: episode.id, parent_id: nil, prev_id: nil)
node_1 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: nil)
node_2 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: node_1.uuid)
node_3 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: node_2.uuid)
node_4 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: node_3.uuid)
node_5 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: node_4.uuid)
node_6 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: node_5.uuid)

nested_node_1 = node_fixture(episode_id: episode.id, parent_id: node_3.uuid, prev_id: nil)

nested_node_2 =
node_fixture(episode_id: episode.id, parent_id: node_3.uuid, prev_id: nested_node_1.uuid)

%{
node_1: node_1,
node_2: node_2,
node_3: node_3,
node_4: node_4,
node_5: node_5,
node_6: node_6,
nested_node_1: nested_node_1,
nested_node_2: nested_node_2,
parent: parent
}
end
end

0 comments on commit 1bd0a00

Please sign in to comment.