diff --git a/lib/radiator/outline.ex b/lib/radiator/outline.ex index d50ca3fd..ae4ec508 100644 --- a/lib/radiator/outline.ex +++ b/lib/radiator/outline.ex @@ -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. @@ -92,26 +174,33 @@ defmodule Radiator.Outline do """ def create_node(attrs \\ %{}) do %Node{} - |> Node.changeset(attrs) + |> Node.insert_changeset(attrs) + |> Repo.insert() + |> broadcast_node_action(:insert) + end + + def create_node(attrs, %{id: id}) do + %Node{creator_id: id} + |> Node.insert_changeset(attrs) |> Repo.insert() |> broadcast_node_action(:insert) end @doc """ - Updates a node. + Updates a nodes content. ## Examples - iex> update_node(node, %{field: new_value}) + iex> update_node_content(node, %{content: new_value}) {:ok, %Node{}} - iex> update_node(node, %{field: bad_value}) + iex> update_node_content(node, %{content: nil}) {:error, %Ecto.Changeset{}} """ - def update_node(%Node{} = node, attrs) do + def update_node_content(%Node{} = node, attrs) do node - |> Node.changeset(attrs) + |> Node.update_content_changeset(attrs) |> Repo.update() |> broadcast_node_action(:update) end @@ -134,23 +223,16 @@ defmodule Radiator.Outline do |> broadcast_node_action(:delete) end - @doc """ - Returns an `%Ecto.Changeset{}` for tracking node changes. - - ## Examples - - iex> change_node(node) - %Ecto.Changeset{data: %Node{}} - - """ - def change_node(%Node{} = node, attrs \\ %{}) do - Node.changeset(node, attrs) - end - defp broadcast_node_action({:ok, node}, action) do PubSub.broadcast(Radiator.PubSub, @topic, {action, node}) {:ok, node} 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 diff --git a/lib/radiator/outline/node.ex b/lib/radiator/outline/node.ex index 8fb151ac..6403a343 100644 --- a/lib/radiator/outline/node.ex +++ b/lib/radiator/outline/node.ex @@ -15,31 +15,36 @@ defmodule Radiator.Outline.Node do field :creator_id, :integer field :parent_id, Ecto.UUID field :prev_id, Ecto.UUID + field :level, :integer, virtual: true belongs_to :episode, Episode timestamps(type: :utc_datetime) end - @required_fields [ - :episode_id - ] - - @optional_fields [ - :content, - :creator_id, - :parent_id, - :prev_id - ] - - @all_fields @optional_fields ++ @required_fields + @doc """ + A changeset for inserting a new node + Work in progress. Since we currently ignore the tree structure, there is + no concept for a root node. + Also questionable wether a node really needs a content from beginning. So probably a root + doesnt have a content + Another issue might be we need to create the uuid upfront and pass it here + """ + def insert_changeset(node, attributes) do + node + |> cast(attributes, [:content, :episode_id, :creator_id, :parent_id, :prev_id]) + |> update_change(:content, &trim/1) + |> validate_required([:content, :episode_id]) + end - @doc false - def changeset(node, attrs) do + @doc """ + Changeset for updating the content of a node + """ + def update_content_changeset(node, attrs) do node - |> cast(attrs, @all_fields) + |> cast(attrs, [:content]) |> update_change(:content, &trim/1) - |> validate_required(@required_fields) + |> validate_required([:content]) end defp trim(content) when is_binary(content), do: String.trim(content) diff --git a/lib/radiator_web/live/episode_live/index.ex b/lib/radiator_web/live/episode_live/index.ex index bacc25ae..0972bbd8 100644 --- a/lib/radiator_web/live/episode_live/index.ex +++ b/lib/radiator_web/live/episode_live/index.ex @@ -61,7 +61,7 @@ defmodule RadiatorWeb.EpisodeLive.Index do case Outline.get_node(uuid) do nil -> nil - node -> Outline.update_node(node, attrs) + node -> Outline.update_node_content(node, attrs) end socket 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 c44e6ea9..aea58822 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,33 +52,116 @@ 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} + + 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/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(node, update_attrs) + assert {:ok, %Node{} = node} = Outline.update_node_content(node, update_attrs) assert node.content == "some updated content" end - test "update_node/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(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 - test "change_node/1 returns a node changeset" do - node = node_fixture() - assert %Ecto.Changeset{} = Outline.change_node(node) + 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