Skip to content

Commit

Permalink
improvement: add Ash.Sort.parse_input/2
Browse files Browse the repository at this point in the history
fix: fix small sort bugs
  • Loading branch information
zachdaniel committed Jan 8, 2021
1 parent 9a9745b commit dbf9f82
Show file tree
Hide file tree
Showing 3 changed files with 248 additions and 6 deletions.
38 changes: 32 additions & 6 deletions lib/ash/actions/sort.ex
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,37 @@ defmodule Ash.Actions.Sort do
defp to_sort_by_fun(sorter) when is_function(sorter, 2),
do: &sorter.(elem(&1, 1), elem(&2, 1))

defp to_sort_by_fun(:asc),
do: &(elem(&1, 1) <= elem(&2, 1))
defp to_sort_by_fun(:asc) do
fn
nil, nil ->
true

_, nil ->
true

nil, _ ->
false

x, y ->
elem(x, 1) <= elem(y, 1)
end
end

defp to_sort_by_fun(:desc),
do: &(elem(&1, 1) >= elem(&2, 1))
defp to_sort_by_fun(:desc) do
fn
nil, nil ->
true

_, nil ->
false

nil, _ ->
true

x, y ->
elem(x, 1) >= elem(y, 1)
end
end

defp to_sort_by_fun(:asc_nils_last) do
fn x, y ->
Expand All @@ -125,7 +151,7 @@ defmodule Ash.Actions.Sort do
end
end

defp to_sort_by_fun(:desc_nulls_first) do
defp to_sort_by_fun(:desc_nils_first) do
fn x, y ->
if is_nil(elem(x, 1)) && !is_nil(elem(y, 1)) do
true
Expand All @@ -135,7 +161,7 @@ defmodule Ash.Actions.Sort do
end
end

defp to_sort_by_fun(:desc_nulls_last) do
defp to_sort_by_fun(:desc_nils_last) do
fn x, y ->
if is_nil(elem(x, 1)) && !is_nil(elem(y, 1)) do
false
Expand Down
149 changes: 149 additions & 0 deletions lib/ash/sort/sort.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
defmodule Ash.Sort do
@moduledoc false

alias Ash.Error.Query.{InvalidSortOrder, NoSuchAttribute}

@doc """
A utility for parsing sorts provided from external input. Only allows sorting
on public attributes and aggregates.
The supported formats are:
### Sort Strings
A comma separated list of fields to sort on, each with an optional prefix.
The prefixes are:
* "+" - Same as no prefix. Sorts `:asc`.
* "++" - Sorts `:asc_nils_first`
* "-" - Sorts `:desc`
* "--" - Sorts `:desc_nils_last`
For example
"foo,-bar,++baz,--buz"
### A list of sort strings
Same prefix rules as above, but provided as a list.
For example:
["foo", "-bar", "++baz", "--buz"]
### A standard Ash sort
"""
@spec parse_input(
Ash.resource(),
String.t()
| list(atom | String.t() | {atom, Ash.sort_order()} | list(String.t()))
| nil
) ::
Ash.sort() | nil
def parse_input(resource, sort) when is_binary(sort) do
sort = String.split(sort, ",")
parse_input(resource, sort)
end

def parse_input(resource, sort) when is_list(sort) do
sort
|> Enum.reduce_while({:ok, []}, fn field, {:ok, sort} ->
case parse_sort(resource, field) do
{:ok, value} -> {:cont, {:ok, [value | sort]}}
{:error, error} -> {:halt, {:error, error}}
end
end)
|> case do
{:ok, values} -> {:ok, Enum.reverse(values)}
{:error, error} -> {:error, error}
end
end

def parse_input(_resource, nil), do: nil

def parse_sort(resource, {field, direction})
when direction in [
:asc,
:desc,
:asc_nils_first,
:asc_nils_last,
:desc_nils_first,
:desc_nils_last
] do
case get_field(resource, field) do
nil -> {:error, NoSuchAttribute.exception(resource: resource, name: field)}
field -> {:ok, {field, direction}}
end
end

def parse_sort(_resource, {_field, order}) do
{:error, InvalidSortOrder.exception(order: order)}
end

def parse_sort(resource, "++" <> field) do
case get_field(resource, field) do
nil -> {:error, NoSuchAttribute.exception(resource: resource, name: field)}
field -> {:ok, {field, :asc_nils_first}}
end
end

def parse_sort(resource, "--" <> field) do
case get_field(resource, field) do
nil -> {:error, NoSuchAttribute.exception(resource: resource, name: field)}
field -> {:ok, {field, :desc_nils_last}}
end
end

def parse_sort(resource, "+" <> field) do
case get_field(resource, field) do
nil -> {:error, NoSuchAttribute.exception(resource: resource, name: field)}
field -> {:ok, {field, :asc}}
end
end

def parse_sort(resource, "-" <> field) do
case get_field(resource, field) do
nil -> {:error, NoSuchAttribute.exception(resource: resource, name: field)}
field -> {:ok, {field, :desc}}
end
end

def parse_sort(resource, field) do
case get_field(resource, field) do
nil -> {:error, NoSuchAttribute.exception(resource: resource, name: field)}
field -> {:ok, {field, :asc}}
end
end

defp get_field(resource, field) do
case Ash.Resource.public_attribute(resource, field) do
%{name: name} ->
name

nil ->
case Ash.Resource.public_attribute(resource, field) do
%{name: name} ->
name

nil ->
nil
end
end
end

@doc """
A utility for sorting a list of records at runtime.
For example:
Ash.Sort.runtime_sort([record1, record2, record3], name: :asc, type: :desc_nils_last)
Keep in mind that it is unrealistic to expect this runtime sort to always
be exactly the same as a sort that may have been applied by your data layer.
This is especially true for strings. For example, `Postgres` strings have a
collation that affects their sorting, making it unpredictable from the perspective
of a tool using the database: https://www.postgresql.org/docs/current/collation.html
"""
defdelegate runtime_sort(results, sort), to: Ash.Actions.Sort
end
67 changes: 67 additions & 0 deletions test/sort/sort_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
defmodule Ash.Test.Sort.SortTest do
@moduledoc false
use ExUnit.Case, async: true

alias Ash.Filter

require Ash.Query

defmodule Post do
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets

ets do
private?(true)
end

actions do
read :default

create :default

update :default
end

attributes do
attribute :id, :uuid, primary_key?: true, default: &Ecto.UUID.generate/0
attribute :title, :string
attribute :contents, :string
attribute :points, :integer, private?: true
end
end

defmodule Api do
@moduledoc false
use Ash.Api

resources do
resource(Post)
end
end

describe "parse_input/2" do
test "simple string sort parses properly" do
assert {:ok, [title: :asc, contents: :desc]} =
Ash.Sort.parse_input(Post, "+title,-contents")
end

test "public attributes cannot be used" do
assert {:error, %Ash.Error.Query.NoSuchAttribute{}} = Ash.Sort.parse_input(Post, "points")
end

test "a list sort parses properly" do
assert {:ok, [title: :asc, contents: :desc]} =
Ash.Sort.parse_input(Post, ["title", "-contents"])
end

test "a regular sort parses properly" do
assert {:ok, [title: :asc, contents: :desc]} =
Ash.Sort.parse_input(Post, title: :asc, contents: :desc)
end

test "++ and -- modifiers work properly" do
assert {:ok, [title: :asc_nils_first, contents: :desc_nils_last]} =
Ash.Sort.parse_input(Post, "++title,--contents")
end
end
end

0 comments on commit dbf9f82

Please sign in to comment.