DEV Community

Roberts Guļāns
Roberts Guļāns

Posted on

Graphql journey. Part 3: pagination

Each system has to handle cases when there are zero data (404), has one instance (viewing this particular post), has few instances (tags assigned to posts) and many instances (dev.to posts. Cases when one can safely assume there will be too many to show in one place everything). This post is about the latter and most practical way to deal with datasets with unbounded size: pagination. But let's start with an easier user case.

Works on my machine
  • elixir: 1.8.1
  • phoenix: 1.4.2
  • absinthe: 1.4.16

Returning everything

defmodule MyAppWeb.Schema do
  use Absinthe.Schema

  @doc """
  Defining query posts which returns a list of posts from the repository
  """
  query do
    field :posts, list_of(:post) do
      resolve(&MyApp.PostsResolver.posts/3)
    end
  end

  @doc """
  Post structure
  """
  object :post do
    field :id, :integer
    field :title, :string
  end
end

defmodule MyApp.PostsRepository do
  @moduledoc """
  Represents the existing code boundary.
  Can be seen as internal code, that won't even know there is a graphql consumer.
  No graphql related code here
  """

  defmodule Post do
    @moduledoc """
    Simulates ecto schema
    """
    defstruct [:id, :title]
  end

  @doc """
  Simulates values coming from db (for example)
  """
  defp all_posts() do
    [
      %Post{id: 1, title: "1"},
      %Post{id: 2, title: "2"},
      %Post{id: 3, title: "3"},
      %Post{id: 4, title: "4"}
    ]
  end

  @doc """
  Returns all posts
  """
  def posts() do
    all_posts()
  end
end

defmodule MyApp.PostsResolver do
  @moduledoc """
  Resolves graphql query.
  Handles graphql call and maps it to necessary internal code.
  """

  @doc """
  Handles posts request and map it to internal posts repository to fetch results.
  """
  def posts(_, _, _) do
    {:ok, MyApp.PostsRepository.posts()}
  end
end

Building from previous posts in this dev.to series seems reasonable. Now we can execute the following graphql query and expect a result that is returned from MyApp.PostsRepository:

query {
  posts {
    id
    title
  }
}

Let's continue with paginating results and accessing further entries.

Pagination

defmodule MyAppWeb.Schema do
  ...

  query do
    field :posts, list_of(:post) do
      # Added required argument to provide page to fetch.
      arg(:page, non_null(:integer))
      resolve(&MyApp.PostsResolver.posts/3)
    end
  end
end

defmodule MyApp.PostsRepository do
  ...

  @doc """
  Returns posts for selected page
  """
  def posts(page, per_page) do
    offset = (page - 1) * per_page

    all_posts()
    |> Enum.drop(offset)
    |> Enum.take(per_page)
  end
end

defmodule MyApp.PostsResolver do
  @doc """
  How many posts show in a single page
  """
  @per_page 2

  @doc """
  Accept selected page and pass it to repository
  """
  def posts(_, %{page: page}, _) do
    {:ok, MyApp.PostsRepository.posts(page, @per_page)}
  end
end

Above you can see code changes to support pagination. It shows how small and trivial those changes actually are. Now we can provide page argument to fallowing graphql query and get the expected output.

query {
  posts(page: 3) {
    id
    title
  }
}

Most paginated endpoints have some meta information. For example how many pages in total, there are. Let's implement that as well.

Paginated with metadata

defmodule MyAppWeb.Schema do
  ...

  query do
    # Changed to return paginated posts object
    field :posts, :paginated_posts do
      arg(:page, non_null(:integer))
      resolve(&MyApp.PostsResolver.posts/3)
    end
  end

  @doc """
  Paginated posts object contains list of data and meta information
  """
  object :paginated_posts do
    field :results, list_of(:post)
    field :meta, :page_info
  end

  @doc """
  Meta information object structure
  """
  object :page_info do
    field :page, :integer
    field :total_pages, :integer
  end
end

defmodule MyApp.PostsRepository do
  ...

  @doc """
  How many posts there are
  """
  def count() do
    Enum.count(all_posts())
  end
end

defmodule MyApp.PostsResolver do
  ...

  @doc """
  Returns custom data set to support graphql defined structure
  """
  def posts(_, %{page: page}, _) do
    results = MyApp.PostsRepository.posts(page, @per_page)
    total_pages = Float.ceil(MyApp.PostsRepository.count() / @per_page)

    {:ok, %{results: results, meta: %{page: page, total_pages: total_pages}}}
  end
end

This is all needed changed code to implement simple pagination. And now graphql query looks like the following:

query {
  posts(page: 1) {
    results {
      id
      title
    },
    meta {
      page,
      totalPages
    }
  }
}

Conclusion

  • Pagination seemed easy for me to grasp. Building upon an understanding of previous posts, it felt natural.
  • Graphql methodology is to handle HTTP layer communication, and I like how unintrusive it is. They don't try to give some idiomatic pagination or something like that. Everything is a field, and you can resolve each field as you wish.
  • Each project could implement and require different structures to support all needs. Extended meta information for example. So take this example with a grain of salt, as not this is the "best" way to do it, nor it tries to be.

P.S. If you have any comments or I haven't been clear enough about some parts, please let me know :)

Uncommented final code version
defmodule MyAppWeb.Schema do
  use Absinthe.Schema

  query do
    field :posts, :paginated_posts do
      arg(:page, non_null(:integer))
      resolve(&MyApp.PostsResolver.posts/3)
    end
  end

  object :paginated_posts do
    field :results, list_of(:post)
    field :meta, :page_info
  end

  object :page_info do
    field :page, :integer
    field :total_pages, :integer
  end

  object :post do
    field :id, :integer
    field :title, :string
  end
end

defmodule MyApp.PostsRepository do
  defmodule Post do
    defstruct [:id, :title]
  end

  def all_posts() do
    [
      %Post{id: 1, title: "1"},
      %Post{id: 2, title: "2"},
      %Post{id: 3, title: "3"},
      %Post{id: 4, title: "4"}
    ]
  end

  def posts(page \\ 1, per_page) do
    offset = (page - 1) * per_page

    all_posts()
    |> Enum.drop(offset)
    |> Enum.take(per_page)
  end

  def count() do
    Enum.count(all_posts())
  end
end

defmodule MyApp.PostsResolver do
  @per_page 2

  def posts(_, %{page: page}, _) do
    results = MyApp.PostsRepository.posts(page, @per_page)
    total_pages = Float.ceil(MyApp.PostsRepository.count() / @per_page)

    {:ok, %{results: results, meta: %{page: page, total_pages: total_pages}}}
  end
end

Top comments (0)