graphql 1.12.3 → 1.12.8

Sign up to get free protection for your applications and to get access to all the features.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/graphql/install_generator.rb +4 -1
  3. data/lib/generators/graphql/loader_generator.rb +1 -0
  4. data/lib/generators/graphql/mutation_generator.rb +1 -0
  5. data/lib/generators/graphql/relay.rb +55 -0
  6. data/lib/generators/graphql/relay_generator.rb +4 -46
  7. data/lib/generators/graphql/type_generator.rb +1 -0
  8. data/lib/graphql.rb +4 -2
  9. data/lib/graphql/backtrace/inspect_result.rb +0 -1
  10. data/lib/graphql/backtrace/table.rb +0 -1
  11. data/lib/graphql/backtrace/traced_error.rb +0 -1
  12. data/lib/graphql/backtrace/tracer.rb +4 -8
  13. data/lib/graphql/dataloader.rb +102 -92
  14. data/lib/graphql/dataloader/null_dataloader.rb +5 -5
  15. data/lib/graphql/dataloader/request.rb +1 -6
  16. data/lib/graphql/dataloader/request_all.rb +1 -4
  17. data/lib/graphql/dataloader/source.rb +20 -6
  18. data/lib/graphql/execution/errors.rb +109 -11
  19. data/lib/graphql/execution/interpreter.rb +2 -2
  20. data/lib/graphql/execution/interpreter/arguments_cache.rb +37 -14
  21. data/lib/graphql/execution/interpreter/resolve.rb +33 -25
  22. data/lib/graphql/execution/interpreter/runtime.rb +41 -78
  23. data/lib/graphql/execution/multiplex.rb +21 -22
  24. data/lib/graphql/introspection.rb +1 -1
  25. data/lib/graphql/introspection/directive_type.rb +7 -3
  26. data/lib/graphql/language.rb +1 -0
  27. data/lib/graphql/language/cache.rb +37 -0
  28. data/lib/graphql/language/parser.rb +15 -5
  29. data/lib/graphql/language/parser.y +15 -5
  30. data/lib/graphql/object_type.rb +0 -2
  31. data/lib/graphql/pagination/active_record_relation_connection.rb +7 -0
  32. data/lib/graphql/pagination/connection.rb +15 -1
  33. data/lib/graphql/pagination/connections.rb +1 -0
  34. data/lib/graphql/pagination/relation_connection.rb +12 -1
  35. data/lib/graphql/parse_error.rb +0 -1
  36. data/lib/graphql/query.rb +9 -5
  37. data/lib/graphql/query/arguments_cache.rb +0 -1
  38. data/lib/graphql/query/context.rb +1 -3
  39. data/lib/graphql/query/executor.rb +0 -1
  40. data/lib/graphql/query/null_context.rb +3 -2
  41. data/lib/graphql/query/validation_pipeline.rb +1 -1
  42. data/lib/graphql/query/variable_validation_error.rb +1 -1
  43. data/lib/graphql/railtie.rb +9 -1
  44. data/lib/graphql/relay/range_add.rb +10 -5
  45. data/lib/graphql/schema.rb +14 -27
  46. data/lib/graphql/schema/argument.rb +61 -0
  47. data/lib/graphql/schema/field.rb +10 -5
  48. data/lib/graphql/schema/field/connection_extension.rb +1 -0
  49. data/lib/graphql/schema/find_inherited_value.rb +3 -1
  50. data/lib/graphql/schema/input_object.rb +6 -2
  51. data/lib/graphql/schema/member/has_arguments.rb +43 -56
  52. data/lib/graphql/schema/member/has_fields.rb +1 -4
  53. data/lib/graphql/schema/member/instrumentation.rb +0 -1
  54. data/lib/graphql/schema/resolver.rb +28 -1
  55. data/lib/graphql/static_validation/rules/argument_literals_are_compatible.rb +3 -1
  56. data/lib/graphql/static_validation/rules/argument_literals_are_compatible_error.rb +6 -2
  57. data/lib/graphql/static_validation/rules/arguments_are_defined.rb +2 -1
  58. data/lib/graphql/static_validation/rules/arguments_are_defined_error.rb +4 -2
  59. data/lib/graphql/static_validation/rules/variable_usages_are_allowed.rb +2 -2
  60. data/lib/graphql/subscriptions/broadcast_analyzer.rb +0 -3
  61. data/lib/graphql/subscriptions/event.rb +0 -1
  62. data/lib/graphql/subscriptions/instrumentation.rb +0 -1
  63. data/lib/graphql/subscriptions/serialize.rb +3 -1
  64. data/lib/graphql/tracing/active_support_notifications_tracing.rb +2 -1
  65. data/lib/graphql/types/relay/base_connection.rb +4 -0
  66. data/lib/graphql/types/relay/connection_behaviors.rb +38 -5
  67. data/lib/graphql/types/relay/edge_behaviors.rb +12 -1
  68. data/lib/graphql/version.rb +1 -1
  69. data/readme.md +1 -1
  70. metadata +8 -90
@@ -56,17 +56,18 @@ module GraphQL
56
56
  # Root .authorized? returned false.
57
57
  write_in_response(path, nil)
58
58
  else
59
- # Prepare this runtime state to be encapsulated in a Fiber
60
- @progress_path = path
61
- @progress_scoped_context = context.scoped_context
62
- @progress_object = object_proxy
63
- @progress_object_type = root_type
64
- @progress_index = nil
65
- @progress_is_eager_selection = root_op_type == "mutation"
66
- @progress_selections = gather_selections(object_proxy, root_type, root_operation.selections)
67
-
59
+ gathered_selections = gather_selections(object_proxy, root_type, root_operation.selections)
68
60
  # Make the first fiber which will begin execution
69
- enqueue_selections_fiber
61
+ @dataloader.append_job {
62
+ evaluate_selections(
63
+ path,
64
+ context.scoped_context,
65
+ object_proxy,
66
+ root_type,
67
+ root_op_type == "mutation",
68
+ gathered_selections,
69
+ )
70
+ }
70
71
  end
71
72
  delete_interpreter_context(:current_path)
72
73
  delete_interpreter_context(:current_field)
@@ -75,32 +76,6 @@ module GraphQL
75
76
  nil
76
77
  end
77
78
 
78
- # Use `@dataloader` to enqueue a fiber that will pick up from the current point.
79
- # @return [void]
80
- def enqueue_selections_fiber
81
- # Read these into local variables so that later assignments don't affect the block below.
82
- path = @progress_path
83
- scoped_context = @progress_scoped_context
84
- owner_object = @progress_object
85
- owner_type = @progress_object_type
86
- idx = @progress_index
87
- is_eager_selection = @progress_is_eager_selection
88
- gathered_selections = @progress_selections
89
-
90
- @dataloader.enqueue {
91
- evaluate_selections(
92
- path,
93
- scoped_context,
94
- owner_object,
95
- owner_type,
96
- is_eager_selection: is_eager_selection,
97
- after: idx,
98
- gathered_selections: gathered_selections,
99
- )
100
- }
101
- nil
102
- end
103
-
104
79
  def gather_selections(owner_object, owner_type, selections, selections_by_name = {})
105
80
  selections.each do |node|
106
81
  # Skip gathering this if the directive says so
@@ -159,39 +134,17 @@ module GraphQL
159
134
  NO_ARGS = {}.freeze
160
135
 
161
136
  # @return [void]
162
- def evaluate_selections(path, scoped_context, owner_object, owner_type, is_eager_selection:, gathered_selections:, after:)
137
+ def evaluate_selections(path, scoped_context, owner_object, owner_type, is_eager_selection, gathered_selections)
163
138
  set_all_interpreter_context(owner_object, nil, nil, path)
164
139
 
165
- @progress_path = path
166
- @progress_scoped_context = scoped_context
167
- @progress_object = owner_object
168
- @progress_object_type = owner_type
169
- @progress_index = nil
170
- @progress_is_eager_selection = is_eager_selection
171
- @progress_selections = gathered_selections
172
-
173
- # Track `idx` manually to avoid an allocation on this hot path
174
- idx = 0
175
140
  gathered_selections.each do |result_name, field_ast_nodes_or_ast_node|
176
- prev_idx = idx
177
- idx += 1
178
- # TODO: this is how a `progress` resumes where this left off.
179
- # Is there a better way to seek in the hash?
180
- # I think we could also use the array of keys; it supports seeking just fine.
181
- if after && prev_idx <= after
182
- next
183
- end
184
- @progress_index = prev_idx
185
- # This is how the current runtime gives itself to `dataloader`
186
- # so that the dataloader can enqueue another fiber to resume if needed.
187
- @dataloader.current_runtime = self
188
- evaluate_selection(path, result_name, field_ast_nodes_or_ast_node, scoped_context, owner_object, owner_type, is_eager_selection)
189
- # The dataloader knows if ^^ that selection halted and later selections were executed in another fiber.
190
- # If that's the case, then don't continue execution here.
191
- if @dataloader.yielded?(path)
192
- break
193
- end
141
+ @dataloader.append_job {
142
+ evaluate_selection(
143
+ path, result_name, field_ast_nodes_or_ast_node, scoped_context, owner_object, owner_type, is_eager_selection
144
+ )
145
+ }
194
146
  end
147
+
195
148
  nil
196
149
  end
197
150
 
@@ -243,13 +196,21 @@ module GraphQL
243
196
  object = authorized_new(field_defn.owner, object, context, next_path)
244
197
  end
245
198
 
246
- begin
247
- kwarg_arguments = arguments(object, field_defn, ast_node)
248
- rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => e
249
- continue_value(next_path, e, owner_type, field_defn, return_type.non_null?, ast_node)
250
- return
199
+ total_args_count = field_defn.arguments.size
200
+ if total_args_count == 0
201
+ kwarg_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY
202
+ evaluate_selection_with_args(kwarg_arguments, field_defn, next_path, ast_node, field_ast_nodes, scoped_context, owner_type, object, is_eager_field)
203
+ else
204
+ # TODO remove all arguments(...) usages?
205
+ @query.arguments_cache.dataload_for(ast_node, field_defn, object) do |resolved_arguments|
206
+ evaluate_selection_with_args(resolved_arguments, field_defn, next_path, ast_node, field_ast_nodes, scoped_context, owner_type, object, is_eager_field)
207
+ end
251
208
  end
209
+ end
252
210
 
211
+ def evaluate_selection_with_args(kwarg_arguments, field_defn, next_path, ast_node, field_ast_nodes, scoped_context, owner_type, object, is_eager_field) # rubocop:disable Metrics/ParameterLists
212
+ context.scoped_context = scoped_context
213
+ return_type = field_defn.type
253
214
  after_lazy(kwarg_arguments, owner: owner_type, field: field_defn, path: next_path, ast_node: ast_node, scoped_context: context.scoped_context, owner_object: object, arguments: kwarg_arguments) do |resolved_arguments|
254
215
  if resolved_arguments.is_a?(GraphQL::ExecutionError) || resolved_arguments.is_a?(GraphQL::UnauthorizedError)
255
216
  continue_value(next_path, resolved_arguments, owner_type, field_defn, return_type.non_null?, ast_node)
@@ -316,10 +277,7 @@ module GraphQL
316
277
  end
317
278
  after_lazy(app_result, owner: owner_type, field: field_defn, path: next_path, ast_node: ast_node, scoped_context: context.scoped_context, owner_object: object, arguments: kwarg_arguments) do |inner_result|
318
279
  continue_value = continue_value(next_path, inner_result, owner_type, field_defn, return_type.non_null?, ast_node)
319
- if RawValue === continue_value
320
- # Write raw value directly to the response without resolving nested objects
321
- write_in_response(next_path, continue_value.resolve)
322
- elsif HALT != continue_value
280
+ if HALT != continue_value
323
281
  continue_field(next_path, continue_value, owner_type, field_defn, return_type, ast_node, next_selections, false, object, kwarg_arguments)
324
282
  end
325
283
  end
@@ -329,10 +287,11 @@ module GraphQL
329
287
  # all of its child fields before moving on to the next root mutation field.
330
288
  # (Subselections of this mutation will still be resolved level-by-level.)
331
289
  if is_eager_field
332
- Interpreter::Resolve.resolve_all([field_result])
290
+ Interpreter::Resolve.resolve_all([field_result], @dataloader)
291
+ else
292
+ # Return this from `after_lazy` because it might be another lazy that needs to be resolved
293
+ field_result
333
294
  end
334
-
335
- nil
336
295
  end
337
296
  end
338
297
 
@@ -370,6 +329,10 @@ module GraphQL
370
329
  continue_value(path, next_value, parent_type, field, is_non_null, ast_node)
371
330
  elsif GraphQL::Execution::Execute::SKIP == value
372
331
  HALT
332
+ elsif value.is_a?(GraphQL::Execution::Interpreter::RawValue)
333
+ # Write raw value directly to the response without resolving nested objects
334
+ write_in_response(path, value.resolve)
335
+ HALT
373
336
  else
374
337
  value
375
338
  end
@@ -419,7 +382,7 @@ module GraphQL
419
382
  response_hash = {}
420
383
  write_in_response(path, response_hash)
421
384
  gathered_selections = gather_selections(continue_value, current_type, next_selections)
422
- evaluate_selections(path, context.scoped_context, continue_value, current_type, is_eager_selection: false, gathered_selections: gathered_selections, after: nil)
385
+ evaluate_selections(path, context.scoped_context, continue_value, current_type, false, gathered_selections)
423
386
  response_hash
424
387
  end
425
388
  end
@@ -35,7 +35,7 @@ module GraphQL
35
35
  @queries = queries
36
36
  @queries.each { |q| q.multiplex = self }
37
37
  @context = context
38
- @context[:dataloader] = @dataloader = @schema.dataloader_class.new(context)
38
+ @context[:dataloader] = @dataloader = @schema.dataloader_class.new
39
39
  @tracers = schema.tracers + (context[:tracers] || [])
40
40
  # Support `context: {backtrace: true}`
41
41
  if context[:backtrace] && !@tracers.include?(GraphQL::Backtrace::Tracer)
@@ -74,6 +74,24 @@ module GraphQL
74
74
  end
75
75
  end
76
76
 
77
+ # @param query [GraphQL::Query]
78
+ def begin_query(results, idx, query, multiplex)
79
+ operation = query.selected_operation
80
+ result = if operation.nil? || !query.valid? || query.context.errors.any?
81
+ NO_OPERATION
82
+ else
83
+ begin
84
+ # These were checked to be the same in `#supports_multiplexing?`
85
+ query.schema.query_execution_strategy.begin_query(query, multiplex)
86
+ rescue GraphQL::ExecutionError => err
87
+ query.context.errors << err
88
+ NO_OPERATION
89
+ end
90
+ end
91
+ results[idx] = result
92
+ nil
93
+ end
94
+
77
95
  private
78
96
 
79
97
  def run_as_multiplex(multiplex)
@@ -83,15 +101,13 @@ module GraphQL
83
101
  # Do as much eager evaluation of the query as possible
84
102
  results = []
85
103
  queries.each_with_index do |query, idx|
86
- multiplex.dataloader.enqueue {
87
- results[idx] = begin_query(query, multiplex)
88
- }
104
+ multiplex.dataloader.append_job { begin_query(results, idx, query, multiplex) }
89
105
  end
90
106
 
91
107
  multiplex.dataloader.run
92
108
 
93
109
  # Then, work through lazy results in a breadth-first way
94
- multiplex.dataloader.enqueue {
110
+ multiplex.dataloader.append_job {
95
111
  multiplex.schema.query_execution_strategy.finish_multiplex(results, multiplex)
96
112
  }
97
113
  multiplex.dataloader.run
@@ -112,23 +128,6 @@ module GraphQL
112
128
  raise
113
129
  end
114
130
 
115
- # @param query [GraphQL::Query]
116
- # @return [Hash] The initial result (may not be finished if there are lazy values)
117
- def begin_query(query, multiplex)
118
- operation = query.selected_operation
119
- if operation.nil? || !query.valid? || query.context.errors.any?
120
- NO_OPERATION
121
- else
122
- begin
123
- # These were checked to be the same in `#supports_multiplexing?`
124
- query.schema.query_execution_strategy.begin_query(query, multiplex)
125
- rescue GraphQL::ExecutionError => err
126
- query.context.errors << err
127
- NO_OPERATION
128
- end
129
- end
130
- end
131
-
132
131
  # @param data_result [Hash] The result for the "data" key, if any
133
132
  # @param query [GraphQL::Query] The query which was run
134
133
  # @return [Hash] final result of this query, including all values and errors
@@ -17,7 +17,7 @@ query IntrospectionQuery {
17
17
  name
18
18
  description
19
19
  locations
20
- args {
20
+ args#{include_deprecated_args ? '(includeDeprecated: true)' : ''} {
21
21
  ...InputValue
22
22
  }
23
23
  }
@@ -12,13 +12,17 @@ module GraphQL
12
12
  field :name, String, null: false, method: :graphql_name
13
13
  field :description, String, null: true
14
14
  field :locations, [GraphQL::Schema::LateBoundType.new("__DirectiveLocation")], null: false
15
- field :args, [GraphQL::Schema::LateBoundType.new("__InputValue")], null: false
15
+ field :args, [GraphQL::Schema::LateBoundType.new("__InputValue")], null: false do
16
+ argument :include_deprecated, Boolean, required: false, default_value: false
17
+ end
16
18
  field :on_operation, Boolean, null: false, deprecation_reason: "Use `locations`.", method: :on_operation?
17
19
  field :on_fragment, Boolean, null: false, deprecation_reason: "Use `locations`.", method: :on_fragment?
18
20
  field :on_field, Boolean, null: false, deprecation_reason: "Use `locations`.", method: :on_field?
19
21
 
20
- def args
21
- @context.warden.arguments(@object)
22
+ def args(include_deprecated:)
23
+ args = @context.warden.arguments(@object)
24
+ args = args.reject(&:deprecation_reason) unless include_deprecated
25
+ args
22
26
  end
23
27
  end
24
28
  end
@@ -6,6 +6,7 @@ require "graphql/language/document_from_schema_definition"
6
6
  require "graphql/language/generation"
7
7
  require "graphql/language/lexer"
8
8
  require "graphql/language/nodes"
9
+ require "graphql/language/cache"
9
10
  require "graphql/language/parser"
10
11
  require "graphql/language/token"
11
12
  require "graphql/language/visitor"
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'graphql/version'
4
+ require 'digest/sha2'
5
+
6
+ module GraphQL
7
+ module Language
8
+ class Cache
9
+ def initialize(path)
10
+ @path = path
11
+ end
12
+
13
+ DIGEST = Digest::SHA256.new << GraphQL::VERSION
14
+ def fetch(filename)
15
+ hash = DIGEST.dup << filename
16
+ begin
17
+ hash << File.mtime(filename).to_i.to_s
18
+ rescue SystemCallError
19
+ return yield
20
+ end
21
+ cache_path = @path.join(hash.to_s)
22
+
23
+ if cache_path.exist?
24
+ Marshal.load(cache_path.read)
25
+ else
26
+ payload = yield
27
+ tmp_path = "#{cache_path}.#{rand}"
28
+
29
+ @path.mkpath
30
+ File.binwrite(tmp_path, Marshal.dump(payload))
31
+ File.rename(tmp_path, cache_path.to_s)
32
+ payload
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -41,12 +41,22 @@ def parse_document
41
41
  end
42
42
  end
43
43
 
44
- def self.parse(query_string, filename: nil, tracer: GraphQL::Tracing::NullTracer)
45
- self.new(query_string, filename: filename, tracer: tracer).parse_document
46
- end
44
+ class << self
45
+ attr_accessor :cache
46
+
47
+ def parse(query_string, filename: nil, tracer: GraphQL::Tracing::NullTracer)
48
+ new(query_string, filename: filename, tracer: tracer).parse_document
49
+ end
47
50
 
48
- def self.parse_file(filename, tracer: GraphQL::Tracing::NullTracer)
49
- self.parse(File.read(filename), filename: filename, tracer: tracer)
51
+ def parse_file(filename, tracer: GraphQL::Tracing::NullTracer)
52
+ if cache
53
+ cache.fetch(filename) do
54
+ parse(File.read(filename), filename: filename, tracer: tracer)
55
+ end
56
+ else
57
+ parse(File.read(filename), filename: filename, tracer: tracer)
58
+ end
59
+ end
50
60
  end
51
61
 
52
62
  private
@@ -462,12 +462,22 @@ def parse_document
462
462
  end
463
463
  end
464
464
 
465
- def self.parse(query_string, filename: nil, tracer: GraphQL::Tracing::NullTracer)
466
- self.new(query_string, filename: filename, tracer: tracer).parse_document
467
- end
465
+ class << self
466
+ attr_accessor :cache
467
+
468
+ def parse(query_string, filename: nil, tracer: GraphQL::Tracing::NullTracer)
469
+ new(query_string, filename: filename, tracer: tracer).parse_document
470
+ end
468
471
 
469
- def self.parse_file(filename, tracer: GraphQL::Tracing::NullTracer)
470
- self.parse(File.read(filename), filename: filename, tracer: tracer)
472
+ def parse_file(filename, tracer: GraphQL::Tracing::NullTracer)
473
+ if cache
474
+ cache.fetch(filename) do
475
+ parse(File.read(filename), filename: filename, tracer: tracer)
476
+ end
477
+ else
478
+ parse(File.read(filename), filename: filename, tracer: tracer)
479
+ end
480
+ end
471
481
  end
472
482
 
473
483
  private
@@ -121,8 +121,6 @@ module GraphQL
121
121
  iface = GraphQL::BaseType.resolve_related_type(type_membership.abstract_type)
122
122
  if iface.is_a?(GraphQL::InterfaceType)
123
123
  @clean_inherited_fields.merge!(iface.fields)
124
- else
125
- pp iface
126
124
  end
127
125
  end
128
126
  @clean_inherited_fields
@@ -5,6 +5,13 @@ module GraphQL
5
5
  module Pagination
6
6
  # Customizes `RelationConnection` to work with `ActiveRecord::Relation`s.
7
7
  class ActiveRecordRelationConnection < Pagination::RelationConnection
8
+ private
9
+
10
+ def relation_larger_than(relation, size)
11
+ initial_offset = relation.offset_value || 0
12
+ relation.offset(initial_offset + size).exists?
13
+ end
14
+
8
15
  def relation_count(relation)
9
16
  int_or_hash = if relation.respond_to?(:unscope)
10
17
  relation.unscope(:order).count(:all)
@@ -45,6 +45,9 @@ module GraphQL
45
45
  end
46
46
  end
47
47
 
48
+ # @return [Hash<Symbol => Object>] The field arguments from the field that returned this connection
49
+ attr_accessor :arguments
50
+
48
51
  # @param items [Object] some unpaginated collection item, like an `Array` or `ActiveRecord::Relation`
49
52
  # @param context [Query::Context]
50
53
  # @param parent [Object] The object this collection belongs to
@@ -52,8 +55,9 @@ module GraphQL
52
55
  # @param after [String, nil] A cursor for pagination, if the client provided one
53
56
  # @param last [Integer, nil] Limit parameter from the client, if provided
54
57
  # @param before [String, nil] A cursor for pagination, if the client provided one.
58
+ # @param arguments [Hash] The arguments to the field that returned the collection wrapped by this connection
55
59
  # @param max_page_size [Integer, nil] A configured value to cap the result size. Applied as `first` if neither first or last are given.
56
- def initialize(items, parent: nil, field: nil, context: nil, first: nil, after: nil, max_page_size: :not_given, last: nil, before: nil, edge_class: nil)
60
+ def initialize(items, parent: nil, field: nil, context: nil, first: nil, after: nil, max_page_size: :not_given, last: nil, before: nil, edge_class: nil, arguments: nil)
57
61
  @items = items
58
62
  @parent = parent
59
63
  @context = context
@@ -62,6 +66,7 @@ module GraphQL
62
66
  @after_value = after
63
67
  @last_value = last
64
68
  @before_value = before
69
+ @arguments = arguments
65
70
  @edge_class = edge_class || self.class::Edge
66
71
  # This is only true if the object was _initialized_ with an override
67
72
  # or if one is assigned later.
@@ -105,6 +110,15 @@ module GraphQL
105
110
  end
106
111
  end
107
112
 
113
+ # This is called by `Relay::RangeAdd` -- it can be overridden
114
+ # when `item` needs some modifications based on this connection's state.
115
+ #
116
+ # @param item [Object] An item newly added to `items`
117
+ # @return [Edge]
118
+ def range_add_edge(item)
119
+ edge_class.new(item, self)
120
+ end
121
+
108
122
  attr_writer :last
109
123
  # @return [Integer, nil] A clamped `last` value. (The underlying instance variable doesn't have limits on it)
110
124
  def last