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.
@@ -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