diff --git a/CHANGELOG.md b/CHANGELOG.md index d157895..f5f202d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.6.0 - 2022-03-10 + +### Changed + +- Added the ability to paginate with order by fragments, when the same query + fragment is a select. See tests for example. + ## 0.5.1 - 2022-02-04 ### Fixed diff --git a/lib/fob.ex b/lib/fob.ex index 7de702a..2870a06 100644 --- a/lib/fob.ex +++ b/lib/fob.ex @@ -57,7 +57,9 @@ defmodule Fob do initial_acc = apply_basic_comparison(id_break, comparison_strictness) where_clause = - Enum.reduce(remaining_breaks, initial_acc, &apply_keyset_comparison/2) + remaining_breaks + |> PageBreak.wrap_field_or_alias(query) + |> Enum.reduce(initial_acc, &apply_keyset_comparison/2) where(query, ^where_clause) end @@ -67,98 +69,77 @@ defmodule Fob do # --- value is nil defp apply_keyset_comparison( - %PageBreak{ - direction: direction, - column: column, - table: table, - value: nil - }, + {%PageBreak{ + direction: direction, + value: nil + }, field_or_alias}, acc ) when direction in [:asc, :asc_nulls_last, :desc_nulls_last] do - dynamic([{t, table}], field(t, ^column) |> is_nil() and ^acc) + dynamic(^field_or_alias |> is_nil() and ^acc) end defp apply_keyset_comparison( - %PageBreak{ - direction: direction, - column: column, - table: table, - value: nil - }, + {%PageBreak{ + direction: direction, + value: nil + }, field_or_alias}, acc ) when direction in [:desc, :desc_nulls_first, :asc_nulls_first] do dynamic( - [{t, table}], - not is_nil(field(t, ^column)) or - (field(t, ^column) |> is_nil() and ^acc) + not is_nil(^field_or_alias) or + (^field_or_alias |> is_nil() and ^acc) ) end # --- value is non-nil defp apply_keyset_comparison( - %PageBreak{ - direction: direction, - column: column, - table: table, - value: value - }, + {%PageBreak{ + direction: direction, + value: value + }, field_or_alias}, acc ) when direction in [:asc, :asc_nulls_last] do dynamic( - [{t, table}], - field(t, ^column) > ^value or field(t, ^column) |> is_nil() or - (field(t, ^column) == ^value and ^acc) + ^field_or_alias > ^value or ^field_or_alias |> is_nil() or + (^field_or_alias == ^value and ^acc) ) end defp apply_keyset_comparison( - %PageBreak{ - direction: :asc_nulls_first, - column: column, - table: table, - value: value - }, + {%PageBreak{ + direction: :asc_nulls_first, + value: value + }, field_or_alias}, acc ) do - dynamic( - [{t, table}], - field(t, ^column) > ^value or (field(t, ^column) == ^value and ^acc) - ) + dynamic(^field_or_alias > ^value or (^field_or_alias == ^value and ^acc)) end defp apply_keyset_comparison( - %PageBreak{ - direction: direction, - column: column, - table: table, - value: value - }, + {%PageBreak{ + direction: direction, + value: value + }, field_or_alias}, acc ) when direction in [:desc, :desc_nulls_first] do - dynamic( - [{t, table}], - field(t, ^column) < ^value or (field(t, ^column) == ^value and ^acc) - ) + dynamic(^field_or_alias < ^value or (^field_or_alias == ^value and ^acc)) end defp apply_keyset_comparison( - %PageBreak{ - direction: :desc_nulls_last, - column: column, - table: table, - value: value - }, + {%PageBreak{ + direction: :desc_nulls_last, + value: value + }, field_or_alias}, acc ) do dynamic( - [{t, table}], - field(t, ^column) < ^value or field(t, ^column) |> is_nil() or - (field(t, ^column) == ^value and ^acc) + ^field_or_alias < ^value or ^field_or_alias |> is_nil() or + (^field_or_alias == ^value and ^acc) ) end @@ -230,10 +211,13 @@ defmodule Fob do query |> Ordering.columns() - |> Enum.map(fn {_table, name} = column -> - key = Map.get(selection_mapping, column, name) + |> Enum.map(fn {table, name} -> + key = Map.get(selection_mapping, {table, name}, name) - %PageBreak{column: name, value: get_in(record, [Access.key(key)])} + %PageBreak{ + column: name, + value: get_in(record, [Access.key(key)]) + } end) end diff --git a/lib/fob/fragment_builder.ex b/lib/fob/fragment_builder.ex new file mode 100644 index 0000000..c02c04d --- /dev/null +++ b/lib/fob/fragment_builder.ex @@ -0,0 +1,50 @@ +defmodule Fob.FragmentBuilder do + @moduledoc false + @spec build([Macro.t()], Macro.t(), Macro.t(), Macro.Env.t()) :: Macro.t() + def build(binding, expr, params, env) do + quote do + %Ecto.Query.DynamicExpr{ + fun: fn query -> + {unquote(expr), unquote(params), []} + end, + binding: unquote(Macro.escape(binding)), + file: unquote(env.file), + line: unquote(env.line) + } + end + end + + defmacro build_from_existing(binding \\ [], expr) do + build(binding, expr, [], __CALLER__) + end + + def table_for_fragment({:fragment, [], _} = frag) do + Macro.prewalk(frag, nil, fn ast, acc -> + case ast do + {:&, [], [table_idx]} when is_integer(table_idx) -> + {ast, table_idx} + + _ -> + {ast, acc} + end + end) + |> elem(1) + end + + def column_for_query_fragment( + {:fragment, [], _} = frag, + %Ecto.Query{} = query + ) do + query.select.expr + |> Macro.prewalk(nil, fn ast, acc -> + case ast do + {virtual_column, ^frag} when is_atom(virtual_column) -> + {ast, virtual_column} + + _ -> + {ast, acc} + end + end) + |> elem(1) + end +end diff --git a/lib/fob/ordering.ex b/lib/fob/ordering.ex index 67859a9..95115df 100644 --- a/lib/fob/ordering.ex +++ b/lib/fob/ordering.ex @@ -5,39 +5,67 @@ defmodule Fob.Ordering do # in a query alias Ecto.Query + import Ecto.Query + alias Fob.FragmentBuilder + require Fob.FragmentBuilder - @typep table :: non_neg_integer() + @typep table :: nil | non_neg_integer() @type t :: %__MODULE__{ table: table(), column: atom(), - direction: :asc | :desc + direction: :asc | :desc, + field_or_alias: any() } - defstruct ~w[table column direction]a + defstruct ~w[table column direction field_or_alias]a @spec config(%Query{}) :: [t()] - def config(%Query{order_bys: orderings}) do + def config(%Query{order_bys: orderings} = query) do Enum.flat_map(orderings, fn %Query.QueryExpr{expr: exprs} -> - config_from_ordering_expressions(exprs) + config_from_ordering_expressions(exprs, query) end) end - defp config_from_ordering_expressions(exprs) when is_list(exprs) do - Enum.map(exprs, &config_from_ordering_expressions/1) + defp config_from_ordering_expressions(exprs, query) when is_list(exprs) do + Enum.map(exprs, &config_from_ordering_expressions(&1, query)) end defp config_from_ordering_expressions( - {direction, {{:., _, [{:&, _, [table]}, column]}, _, _}} + {direction, {{:., _, [{:&, _, [table]}, column]}, _, _}}, + _ ) do %__MODULE__{ direction: direction, column: column, - table: table + table: table, + field_or_alias: dynamic([{t, table}], field(t, ^column)) } end - @spec columns(%Query{}) :: [{table(), atom()}] + defp config_from_ordering_expressions( + {direction, {:fragment, [], _} = frag}, + query + ) do + table = FragmentBuilder.table_for_fragment(frag) + + column = FragmentBuilder.column_for_query_fragment(frag, query) + + field_or_alias = + Fob.FragmentBuilder.build_from_existing( + [{t, table}], + frag + ) + + %__MODULE__{ + direction: direction, + column: column, + table: table, + field_or_alias: field_or_alias + } + end + + @spec columns(%Query{}) :: [{table(), atom(), any()}] def columns(%Query{} = query) do query |> config() diff --git a/lib/fob/page_break.ex b/lib/fob/page_break.ex index 85b5dd6..15a0c0e 100644 --- a/lib/fob/page_break.ex +++ b/lib/fob/page_break.ex @@ -31,6 +31,18 @@ defmodule Fob.PageBreak do %__MODULE__{page_break | table: order.table, direction: order.direction} end + def wrap_field_or_alias(page_breaks, %Ecto.Query{} = query) + when is_list(page_breaks) do + ordering_config = Ordering.config(query) + + Enum.map(page_breaks, &wrap_field_or_alias(&1, ordering_config)) + end + + def wrap_field_or_alias(%{column: column} = page_break, ordering_config) do + order = Enum.find(ordering_config, fn order -> column == order.column end) + {page_break, order.field_or_alias} + end + @doc since: "0.2.0" def compare(a, b, query) when is_list(a) and is_list(b) do compare(add_query_info(a, query), add_query_info(b, query)) diff --git a/test/fob/complex_order_by_test.exs b/test/fob/complex_order_by_test.exs new file mode 100644 index 0000000..300d705 --- /dev/null +++ b/test/fob/complex_order_by_test.exs @@ -0,0 +1,82 @@ +defmodule Fob.ComplexOrderByTest do + use Fob.RepoCase + + @moduledoc """ + Tests functionality where a query is sorted by a complex fragment + """ + + alias Fob.Cursor + alias Ecto.Multi + + setup do + [schema: SimplePrimaryKeySchema, repo: Fob.Repo] + end + + setup c do + records = for n <- 1..20, do: %{id: n} + + Multi.new() + |> Multi.insert_all(:seeds, c.schema, records) + |> c.repo.transaction() + + :ok + end + + test "when sorting ascending with fragment, we can get each page", c do + cursor = + Cursor.new( + from( + s in c.schema, + select: %{ + id: s.id, + virtual_column: fragment("(? % 2)", s.id) + }, + order_by: [asc: fragment("(? % 2)", s.id), asc: s.id] + ), + c.repo, + _initial_page_breaks = nil, + 5 + ) + + assert {records, cursor} = Cursor.next(cursor) + assert Enum.map(records, & &1.id) == [2, 4, 6, 8, 10] + + assert {records, cursor} = Cursor.next(cursor) + assert Enum.map(records, & &1.id) == [12, 14, 16, 18, 20] + + assert {records, cursor} = Cursor.next(cursor) + assert Enum.map(records, & &1.id) == [1, 3, 5, 7, 9] + + assert {records, cursor} = Cursor.next(cursor) + assert Enum.map(records, & &1.id) == [11, 13, 15, 17, 19] + + # end of the data set + assert {[], _cursor} = Cursor.next(cursor) + end + + test "when sorting descending with fragment, the records are in reverse order", + c do + cursor = + Cursor.new( + from( + s in c.schema, + select: %{ + id: s.id, + virtual_column: fragment("(? % 2)", s.id) + }, + order_by: [desc: fragment("(? % 2)", s.id), desc: s.id] + ), + c.repo, + nil, + 10 + ) + + assert {records, cursor} = Cursor.next(cursor) + assert Enum.map(records, & &1.id) == [19, 17, 15, 13, 11, 9, 7, 5, 3, 1] + + assert {records, cursor} = Cursor.next(cursor) + assert Enum.map(records, & &1.id) == [20, 18, 16, 14, 12, 10, 8, 6, 4, 2] + + assert {[], _cursor} = Cursor.next(cursor) + end +end diff --git a/test/fob/fragment_builder_test.exs b/test/fob/fragment_builder_test.exs new file mode 100644 index 0000000..ad76b1c --- /dev/null +++ b/test/fob/fragment_builder_test.exs @@ -0,0 +1,25 @@ +defmodule Fob.FragmentBuilderTest do + use ExUnit.Case + + import Ecto.Query + import Ecto.Query + require Fob.FragmentBuilder + + test "fragement builder builds dynamic query" do + query = + from( + s in "schema", + as: :s, + select: %{ + virtual_column: fragment("(? % 2)", s.id) + }, + order_by: [asc: fragment("(? % 2)", s.id)] + ) + + %{expr: [{_direction, frag}]} = hd(query.order_bys) + + dyn = Fob.FragmentBuilder.build_from_existing([s: s], frag) + + assert %Ecto.Query.DynamicExpr{} = dyn + end +end