graphql_declarative 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +225 -0
- data/Rakefile +10 -0
- data/SPEC.md +336 -0
- data/bench/query_count.rb +77 -0
- data/gemfiles/ar_7.0.gemfile +10 -0
- data/gemfiles/ar_7.1.gemfile +7 -0
- data/gemfiles/ar_7.2.gemfile +7 -0
- data/gemfiles/ar_8.0.gemfile +7 -0
- data/lib/graphql_declarative/cursor.rb +174 -0
- data/lib/graphql_declarative/filter.rb +151 -0
- data/lib/graphql_declarative/filter_input.rb +120 -0
- data/lib/graphql_declarative/keyset_connection.rb +182 -0
- data/lib/graphql_declarative/preloader.rb +158 -0
- data/lib/graphql_declarative/resolver.rb +426 -0
- data/lib/graphql_declarative/sort.rb +196 -0
- data/lib/graphql_declarative/version.rb +5 -0
- data/lib/graphql_declarative.rb +16 -0
- data/sig/graphql_declarative.rbs +4 -0
- metadata +116 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphqlDeclarative
|
|
4
|
+
# Builds the preload list from the query's actual selection set, so adding a
|
|
5
|
+
# field to a query never reintroduces an N+1 and no `.includes` list has to be
|
|
6
|
+
# kept in sync by hand.
|
|
7
|
+
#
|
|
8
|
+
# Walk `lookahead` for selections whose field maps to an association on the
|
|
9
|
+
# model, recursing to build a nested preload hash:
|
|
10
|
+
# {author: [:profile], enrollments: []}
|
|
11
|
+
#
|
|
12
|
+
# Ignore selections that are plain columns, and connection wrapper fields
|
|
13
|
+
# (edges/node) must be unwrapped before matching.
|
|
14
|
+
class Preloader
|
|
15
|
+
# Connection plumbing. These are structural, not associations: the entity
|
|
16
|
+
# selections of `courses { edges { node { author { ... } } } }` live two
|
|
17
|
+
# levels below `courses`. Unwrap them BEFORE matching names against
|
|
18
|
+
# associations, otherwise every connection field looks like a leaf column
|
|
19
|
+
# and nothing is ever preloaded.
|
|
20
|
+
#
|
|
21
|
+
# An association is checked for first, so a model that genuinely has an
|
|
22
|
+
# association called `nodes` still wins over the unwrap rule.
|
|
23
|
+
CONNECTION_FIELDS = %i[edges node nodes].freeze
|
|
24
|
+
|
|
25
|
+
# A deeply nested query would otherwise let a client dictate the size of the
|
|
26
|
+
# preload tree: `a { b { c { d { ... } } } }` is cheap to write and
|
|
27
|
+
# expensive to serve. Three levels of associations covers the real cases;
|
|
28
|
+
# anything below that is simply not preloaded (it still resolves, just
|
|
29
|
+
# lazily).
|
|
30
|
+
DEFAULT_MAX_DEPTH = 3
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# @param lookahead [GraphQL::Execution::Lookahead] the field's lookahead
|
|
34
|
+
# @param model [Class] the ActiveRecord model the scope selects
|
|
35
|
+
# @param max_depth [Integer] association nesting levels to descend
|
|
36
|
+
# @param static [Symbol, Array, Hash, nil] declared preloads to merge in
|
|
37
|
+
# @return [Hash] suitable for `.preload`, e.g. {author: [:profile]}
|
|
38
|
+
def from_lookahead(lookahead, model, max_depth: DEFAULT_MAX_DEPTH, static: nil)
|
|
39
|
+
derived = if lookahead.respond_to?(:selections) && model.respond_to?(:reflect_on_association)
|
|
40
|
+
walk(lookahead, model, Integer(max_depth))
|
|
41
|
+
else
|
|
42
|
+
{}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
denormalize(deep_merge(normalize(static), derived))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Merge declared (`preload author: :profile`) preloads with derived ones.
|
|
49
|
+
#
|
|
50
|
+
# Both sides are normalized to a canonical tree and unioned, so a key
|
|
51
|
+
# present in both keeps everything the static declaration asked for and
|
|
52
|
+
# gains whatever the query additionally selected underneath it. Static is
|
|
53
|
+
# authoritative in the sense that nothing it declares can be dropped or
|
|
54
|
+
# flattened by the derived tree -- which is the only "conflict" that can
|
|
55
|
+
# arise between two preload trees. Preloading a little extra is harmless;
|
|
56
|
+
# preloading too little is an N+1.
|
|
57
|
+
#
|
|
58
|
+
# @return [Hash] suitable for `.preload`
|
|
59
|
+
def merge(static, derived)
|
|
60
|
+
denormalize(deep_merge(normalize(static), normalize(derived)))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Recursive descent. `depth` counts association hops still allowed, so
|
|
66
|
+
# unwrapping edges/node does not consume budget -- only real associations
|
|
67
|
+
# do.
|
|
68
|
+
def walk(lookahead, model, depth)
|
|
69
|
+
return {} if depth <= 0
|
|
70
|
+
|
|
71
|
+
association_selections(lookahead, model).each_with_object({}) do |selection, tree|
|
|
72
|
+
name = selection.name
|
|
73
|
+
child_model = association_model(model.reflect_on_association(name))
|
|
74
|
+
nested = child_model ? walk(selection, child_model, depth - 1) : {}
|
|
75
|
+
|
|
76
|
+
# The same association can appear more than once under GraphQL
|
|
77
|
+
# aliases (`a: author { name } b: author { profile }`); union the
|
|
78
|
+
# subtrees rather than letting the last one win.
|
|
79
|
+
tree[name] = deep_merge(tree[name] || {}, nested)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Selections on `lookahead` that name an association on `model`, with
|
|
84
|
+
# connection wrappers transparently descended through.
|
|
85
|
+
def association_selections(lookahead, model)
|
|
86
|
+
lookahead.selections.flat_map do |selection|
|
|
87
|
+
name = selection.name
|
|
88
|
+
|
|
89
|
+
if name.nil?
|
|
90
|
+
[]
|
|
91
|
+
elsif model.reflect_on_association(name)
|
|
92
|
+
[selection]
|
|
93
|
+
elsif CONNECTION_FIELDS.include?(name)
|
|
94
|
+
association_selections(selection, model)
|
|
95
|
+
else
|
|
96
|
+
# A plain column, `cursor`, `pageInfo`, `__typename`: nothing to
|
|
97
|
+
# preload.
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The model on the far side of an association, or nil when it cannot be
|
|
104
|
+
# resolved. Polymorphic associations have no single target class, and are
|
|
105
|
+
# a non-goal for v0.1.0 -- the association itself is still preloaded, we
|
|
106
|
+
# just do not descend into it.
|
|
107
|
+
def association_model(reflection)
|
|
108
|
+
return nil if reflection.nil?
|
|
109
|
+
return nil if reflection.polymorphic?
|
|
110
|
+
|
|
111
|
+
reflection.klass
|
|
112
|
+
rescue NameError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# --- canonical tree helpers -------------------------------------------
|
|
117
|
+
#
|
|
118
|
+
# Internally a preload tree is Hash{Symbol => Hash}, recursively, with an
|
|
119
|
+
# empty hash meaning "leaf". That form merges without special cases. The
|
|
120
|
+
# public methods denormalize it back to the `.preload` shape the spec
|
|
121
|
+
# documents: {author: [:profile], enrollments: []}.
|
|
122
|
+
|
|
123
|
+
def normalize(spec)
|
|
124
|
+
case spec
|
|
125
|
+
when nil then {}
|
|
126
|
+
when Symbol then {spec => {}}
|
|
127
|
+
when String then {spec.to_sym => {}}
|
|
128
|
+
when Array then spec.each_with_object({}) { |part, tree| deep_merge!(tree, normalize(part)) }
|
|
129
|
+
when Hash
|
|
130
|
+
spec.each_with_object({}) do |(key, value), tree|
|
|
131
|
+
deep_merge!(tree, {key.to_sym => normalize(value)})
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
raise Error, "cannot interpret #{spec.inspect} as a preload declaration"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def deep_merge(left, right)
|
|
139
|
+
deep_merge!(left.dup, right)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def deep_merge!(left, right)
|
|
143
|
+
right.each do |key, subtree|
|
|
144
|
+
left[key] = left.key?(key) ? deep_merge(left[key], subtree) : subtree
|
|
145
|
+
end
|
|
146
|
+
left
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def denormalize(tree)
|
|
150
|
+
tree.transform_values { |subtree| entries(subtree) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def entries(tree)
|
|
154
|
+
tree.map { |key, subtree| subtree.empty? ? key : {key => entries(subtree)} }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphqlDeclarative
|
|
4
|
+
# The public surface. A resolver declares; it does not define `resolve`.
|
|
5
|
+
#
|
|
6
|
+
# class Resolvers::Courses < GraphqlDeclarative::Resolver
|
|
7
|
+
# type Types::Course.connection_type, null: false
|
|
8
|
+
#
|
|
9
|
+
# filterable_by Types::CourseFilter
|
|
10
|
+
# sortable_by :title, :created_at
|
|
11
|
+
# paginate cursor: :id, default_page_size: 25, max_page_size: 100
|
|
12
|
+
#
|
|
13
|
+
# preload author: :profile
|
|
14
|
+
# preload_from_selection
|
|
15
|
+
#
|
|
16
|
+
# def base_scope
|
|
17
|
+
# Course.where(school_id: context[:school_id])
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# `resolve` is provided: base_scope -> Filter -> Sort -> Preloader -> page.
|
|
22
|
+
# Order matters. Filter before sort; preload last, after the page is bounded,
|
|
23
|
+
# or you preload the whole table.
|
|
24
|
+
class Resolver < GraphQL::Schema::Resolver
|
|
25
|
+
# Defaults for `paginate`, matching SPEC.md 6.5/6.7.
|
|
26
|
+
DEFAULT_PAGE_SIZE = 25
|
|
27
|
+
MAX_PAGE_SIZE = 100
|
|
28
|
+
|
|
29
|
+
# One shared enum class, not one per resolver: it is the same two values
|
|
30
|
+
# everywhere, and reusing the class means one `SortDirection` type in the
|
|
31
|
+
# schema instead of one per list endpoint.
|
|
32
|
+
class SortDirection < GraphQL::Schema::Enum
|
|
33
|
+
graphql_name "SortDirection"
|
|
34
|
+
description "Ordering direction for a sorted list."
|
|
35
|
+
|
|
36
|
+
value "ASC", "Ascending order.", value: :asc
|
|
37
|
+
value "DESC", "Descending order.", value: :desc
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# --- declarations ---------------------------------------------------
|
|
42
|
+
#
|
|
43
|
+
# Every reader below walks `superclass` instead of copying state into the
|
|
44
|
+
# subclass at `inherited` time. Same rule as FilterInput (SPEC.md 6.1):
|
|
45
|
+
# a subclass sees its parent's declarations, never holds the parent's
|
|
46
|
+
# objects, and cannot mutate them by accident.
|
|
47
|
+
|
|
48
|
+
# Adds the `filter:` argument of the given input type. The class itself is
|
|
49
|
+
# remembered because `Filter.apply` reads `definitions` back off it at
|
|
50
|
+
# request time — that registry is the only source of column and
|
|
51
|
+
# association identifiers in the SQL layer (SPEC.md 7).
|
|
52
|
+
def filterable_by(filter_class)
|
|
53
|
+
unless filter_class.is_a?(Class) && filter_class < GraphQL::Schema::InputObject
|
|
54
|
+
raise Error,
|
|
55
|
+
"filterable_by expects a GraphqlDeclarative::FilterInput subclass, got #{filter_class.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@own_filter_class = filter_class
|
|
59
|
+
argument :filter, filter_class, required: false
|
|
60
|
+
filter_class
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [Class, nil] the declared filter input, inherited if the
|
|
64
|
+
# subclass did not declare its own.
|
|
65
|
+
def filter_class
|
|
66
|
+
own = defined?(@own_filter_class) ? @own_filter_class : nil
|
|
67
|
+
return own if own
|
|
68
|
+
|
|
69
|
+
superclass.respond_to?(:filter_class) ? superclass.filter_class : nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Adds `sort_by:` (an enum of exactly these fields) and `sort_direction:`.
|
|
73
|
+
#
|
|
74
|
+
# The whitelist is the whole point: `sort_by` reaches ActiveRecord only
|
|
75
|
+
# after Sort has matched it against this list and swapped in the list's
|
|
76
|
+
# own canonical name. A column name never travels from the client into
|
|
77
|
+
# SQL (SPEC.md 7).
|
|
78
|
+
def sortable_by(*fields)
|
|
79
|
+
fields = fields.flatten.compact.map(&:to_sym)
|
|
80
|
+
raise Error, "sortable_by requires at least one field" if fields.empty?
|
|
81
|
+
|
|
82
|
+
# Union with the inherited list rather than replacing it: a subclass
|
|
83
|
+
# that adds one sortable field should not silently drop its parent's.
|
|
84
|
+
fields = (inherited_sortable_fields + fields).uniq
|
|
85
|
+
@own_sortable_fields = fields.freeze
|
|
86
|
+
|
|
87
|
+
argument :sort_by, build_sort_by_enum(fields), required: false
|
|
88
|
+
argument :sort_direction, SortDirection, required: false, default_value: :asc
|
|
89
|
+
fields
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Array<Symbol>] the sortable whitelist, empty when undeclared.
|
|
93
|
+
# Empty means "sort by :id ascending" (SPEC.md 6.7); Sort treats :id as
|
|
94
|
+
# implicitly sortable, so an empty whitelist is not a broken resolver.
|
|
95
|
+
def sortable_fields
|
|
96
|
+
own = defined?(@own_sortable_fields) ? @own_sortable_fields : nil
|
|
97
|
+
return own if own
|
|
98
|
+
|
|
99
|
+
inherited_sortable_fields
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Adds `first:` and `after:`, and records the page-size policy.
|
|
103
|
+
#
|
|
104
|
+
# `default_page_size`/`max_page_size` are set through graphql-ruby's own
|
|
105
|
+
# resolver DSL so the field reports them in introspection too; the
|
|
106
|
+
# connection reads them back from there.
|
|
107
|
+
#
|
|
108
|
+
# `cursor:` is optional and names the column to sort (and therefore issue
|
|
109
|
+
# cursors) by when no `sortable_by` is declared. SPEC.md 6.7 lists
|
|
110
|
+
# `paginate(default_page_size:, max_page_size:)`; `cursor:` is accepted
|
|
111
|
+
# because the documented example in this file's header passes it.
|
|
112
|
+
def paginate(cursor: nil, default_page_size: DEFAULT_PAGE_SIZE, max_page_size: MAX_PAGE_SIZE)
|
|
113
|
+
@own_paginate = true
|
|
114
|
+
@own_cursor_column = cursor&.to_sym
|
|
115
|
+
|
|
116
|
+
self.default_page_size(Integer(default_page_size))
|
|
117
|
+
self.max_page_size(Integer(max_page_size))
|
|
118
|
+
|
|
119
|
+
argument :first, GraphQL::Types::Int, required: false
|
|
120
|
+
argument :after, GraphQL::Types::String, required: false
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Boolean] whether `paginate` was declared here or by an ancestor.
|
|
125
|
+
def paginate?
|
|
126
|
+
return true if defined?(@own_paginate) && @own_paginate
|
|
127
|
+
|
|
128
|
+
superclass.respond_to?(:paginate?) ? superclass.paginate? : false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Symbol, nil] the `paginate cursor:` column, if one was given.
|
|
132
|
+
def cursor_column
|
|
133
|
+
own = defined?(@own_cursor_column) ? @own_cursor_column : nil
|
|
134
|
+
return own if own
|
|
135
|
+
|
|
136
|
+
superclass.respond_to?(:cursor_column) ? superclass.cursor_column : nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Preloads that are applied on every request regardless of the query.
|
|
140
|
+
# Accepts anything `.preload` accepts: `:author`, `[:a, :b]`,
|
|
141
|
+
# `{author: :profile}`.
|
|
142
|
+
def preload(*args)
|
|
143
|
+
own_static_preloads.concat(args)
|
|
144
|
+
own_static_preloads
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [Array] declared preloads, ancestors first.
|
|
148
|
+
def static_preloads
|
|
149
|
+
inherited = superclass.respond_to?(:static_preloads) ? superclass.static_preloads : []
|
|
150
|
+
inherited + own_static_preloads
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def own_static_preloads
|
|
154
|
+
@own_static_preloads ||= []
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Derive the rest of the preload set from what the query actually selects,
|
|
158
|
+
# so adding a field to a query cannot reintroduce an N+1 and no hand-kept
|
|
159
|
+
# `.includes` list can drift.
|
|
160
|
+
#
|
|
161
|
+
# This needs the lookahead, which graphql-ruby only passes when the field
|
|
162
|
+
# asks for it, hence the `extras` registration.
|
|
163
|
+
def preload_from_selection(max_depth: Preloader::DEFAULT_MAX_DEPTH)
|
|
164
|
+
@own_preload_from_selection = true
|
|
165
|
+
@own_preload_max_depth = Integer(max_depth)
|
|
166
|
+
|
|
167
|
+
current = extras
|
|
168
|
+
extras(current + [:lookahead]) unless current.include?(:lookahead)
|
|
169
|
+
true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def preload_from_selection?
|
|
173
|
+
return true if defined?(@own_preload_from_selection) && @own_preload_from_selection
|
|
174
|
+
|
|
175
|
+
superclass.respond_to?(:preload_from_selection?) ? superclass.preload_from_selection? : false
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def preload_max_depth
|
|
179
|
+
own = defined?(@own_preload_max_depth) ? @own_preload_max_depth : nil
|
|
180
|
+
return own if own
|
|
181
|
+
|
|
182
|
+
if superclass.respond_to?(:preload_max_depth)
|
|
183
|
+
superclass.preload_max_depth
|
|
184
|
+
else
|
|
185
|
+
Preloader::DEFAULT_MAX_DEPTH
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def inherited_sortable_fields
|
|
192
|
+
superclass.respond_to?(:sortable_fields) ? superclass.sortable_fields : []
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# One enum per resolver, named after it. Enum *values* are the whitelisted
|
|
196
|
+
# symbols themselves, so `args[:sort_by]` arrives as `:created_at` — the
|
|
197
|
+
# declaration's own symbol, never a client string.
|
|
198
|
+
def build_sort_by_enum(fields)
|
|
199
|
+
prefix = graphql_name_prefix
|
|
200
|
+
|
|
201
|
+
Class.new(GraphQL::Schema::Enum) do
|
|
202
|
+
graphql_name "#{prefix}SortBy"
|
|
203
|
+
description "Fields #{prefix} can be sorted by."
|
|
204
|
+
|
|
205
|
+
fields.each { |field_name| value(field_name.to_s.upcase, value: field_name) }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# GraphQL type names must be unique and must match /[_A-Za-z][_0-9A-Za-z]*/.
|
|
210
|
+
# Anonymous resolver classes (common in specs) get a stable synthetic name.
|
|
211
|
+
def graphql_name_prefix
|
|
212
|
+
base = name.to_s.gsub("::", "")
|
|
213
|
+
base.empty? ? "Anon#{object_id.abs.to_s(36)}" : base
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# --- execution ----------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
# SPEC.md 5, in order. The order is load-bearing:
|
|
220
|
+
#
|
|
221
|
+
# base_scope
|
|
222
|
+
# -> Filter.apply association filters become id subqueries, so
|
|
223
|
+
# the paginated relation is never joined
|
|
224
|
+
# -> Sort.apply whitelisted column + :id tiebreaker
|
|
225
|
+
# -> Cursor.seek AFTER Sort, because the seek predicate is built
|
|
226
|
+
# from the sort column
|
|
227
|
+
# -> LIMIT page_size + 1 (inside KeysetConnection; the +1 is how
|
|
228
|
+
# has_next_page is known without a COUNT)
|
|
229
|
+
# -> Preloader (inside KeysetConnection, on the bounded page)
|
|
230
|
+
# -> KeysetConnection
|
|
231
|
+
#
|
|
232
|
+
# The last two steps are handed to KeysetConnection rather than done here on
|
|
233
|
+
# purpose: page_size clamping decides the LIMIT, and preloading must happen
|
|
234
|
+
# strictly after that LIMIT. Doing it here would mean computing the page
|
|
235
|
+
# size twice and getting invariant 1 wrong the second time.
|
|
236
|
+
def resolve(**args)
|
|
237
|
+
scope = validated_base_scope
|
|
238
|
+
model = scope.klass
|
|
239
|
+
|
|
240
|
+
begin
|
|
241
|
+
reject_backward_pagination!(args)
|
|
242
|
+
|
|
243
|
+
scope = Filter.apply(scope, self.class.filter_class, args[:filter])
|
|
244
|
+
|
|
245
|
+
direction = normalize_direction(args[:sort_direction])
|
|
246
|
+
allowed, requested = sort_request(args)
|
|
247
|
+
column = Sort.column_for(scope, allowed: allowed, field: requested)
|
|
248
|
+
scope = Sort.apply(scope, allowed: allowed, field: requested, direction: direction)
|
|
249
|
+
|
|
250
|
+
after = pagination_argument(args, :after)
|
|
251
|
+
scope = apply_seek(scope, model, column, direction, after)
|
|
252
|
+
|
|
253
|
+
KeysetConnection.new(
|
|
254
|
+
scope,
|
|
255
|
+
sort_column: column,
|
|
256
|
+
sort_direction: direction,
|
|
257
|
+
preloader: preloader_for(args, model),
|
|
258
|
+
first: pagination_argument(args, :first),
|
|
259
|
+
after: after,
|
|
260
|
+
context: context,
|
|
261
|
+
parent: object,
|
|
262
|
+
field: field,
|
|
263
|
+
**page_size_options
|
|
264
|
+
)
|
|
265
|
+
rescue Error => e
|
|
266
|
+
# SPEC.md 7: a bad cursor, an unsortable field or an unknown filter key
|
|
267
|
+
# is the client's mistake, not a server fault. It surfaces as a GraphQL
|
|
268
|
+
# error rather than a 500.
|
|
269
|
+
raise GraphQL::ExecutionError, e.message
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Subclasses implement this and nothing else. Multi-tenancy scoping lives
|
|
274
|
+
# here; the gem never adds or removes conditions of its own (SPEC.md 7).
|
|
275
|
+
def base_scope
|
|
276
|
+
raise NotImplementedError, "#{self.class} must define #base_scope"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
def validated_base_scope
|
|
282
|
+
scope = base_scope
|
|
283
|
+
|
|
284
|
+
unless defined?(::ActiveRecord::Relation) && scope.is_a?(::ActiveRecord::Relation)
|
|
285
|
+
raise Error,
|
|
286
|
+
"#{self.class}#base_scope must return an ActiveRecord::Relation, got #{scope.class}. " \
|
|
287
|
+
"Every later stage (subquery filters, keyset seek, LIMIT, preload) is relation algebra; " \
|
|
288
|
+
"an Array has already been loaded and cannot be paginated in the database."
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
scope
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Resolves the whitelist and the requested field together, because the
|
|
295
|
+
# answer to "what may be sorted on" depends on whether anything was declared:
|
|
296
|
+
#
|
|
297
|
+
# sortable_by declared -> that list, client picks from it
|
|
298
|
+
# only `paginate cursor:` -> that single column, and it is also the default
|
|
299
|
+
# neither -> empty list, and Sort falls back to :id ASC
|
|
300
|
+
def sort_request(args)
|
|
301
|
+
declared = self.class.sortable_fields
|
|
302
|
+
fallback = self.class.cursor_column
|
|
303
|
+
|
|
304
|
+
allowed = declared.empty? ? Array(fallback) : declared
|
|
305
|
+
[allowed, args[:sort_by] || fallback]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def apply_seek(scope, model, column, direction, after)
|
|
309
|
+
return scope if after.nil? || after.to_s.empty?
|
|
310
|
+
|
|
311
|
+
# Cast by the column's own type. A datetime cursor that comes back out of
|
|
312
|
+
# JSON as a String would otherwise be compared as a String, which silently
|
|
313
|
+
# drops or repeats rows (SPEC.md 6.4).
|
|
314
|
+
payload = Cursor.decode(after, type: model.type_for_attribute(column.to_s))
|
|
315
|
+
|
|
316
|
+
Cursor.seek(
|
|
317
|
+
scope,
|
|
318
|
+
column: column,
|
|
319
|
+
direction: direction,
|
|
320
|
+
sort_value: payload[:sort_value],
|
|
321
|
+
id: payload[:id]
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# SPEC.md §3: v0.1.0 is forward-only, and that limit must be documented,
|
|
326
|
+
# "not hidden" — a request that asks for backward pagination must fail
|
|
327
|
+
# loudly rather than return a plausible-looking wrong page.
|
|
328
|
+
#
|
|
329
|
+
# graphql-ruby's ConnectionExtension publishes `last:`/`before:` on every
|
|
330
|
+
# field whose return type is a Connection (field/connection_extension.rb);
|
|
331
|
+
# that decision is made by the owning field from the return type, not by
|
|
332
|
+
# this resolver's own `argument` declarations, so there is no clean way
|
|
333
|
+
# for a Resolver subclass to stop graphql-ruby from offering them (see the
|
|
334
|
+
# note in FINDING 2's fix for the alternative considered and rejected).
|
|
335
|
+
# ConnectionExtension also strips :first/:last/:before/:after out of the
|
|
336
|
+
# keyword arguments it hands to `resolve` — the same mechanism
|
|
337
|
+
# `pagination_argument` already uses to recover `after:` — so `last:`/
|
|
338
|
+
# `before:` must be read back the same way to be seen at all.
|
|
339
|
+
def reject_backward_pagination!(args)
|
|
340
|
+
last = pagination_argument(args, :last)
|
|
341
|
+
before = pagination_argument(args, :before)
|
|
342
|
+
return if last.nil? && before.nil?
|
|
343
|
+
|
|
344
|
+
raise Error,
|
|
345
|
+
"backward pagination (last:/before:) is not supported in v0.1.0 — " \
|
|
346
|
+
"this connection is forward-only. Use first:/after: instead (SPEC.md section 3)."
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def normalize_direction(value)
|
|
350
|
+
normalized = (value || :asc).to_s.downcase.to_sym
|
|
351
|
+
unless Sort::DIRECTIONS.include?(normalized)
|
|
352
|
+
raise Error, "sort direction must be one of #{Sort::DIRECTIONS.inspect}, got #{value.inspect}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
normalized
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Builds the callable KeysetConnection runs on the page it just fetched.
|
|
359
|
+
# It is a lambda, not a preload call, precisely so it cannot run early: the
|
|
360
|
+
# connection invokes it after LIMIT (SPEC.md 5, invariant 1). Preloading the
|
|
361
|
+
# relation here would preload the entire filtered set.
|
|
362
|
+
def preloader_for(args, model)
|
|
363
|
+
associations = preload_associations(args, model)
|
|
364
|
+
return nil if associations.empty?
|
|
365
|
+
|
|
366
|
+
->(records) { run_preload(records, associations) }
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def preload_associations(args, model)
|
|
370
|
+
static = self.class.static_preloads
|
|
371
|
+
|
|
372
|
+
if self.class.preload_from_selection?
|
|
373
|
+
Preloader.from_lookahead(
|
|
374
|
+
pagination_argument(args, :lookahead),
|
|
375
|
+
model,
|
|
376
|
+
max_depth: self.class.preload_max_depth,
|
|
377
|
+
static: static
|
|
378
|
+
)
|
|
379
|
+
else
|
|
380
|
+
Preloader.merge(static, {})
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def run_preload(records, associations)
|
|
385
|
+
ar_preloader = ::ActiveRecord::Associations::Preloader
|
|
386
|
+
|
|
387
|
+
# Rails 7+ takes keywords; 6.1 took positional arguments to #preload.
|
|
388
|
+
if ar_preloader.instance_method(:initialize).parameters.any? { |_kind, key| key == :records }
|
|
389
|
+
ar_preloader.new(records: records, associations: associations).call
|
|
390
|
+
else
|
|
391
|
+
ar_preloader.new.preload(records, associations)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
records
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Only forwarded when actually declared. GraphQL::Pagination::Connection
|
|
398
|
+
# treats a passed `max_page_size:` as an override even if it is nil, so
|
|
399
|
+
# passing them unconditionally would shadow the schema-level defaults.
|
|
400
|
+
def page_size_options
|
|
401
|
+
options = {}
|
|
402
|
+
options[:default_page_size] = self.class.default_page_size if self.class.has_default_page_size?
|
|
403
|
+
options[:max_page_size] = self.class.max_page_size if self.class.has_max_page_size?
|
|
404
|
+
options
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# `first:` and `after:` do not necessarily reach `resolve`.
|
|
408
|
+
#
|
|
409
|
+
# When the field returns a connection type, graphql-ruby's own
|
|
410
|
+
# ConnectionExtension strips :first/:last/:before/:after out of the keyword
|
|
411
|
+
# arguments before the resolver is called (field/connection_extension.rb),
|
|
412
|
+
# because it expects to apply them itself afterwards. We need `after:`
|
|
413
|
+
# *during* resolve — the seek is a WHERE clause, not a post-filter — so fall
|
|
414
|
+
# back to the runtime's record of the field's real arguments.
|
|
415
|
+
#
|
|
416
|
+
# `args` still wins when present, which covers a resolver on a plain list
|
|
417
|
+
# field and a directly-invoked resolver in a unit test.
|
|
418
|
+
def pagination_argument(args, key)
|
|
419
|
+
return args[key] if args.key?(key)
|
|
420
|
+
|
|
421
|
+
current = context && context[:current_arguments]
|
|
422
|
+
keywords = current.respond_to?(:keyword_arguments) ? current.keyword_arguments : current
|
|
423
|
+
keywords.is_a?(Hash) ? keywords[key] : nil
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|