From bea6496ecdbc6046ca82ab42c055a930a5ec9559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Wo=CC=88ginger?= Date: Mon, 29 Jan 2024 22:55:38 +0100 Subject: [PATCH] add tree node function --- lib/radiator/outline.ex | 94 +++++++++++++++++++++++-- lib/radiator/outline/node.ex | 11 +-- priv/repo/seeds.exs | 5 +- test/radiator/outline_test.exs | 121 +++++++++++++++++++++++++++++---- 4 files changed, 200 insertions(+), 31 deletions(-) diff --git a/lib/radiator/outline.ex b/lib/radiator/outline.ex index 18b4b3b8..809edab3 100644 --- a/lib/radiator/outline.ex +++ b/lib/radiator/outline.ex @@ -89,6 +89,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. @@ -108,12 +190,6 @@ defmodule Radiator.Outline do |> Notify.broadcast_node_action(:insert, socket_id) end - def create_node(attrs, %{id: id}) do - %Node{creator_id: id} - |> Node.insert_changeset(attrs) - |> Repo.insert() - end - @doc """ Updates a nodes content. @@ -149,4 +225,10 @@ defmodule Radiator.Outline do node |> Repo.delete() end + + defp binaray_uuid_to_ecto_uuid(nil), do: nil + + defp binaray_uuid_to_ecto_uuid(uuid) do + Ecto.UUID.load!(uuid) + end end diff --git a/lib/radiator/outline/node.ex b/lib/radiator/outline/node.ex index 0ce640c6..6403a343 100644 --- a/lib/radiator/outline/node.ex +++ b/lib/radiator/outline/node.ex @@ -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 @@ -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 """ diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 03a5e3b0..89df6abe 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -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} = @@ -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}) diff --git a/test/radiator/outline_test.exs b/test/radiator/outline_test.exs index 849d70e5..55aea0cf 100644 --- a/test/radiator/outline_test.exs +++ b/test/radiator/outline_test.exs @@ -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() @@ -25,13 +26,17 @@ 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} @@ -39,7 +44,7 @@ defmodule Radiator.OutlineTest do 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} @@ -47,11 +52,23 @@ defmodule Radiator.OutlineTest do 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, creator_id: user.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"} @@ -59,16 +76,92 @@ defmodule Radiator.OutlineTest do 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, %{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