Class: GraphqlDeclarative::Resolver

Inherits:
GraphQL::Schema::Resolver
  • Object
show all
Defined in:
lib/graphql_declarative/resolver.rb

Overview

The public surface. A resolver declares; it does not define resolve.

class Resolvers::Courses < GraphqlDeclarative::Resolver
type Types::Course.connection_type, null: false

filterable_by Types::CourseFilter
sortable_by   :title, :created_at
paginate      cursor: :id, default_page_size: 25, max_page_size: 100

preload author: :profile
preload_from_selection

def base_scope
  Course.where(school_id: context[:school_id])
end
end

resolve is provided: base_scope -> Filter -> Sort -> Preloader -> page. Order matters. Filter before sort; preload last, after the page is bounded, or you preload the whole table.

Defined Under Namespace

Classes: SortDirection

Constant Summary collapse

DEFAULT_PAGE_SIZE =

Defaults for paginate, matching SPEC.md 6.5/6.7.

25
MAX_PAGE_SIZE =
100

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.cursor_columnSymbol?

Returns the paginate cursor: column, if one was given.

Returns:

  • (Symbol, nil)

    the paginate cursor: column, if one was given.



132
133
134
135
136
137
# File 'lib/graphql_declarative/resolver.rb', line 132

def cursor_column
  own = defined?(@own_cursor_column) ? @own_cursor_column : nil
  return own if own

  superclass.respond_to?(:cursor_column) ? superclass.cursor_column : nil
end

.filter_classClass?

Returns the declared filter input, inherited if the subclass did not declare its own.

Returns:

  • (Class, nil)

    the declared filter input, inherited if the subclass did not declare its own.



65
66
67
68
69
70
# File 'lib/graphql_declarative/resolver.rb', line 65

def filter_class
  own = defined?(@own_filter_class) ? @own_filter_class : nil
  return own if own

  superclass.respond_to?(:filter_class) ? superclass.filter_class : nil
end

.filterable_by(filter_class) ⇒ Object

Adds the filter: argument of the given input type. The class itself is remembered because Filter.apply reads definitions back off it at request time — that registry is the only source of column and association identifiers in the SQL layer (SPEC.md 7).



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

def filterable_by(filter_class)
  unless filter_class.is_a?(Class) && filter_class < GraphQL::Schema::InputObject
    raise Error,
      "filterable_by expects a GraphqlDeclarative::FilterInput subclass, got #{filter_class.inspect}"
  end

  @own_filter_class = filter_class
  argument :filter, filter_class, required: false
  filter_class
end

.own_static_preloadsObject



153
154
155
# File 'lib/graphql_declarative/resolver.rb', line 153

def own_static_preloads
  @own_static_preloads ||= []
end

.paginate(cursor: nil, default_page_size: DEFAULT_PAGE_SIZE, max_page_size: MAX_PAGE_SIZE) ⇒ Object

Adds first: and after:, and records the page-size policy.

default_page_size/max_page_size are set through graphql-ruby's own resolver DSL so the field reports them in introspection too; the connection reads them back from there.

cursor: is optional and names the column to sort (and therefore issue cursors) by when no sortable_by is declared. SPEC.md 6.7 lists paginate(default_page_size:, max_page_size:); cursor: is accepted because the documented example in this file's header passes it.



112
113
114
115
116
117
118
119
120
121
122
# File 'lib/graphql_declarative/resolver.rb', line 112

def paginate(cursor: nil, default_page_size: DEFAULT_PAGE_SIZE, max_page_size: MAX_PAGE_SIZE)
  @own_paginate = true
  @own_cursor_column = cursor&.to_sym

  self.default_page_size(Integer(default_page_size))
  self.max_page_size(Integer(max_page_size))

  argument :first, GraphQL::Types::Int, required: false
  argument :after, GraphQL::Types::String, required: false
  true
end

.paginate?Boolean

Returns whether paginate was declared here or by an ancestor.

Returns:

  • (Boolean)

    whether paginate was declared here or by an ancestor.



125
126
127
128
129
# File 'lib/graphql_declarative/resolver.rb', line 125

def paginate?
  return true if defined?(@own_paginate) && @own_paginate

  superclass.respond_to?(:paginate?) ? superclass.paginate? : false
end

.preload(*args) ⇒ Object

Preloads that are applied on every request regardless of the query. Accepts anything .preload accepts: :author, [:a, :b], {author: :profile}.



142
143
144
145
# File 'lib/graphql_declarative/resolver.rb', line 142

def preload(*args)
  own_static_preloads.concat(args)
  own_static_preloads
end

.preload_from_selection(max_depth: Preloader::DEFAULT_MAX_DEPTH) ⇒ Object

Derive the rest of the preload set from what the query actually selects, so adding a field to a query cannot reintroduce an N+1 and no hand-kept .includes list can drift.

This needs the lookahead, which graphql-ruby only passes when the field asks for it, hence the extras registration.



163
164
165
166
167
168
169
170
# File 'lib/graphql_declarative/resolver.rb', line 163

def preload_from_selection(max_depth: Preloader::DEFAULT_MAX_DEPTH)
  @own_preload_from_selection = true
  @own_preload_max_depth = Integer(max_depth)

  current = extras
  extras(current + [:lookahead]) unless current.include?(:lookahead)
  true
end

.preload_from_selection?Boolean

Returns:

  • (Boolean)


172
173
174
175
176
# File 'lib/graphql_declarative/resolver.rb', line 172

def preload_from_selection?
  return true if defined?(@own_preload_from_selection) && @own_preload_from_selection

  superclass.respond_to?(:preload_from_selection?) ? superclass.preload_from_selection? : false
end

.preload_max_depthObject



178
179
180
181
182
183
184
185
186
187
# File 'lib/graphql_declarative/resolver.rb', line 178

def preload_max_depth
  own = defined?(@own_preload_max_depth) ? @own_preload_max_depth : nil
  return own if own

  if superclass.respond_to?(:preload_max_depth)
    superclass.preload_max_depth
  else
    Preloader::DEFAULT_MAX_DEPTH
  end
end

.sortable_by(*fields) ⇒ Object

Adds sort_by: (an enum of exactly these fields) and sort_direction:.

The whitelist is the whole point: sort_by reaches ActiveRecord only after Sort has matched it against this list and swapped in the list's own canonical name. A column name never travels from the client into SQL (SPEC.md 7).

Raises:



78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/graphql_declarative/resolver.rb', line 78

def sortable_by(*fields)
  fields = fields.flatten.compact.map(&:to_sym)
  raise Error, "sortable_by requires at least one field" if fields.empty?

  # Union with the inherited list rather than replacing it: a subclass
  # that adds one sortable field should not silently drop its parent's.
  fields = (inherited_sortable_fields + fields).uniq
  @own_sortable_fields = fields.freeze

  argument :sort_by, build_sort_by_enum(fields), required: false
  argument :sort_direction, SortDirection, required: false, default_value: :asc
  fields
end

.sortable_fieldsArray<Symbol>

Returns the sortable whitelist, empty when undeclared. Empty means "sort by :id ascending" (SPEC.md 6.7); Sort treats :id as implicitly sortable, so an empty whitelist is not a broken resolver.

Returns:

  • (Array<Symbol>)

    the sortable whitelist, empty when undeclared. Empty means "sort by :id ascending" (SPEC.md 6.7); Sort treats :id as implicitly sortable, so an empty whitelist is not a broken resolver.



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

def sortable_fields
  own = defined?(@own_sortable_fields) ? @own_sortable_fields : nil
  return own if own

  inherited_sortable_fields
end

.static_preloadsArray

Returns declared preloads, ancestors first.

Returns:

  • (Array)

    declared preloads, ancestors first.



148
149
150
151
# File 'lib/graphql_declarative/resolver.rb', line 148

def static_preloads
  inherited = superclass.respond_to?(:static_preloads) ? superclass.static_preloads : []
  inherited + own_static_preloads
end

Instance Method Details

#base_scopeObject

Subclasses implement this and nothing else. Multi-tenancy scoping lives here; the gem never adds or removes conditions of its own (SPEC.md 7).

Raises:

  • (NotImplementedError)


275
276
277
# File 'lib/graphql_declarative/resolver.rb', line 275

def base_scope
  raise NotImplementedError, "#{self.class} must define #base_scope"
end

#resolve(**args) ⇒ Object

SPEC.md 5, in order. The order is load-bearing:

base_scope
-> Filter.apply        association filters become id subqueries, so
                       the paginated relation is never joined
-> Sort.apply          whitelisted column + :id tiebreaker
-> Cursor.seek         AFTER Sort, because the seek predicate is built
                       from the sort column
-> LIMIT page_size + 1 (inside KeysetConnection; the +1 is how
                       has_next_page is known without a COUNT)
-> Preloader           (inside KeysetConnection, on the bounded page)
-> KeysetConnection

The last two steps are handed to KeysetConnection rather than done here on purpose: page_size clamping decides the LIMIT, and preloading must happen strictly after that LIMIT. Doing it here would mean computing the page size twice and getting invariant 1 wrong the second time.



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/graphql_declarative/resolver.rb', line 236

def resolve(**args)
  scope = validated_base_scope
  model = scope.klass

  begin
    reject_backward_pagination!(args)

    scope = Filter.apply(scope, self.class.filter_class, args[:filter])

    direction = normalize_direction(args[:sort_direction])
    allowed, requested = sort_request(args)
    column = Sort.column_for(scope, allowed: allowed, field: requested)
    scope = Sort.apply(scope, allowed: allowed, field: requested, direction: direction)

    after = pagination_argument(args, :after)
    scope = apply_seek(scope, model, column, direction, after)

    KeysetConnection.new(
      scope,
      sort_column: column,
      sort_direction: direction,
      preloader: preloader_for(args, model),
      first: pagination_argument(args, :first),
      after: after,
      context: context,
      parent: object,
      field: field,
      **page_size_options
    )
  rescue Error => e
    # SPEC.md 7: a bad cursor, an unsortable field or an unknown filter key
    # is the client's mistake, not a server fault. It surfaces as a GraphQL
    # error rather than a 500.
    raise GraphQL::ExecutionError, e.message
  end
end