├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config ├── config.exs ├── test.exs └── test.exs.travis ├── coveralls.json ├── lib ├── as_nested_set.ex └── as_nested_set │ ├── helper.ex │ ├── model.ex │ ├── modifiable.ex │ ├── queriable.ex │ ├── scoped.ex │ └── traversable.ex ├── mix.exs ├── mix.lock ├── priv └── test_repo │ └── migrations │ └── 1_migrate_all.exs └── test ├── as_nested_set ├── helper_test.exs ├── modifiable_test.exs ├── queriable_test.exs ├── scoped_test.exs └── traversable_test.exs ├── as_nested_set_test.exs ├── support ├── ecto_case.ex ├── factory.ex ├── matcher.ex ├── models │ └── taxon.ex └── test_repo.ex └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporaray files 2 | /_build 3 | /cover 4 | /deps 5 | erl_crash.dump 6 | *.ez 7 | 8 | # IntelliJ configuration files 9 | /.idea 10 | 11 | # Auto-generated files 12 | /doc 13 | 14 | # asdf config file 15 | /.tool-versions 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: '1.7.3' 3 | otp_release: '21.0.9' 4 | services: 5 | - postgresql 6 | before_script: 7 | - psql -c 'create database travis_ci_test;' -U postgres 8 | - cp config/test.exs.travis config/test.exs 9 | script: 10 | - mix coveralls.travis 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # V3.4.0 2 | 3 | * [chore] Upgraded Ecto to 3.4 4 | 5 | # V3.3.0 6 | 7 | * [chore] Upgraded Ecto to 3.3 8 | 9 | # V3.2.1 10 | 11 | * [chore] Relaxed the specificity of the Ecto 12 | 13 | # V3.2.0 14 | 15 | * [chore] Support Ecto-3.x by default 16 | 17 | # V3.1.2 18 | 19 | * [chore] Upgrade Ecto to 2.2.0 20 | 21 | # V3.1.1 22 | 23 | * [fix] Fixed te issue where moving the last child to the child of its parent will violate the index 24 | 25 | # V3.1.0 26 | 27 | * [feature] Defined new accessors in `Model` & `Scope` 28 | * [feature] Added `Traversable` APIs 29 | * [feature] Added `Queriable/right_most` 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Rainer Du 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # as_nested_set 2 | 3 | [![Build Status](https://travis-ci.com/secretworry/as_nested_set.svg?branch=master)](https://travis-ci.org/secretworry/as_nested_set) 4 | [![Coveralls Coverage](https://img.shields.io/coveralls/secretworry/as_nested_set.svg)](https://coveralls.io/github/secretworry/as_nested_set) 5 | [![Hex.pm](https://img.shields.io/hexpm/v/as_nested_set.svg)](http://hex.pm/packages/as_nested_set) 6 | 7 | **An [ecto](https://github.com/elixir-lang/ecto) based [Nested set model](https://en.wikipedia.org/wiki/Nested_set_model) implementation for database** 8 | 9 | ## Installation 10 | 11 | Add as_nested_set to your list of dependencies in `mix.exs`: 12 | 13 | ```elixir 14 | # use the stable version 15 | def deps do 16 | [{:as_nested_set, "~> 3.4"}] 17 | end 18 | 19 | # use the latest version 20 | def deps do 21 | [{:as_nested_set, github: "https://github.com/secretworry/as_nested_set.git"}] 22 | end 23 | ``` 24 | 25 | ## Usage 26 | 27 | To make use of `as_nested_set`, 4 fields( `id`, `lft`, `rgt` and `parent_id`) are required for your model. The name of those fields are configurable. 28 | 29 | ```elixir 30 | defmodule AsNestedSet.TestRepo.Migrations.MigrateAll do 31 | use Ecto.Migration 32 | 33 | def change do 34 | create table(:taxons) do 35 | add :name, :string 36 | add :taxonomy_id, :id 37 | add :parent_id, :id 38 | add :lft, :integer 39 | add :rgt, :integer 40 | 41 | timestamps 42 | end 43 | 44 | end 45 | end 46 | ``` 47 | 48 | Enable the nested set functionality by `use AsNestedSet` on your model 49 | 50 | ```elixir 51 | defmodule AsNestedSet.Taxon do 52 | use AsNestedSet, scope: [:taxonomy_id] 53 | # ... 54 | end 55 | ``` 56 | 57 | ## Options 58 | 59 | You can config the name of required fields through attributes: 60 | 61 | ```elixir 62 | defmodule AsNestedSet.Taxon do 63 | @right_column :right 64 | @left_column :left 65 | @node_id_column :node_id 66 | @parent_id_column :pid 67 | # ... 68 | end 69 | ``` 70 | 71 | * `@right_column`: column name for the right boundary (defaults to `lft`, since left is a reserved keyword for Mysql) 72 | * `@left_column`: column name for the left boundary (defaults to `rgt`, reserved too) 73 | * `@node_id_column`: specifies the name for the node id column (defaults to `id`, i.e. the id of the model, change this if you want to have a different id, or you id field is different) 74 | * `@parent_id_column`: specifies the name for the parent id column (defaults to `parent_id`) 75 | 76 | You can also pass following arguments to modify its behavior: 77 | 78 | ```elixir 79 | defmodule AsNestedSet.Taxon do 80 | use AsNestedSet, scope: [:taxonomy_id] 81 | # ... 82 | end 83 | ``` 84 | 85 | * `scope`: (optional) a list of column names which restrict what are to be considered within the same tree(same scope). When ignored, all the nodes will be considered under the same tree. 86 | 87 | ## Model Operations 88 | 89 | Once you have set up you model, you can then 90 | 91 | Add new nodes 92 | 93 | ```elixir 94 | target = Repo.find!(Taxon, 1) 95 | # add to left 96 | %Taxon{name: "left", taxonomy_id: 1} |> AsNestedSet.create(target, :left) |> AsNestedSet.execute(TestRepo) 97 | # add to right 98 | %Taxon{name: "right", taxonomy_id: 1} |> AsNestedSet.create(target, :right) |> AsNestedSet.execute(TestRepo) 99 | # add as first child 100 | %Taxon{name: "child", taxonomy_id: 1} |> AsNestedSet.create(target, :child) |> AsNestedSet.execute(TestRepo) 101 | # add as parent 102 | %Taxon{name: "parent", taxonomy_id: 1} |> AsNestedSet.create(target, :parent) |> AsNestedSet.execute(TestRepo) 103 | 104 | # add as root 105 | %Taxon{name: "root", taxonomy_id: 1} |> AsNestedSet.create(:root) |> AsNestedSet.execute(TestRepo) 106 | 107 | # move a node to a new position 108 | 109 | node |> AsNestedSet.move(:root) |> AsNestedSet.execute(TestRepo) // move the node to be a new root 110 | node |> AsNestedSet.move(target, :left) |> AsNestedSet.execute(TestRepo) // move the node to the left of the target 111 | node |> AsNestedSet.move(target, :right) |> AsNestedSet.execute(TestRepo) // move the node to the right of the target 112 | node |> AsNestedSet.move(target, :child) |> AsNestedSet.execute(TestRepo) // move the node to be the right-most child of target 113 | 114 | ``` 115 | 116 | Remove a specified node and all its descendants 117 | 118 | ```elixir 119 | target = Repo.find!(Taxon, 1) 120 | AsNestedSet.delete(target) |> AsNestedSet.execute(TestRepo) 121 | ``` 122 | 123 | Query different nodes 124 | 125 | ```elixir 126 | 127 | # find all roots 128 | AsNestedSet.roots(Taxon, %{taxonomy_id: 1}) |> AsNestedSet.execute(TestRepo) 129 | 130 | # find all children of target 131 | AsNestedSet.children(target) |> AsNestedSet.execute(TestRepo) 132 | 133 | # find all the leaves for given scope 134 | AsNestedSet.leaves(Taxon, %{taxonomy_id: 1}) |> AsNestedSet.execute(TestRepo) 135 | 136 | # find all descendants 137 | AsNestedSet.descendants(target) |> AsNestedSet.execute(TestRepo) 138 | # include self 139 | AsNestedSet.self_and_descendants(target) |> AsNestedSet.execute(TestRepo) 140 | 141 | # find all ancestors 142 | AsNestedSet.ancestors(target) |> AsNestedSet.execute(TestRepo) 143 | 144 | #find all siblings (self included) 145 | AsNestedSet.self_and_siblings(target) |> AsNestedSet.execute(TestRepo) 146 | 147 | ``` 148 | 149 | Traverse the tree 150 | ```elixir 151 | # traverse a tree with 3 args post callback 152 | AsNestedSet.traverse(Taxon, %{taxonomy_id}, context, fn node, context -> {node, context}, end, fn node, children, context -> {node, context} end) |> AsNestedSet.execute(TestRepo) 153 | # traverse a tree with 2 args post callback 154 | AsNestedSet.traverse(Taxon, %{taxonomy_id}, context, fn node, context -> {node, context}, end, fn node, context -> {node, context} end) |> AsNestedSet.execute(TestRepo) 155 | 156 | # traverse a subtree with 3 args post callback 157 | AsNestedSet.traverse(target, context, fn node, context -> {node, context}, end, fn node, children, context -> {node, context} end) |> AsNestedSet.execute(TestRepo) 158 | # traverse a tree with 2 args post callback 159 | AsNestedSet.traverse(target, context, fn node, context -> {node, context}, end, fn node, context -> {node, context} end) |> AsNestedSet.execute(TestRepo) 160 | ``` 161 | 162 | # FAQ 163 | 164 | ## How to ensure the consistency 165 | 166 | *We recommend users to use a transaction to wrap all the operations in the production environment* 167 | 168 | We introduced the `@type executable`( a delayed execution ) as the return value of each API, so using transaction or not and how granular the transaction should be are all up to users. 169 | 170 | In general, almost all modifications of a nested set can be done in one SQL, but we can't express some of them using ecto's DSL( ecto doesn't support `case-when` in update query ), so users having concurrent modifications *must* wrap `AsNestedSet.execute(call, repo)` in a Transaction, for example 171 | 172 | ```elixir 173 | exec = node |> AsNestedSet.move(:root) 174 | Repo.transaction fn -> AsNestedSet.execute(exec, Repo) end 175 | ``` 176 | 177 | The `node` passed in as an argument might has changed after loaded from db, we will reload it from DB before use it, so there is no need to wrap the `load` and the `execute` in the same transaction 178 | 179 | ```elixir 180 | # This is not necessary 181 | Repo.transaction fn -> 182 | node = loadNode() 183 | node |> AsNestedSet.move(:root) |> AsNestedSet.execute(Repo) # We will reload the node passed in 184 | end 185 | ``` 186 | 187 | But if you want to ensure consistency across multiple `execute`s , to avoid the racing condition, you have to isolate them by wrap them in different transactions. 188 | 189 | ## How to move a node to be the n-th child of a target 190 | 191 | Be default, after using `AsNestedSet.move(node, target, :child)`, you move the `node` to be the right-most child of the `target`, because we can know the `left` and `right` of the target right way, but to find out the proper `right` and `left` for n-th child requires more operations. 192 | 193 | To achieve the goal, you should: 194 | 1. Query the n-th child or (n-1)th child of the target by `AsNestedSet.children(target)`, 195 | 2. Use `move(node, n_th_child, :left)` and `move(node, n_1_th_child, :right)` respectively. 196 | 197 | ## Ecto 2.x 198 | 199 | `Ecto` is upgrading to 3.0, with a clear API and a lot lot of bug fixes. Please consider to upgrade for your projects too:) 200 | We will not support 2.x in our public releases, but if you are using Ecto 2.x, you can get the latest updates by using branch `ecto-2.x` 201 | 202 | # Contributors 203 | 204 | * [@SagarKarwande](https://github.com/SagarKarwande) 205 | * [@oyeb](https://github.com/oyeb) 206 | * [@nicholasjhenry](https://github.com/nicholasjhenry) 207 | * [@montebrown](https://github.com/montebrown) 208 | 209 | # Special thanks 210 | 211 | * Thanks [Travis CI](https://travis-ci.com/) for providing free and convenient integration test 212 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :as_nested_set, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:as_nested_set, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | 31 | if Mix.env == :test do 32 | import_config "test.exs" 33 | end 34 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :as_nested_set, AsNestedSet.TestRepo, 4 | hostname: "localhost", 5 | database: "as_nested_set_test", 6 | pool: Ecto.Adapters.SQL.Sandbox 7 | 8 | config :logger, level: :warn 9 | -------------------------------------------------------------------------------- /config/test.exs.travis: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :as_nested_set, AsNestedSet.TestRepo, 4 | hostname: "localhost", 5 | database: "travis_ci_test", 6 | adapter: Ecto.Adapters.Postgres, 7 | pool: Ecto.Adapters.SQL.Sandbox 8 | 9 | config :logger, level: :warn 10 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test", 4 | "lib/as_nested_set.ex" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /lib/as_nested_set.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet do 2 | defmacro __using__(args) do 3 | scope = Keyword.get(args, :scope, []) 4 | quote do 5 | use AsNestedSet.Model 6 | use AsNestedSet.Scoped, scope: unquote(scope) 7 | end 8 | end 9 | 10 | @type t :: struct 11 | 12 | @type executable :: (Ecto.Repo.t -> any) 13 | 14 | @spec defined?(struct) :: boolean 15 | def defined?(struct) when is_atom(struct) do 16 | try do 17 | struct.__as_nested_set_fields__() 18 | true 19 | rescue 20 | UndefinedFunctionError -> 21 | false 22 | end 23 | end 24 | def defined?(%{__struct__: struct}) do 25 | defined?(struct) 26 | end 27 | def defined?(_), do: false 28 | 29 | @spec execute(executable, Ecto.Repo.t) :: any 30 | def execute(call, repo) do 31 | call.(repo) 32 | end 33 | 34 | defdelegate create(new_model, position), to: AsNestedSet.Modifiable 35 | defdelegate create(new_model, target, position), to: AsNestedSet.Modifiable 36 | defdelegate reload(model), to: AsNestedSet.Modifiable 37 | defdelegate delete(model), to: AsNestedSet.Modifiable 38 | defdelegate move(model, target, position), to: AsNestedSet.Modifiable 39 | defdelegate move(model, position), to: AsNestedSet.Modifiable 40 | 41 | defdelegate self_and_siblings(target), to: AsNestedSet.Queriable 42 | defdelegate ancestors(target), to: AsNestedSet.Queriable 43 | defdelegate self_and_descendants(target), to: AsNestedSet.Queriable 44 | defdelegate root(module, scope), to: AsNestedSet.Queriable 45 | defdelegate roots(module, scope), to: AsNestedSet.Queriable 46 | defdelegate descendants(target), to: AsNestedSet.Queriable 47 | defdelegate leaves(module, scope), to: AsNestedSet.Queriable 48 | defdelegate children(target), to: AsNestedSet.Queriable 49 | defdelegate dump(module, scope), to: AsNestedSet.Queriable 50 | defdelegate dump(module, scope, parent_id), to: AsNestedSet.Queriable 51 | defdelegate dump_one(module, scope), to: AsNestedSet.Queriable 52 | defdelegate right_most(module, scope), to: AsNestedSet.Queriable 53 | defdelegate right_most(target), to: AsNestedSet.Queriable 54 | 55 | defdelegate traverse(module, scope, context, pre, post), to: AsNestedSet.Traversable 56 | defdelegate traverse(node, context, pre, post), to: AsNestedSet.Traversable 57 | end 58 | -------------------------------------------------------------------------------- /lib/as_nested_set/helper.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.Helper do 2 | 3 | def get_column_name(%{__struct__: struct}, field) do 4 | get_column_name(struct, field) 5 | end 6 | 7 | def get_column_name(module, field) when is_atom(module) do 8 | module.__as_nested_set_column_name__(field) 9 | end 10 | 11 | def get_field(%{__struct__: struct} = model, field) do 12 | struct.__as_nested_set_get_field__(model, field) 13 | end 14 | 15 | def set_field(%{__struct__: struct} = model, field, value) do 16 | struct.__as_nested_set_set_field__(model, field, value) 17 | end 18 | 19 | def fields(module) when is_atom(module) do 20 | module.__as_nested_set_fields__() 21 | end 22 | 23 | 24 | end -------------------------------------------------------------------------------- /lib/as_nested_set/model.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.Model do 2 | 3 | defmacro __using__(_) do 4 | quote do 5 | @node_id_column :id 6 | @left_column :lft 7 | @right_column :rgt 8 | @parent_id_column :parent_id 9 | @before_compile AsNestedSet.Model 10 | end 11 | end 12 | 13 | defmacro __before_compile__(env) do 14 | [ 15 | define_accessors([:node_id, :left, :right, :parent_id], env), 16 | define_queriers(env) 17 | ] 18 | end 19 | 20 | defp define_accessors(names, env) do 21 | fields = Enum.map(names, fn 22 | name -> 23 | attribute_name = String.to_atom("#{name}_column") 24 | column_name = Module.get_attribute(env.module, attribute_name) 25 | {name, column_name} 26 | end) |> Enum.into(%{}) 27 | 28 | Enum.map(fields, fn 29 | {name, column_name}-> 30 | quote do 31 | def __as_nested_set_column_name__(unquote(name)) do 32 | unquote(column_name) 33 | end 34 | def __as_nested_set_get_field__(model, unquote(name)) do 35 | Map.get(model, unquote(column_name)) 36 | end 37 | def __as_nested_set_set_field__(model, unquote(name), value) do 38 | Map.put(model, unquote(column_name), value) 39 | end 40 | end 41 | end) ++ [ 42 | quote do 43 | def __as_nested_set_field__(field) do 44 | raise ArgumentError, "Unknown AsNestedSet field #{inspect field} for #{inspect __MODULE__}" 45 | end 46 | def __as_nested_set_get_field__(model, _), do: nil 47 | def __as_nested_set_set_field__(model, _, _), do: model 48 | def __as_nested_set_fields__(), do: unquote(fields |> Macro.escape) 49 | end 50 | ] 51 | end 52 | 53 | defp define_queriers(_env) do 54 | quote do 55 | def child?(model) do 56 | __as_nested_set_get_field__(model, :parent_id) != nil 57 | end 58 | 59 | def root?(model) do 60 | __as_nested_set_get_field__(model, :parent_id) == nil 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/as_nested_set/modifiable.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.Modifiable do 2 | 3 | @type position :: :left | :right | :child | :parent 4 | 5 | import Ecto.Query 6 | import AsNestedSet.Helper 7 | 8 | @spec create(AsNestedSet.t, AsNestedSet.t, position) :: AsNestedSet.executable 9 | @spec create(AsNestedSet.t, nil, :root) :: AsNestedSet.executable 10 | def create(new_model, target \\ nil, position) when is_atom(position) do 11 | fn repo -> 12 | case validate_create(new_model, target, position) do 13 | :ok -> do_safe_create(repo, new_model, do_reload(repo, target), position) 14 | error -> error 15 | end 16 | end 17 | end 18 | 19 | @spec reload(AsNestedSet.t) :: AsNestedSet.executable 20 | def reload(model) do 21 | fn repo -> 22 | do_reload(repo, model) 23 | end 24 | end 25 | 26 | defp validate_create(new_model, parent, position) do 27 | cond do 28 | parent == nil && position != :root -> {:error, :target_is_required} 29 | position != :root && !AsNestedSet.Scoped.same_scope?(parent, new_model) -> {:error, :not_the_same_scope} 30 | true -> :ok 31 | end 32 | end 33 | 34 | defp do_safe_create(repo, %{__struct__: struct} = new_model, target, :left) do 35 | left = get_field(target, :left) 36 | left_column = get_column_name(target, :left) 37 | right_column = get_column_name(target, :right) 38 | # update all the left and right column 39 | from(q in struct, 40 | where: field(q, ^left_column) >= ^left, 41 | update: [inc: ^[{left_column, 2}]] 42 | ) 43 | |> AsNestedSet.Scoped.scoped_query(target) 44 | |> repo.update_all([]) 45 | 46 | from(q in struct, 47 | where: field(q, ^right_column) > ^left, 48 | update: [inc: ^[{right_column, 2}]] 49 | ) 50 | |> AsNestedSet.Scoped.scoped_query(target) 51 | |> repo.update_all([]) 52 | 53 | 54 | parent_id_column = get_column_name(target, :parent_id) 55 | parent_id = get_field(target, :parent_id) 56 | # insert the new model 57 | new_model 58 | |> struct.changeset(Map.new([ 59 | {left_column, left}, 60 | {right_column, left + 1}, 61 | {parent_id_column, parent_id} 62 | ])) 63 | |> repo.insert! 64 | end 65 | 66 | defp do_safe_create(repo, %{__struct__: struct} = new_model, target, :right) do 67 | right = get_field(target, :right) 68 | left_column = get_column_name(target, :left) 69 | # update all the left and right column 70 | from(q in struct, 71 | where: field(q, ^left_column) > ^right, 72 | update: [inc: ^[{left_column, 2}]] 73 | ) 74 | |> AsNestedSet.Scoped.scoped_query(target) 75 | |> repo.update_all([]) 76 | 77 | right_column = get_column_name(target, :right) 78 | from(q in struct, 79 | where: field(q, ^right_column) > ^right, 80 | update: [inc: ^[{right_column, 2}]] 81 | ) 82 | |> AsNestedSet.Scoped.scoped_query(target) 83 | |> repo.update_all([]) 84 | 85 | parent_id_column = get_column_name(target, :parent_id) 86 | parent_id = get_field(target, :parent_id) 87 | # insert new model 88 | new_model 89 | |> struct.changeset(Map.new([ 90 | {left_column, right + 1}, 91 | {right_column, right + 2}, 92 | {parent_id_column, parent_id} 93 | ])) 94 | |> repo.insert! 95 | end 96 | 97 | defp do_safe_create(repo, %{__struct__: struct} = new_model, target, :child) do 98 | 99 | left_column = get_column_name(target, :left) 100 | 101 | right = get_field(target, :right) 102 | from(q in struct, 103 | where: field(q, ^left_column) > ^right, 104 | update: [inc: ^[{left_column, 2}]] 105 | ) 106 | |> AsNestedSet.Scoped.scoped_query(target) 107 | |> repo.update_all([]) 108 | 109 | right_column = get_column_name(target, :right) 110 | from(q in struct, 111 | where: field(q, ^right_column) >= ^right, 112 | update: [inc: ^[{right_column, 2}]] 113 | ) 114 | |> AsNestedSet.Scoped.scoped_query(target) 115 | |> repo.update_all([]) 116 | 117 | 118 | parent_id_column = get_column_name(target, :parent_id) 119 | node_id = get_field(target, :node_id) 120 | new_model 121 | |> struct.changeset(Map.new([ 122 | {left_column, right}, 123 | {right_column, right + 1}, 124 | {parent_id_column, node_id} 125 | ])) 126 | |> repo.insert! 127 | end 128 | 129 | defp do_safe_create(repo, %{__struct__: struct} = new_model, _target, :root) do 130 | right_most = AsNestedSet.Queriable.right_most(struct, new_model).(repo) || -1 131 | 132 | new_model = new_model 133 | |> set_field(:left, right_most + 1) 134 | |> set_field(:right, right_most + 2) 135 | |> set_field(:parent_id, nil) 136 | |> repo.insert! 137 | 138 | new_model 139 | end 140 | 141 | defp do_safe_create(repo, %{__struct__: struct} = new_model, target, :parent) do 142 | right = get_field(target, :right) 143 | left = get_field(target, :left) 144 | 145 | right_column = get_column_name(target, :right) 146 | from(q in struct, 147 | where: field(q, ^right_column) > ^right, 148 | update: [inc: ^[{right_column, 2}]] 149 | ) 150 | |> AsNestedSet.Scoped.scoped_query(target) 151 | |> repo.update_all([]) 152 | 153 | left_column = get_column_name(target, :left) 154 | from(q in struct, 155 | where: field(q, ^left_column) > ^right, 156 | update: [inc: ^[{left_column, 2}]] 157 | ) 158 | |> AsNestedSet.Scoped.scoped_query(target) 159 | |> repo.update_all([]) 160 | 161 | from(q in struct, 162 | where: field(q, ^left_column) >= ^left and field(q, ^right_column) <= ^right, 163 | update: [inc: ^[{right_column, 1}, {left_column, 1}]] 164 | ) 165 | |> AsNestedSet.Scoped.scoped_query(target) 166 | |> repo.update_all([]) 167 | 168 | parent_id = get_field(target, :parent_id) 169 | new_model = new_model 170 | |> set_field(:left, left) 171 | |> set_field(:right, right + 2) 172 | |> set_field(:parent_id, parent_id) 173 | |> repo.insert! 174 | 175 | node_id = get_field(target, :node_id) 176 | node_id_column = get_column_name(target, :node_id) 177 | parent_id_column = get_column_name(target, :parent_id) 178 | 179 | new_model_id = get_field(new_model, :node_id) 180 | 181 | from(q in struct, 182 | where: field(q, ^node_id_column) == ^node_id, 183 | update: [set: ^[{parent_id_column, new_model_id}]] 184 | ) 185 | |> AsNestedSet.Scoped.scoped_query(target) 186 | |> repo.update_all([]) 187 | 188 | new_model 189 | end 190 | 191 | defp do_reload(_repo, nil), do: nil 192 | 193 | defp do_reload(repo, %{__struct__: struct} = target) do 194 | node_id = get_field(target, :node_id) 195 | node_id_column = get_column_name(target, :node_id) 196 | from(q in struct, 197 | where: field(q, ^node_id_column) == ^node_id, 198 | limit: 1 199 | ) 200 | |> AsNestedSet.Scoped.scoped_query(target) 201 | |> repo.one 202 | end 203 | 204 | @spec delete(AsNestedSet.t) :: AsNestedSet.executable 205 | def delete(%{__struct__: struct} = model) do 206 | fn repo -> 207 | left = get_field(model, :left) 208 | right = get_field(model, :right) 209 | width = right - left + 1 210 | 211 | left_column = get_column_name(model, :left) 212 | right_column = get_column_name(model, :right) 213 | 214 | from(q in struct, 215 | where: field(q, ^left_column) >= ^left and field(q, ^left_column) <= ^right 216 | ) 217 | |> AsNestedSet.Scoped.scoped_query(model) 218 | |> repo.delete_all([]) 219 | 220 | from(q in struct, 221 | where: field(q, ^right_column) > ^right, 222 | update: [inc: ^[{right_column, -width}]] 223 | ) 224 | |> AsNestedSet.Scoped.scoped_query(model) 225 | |> repo.update_all([]) 226 | 227 | from(q in struct, 228 | where: field(q, ^left_column) > ^right, 229 | update: [inc: ^[{left_column, -width}]] 230 | ) 231 | |> AsNestedSet.Scoped.scoped_query(model) 232 | |> repo.update_all([]) 233 | end 234 | end 235 | 236 | @spec move(AsNestedSet.t, AsNestedSet.t, position) :: AsNestedSet.executable 237 | @spec move(AsNestedSet.t, nil, :root) :: AsNestedSet.executable 238 | def move(%{__struct__: _} = model, target \\ nil, position) when is_atom(position) do 239 | fn repo -> 240 | model = do_reload(repo, model) 241 | case validate_move(model, target, position) do 242 | :ok -> do_safe_move(repo, model, do_reload(repo, target), position) 243 | error -> error 244 | end 245 | end 246 | end 247 | 248 | defp validate_move(model, target, position) do 249 | cond do 250 | target == nil && position != :root -> {:error, :target_is_required} 251 | position == :parent -> {:error, :cannot_move_to_parent} 252 | target != nil && get_field(model, :left) <= get_field(target, :left) && get_field(model, :right) >= get_field(target, :right) -> {:error, :within_the_same_tree} 253 | position != :root && !AsNestedSet.Scoped.same_scope?(target, model) -> {:error, :not_the_same_scope} 254 | true -> :ok 255 | end 256 | end 257 | 258 | defp do_safe_move(repo, model, target, position) do 259 | if target != nil && get_field(model, :node_id) == get_field(target, :node_id) do 260 | model 261 | else 262 | target_bound = target_bound(repo, model, target, position) 263 | left = get_field(model, :left) 264 | right = get_field(model, :right) 265 | case get_bounaries(model, target_bound) do 266 | {bound, other_bound} -> 267 | do_switch(repo, model, {left, right, bound, other_bound}, new_parent_id(target, position)) 268 | :no_operation -> 269 | model 270 | end 271 | end 272 | end 273 | 274 | def target_bound(repo, model, target, position) do 275 | case position do 276 | :child -> get_field(target, :right) 277 | :left -> get_field(target, :left) 278 | :right -> get_field(target, :right) + 1 279 | :root -> AsNestedSet.right_most(model).(repo) + 1 280 | end 281 | end 282 | 283 | def get_bounaries(model, target_bound) do 284 | left = get_field(model, :left) 285 | right = get_field(model, :right) 286 | cond do 287 | target_bound - 1 >= right + 1 -> 288 | {right + 1, target_bound - 1} 289 | target_bound <= left - 1 -> 290 | {target_bound, left - 1} 291 | true -> 292 | :no_operation 293 | end 294 | end 295 | 296 | defp new_parent_id(target, position) do 297 | case position do 298 | :child -> get_field(target, :node_id) 299 | :left -> get_field(target, :parent_id) 300 | :right -> get_field(target, :parent_id) 301 | :root -> nil 302 | end 303 | end 304 | 305 | defp do_switch(repo, %{__struct__: struct} = model, boundaries, new_parent_id) do 306 | # As we checked the boundaries, the two interval is non-overlapping 307 | [a, b, c, d]= boundaries |> Tuple.to_list |> Enum.sort 308 | node_id = get_field(model, :node_id) 309 | node_id_column = get_column_name(model, :node_id) 310 | parent_id_column = get_column_name(model, :parent_id) 311 | # shift the left part to the temporary position (negative space) 312 | do_shift(repo, model, {a, b}, -b - 1) 313 | do_shift(repo, model, {c, d}, a - c) 314 | do_shift(repo, model, {a - b - 1, -1}, d + 1) 315 | from(n in struct, where: field(n, ^node_id_column) == ^node_id, update: [set: ^[{parent_id_column, new_parent_id}]]) 316 | |> AsNestedSet.Scoped.scoped_query(model) 317 | |> repo.update_all([]) 318 | do_reload(repo, model) 319 | end 320 | 321 | defp do_shift(repo, %{__struct__: struct} = model, {left, right}, delta) do 322 | left_column = get_column_name(model, :left) 323 | right_column = get_column_name(model, :right) 324 | from(struct) 325 | |> where([n], field(n, ^left_column) >= ^left and field(n, ^left_column) <= ^right) 326 | |> update([n], [inc: ^[{left_column, delta}]]) 327 | |> AsNestedSet.Scoped.scoped_query(model) 328 | |> repo.update_all([]) 329 | 330 | from(struct) 331 | |> where([n], field(n, ^right_column) >= ^left and field(n, ^right_column) <= ^right) 332 | |> update([n], [inc: ^[{right_column, delta}]]) 333 | |> AsNestedSet.Scoped.scoped_query(model) 334 | |> repo.update_all([]) 335 | 336 | end 337 | end 338 | -------------------------------------------------------------------------------- /lib/as_nested_set/queriable.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.Queriable do 2 | 3 | import Ecto.Query 4 | import AsNestedSet.Helper 5 | 6 | def self_and_siblings(%{__struct__: struct} = target) do 7 | fn repo -> 8 | parent_id_column = get_column_name(target, :parent_id) 9 | left_column = get_column_name(target, :left) 10 | parent_id = get_field(target, :parent_id) 11 | from(q in struct, 12 | where: field(q, ^parent_id_column) == ^parent_id, 13 | order_by: ^[left_column] 14 | ) 15 | |> AsNestedSet.Scoped.scoped_query(target) 16 | |> repo.all 17 | end 18 | end 19 | 20 | def ancestors(%{__struct__: struct} = target) do 21 | fn repo -> 22 | left = get_field(target, :left) 23 | right = get_field(target, :right) 24 | left_column = get_column_name(target, :left) 25 | right_column = get_column_name(target, :right) 26 | from(q in struct, 27 | where: field(q, ^left_column) < ^left and field(q, ^right_column) > ^right, 28 | order_by: ^[left_column] 29 | ) 30 | |> AsNestedSet.Scoped.scoped_query(target) 31 | |> repo.all 32 | end 33 | end 34 | 35 | def self_and_descendants(%{__struct__: struct} = target) do 36 | fn repo -> 37 | left = get_field(target, :left) 38 | right = get_field(target, :right) 39 | left_column = get_column_name(target, :left) 40 | right_column = get_column_name(target, :right) 41 | from(q in struct, 42 | where: field(q, ^left_column) >= ^left and field(q, ^right_column) <= ^right, 43 | order_by: ^[left_column] 44 | ) 45 | |> AsNestedSet.Scoped.scoped_query(target) 46 | |> repo.all 47 | end 48 | end 49 | 50 | def root(module, scope) when is_atom(module) do 51 | fn repo -> 52 | parent_id_column = get_column_name(module, :parent_id) 53 | from(q in module, 54 | where: is_nil(field(q, ^parent_id_column)), 55 | limit: 1 56 | ) 57 | |> AsNestedSet.Scoped.scoped_query(scope) 58 | |> repo.one 59 | end 60 | end 61 | 62 | def roots(module, scope) when is_atom(module) do 63 | fn repo -> 64 | parent_id_column = get_column_name(module, :parent_id) 65 | left_column = get_column_name(module, :left) 66 | from(q in module, 67 | where: is_nil(field(q, ^parent_id_column)), 68 | order_by: ^[left_column] 69 | ) 70 | |> AsNestedSet.Scoped.scoped_query(scope) 71 | |> repo.all 72 | end 73 | end 74 | 75 | def descendants(%{__struct__: struct} = target) do 76 | fn repo -> 77 | left = get_field(target, :left) 78 | right = get_field(target, :right) 79 | left_column = get_column_name(target, :left) 80 | right_column = get_column_name(target, :right) 81 | from(q in struct, 82 | where: field(q, ^left_column) > ^left and field(q, ^right_column) < ^right, 83 | order_by: ^[left_column] 84 | ) 85 | |> AsNestedSet.Scoped.scoped_query(target) 86 | |> repo.all 87 | end 88 | end 89 | 90 | def leaves(module, scope) when is_atom(module) do 91 | fn repo -> 92 | left_column = get_column_name(module, :left) 93 | right_column = get_column_name(module, :right) 94 | from(q in module, 95 | where: fragment("? - ?", field(q, ^right_column), field(q, ^left_column)) == 1, 96 | order_by: ^[left_column] 97 | ) 98 | |> AsNestedSet.Scoped.scoped_query(scope) 99 | |> repo.all 100 | end 101 | end 102 | 103 | def children(%{__struct__: struct} = target) do 104 | fn repo -> 105 | parent_id_column = get_column_name(target, :parent_id) 106 | left_column = get_column_name(target, :left) 107 | node_id = get_field(target, :node_id) 108 | from(q in struct, 109 | where: field(q, ^parent_id_column) == ^node_id, 110 | order_by: ^[left_column] 111 | ) 112 | |> AsNestedSet.Scoped.scoped_query(target) 113 | |> repo.all 114 | end 115 | end 116 | 117 | def dump(module, scope, parent_id \\ nil) do 118 | fn repo -> 119 | parent_id_column = get_column_name(module, :parent_id) 120 | left_column = get_column_name(module, :left) 121 | 122 | children = if parent_id do 123 | from(q in module, 124 | where: field(q, ^parent_id_column) == ^parent_id, 125 | order_by: ^[left_column] 126 | ) 127 | else 128 | from(q in module, 129 | where: is_nil(field(q, ^parent_id_column)), 130 | order_by: ^[left_column] 131 | ) 132 | end 133 | |> AsNestedSet.Scoped.scoped_query(scope) 134 | |> repo.all 135 | 136 | Enum.map(children, fn(child) -> 137 | node_id = get_field(child, :node_id) 138 | {child, dump(module, scope, node_id).(repo)} 139 | end) 140 | end 141 | end 142 | 143 | def dump_one(module, scope) do 144 | fn repo -> 145 | case dump(module, scope).(repo) do 146 | [dump|_] -> dump 147 | error -> error 148 | end 149 | end 150 | end 151 | 152 | def right_most(module, scope) when is_atom(module) do 153 | fn repo -> 154 | right_column = get_column_name(module, :right) 155 | from(q in module, 156 | select: max(field(q, ^right_column)) 157 | ) 158 | |> AsNestedSet.Scoped.scoped_query(scope) 159 | |> repo.one! 160 | end 161 | end 162 | 163 | def right_most(nil), do: fn _ -> -1 end 164 | def right_most(%{__struct__: struct} = target) do 165 | fn repo -> 166 | right_most(struct, target).(repo) 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/as_nested_set/scoped.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.Scoped do 2 | 3 | import Ecto.Query 4 | 5 | @type scope :: [atom] 6 | 7 | defmacro __using__(args) do 8 | quote do 9 | @scope unquote(Keyword.get(args, :scope, [])) 10 | @before_compile AsNestedSet.Scoped 11 | end 12 | end 13 | 14 | defmacro __before_compile__(env) do 15 | scope = Module.get_attribute(env.module, :scope) 16 | quote do 17 | def __as_nested_set_scope__(), do: unquote(scope) 18 | end 19 | end 20 | 21 | @spec same_scope?(AsNestedSet.t, AsNestedSet.t) :: boolean 22 | def same_scope?(source, target) do 23 | source.__struct__ == target.__struct__ 24 | && do_same_scope?(source, target) 25 | end 26 | 27 | @spec scoped_query(Ecto.Query.t, map) :: Ecto.Query.t 28 | def scoped_query(query, scope) do 29 | %Ecto.Query.FromExpr{source: {_, module}} = query.from 30 | do_scoped_query(query, scope, module.__as_nested_set_scope__()) 31 | end 32 | 33 | @spec assign_scope_from(any, any) :: any 34 | def assign_scope_from(%{__struct__: struct} = target, %{__struct__: struct} = source) do 35 | scope = struct.__as_nested_set_scope__ 36 | Enum.reduce(scope, target, fn(scope, acc) -> 37 | Map.put(acc, scope, Map.fetch!(source, scope)) 38 | end) 39 | end 40 | 41 | @spec scope(any) :: map 42 | def scope(%{__struct__: struct} = target) do 43 | scope = struct.__as_nested_set_scope__ 44 | Enum.reduce(scope, %{}, fn scope, acc -> 45 | Map.put(acc, scope, Map.fetch!(target, scope)) 46 | end) 47 | end 48 | 49 | def scope(module) when is_atom(module) do 50 | module.__as_nested_set_scope__ 51 | end 52 | 53 | defp do_scoped_query(query, scope, scope_fields) do 54 | Enum.reduce(scope_fields, query, fn(scope_field, acc) -> 55 | case Map.fetch!(scope, scope_field) do 56 | nil -> 57 | acc |> where([p], is_nil(field(p, ^scope_field))) 58 | v -> 59 | acc |> where([p], field(p, ^scope_field) == ^v) 60 | end 61 | end) 62 | end 63 | 64 | defp do_same_scope?(%{__struct__: struct} = source, target) do 65 | scope = struct.__as_nested_set_scope__() 66 | Enum.all?(scope, fn field -> 67 | Map.get(source, field) == Map.get(target, field) 68 | end) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/as_nested_set/traversable.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.Traversable do 2 | 3 | @type pre_fun :: (AsNestedSet.t, any -> {AsNestedSet.t, any}) 4 | @type post_fun :: (AsNestedSet.t, [AsNestedSet.t], any -> {AsNestedSet.t, any}) | (AsNestedSet.t, any -> {AsNestedSet.t, any}) 5 | 6 | @spec traverse(AsNestedSet.t, any, pre_fun, post_fun) :: (Ecto.Repo.t -> {AsNestedSet.t, any}) 7 | def traverse(%{__struct__: _} = node, context, pre, post) do 8 | fn repo -> 9 | node = do_reload(node, repo) 10 | {node, context} = call_pre(pre, node, context) 11 | do_traverse(node, context, pre, post, repo) 12 | end 13 | end 14 | 15 | @spec traverse(module, AsNestedSet.Scoped.scope, any, pre_fun, post_fun) :: (Ecto.Repo.t -> {[AsNestedSet.t], any}) 16 | def traverse(module, scope, context, pre, post) do 17 | fn repo -> 18 | AsNestedSet.Queriable.roots(module, scope).(repo) 19 | |> do_traverse_children(context, pre, post, repo) 20 | end 21 | end 22 | 23 | defp do_traverse(node, context, pre, post, repo) do 24 | {children, context} = AsNestedSet.Queriable.children(node).(repo) 25 | |> do_traverse_children(context, pre, post, repo) 26 | call_post(post, do_reload(node, repo), do_reload(children, repo), context) 27 | end 28 | 29 | defp do_traverse_children([], context, _pre, _post, _repo), do: {[], context} 30 | defp do_traverse_children(children, context, pre, post, repo) do 31 | {children, context} = Enum.reduce(children, {[], context}, fn 32 | child, {acc, context} -> 33 | {child, context} = call_pre(pre, do_reload(child, repo), context) 34 | {child, context} = do_traverse(child, context, pre, post, repo) 35 | {[child|acc], context} 36 | end) 37 | {children |> Enum.reverse, context} 38 | end 39 | 40 | defp do_reload(nodes, repo) when is_list(nodes) do 41 | Enum.map(nodes, &do_reload(&1, repo)) 42 | end 43 | 44 | defp do_reload(node, repo) do 45 | AsNestedSet.Modifiable.reload(node).(repo) 46 | end 47 | 48 | defp call_pre(pre, node, context) do 49 | pre.(node, context) |> check_callback_response(:pre) 50 | end 51 | 52 | defp call_post(post, node, children, context) when is_function(post, 3)do 53 | post.(node, children, context) |> check_callback_response(:post) 54 | end 55 | 56 | defp call_post(post, node, _children, context) when is_function(post, 2) do 57 | post.(node, context) |> check_callback_response(:post) 58 | end 59 | 60 | defp check_callback_response({_node, _context} = ok, _), do: ok 61 | defp check_callback_response(error, callback), do: raise ArgumentError, "Expect #{inspect callback} to return {AsNestedSet.t, context} but got #{inspect error}" 62 | end 63 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :as_nested_set, 7 | version: "3.4.1", 8 | elixir: "~> 1.2", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | description: description(), 11 | package: package(), 12 | build_embedded: Mix.env() == :prod, 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | test_coverage: [tool: ExCoveralls], 16 | preferred_cli_env: [ 17 | coveralls: :test, 18 | "coveralls.detail": :test, 19 | "coveralls.post": :test, 20 | "coveralls.html": :test, 21 | "coveralls.travis": :test 22 | ] 23 | ] 24 | end 25 | 26 | defp description do 27 | """ 28 | An ecto based Nested set model implementation 29 | """ 30 | end 31 | 32 | defp package do 33 | [ 34 | name: :as_nested_set, 35 | files: ["lib", "priv", "mix.exs", "README*", "LICENSE*", "CHANGELOG*"], 36 | maintainers: ["dusiyh@gmail.com"], 37 | licenses: ["MIT"], 38 | links: %{"GitHub" => "https://github.com/secretworry/as_nested_set"} 39 | ] 40 | end 41 | 42 | def application do 43 | [applications: app_list(Mix.env())] 44 | end 45 | 46 | def app_list(:test), do: app_list() ++ [:ecto, :postgrex, :ex_machina] 47 | def app_list(_), do: app_list() 48 | def app_list, do: [:logger] 49 | 50 | defp deps do 51 | [ 52 | {:ecto, "~> 3.4"}, 53 | {:ecto_sql, "~> 3.4"}, 54 | {:ex_doc, ">= 0.18.3", only: :dev}, 55 | {:postgrex, "~> 0.15.0", only: :test}, 56 | {:ex_machina, "~> 2.2", only: [:test]}, 57 | {:excoveralls, "~> 0.12", only: [:test]} 58 | ] 59 | end 60 | 61 | defp elixirc_paths(:test), do: elixirc_paths() ++ ["test/support"] 62 | defp elixirc_paths(_), do: elixirc_paths() 63 | defp elixirc_paths, do: ["lib"] 64 | end 65 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, 3 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, 4 | "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, 5 | "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, 6 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 7 | "ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"}, 8 | "ecto_sql": {:hex, :ecto_sql, "3.4.4", "d28bac2d420f708993baed522054870086fd45016a9d09bb2cd521b9c48d32ea", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "edb49af715dd72f213b66adfd0f668a43c17ed510b5d9ac7528569b23af57fe8"}, 9 | "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, 10 | "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"}, 11 | "excoveralls": {:hex, :excoveralls, "0.12.1", "a553c59f6850d0aff3770e4729515762ba7c8e41eedde03208182a8dc9d0ce07", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "5c1f717066a299b1b732249e736c5da96bb4120d1e55dc2e6f442d251e18a812"}, 12 | "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"}, 13 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, 14 | "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, 15 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, 17 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 18 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 19 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, 20 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 21 | "postgrex": {:hex, :postgrex, "0.15.5", "aec40306a622d459b01bff890fa42f1430dac61593b122754144ad9033a2152f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ed90c81e1525f65a2ba2279dbcebf030d6d13328daa2f8088b9661eb9143af7f"}, 22 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"}, 23 | "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, 24 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, 25 | } 26 | -------------------------------------------------------------------------------- /priv/test_repo/migrations/1_migrate_all.exs: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.TestRepo.Migrations.MigrateAll do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:taxons) do 6 | add :name, :string 7 | add :taxonomy_id, :id 8 | add :parent_id, :id 9 | add :lft, :integer 10 | add :rgt, :integer 11 | 12 | timestamps() 13 | end 14 | 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/as_nested_set/helper_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.HelperTest do 2 | 3 | use AsNestedSet.EctoCase 4 | 5 | import AsNestedSet.Helper 6 | 7 | defmodule Sample do 8 | use AsNestedSet 9 | defstruct id: "id", lft: "left", rgt: "right", parent_id: "parent_id" 10 | end 11 | 12 | describe "fields/1" do 13 | 14 | test "should return fields configurations for specified module" do 15 | assert %{left: :lft, node_id: :id, parent_id: :parent_id, right: :rgt} = fields(Sample) 16 | end 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /test/as_nested_set/modifiable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.ModifiableTest do 2 | 3 | use AsNestedSet.EctoCase 4 | 5 | import AsNestedSet.Factory, only: [insert: 2] 6 | import AsNestedSet.Matcher 7 | alias AsNestedSet.Taxon 8 | alias AsNestedSet.TestRepo, as: Repo 9 | 10 | import AsNestedSet.Modifiable 11 | import AsNestedSet.Queriable, only: [dump: 2, dump_one: 2] 12 | 13 | @doc """ 14 | Create a nested set tree 15 | +--------------------+ 16 | | +-----------+ | 17 | | +---+| +-------+ | | 18 | |0|1 2||3|4 5|6 7|8|9| 19 | +--------------------+ 20 | """ 21 | def create_tree(taxonomy_id) do 22 | n0 = insert(:taxon, name: "n0", lft: 0, rgt: 9, taxonomy_id: taxonomy_id) 23 | n00 = insert(:taxon, name: "n00", lft: 1, rgt: 2, parent_id: n0.id, taxonomy_id: taxonomy_id) 24 | n01 = insert(:taxon, name: "n01", lft: 3, rgt: 8, parent_id: n0.id, taxonomy_id: taxonomy_id) 25 | n010 = insert(:taxon, name: "n010", lft: 4, rgt: 5, parent_id: n01.id, taxonomy_id: taxonomy_id) 26 | n011 = insert(:taxon, name: "n011", lft: 6, rgt: 7, parent_id: n01.id, taxonomy_id: taxonomy_id) 27 | {n0, [ 28 | {n00, []}, 29 | {n01, [ 30 | {n010, []}, 31 | {n011, []} 32 | ]} 33 | ]} 34 | end 35 | 36 | def execute(executable) do 37 | AsNestedSet.execute(executable, Repo) 38 | end 39 | 40 | test "create/2 should create nodes with nil scope field without error" do 41 | assert %AsNestedSet.Taxon{} = create(%Taxon{taxonomy_id: nil}, :root) |> execute 42 | end 43 | 44 | test "create/3 should return {:error, :not_the_same_scope} for creating node from another scope" do 45 | node = insert(:taxon, lft: 0, rgt: 1, taxonomy_id: 0) 46 | assert create(%Taxon{taxonomy_id: 1}, node , :child) |> execute == {:error, :not_the_same_scope} 47 | end 48 | 49 | test "create/3 should return {:error, :target_is_required} for creating without passing a target" do 50 | insert(:taxon, lft: 0, rgt: 1, taxonomy_id: 0) 51 | assert create(%Taxon{taxonomy_id: 1}, :child) |> execute == {:error, :target_is_required} 52 | end 53 | 54 | test "create/3 should create left node" do 55 | {_, [{target, _}|_]} = create_tree(1) 56 | 57 | %Taxon{name: "left", taxonomy_id: 1} |> create(target, :left) |> execute 58 | 59 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 60 | {%{name: "n0", lft: 0, rgt: 11, taxonomy_id: 1}, [ 61 | {%{ name: "left", lft: 1, rgt: 2, taxonomy_id: 1}, []}, 62 | {%{ name: "n00", lft: 3, rgt: 4, taxonomy_id: 1}, []}, 63 | {%{ name: "n01", lft: 5, rgt: 10, taxonomy_id: 1}, [ 64 | {%{ name: "n010", lft: 6, rgt: 7, taxonomy_id: 1}, []}, 65 | {%{ name: "n011", lft: 8, rgt: 9, taxonomy_id: 1}, []} 66 | ]} 67 | ]} 68 | ) 69 | end 70 | 71 | test "create/3 should create right node" do 72 | {_, [{target, _}|_]} = create_tree(1) 73 | %Taxon{name: "right", taxonomy_id: 1} |> create(target, :right) |> execute 74 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 75 | {%{name: "n0", lft: 0, rgt: 11, taxonomy_id: 1}, [ 76 | {%{ name: "n00", lft: 1, rgt: 2, taxonomy_id: 1}, []}, 77 | {%{ name: "right", lft: 3, rgt: 4, taxonomy_id: 1}, []}, 78 | {%{ name: "n01", lft: 5, rgt: 10, taxonomy_id: 1}, [ 79 | {%{ name: "n010", lft: 6, rgt: 7, taxonomy_id: 1}, []}, 80 | {%{ name: "n011", lft: 8, rgt: 9, taxonomy_id: 1}, []} 81 | ]} 82 | ]} 83 | ) 84 | end 85 | 86 | test "create/3 should create child node" do 87 | {_, [{target, _}|_]} = create_tree(1) 88 | %Taxon{name: "child", taxonomy_id: 1} |> create(target, :child) |> execute 89 | 90 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 91 | {%{name: "n0", lft: 0, rgt: 11, taxonomy_id: 1}, [ 92 | {%{ name: "n00", lft: 1, rgt: 4, taxonomy_id: 1}, [ 93 | {%{ name: "child", lft: 2, rgt: 3, taxonomy_id: 1}, []} 94 | ]}, 95 | {%{ name: "n01", lft: 5, rgt: 10, taxonomy_id: 1}, [ 96 | {%{ name: "n010", lft: 6, rgt: 7, taxonomy_id: 1}, []}, 97 | {%{ name: "n011", lft: 8, rgt: 9, taxonomy_id: 1}, []} 98 | ]} 99 | ]} 100 | ) 101 | end 102 | 103 | test "create/2 should create root node for empty tree" do 104 | %Taxon{name: "root", taxonomy_id: 1} |> create(:root) |> execute 105 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 106 | {%{name: "root", lft: 0, rgt: 1, taxonomy_id: 1}, []} 107 | ) 108 | end 109 | 110 | test "create/2 should create root node" do 111 | create_tree(1) 112 | %Taxon{name: "root", taxonomy_id: 1} |> create(:root) |> execute 113 | assert match(dump(Taxon, %{taxonomy_id: 1}) |> execute, [ 114 | {%{name: "n0", lft: 0, rgt: 9, taxonomy_id: 1}, [ 115 | {%{ name: "n00", lft: 1, rgt: 2, taxonomy_id: 1}, []}, 116 | {%{ name: "n01", lft: 3, rgt: 8, taxonomy_id: 1}, [ 117 | {%{ name: "n010", lft: 4, rgt: 5, taxonomy_id: 1}, []}, 118 | {%{ name: "n011", lft: 6, rgt: 7, taxonomy_id: 1}, []} 119 | ]} 120 | ]}, 121 | {%{name: "root", lft: 10, rgt: 11, taxonomy_id: 1}, []} 122 | ]) 123 | end 124 | 125 | test "create/2 should create parent node" do 126 | {_, [{target, _}|_]} = create_tree(1) 127 | %Taxon{name: "parent", taxonomy_id: 1} |> create(target, :parent) |> execute 128 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, { 129 | %{name: "n0", lft: 0, rgt: 11, taxonomy_id: 1}, [ 130 | {%{name: "parent", lft: 1, rgt: 4, taxonomy_id: 1}, [ 131 | {%{ name: "n00", lft: 2, rgt: 3, taxonomy_id: 1}, []}, 132 | ]}, 133 | {%{ name: "n01", lft: 5, rgt: 10, taxonomy_id: 1}, [ 134 | {%{ name: "n010", lft: 6, rgt: 7, taxonomy_id: 1}, []}, 135 | {%{ name: "n011", lft: 8, rgt: 9, taxonomy_id: 1}, []} 136 | ]} 137 | ] 138 | }) 139 | end 140 | 141 | test "create/3 should not affect other tree" do 142 | create_tree(1) 143 | create_tree(2) 144 | %Taxon{name: "root", taxonomy_id: 1} |> create(:root) |> execute 145 | assert match(dump_one(Taxon, %{taxonomy_id: 2}) |> execute, 146 | {%{name: "n0", lft: 0, rgt: 9, taxonomy_id: 2}, [ 147 | {%{ name: "n00", lft: 1, rgt: 2, taxonomy_id: 2}, []}, 148 | {%{ name: "n01", lft: 3, rgt: 8, taxonomy_id: 2}, [ 149 | {%{ name: "n010", lft: 4, rgt: 5, taxonomy_id: 2}, []}, 150 | {%{ name: "n011", lft: 6, rgt: 7, taxonomy_id: 2}, []} 151 | ]} 152 | ]} 153 | ) 154 | end 155 | 156 | test "delete/1 should delete a node and all its descendants" do 157 | {_, [_,{target, _}]} = create_tree(1) 158 | delete(target) |> execute 159 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 160 | {%{name: "n0", lft: 0, rgt: 3, taxonomy_id: 1}, [ 161 | {%{ name: "n00", lft: 1, rgt: 2, taxonomy_id: 1}, []} 162 | ]} 163 | ) 164 | end 165 | 166 | test "create/3 should create consecutive children" do 167 | root = %Taxon{name: "root", taxonomy_id: 1} |> create(:root) |> execute 168 | %Taxon{name: "child0", taxonomy_id: 1} |> create(root, :child) |> execute 169 | %Taxon{name: "child1", taxonomy_id: 1} |> create(root, :child) |> execute 170 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 171 | {%{name: "root", lft: 0, rgt: 5, taxonomy_id: 1}, [ 172 | {%{name: "child0", lft: 1, rgt: 2, taxonomy_id: 1}, []}, 173 | {%{name: "child1", lft: 3, rgt: 4, taxonomy_id: 1}, []} 174 | ]} 175 | ) 176 | end 177 | 178 | test "move(node, :root) should move node to root" do 179 | {_, [_, {_, [{n010, _} | _]}]} = create_tree(1) 180 | move(n010, :root) |> execute 181 | assert match(dump(Taxon, %{taxonomy_id: 1}) |> execute, 182 | [ 183 | {%{name: "n0", lft: 0, rgt: 7, taxonomy_id: 1}, [ 184 | {%{ name: "n00", lft: 1, rgt: 2, taxonomy_id: 1}, []}, 185 | {%{ name: "n01", lft: 3, rgt: 6, taxonomy_id: 1}, [ 186 | {%{ name: "n011", lft: 4, rgt: 5, taxonomy_id: 1}, []} 187 | ]} 188 | ]}, 189 | {%{ name: "n010", lft: 8, rgt: 9, taxonomy_id: 1}, []} 190 | ] 191 | ) 192 | end 193 | 194 | test "move(node, target, :child) should move given node to the child of target" do 195 | {_, [{n00, _}, {n01, _}]} = create_tree(1) 196 | move(n01, n00, :child) |> execute 197 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 198 | {%{name: "n0", lft: 0, rgt: 9, taxonomy_id: 1}, [ 199 | {%{ name: "n00", lft: 1, rgt: 8, taxonomy_id: 1}, [ 200 | {%{ name: "n01", lft: 2, rgt: 7, taxonomy_id: 1}, [ 201 | {%{ name: "n010", lft: 3, rgt: 4, taxonomy_id: 1}, []}, 202 | {%{ name: "n011", lft: 5, rgt: 6, taxonomy_id: 1}, []} 203 | ]} 204 | ]} 205 | ]} 206 | ) 207 | end 208 | 209 | test "move(node, target, :left) should move given node to the left of target" do 210 | {_, [{n00, _}, {n01, _}]} = create_tree(1) 211 | move(n01, n00, :left) |> execute 212 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 213 | {%{name: "n0", lft: 0, rgt: 9, taxonomy_id: 1}, [ 214 | {%{ name: "n01", lft: 1, rgt: 6, taxonomy_id: 1}, [ 215 | {%{ name: "n010", lft: 2, rgt: 3, taxonomy_id: 1}, []}, 216 | {%{ name: "n011", lft: 4, rgt: 5, taxonomy_id: 1}, []} 217 | ]}, 218 | {%{ name: "n00", lft: 7, rgt: 8, taxonomy_id: 1}, []} 219 | ]} 220 | ) 221 | end 222 | 223 | test "move(node, target, :right) should move given node to the right of target" do 224 | {_, [{n00, _}, {n01, _}]} = create_tree(1) 225 | move(n00, n01, :right) |> execute 226 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 227 | {%{name: "n0", lft: 0, rgt: 9, taxonomy_id: 1}, [ 228 | {%{ name: "n01", lft: 1, rgt: 6, taxonomy_id: 1}, [ 229 | {%{ name: "n010", lft: 2, rgt: 3, taxonomy_id: 1}, []}, 230 | {%{ name: "n011", lft: 4, rgt: 5, taxonomy_id: 1}, []} 231 | ]}, 232 | {%{ name: "n00", lft: 7, rgt: 8, taxonomy_id: 1}, []} 233 | ]} 234 | ) 235 | end 236 | 237 | test "move(root_node, :root) should execute without error" do 238 | {n0, _} = create_tree(1) 239 | move(n0, :root) |> execute 240 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 241 | {%{name: "n0", lft: 0, rgt: 9, taxonomy_id: 1}, [ 242 | {%{ name: "n00", lft: 1, rgt: 2, taxonomy_id: 1}, []}, 243 | {%{ name: "n01", lft: 3, rgt: 8, taxonomy_id: 1}, [ 244 | {%{ name: "n010", lft: 4, rgt: 5, taxonomy_id: 1}, []}, 245 | {%{ name: "n011", lft: 6, rgt: 7, taxonomy_id: 1}, []} 246 | ]} 247 | ]} 248 | ) 249 | end 250 | 251 | test "move(child_node, parent_node, :child) should move given child to the end of children of parent" do 252 | {n0, [{n00, _}, _]} = create_tree(1) 253 | 254 | move(n00, n0, :child) |> execute 255 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 256 | {%{name: "n0", lft: 0, rgt: 9, taxonomy_id: 1}, [ 257 | {%{ name: "n01", lft: 1, rgt: 6, taxonomy_id: 1}, [ 258 | {%{ name: "n010", lft: 2, rgt: 3, taxonomy_id: 1}, []}, 259 | {%{ name: "n011", lft: 4, rgt: 5, taxonomy_id: 1}, []} 260 | ]}, 261 | {%{ name: "n00", lft: 7, rgt: 8, taxonomy_id: 1}, []} 262 | ]} 263 | ) 264 | end 265 | 266 | test "move(last_child_node, parent_node, :child) should do nothing" do 267 | {n0, [_, {n01, _}]} = create_tree(1) 268 | 269 | move(n01, n0, :child) |> execute 270 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 271 | {%{name: "n0", lft: 0, rgt: 9, taxonomy_id: 1}, [ 272 | {%{ name: "n00", lft: 1, rgt: 2, taxonomy_id: 1}, []}, 273 | {%{ name: "n01", lft: 3, rgt: 8, taxonomy_id: 1}, [ 274 | {%{ name: "n010", lft: 4, rgt: 5, taxonomy_id: 1}, []}, 275 | {%{ name: "n011", lft: 6, rgt: 7, taxonomy_id: 1}, []} 276 | ]} 277 | ]} 278 | ) 279 | end 280 | 281 | test "move to the same node should do nothing" do 282 | {n0, _} = create_tree(1) 283 | move(n0, n0, :child) |> execute 284 | assert match(dump_one(Taxon, %{taxonomy_id: 1}) |> execute, 285 | {%{name: "n0", lft: 0, rgt: 9, taxonomy_id: 1}, [ 286 | {%{ name: "n00", lft: 1, rgt: 2, taxonomy_id: 1}, []}, 287 | {%{ name: "n01", lft: 3, rgt: 8, taxonomy_id: 1}, [ 288 | {%{ name: "n010", lft: 4, rgt: 5, taxonomy_id: 1}, []}, 289 | {%{ name: "n011", lft: 6, rgt: 7, taxonomy_id: 1}, []} 290 | ]} 291 | ]} 292 | ) 293 | end 294 | 295 | test "move node across different scope should be refused" do 296 | {_, [_, {n01, _}]} = create_tree(1) 297 | {_, [_, {m01, _}]} = create_tree(2) 298 | assert {:error, :within_the_same_tree} = move(m01, n01, :child) |> execute 299 | end 300 | end 301 | -------------------------------------------------------------------------------- /test/as_nested_set/queriable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.QueriableTest do 2 | use AsNestedSet.EctoCase 3 | 4 | import AsNestedSet.Factory 5 | import AsNestedSet.Matcher 6 | alias AsNestedSet.Taxon 7 | 8 | alias AsNestedSet.TestRepo, as: Repo 9 | 10 | import AsNestedSet.Queriable 11 | 12 | 13 | @doc """ 14 | Create a nested set tree 15 | +--------------------+ 16 | | +-----------+ | 17 | | +---+| +-------+ | | 18 | |0|1 2||3|4 5|6 7|8|9| 19 | +--------------------+ 20 | """ 21 | def create_tree(taxonomy_id) do 22 | n0 = insert(:taxon, name: "n0", lft: 0, rgt: 9, taxonomy_id: taxonomy_id) 23 | n00 = insert(:taxon, name: "n00", lft: 1, rgt: 2, parent_id: n0.id, taxonomy_id: taxonomy_id) 24 | n01 = insert(:taxon, name: "n01", lft: 3, rgt: 8, parent_id: n0.id, taxonomy_id: taxonomy_id) 25 | n010 = insert(:taxon, name: "n010", lft: 4, rgt: 5, parent_id: n01.id, taxonomy_id: taxonomy_id) 26 | n011 = insert(:taxon, name: "n011", lft: 6, rgt: 7, parent_id: n01.id, taxonomy_id: taxonomy_id) 27 | {n0, [ 28 | {n00, []}, 29 | {n01, [ 30 | {n010, []}, 31 | {n011, []} 32 | ]} 33 | ]} 34 | end 35 | 36 | def create_forest(taxonomy_id) do 37 | n0 = insert(:taxon, name: "n0", lft: 0, rgt: 1, taxonomy_id: taxonomy_id) 38 | n1 = insert(:taxon, name: "n1", lft: 2, rgt: 3, taxonomy_id: taxonomy_id) 39 | [ 40 | {n0, []}, 41 | {n1, []} 42 | ] 43 | end 44 | 45 | def execute(executable) do 46 | AsNestedSet.execute(executable, Repo) 47 | end 48 | 49 | test "right_most/2 works fine" do 50 | create_tree(1) 51 | assert right_most(Taxon, %{taxonomy_id: 1}) |> execute() == 9 52 | end 53 | 54 | test "right_most/1 works fine" do 55 | {node, _} = create_tree(1) 56 | assert right_most(node) |> execute() == 9 57 | end 58 | 59 | test "children/1 should get all children in the right sequence" do 60 | {root, [{no_child, []}, _]} = create_tree(1) 61 | assert match(children(root) |> execute(),[ 62 | %{name: "n00", lft: 1, rgt: 2, taxonomy_id: 1}, 63 | %{name: "n01", lft: 3, rgt: 8, taxonomy_id: 1}, 64 | ]) 65 | assert match(children(no_child) |> execute(), []) 66 | end 67 | 68 | test "leaves/2 should return all leaves" do 69 | create_tree(1) 70 | assert match(leaves(Taxon, %{taxonomy_id: 1}) |> execute(), [ 71 | %{name: "n00", lft: 1, rgt: 2, taxonomy_id: 1}, 72 | %{name: "n010", lft: 4, rgt: 5, taxonomy_id: 1}, 73 | %{name: "n011", lft: 6, rgt: 7, taxonomy_id: 1}, 74 | ]) 75 | end 76 | 77 | test "descendants/1 should return all decendants" do 78 | {root, _} = create_tree(1) 79 | assert match(descendants(root) |> execute(), [ 80 | %{name: "n00", lft: 1, rgt: 2, taxonomy_id: 1}, 81 | %{name: "n01", lft: 3, rgt: 8, taxonomy_id: 1}, 82 | %{name: "n010", lft: 4, rgt: 5, taxonomy_id: 1}, 83 | %{name: "n011", lft: 6, rgt: 7, taxonomy_id: 1} 84 | ]) 85 | end 86 | 87 | test "descendants/1 returns [] for node without child" do 88 | {_, [{no_child, []}|_]} = create_tree(1) 89 | assert descendants(no_child) |> execute() == [] 90 | end 91 | 92 | test "root/2 returns nil for emtpy tree" do 93 | assert root(Taxon, %{taxonomy_id: 1}) |> execute() == nil 94 | end 95 | 96 | test "root/2 returns the root of the tree" do 97 | create_tree(1) 98 | assert match(root(Taxon, %{taxonomy_id: 1}) |> execute(), 99 | %{name: "n0", lft: 0, rgt: 9, taxonomy_id: 1} 100 | ) 101 | end 102 | 103 | test "roots/2 returns the roots of the forest" do 104 | create_forest(1) 105 | assert match(roots(Taxon, %{taxonomy_id: 1}) |> execute(), [ 106 | %{name: "n0", lft: 0, rgt: 1, taxonomy_id: 1}, 107 | %{name: "n1", lft: 2, rgt: 3, taxonomy_id: 1} 108 | ]) 109 | end 110 | 111 | test "self_and_descendants/1 should returns self and all descendants" do 112 | {_, [_, {target, _}|_]} = create_tree(1) 113 | assert match(self_and_descendants(target) |> execute(), [ 114 | %{name: "n01", lft: 3, rgt: 8, taxonomy_id: 1}, 115 | %{name: "n010", lft: 4, rgt: 5, taxonomy_id: 1}, 116 | %{name: "n011", lft: 6, rgt: 7, taxonomy_id: 1} 117 | ]) 118 | end 119 | 120 | test "ancestors/1 should return all its ancestors" do 121 | {_, [_, {_, [{target, []}|_]}|_]} = create_tree(1) 122 | assert match(ancestors(target) |> execute(), [ 123 | %{name: "n0", lft: 0, rgt: 9, taxonomy_id: 1}, 124 | %{name: "n01", lft: 3, rgt: 8, taxonomy_id: 1} 125 | ]) 126 | end 127 | 128 | test "self_and_siblings/1 should return self and all its sliblings" do 129 | {_, [{target, _}|_]} = create_tree(1) 130 | assert match(self_and_siblings(target) |> execute(), [ 131 | %{name: "n00", lft: 1, rgt: 2, taxonomy_id: 1}, 132 | %{name: "n01", lft: 3, rgt: 8, taxonomy_id: 1} 133 | ]) 134 | end 135 | 136 | end 137 | -------------------------------------------------------------------------------- /test/as_nested_set/scoped_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.ScopedTest do 2 | use ExUnit.Case 3 | doctest AsNestedSet.Scoped 4 | 5 | defmodule Sample do 6 | use AsNestedSet.Scoped, scope: [:scope_field] 7 | defstruct scope_field: nil, non_scope_field: nil 8 | end 9 | 10 | test "should export same_scope/2" do 11 | AsNestedSet.Scoped.same_scope?(%Sample{}, %Sample{}) 12 | end 13 | 14 | test "same_scope/2 should return true for models with the same scope field value" do 15 | assert AsNestedSet.Scoped.same_scope?(%Sample{scope_field: "same", non_scope_field: "diff0"}, %Sample{scope_field: "same", non_scope_field: "diff1"}) 16 | end 17 | 18 | test "same_scope/2 should return false for models with different scope field value" do 19 | assert !AsNestedSet.Scoped.same_scope?(%Sample{scope_field: "diff0"}, %Sample{scope_field: "diff1"}) 20 | end 21 | 22 | describe "scope/1" do 23 | test "should return default scope" do 24 | assert AsNestedSet.Scoped.scope(Sample) == [:scope_field] 25 | end 26 | 27 | test "should return scope for given instance" do 28 | assert AsNestedSet.Scoped.scope(%Sample{scope_field: "value"}) == %{scope_field: "value"} 29 | end 30 | end 31 | 32 | describe "assign_scope_from/2" do 33 | test "should assign scope from source to target" do 34 | source_scope = %{scope_field: "source"} 35 | source = struct(Sample, source_scope) 36 | target = %Sample{scope_field: "target"} |> AsNestedSet.Scoped.assign_scope_from(source) 37 | assert AsNestedSet.Scoped.scope(target) == source_scope 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/as_nested_set/traversable_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.TraversableTest do 2 | use AsNestedSet.EctoCase 3 | import AsNestedSet.Factory 4 | 5 | import AsNestedSet.Traversable 6 | 7 | alias AsNestedSet.TestRepo, as: Repo 8 | alias AsNestedSet.Taxon 9 | 10 | @doc """ 11 | Create a nested set tree 12 | +--------------------+ 13 | | +-----------+ | 14 | | +---+| +-------+ | | 15 | |0|1 2||3|4 5|6 7|8|9| 16 | +--------------------+ 17 | """ 18 | def create_tree(taxonomy_id) do 19 | n0 = insert(:taxon, name: "n0", lft: 0, rgt: 9, taxonomy_id: taxonomy_id) 20 | n00 = insert(:taxon, name: "n00", lft: 1, rgt: 2, parent_id: n0.id, taxonomy_id: taxonomy_id) 21 | n01 = insert(:taxon, name: "n01", lft: 3, rgt: 8, parent_id: n0.id, taxonomy_id: taxonomy_id) 22 | 23 | n010 = 24 | insert(:taxon, name: "n010", lft: 4, rgt: 5, parent_id: n01.id, taxonomy_id: taxonomy_id) 25 | 26 | n011 = 27 | insert(:taxon, name: "n011", lft: 6, rgt: 7, parent_id: n01.id, taxonomy_id: taxonomy_id) 28 | 29 | {n0, 30 | [ 31 | {n00, []}, 32 | {n01, 33 | [ 34 | {n010, []}, 35 | {n011, []} 36 | ]} 37 | ]} 38 | end 39 | 40 | def create_forest(taxonomy_id) do 41 | n0 = insert(:taxon, name: "n0", lft: 0, rgt: 1, taxonomy_id: taxonomy_id) 42 | n1 = insert(:taxon, name: "n1", lft: 2, rgt: 3, taxonomy_id: taxonomy_id) 43 | 44 | [ 45 | {n0, []}, 46 | {n1, []} 47 | ] 48 | end 49 | 50 | def execute(executable) do 51 | AsNestedSet.execute(executable, Repo) 52 | end 53 | 54 | test "traverse in the right order" do 55 | create_tree(1) 56 | 57 | {_, acc} = 58 | traverse( 59 | Taxon, 60 | %{taxonomy_id: 1}, 61 | [], 62 | fn node, acc -> 63 | {node, [node.name | acc]} 64 | end, 65 | fn node, acc -> 66 | {node, [node.name | acc]} 67 | end 68 | ) 69 | |> execute 70 | 71 | assert ["n0", "n00", "n00", "n01", "n010", "n010", "n011", "n011", "n01", "n0"] == 72 | acc |> Enum.reverse() 73 | end 74 | 75 | test "traverse forest in the right order" do 76 | create_forest(1) 77 | 78 | {_, acc} = 79 | traverse( 80 | Taxon, 81 | %{taxonomy_id: 1}, 82 | [], 83 | fn node, acc -> 84 | {node, [node.name | acc]} 85 | end, 86 | fn node, acc -> 87 | {node, [node.name | acc]} 88 | end 89 | ) 90 | |> execute 91 | 92 | assert ["n0", "n0", "n1", "n1"] == acc |> Enum.reverse() 93 | end 94 | 95 | test "traverse a subtree with right order" do 96 | {_, [_, {n01, _}]} = create_tree(1) 97 | 98 | {_, acc} = 99 | traverse( 100 | n01, 101 | [], 102 | fn node, acc -> 103 | {node, [node.name | acc]} 104 | end, 105 | fn node, acc -> 106 | {node, [node.name | acc]} 107 | end 108 | ) 109 | |> execute 110 | 111 | assert ["n01", "n010", "n010", "n011", "n011", "n01"] == acc |> Enum.reverse() 112 | end 113 | 114 | test "traverse should return node and context" do 115 | {root, _} = create_tree(1) 116 | 117 | assert_raise ArgumentError, ~r/Expect :pre to return {AsNestedSet.t, context} but got/, fn -> 118 | traverse( 119 | root, 120 | [], 121 | fn node, _acc -> 122 | node 123 | end, 124 | fn node, _acc -> 125 | {node, []} 126 | end 127 | ) 128 | |> execute 129 | end 130 | 131 | assert_raise ArgumentError, ~r/Expect :post to return {AsNestedSet.t, context} but got/, fn -> 132 | traverse( 133 | root, 134 | [], 135 | fn node, _acc -> 136 | {node, []} 137 | end, 138 | fn node, _acc -> 139 | node 140 | end 141 | ) 142 | |> execute 143 | end 144 | end 145 | 146 | test "traverse with 3 arguments post_fun should get children as the second argument" do 147 | {root, _} = create_tree(1) 148 | 149 | {_, acc} = 150 | traverse(root, [], fn node, acc -> {node, acc} end, fn node, children, acc -> 151 | {node, [{node.name, children |> Enum.map(fn x -> x.name end)} | acc]} 152 | end) 153 | |> execute 154 | 155 | assert acc == [ 156 | {"n0", ["n00", "n01"]}, 157 | {"n01", ["n010", "n011"]}, 158 | {"n011", []}, 159 | {"n010", []}, 160 | {"n00", []} 161 | ] 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /test/as_nested_set_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSetTest do 2 | 3 | use ExUnit.Case 4 | 5 | defmodule Sample do 6 | use AsNestedSet 7 | defstruct id: "id", lft: "left", rgt: "right", parent_id: "parent_id" 8 | end 9 | 10 | defmodule Redefined do 11 | use AsNestedSet.Model 12 | @node_id_column :node_id 13 | @parent_id_column :pid 14 | @left_column :left 15 | @right_column :right 16 | defstruct node_id: "node_id", left: "left", right: "right", pid: "parent_id" 17 | end 18 | 19 | defmodule Undefined do 20 | defstruct id: "id" 21 | end 22 | 23 | @fields ~w{node_id left right parent_id}a 24 | 25 | test "should define __as_nested_set_get_field__(model, field)" do 26 | sample = %Sample{} 27 | redefined = %Redefined{} 28 | assert Sample.__as_nested_set_get_field__(sample, :node_id) == sample.id 29 | assert Redefined.__as_nested_set_get_field__(redefined, :node_id) == redefined.node_id 30 | 31 | assert Sample.__as_nested_set_get_field__(sample, :left) == sample.lft 32 | assert Redefined.__as_nested_set_get_field__(redefined, :left) == redefined.left 33 | 34 | assert Sample.__as_nested_set_get_field__(sample, :right) == sample.rgt 35 | assert Redefined.__as_nested_set_get_field__(redefined, :right) == redefined.right 36 | 37 | assert Sample.__as_nested_set_get_field__(sample, :parent_id) == sample.parent_id 38 | assert Redefined.__as_nested_set_get_field__(redefined, :parent_id) == redefined.pid 39 | end 40 | 41 | test "should define __as_nested_set_set_field__(model, field, value)" do 42 | sample = %Sample{} 43 | redefined = %Redefined{} 44 | sample = @fields |> Enum.reduce(sample, fn field, sample -> 45 | Sample.__as_nested_set_set_field__(sample, field, "test_value") 46 | end) 47 | assert %Sample{id: "test_value", lft: "test_value", rgt: "test_value", parent_id: "test_value"} == sample 48 | 49 | redefined = @fields |> Enum.reduce(redefined, fn field, redefined -> 50 | Redefined.__as_nested_set_set_field__(redefined, field, "test_value") 51 | end) 52 | assert %Redefined{node_id: "test_value", left: "test_value", right: "test_value", pid: "test_value"} == redefined 53 | end 54 | 55 | test "should define __as_nested_set_fields__" do 56 | assert %{left: :lft, node_id: :id, parent_id: :parent_id, right: :rgt} == Sample.__as_nested_set_fields__ 57 | assert %{left: :left, node_id: :node_id, parent_id: :pid, right: :right} == Redefined.__as_nested_set_fields__ 58 | end 59 | 60 | test "should define child?(model)" do 61 | assert Sample.child?(%Sample{parent_id: "parent_id"}) 62 | refute Sample.child?(%Sample{parent_id: nil}) 63 | 64 | assert Redefined.child?(%Redefined{pid: "parent_id"}) 65 | refute Redefined.child?(%Redefined{pid: nil}) 66 | end 67 | 68 | describe "AsNestedSet.defined?/1" do 69 | test "should return true for a struct defined AsNestedSet" do 70 | assert AsNestedSet.defined?(Sample) 71 | assert AsNestedSet.defined?(%Sample{}) 72 | end 73 | test "should return false for a module defined AsNestedSet" do 74 | refute AsNestedSet.defined?(Undefined) 75 | refute AsNestedSet.defined?(%Undefined{}) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/support/ecto_case.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.EctoCase do 2 | use ExUnit.CaseTemplate 3 | 4 | setup do 5 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(AsNestedSet.TestRepo) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.Factory do 2 | use ExMachina.Ecto, repo: AsNestedSet.TestRepo 3 | 4 | def taxon_factory do 5 | %AsNestedSet.Taxon{name: sequence(:name, &"name-#{&1}")} 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/support/matcher.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.Matcher do 2 | 3 | def match([source|source_tail], [target|target_tail]) do 4 | match(source, target) and match(source_tail, target_tail) 5 | end 6 | 7 | def match({source, source_children}, {target, target_children}) do 8 | match(source, target) and match(source_children, target_children) 9 | end 10 | 11 | def match([], []) do 12 | true 13 | end 14 | 15 | def match(source, target) when is_map(source) and is_map(target) do 16 | name = source.name 17 | lft = source.lft 18 | rgt = source.rgt 19 | taxonomy_id = source.taxonomy_id 20 | try do 21 | %{:name => ^name, :lft => ^lft, :rgt => ^rgt, :taxonomy_id => ^taxonomy_id} = target 22 | rescue 23 | _x in [MatchError] -> 24 | expected = %{:name => name, :lft => lft, :rgt => rgt, :taxonomy_id => taxonomy_id} 25 | raise "Expected #{inspect expected} but got #{inspect target}" 26 | end 27 | true 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/support/models/taxon.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.Taxon do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | use AsNestedSet, scope: [:taxonomy_id] 5 | 6 | schema "taxons" do 7 | 8 | field :name, :string 9 | field :taxonomy_id, :integer 10 | field :parent_id, :integer 11 | field :lft, :integer 12 | field :rgt, :integer 13 | 14 | timestamps() 15 | end 16 | 17 | @required_fields ~w(name taxonomy_id lft rgt)a 18 | @optional_fields ~w(parent_id)a 19 | 20 | 21 | @doc """ 22 | Creates a changeset based on the `model` and `params`. 23 | 24 | If no params are provided, an invalid changeset is returned 25 | with no validation performed. 26 | """ 27 | def changeset(model, params \\ :empty) do 28 | model 29 | |> cast(params, @required_fields ++ @optional_fields) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/support/test_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule AsNestedSet.TestRepo do 2 | use Ecto.Repo, otp_app: :as_nested_set, adapter: Ecto.Adapters.Postgres 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Mix.Task.run "ecto.drop", ["quiet", "-r", "AsNestedSet.TestRepo"] 2 | Mix.Task.run "ecto.create", ["quiet", "-r", "AsNestedSet.TestRepo"] 3 | Mix.Task.run "ecto.migrate", ["-r", "AsNestedSet.TestRepo"] 4 | 5 | AsNestedSet.TestRepo.start_link 6 | ExUnit.start() 7 | 8 | Ecto.Adapters.SQL.Sandbox.mode(AsNestedSet.TestRepo, :manual) 9 | --------------------------------------------------------------------------------