Current section
Files
Jump to
Current section
Files
lib/ecto_ordered.ex
defmodule EctoOrdered do
@moduledoc """
EctoOrdered provides changeset methods for updating ordering an ordering column
It should be added to your schema like so:
```
defmodule OrderedListItem do
use Ecto.Schema
import Ecto.Changeset
schema "ordered_list_item" do
field :title, :string
field :position, :integer, virtual: true
field :rank, :integer
field :move, :any, virtual: true
end
def changeset(model, params) do
model
|> cast(params, [:position, :title, :move])
|> set_order(:position, :rank)
end
end
```
"""
# These are the max bounds of an INT in postgresql
@max 2147483647
@min -2147483648
defmodule Options do
defstruct position_field: :position, move_field: :move,
rank_field: :rank, scope_field: nil, module: nil
end
import Ecto.Query
import Ecto.Changeset
@doc """
Returns a changeset which will include updates to the other ordered rows
within the same transaction as the insertion, deletion or update of this row.
The arguments are as follows:
- `changeset` the changeset which is part of the ordered list
- `position_field` the (virtual) field in which the order is set
- `rank_field` the field in which the ranking should be stored
- `scope` the field in which the scope for the order should be stored (optional)
"""
def set_order(changeset, position_field \\ :position, rank_field \\ :rank, scope_field \\ nil) do
changeset
|> prepare_changes(fn changeset ->
case changeset.action do
:insert -> EctoOrdered.before_insert changeset, position_field, rank_field, scope_field
:update -> EctoOrdered.before_update changeset, position_field, rank_field, scope_field
end
end)
end
@doc false
def before_insert(cs, position_field, rank_field, scope_field) do
options = %Options{
position_field: position_field,
rank_field: rank_field,
scope_field: scope_field,
module: cs.data.__struct__
}
updated = if get_field(cs, position_field) do
options |> update_rank(cs)
else
options |> update_rank(put_change(cs, position_field, :last))
end
ensure_unique_position(updated, options)
end
@doc false
def before_update(cs, position_field, rank_field, scope_field \\ nil) do
options = %Options{
position_field: position_field,
rank_field: rank_field,
scope_field: scope_field,
module: cs.data.__struct__
}
case {fetch_change(cs, position_field), fetch_change(cs, options.move_field)} do
{_, {:ok, _}} -> options |> move(cs, options.move_field) |> ensure_unique_position(options)
{{:ok, _},_} -> options |> update_rank(cs) |> ensure_unique_position(options)
foo ->
cs
end
end
defp update_rank(%Options{rank_field: rank_field, position_field: position_field} = options, cs) do
case get_field(cs, position_field) do
:last ->
current_last = get_current_last(options, cs)
if current_last do
put_change(cs, rank_field, rank_between(@max, current_last))
else
update_rank(options, put_change(cs, position_field, :middle))
end
:middle -> put_change(cs, rank_field, rank_between(@max, @min))
nil -> update_rank(options, put_change(cs, position_field, :last))
position when is_integer(position) ->
{rank_before, rank_after} = neighbours_at_position(options, position, cs)
put_change(cs, rank_field, rank_between(rank_after, rank_before))
end
end
defp move(options, changeset, move_field) do
do_move(get_field(changeset, move_field), options, changeset)
end
def do_move(:up, options, changeset) do
case get_previous_two(options, changeset) do
{upper, lower} -> put_change(changeset, options.rank_field, rank_between(upper, lower))
_ -> ensure_ranked(options, changeset)
end
end
def do_move(:down, options, changeset) do
case get_next_two(options, changeset) do
{upper, lower} -> put_change(changeset, options.rank_field, rank_between(upper, lower))
_ -> ensure_ranked(options, changeset)
end
end
def do_move(_, options, changeset), do: changeset
defp get_previous_two(options, cs) do
current_rank = get_field(cs, options.rank_field) || @max
previous = options
|> nearby_query(cs)
|> where([r], field(r, ^options.rank_field) < ^current_rank)
|> cs.repo.all
case previous do
[] -> nil
[lower] -> {@min, lower}
[upper, lower] -> {upper, lower}
end
end
defp get_next_two(options, cs) do
current_rank = get_field(cs, options.rank_field)
next = options
|> nearby_query(cs)
|> where([r], field(r, ^options.rank_field) > ^current_rank)
|> cs.repo.all
case next do
[] -> nil
[lower] -> {@max, lower}
[upper, lower] -> {upper, lower}
end
end
defp nearby_query(options, cs) do
options
|> rank_query
|> scope_query(options, cs)
|> select_rank(options.rank_field)
|> limit(2)
|> order_by(^options.rank_field)
end
defp ensure_unique_position(cs, %Options{rank_field: rank_field} = options) do
# If we're not changing ranks, then don't bother
rank = get_change(cs, rank_field)
if rank != nil && (rank > @max || current_at_rank(options, cs)) do
shift_ranks(options, cs)
end
cs
end
defp ensure_ranked(options, changeset) do
if get_field(changeset, options.rank_field) do
changeset
else
update_rank(options, changeset)
end
end
defp shift_ranks(%Options{rank_field: rank_field} = options, cs) do
current_rank = get_field(cs, rank_field)
current_first = get_current_first(options, cs)
current_last = get_current_last(options, cs)
cond do
current_first > @min && current_rank == @max -> decrement_other_ranks(options, cs)
current_last < @max - 1 && current_rank < current_last -> increment_other_ranks(options, cs)
true -> rebalance_ranks(options, cs)
end
end
defp rebalance_ranks(%Options{
rank_field: rank_field,
position_field: position_field
} = options, cs) do
rows = current_order(options, cs)
old_attempted_rank = get_field(cs, rank_field)
count = length(rows) + 1
rows
|> Enum.with_index
|> Enum.map(fn {row, index} ->
old_rank = Map.get(row, rank_field)
row
|> change([{rank_field, rank_for_row(old_rank, index, count, old_attempted_rank)}])
|> cs.repo.update!
end)
put_change(cs, rank_field, rank_for_row(0, get_field(cs, position_field), count, 1))
end
defp rank_for_row(old_rank, :last, count, old_attempted_rank) do
rank_for_row(old_rank, count - 1, count, old_attempted_rank)
end
defp rank_for_row(old_rank, index, count, old_attempted_rank) do
# If our old rank is less than the old attempted rank, then our effective index is fine
new_index = if old_rank < old_attempted_rank do
index
# otherwise, we need to increment our index by 1
else
index + 1
end
round((@max - @min) / count) * new_index + @min
end
defp current_order(%Options{rank_field: rank_field} = options, cs) do
options
|> rank_query
|> scope_query(options, cs)
|> cs.repo.all
end
defp increment_other_ranks(%Options{rank_field: rank_field} = options, %{data: existing} = cs) do
current_rank = get_field(cs, rank_field)
options.module
|> where([r], field(r, ^rank_field) >= ^current_rank)
|> exclude_existing(existing)
|> cs.repo.update_all([inc: [{rank_field, 1}]])
cs
end
defp decrement_other_ranks(%Options{rank_field: rank_field} = options, %{data: existing} = cs) do
current_rank = get_field(cs, rank_field)
options.module
|> where([r], field(r, ^rank_field) <= ^current_rank)
|> exclude_existing(existing)
|> cs.repo.update_all([inc: [{rank_field, -1}]])
cs
end
defp current_at_rank(%Options{rank_field: rank_field} = options, cs) do
rank = get_field(cs, rank_field)
options.module
|> where([r], field(r, ^rank_field) == ^rank)
|> limit(1)
|> scope_query(options, cs)
|> cs.repo.one
end
defp neighbours_at_position(%Options{
rank_field: rank_field,
} = options, position, cs) when position <= 0 do
first = options
|> rank_query
|> select_rank(rank_field)
|> limit(1)
|> scope_query(options, cs) |> cs.repo.one
if first do
{@min, first}
else
{@min, @max}
end
end
defp neighbours_at_position(%Options{rank_field: rank_field,
} = options, position, %{data: existing} = cs) do
current_last = get_current_last(options, cs)
neighbours = options
|> rank_query
|> select_rank(rank_field)
|> limit(2)
|> offset(^(position - 1))
|> scope_query(options, cs)
|> exclude_existing(existing)
|> cs.repo.all
case neighbours do
[] -> {current_last, @max}
[bef] -> {bef, @max}
[bef, aft] -> {bef, aft}
end
end
defp ranked(query, rank_field) do
(from m in query, order_by: [asc: field(m, ^rank_field)])
end
defp select_rank(query, rank_field) do
(from q in query, select: field(q, ^rank_field))
end
defp exclude_existing(query, %{id: nil}) do
query
end
defp exclude_existing(query, existing) do
from r in query, where: r.id != ^existing.id
end
defp get_current_last(%Options{
rank_field: rank_field,
} = options, cs) do
last = options.module
|> select_rank(rank_field)
|> order_by(desc: ^rank_field)
|> limit(1)
|> scope_query(options, cs)
|> cs.repo.one
if last do
last
else
@min
end
end
defp get_current_first(%Options{rank_field: rank_field} = options, cs) do
first = options
|> rank_query
|> select_rank(rank_field)
|> order_by(asc: ^rank_field)
|> limit(1)
|> scope_query(options, cs)
|> cs.repo.one
if first do
first
else
options
end
end
defp rank_between(nil, nil) do
rank_between(@max, @min)
end
defp rank_between(above, below) do
round((above - below) / 2) + below
end
defp rank_query(options) do
options.module
|> ranked(options.rank_field)
end
defp scope_query(query, %Options{scope_field: scope_field}, cs) do
scope = get_field(cs, scope_field)
if scope do
(from q in query, where: field(q, ^scope_field) == ^scope)
else
query
end
end
end