Skip to content

Commit

Permalink
Merge pull request #2 from DmitryTsepelev/keyset-connections
Browse files Browse the repository at this point in the history
Keyset connections
  • Loading branch information
bibendi authored Jul 12, 2021
2 parents c5ee42f + aaef6ed commit baf2065
Show file tree
Hide file tree
Showing 12 changed files with 1,013 additions and 259 deletions.
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ Records are sorted by model's primary key by default. You can change this behavi
GraphQL::Connections::Stable.new(Message.all, primary_key: :created_at)
```

In case when you want records to be sorted by more than one field (i.e., _keyset pagination_), you can use `keys` param:

```ruby
GraphQL::Connections::Stable.new(Message.all, keys: %w[name id])
```

When you pass only one key, a primary key will be added as a second one:

```ruby
GraphQL::Connections::Stable.new(Message.all, keys: [:name])
```

**NOTE:** Currently we support maximum two keys in the keyset.

Also, you can pass the `:desc` option to reverse the relation:

```ruby
GraphQL::Connections::Stable.new(Message.all, keys: %w[name id], desc: true)
```

**NOTE:** `:desc` option is not implemented for stable connections with `:primary_key` passed; if you need it—use keyset pagination or implement `:desc` option for us 🙂.

Also, you can disable opaque cursors by setting `opaque_cursor` param:

```ruby
Expand All @@ -53,7 +75,7 @@ class ApplicationSchema < GraphQL::Schema
end
```

**NOTE:** Don't use stable connections for relations whose ordering is too complicated for cursor generation.
**NOTE:** Don't use stable connections for relations whose ordering is too complicated for cursor generation.

## Development

Expand Down
7 changes: 7 additions & 0 deletions lib/graphql/connections.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ module Connections
end

require "graphql/connections/stable"

require "graphql/connections/base"
require "graphql/connections/key_asc"

require "graphql/connections/keyset/base"
require "graphql/connections/keyset/asc"
require "graphql/connections/keyset/desc"
65 changes: 65 additions & 0 deletions lib/graphql/connections/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

module GraphQL
module Connections
# Base class for pagination implementations
class Base < ::GraphQL::Pagination::Connection
attr_reader :opaque_cursor

delegate :arel_table, to: :items

def initialize(*args, opaque_cursor: true, **kwargs)
@opaque_cursor = opaque_cursor

super(*args, **kwargs)
end

def primary_key
@primary_key ||= items.model.primary_key
end

def nodes
@nodes ||= limited_relation
end

def has_previous_page # rubocop:disable Naming/PredicateName
raise NotImplementedError
end

def has_next_page # rubocop:disable Naming/PredicateName
raise NotImplementedError
end

def cursor_for(item)
raise NotImplementedError
end

private

def serialize(cursor)
case cursor
when Time, DateTime, Date
cursor.iso8601
else
cursor.to_s
end
end

def limited_relation
raise NotImplementedError
end

def sliced_relation
raise NotImplementedError
end

def after_cursor
@after_cursor ||= opaque_cursor ? decode(after) : after
end

def before_cursor
@before_cursor ||= opaque_cursor ? decode(before) : before
end
end
end
end
69 changes: 69 additions & 0 deletions lib/graphql/connections/key_asc.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

module GraphQL
module Connections
# Implements pagination by one field with asc order
class KeyAsc < ::GraphQL::Connections::Base
def initialize(*args, primary_key: nil, **kwargs)
@primary_key = primary_key

super(*args, **kwargs)
end

def has_previous_page # rubocop:disable Naming/PredicateName, Metrics/AbcSize
if last
nodes.any? && items.where(arel_table[primary_key].lt(nodes.first[primary_key])).exists?
elsif after
items.where(arel_table[primary_key].lteq(after_cursor)).exists?
else
false
end
end

def has_next_page # rubocop:disable Naming/PredicateName, Metrics/AbcSize
if first
nodes.any? && items.where(arel_table[primary_key].gt(nodes.last[primary_key])).exists?
elsif before
items.where(arel_table[primary_key].gteq(before_cursor)).exists?
else
false
end
end

def cursor_for(item)
cursor = serialize(item[primary_key])
cursor = encode(cursor) if opaque_cursor
cursor
end

private

def limited_relation # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
scope = sliced_relation
nodes = []

if first
nodes |= scope
.reorder(arel_table[primary_key].asc)
.limit(first)
.to_a
end

if last
nodes |= scope
.reorder(arel_table[primary_key].desc)
.limit(last)
.to_a.reverse!
end

nodes
end

def sliced_relation # rubocop:disable Metrics/AbcSize
items
.yield_self { |s| after ? s.where(arel_table[primary_key].gt(after_cursor)) : s }
.yield_self { |s| before ? s.where(arel_table[primary_key].lt(before_cursor)) : s }
end
end
end
end
85 changes: 85 additions & 0 deletions lib/graphql/connections/keyset/asc.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true

module GraphQL
module Connections
module Keyset
# Implements keyset pagination by two fields with asc order
class Asc < ::GraphQL::Connections::Keyset::Base
# rubocop:disable Naming/PredicateName, Metrics/AbcSize, Metrics/MethodLength
def has_previous_page
if last
nodes.any? &&
items.where(arel_table[field_key].eq(nodes.first[field_key]))
.where(arel_table[primary_key].lt(nodes.first[primary_key]))
.or(items.where(arel_table[field_key].lt(nodes.first[field_key])))
.exists?
elsif after
items
.where(arel_table[field_key].lt(after_cursor_date))
.or(
items.where(arel_table[field_key].eq(after_cursor_date))
.where(arel_table[primary_key].lt(after_cursor_primary_key))
).exists?
else
false
end
end

def has_next_page
if first
nodes.any? &&
items.where(arel_table[field_key].eq(nodes.last[field_key]))
.where(arel_table[primary_key].gt(nodes.last[primary_key]))
.or(items.where(arel_table[field_key].gt(nodes.last[field_key])))
.exists?
elsif before
items
.where(arel_table[field_key].gt(before_cursor_date))
.or(
items.where(arel_table[field_key].eq(before_cursor_date))
.where(arel_table[primary_key].gt(before_cursor_primary_key))
).exists?
else
false
end
end
# rubocop:enable Naming/PredicateName, Metrics/AbcSize, Metrics/MethodLength

private

def limited_relation # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
scope = sliced_relation
nodes = []

if first
nodes |= scope
.reorder(arel_table[field_key].asc, arel_table[primary_key].asc)
.limit(first).to_a
end

if last
nodes |= scope
.reorder(arel_table[field_key].desc, arel_table[primary_key].desc)
.limit(last).to_a.reverse!
end

nodes
end

def sliced_relation_after(relation) # rubocop:disable Metrics/AbcSize
relation
.where(arel_table[field_key].eq(after_cursor_date))
.where(arel_table[primary_key].gt(after_cursor_primary_key))
.or(relation.where(arel_table[field_key].gt(after_cursor_date)))
end

def sliced_relation_before(relation) # rubocop:disable Metrics/AbcSize
relation
.where(arel_table[field_key].eq(before_cursor_date))
.where(arel_table[primary_key].lt(before_cursor_primary_key))
.or(relation.where(arel_table[field_key].lt(before_cursor_date)))
end
end
end
end
end
59 changes: 59 additions & 0 deletions lib/graphql/connections/keyset/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

module GraphQL
module Connections
module Keyset
# Base class for keyset pagination implementations
class Base < ::GraphQL::Connections::Base
attr_reader :field_key

SEPARATOR = "/"

def initialize(*args, keys:, separator: SEPARATOR, **kwargs)
@field_key, @primary_key = keys
@separator = separator

super(*args, **kwargs)
end

def cursor_for(item)
cursor = [item[field_key], item[primary_key]].map { |value| serialize(value) }.join(@separator)
cursor = encode(cursor) if opaque_cursor
cursor
end

private

def sliced_relation
items
.yield_self { |s| after ? sliced_relation_after(s) : s }
.yield_self { |s| before ? sliced_relation_before(s) : s }
end

def after_cursor_date
@after_cursor_date ||= after_cursor.first
end

def after_cursor_primary_key
@after_cursor_primary_key ||= after_cursor.last
end

def after_cursor
@after_cursor ||= super.split(SEPARATOR)
end

def before_cursor_date
@before_cursor_date ||= before_cursor.first
end

def before_cursor_primary_key
@before_cursor_primary_key ||= before_cursor.last
end

def before_cursor
@before_cursor ||= super.split(SEPARATOR)
end
end
end
end
end
Loading

0 comments on commit baf2065

Please sign in to comment.