ruby-druid 0.1.1
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.
- checksums.yaml +15 -0
- data/.gitignore +5 -0
- data/Gemfile +21 -0
- data/Guardfile +10 -0
- data/LICENSE +20 -0
- data/README.md +286 -0
- data/Rakefile +1 -0
- data/bin/dripl +40 -0
- data/dot_driplrc_example +12 -0
- data/lib/druid.rb +8 -0
- data/lib/druid/client.rb +95 -0
- data/lib/druid/console.rb +66 -0
- data/lib/druid/filter.rb +216 -0
- data/lib/druid/having.rb +53 -0
- data/lib/druid/post_aggregation.rb +111 -0
- data/lib/druid/query.rb +175 -0
- data/lib/druid/response_row.rb +32 -0
- data/lib/druid/zoo_handler.rb +129 -0
- data/ruby-druid.gemspec +19 -0
- data/spec/lib/client_spec.rb +69 -0
- data/spec/lib/query_spec.rb +377 -0
- data/spec/lib/zoo_handler_spec.rb +200 -0
- data/spec/spec_helper.rb +2 -0
- metadata +96 -0
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'active_support/time'
|
2
|
+
require 'ap'
|
3
|
+
require 'forwardable'
|
4
|
+
require 'irb'
|
5
|
+
require 'ripl'
|
6
|
+
require 'terminal-table'
|
7
|
+
|
8
|
+
require 'druid'
|
9
|
+
|
10
|
+
Ripl::Shell.class_eval do
|
11
|
+
def format_query_result(result, query)
|
12
|
+
|
13
|
+
include_timestamp = query.properties[:granularity] != 'all'
|
14
|
+
|
15
|
+
keys = result.empty? ? [] : result.last.keys
|
16
|
+
|
17
|
+
Terminal::Table.new({
|
18
|
+
headings: (include_timestamp ? ["timestamp"] : []) + keys,
|
19
|
+
rows: result.map { |row| (include_timestamp ? [row.timestamp] : []) + row.values }
|
20
|
+
})
|
21
|
+
end
|
22
|
+
|
23
|
+
def format_result(result)
|
24
|
+
if result.is_a?(Druid::Query)
|
25
|
+
puts format_query_result(result.send, result)
|
26
|
+
else
|
27
|
+
ap(result)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
module Druid
|
33
|
+
class Console
|
34
|
+
|
35
|
+
extend Forwardable
|
36
|
+
|
37
|
+
def initialize(uri, source, options)
|
38
|
+
@uri, @source, @options = uri, source, options
|
39
|
+
Ripl.start(binding: binding)
|
40
|
+
end
|
41
|
+
|
42
|
+
def client
|
43
|
+
@client ||= Druid::Client.new(@uri, @options)
|
44
|
+
@source ||= @client.data_sources[0]
|
45
|
+
@client
|
46
|
+
end
|
47
|
+
|
48
|
+
def source
|
49
|
+
client.data_source(@source)
|
50
|
+
end
|
51
|
+
|
52
|
+
def dimensions
|
53
|
+
source.dimensions
|
54
|
+
end
|
55
|
+
|
56
|
+
def metrics
|
57
|
+
source.metrics
|
58
|
+
end
|
59
|
+
|
60
|
+
def query
|
61
|
+
client.query(@source)
|
62
|
+
end
|
63
|
+
|
64
|
+
def_delegators :query, :group_by, :sum, :long_sum, :postagg, :interval, :granularity, :filter, :time_series
|
65
|
+
end
|
66
|
+
end
|
data/lib/druid/filter.rb
ADDED
@@ -0,0 +1,216 @@
|
|
1
|
+
module Druid
|
2
|
+
class Filter
|
3
|
+
(instance_methods + private_instance_methods).each do |method|
|
4
|
+
unless method.to_s =~ /^(__|instance_eval|instance_exec|initialize|object_id|raise|puts|inspect)/ || method.to_s =~ /\?/
|
5
|
+
undef_method method
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
def method_missing(method_id, *args)
|
10
|
+
FilterDimension.new(method_id)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class FilterParameter
|
15
|
+
(instance_methods + private_instance_methods).each do |method|
|
16
|
+
unless method.to_s =~ /^(__|instance_eval|instance_exec|initialize|object_id|raise|puts|inspect|class)/ || method.to_s =~ /\?/
|
17
|
+
undef_method method
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
to_hash.to_s
|
23
|
+
end
|
24
|
+
|
25
|
+
def as_json(*a)
|
26
|
+
to_hash
|
27
|
+
end
|
28
|
+
|
29
|
+
def to_json(*a)
|
30
|
+
to_hash.to_json(*a)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class FilterDimension < FilterParameter
|
35
|
+
def initialize(name)
|
36
|
+
@name = name
|
37
|
+
@value = nil
|
38
|
+
@regexp = nil
|
39
|
+
end
|
40
|
+
|
41
|
+
def eq(value)
|
42
|
+
return self.in(value) if value.is_a? Array
|
43
|
+
return self.regexp(value) if value.is_a? Regexp
|
44
|
+
@value = value
|
45
|
+
self
|
46
|
+
end
|
47
|
+
|
48
|
+
alias :'==' :eq
|
49
|
+
|
50
|
+
|
51
|
+
def neq(value)
|
52
|
+
return !self.in(value)
|
53
|
+
end
|
54
|
+
|
55
|
+
alias :'!=' :neq
|
56
|
+
|
57
|
+
def in(*args)
|
58
|
+
values = args.flatten
|
59
|
+
raise "Must provide non-empty array in in()" if values.empty?
|
60
|
+
|
61
|
+
if (values.length == 1)
|
62
|
+
return self.eq(values[0])
|
63
|
+
end
|
64
|
+
|
65
|
+
filter_or = FilterOperator.new('or', true)
|
66
|
+
values.each do |value|
|
67
|
+
raise "query is too complex" if value.is_a? FilterParameter
|
68
|
+
param = FilterDimension.new(@name)
|
69
|
+
param.eq value
|
70
|
+
filter_or.add param
|
71
|
+
end
|
72
|
+
filter_or
|
73
|
+
end
|
74
|
+
|
75
|
+
def &(other)
|
76
|
+
filter_and = FilterOperator.new('and', true)
|
77
|
+
filter_and.add(self)
|
78
|
+
filter_and.add(other)
|
79
|
+
filter_and
|
80
|
+
end
|
81
|
+
|
82
|
+
def |(other)
|
83
|
+
filter_or = FilterOperator.new('or', true)
|
84
|
+
filter_or.add(self)
|
85
|
+
filter_or.add(other)
|
86
|
+
filter_or
|
87
|
+
end
|
88
|
+
|
89
|
+
def !()
|
90
|
+
filter_not = FilterOperator.new('not', false)
|
91
|
+
filter_not.add(self)
|
92
|
+
filter_not
|
93
|
+
end
|
94
|
+
|
95
|
+
def >(value)
|
96
|
+
filter_js = FilterJavascript.new_comparison(@name, '>', value)
|
97
|
+
filter_js
|
98
|
+
end
|
99
|
+
|
100
|
+
def <(value)
|
101
|
+
filter_js = FilterJavascript.new_comparison(@name, '<', value)
|
102
|
+
filter_js
|
103
|
+
end
|
104
|
+
|
105
|
+
def >=(value)
|
106
|
+
filter_js = FilterJavascript.new_comparison(@name, '>=', value)
|
107
|
+
filter_js
|
108
|
+
end
|
109
|
+
|
110
|
+
def <=(value)
|
111
|
+
filter_js = FilterJavascript.new_comparison(@name, '<=', value)
|
112
|
+
filter_js
|
113
|
+
end
|
114
|
+
|
115
|
+
def javascript(js)
|
116
|
+
filter_js = FilterJavascript.new(@name, js)
|
117
|
+
filter_js
|
118
|
+
end
|
119
|
+
|
120
|
+
def regexp(r)
|
121
|
+
r = Regexp.new(r) unless r.is_a? Regexp
|
122
|
+
@regexp = r.inspect[1...-1] #to_s doesn't work
|
123
|
+
self
|
124
|
+
end
|
125
|
+
|
126
|
+
def to_hash
|
127
|
+
raise 'no value assigned' unless @value.nil? ^ @regexp.nil?
|
128
|
+
hash = {
|
129
|
+
:dimension => @name
|
130
|
+
}
|
131
|
+
if @value
|
132
|
+
hash['type'] = 'selector'
|
133
|
+
hash['value'] = @value
|
134
|
+
elsif @regexp
|
135
|
+
hash['type'] = 'regex'
|
136
|
+
hash['pattern'] = @regexp
|
137
|
+
end
|
138
|
+
hash
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
class FilterOperator < FilterParameter
|
143
|
+
def initialize(name, takes_many)
|
144
|
+
@name = name
|
145
|
+
@takes_many = takes_many
|
146
|
+
@elements = []
|
147
|
+
end
|
148
|
+
|
149
|
+
def add(element)
|
150
|
+
@elements.push element
|
151
|
+
end
|
152
|
+
|
153
|
+
def &(other)
|
154
|
+
if @name == 'and'
|
155
|
+
filter_and = self
|
156
|
+
else
|
157
|
+
filter_and = FilterOperator.new('and', true)
|
158
|
+
filter_and.add(self)
|
159
|
+
end
|
160
|
+
filter_and.add(other)
|
161
|
+
filter_and
|
162
|
+
end
|
163
|
+
|
164
|
+
def |(other)
|
165
|
+
if @name == 'or'
|
166
|
+
filter_or = self
|
167
|
+
else
|
168
|
+
filter_or = FilterOperator.new('or', true)
|
169
|
+
filter_or.add(self)
|
170
|
+
end
|
171
|
+
filter_or.add(other)
|
172
|
+
filter_or
|
173
|
+
end
|
174
|
+
|
175
|
+
def !()
|
176
|
+
if @name == 'not'
|
177
|
+
@elements[0]
|
178
|
+
else
|
179
|
+
filter_not = FilterOperator.new('not', false)
|
180
|
+
filter_not.add(self)
|
181
|
+
filter_not
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def to_hash
|
186
|
+
result = {
|
187
|
+
:type => @name
|
188
|
+
}
|
189
|
+
if @takes_many
|
190
|
+
result[:fields] = @elements.map(&:to_hash)
|
191
|
+
else
|
192
|
+
result[:field] = @elements[0].to_hash
|
193
|
+
end
|
194
|
+
result
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
class FilterJavascript < FilterDimension
|
199
|
+
def initialize(dimension, expression)
|
200
|
+
@dimension = dimension
|
201
|
+
@expression = expression
|
202
|
+
end
|
203
|
+
|
204
|
+
def self.new_comparison(dimension, operator, value)
|
205
|
+
self.new(dimension, "#{dimension} #{operator} #{value.is_a?(String) ? "'#{value}'" : value}")
|
206
|
+
end
|
207
|
+
|
208
|
+
def to_hash
|
209
|
+
{
|
210
|
+
:type => 'javascript',
|
211
|
+
:dimension => @dimension,
|
212
|
+
:function => "function(#{@dimension}) { return(#{@expression}); }"
|
213
|
+
}
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
data/lib/druid/having.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
module Druid
|
2
|
+
class Having
|
3
|
+
def method_missing(name, *args)
|
4
|
+
if args.empty?
|
5
|
+
HavingClause.new(name)
|
6
|
+
end
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
class HavingClause
|
11
|
+
(instance_methods + private_instance_methods).each do |method|
|
12
|
+
unless method.to_s =~ /^(__|instance_eval|instance_exec|initialize|object_id|raise|puts|inspect|class)/ || method.to_s =~ /\?/
|
13
|
+
undef_method method
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(metric)
|
18
|
+
@metric = metric
|
19
|
+
end
|
20
|
+
|
21
|
+
def <(value)
|
22
|
+
@type = "lessThan"
|
23
|
+
@value = value
|
24
|
+
self
|
25
|
+
end
|
26
|
+
|
27
|
+
def >(value)
|
28
|
+
@type = "greaterThan"
|
29
|
+
@value = value
|
30
|
+
self
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_s
|
34
|
+
to_hash.to_s
|
35
|
+
end
|
36
|
+
|
37
|
+
def as_json(*a)
|
38
|
+
to_hash
|
39
|
+
end
|
40
|
+
|
41
|
+
def to_json(*a)
|
42
|
+
to_hash.to_json(*a)
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_hash
|
46
|
+
{
|
47
|
+
:type => @type,
|
48
|
+
:aggregation => @metric,
|
49
|
+
:value => @value
|
50
|
+
}
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
module Druid
|
2
|
+
class PostAggregation
|
3
|
+
def method_missing(name, *args)
|
4
|
+
if args.empty?
|
5
|
+
PostAggregationField.new(name)
|
6
|
+
end
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
module PostAggregationOperators
|
11
|
+
def +(value)
|
12
|
+
PostAggregationOperation.new(self, :+, value)
|
13
|
+
end
|
14
|
+
|
15
|
+
def -(value)
|
16
|
+
PostAggregationOperation.new(self, :-, value)
|
17
|
+
end
|
18
|
+
|
19
|
+
def *(value)
|
20
|
+
PostAggregationOperation.new(self, :*, value)
|
21
|
+
end
|
22
|
+
|
23
|
+
def /(value)
|
24
|
+
PostAggregationOperation.new(self, :/, value)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class PostAggregationOperation
|
29
|
+
include PostAggregationOperators
|
30
|
+
|
31
|
+
attr_reader :left, :operator, :right, :name
|
32
|
+
|
33
|
+
def initialize(left, operator, right)
|
34
|
+
@left = left.is_a?(Numeric) ? PostAggregationConstant.new(left) : left
|
35
|
+
@operator = operator
|
36
|
+
@right = right.is_a?(Numeric) ? PostAggregationConstant.new(right) : right
|
37
|
+
end
|
38
|
+
|
39
|
+
def as(field)
|
40
|
+
@name = field.name.to_s
|
41
|
+
self
|
42
|
+
end
|
43
|
+
|
44
|
+
def get_field_names
|
45
|
+
field_names = []
|
46
|
+
field_names << left.get_field_names if left.respond_to?(:get_field_names)
|
47
|
+
field_names << right.get_field_names if right.respond_to?(:get_field_names)
|
48
|
+
field_names
|
49
|
+
end
|
50
|
+
|
51
|
+
def to_hash
|
52
|
+
hash = { "type" => "arithmetic", "fn" => @operator, "fields" => [@left.to_hash, @right.to_hash] }
|
53
|
+
hash["name"] = @name if @name
|
54
|
+
hash
|
55
|
+
end
|
56
|
+
|
57
|
+
def to_json(*a)
|
58
|
+
to_hash.to_json(*a)
|
59
|
+
end
|
60
|
+
|
61
|
+
def as_json(*a)
|
62
|
+
to_hash
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
class PostAggregationField
|
67
|
+
include PostAggregationOperators
|
68
|
+
|
69
|
+
attr_reader :name
|
70
|
+
|
71
|
+
def initialize(name)
|
72
|
+
@name = name
|
73
|
+
end
|
74
|
+
|
75
|
+
def get_field_names
|
76
|
+
@name
|
77
|
+
end
|
78
|
+
|
79
|
+
def to_hash
|
80
|
+
{ "type" => "fieldAccess", "name" => @name, "fieldName" => @name }
|
81
|
+
end
|
82
|
+
|
83
|
+
def to_json(*a)
|
84
|
+
to_hash.to_json(*a)
|
85
|
+
end
|
86
|
+
|
87
|
+
def as_json(*a)
|
88
|
+
to_hash
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
class PostAggregationConstant
|
93
|
+
attr_reader :value
|
94
|
+
|
95
|
+
def initialize(value)
|
96
|
+
@value = value
|
97
|
+
end
|
98
|
+
|
99
|
+
def to_hash
|
100
|
+
{ "type" => "constant", "value" => @value }
|
101
|
+
end
|
102
|
+
|
103
|
+
def to_json(*a)
|
104
|
+
to_hash.to_json(*a)
|
105
|
+
end
|
106
|
+
|
107
|
+
def as_json(*a)
|
108
|
+
to_hash
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
data/lib/druid/query.rb
ADDED
@@ -0,0 +1,175 @@
|
|
1
|
+
require 'druid/filter'
|
2
|
+
require 'druid/having'
|
3
|
+
require 'druid/post_aggregation'
|
4
|
+
require 'time'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module Druid
|
8
|
+
class Query
|
9
|
+
|
10
|
+
attr_reader :properties
|
11
|
+
|
12
|
+
def initialize(source, client = nil)
|
13
|
+
@properties = {}
|
14
|
+
@client = client
|
15
|
+
|
16
|
+
# set some defaults
|
17
|
+
data_source(source)
|
18
|
+
granularity(:all)
|
19
|
+
|
20
|
+
interval(today)
|
21
|
+
end
|
22
|
+
|
23
|
+
def today
|
24
|
+
Time.now.to_date.to_time
|
25
|
+
end
|
26
|
+
|
27
|
+
def send
|
28
|
+
@client.send(self)
|
29
|
+
end
|
30
|
+
|
31
|
+
def query_type(type)
|
32
|
+
@properties[:queryType] = type
|
33
|
+
self
|
34
|
+
end
|
35
|
+
|
36
|
+
def get_query_type()
|
37
|
+
@properties[:queryType] || :groupBy
|
38
|
+
end
|
39
|
+
|
40
|
+
def data_source(source)
|
41
|
+
source = source.split('/')
|
42
|
+
@properties[:dataSource] = source.last
|
43
|
+
@service = source.first
|
44
|
+
self
|
45
|
+
end
|
46
|
+
|
47
|
+
def source
|
48
|
+
"#{@service}/#{@properties[:dataSource]}"
|
49
|
+
end
|
50
|
+
|
51
|
+
def group_by(*dimensions)
|
52
|
+
query_type(:groupBy)
|
53
|
+
@properties[:dimensions] = dimensions.flatten
|
54
|
+
self
|
55
|
+
end
|
56
|
+
|
57
|
+
def time_series(*aggregations)
|
58
|
+
query_type(:timeseries)
|
59
|
+
#@properties[:aggregations] = aggregations.flatten
|
60
|
+
self
|
61
|
+
end
|
62
|
+
|
63
|
+
[:long_sum, :double_sum].each do |method_name|
|
64
|
+
agg_type = method_name.to_s.split('_')
|
65
|
+
agg_type[1].capitalize!
|
66
|
+
agg_type = agg_type.join
|
67
|
+
|
68
|
+
define_method method_name do |*metrics|
|
69
|
+
query_type(get_query_type())
|
70
|
+
aggregations = @properties[:aggregations] || []
|
71
|
+
aggregations.concat(metrics.flatten.map{ |metric|
|
72
|
+
{
|
73
|
+
:type => agg_type,
|
74
|
+
:name => metric.to_s,
|
75
|
+
:fieldName => metric.to_s
|
76
|
+
}
|
77
|
+
}).uniq!
|
78
|
+
@properties[:aggregations] = aggregations
|
79
|
+
self
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
alias_method :sum, :long_sum
|
84
|
+
|
85
|
+
def postagg(type=:long, &block)
|
86
|
+
post_agg = PostAggregation.new.instance_exec(&block)
|
87
|
+
@properties[:postAggregations] ||= []
|
88
|
+
@properties[:postAggregations] << post_agg
|
89
|
+
|
90
|
+
# make sure, the required fields are in the query
|
91
|
+
field_type = (type.to_s + '_sum').to_sym
|
92
|
+
# ugly workaround, because SOMEONE overwrote send
|
93
|
+
sum_method = self.method(field_type)
|
94
|
+
sum_method.call(post_agg.get_field_names)
|
95
|
+
|
96
|
+
self
|
97
|
+
end
|
98
|
+
|
99
|
+
def postagg_double(&block)
|
100
|
+
postagg(:double, &block)
|
101
|
+
end
|
102
|
+
|
103
|
+
def filter(hash = nil, &block)
|
104
|
+
if hash
|
105
|
+
last = nil
|
106
|
+
hash.each do |k,values|
|
107
|
+
filter = FilterDimension.new(k).in(values)
|
108
|
+
last = last ? last.&(filter) : filter
|
109
|
+
end
|
110
|
+
@properties[:filter] = @properties[:filter] ? @properties[:filter].&(last) : last
|
111
|
+
end
|
112
|
+
if block
|
113
|
+
filter = Filter.new.instance_exec(&block)
|
114
|
+
raise "Not a valid filter" unless filter.is_a? FilterParameter
|
115
|
+
@properties[:filter] = @properties[:filter] ? @properties[:filter].&(filter) : filter
|
116
|
+
end
|
117
|
+
self
|
118
|
+
end
|
119
|
+
|
120
|
+
def interval(from, to = Time.now)
|
121
|
+
intervals([[from, to]])
|
122
|
+
end
|
123
|
+
|
124
|
+
def intervals(is)
|
125
|
+
@properties[:intervals] = is.map{ |ii| mk_interval(ii[0], ii[1]) }
|
126
|
+
self
|
127
|
+
end
|
128
|
+
|
129
|
+
def having(&block)
|
130
|
+
having = Having.new.instance_exec(&block)
|
131
|
+
@properties[:having] = having
|
132
|
+
self
|
133
|
+
end
|
134
|
+
|
135
|
+
alias_method :[], :interval
|
136
|
+
|
137
|
+
def granularity(gran, time_zone = nil)
|
138
|
+
gran = gran.to_s
|
139
|
+
case gran
|
140
|
+
when 'none', 'all', 'second', 'minute', 'fifteen_minute', 'thirty_minute', 'hour'
|
141
|
+
@properties[:granularity] = gran
|
142
|
+
return self
|
143
|
+
when 'day'
|
144
|
+
gran = 'P1D'
|
145
|
+
end
|
146
|
+
|
147
|
+
time_zone ||= Time.now.strftime('%Z')
|
148
|
+
# druid doesn't seem to understand 'CEST'
|
149
|
+
# this is a work around
|
150
|
+
time_zone = 'Europe/Berlin' if time_zone == 'CEST'
|
151
|
+
|
152
|
+
@properties[:granularity] = {
|
153
|
+
:type => 'period',
|
154
|
+
:period => gran,
|
155
|
+
:timeZone => time_zone
|
156
|
+
}
|
157
|
+
self
|
158
|
+
end
|
159
|
+
|
160
|
+
def to_json
|
161
|
+
@properties.to_json
|
162
|
+
end
|
163
|
+
|
164
|
+
private
|
165
|
+
|
166
|
+
def mk_interval(from, to)
|
167
|
+
from = today + from if from.is_a?(Fixnum)
|
168
|
+
to = today + to if to.is_a?(Fixnum)
|
169
|
+
|
170
|
+
from = DateTime.parse(from.to_s) unless from.respond_to? :iso8601
|
171
|
+
to = DateTime.parse(to.to_s) unless to.respond_to? :iso8601
|
172
|
+
"#{from.iso8601}/#{to.iso8601}"
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|