Shuffled Records in Phoenix

elixir elixir-phoenix ecto
Posted on: 2017-12-12

I've written previously about a way to query for records in a shuffled order that works for pagination.

The TL;DR was: use a shuffled (not random) ordering, based on a prime integer parameter.

SELECT whatever
FROM table
ORDER BY large_integer_field % prime_integer_parameter -- modulo to shuffle
LIMIT 10 OFFSET 0;

Reuse the prime_integer_parameter with OFFSET 10 for subsequent page requests.

This technique would be slow with large record sets and has issues when large_integer_field values start at 1; see the previous post for further discussion.

Applying this in Phoenix

I came up with this while working on a Phoenix-backed JSON API for Mealthy.com, using ja_resource and ja_serializer, and thought I'd show how I implemented it there.

The idea is this:

  • A client could request something like /api/recipes?filter[shuffle]=true
  • The server chooses a shuffle value - say, 11
  • It shuffles the records accordingly
  • In the response, it includes pagination links with ?filter[shuffle]=11

To that end, I created a plug that picks the shuffle value and puts it into the connection as needed for filtering and paginating.

defmodule Mealthy.Api.AutoShuffleParam do

  def init(opts), do: opts

  # Exists to support API requests with `filter[shuffle]=true`;
  # picks a random "shuffle value" and updates the params with it,
  # so the request is treated as if it had been, eg, `filter[shuffle]=31`
  def call(conn = %{params: %{"filter" => %{"shuffle" => "true"}}}, _opts) do
    shuffle_val = Mealthy.Shuffle.random_shuffle_val()
    # conn.params is what ja_resource passes to filter functions
    conn = put_in(conn.params["filter"]["shuffle"], shuffle_val)
    # conn.query_params is what ja_serializer uses to build links.next
    conn = put_in(conn.query_params["filter"]["shuffle"], shuffle_val)
    conn
  end

  def call(conn, _opts) do
    conn
  end

end

The Shuffle module implements random_shuffle_val/0. It also implements shuffle_query/2, which lets us easily shuffle records of any kind.

defmodule Mealthy.Shuffle do

  # These numbers are to be used as the modulus to shuffle the order of records
  # returned, via `ORDER BY id % val, id`.
  # They are:
  # - prime, because i % 2 and i % 4 will often be the same; primes give very
  # different orderings
  # - smallish, because we don't have a ton of records. Eg, if we did
  #  `ORDER BY id % 200, id`, it wouldn't change the ordering of the first
  #  200 records at all, because any of them % 200 is itself.
  @shuffle_vals ~w[2 3 5 7 11 13 17 19 23 29 31 37]

  def random_shuffle_val do
    Enum.random(@shuffle_vals)
  end

  def shuffle_query(query, "true") do
    shuffle_query(query, random_shuffle_val())
  end

  def shuffle_query(query, val) do
    import Ecto.Query
    val = String.to_integer(val)
    from row in query,
      order_by: [
        fragment("? % ? DESC", row.id, ^val),
        desc: row.id,
      ]
  end
end

Then we can use Mealthy.Shuffle.shuffle_query(query, val) from any of our filter clauses for ja_resource.