reportinator 0.1.1 → 0.3.2

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 (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