Skip to content

Commit

Permalink
Merge pull request duffelhq#166 from mattnenterprise/expression-curso…
Browse files Browse the repository at this point in the history
…r-v3

Add expression based cursor v3
  • Loading branch information
telphan authored Aug 17, 2023
2 parents d379366 + f0d1084 commit 3508d6a
Show file tree
Hide file tree
Showing 13 changed files with 292 additions and 49 deletions.
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,51 @@ cursor_before = metadata.before
IO.puts "total count: #{metadata.total_count}"
```

## Dynamic expressions

```elixir
query =
from(
f in Post,
# Alias for fragment must match witch cursor field name in fetch_cursor_value_fun and cursor_fields
select_merge: %{
rank_value:
fragment("ts_rank(document, plainto_tsquery('simple', ?)) AS rank_value", ^q)
},
where: fragment("document @@ plainto_tsquery('simple', ?)", ^q),
order_by: [
desc: fragment("rank_value"),
desc: f.id
]
)
query
|> Repo.paginate(
limit: 30,
fetch_cursor_value_fun: fn
# Here we build the rank_value for each returned row
schema, :rank_value ->
{:ok, %{rows: [[rank_value]]}} =
Repo.query("SELECT ts_rank($1, plainto_tsquery('simple', $2))", [
schema.document,
q
])
rank_value
schema, field ->
Paginator.default_fetch_cursor_value(schema, field)
end,
cursor_fields: [
{:rank_value, # Here we build the rank_value that will be used in the where clause
fn ->
dynamic(
[x],
fragment("ts_rank(document, plainto_tsquery('simple', ?))", ^q)
)
end},
:id
]
)
```

## Security Considerations

`Repo.paginate/4` will throw an `ArgumentError` should it detect an executable term in the cursor parameters passed to it (`before`, `after`).
Expand Down
6 changes: 6 additions & 0 deletions lib/paginator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,12 @@ defmodule Paginator do
}) do
cursor_fields
|> Enum.map(fn
{{cursor_field, func}, _order} when is_atom(cursor_field) and is_function(func) ->
{cursor_field, fetch_cursor_value_fun.(schema, cursor_field)}

{cursor_field, func} when is_atom(cursor_field) and is_function(func) ->
{cursor_field, fetch_cursor_value_fun.(schema, cursor_field)}

{cursor_field, _order} ->
{cursor_field, fetch_cursor_value_fun.(schema, cursor_field)}

Expand Down
7 changes: 7 additions & 0 deletions lib/paginator/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ defmodule Paginator.Config do
when is_atom(schema) and is_atom(field) and value in @order_directions ->
{schema, field}

{{field, func}, value}
when is_function(func) and is_atom(field) and value in @order_directions ->
field

{field, func} when is_function(func) and is_atom(field) ->
field

field when is_atom(field) ->
field

Expand Down
24 changes: 18 additions & 6 deletions lib/paginator/ecto/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ defmodule Paginator.Ecto.Query do
where(query, [{q, 0}], ^filters)
end

defp build_where_expression(query, [{column, order}], values, cursor_direction) do
value = Map.get(values, column)
{q_position, q_binding} = column_position(query, column)
defp build_where_expression(query, [{field, order} = column], values, cursor_direction) do
value = column_value(column, values)
{q_position, q_binding} = column_position(query, field)

DynamicFilterBuilder.build!(%{
sort_order: order,
Expand All @@ -50,9 +50,9 @@ defmodule Paginator.Ecto.Query do
})
end

defp build_where_expression(query, [{column, order} | fields], values, cursor_direction) do
value = Map.get(values, column)
{q_position, q_binding} = column_position(query, column)
defp build_where_expression(query, [{field, order} = column | fields], values, cursor_direction) do
value = column_value(column, values)
{q_position, q_binding} = column_position(query, field)

filters = build_where_expression(query, fields, values, cursor_direction)

Expand All @@ -66,6 +66,14 @@ defmodule Paginator.Ecto.Query do
})
end

defp column_value({{field, func}, _order}, values) when is_function(func) and is_atom(field) do
Map.get(values, field)
end

defp column_value({column, _order}, values) do
Map.get(values, column)
end

defp maybe_where(query, %Config{
after: nil,
before: nil
Expand Down Expand Up @@ -102,6 +110,10 @@ defmodule Paginator.Ecto.Query do
|> filter_values(cursor_fields, before_values, :before)
end

# With custom column handler
defp column_position(_query, {_, handler} = column) when is_function(handler),
do: {0, column}

# Lookup position of binding in query aliases
defp column_position(query, {binding_name, column}) do
case Map.fetch(query.aliases, binding_name) do
Expand Down
21 changes: 11 additions & 10 deletions lib/paginator/ecto/query/asc_nulls_first.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule Paginator.Ecto.Query.AscNullsFirst do
@behaviour Paginator.Ecto.Query.DynamicFilterBuilder

import Ecto.Query
import Paginator.Ecto.Query.FieldOrExpression

@impl Paginator.Ecto.Query.DynamicFilterBuilder
def build_dynamic_filter(%{direction: :after, value: nil, next_filters: true}) do
Expand All @@ -11,23 +12,23 @@ defmodule Paginator.Ecto.Query.AscNullsFirst do
def build_dynamic_filter(args = %{direction: :after, value: nil}) do
dynamic(
[{query, args.entity_position}],
(is_nil(field(query, ^args.column)) and ^args.next_filters) or
not is_nil(field(query, ^args.column))
(^field_or_expr_is_nil(args) and ^args.next_filters) or
not (^field_or_expr_is_nil(args))
)
end

def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) > ^args.value
^field_or_expr_greater(args)
)
end

def build_dynamic_filter(args = %{direction: :after}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) > ^args.value
(^field_or_expr_equal(args) and ^args.next_filters) or
^field_or_expr_greater(args)
)
end

Expand All @@ -38,23 +39,23 @@ defmodule Paginator.Ecto.Query.AscNullsFirst do
def build_dynamic_filter(args = %{direction: :before, value: nil}) do
dynamic(
[{query, args.entity_position}],
is_nil(field(query, ^args.column)) and ^args.next_filters
^field_or_expr_is_nil(args) and ^args.next_filters
)
end

def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) < ^args.value or is_nil(field(query, ^args.column))
^field_or_expr_less(args) or ^field_or_expr_is_nil(args)
)
end

def build_dynamic_filter(args = %{direction: :before}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) < ^args.value or
is_nil(field(query, ^args.column))
(^field_or_expr_equal(args) and ^args.next_filters) or
^field_or_expr_less(args) or
^field_or_expr_is_nil(args)
)
end
end
21 changes: 11 additions & 10 deletions lib/paginator/ecto/query/asc_nulls_last.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule Paginator.Ecto.Query.AscNullsLast do
@behaviour Paginator.Ecto.Query.DynamicFilterBuilder

import Ecto.Query
import Paginator.Ecto.Query.FieldOrExpression

@impl Paginator.Ecto.Query.DynamicFilterBuilder
def build_dynamic_filter(%{direction: :after, value: nil, next_filters: true}) do
Expand All @@ -11,23 +12,23 @@ defmodule Paginator.Ecto.Query.AscNullsLast do
def build_dynamic_filter(args = %{direction: :after, value: nil}) do
dynamic(
[{query, args.entity_position}],
is_nil(field(query, ^args.column)) and ^args.next_filters
^field_or_expr_is_nil(args) and ^args.next_filters
)
end

def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) > ^args.value or is_nil(field(query, ^args.column))
^field_or_expr_greater(args) or ^field_or_expr_is_nil(args)
)
end

def build_dynamic_filter(args = %{direction: :after}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) > ^args.value or
is_nil(field(query, ^args.column))
(^field_or_expr_equal(args) and ^args.next_filters) or
^field_or_expr_greater(args) or
^field_or_expr_is_nil(args)
)
end

Expand All @@ -38,20 +39,20 @@ defmodule Paginator.Ecto.Query.AscNullsLast do
def build_dynamic_filter(args = %{direction: :before, value: nil}) do
dynamic(
[{query, args.entity_position}],
(is_nil(field(query, ^args.column)) and ^args.next_filters) or
not is_nil(field(query, ^args.column))
(^field_or_expr_is_nil(args) and ^args.next_filters) or
not (^field_or_expr_is_nil(args))
)
end

def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do
dynamic([{query, args.entity_position}], field(query, ^args.column) < ^args.value)
dynamic([{query, args.entity_position}], ^field_or_expr_less(args))
end

def build_dynamic_filter(args = %{direction: :before}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) < ^args.value
(^field_or_expr_equal(args) and ^args.next_filters) or
^field_or_expr_less(args)
)
end
end
21 changes: 11 additions & 10 deletions lib/paginator/ecto/query/desc_nulls_first.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule Paginator.Ecto.Query.DescNullsFirst do
@behaviour Paginator.Ecto.Query.DynamicFilterBuilder

import Ecto.Query
import Paginator.Ecto.Query.FieldOrExpression

@impl Paginator.Ecto.Query.DynamicFilterBuilder
def build_dynamic_filter(%{direction: :before, value: nil, next_filters: true}) do
Expand All @@ -11,23 +12,23 @@ defmodule Paginator.Ecto.Query.DescNullsFirst do
def build_dynamic_filter(args = %{direction: :before, value: nil}) do
dynamic(
[{query, args.entity_position}],
is_nil(field(query, ^args.column)) and ^args.next_filters
^field_or_expr_is_nil(args) and ^args.next_filters
)
end

def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) > ^args.value or is_nil(field(query, ^args.column))
^field_or_expr_greater(args) or ^field_or_expr_is_nil(args)
)
end

def build_dynamic_filter(args = %{direction: :before}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) > ^args.value or
is_nil(field(query, ^args.column))
(^field_or_expr_equal(args) and ^args.next_filters) or
^field_or_expr_greater(args) or
^field_or_expr_is_nil(args)
)
end

Expand All @@ -38,20 +39,20 @@ defmodule Paginator.Ecto.Query.DescNullsFirst do
def build_dynamic_filter(args = %{direction: :after, value: nil}) do
dynamic(
[{query, args.entity_position}],
(is_nil(field(query, ^args.column)) and ^args.next_filters) or
not is_nil(field(query, ^args.column))
(^field_or_expr_is_nil(args) and ^args.next_filters) or
not (^field_or_expr_is_nil(args))
)
end

def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do
dynamic([{query, args.entity_position}], field(query, ^args.column) < ^args.value)
dynamic([{query, args.entity_position}], ^field_or_expr_less(args))
end

def build_dynamic_filter(args = %{direction: :after}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) < ^args.value
(^field_or_expr_equal(args) and ^args.next_filters) or
^field_or_expr_less(args)
)
end
end
21 changes: 11 additions & 10 deletions lib/paginator/ecto/query/desc_nulls_last.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule Paginator.Ecto.Query.DescNullsLast do
@behaviour Paginator.Ecto.Query.DynamicFilterBuilder

import Ecto.Query
import Paginator.Ecto.Query.FieldOrExpression

@impl Paginator.Ecto.Query.DynamicFilterBuilder
def build_dynamic_filter(%{direction: :before, value: nil, next_filters: true}) do
Expand All @@ -11,23 +12,23 @@ defmodule Paginator.Ecto.Query.DescNullsLast do
def build_dynamic_filter(args = %{direction: :before, value: nil}) do
dynamic(
[{query, args.entity_position}],
(is_nil(field(query, ^args.column)) and ^args.next_filters) or
not is_nil(field(query, ^args.column))
(^field_or_expr_is_nil(args) and ^args.next_filters) or
not (^field_or_expr_is_nil(args))
)
end

def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) > ^args.value
^field_or_expr_greater(args)
)
end

def build_dynamic_filter(args = %{direction: :before}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) > ^args.value
(^field_or_expr_equal(args) and ^args.next_filters) or
^field_or_expr_greater(args)
)
end

Expand All @@ -38,23 +39,23 @@ defmodule Paginator.Ecto.Query.DescNullsLast do
def build_dynamic_filter(args = %{direction: :after, value: nil}) do
dynamic(
[{query, args.entity_position}],
is_nil(field(query, ^args.column)) and ^args.next_filters
^field_or_expr_is_nil(args) and ^args.next_filters
)
end

def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) < ^args.value or is_nil(field(query, ^args.column))
^field_or_expr_less(args) or ^field_or_expr_is_nil(args)
)
end

def build_dynamic_filter(args = %{direction: :after}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) < ^args.value or
is_nil(field(query, ^args.column))
(^field_or_expr_equal(args) and ^args.next_filters) or
^field_or_expr_less(args) or
^field_or_expr_is_nil(args)
)
end
end
Loading

0 comments on commit 3508d6a

Please sign in to comment.