Class: GraphqlDeclarative::KeysetConnection

Inherits:
GraphQL::Pagination::Connection
  • Object
show all
Defined in:
lib/graphql_declarative/keyset_connection.rb

Overview

Returned directly from Resolver#resolve — graphql-ruby uses a Connection instance as-is instead of re-wrapping it.

Unlike GraphQL::Pagination::RelationConnection, whose #cursor_for encodes an OFFSET (relation_connection.rb:47), this encodes the key tuple (sort_value, id). Offset cursors shift whenever a row is inserted or deleted before the current page, so pages repeat or skip records. See SPEC.md §6.5 and spec/stability_spec.rb.

has_next_page comes from fetching page_size + 1 rows, never from COUNT. has_previous_page is always false in v0.1.0 (forward-only). Documented, not hidden.

Construction (see SPEC.md §5 for where this sits in the pipeline):

KeysetConnection.new(
scope,                       # filtered + sorted + seeked, NOT limited
sort_column: :created_at,    # the column Sort ordered by; :id by default
sort_direction: :asc,        # recorded for callers; not used to query here
preloader: ->(records) { GraphqlDeclarative::Preloader... },
first: args[:first], after: args[:after], context: context,
default_page_size: 25, max_page_size: 100
)

The after: seek is applied by the caller before the relation gets here: the seek predicate belongs with Sort (SPEC.md §5, invariant 2). This class never re-applies it.

items may be either:

* an ActiveRecord::Relation — this class applies LIMIT page_size + 1,
loads it, trims to page_size, and only then runs `preloader`. That order
is invariant 1 in SPEC.md §5: preloading an unbounded relation preloads
the whole filtered set.
* an Array the caller already bounded to page_size + 1 rows (use
.page_size_for to compute the same limit) — it is trimmed here, and
`preloader` is still applied if given.

Constant Summary collapse

DEFAULT_PAGE_SIZE =

Fallbacks used only when neither an explicit override nor a schema-level setting is available (e.g. a connection built outside a query).

25
MAX_PAGE_SIZE =
100

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(items, sort_column: :id, sort_direction: :asc, preloader: nil, **kwargs) ⇒ KeysetConnection

Returns a new instance of KeysetConnection.



53
54
55
56
57
58
# File 'lib/graphql_declarative/keyset_connection.rb', line 53

def initialize(items, sort_column: :id, sort_direction: :asc, preloader: nil, **kwargs)
  @sort_column = sort_column.to_sym
  @sort_direction = sort_direction.to_sym
  @preloader = preloader
  super(items, **kwargs)
end

Instance Attribute Details

#sort_columnSymbol (readonly)

Returns the column the relation is ordered by; also the value half of every cursor this connection issues.

Returns:

  • (Symbol)

    the column the relation is ordered by; also the value half of every cursor this connection issues.



47
48
49
# File 'lib/graphql_declarative/keyset_connection.rb', line 47

def sort_column
  @sort_column
end

#sort_directionSymbol (readonly)

Returns :asc or :desc — the direction the relation was sorted in. Carried so a caller can round-trip it; the seek itself happens upstream.

Returns:

  • (Symbol)

    :asc or :desc — the direction the relation was sorted in. Carried so a caller can round-trip it; the seek itself happens upstream.



51
52
53
# File 'lib/graphql_declarative/keyset_connection.rb', line 51

def sort_direction
  @sort_direction
end

Class Method Details

.page_size_for(first: nil, default_page_size: nil, max_page_size: nil) ⇒ Object

The limit a caller must use if it wants to fetch the page itself: page_size + 1 rows, where the +1 is what makes has_next_page free.

first: 0 returns an empty page (Relay-conformant); only a negative first: is an error. The subtlety this class must not repeat: clamping a negative to 0 would let load_page fetch 1 row, report has_next_page: true off it, and trim nodes to [] — a page with no rows and no cursor, since endCursor is nil when nodes is empty. graphql-ruby's own RelationConnection avoids the whole question because Connection#first clamps through limit_pagination_argument; this class bypasses that by reading first_value (the raw, unclamped value) directly, so it validates the bound itself.



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/graphql_declarative/keyset_connection.rb', line 72

def self.page_size_for(first: nil, default_page_size: nil, max_page_size: nil)
  # The Relay Cursor Connections spec treats first: 0 as valid — an empty
  # page — and only a negative value as an error, so this returns 0 rather
  # than raising. Note what that means: load_page fetches page_size + 1 == 1
  # row, so hasNextPage is true whenever any row matches, with no endCursor
  # to advance from. That is Relay-conformant, and a client that loops on
  # hasNextPage while asking for first: 0 will not progress — but that is
  # the client asking for no rows, not the connection misreporting.
  if first&.negative?
    raise GraphQL::ExecutionError,
      "first: must not be negative (got #{first.inspect}). Omit first: to use the default " \
      "page size."
  end
  return 0 if first == 0

  requested = first || default_page_size || DEFAULT_PAGE_SIZE
  max = max_page_size || MAX_PAGE_SIZE
  [requested, max].min
end

Instance Method Details

#cursor_for(item) ⇒ Object

The whole point of the class. RelationConnection encodes an offset here; this encodes the key tuple, so the cursor keeps meaning the same row even when rows are inserted or deleted before the current page.



124
125
126
# File 'lib/graphql_declarative/keyset_connection.rb', line 124

def cursor_for(item)
  Cursor.encode(sort_value: sort_value_for(item), id: item.id)
end

#has_next_pageObject

True iff the LIMIT page_size + 1 query came back with the extra row. No COUNT — an unbounded count on a filtered set is the thing this avoids.



109
110
111
112
# File 'lib/graphql_declarative/keyset_connection.rb', line 109

def has_next_page # rubocop:disable Naming/PredicateName
  load_page
  @has_next_page
end

#has_previous_pageObject

Forward-only in v0.1.0 (SPEC.md §3). Stated, not hidden: a client that walks forward never needs it, and honouring it would double the cursor logic for last:/before:.



117
118
119
# File 'lib/graphql_declarative/keyset_connection.rb', line 117

def has_previous_page # rubocop:disable Naming/PredicateName
  false
end

#nodesObject



102
103
104
105
# File 'lib/graphql_declarative/keyset_connection.rb', line 102

def nodes
  load_page
  @nodes
end

#page_sizeObject

[first || default_page_size, max_page_size].min. A first: above max_page_size is clamped, not an error (SPEC.md §6.5).



94
95
96
97
98
99
100
# File 'lib/graphql_declarative/keyset_connection.rb', line 94

def page_size
  @page_size ||= self.class.page_size_for(
    first: first_value,
    default_page_size: configured_default_page_size,
    max_page_size: configured_max_page_size
  )
end