Class: GraphqlDeclarative::Sort

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

Overview

Whitelisted ordering. Never interpolate a client-supplied column name. Always append the model's primary key as the final tiebreaker so ordering is total — a non-total order makes keyset pagination non-deterministic.

Sort.apply(Course.all, allowed: [:title, :created_at], field: "title", direction: :desc)
# => ORDER BY "courses"."title" DESC, "courses"."id" DESC

Two rows with the same title would otherwise come back in whatever order the adapter felt like this time. A cursor built from (title, id) cannot tell them apart if the primary key is not part of the ORDER BY, so page 2 either repeats or skips the tied rows. The tiebreaker goes in the same direction as the sort column, because Cursor.seek compares both halves in that direction.

The tiebreaker is always model.primary_key, not a hardcoded :id. Sort and Cursor derive it from the same source (ActiveRecord::Base#primary_key) for exactly this reason: if Sort ordered by one column and Cursor seeked on another, the total order the keyset depends on would not exist. A model whose primary key is not "id" (e.g. a uuid column) would then get an ORDER BY and a WHERE clause built from different columns — on Postgres/MySQL a hard UndefinedColumn error, on SQLite a silent, wrong fallback to rowid.

Identifier safety (SPEC.md §7): field is only ever used after it has been matched against allowed — the value that reaches ActiveRecord is the canonical name from the whitelist, never the caller's string. Ordering is expressed as a Hash (order(title: :asc)) so ActiveRecord quotes the identifier itself; there is no Arel.sql on an interpolated string here.

Constant Summary collapse

DIRECTIONS =
%i[asc desc].freeze

Class Method Summary collapse

Class Method Details

.apply(scope, allowed:, field: nil, direction: :asc) ⇒ ActiveRecord::Relation

Parameters:

  • scope (ActiveRecord::Relation, Class)

    relation (or model) to order

  • allowed (Array<Symbol, String, #name>)

    the sortable_by whitelist. Entries may be plain names or FilterInput::Definition structs; a definition carrying through: is rejected (see below).

  • field (Symbol, String, nil) (defaults to: nil)

    requested sort field. nil means "no sort declared" and orders by the primary key, which SPEC.md §6.7 makes the default.

  • direction (Symbol, String) (defaults to: :asc)

    :asc or :desc

Returns:

  • (ActiveRecord::Relation)


43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/graphql_declarative/sort.rb', line 43

def self.apply(scope, allowed:, field: nil, direction: :asc)
  direction = normalize_direction(direction)
  model = model_for(scope)
  tiebreaker = primary_key_for(model)
  field = resolve_field(model, allowed, field, tiebreaker)

  # reorder, not order: the declared sort is authoritative. Appending to a
  # default_scope's ORDER BY would leave some other column as the leading
  # term, and the seek predicate is built from *our* leading term.
  return scope.reorder(tiebreaker => direction) if field == tiebreaker

  scope.reorder(field => direction).order(tiebreaker => direction)
end

.column_for(scope, allowed:, field: nil) ⇒ Symbol

Returns the column that was actually ordered by — the caller needs it to build cursors, and it may differ from field (nil => the model's primary key).

Returns:

  • (Symbol)

    the column that was actually ordered by — the caller needs it to build cursors, and it may differ from field (nil => the model's primary key).



60
61
62
63
# File 'lib/graphql_declarative/sort.rb', line 60

def self.column_for(scope, allowed:, field: nil)
  model = model_for(scope)
  resolve_field(model, allowed, field, primary_key_for(model))
end