graphlyte 0.3.0 → 1.0.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/lib/graphlyte/data.rb +68 -0
  3. data/lib/graphlyte/document.rb +131 -0
  4. data/lib/graphlyte/dsl.rb +86 -0
  5. data/lib/graphlyte/editor.rb +288 -0
  6. data/lib/graphlyte/editors/annotate_types.rb +75 -0
  7. data/lib/graphlyte/editors/canonicalize.rb +26 -0
  8. data/lib/graphlyte/editors/collect_variable_references.rb +36 -0
  9. data/lib/graphlyte/editors/infer_signature.rb +36 -0
  10. data/lib/graphlyte/editors/inline_fragments.rb +37 -0
  11. data/lib/graphlyte/editors/remove_unneeded_spreads.rb +64 -0
  12. data/lib/graphlyte/editors/select_operation.rb +116 -0
  13. data/lib/graphlyte/editors/with_variables.rb +106 -0
  14. data/lib/graphlyte/errors.rb +33 -0
  15. data/lib/graphlyte/lexer.rb +392 -0
  16. data/lib/graphlyte/lexing/location.rb +43 -0
  17. data/lib/graphlyte/lexing/token.rb +31 -0
  18. data/lib/graphlyte/parser.rb +269 -0
  19. data/lib/graphlyte/parsing/backtracking_parser.rb +160 -0
  20. data/lib/graphlyte/refinements/string_refinement.rb +14 -8
  21. data/lib/graphlyte/refinements/syntax_refinements.rb +62 -0
  22. data/lib/graphlyte/schema.rb +165 -0
  23. data/lib/graphlyte/schema_query.rb +82 -65
  24. data/lib/graphlyte/selection_builder.rb +189 -0
  25. data/lib/graphlyte/selector.rb +75 -0
  26. data/lib/graphlyte/serializer.rb +223 -0
  27. data/lib/graphlyte/syntax.rb +369 -0
  28. data/lib/graphlyte.rb +24 -42
  29. metadata +88 -18
  30. data/lib/graphlyte/arguments/set.rb +0 -88
  31. data/lib/graphlyte/arguments/value.rb +0 -32
  32. data/lib/graphlyte/builder.rb +0 -53
  33. data/lib/graphlyte/directive.rb +0 -21
  34. data/lib/graphlyte/field.rb +0 -65
  35. data/lib/graphlyte/fieldset.rb +0 -36
  36. data/lib/graphlyte/fragment.rb +0 -17
  37. data/lib/graphlyte/inline_fragment.rb +0 -29
  38. data/lib/graphlyte/query.rb +0 -148
  39. data/lib/graphlyte/schema/parser.rb +0 -674
  40. data/lib/graphlyte/schema/types/base.rb +0 -54
  41. data/lib/graphlyte/types.rb +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97c3b9e1d17580b4d94cf3ffaa9f7291d8ff10c9441a9c2270a378daad39f977
4
- data.tar.gz: f3586e4ea3a92e0ac462b92a17115a81a8c64e6643c296861072a23543ae2b92
3
+ metadata.gz: 50a30d874f185f08a1d8e9d7911056135927ec25a2f9e272b14dd50124aeea79
4
+ data.tar.gz: 97b938f8391811a7da0a29d855daea3812c7e97e953dd64fc5fa90fd98a10b8d
5
5
  SHA512:
6
- metadata.gz: a4dd27faa45576150659090362b94f30297faad8a1a1d5758480e63359bd74e92a30960e70fad8aa446c60e1540dd384458590d5f6cc00eb49c9aa09b44d6659
7
- data.tar.gz: 1887884979b8f92cc8c1b145c782363b7be9e907fc3541cee5e103a4e901d07261f1a6f69aa0aafcce337efda5e5de4a3950fa4803f17a074f4cc6bbd0555ace
6
+ metadata.gz: ebe479ecb5e89d21e0e193207cd72fa6891df00621882f0de1d9fa344655d8058ee566857fc12e3d7ea25d34c83f1b6fb6545ea30739e4473695ef875ef6661a
7
+ data.tar.gz: 7d1bc0754b536f724f4e9ef4f91b3d5411335e568227a4e77e171a45c5a5d719b4edf3c4f289504321f5662d8d25571d7041970fc0bf873e54a489f9698e1d0e
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Graphlyte
6
+ # Very simplistic data-class. Inheritance is not modelled.
7
+ class Data
8
+ def self.attr_accessor(*names)
9
+ super
10
+ attributes.merge(names)
11
+ end
12
+
13
+ def self.attr_reader(*names)
14
+ super
15
+ attributes.merge(names)
16
+ end
17
+
18
+ def self.attributes
19
+ @attributes ||= [].to_set
20
+ end
21
+
22
+ # Permissive constructor: ignores unknown attributes
23
+ def initialize(**kwargs)
24
+ self.class.attributes.each do |arg|
25
+ send(:"#{arg}=", kwargs[arg]) if kwargs.key?(arg)
26
+ end
27
+ end
28
+
29
+ def eql?(other)
30
+ other.is_a?(self.class) && state == other.send(:state)
31
+ end
32
+
33
+ def ==(other)
34
+ eql?(other)
35
+ end
36
+
37
+ def hash
38
+ state.hash
39
+ end
40
+
41
+ def dup
42
+ self.class.new(**self.class.attributes.to_h { [_1, dup_attribute(_1)] })
43
+ end
44
+
45
+ def inspect
46
+ "#<#{self.class} #{self.class.attributes.map { "@#{_1}=#{send(_1).inspect}" }.join(' ')}>"
47
+ end
48
+
49
+ private
50
+
51
+ def dup_attribute(attr)
52
+ value = send(attr)
53
+
54
+ case value
55
+ when Array
56
+ value.map(&:dup)
57
+ when Hash
58
+ value.transform_values(&:dup)
59
+ else
60
+ value.dup
61
+ end
62
+ end
63
+
64
+ def state
65
+ self.class.attributes.map { send _1 }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ require_relative './syntax'
6
+ require_relative './data'
7
+ require_relative './serializer'
8
+ require_relative './refinements/string_refinement'
9
+ require_relative './editors/with_variables'
10
+
11
+ module Graphlyte
12
+ # The representation of a GraphQL document.
13
+ #
14
+ # Documents can have multiple definitions, which can
15
+ # be queries, mutations, subscriptions (operations) or fragments.
16
+ #
17
+ # During execution, only one operation can be executed.
18
+ class Document < Graphlyte::Data
19
+ using Graphlyte::Refinements::StringRefinement
20
+ extend Forwardable
21
+
22
+ attr_accessor :definitions, :variables, :schema
23
+
24
+ def_delegators :@definitions, :length, :empty?
25
+
26
+ def initialize(**kwargs)
27
+ super
28
+ @definitions ||= []
29
+ @variables ||= {}
30
+ @var_name_counter = @variables.size + 1
31
+ end
32
+
33
+ def +(other)
34
+ return dup unless other
35
+
36
+ other = other.dup
37
+ doc = dup
38
+
39
+ defs = doc.definitions + other.definitions
40
+ vars = doc.variables.merge(other.variables) # TODO: detect conflicts?
41
+
42
+ self.class.new(definitions: defs, vars: vars)
43
+ end
44
+
45
+ def eql?(other)
46
+ other.is_a?(self.class) && other.fragments == fragments && other.operations == operations
47
+ end
48
+
49
+ alias == eql?
50
+
51
+ def define(dfn)
52
+ @definitions << dfn
53
+ end
54
+
55
+ def add_fragments(frags)
56
+ current = fragments
57
+
58
+ frags.each do |frag|
59
+ @definitions << frag unless current[frag.name]
60
+ end
61
+ end
62
+
63
+ def declare(var)
64
+ if var.name.nil?
65
+ var.name = "var#{@var_name_counter}"
66
+ @var_name_counter += 1
67
+ end
68
+
69
+ parser = Graphlyte::Parser.new(tokens: Graphlyte::Lexer.lex(var.type))
70
+ parsed_type = parser.type_name! if var.type
71
+ current_def = @variables[var.name]
72
+
73
+ if current_def && current_def.type != parsed_type
74
+ msg = "Cannot re-declare #{var.name} at different types. #{current_def.type} != #{var.type}"
75
+ raise ArgumentError, msg
76
+ end
77
+
78
+ @variables[var.name] ||= Graphlyte::Syntax::VariableDefinition.new(
79
+ variable: var.name,
80
+ type: parsed_type
81
+ )
82
+
83
+ Syntax::VariableReference.new(var.name, parsed_type)
84
+ end
85
+
86
+ def fragments
87
+ definitions.select { _1.is_a?(Graphlyte::Syntax::Fragment) }.to_h { [_1.name, _1] }
88
+ end
89
+
90
+ def operations
91
+ @definitions.select { _1.is_a?(Graphlyte::Syntax::Operation) }.to_h { [_1.name, _1] }
92
+ end
93
+
94
+ def executable?
95
+ @definitions.all?(&:executable?)
96
+ end
97
+
98
+ def to_s
99
+ buff = []
100
+ write(buff)
101
+
102
+ buff.join
103
+ end
104
+
105
+ # More efficient for writing to files or streams - avoids building up the full string.
106
+ def write(io)
107
+ Graphlyte::Serializer.new(io).dump_definitions(definitions)
108
+ end
109
+
110
+ # Return this document as a JSON request body, suitable for posting to a server.
111
+ def request_body(operation = nil, **variables)
112
+ if operation.nil? && operations.size != 1
113
+ raise ArgumentError, 'Operation name is required when the document contains multiple operations'
114
+ end
115
+
116
+ variables.transform_keys! { _1.to_s.camelize }
117
+
118
+ doc = Editors::WithVariables.new(schema, operation, variables).edit(dup)
119
+
120
+ {
121
+ query: doc.to_s,
122
+ variables: variables,
123
+ operation: operation
124
+ }.compact.to_json
125
+ end
126
+
127
+ def variable_references
128
+ Editors::CollectVariableReferences.new.edit(self)[Syntax::Operation]
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './syntax'
4
+ require_relative './selection_builder'
5
+ require_relative './refinements/string_refinement'
6
+ require_relative './editors/infer_signature'
7
+
8
+ module Graphlyte
9
+ # The DSL methods for query construction are defined here.
10
+ #
11
+ # The main methods are:
12
+ #
13
+ # - `var`: creates a fresh unique variable
14
+ # - `enum`: allows referring to enum values
15
+ # - `fragment`: creates a fragment that can be re-used in operations
16
+ # - `query`: creates a `Query` operation
17
+ # - `mutation`: creates a `Mutation` operation
18
+ class DSL
19
+ using Graphlyte::Refinements::StringRefinement
20
+
21
+ attr_reader :schema
22
+
23
+ def initialize(schema = nil)
24
+ @schema = schema
25
+ end
26
+
27
+ def var(type = nil, name = nil)
28
+ SelectionBuilder::Variable.new(type: type, name: name&.to_s&.camelize)
29
+ end
30
+
31
+ def enum(value)
32
+ Syntax::Value.new(value.to_sym, :ENUM)
33
+ end
34
+
35
+ def query(name = nil, doc = Document.new, &block)
36
+ op = Syntax::Operation.new(type: :query)
37
+ doc.define(op)
38
+
39
+ op.name = name
40
+ op.selection = SelectionBuilder.build(doc, &block)
41
+
42
+ Editors::InferSignature.new(@schema).edit(doc)
43
+
44
+ doc
45
+ end
46
+
47
+ def mutation(name = nil, doc = Document.new, &block)
48
+ op = Syntax::Operation.new(type: :mutation)
49
+ doc.define(op)
50
+
51
+ op.name = name
52
+ op.selection = SelectionBuilder.build(doc, &block)
53
+
54
+ # TODO: infer operation signatures (requires schema!)
55
+ doc
56
+ end
57
+
58
+ def fragment(fragment_name = nil, on:, doc: Document.new, &block)
59
+ frag = Graphlyte::Syntax::Fragment.new
60
+
61
+ frag.type_name = on
62
+ frag.selection = SelectionBuilder.build(doc, &block)
63
+
64
+ if fragment_name
65
+ frag.name = fragment_name
66
+ else
67
+ base = "#{on}Fields"
68
+ n = 1
69
+ frag.name = base
70
+
71
+ while doc.fragments[frag.name]
72
+ frag.name = "#{base}_#{n}"
73
+ n += 1
74
+ end
75
+ end
76
+
77
+ doc.fragments.each_value do |required|
78
+ frag.refers_to required
79
+ end
80
+
81
+ doc.define(frag)
82
+
83
+ frag
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './syntax'
4
+
5
+ module Graphlyte
6
+ # Walk the document tree and edit or collect data.
7
+ #
8
+ # This is the general purpose recursive transformer for
9
+ # syntax trees, used to write various validation and
10
+ # transformation passes. See `lib/graphlyte/editors`
11
+ #
12
+ # Usage
13
+ #
14
+ # A fragment inliner:
15
+ #
16
+ # inliner = Editor.new.on_fragment_spread do |spread, action|
17
+ # action.replace action.document.fragments[spread.name].inline
18
+ # end
19
+ #
20
+ # inliner.edit(document)
21
+ #
22
+ # A variable renamer:
23
+ #
24
+ # renamer = Editor.new.on_variable do |var|
25
+ # var.variable = 'x' if var.variable == 'y'
26
+ # end
27
+ #
28
+ # renamer.edit(document)
29
+ #
30
+ # A string collector:
31
+ #
32
+ # strings = []
33
+ # collector = Editor.new.on_value do |value|
34
+ # strings << value.value if value.type == :STRING
35
+ # end
36
+ #
37
+ # collector.edit(document)
38
+ #
39
+ class Editor
40
+ Deleted = Class.new(StandardError)
41
+
42
+ attr_accessor :direction
43
+
44
+ def initialize
45
+ @hooks = {}
46
+ @direction = :bottom_up
47
+ end
48
+
49
+ # The value passed to the handler blocks, in addition to the syntax node.
50
+ # Users can call methods on this object to edit the document in-place, as
51
+ # well as read information about the context of this node.
52
+ class Action
53
+ attr_reader :new_nodes, :path, :definition, :parent, :document
54
+
55
+ def initialize(old_node, path, parent, document)
56
+ @new_nodes = [old_node]
57
+ @definition = path.first
58
+ @path = path.dup.freeze
59
+ @parent = parent
60
+ @document = document
61
+ end
62
+
63
+ def replace(replacement)
64
+ @new_nodes = [replacement]
65
+ end
66
+
67
+ def insert_before(node)
68
+ @new_nodes = [node] + @new_nodes
69
+ end
70
+
71
+ def insert_after(node)
72
+ @new_nodes.push(node)
73
+ end
74
+
75
+ def delete
76
+ @new_nodes = []
77
+ end
78
+
79
+ def expand(new_nodes)
80
+ @new_nodes = new_nodes
81
+ end
82
+
83
+ def closest(node_type)
84
+ @path.reverse.find { _1.is_a?(node_type) }
85
+ end
86
+ end
87
+
88
+ # The class responsible for orchestration of the hooks. This class
89
+ # defines the recursion through the document.
90
+ Context = Struct.new(:document, :direction, :hooks, :path) do
91
+ def edit(object, &block)
92
+ parent = path.last
93
+ path.push(object)
94
+
95
+ processor = hooks[object.class]
96
+ action = Action.new(object, path, parent, document)
97
+
98
+ case direction
99
+ when :bottom_up
100
+ edit_bottom_up(object, processor, action, &block)
101
+ when :top_down
102
+ edit_top_down(object, processor, action, &block)
103
+ else
104
+ raise ArgumentError, "Unknown direction: #{direction}"
105
+ end
106
+
107
+ action.new_nodes
108
+ ensure
109
+ path.pop
110
+ end
111
+
112
+ def edit_top_down(object, processor, action)
113
+ processor&.call(object, action)
114
+ action.new_nodes = action.new_nodes.filter_map do |node|
115
+ yield node if block_given?
116
+ node
117
+ rescue Deleted
118
+ nil
119
+ end
120
+ end
121
+
122
+ def edit_bottom_up(object, processor, action)
123
+ yield object if block_given?
124
+ processor&.call(object, action)
125
+ rescue Deleted
126
+ action.new_nodes = []
127
+ end
128
+
129
+ def edit_variables(object)
130
+ return unless object.respond_to?(:variables)
131
+
132
+ object.variables = object.variables&.flat_map do |var|
133
+ edit(var) do |v|
134
+ edit_directives(v)
135
+ v.default_value = edit_value(v.default_value).first
136
+ end
137
+ end
138
+ end
139
+
140
+ def edit_directives(object)
141
+ return unless object.respond_to?(:directives)
142
+
143
+ object.directives = object.directives&.flat_map do |dir|
144
+ edit(dir) { |d| edit_arguments(d) }
145
+ end
146
+ end
147
+
148
+ def edit_arguments(object)
149
+ return unless object.respond_to?(:arguments)
150
+
151
+ object.arguments = object.arguments&.flat_map do |arg|
152
+ edit(arg) do |_a|
153
+ arg.value = edit_value(arg.value).first
154
+ raise Deleted if arg.value.nil?
155
+ end
156
+ end
157
+ end
158
+
159
+ def edit_value(object)
160
+ case object
161
+ when Array
162
+ [object.flat_map { edit_value(_1) }]
163
+ when Hash
164
+ [
165
+ object.to_a.flat_map do |(k, old_value)|
166
+ edit_value(old_value).take(1).map do |new_value|
167
+ [k, new_value]
168
+ end
169
+ end.to_h
170
+ ]
171
+ else
172
+ edit(object)
173
+ end
174
+ end
175
+
176
+ def edit_selection(object)
177
+ return unless object.respond_to?(:selection)
178
+
179
+ object.selection = object.selection&.flat_map do |selected|
180
+ edit(selected) do |s|
181
+ edit_arguments(s)
182
+ edit_directives(s)
183
+ edit_selection(s)
184
+ end
185
+ end
186
+ end
187
+
188
+ def edit_definition(object)
189
+ edit(object) do |o|
190
+ edit_variables(o)
191
+ edit_directives(o)
192
+ edit_selection(o)
193
+ end
194
+ end
195
+ end
196
+
197
+ def self.top_down
198
+ e = new
199
+ e.direction = :top_down
200
+
201
+ e
202
+ end
203
+
204
+ def self.bottom_up
205
+ new
206
+ end
207
+
208
+ def on_value(&block)
209
+ @hooks[Syntax::Value] = block
210
+ self
211
+ end
212
+
213
+ def on_argument(&block)
214
+ @hooks[Syntax::Argument] = block
215
+ self
216
+ end
217
+
218
+ def on_directive(&block)
219
+ @hooks[Syntax::Directive] = block
220
+ self
221
+ end
222
+
223
+ def on_operation(&block)
224
+ @hooks[Syntax::Operation] = block
225
+ self
226
+ end
227
+
228
+ def on_variable(&block)
229
+ on_variable_definition(&block)
230
+ on_variable_reference(&block)
231
+ self
232
+ end
233
+
234
+ def on_variable_definition(&block)
235
+ @hooks[Syntax::VariableDefinition] = block
236
+ self
237
+ end
238
+
239
+ def on_variable_reference(&block)
240
+ @hooks[Syntax::VariableReference] = block
241
+ self
242
+ end
243
+
244
+ # Selected nodes:
245
+
246
+ def on_field(&block)
247
+ @hooks[Syntax::Field] = block
248
+ self
249
+ end
250
+
251
+ def on_fragment(&block)
252
+ on_inline_fragment(&block)
253
+ on_fragment_definition(&block)
254
+ self
255
+ end
256
+
257
+ def on_fragment_spread(&block)
258
+ @hooks[Syntax::FragmentSpread] = block
259
+ self
260
+ end
261
+
262
+ def on_inline_fragment(&block)
263
+ @hooks[Syntax::InlineFragment] = block
264
+ self
265
+ end
266
+
267
+ def on_fragment_definition(&block)
268
+ @hooks[Syntax::Fragment] = block
269
+ self
270
+ end
271
+
272
+ # To edit specific nodes in a document (or isolated from a document)
273
+ # you will need a Context.
274
+ def context(document = nil)
275
+ Context.new(document, direction, @hooks.dup.freeze, [])
276
+ end
277
+
278
+ def edit(document)
279
+ c = context(document)
280
+
281
+ document.definitions = document.definitions.flat_map do |object|
282
+ c.edit_definition(object)
283
+ end
284
+
285
+ document
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Graphlyte
4
+ module Editors
5
+ # Use a schema definition to annotate the type of each field and variable reference.
6
+ class AnnotateTypes
7
+ TypeCheckError = Class.new(StandardError)
8
+ TypeNotFound = Class.new(TypeCheckError)
9
+ FieldNotFound = Class.new(TypeCheckError)
10
+ CannotDetermineTypeName = Class.new(TypeCheckError)
11
+
12
+ def initialize(schema, recheck: false)
13
+ @schema = schema
14
+ @recheck = recheck
15
+ end
16
+
17
+ def edit(doc)
18
+ return if !recheck && doc.schema # Previously annotated
19
+
20
+ doc.schema = @schema
21
+ editor.edit(doc)
22
+ end
23
+
24
+ def editor
25
+ @editor ||=
26
+ Editor
27
+ .top_down
28
+ .on_field { |field, action| infer_field(field, action.parent, action.document) }
29
+ .on_variable_reference do |ref, action|
30
+ infer_ref(ref, action.closest(Syntax::Argument), action.closest(Syntax::Field))
31
+ end
32
+ end
33
+
34
+ # For now we are ignoring variables nested in input objects.
35
+ # TODO: encode input objects differently?
36
+ def infer_ref(ref, argument, field)
37
+ type = @schema.types[field.type.unpack]
38
+ arg = type.arguments[argument.name]
39
+ raise ArgumentNotFound, "#{type.name}.#{field.name}(#{argument.name})" unless arg
40
+
41
+ ref.inferred_type = Syntax::Type.from_type_ref(arg.type)
42
+ end
43
+
44
+ def infer_field(field, parent, document)
45
+ name = object_name(parent, document)
46
+ object_type = type(name)
47
+
48
+ raise FieldNotFound, "#{object_name}.#{field.name}" unless object_type.fields.key?(field.name)
49
+
50
+ field.type = Syntax::Type.from_type_ref(object_type.fields[field.name].type)
51
+ end
52
+
53
+ def type(name)
54
+ object_type = @schema.types[name]
55
+ raise TypeNotFound, object_name unless object_type
56
+
57
+ object_type
58
+ end
59
+
60
+ def object_name(parent, document)
61
+ case parent
62
+ when Syntax::FragmentSpread
63
+ fragment = document.fragments[parent.name]
64
+ raise CannotDetermineTypeName, parent unless fragment
65
+
66
+ fragment.type_name
67
+ when Syntax::InlineFragment, Syntax::Fragment
68
+ parent.type_name
69
+ when Syntax::Field
70
+ parent.type.unpack
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../editor'
4
+ require_relative '../syntax'
5
+ require_relative './inline_fragments'
6
+
7
+ module Graphlyte
8
+ module Editors
9
+ # Reduce the query to a canonical form.
10
+ class Canonicalize
11
+ def edit(doc)
12
+ doc = doc.dup
13
+ InlineFragments.new.edit(doc)
14
+ doc.definitions = doc.definitions.sort_by(&:name)
15
+ # TODO: we should also perform the selection Merge operation here.
16
+ order_argments.edit(doc)
17
+ end
18
+
19
+ def order_argments
20
+ Editor.new
21
+ .on_field { |field| field.arguments = field.arguments&.sort_by(&:name) }
22
+ .on_directive { |dir| dir.arguments = dir.arguments&.sort_by(&:name) }
23
+ end
24
+ end
25
+ end
26
+ end