redis-time-series 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d699daec01bb891952501de6a51cc4b966be74a0f8585030bafb4f6a908b36e
4
- data.tar.gz: dfa027d344c4d5485b0a22a33edd67973c2a60cb60dbbaa1a57101ccce8833fc
3
+ metadata.gz: 58671cce77efea0c1b4f405e60dcd73cb3f01101fabdb0cb9bc8b11335392116
4
+ data.tar.gz: b9c7e56687b5eb7d5286a88990ca20c5d82f6857070ab953e6d74925d5cbefb6
5
5
  SHA512:
6
- metadata.gz: acadcad9c6919e810f0e100bfe20b60055507e2efa98d0853fca6c15a252f99b65c722c71acc99d37d8d9f72964d91f862c9604fb96458ca3faaddce9f841752
7
- data.tar.gz: 489415e8247d8cffefcf13e4e70c82cde752170c4416bc0973495c5d38b1d27d2b554cdd62db8729c9c71b125cb4848aa5a1efd81b998aa5f6e5683c13be8ccc
6
+ metadata.gz: 402de8f815ef0ff2dfe9426d6be2e68508093c3a35d7def4721f5bfe9ba3a9b52d8b375600302b989c24fc10b4d9c7bc43249a81715267112fa4776d8429ea18
7
+ data.tar.gz: 3c6a554173d5f9c4a92a77c65c8c8fed8035395b3066ff0cbf222e1983347645fbabf1e498516218a042c3a3c0ad7d157ab5e39b1e22971a73189a5e3aba9496
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0
6
+ * Added [hash-based filter DSL](https://github.com/dzunk/redis-time-series/tree/7173c73588da50614c02f9c89bf2ecef77766a78#filter-dsl)
7
+ * Removed `Time#ts_msec` monkey-patch
8
+ * Renamed `TimeSeries.queryindex` to `.query_index`
9
+ * Added `TS.CREATERULE` and `TS.DELETERULE` commands
10
+ * Renamed `InvalidFilters` to `FilterError`
11
+
5
12
  ## 0.3.0
6
13
  * Added `TS.QUERYINDEX` command
7
14
 
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- redis-time-series (0.3.0)
4
+ redis-time-series (0.4.0)
5
5
  redis (~> 4.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -162,23 +162,92 @@ ts.size
162
162
  ```
163
163
  Find series matching specific label(s)
164
164
  ```ruby
165
- Redis::TimeSeries.queryindex('foo=bar')
165
+ Redis::TimeSeries.query_index('foo=bar')
166
166
  => [#<Redis::TimeSeries:0x00007fc115ba1610
167
167
  @key="ts3",
168
168
  @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>,
169
169
  @retention=nil,
170
170
  @uncompressed=false>]
171
171
  # Note that you need at least one "label equals value" filter
172
- Redis::TimeSeries.queryindex('foo!=bar')
172
+ Redis::TimeSeries.query_index('foo!=bar')
173
173
  => RuntimeError: Filtering requires at least one equality comparison
174
+ # query_index is also aliased as .where for fluency
175
+ Redis::TimeSeries.where('foo=bar')
176
+ => [#<Redis::TimeSeries:0x00007fb8981010c8
177
+ @key="ts3",
178
+ @redis=#<Redis client v4.2.1 for redis://127.0.0.1:6379/0>,
179
+ @retention=nil,
180
+ @uncompressed=false>]
181
+ ```
182
+ ### Filter DSL
183
+ You can provide filter strings directly, per the time series documentation.
184
+ ```ruby
185
+ Redis::TimeSeries.where('foo=bar')
186
+ => [#<Redis::TimeSeries:0x00007fb8981010c8...>]
187
+ ```
188
+ There is also a hash-based syntax available, which may be more pleasant to work with.
189
+ ```ruby
190
+ Redis::TimeSeries.where(foo: 'bar')
191
+ => [#<Redis::TimeSeries:0x00007fb89811dca0...>]
192
+ ```
193
+ All six filter types are represented in hash format below.
194
+ ```ruby
195
+ {
196
+ foo: 'bar', # label=value (equality)
197
+ foo: { not: 'bar' }, # label!=value (inequality)
198
+ foo: true, # label= (presence)
199
+ foo: false, # label!= (absence)
200
+ foo: [1, 2], # label=(1,2) (any value)
201
+ foo: { not: [1, 2] } # label!=(1,2) (no values)
202
+ }
203
+ ```
204
+ Note the special use of `true` and `false`. If you're representing a boolean value with a label, rather than setting its value to "true" or "false" (which would be treated as strings in Redis anyway), you should add or remove the label from the series.
205
+
206
+ Values can be any object that responds to `.to_s`:
207
+ ```ruby
208
+ class Person
209
+ def initialize(name)
210
+ @name = name
211
+ end
212
+
213
+ def to_s
214
+ @name
215
+ end
216
+ end
217
+
218
+ Redis::TimeSeries.where(person: Person.new('John'))
219
+ #=> TS.QUERYINDEX person=John
220
+ ```
221
+
222
+ ### Compaction Rules
223
+ Add a compaction rule to a series.
224
+ ```ruby
225
+ # `dest` needs to be created before the rule is added.
226
+ other_ts = Redis::TimeSeries.create('other_ts')
227
+
228
+ # Note: aggregation durations are measured in milliseconds
229
+ ts.create_rule(dest: other_ts, aggregation: [:count, 6000])
230
+
231
+ # Can also provide a string key instead of a time series object
232
+ ts.create_rule(dest: 'other_ts', aggregation: [:avg, 120000])
233
+
234
+ # Can also provide an Aggregation object instead of an array
235
+ agg = Redis::TimeSeries::Aggregation.new(:avg, 120000)
236
+ ts.create_rule(dest: other_ts, aggregation: agg)
237
+
238
+ # Class-level method also available
239
+ Redis::TimeSeries.create_rule(source: ts, dest: other_ts, aggregation: ['std.p', 150000])
240
+ ```
241
+ Remove an existing compaction rule
242
+ ```ruby
243
+ ts.delete_rule(dest: 'other_ts')
244
+ Redis::TimeSeries.delete_rule(source: ts, dest: 'other_ts')
174
245
  ```
175
246
 
176
247
 
177
248
  ### TODO
178
249
  * `TS.REVRANGE`
179
250
  * `TS.MRANGE`/`TS.MREVRANGE`
180
- * Compaction rules
181
- * Filters
182
251
  * Probably a bunch more stuff
183
252
 
184
253
  ## Development
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+ module TimeMsec
3
+ refine Time do
4
+ def ts_msec
5
+ (to_f * 1000.0).to_i
6
+ end
7
+ end
8
+ end
@@ -1,11 +1,13 @@
1
1
  require 'bigdecimal'
2
2
  require 'forwardable'
3
- require 'time/msec'
4
- require 'redis/time_series/filter'
3
+ require 'ext/time_msec'
4
+ require 'redis/time_series/errors'
5
+ require 'redis/time_series/aggregation'
6
+ require 'redis/time_series/filters'
5
7
  require 'redis/time_series/info'
6
8
  require 'redis/time_series/sample'
7
9
  require 'redis/time_series'
8
10
 
9
11
  class RedisTimeSeries
10
- VERSION = '0.3.0'
12
+ VERSION = '0.4.0'
11
13
  end
@@ -1,4 +1,6 @@
1
1
  # frozen_string_literal: true
2
+ using TimeMsec
3
+
2
4
  class Redis
3
5
  class TimeSeries
4
6
  extend Forwardable
@@ -8,6 +10,27 @@ class Redis
8
10
  new(key, **options).create(labels: options[:labels])
9
11
  end
10
12
 
13
+ def create_rule(source:, dest:, aggregation:)
14
+ args = [
15
+ source.is_a?(self) ? source.key : source.to_s,
16
+ dest.is_a?(self) ? dest.key : dest.to_s,
17
+ Aggregation.parse(aggregation).to_a
18
+ ]
19
+ redis.call 'TS.CREATERULE', *args.flatten
20
+ end
21
+
22
+ def delete_rule(source:, dest:)
23
+ args = [
24
+ source.is_a?(self) ? source.key : source.to_s,
25
+ dest.is_a?(self) ? dest.key : dest.to_s
26
+ ]
27
+ redis.call 'TS.DELETERULE', *args
28
+ end
29
+
30
+ def destroy(key)
31
+ redis.del key
32
+ end
33
+
11
34
  def madd(data)
12
35
  data.reduce([]) do |memo, (key, value)|
13
36
  if value.is_a?(Hash) || (value.is_a?(Array) && value.first.is_a?(Array))
@@ -33,11 +56,13 @@ class Redis
33
56
  end
34
57
  end
35
58
 
36
- def queryindex(filter_value)
37
- filters = Filter.new(filter_value)
59
+ def query_index(filter_value)
60
+ filters = Filters.new(filter_value)
38
61
  filters.validate!
62
+ puts "DEBUG: TS.QUERYINDEX #{filters.to_a.join(' ')}" if ENV['DEBUG']
39
63
  redis.call('TS.QUERYINDEX', *filters.to_a).map { |key| new(key) }
40
64
  end
65
+ alias where query_index
41
66
 
42
67
  def redis
43
68
  @redis ||= Redis.current
@@ -72,6 +97,14 @@ class Redis
72
97
  self
73
98
  end
74
99
 
100
+ def create_rule(dest:, aggregation:)
101
+ self.class.create_rule(source: self, dest: dest, aggregation: aggregation)
102
+ end
103
+
104
+ def delete_rule(dest:)
105
+ self.class.delete_rule(source: self, dest: dest)
106
+ end
107
+
75
108
  def decrby(value = 1, timestamp = nil)
76
109
  args = [key, value]
77
110
  args << timestamp if timestamp
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ class Redis
3
+ class TimeSeries
4
+ class Aggregation
5
+ TYPES = %w[
6
+ avg
7
+ count
8
+ first
9
+ last
10
+ max
11
+ min
12
+ range
13
+ std.p
14
+ std.s
15
+ sum
16
+ var.p
17
+ var.s
18
+ ]
19
+
20
+ attr_reader :type, :duration
21
+
22
+ alias aggregation_type type
23
+ alias time_bucket duration
24
+
25
+ def self.parse(agg)
26
+ return agg if agg.is_a?(self)
27
+ return new(agg.first, agg.last) if agg.is_a?(Array) && agg.size == 2
28
+ raise AggregationError, "Couldn't parse #{agg} into an aggregation rule!"
29
+ end
30
+
31
+ def initialize(type, duration)
32
+ unless TYPES.include? type.to_s
33
+ raise AggregationError, "#{type} is not a valid aggregation type!"
34
+ end
35
+ @type = type.to_s
36
+ @duration = duration.to_i
37
+ end
38
+
39
+ def to_a
40
+ ['AGGREGATION', type, duration]
41
+ end
42
+
43
+ def to_s
44
+ to_a.join(' ')
45
+ end
46
+
47
+ def ==(other)
48
+ parsed = self.class.parse(other)
49
+ type == parsed.type && duration == parsed.duration
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,15 @@
1
+ class Redis
2
+ class TimeSeries
3
+ # Base error class for convenient `rescue`ing
4
+ class Error < StandardError; end
5
+
6
+ # Invalid filter error is raised when attempting to filter without at least
7
+ # one equality comparison ("foo=bar")
8
+ class FilterError < Error; end
9
+
10
+ # Aggregation error is raised when attempting to create anaggreation with
11
+ # an unknown type, or when calling a command with an invalid aggregation value.
12
+ # @see Redis::TimeSeries::Aggregation
13
+ class AggregationError < Error; end
14
+ end
15
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  class Redis
3
3
  class TimeSeries
4
- class Filter
4
+ class Filters
5
5
  Equal = Struct.new(:label, :value) do
6
6
  self::REGEX = /^[^!]+=[^(]+/
7
7
 
@@ -9,6 +9,10 @@ class Redis
9
9
  new(*str.split('='))
10
10
  end
11
11
 
12
+ def to_h
13
+ { label => value }
14
+ end
15
+
12
16
  def to_s
13
17
  "#{label}=#{value}"
14
18
  end
@@ -21,6 +25,10 @@ class Redis
21
25
  new(*str.split('!='))
22
26
  end
23
27
 
28
+ def to_h
29
+ { label => { not: value } }
30
+ end
31
+
24
32
  def to_s
25
33
  "#{label}!=#{value}"
26
34
  end
@@ -33,6 +41,10 @@ class Redis
33
41
  new(str.delete('='))
34
42
  end
35
43
 
44
+ def to_h
45
+ { label => false }
46
+ end
47
+
36
48
  def to_s
37
49
  "#{label}="
38
50
  end
@@ -45,6 +57,10 @@ class Redis
45
57
  new(str.delete('!='))
46
58
  end
47
59
 
60
+ def to_h
61
+ { label => true }
62
+ end
63
+
48
64
  def to_s
49
65
  "#{label}!="
50
66
  end
@@ -59,8 +75,12 @@ class Redis
59
75
  new(label, values)
60
76
  end
61
77
 
78
+ def to_h
79
+ { label => values }
80
+ end
81
+
62
82
  def to_s
63
- "#{label}=(#{values.join(',')})"
83
+ "#{label}=(#{values.map(&:to_s).join(',')})"
64
84
  end
65
85
  end
66
86
 
@@ -73,14 +93,18 @@ class Redis
73
93
  new(label, values)
74
94
  end
75
95
 
96
+ def to_h
97
+ { label => { not: values } }
98
+ end
99
+
76
100
  def to_s
77
- "#{label}!=(#{values.join(',')})"
101
+ "#{label}!=(#{values.map(&:to_s).join(',')})"
78
102
  end
79
103
  end
80
104
 
81
105
  TYPES = [Equal, NotEqual, Absent, Present, AnyValue, NoValues]
82
106
  TYPES.each do |type|
83
- define_method "#{type.to_s.split('::').last.gsub(/(.)([A-Z])/,'\1_\2').downcase}_filters" do
107
+ define_method "#{type.to_s.split('::').last.gsub(/(.)([A-Z])/,'\1_\2').downcase}" do
84
108
  filters.select { |f| f.is_a? type }
85
109
  end
86
110
  end
@@ -88,12 +112,15 @@ class Redis
88
112
  attr_reader :filters
89
113
 
90
114
  def initialize(filters = nil)
91
- filters = parse_string(filters) if filters.is_a?(String)
92
- @filters = filters.presence || {}
115
+ @filters = case filters
116
+ when String then parse_string(filters)
117
+ when Hash then parse_hash(filters)
118
+ else []
119
+ end
93
120
  end
94
121
 
95
122
  def validate!
96
- valid? || raise('Filtering requires at least one equality comparison')
123
+ valid? || raise(FilterError, 'Filtering requires at least one equality comparison')
97
124
  end
98
125
 
99
126
  def valid?
@@ -104,15 +131,39 @@ class Redis
104
131
  filters.map(&:to_s)
105
132
  end
106
133
 
134
+ def to_h
135
+ filters.reduce({}) { |h, filter| h.merge(filter.to_h) }
136
+ end
137
+
138
+ def to_s
139
+ to_a.join(' ')
140
+ end
141
+
107
142
  private
108
143
 
109
144
  def parse_string(filter_string)
145
+ return unless filter_string.is_a? String
110
146
  filter_string.split(' ').map do |str|
111
147
  match = TYPES.find { |f| f::REGEX.match? str }
112
- raise "Unable to parse '#{str}'" unless match
148
+ raise(FilterError, "Unable to parse '#{str}'") unless match
113
149
  match.parse(str)
114
150
  end
115
151
  end
152
+
153
+ def parse_hash(filter_hash)
154
+ return unless filter_hash.is_a? Hash
155
+ filter_hash.map do |label, value|
156
+ case value
157
+ when TrueClass then Present.new(label)
158
+ when FalseClass then Absent.new(label)
159
+ when Array then AnyValue.new(label, value)
160
+ when Hash
161
+ raise(FilterError, "Invalid filter hash value #{value}") unless value.keys === [:not]
162
+ (v = value.values.first).is_a?(Array) ? NoValues.new(label, v) : NotEqual.new(label, v)
163
+ else Equal.new(label, value)
164
+ end
165
+ end
166
+ end
116
167
  end
117
168
  end
118
169
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-time-series
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Duszynski
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-07-17 00:00:00.000000000 Z
11
+ date: 2020-07-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -112,12 +112,14 @@ files:
112
112
  - Rakefile
113
113
  - bin/console
114
114
  - bin/setup
115
+ - lib/ext/time_msec.rb
115
116
  - lib/redis-time-series.rb
116
117
  - lib/redis/time_series.rb
117
- - lib/redis/time_series/filter.rb
118
+ - lib/redis/time_series/aggregation.rb
119
+ - lib/redis/time_series/errors.rb
120
+ - lib/redis/time_series/filters.rb
118
121
  - lib/redis/time_series/info.rb
119
122
  - lib/redis/time_series/sample.rb
120
- - lib/time/msec.rb
121
123
  - redis-time-series.gemspec
122
124
  homepage: https://github.com/dzunk/redis-time-series
123
125
  licenses:
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
- class Time
3
- # TODO: use refinemenets instead of monkey-patching Time
4
- def ts_msec
5
- (to_f * 1000.0).to_i
6
- end
7
- end