Created
April 10, 2019 14:07
-
-
Save rmosolgo/da1dd95c297d8ed218a319ac83a05d91 to your computer and use it in GitHub Desktop.
Generic page number / per-page pagination with GraphQL-Ruby
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# This is a full-blown offset-based pagination system modelled after GraphQL-Ruby's | |
# built-in connections. It has a few different elements: | |
# | |
# - `::Page`, a plain ol' Ruby class for modeling _pages_ of things. | |
# This class handles applying pagination arguments to lists (Arrays and AR::Relations) | |
# and provides metadata about pagination. (Similar to `will_paginate`.) | |
# - `Schema::BasePage` is a generic GraphQL-Ruby object type. It's never used directly, | |
# but it can generate subclasses which wrap _specific_ object types in the schema. | |
# - `Schema::BaseObject.page_type` is a convenience method for generating page types | |
# from your object types. You could leave this out and make subclasses with plain ol' | |
# Ruby instead (using `class BookPage < BasePage ...`). It's a lot _simpler_ since | |
# there's no dynamically generated classes, but it's more verbose. A tradeoff. | |
# - `Schema::BaseField::PageWrapperExtension` applies the pagination arguments & wrapper | |
# to lists of items. This is just for convenience, like connections in GraphQL-Ruby. | |
# If you left this off, you could easily add arguments and `::Page.new(...)` setup | |
# to each field that return a `.page_type`. | |
# | |
# Finally, there's an example schema and some queries to demonstrate the behavior. | |
require "graphql" | |
require "active_record" | |
# Some example data | |
BOOKS = [ | |
{ title: "Ruby Under a Microscope" }, | |
{ title: "Patterns of Enterprise Application Architecture" }, | |
{ title: "ReWork" }, | |
{ title: "Land of Lisp" }, | |
{ title: "The C Programming Language" }, | |
{ title: "Smalltalk-80: The Language and its Implementation" }, | |
{ title: "Domain-Driven Design Distilled" }, | |
] | |
# This object represents a generic page of items. | |
# `all_nodes` may be an ActiveRecord::Relation or an Array. | |
# (Other implementations may be added below.) | |
# | |
# It implements the fields of the `BasePage` type defined below. | |
class Page | |
DEFAULT_PAGE_SIZE = 20 | |
MAX_PAGE_SIZE = 50 | |
def initialize(all_nodes, page:, per_page:) | |
@all_nodes = all_nodes | |
# Normalize pagination arguments | |
@page = if page.nil? || page < 1 | |
1 | |
else | |
page | |
end | |
@per_page = if per_page.nil? || per_page < 1 | |
DEFAULT_PAGE_SIZE | |
elsif per_page > MAX_PAGE_SIZE | |
MAX_PAGE_SIZE | |
else | |
per_page | |
end | |
end | |
# True if there are items on the list after this page of items | |
def has_next_page | |
nodes_count > (@page * @per_page) | |
end | |
# True if there are items on the list before this page of items | |
def has_previous_page | |
@page > 1 | |
end | |
# The total number of items in the list | |
def nodes_count | |
@nodes_count ||= case @all_nodes | |
when ActiveRecord::Relation | |
# Remove `ORDER BY` for better performance | |
@all_nodes.unscope(:order).count | |
when Array | |
@all_nodes.count | |
else | |
# TODO: implement other counts here | |
raise "`nodes_count` not implemented for #{@all_nodes.class} (#{@all_nodes.inspect})" | |
end | |
end | |
# The total number of pages for this page size | |
def pages_count | |
(nodes_count / @per_page.to_f).ceil | |
end | |
# The items in this page | |
def nodes | |
@nodes ||= case @all_nodes | |
when ActiveRecord::Relation | |
offset = (@page - 1) * @per_page | |
@all_nodes.offset(offset).limit(@per_page) | |
when Array | |
offset = (@page - 1) * @per_page | |
@all_nodes[offset, @per_page] || [] # return empty if out-of-bounds | |
else | |
# TODO: implement other counts here | |
raise "`nodes_count` not implemented for #{@all_nodes.class} (#{@all_nodes.inspect})" | |
end | |
end | |
end | |
class Schema < GraphQL::Schema | |
class BaseField < GraphQL::Schema::Field | |
def initialize(**kwargs, &block) | |
# Do all the normal field setup: | |
super | |
# Add pagination args if this is a `Page` field | |
return_type = kwargs[:type] | |
if return_type.is_a?(Class) && return_type < BasePage | |
self.extension(PageWrapperExtension) | |
end | |
end | |
# Like the built-in ConnectionExtension, this adds arguments | |
# and automatic argument handling for page fields | |
# @see https://graphql-ruby.org/type_definitions/field_extensions.html | |
class PageWrapperExtension < GraphQL::Schema::FieldExtension | |
# Add the arguments to the field | |
def apply | |
field.argument(:page, Integer, required: false) | |
field.argument(:per_page, Integer, required: false) | |
end | |
def resolve(object:, arguments:, **rest) | |
# Remove pagination arguments | |
cleaned_arguments = arguments.dup | |
page = cleaned_arguments.delete(:page) | |
per_page = cleaned_arguments.delete(:per_page) | |
# Call the underlying resolver (without pagination args) | |
resolved_object = yield(object, cleaned_arguments) | |
# Then, apply the wrapper and return it | |
::Page.new(resolved_object, page: page, per_page: per_page) | |
end | |
end | |
end | |
class BaseObject < GraphQL::Schema::Object | |
field_class BaseField | |
# Generate a page type for this object, | |
# or use an already-cached one. | |
def self.page_type | |
@page_type ||= BasePage.create(self) | |
end | |
end | |
# A generic page type | |
class BasePage < BaseObject | |
def self.create(node_class) | |
Class.new(self) do | |
# Override the name so it reflects the node class | |
graphql_name("#{node_class.graphql_name}Page") | |
# Add the nodes field which reflects the node class | |
field :nodes, [node_class], null: false | |
end | |
end | |
field :has_previous_page, Boolean, null: false | |
field :has_next_page, Boolean, null: false | |
field :pages_count, Integer, null: false | |
field :nodes_count, Integer, null: false | |
end | |
class Book < BaseObject | |
field :title, String, null: false | |
end | |
class Query < BaseObject | |
field :books, Book.page_type, null: false | |
def books | |
BOOKS | |
end | |
end | |
query(Query) | |
end | |
query_string = <<-GRAPHQL | |
query($page: Int, $perPage: Int) { | |
books(page: $page, perPage: $perPage) { | |
hasNextPage | |
hasPreviousPage | |
pagesCount | |
nodesCount | |
nodes { | |
title | |
} | |
} | |
} | |
GRAPHQL | |
# Using the default page size | |
pp Schema.execute(query_string)["data"]["books"] | |
# {"hasNextPage"=>false, | |
# "hasPreviousPage"=>false, | |
# "pagesCount"=>1, | |
# "nodesCount"=>7, | |
# "nodes"=> | |
# [{"title"=>"Ruby Under a Microscope"}, | |
# {"title"=>"Patterns of Enterprise Application Architecture"}, | |
# {"title"=>"ReWork"}, | |
# {"title"=>"Land of Lisp"}, | |
# {"title"=>"The C Programming Language"}, | |
# {"title"=>"Smalltalk-80: The Language and its Implementation"}, | |
# {"title"=>"Domain-Driven Design Distilled"}]} | |
# Out-of-bounds page number | |
pp Schema.execute(query_string, variables: { page: 2 })["data"]["books"] | |
# {"hasNextPage"=>false, | |
# "hasPreviousPage"=>true, | |
# "pagesCount"=>1, | |
# "nodesCount"=>7, | |
# "nodes"=>[]} | |
# Using a custom page size | |
pp Schema.execute(query_string, variables: { page: 3, perPage: 2 })["data"]["books"] | |
# {"hasNextPage"=>true, | |
# "hasPreviousPage"=>true, | |
# "pagesCount"=>4, | |
# "nodesCount"=>7, | |
# "nodes"=> | |
# [{"title"=>"The C Programming Language"}, | |
# {"title"=>"Smalltalk-80: The Language and its Implementation"}]} |
👋 I haven't done any further work on offset-based pagination but I would expect this approach to work just fine!
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@rmosolgo Is this still the recommended way of performing page-based pagination? We are utilising Connections and have a requirement to move away from cursor-based pagination.