Skip to content

Instantly share code, notes, and snippets.

@rmosolgo
Created April 10, 2019 14:07
Show Gist options
  • Save rmosolgo/da1dd95c297d8ed218a319ac83a05d91 to your computer and use it in GitHub Desktop.
Save rmosolgo/da1dd95c297d8ed218a319ac83a05d91 to your computer and use it in GitHub Desktop.
Generic page number / per-page pagination with GraphQL-Ruby
# 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"}]}
@rmosolgo
Copy link
Author

👋 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