Class: GraphqlDeclarative::Cursor

Inherits:
Object
  • Object
show all
Defined in:
lib/graphql_declarative/cursor.rb

Overview

Opaque keyset cursors. Encode the tuple (sort_value, id) — never the offset, and never the sort value alone, or ties silently drop records.

Cursor.encode(sort_value: "Ruby 101", id: 42)
Cursor.decode("eyJzIjoiUnVieSAxMDEiLCJpZCI6NDJ9")

Seek predicate for ASC: (sort_col, id) > (sort_value, id) Emulate row-value comparison where the adapter lacks it:

sort_col > :s OR (sort_col = :s AND id > :id)

The payload is JSON {"v" => sort_value, "i" => id} in urlsafe, unpadded Base64. That encoding is an implementation detail: it is opaque to clients by contract and is not documented as stable.

Constant Summary collapse

KEY_VALUE =
"v"
KEY_ID =
"i"
TIME_PRECISION =

Fractional-second digits kept when a Time is serialised. A datetime that round-trips through to_s loses everything after the second, and two rows created in the same second then compare equal to the cursor: the seek created_at > :v drops the second one, or >= would repeat the first. Nine digits is more than any supported adapter stores.

9

Class Method Summary collapse

Class Method Details

.decode(str, type: nil) ⇒ Hash

Returns id:.

Parameters:

  • str (String)

    a cursor produced by .encode

  • type (ActiveModel::Type::Value, Symbol, nil) (defaults to: nil)

    the type of the sort column, used to cast the decoded value back. Pass model.type_for_attribute(sort_column) — without it a datetime comes back as the ISO8601 String it was encoded as, and comparing a String against a datetime column is adapter-dependent nonsense.

Returns:

  • (Hash)

    id:

Raises:

  • (Error)

    on anything malformed. A bad cursor is never silently treated as "start from the beginning": the client would be handed page 1 while believing it was on page 7, and would never notice.



52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/graphql_declarative/cursor.rb', line 52

def self.decode(str, type: nil)
  raise Error, "cursor is missing" if str.nil? || str.to_s.empty?

  payload = parse(str)
  unless payload.is_a?(Hash) && payload.key?(KEY_VALUE) && payload.key?(KEY_ID)
    raise Error, "malformed cursor: expected keys #{KEY_VALUE.inspect} and #{KEY_ID.inspect}"
  end

  id = payload[KEY_ID]
  raise Error, "malformed cursor: missing id" if id.nil?

  {sort_value: cast(payload[KEY_VALUE], type), id: id}
end

.encode(sort_value:, id:) ⇒ String

Returns urlsafe, unpadded Base64.

Parameters:

  • sort_value (Object)

    the value of the sort column for this row

  • id (Object)

    the row's primary key

Returns:

  • (String)

    urlsafe, unpadded Base64



37
38
39
40
# File 'lib/graphql_declarative/cursor.rb', line 37

def self.encode(sort_value:, id:)
  payload = {KEY_VALUE => serialize(sort_value), KEY_ID => id}
  Base64.urlsafe_encode64(JSON.generate(payload), padding: false)
end

.seek(scope, column:, direction:, sort_value:, id:) ⇒ ActiveRecord::Relation

Builds the seek predicate. Portable form, because SQLite's row-value support is version-dependent and MySQL's optimiser treats row-value comparisons differently again:

ASC:   sort_col > :v OR (sort_col = :v AND id > :i)
DESC:  sort_col < :v OR (sort_col = :v AND id < :i)

Both halves flip together for DESC — the tiebreaker has to run the same way as the sort column or the tied rows come back in the wrong order and the page walks backwards through them.

Parameters:

  • scope (ActiveRecord::Relation, Class)
  • column (Symbol, String)

    sort column, from the sortable_by whitelist only — never from user input (SPEC.md §7).

  • direction (Symbol)

    :asc or :desc

  • sort_value (Object)

    value half of the cursor

  • id (Object)

    id half of the cursor

Returns:

  • (ActiveRecord::Relation)


84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/graphql_declarative/cursor.rb', line 84

def self.seek(scope, column:, direction:, sort_value:, id:)
  direction = direction.to_s.downcase.to_sym
  unless %i[asc desc].include?(direction)
    raise Error, "seek direction must be :asc or :desc, got #{direction.inspect}"
  end

  model = scope.respond_to?(:klass) ? scope.klass : scope
  column = column.to_sym
  unless model.column_names.include?(column.to_s)
    raise Error, "cannot seek on #{column.inspect}: #{model.name} has no such column"
  end

  if sort_value.nil?
    # NULL never compares true, so every row after this one would be lost.
    # v0.1.0 requires sortable columns to be NOT NULL (SPEC.md §6.4); this
    # is where that requirement stops being silent.
    raise Error,
      "cannot seek on a NULL #{column} value: keyset pagination requires the sort column " \
      "to be NOT NULL in v0.1.0 (a NULL never compares true, so the remaining rows vanish)."
  end

  table = model.arel_table
  sort_col = table[column]
  id_col = table[model.primary_key]

  value_bind = bind(model, column, sort_value)
  id_bind = bind(model, model.primary_key, id)

  tie = ->(node) { Arel::Nodes::Grouping.new(sort_col.eq(value_bind).and(node)) }

  predicate =
    if direction == :asc
      sort_col.gt(value_bind).or(tie.call(id_col.gt(id_bind)))
    else
      sort_col.lt(value_bind).or(tie.call(id_col.lt(id_bind)))
    end

  scope.where(predicate)
end