Unnest for runtime sorted results
The recent ecto
3.5 update introduced the parameterized type Ecto.Enum
, which
is a great way to use atoms in elixir for signifying things like statuses or
types of data. In the database those values are stored as plain strings, so
they’re easy to manage there as well – until it comes to sorting by those columns.
The simplest solution for sorting not alphabetically, but by e.g. the order the
statuses are applied, is likely to use an proper enum
column in the database or
joining a table in the db, which holds an index for each possible value in a secondary
column.
Arbitrary order
But what about arbitrary sorting, which cannot be placed in the db?
For postgres there’s the possibility to build that join table dynamically with
unnest
instead of an actual table. The usual answer one finds on the internet
most often involves using VALUES
lists, but they don’t really work well with
ecto queries, as they’re not easily parameterized. unnest
luckily can be:
defmodule MyApp.QueryHelpers
@doc """
Unnest into an table format.
## Example
import MyApp.QueryHelpers
status = [:waiting, :running, :done]
order = [1, 2, 3]
from jobs in "jobs",
join: ordering in unnest(^status, ^order),
on: jobs.status == ordering.a,
order_by: ordering.b
"""
defmacro unnest(list_a, list_b) do
quote do
fragment("SELECT * FROM unnest(?, ?) AS t(a, b)", unquote(list_a), unquote(list_b))
end
end
end
As you can see unnest
does work with plain lists, which can be passed as
parameters and therefore work with fragment
. This allows to build up an table
of arbitrary size (more columns could be done by adding additional arities for
the unnest
helper), which can be joined to the data in the database.
Updating pagination
Given we’re manually supplying the list of “positions” this can not only be used for read operations, but e.g. also for updates for things like position columns for data ordered in the db.
@spec update_order(%{produce_id :: integer => new_position :: integer}) :: result :: term
def update_order(new_order) do
{ids, positions} = Enum.unzip(new_order)
query =
from p in Product,
join:
positions in unnest(
type(^ids, {:array, :integer}),
type(^positions, {:array, :integer})
),
on: p.id == positions.a,
update: [set: [position: positions.b]]
Repo.transaction(fn ->
# Create deferable constraint like that (not `unique_index/3`):
#
# execute("""
# ALTER TABLE products
# ADD constraint products_position_unique unique (position) deferrable;
# """, "")
#
# This allows for all rows to update before checking if everything is
# still unique.
Repo.query!("SET CONSTRAINTS products_position_unique DEFERRED")
Repo.update_all(query, [])
end)
end