Class: GraphqlDeclarative::Sort
- Inherits:
-
Object
- Object
- GraphqlDeclarative::Sort
- 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
- .apply(scope, allowed:, field: nil, direction: :asc) ⇒ ActiveRecord::Relation
-
.column_for(scope, allowed:, field: nil) ⇒ 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).
Class Method Details
.apply(scope, allowed:, field: nil, direction: :asc) ⇒ 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).
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 |