Class: GraphqlDeclarative::Cursor
- Inherits:
-
Object
- Object
- GraphqlDeclarative::Cursor
- 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_sloses everything after the second, and two rows created in the same second then compare equal to the cursor: the seekcreated_at > :vdrops the second one, or>=would repeat the first. Nine digits is more than any supported adapter stores. 9
Class Method Summary collapse
-
.decode(str, type: nil) ⇒ Hash
id:.
-
.encode(sort_value:, id:) ⇒ String
Urlsafe, unpadded Base64.
-
.seek(scope, column:, direction:, sort_value:, id:) ⇒ ActiveRecord::Relation
Builds the seek predicate.
Class Method Details
.decode(str, type: nil) ⇒ Hash
Returns id:.
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.
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.
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 |