reportinator 0.1.1 → 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -7
  3. data/Gemfile.lock +11 -1
  4. data/README.md +201 -291
  5. data/app/reports/example.report.json +7 -3
  6. data/app/reports/multiplication.report.json +1 -1
  7. data/app/reports/multiplication_v2.report.json +15 -0
  8. data/data/schema/report_schema.json +76 -0
  9. data/docs/0_first_report.md +267 -0
  10. data/lib/reportinator/base.rb +2 -1
  11. data/lib/reportinator/config.rb +30 -0
  12. data/lib/reportinator/function.rb +33 -0
  13. data/lib/reportinator/functions/array/flatten.rb +12 -0
  14. data/lib/reportinator/functions/array/helper.rb +77 -0
  15. data/lib/reportinator/functions/array/join.rb +11 -0
  16. data/lib/reportinator/functions/array/method.rb +9 -0
  17. data/lib/reportinator/functions/array/range.rb +11 -0
  18. data/lib/reportinator/functions/array/snippet.rb +30 -0
  19. data/lib/reportinator/functions/array/string.rb +10 -0
  20. data/lib/reportinator/functions/array.rb +43 -0
  21. data/lib/reportinator/functions/string/addition.rb +11 -0
  22. data/lib/reportinator/functions/string/constant.rb +9 -0
  23. data/lib/reportinator/functions/string/date.rb +9 -0
  24. data/lib/reportinator/functions/string/join.rb +10 -0
  25. data/lib/reportinator/functions/string/logical.rb +14 -0
  26. data/lib/reportinator/functions/string/number.rb +33 -0
  27. data/lib/reportinator/functions/string/range.rb +14 -0
  28. data/lib/reportinator/functions/string/symbol.rb +9 -0
  29. data/lib/reportinator/functions/string/variable.rb +12 -0
  30. data/lib/reportinator/functions/string.rb +29 -0
  31. data/lib/reportinator/helpers.rb +29 -0
  32. data/lib/reportinator/parser.rb +25 -0
  33. data/lib/reportinator/parsers/method.rb +8 -3
  34. data/lib/reportinator/parsers/report.rb +47 -0
  35. data/lib/reportinator/parsers/value.rb +15 -112
  36. data/lib/reportinator/report/column.rb +25 -0
  37. data/lib/reportinator/report/loader.rb +71 -0
  38. data/lib/reportinator/report/report.rb +33 -0
  39. data/lib/reportinator/report/row.rb +42 -0
  40. data/lib/reportinator/report/template.rb +108 -0
  41. data/lib/reportinator/{report.rb → report_type.rb} +4 -1
  42. data/lib/reportinator/types/model.rb +15 -7
  43. data/lib/reportinator/types/preset.rb +23 -2
  44. data/lib/reportinator/version.rb +1 -1
  45. data/lib/reportinator.rb +23 -9
  46. metadata +48 -5
  47. data/lib/reportinator/loader.rb +0 -112
@@ -0,0 +1,76 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://moxvallix.com/meta/reportinator/report_schema.json",
4
+ "title": "Report",
5
+ "description": "A Reportinator Report",
6
+ "type": ["array", "object"],
7
+ "$defs": {
8
+ "template": {
9
+ "type": "object",
10
+ "properties": {
11
+ "type": {
12
+ "type": ["string", "array"]
13
+ },
14
+ "template": {
15
+ "type": ["string", "array"]
16
+ },
17
+ "metadata": {
18
+ "type": "object",
19
+ "properties": {
20
+ "snippets": {
21
+ "type": "object"
22
+ },
23
+ "variables": {
24
+ "type": "object"
25
+ }
26
+ }
27
+ },
28
+ "params": {
29
+ "type": "object"
30
+ }
31
+ },
32
+ "oneOf": [
33
+ {
34
+ "required": ["type"]
35
+ },
36
+ {
37
+ "required": ["template"]
38
+ }
39
+ ],
40
+ "allOf": [
41
+ {
42
+ "if": {
43
+ "properties": { "type": { "const": ":preset" } }
44
+ },
45
+ "then": {
46
+ "properties": { "params": { "properties": { "values": { "type": "array" } } } }
47
+ }
48
+ },
49
+ {
50
+ "if": {
51
+ "properties": { "type": { "const": ":model" } }
52
+ },
53
+ "then": {
54
+ "properties": {
55
+ "params": {
56
+ "properties": {
57
+ "target": { "type": ["string", "array"] },
58
+ "method_list": { "type": "array" }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ]
65
+ }
66
+ },
67
+ "if": {
68
+ "type": "object"
69
+ },
70
+ "then": {
71
+ "$ref": "#/$defs/template"
72
+ },
73
+ "else": {
74
+ "properties": { "items": { "$ref": "#/$defs/template" } }
75
+ }
76
+ }
@@ -0,0 +1,267 @@
1
+ # Creating my First Report
2
+ Let's start by considering what we want our output to be.
3
+ Say we want a multiplication table, like such:
4
+ | nx1 | nx2 | nx3 | nx4 | nx5 |
5
+ |-----|-----|-----|-----|-----|
6
+ | 1 | 2 | 3 | 4 | 5 |
7
+ | 2 | 4 | 6 | 8 | 10 |
8
+ | 3 | 6 | 9 | 12 | 15 |
9
+ | 4 | 8 | 12 | 16 | 20 |
10
+ | 5 | 10 | 15 | 20 | 25 |
11
+
12
+ Make a new file in your `app/reports` directory.
13
+ Name it `multiplication.report.json`
14
+
15
+ Set it's type to ":preset". The ":preset" type takes one parameter, "data",
16
+ and returns any values passed inside it, but with their values parsed.
17
+ We put a colon in front of the word "preset", such that the value parser
18
+ knows to turn it into a symbol.
19
+
20
+ ```
21
+ {
22
+ "type": ":preset"
23
+ }
24
+ ```
25
+
26
+ Next we need to give it parameters to be passed into the report.
27
+ ":preset" only accepts the "data" parameter.
28
+ Add "data" to a "params" object, and set it to be an empty array.
29
+
30
+ ```
31
+ {
32
+ "type": ":preset",
33
+ "params": {
34
+ "values": []
35
+ }
36
+ }
37
+ ```
38
+
39
+ You can now try running this report:
40
+
41
+ ```
42
+ > Reportinator.report("multiplication")
43
+ => []
44
+ ```
45
+
46
+ If all went to plan, you should have gotten an empty array.
47
+ Let's now add some data to this bad boy.
48
+
49
+ ```
50
+ {
51
+ "type": ":preset",
52
+ "params": {
53
+ "values": ["nx1","nx2","nx3","nx4","nx5"]
54
+ }
55
+ }
56
+ ```
57
+ ```
58
+ > Reportinator.report("multiplication")
59
+ => [["nx1", "nx2", "nx3", "nx4", "nx5"]]
60
+ ```
61
+
62
+ Now we could add the other rows ourselves, by adding more rows to "values":
63
+
64
+ ```
65
+ {
66
+ "type": ":preset",
67
+ "params": {
68
+ "values": [
69
+ ["nx1","nx2","nx3","nx4","nx5"],
70
+ [1, 2, 3, 4, 5],
71
+ [2, 4, 6, 8, 10],
72
+ [3, 6, 9, 12, 15],
73
+ [4, 8, 12, 16, 20],
74
+ [5, 10, 15, 20, 25]
75
+ ]
76
+ }
77
+ }
78
+ ```
79
+ ```
80
+ > Reportinator.report("multiplication")
81
+ =>
82
+ [
83
+ ["nx1", "nx2", "nx3", "nx4", "nx5"],
84
+ [1, 2, 3, 4, 5],
85
+ [2, 4, 6, 8, 10],
86
+ [3, 6, 9, 12, 15],
87
+ [4, 8, 12, 16, 20],
88
+ [5, 10, 15, 20, 25]
89
+ ]
90
+ ```
91
+
92
+ However, there is a cleaner way of doing this.
93
+ Move your entire report object inside of an array.
94
+ This allows us to string reports together in the same template.
95
+
96
+ ```
97
+ [
98
+ {
99
+ "type": ":preset",
100
+ "params": {
101
+ "values": ["nx1","nx2","nx3","nx4","nx5"]
102
+ }
103
+ }
104
+ ]
105
+ ```
106
+
107
+ Add a new report object underneath the first.
108
+ This time, the type will be ":model".
109
+
110
+ ":model" reports take two parameters:
111
+ 1. "target"
112
+ 2. "method_list"
113
+
114
+ Add both these keys to the "params" of the second report object.
115
+ Set both to be an empty array.
116
+
117
+ ```
118
+ [
119
+ {
120
+ "type": ":preset",
121
+ "params": {
122
+ "values": ["nx1","nx2","nx3","nx4","nx5"]
123
+ }
124
+ },
125
+ {
126
+ "type": ":model",
127
+ "params": {
128
+ "target": [],
129
+ "method_list": []
130
+ }
131
+ }
132
+ ]
133
+ ```
134
+
135
+ Model reports take a target, as specified in "target", and run methods against it,
136
+ specified in "method_list", saving the outputs of each to the row.
137
+
138
+ If the target is enumerable, said methods will run on each enumeration of the target,
139
+ each enumeration adding a new row to the report.
140
+
141
+ A method is specified by either a symbol, array or hash.
142
+ Lets take the string "100" as our target.
143
+
144
+ If our method was to be `":reverse"`, it would be the same as running"
145
+
146
+ ```
147
+ > "100".reverse
148
+ => "001"
149
+ ```
150
+
151
+ We can chain methods using an array. For example: `[":reverse", ":to_i"]`
152
+
153
+ ```
154
+ > "100".reverse.to_i
155
+ => 1
156
+ ```
157
+
158
+ Methods inside a hash allow for parameters to be passed to the method.
159
+ The value of the hash are passed as the parameters, and an array is passed
160
+ as multiple parameters.
161
+
162
+ Eg. `{"gsub": ["0", "1"]}`
163
+
164
+ ```
165
+ > "100".gsub("0", "1")
166
+ => "111"
167
+ ```
168
+
169
+ In Ruby, it turns out the multiplication "*" sign is a method.
170
+ Using this, we can write a much smarter report.
171
+
172
+ ```
173
+ [
174
+ {
175
+ "type": ":preset",
176
+ "params": {
177
+ "values": ["nx1","nx2","nx3","nx4","nx5"]
178
+ }
179
+ },
180
+ {
181
+ "type": ":model",
182
+ "params": {
183
+ "target": [1, 2, 3, 4, 5],
184
+ "method_list": [{"*": 1},{"*": 2},{"*": 3},{"*": 4},{"*": 5}]
185
+ }
186
+ }
187
+ ]
188
+ ```
189
+
190
+ The "*" is behaving exactly the same way as our "gsub" example earlier.
191
+
192
+ If we run our report again:
193
+
194
+ ```
195
+ > Reportinator.report("multiplication")
196
+ =>
197
+ [
198
+ ["nx1", "nx2", "nx3", "nx4", "nx5"],
199
+ [1, 2, 3, 4, 5],
200
+ [2, 4, 6, 8, 10],
201
+ [3, 6, 9, 12, 15],
202
+ [4, 8, 12, 16, 20],
203
+ [5, 10, 15, 20, 25]
204
+ ]
205
+ ```
206
+
207
+ The result should be exactly the same.
208
+
209
+ This is pretty good, but we can do better!
210
+ Notice how the "target" was an array? As it is enumerable,
211
+ we could run our methods against each element within it.
212
+
213
+ But what if we wanted to have 10 rows? Or 50? Soon our array is going to get pretty long.
214
+
215
+ This is where a range would be perfect. Set the start value to 1, the end to whatever number we need,
216
+ and then we go from there.
217
+
218
+ Unfortunately, we can't use a range in JSON.
219
+
220
+ ... or can we?
221
+
222
+ Reportinator has a bunch of handy built in functions, for converting strings.
223
+ We have already seen ":symbol" to make a string into a symbol.
224
+
225
+ We won't explore all the functions now, but we will explore "!r".
226
+ Or more specifically, "!rn", which auto converts strings into numbers as well.
227
+
228
+ We can make a range simply by writing "!rn 1,5". It takes the number before the comma,
229
+ as the start of the range, and the one after as the end.
230
+
231
+ We can test this with the actual parse method:
232
+
233
+ ```
234
+ > Reportinator.parse("!rn 1, 5")
235
+ => (1..5)
236
+ ```
237
+
238
+ Let's add this now as the target of our report:
239
+
240
+ ```
241
+ [
242
+ {
243
+ "type": ":preset",
244
+ "params": {
245
+ "values": ["nx1","nx2","nx3","nx4","nx5"]
246
+ }
247
+ },
248
+ {
249
+ "type": ":model",
250
+ "params": {
251
+ "target": "!rn 1,5",
252
+ "method_list": [{"*": 1},{"*": 2},{"*": 3},{"*": 4},{"*": 5}]
253
+ }
254
+ }
255
+ ]
256
+ ```
257
+
258
+ Finally, rather than peering at the console to see if it worked,
259
+ lets put it into a csv.
260
+
261
+ ```
262
+ > Reportinator.output("multiplication.csv","multiplication")
263
+ => "multiplication.csv"
264
+ ```
265
+
266
+ Open the csv up in your spreadsheet viewer of choice, and revel
267
+ in your brand new report!
@@ -2,9 +2,10 @@ module Reportinator
2
2
  class Base
3
3
  include ActiveModel::API
4
4
  include ActiveModel::Attributes
5
-
6
5
  require_rel "."
7
6
 
7
+ include Helpers
8
+
8
9
  def self.config
9
10
  Reportinator.config
10
11
  end
@@ -6,10 +6,31 @@ module Reportinator
6
6
  }
7
7
  DEFAULT_REPORT_DIRS = ["reports", "app/reports"]
8
8
  DEFAULT_REPORT_SUFFIXES = ["report.json", "json"]
9
+ DEFAULT_FUNCTIONS = [
10
+ "Reportinator::HelperArrayFunction",
11
+ "Reportinator::JoinArrayFunction",
12
+ "Reportinator::RangeArrayFunction",
13
+ "Reportinator::StringArrayFunction",
14
+ "Reportinator::SnippetArrayFunction",
15
+ "Reportinator::FlattenArrayFunction",
16
+ "Reportinator::MethodArrayFunction",
17
+ "Reportinator::AdditionStringFunction",
18
+ "Reportinator::ConstantStringFunction",
19
+ "Reportinator::DateStringFunction",
20
+ "Reportinator::JoinStringFunction",
21
+ "Reportinator::LogicalStringFunction",
22
+ "Reportinator::NumberStringFunction",
23
+ "Reportinator::RangeStringFunction",
24
+ "Reportinator::SymbolStringFunction",
25
+ "Reportinator::VariableStringFunction"
26
+ ]
27
+ DEFAULT_UNPARSEDS = [:snippets]
9
28
 
10
29
  attribute :report_directories, default: []
11
30
  attribute :report_suffixes, default: []
12
31
  attribute :report_types, default: {}
32
+ attribute :parser_functions, default: []
33
+ attribute :unparsed_metadata, default: []
13
34
  attribute :output_directory, default: "reports"
14
35
 
15
36
  def configured_directories
@@ -24,5 +45,14 @@ module Reportinator
24
45
  types = DEFAULT_TYPES
25
46
  types.merge(report_types)
26
47
  end
48
+
49
+ def configured_functions
50
+ functions = DEFAULT_FUNCTIONS + parser_functions
51
+ functions.map { |function| function.constantize }
52
+ end
53
+
54
+ def configured_metadata
55
+ DEFAULT_UNPARSEDS + unparsed_metadata
56
+ end
27
57
  end
28
58
  end
@@ -0,0 +1,33 @@
1
+ module Reportinator
2
+ class Function < Base
3
+ attribute :element
4
+ attribute :metadata, default: {}
5
+
6
+ def self.parse(element, metadata = {})
7
+ new(element: element, metadata: metadata).get
8
+ end
9
+
10
+ def parse_value(value)
11
+ ValueParser.parse(value, metadata)
12
+ end
13
+
14
+ def parse_and_execute_value(target, value)
15
+ ValueParser.parse_and_execute(target, value, metadata)
16
+ end
17
+
18
+ def prefixes
19
+ self.class::PREFIXES
20
+ end
21
+
22
+ private
23
+
24
+ def get_prefix(value)
25
+ raise "Value is not a string" unless value.instance_of? String
26
+ sorted_prefixes = prefixes.sort.reverse
27
+ sorted_prefixes.each do |prefix|
28
+ return prefix if value.start_with? prefix
29
+ end
30
+ raise "Value #{value} is incompatible with this function!"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,12 @@
1
+ module Reportinator
2
+ class FlattenArrayFunction < ArrayFunction
3
+ PREFIXES = [">flatten"]
4
+
5
+ def output
6
+ array = []
7
+ array.append parse_value(target)
8
+ array.append parse_value(values)
9
+ array.flatten
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,77 @@
1
+ module Reportinator
2
+ class HelperArrayFunction < ArrayFunction
3
+ PREFIXES = [">strf", ">offset", ">title", ">sum"]
4
+
5
+ INTERVALS = {
6
+ days: {start: "at_beginning_of_day", end: "at_end_of_day"},
7
+ weeks: {start: "at_beginning_of_week", end: "at_end_of_week"},
8
+ months: {start: "at_beginning_of_month", end: "at_end_of_month"},
9
+ years: {start: "at_beginning_of_year", end: "at_end_of_year"}
10
+ }
11
+
12
+ attr_writer :parsed_target, :parsed_values
13
+
14
+ def output
15
+ return parse_strftime if prefix == ">strf"
16
+ return parse_offset if prefix == ">offset"
17
+ return parse_title if prefix == ">title"
18
+ return parse_sum if prefix == ">sum"
19
+ element
20
+ end
21
+
22
+ def target_to_time
23
+ parsed_target.to_s.to_time
24
+ end
25
+
26
+ def parse_strftime
27
+ time = target_to_time
28
+ return "Invalid Time" if time.nil?
29
+ return "Invalid Format" unless parsed_values[0].instance_of? String
30
+ time.strftime parsed_values[0]
31
+ end
32
+
33
+ def parse_offset
34
+ time = target_to_time
35
+ return "Invalid Time" if time.nil?
36
+ offset = parsed_values[0]
37
+ return "Missing Offset" unless offset.present?
38
+ interval = parsed_values[1]
39
+ snap = parsed_values[2]
40
+ calculate_offset(time, offset, interval, snap)
41
+ end
42
+
43
+ def calculate_offset(time, offset, interval, snap)
44
+ interval = (interval.present? ? interval : :months)
45
+ interval = interval.to_s.pluralize.to_sym
46
+ return "Invalid Interval" unless INTERVALS.include? interval
47
+ interval_data = INTERVALS[interval]
48
+ snap = false unless interval_data.include? snap
49
+ output = time.advance({interval => offset})
50
+ output = output.send(interval_data[snap]) if snap.present?
51
+ output
52
+ end
53
+
54
+ def parse_title
55
+ to_join = [parsed_target] + parsed_values
56
+ to_join.join(" ").titleize
57
+ end
58
+
59
+ def parse_sum
60
+ sum_values = parsed_values.append parsed_target
61
+ sum_values.sum { |value| parse_value("!n #{value}") }
62
+ end
63
+
64
+ def parsed_target
65
+ @parsed_target ||= parse_target
66
+ end
67
+
68
+ def parse_target
69
+ formatted_target = (target.instance_of?(String) ? target.strip : target)
70
+ parse_value(formatted_target)
71
+ end
72
+
73
+ def parsed_values
74
+ @parsed_values ||= values.map { |value| parse_value(value) }
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,11 @@
1
+ module Reportinator
2
+ class JoinArrayFunction < ArrayFunction
3
+ PREFIXES = [">join"]
4
+
5
+ def output
6
+ joiner = parse_value(target)
7
+ joiner = (joiner.instance_of?(String) ? joiner : target)
8
+ values.map { |value| parse_value(value) }.join(joiner)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ module Reportinator
2
+ class MethodArrayFunction < ArrayFunction
3
+ PREFIXES = ["#"]
4
+
5
+ def output
6
+ parse_and_execute_value(target, values)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ module Reportinator
2
+ class RangeArrayFunction < ArrayFunction
3
+ PREFIXES = [">range"]
4
+
5
+ def output
6
+ start_value = parse_value(target)
7
+ end_value = parse_value(values[0])
8
+ (start_value..end_value)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,30 @@
1
+ module Reportinator
2
+ class SnippetArrayFunction < ArrayFunction
3
+ PREFIXES = [">snippet"]
4
+
5
+ def output
6
+ name = target
7
+ name.strip! if name.instance_of? String
8
+ parsed_name = parse_value(name)
9
+ snippet = find_snippet(parsed_name)
10
+ return "Missing Snippet" unless snippet.present?
11
+ parse_snippet(snippet)
12
+ end
13
+
14
+ def find_snippet(name)
15
+ snippets = metadata[:snippets]
16
+ return false unless snippets.present?
17
+ return false unless snippets[name].present?
18
+ snippets[name]
19
+ end
20
+
21
+ def parse_snippet(snippet)
22
+ snippet_metadata = metadata.dup
23
+ snippet_metadata.delete :snippets
24
+ variables = values[0]
25
+ parsed_variables = parse_value(variables)
26
+ input_metadata = merge_hash(snippet_metadata, {variables: parsed_variables})
27
+ ValueParser.parse(snippet, input_metadata)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,10 @@
1
+ module Reportinator
2
+ class StringArrayFunction < ArrayFunction
3
+ PREFIXES = [">string"]
4
+
5
+ def output
6
+ values.prepend target
7
+ values.map { |value| parse_value(value).to_s }.join
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,43 @@
1
+ module Reportinator
2
+ class ArrayFunction < Function
3
+ PREFIXES = []
4
+
5
+ attribute :prefix
6
+ attribute :target
7
+ attribute :values
8
+
9
+ def self.accepts? input
10
+ return false unless input.instance_of? Array
11
+ return false if self::PREFIXES.empty?
12
+ return false unless input[0].instance_of? String
13
+ self::PREFIXES.each do |prefix|
14
+ return true if input[0].start_with? prefix
15
+ end
16
+ false
17
+ end
18
+
19
+ def get
20
+ raise "Function missing output!" unless respond_to? :output
21
+ if set_attributes
22
+ output
23
+ else
24
+ element
25
+ end
26
+ end
27
+
28
+ def set_attributes
29
+ array = element
30
+ prefix = get_prefix(array[0])
31
+ target_value = array.delete_at(0).sub(prefix, "")
32
+ target_value = array.delete_at(0) if target_value.empty?
33
+ if target_value.to_s.empty?
34
+ false
35
+ else
36
+ self.prefix = prefix
37
+ self.target = target_value
38
+ self.values = array
39
+ true
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,11 @@
1
+ module Reportinator
2
+ class AdditionStringFunction < StringFunction
3
+ PREFIXES = ["!a"]
4
+
5
+ def output
6
+ values = body.split(",").map { |value| parse_value(value.strip) }
7
+ values.map! { |subvalue| NumberStringFunction.parse("!n #{subvalue}") }
8
+ values.sum(0)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ module Reportinator
2
+ class ConstantStringFunction < StringFunction
3
+ PREFIXES = ["&"]
4
+
5
+ def output
6
+ body.constantize
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Reportinator
2
+ class DateStringFunction < StringFunction
3
+ PREFIXES = ["!d"]
4
+
5
+ def output
6
+ Time.parse(body)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ module Reportinator
2
+ class JoinStringFunction < StringFunction
3
+ PREFIXES = ["!j"]
4
+
5
+ def output
6
+ values = body.split(",").map { |value| parse_value(value.strip) }
7
+ values.join(" ")
8
+ end
9
+ end
10
+ end