redlics 0.1.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.
- checksums.yaml +7 -0
- data/.gitignore +6 -0
- data/.travis.yml +24 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +64 -0
- data/Rakefile +9 -0
- data/lib/redlics.rb +94 -0
- data/lib/redlics/config.rb +62 -0
- data/lib/redlics/connection.rb +79 -0
- data/lib/redlics/counter.rb +94 -0
- data/lib/redlics/exception.rb +30 -0
- data/lib/redlics/granularity.rb +49 -0
- data/lib/redlics/key.rb +244 -0
- data/lib/redlics/lua/script.lua +82 -0
- data/lib/redlics/operators.rb +51 -0
- data/lib/redlics/query.rb +234 -0
- data/lib/redlics/query/operation.rb +135 -0
- data/lib/redlics/time_frame.rb +110 -0
- data/lib/redlics/tracker.rb +64 -0
- data/lib/redlics/version.rb +4 -0
- data/redlics.gemspec +33 -0
- data/test/redlics_test.rb +13 -0
- metadata +167 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
module Redlics
|
2
|
+
|
3
|
+
# Exception namespace
|
4
|
+
module Exception
|
5
|
+
|
6
|
+
# Error Pattern namespace
|
7
|
+
module ErrorPatterns
|
8
|
+
NOSCRIPT = /^NOSCRIPT/.freeze
|
9
|
+
end
|
10
|
+
|
11
|
+
|
12
|
+
# Lua Range Error class
|
13
|
+
#
|
14
|
+
# Maximal Lua stack size for the method `unpack` is by default 8000.
|
15
|
+
# To change this parameter in Redis an own make and build of Redis is needed.
|
16
|
+
# @see https://github.com/antirez/redis/blob/3.2/deps/lua/src/luaconf.h
|
17
|
+
class LuaRangeError < StandardError;
|
18
|
+
|
19
|
+
# Initialization with default error message.
|
20
|
+
#
|
21
|
+
# @param msg [String] the error message
|
22
|
+
# @return [Redlics::Exception::LuaRangeError] error message
|
23
|
+
def initialize(msg = 'Too many keys (max. 8000 keys defined by LUAI_MAXCSTACK)')
|
24
|
+
super(msg)
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module Redlics
|
2
|
+
|
3
|
+
# Granularity namespace
|
4
|
+
module Granularity
|
5
|
+
|
6
|
+
extend self
|
7
|
+
|
8
|
+
|
9
|
+
# Validate granularities by given context.
|
10
|
+
#
|
11
|
+
# @param context [Hash] the hash of a context defined in Redlics::CONTEXTS
|
12
|
+
# @param granularities [Range] granularity range
|
13
|
+
# @param granularities [String] single granularity
|
14
|
+
# @param granularities [Array] granularity array
|
15
|
+
# @return [Array] includes all valid granularities
|
16
|
+
def validate(context, granularities)
|
17
|
+
check(granularities) || default(context)
|
18
|
+
end
|
19
|
+
|
20
|
+
|
21
|
+
# Get default granularities by given context.
|
22
|
+
#
|
23
|
+
# @param context [Hash] the hash of a context defined in Redlics::CONTEXTS
|
24
|
+
# @return [Array] includes all valid default granularities
|
25
|
+
def default(context)
|
26
|
+
check(Redlics.config["#{context[:long]}_granularity"]) || [Redlics.config.granularities.keys.first]
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
# Check if granularities are defined in the configuration.
|
33
|
+
#
|
34
|
+
# @param granularities [Range] granularity range
|
35
|
+
# @param granularities [String] single granularity
|
36
|
+
# @param granularities [Array] granularity array
|
37
|
+
# @return [Array] includes all valid granularities
|
38
|
+
def check(granularities)
|
39
|
+
keys = Redlics.config.granularities.keys
|
40
|
+
checked = if granularities.is_a?(Range)
|
41
|
+
keys[keys.index(granularities.first)..keys.index(granularities.last)]
|
42
|
+
else
|
43
|
+
[granularities].flatten & Redlics.config.granularities.keys
|
44
|
+
end
|
45
|
+
checked.any? ? checked : nil
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
end
|
data/lib/redlics/key.rb
ADDED
@@ -0,0 +1,244 @@
|
|
1
|
+
module Redlics
|
2
|
+
|
3
|
+
# Key namespace
|
4
|
+
module Key
|
5
|
+
|
6
|
+
extend self
|
7
|
+
|
8
|
+
|
9
|
+
# Construct the key name with given parameters.
|
10
|
+
#
|
11
|
+
# @param context [Hash] the hash of a context defined in Redlics::CONTEXTS
|
12
|
+
# @param event [String] event name with eventual Redis namespace separator
|
13
|
+
# @param granularity [Symbol] existing granularity
|
14
|
+
# @param past [Time] a time object
|
15
|
+
# @param options [Hash] configuration options
|
16
|
+
# @return [String] unbucketized key name
|
17
|
+
# @return [Array] bucketized key name
|
18
|
+
def name(context, event, granularity, past, options = {})
|
19
|
+
past ||= Time.now
|
20
|
+
granularity = Granularity.validate(context, granularity).first
|
21
|
+
event = encode_event(event) if Redlics.config.encode[:events]
|
22
|
+
key = "#{context[:short]}#{Redlics.config.separator}#{event}#{Redlics.config.separator}#{time_format(granularity, past)}"
|
23
|
+
key = with_namespace(key) if options[:namespaced]
|
24
|
+
return bucketize(key, options[:id]) if bucketize?(context, options)
|
25
|
+
return unbucketize(key, options[:id]) if context[:long] == :counter && !options[:id].nil?
|
26
|
+
key
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
# Construct an array with all keys of a time frame in a given granularity.
|
31
|
+
#
|
32
|
+
# @param context [Hash] the hash of a context defined in Redlics::CONTEXTS
|
33
|
+
# @param event [String] event name with eventual Redis namespace separator
|
34
|
+
# @param time_object [Symbol] time object predefined in Redlics::TimeFrame.init_with_symbol
|
35
|
+
# @param time_object [Hash] time object with keys `from` and `to`
|
36
|
+
# @param time_object [Range] time object as range
|
37
|
+
# @param time_object [Time] time object
|
38
|
+
# @param options [Hash] configuration options
|
39
|
+
# @return [Array] array with all keys of a time frame in a given granularity
|
40
|
+
def timeframed(context, event, time_object, options = {})
|
41
|
+
options = { namespaced: true }.merge(options)
|
42
|
+
timeframe = TimeFrame.new(context, time_object, options)
|
43
|
+
timeframe.splat do |time|
|
44
|
+
name(context, event, timeframe.granularity, time, options)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
|
49
|
+
# Prepend namespace to a key.
|
50
|
+
#
|
51
|
+
# @param key [String] the key name
|
52
|
+
# @return [String] the key name with prepended namespace
|
53
|
+
def with_namespace(key)
|
54
|
+
return key unless Redlics.config.namespace.length > 0
|
55
|
+
return key if key.split(Redlics.config.separator).first == Redlics.config.namespace.to_s
|
56
|
+
"#{Redlics.config.namespace}#{Redlics.config.separator}#{key}"
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
# Encode a number with a mapping table.
|
61
|
+
#
|
62
|
+
# @param number [Integer] the number to encode
|
63
|
+
# @return [String] the encoded number as string
|
64
|
+
def encode(number)
|
65
|
+
encoded = ''
|
66
|
+
number = number.to_s
|
67
|
+
number = (number.size % 2) != 0 ? "0#{number}" : number
|
68
|
+
token = 0
|
69
|
+
while token <= number.size - 1
|
70
|
+
encoded += encode_map[number[token..token+1].to_i.to_s.to_sym].to_s
|
71
|
+
token += 2
|
72
|
+
end
|
73
|
+
encoded
|
74
|
+
end
|
75
|
+
|
76
|
+
|
77
|
+
# Decode a number with a mapping table.
|
78
|
+
#
|
79
|
+
# @param string [String] the string to encode
|
80
|
+
# @return [Integer] the decoded string as integer
|
81
|
+
def decode(string)
|
82
|
+
decoded = ''
|
83
|
+
string = string.to_s
|
84
|
+
token = 0
|
85
|
+
while token <= string.size - 1
|
86
|
+
number = decode_map[string[token].to_s.to_sym].to_s
|
87
|
+
decoded += number.size == 1 ? "0#{number}" : number
|
88
|
+
token += 1
|
89
|
+
end
|
90
|
+
decoded.to_i
|
91
|
+
end
|
92
|
+
|
93
|
+
|
94
|
+
# Check if a key exists in Redis.
|
95
|
+
#
|
96
|
+
# @param string [String] the key name to check
|
97
|
+
# @return [Boolean] true id key exists, false if not
|
98
|
+
def exists?(key)
|
99
|
+
Redlics.redis.exists(key) == 1
|
100
|
+
end
|
101
|
+
|
102
|
+
|
103
|
+
# Check if Redlics can bucketize.
|
104
|
+
#
|
105
|
+
# @param context [Hash] the hash of a context defined in Redlics::CONTEXTS
|
106
|
+
# @param options [Hash] configuration options
|
107
|
+
# @return [Boolean] true if can bucketize, false if not
|
108
|
+
def bucketize?(context, options = {})
|
109
|
+
context[:long] == :counter && Redlics.config.bucket && !options[:id].nil?
|
110
|
+
end
|
111
|
+
|
112
|
+
|
113
|
+
# Create a unique operation key in Redis.
|
114
|
+
# @return [String] the created unique operation key
|
115
|
+
def unique_namespace
|
116
|
+
loop do
|
117
|
+
ns = operation
|
118
|
+
unless exists?(ns)
|
119
|
+
Redlics.redis.pipelined do |redis|
|
120
|
+
redis.set(ns, 0)
|
121
|
+
redis.expire(ns, Redlics.config.operation_expiration)
|
122
|
+
end
|
123
|
+
break ns
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
|
129
|
+
private
|
130
|
+
|
131
|
+
# Create a operation key.
|
132
|
+
# @return [String] the created operation key
|
133
|
+
def operation
|
134
|
+
"#{Redlics::CONTEXTS[:operation][:short]}#{Redlics.config.separator}#{SecureRandom.uuid}"
|
135
|
+
end
|
136
|
+
|
137
|
+
|
138
|
+
# Get the time format pattern of a granularity.
|
139
|
+
#
|
140
|
+
# @param granularity [Symbol] existing granularity
|
141
|
+
# @param past [Time] a time object
|
142
|
+
# @return [String] pattern of defined granularity
|
143
|
+
def time_format(granularity, past)
|
144
|
+
past.strftime(Redlics.config.granularities[granularity][:pattern])
|
145
|
+
end
|
146
|
+
|
147
|
+
|
148
|
+
# Encode ids in event names.
|
149
|
+
#
|
150
|
+
# @param event [String] event name with eventual Redis namespace separator
|
151
|
+
# @return [String] event name with encoded ids
|
152
|
+
def encode_event(event)
|
153
|
+
event.to_s.split(Redlics.config.separator).map { |v| v.match(/\A\d+\z/) ? encode(v) : v }.join(Redlics.config.separator)
|
154
|
+
end
|
155
|
+
|
156
|
+
|
157
|
+
# Bucketize key name with id.
|
158
|
+
#
|
159
|
+
# @param key [String] key name
|
160
|
+
# @param id [Integer] object id
|
161
|
+
# @return [Array] bucketized key name and value
|
162
|
+
def bucketize(key, id)
|
163
|
+
bucket = id.to_i / Redlics.config.bucket_size.to_i
|
164
|
+
value = id.to_i % Redlics.config.bucket_size.to_i
|
165
|
+
if Redlics.config.encode[:ids]
|
166
|
+
bucket = encode(bucket)
|
167
|
+
value = encode(value)
|
168
|
+
end
|
169
|
+
["#{key}#{Redlics.config.separator}#{bucket}", value]
|
170
|
+
end
|
171
|
+
|
172
|
+
|
173
|
+
# Unbucketize key name with id. Encode the id if configured to encode.
|
174
|
+
#
|
175
|
+
# @param key [String] key name
|
176
|
+
# @param id [Integer] object id
|
177
|
+
# @return [String] unbucketized key name with eventual encoded object id
|
178
|
+
def unbucketize(key, id)
|
179
|
+
id = encode(id) if Redlics.config.encode[:ids]
|
180
|
+
"#{key}#{Redlics.config.separator}#{id}"
|
181
|
+
end
|
182
|
+
|
183
|
+
|
184
|
+
# Defined encode map.
|
185
|
+
# @return [Hash] the encode map with numbers as keys
|
186
|
+
def encode_map
|
187
|
+
@encode_map ||= replace_separator_encode({
|
188
|
+
'0' => '1', '1' => '2', '2' => '3', '3' => '4', '4' => '5', '5' => '6', '6' => '7', '7' => '8', '8' => '9', '9' => '0',
|
189
|
+
'10' => '-', '11' => '=', '12' => '!', '13' => '@', '14' => '#', '15' => '$', '16' => '%', '17' => '^', '18' => '&', '19' => '*',
|
190
|
+
'20' => '(', '21' => ')', '22' => '_', '23' => '+', '24' => 'a', '25' => 'b', '26' => 'c', '27' => 'd', '28' => 'e', '29' => 'f',
|
191
|
+
'30' => 'g', '31' => 'h', '32' => 'i', '33' => 'j', '34' => 'k', '35' => 'l', '36' => 'm', '37' => 'n', '38' => 'o', '39' => 'p',
|
192
|
+
'40' => 'q', '41' => 'r', '42' => 's', '43' => 't', '44' => 'u', '45' => 'v', '46' => 'w', '47' => 'x', '48' => 'y', '49' => 'z',
|
193
|
+
'50' => 'A', '51' => 'B', '52' => 'C', '53' => 'D', '54' => 'E', '55' => 'F', '56' => 'G', '57' => 'H', '58' => 'I', '59' => 'J',
|
194
|
+
'60' => 'K', '61' => 'L', '62' => 'M', '63' => 'N', '64' => 'O', '65' => 'P', '66' => 'Q', '67' => 'R', '68' => 'S', '69' => 'T',
|
195
|
+
'70' => 'U', '71' => 'V', '72' => 'W', '73' => 'X', '74' => 'Y', '75' => 'Z', '76' => '[', '77' => ']', '78' => '\\', '79' => ';',
|
196
|
+
'80' => ',', '81' => '.', '82' => '/', '83' => '{', '84' => '}', '85' => '|', '86' => '§', '87' => '<', '88' => '>', '89' => '?',
|
197
|
+
'90' => '`', '91' => '~', '92' => 'ä', '93' => 'Ä', '94' => 'ü', '95' => 'Ü', '96' => 'ö', '97' => 'Ö', '98' => 'é', '99' => 'É' }).freeze
|
198
|
+
end
|
199
|
+
|
200
|
+
|
201
|
+
# Defined decode map.
|
202
|
+
# @return [Hash] the decode map with numbers as values
|
203
|
+
def decode_map
|
204
|
+
@decode_map ||= replace_separator_decode({
|
205
|
+
'1' => '0', '2' => '1', '3' => '2', '4' => '3', '5' => '4', '6' => '5', '7' => '6', '8' => '7', '9' => '8', '0' => '9',
|
206
|
+
'-' => '10', '=' => '11', '!' => '12', '@' => '13', '#' => '14', '$' => '15', '%' => '16', '^' => '17', '&' => '18', '*' => '19',
|
207
|
+
'(' => '20', ')' => '21', '_' => '22', '+' => '23', 'a' => '24', 'b' => '25', 'c' => '26', 'd' => '27', 'e' => '28', 'f' => '29',
|
208
|
+
'g' => '30', 'h' => '31', 'i' => '32', 'j' => '33', 'k' => '34', 'l' => '35', 'm' => '36', 'n' => '37', 'o' => '38', 'p' => '39',
|
209
|
+
'q' => '40', 'r' => '41', 's' => '42', 't' => '43', 'u' => '44', 'v' => '45', 'w' => '46', 'x' => '47', 'y' => '48', 'z' => '49',
|
210
|
+
'A' => '50', 'B' => '51', 'C' => '52', 'D' => '53', 'E' => '54', 'F' => '55', 'G' => '56', 'H' => '57', 'I' => '58', 'J' => '59',
|
211
|
+
'K' => '60', 'L' => '61', 'M' => '62', 'N' => '63', 'O' => '64', 'P' => '65', 'Q' => '66', 'R' => '67', 'S' => '68', 'T' => '69',
|
212
|
+
'U' => '70', 'V' => '71', 'W' => '72', 'X' => '73', 'Y' => '74', 'Z' => '75', '[' => '76', ']' => '77', '\\' => '78', ';' => '79',
|
213
|
+
',' => '80', '.' => '81', '/' => '82', '{' => '83', '}' => '84', '|' => '85', '§' => '86', '<' => '87', '>' => '88', '?' => '89',
|
214
|
+
'`' => '90', '~' => '91', 'ä' => '92', 'Ä' => '93', 'ü' => '94', 'Ü' => '95', 'ö' => '96', 'Ö' => '97', 'é' => '98', 'É' => '99' }).freeze
|
215
|
+
end
|
216
|
+
|
217
|
+
|
218
|
+
# Replace defined separator in configuration from the encode map.
|
219
|
+
#
|
220
|
+
# @param map [Hash] encode map hash
|
221
|
+
# @return [Hash] encode map hash without defined separator in configuration.
|
222
|
+
def replace_separator_encode(map)
|
223
|
+
unless Redlics.config.separator == ':'
|
224
|
+
key = map.key(Redlics.config.separator)
|
225
|
+
map[key] = ':' if key
|
226
|
+
end
|
227
|
+
map
|
228
|
+
end
|
229
|
+
|
230
|
+
|
231
|
+
# Replace defined separator in configuration from the decode map.
|
232
|
+
#
|
233
|
+
# @param map [Hash] decode map hash
|
234
|
+
# @return [Hash] decode map hash without defined separator in configuration.
|
235
|
+
def replace_separator_decode(map)
|
236
|
+
unless Redlics.config.separator == ':'
|
237
|
+
key = Redlics.config.separator.to_s.to_sym
|
238
|
+
map[':'.to_sym] = map.delete(key) if map.key?(key)
|
239
|
+
end
|
240
|
+
map
|
241
|
+
end
|
242
|
+
|
243
|
+
end
|
244
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
---
|
2
|
+
redis.log(redis.LOG_NOTICE, 'Redlics')
|
3
|
+
|
4
|
+
local func = cmsgpack.unpack(ARGV[1])
|
5
|
+
local keys = cmsgpack.unpack(ARGV[2])
|
6
|
+
local options = cmsgpack.unpack(ARGV[3])
|
7
|
+
|
8
|
+
|
9
|
+
local function operate(operator, keys)
|
10
|
+
redis.call('BITOP', operator, options['dest'], unpack(keys))
|
11
|
+
return options['dest']
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
local function AND(keys) return operate('AND', keys) end
|
16
|
+
local function OR(keys) return operate('OR', keys) end
|
17
|
+
local function XOR(keys) return operate('XOR', keys) end
|
18
|
+
local function NOT(keys) return operate('NOT', keys) end
|
19
|
+
local function MINUS(keys)
|
20
|
+
local items = keys
|
21
|
+
local src = table.remove(items, 1)
|
22
|
+
local and_op = AND(keys)
|
23
|
+
return XOR({ src, and_op })
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
local function operation(keys, options)
|
28
|
+
if options['operator'] == 'MINUS' then
|
29
|
+
return MINUS(keys)
|
30
|
+
else
|
31
|
+
return operate(options['operator'], keys)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
local function counts(keys, options)
|
37
|
+
local result
|
38
|
+
if options['bucketized'] then
|
39
|
+
result = 0
|
40
|
+
for i,v in ipairs(keys) do
|
41
|
+
result = result + (redis.call('HGET', v[1], v[2]) or 0)
|
42
|
+
end
|
43
|
+
else
|
44
|
+
result = redis.call('MGET', unpack(keys))
|
45
|
+
end
|
46
|
+
return result
|
47
|
+
end
|
48
|
+
|
49
|
+
|
50
|
+
local function plot_counts(keys, options)
|
51
|
+
local plot = {}
|
52
|
+
if options['bucketized'] then
|
53
|
+
for i,v in ipairs(keys) do
|
54
|
+
plot[v[1]..v[2]] = (redis.call('HGET', v[1], v[2]) or 0)
|
55
|
+
end
|
56
|
+
else
|
57
|
+
local values = redis.call('MGET', unpack(keys))
|
58
|
+
for i,v in ipairs(keys) do
|
59
|
+
plot[v] = values[i]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
return cjson.encode(plot)
|
63
|
+
end
|
64
|
+
|
65
|
+
|
66
|
+
local function plot_tracks(keys, options)
|
67
|
+
local plot = {}
|
68
|
+
for i,v in ipairs(keys) do
|
69
|
+
plot[v] = redis.call('bitcount', keys[i])
|
70
|
+
end
|
71
|
+
return cjson.encode(plot)
|
72
|
+
end
|
73
|
+
|
74
|
+
|
75
|
+
local exportFuncs = {
|
76
|
+
operation = operation,
|
77
|
+
counts = counts,
|
78
|
+
plot_counts = plot_counts,
|
79
|
+
plot_tracks = plot_tracks
|
80
|
+
}
|
81
|
+
|
82
|
+
return exportFuncs[func](keys, options)
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Redlics
|
2
|
+
|
3
|
+
# Operators namespace
|
4
|
+
module Operators
|
5
|
+
|
6
|
+
# AND (&) operator.
|
7
|
+
#
|
8
|
+
# @param query [Redlics::Query] Redlics query object
|
9
|
+
# @return [Redlics::Query::Operation] a Redlics query operation object
|
10
|
+
def &(query)
|
11
|
+
Query::Operation.new('AND', [self, query])
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
# OR (|) operator.
|
16
|
+
#
|
17
|
+
# @param query [Redlics::Query] Redlics query object
|
18
|
+
# @return [Redlics::Query::Operation] a Redlics query operation object
|
19
|
+
def |(query)
|
20
|
+
Query::Operation.new('OR', [self, query])
|
21
|
+
end
|
22
|
+
alias_method :+, :|
|
23
|
+
|
24
|
+
|
25
|
+
# XOR (^) operator.
|
26
|
+
#
|
27
|
+
# @param query [Redlics::Query] Redlics query object
|
28
|
+
# @return [Redlics::Query::Operation] a Redlics query operation object
|
29
|
+
def ^(query)
|
30
|
+
Query::Operation.new('XOR', [self, query])
|
31
|
+
end
|
32
|
+
|
33
|
+
|
34
|
+
# NOT (-, ~) operator.
|
35
|
+
# @return [Redlics::Query::Operation] a Redlics query operation object
|
36
|
+
def -@()
|
37
|
+
Query::Operation.new('NOT', [self])
|
38
|
+
end
|
39
|
+
alias_method :~@, :-@
|
40
|
+
|
41
|
+
|
42
|
+
# MINUS (-) operator.
|
43
|
+
#
|
44
|
+
# @param query [Redlics::Query] Redlics query object
|
45
|
+
# @return [Redlics::Query::Operation] a Redlics query operation object
|
46
|
+
def -(query)
|
47
|
+
Query::Operation.new('MINUS', [self, query])
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
end
|