trifle-stats 2.4.1 → 2.6.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 +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +21 -2
- data/lib/trifle/stats/configuration.rb +1 -0
- data/lib/trifle/stats/driver/api.rb +131 -0
- data/lib/trifle/stats/driver/mysql.rb +33 -15
- data/lib/trifle/stats/driver/postgres.rb +66 -37
- data/lib/trifle/stats/driver/redis.rb +37 -30
- data/lib/trifle/stats/driver/sqlite.rb +66 -35
- data/lib/trifle/stats/nocturnal.rb +2 -2
- data/lib/trifle/stats/operations/timeseries/increment.rb +10 -0
- data/lib/trifle/stats/operations/timeseries/set.rb +10 -0
- data/lib/trifle/stats/transponder/expression.rb +117 -0
- data/lib/trifle/stats/transponder/expression_engine.rb +356 -0
- data/lib/trifle/stats/version.rb +1 -1
- data/lib/trifle/stats.rb +4 -10
- metadata +5 -13
- data/lib/trifle/stats/nocturnal_legacy.rb +0 -161
- data/lib/trifle/stats/transponder/add.rb +0 -30
- data/lib/trifle/stats/transponder/divide.rb +0 -30
- data/lib/trifle/stats/transponder/max.rb +0 -31
- data/lib/trifle/stats/transponder/mean.rb +0 -31
- data/lib/trifle/stats/transponder/min.rb +0 -31
- data/lib/trifle/stats/transponder/multiply.rb +0 -30
- data/lib/trifle/stats/transponder/ratio.rb +0 -30
- data/lib/trifle/stats/transponder/standard_deviation.rb +0 -34
- data/lib/trifle/stats/transponder/subtract.rb +0 -30
- data/lib/trifle/stats/transponder/sum.rb +0 -28
|
@@ -70,16 +70,19 @@ module Trifle
|
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
def inc_query(identifier:, data:)
|
|
73
|
+
def inc_query(identifier:, data:) # rubocop:disable Metrics/MethodLength
|
|
74
74
|
columns = identifier.keys.join(', ')
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
placeholders = (['?'] * identifier.size).join(', ')
|
|
76
|
+
expression = data.inject('data') do |o, (k, v)|
|
|
77
|
+
path = json_path_for(k)
|
|
78
|
+
"json_set(#{o}, '#{path}', IFNULL(json_extract(data, '#{path}'), 0) + #{numeric_value(k, v)})"
|
|
79
|
+
end
|
|
77
80
|
|
|
78
|
-
<<-SQL
|
|
79
|
-
INSERT INTO #{table_name} (#{columns}, data) VALUES (#{
|
|
80
|
-
ON CONFLICT (#{
|
|
81
|
-
#{data.inject('data') { |o, (k, v)| "json_set(#{o}, '$.#{k}', IFNULL(json_extract(data, '$.#{k}'), 0) + #{v})" }};
|
|
81
|
+
query = <<-SQL
|
|
82
|
+
INSERT INTO #{table_name} (#{columns}, data) VALUES (#{placeholders}, json(?))
|
|
83
|
+
ON CONFLICT (#{columns}) DO UPDATE SET data = #{expression};
|
|
82
84
|
SQL
|
|
85
|
+
[query, query_params(identifier) + [data.to_json]]
|
|
83
86
|
end
|
|
84
87
|
|
|
85
88
|
def set(keys:, values:, count: 1, tracking_key: nil)
|
|
@@ -104,16 +107,20 @@ module Trifle
|
|
|
104
107
|
)
|
|
105
108
|
end
|
|
106
109
|
|
|
107
|
-
def set_query(identifier:, data:)
|
|
110
|
+
def set_query(identifier:, data:) # rubocop:disable Metrics/MethodLength
|
|
108
111
|
columns = identifier.keys.join(', ')
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
placeholders = (['?'] * identifier.size).join(', ')
|
|
113
|
+
params = query_params(identifier) + [data.to_json]
|
|
114
|
+
expression = data.inject('data') do |o, (k, v)|
|
|
115
|
+
params << v.to_json
|
|
116
|
+
"json_set(#{o}, '#{json_path_for(k)}', json(?))"
|
|
117
|
+
end
|
|
111
118
|
|
|
112
|
-
<<-SQL
|
|
113
|
-
INSERT INTO #{table_name} (#{columns}, data) VALUES (#{
|
|
114
|
-
ON CONFLICT (#{
|
|
115
|
-
#{data.inject('data') { |o, (k, v)| "json_set(#{o}, '$.#{k}', #{v.to_json})" }};
|
|
119
|
+
query = <<-SQL
|
|
120
|
+
INSERT INTO #{table_name} (#{columns}, data) VALUES (#{placeholders}, json(?))
|
|
121
|
+
ON CONFLICT (#{columns}) DO UPDATE SET data = #{expression};
|
|
116
122
|
SQL
|
|
123
|
+
[query, params]
|
|
117
124
|
end
|
|
118
125
|
|
|
119
126
|
def get(keys:)
|
|
@@ -123,7 +130,8 @@ module Trifle
|
|
|
123
130
|
end
|
|
124
131
|
|
|
125
132
|
def get_all(identifiers:) # rubocop:disable Metrics/AbcSize
|
|
126
|
-
|
|
133
|
+
query, params = get_query(identifiers: identifiers)
|
|
134
|
+
results = client.execute(query, params).to_a
|
|
127
135
|
sample = identifiers.first
|
|
128
136
|
|
|
129
137
|
results.each_with_object(Hash.new({})) do |r, o|
|
|
@@ -136,38 +144,42 @@ module Trifle
|
|
|
136
144
|
end
|
|
137
145
|
|
|
138
146
|
def get_query(identifiers:)
|
|
147
|
+
params = []
|
|
139
148
|
conditions = identifiers.map do |identifier|
|
|
140
|
-
identifier.map { |k, v| build_field_condition(k, v) }.join(' AND ')
|
|
149
|
+
identifier.map { |k, v| build_field_condition(k, v, params) }.join(' AND ')
|
|
141
150
|
end.join(' OR ')
|
|
142
151
|
|
|
143
|
-
<<-SQL
|
|
152
|
+
query = <<-SQL
|
|
144
153
|
SELECT * FROM #{table_name} WHERE #{conditions};
|
|
145
154
|
SQL
|
|
155
|
+
[query, params]
|
|
146
156
|
end
|
|
147
157
|
|
|
148
158
|
def ping(key:, values:)
|
|
149
159
|
return [] if @joined_identifier
|
|
150
160
|
|
|
151
161
|
data = self.class.pack(hash: { data: values, at: key.at })
|
|
152
|
-
|
|
162
|
+
query, params = ping_query(key: key.key, at: key.at, data: data)
|
|
153
163
|
client.transaction do |c|
|
|
154
|
-
c.execute(
|
|
164
|
+
c.execute(query, params) # TODO: should use batch_data_operations
|
|
155
165
|
end
|
|
156
166
|
end
|
|
157
167
|
|
|
158
168
|
def ping_query(key:, at:, data:)
|
|
159
169
|
at_formatted = format_time_value(at)
|
|
160
170
|
|
|
161
|
-
<<-SQL
|
|
162
|
-
INSERT INTO #{ping_table_name} (key, at, data) VALUES (
|
|
163
|
-
ON CONFLICT (key) DO UPDATE SET at =
|
|
171
|
+
query = <<-SQL
|
|
172
|
+
INSERT INTO #{ping_table_name} (key, at, data) VALUES (?, ?, json(?))
|
|
173
|
+
ON CONFLICT (key) DO UPDATE SET at = ?, data = json(?);
|
|
164
174
|
SQL
|
|
175
|
+
[query, [key.to_s, at_formatted, data.to_json, at_formatted, data.to_json]]
|
|
165
176
|
end
|
|
166
177
|
|
|
167
178
|
def scan(key:)
|
|
168
179
|
return [] if @joined_identifier
|
|
169
180
|
|
|
170
|
-
|
|
181
|
+
query, params = scan_query(key: key.key)
|
|
182
|
+
result = client.execute(query, params).first
|
|
171
183
|
return [] if result.nil?
|
|
172
184
|
|
|
173
185
|
# SQLite returns columns in order: key, at, data
|
|
@@ -177,9 +189,10 @@ module Trifle
|
|
|
177
189
|
end
|
|
178
190
|
|
|
179
191
|
def scan_query(key:)
|
|
180
|
-
<<-SQL
|
|
181
|
-
SELECT key, at, data FROM #{ping_table_name} WHERE key =
|
|
192
|
+
query = <<-SQL
|
|
193
|
+
SELECT key, at, data FROM #{ping_table_name} WHERE key = ? ORDER BY at DESC LIMIT 1;
|
|
182
194
|
SQL
|
|
195
|
+
[query, [key.to_s]]
|
|
183
196
|
end
|
|
184
197
|
|
|
185
198
|
def self.normalize_joined_identifier(value)
|
|
@@ -193,13 +206,17 @@ module Trifle
|
|
|
193
206
|
|
|
194
207
|
private
|
|
195
208
|
|
|
196
|
-
def build_field_condition(key, value)
|
|
197
|
-
|
|
209
|
+
def build_field_condition(key, value, params)
|
|
210
|
+
unless key == :at
|
|
211
|
+
params << query_param(value)
|
|
212
|
+
return "#{key} = ?"
|
|
213
|
+
end
|
|
198
214
|
|
|
199
215
|
formatted = format_time_value(value)
|
|
200
|
-
|
|
216
|
+
params << formatted
|
|
217
|
+
params << formatted.sub('Z', '.000000Z')
|
|
201
218
|
|
|
202
|
-
|
|
219
|
+
'(at = ? OR at = ?)'
|
|
203
220
|
end
|
|
204
221
|
|
|
205
222
|
# Batch data operations to avoid SQLite parser stack overflow
|
|
@@ -209,16 +226,30 @@ module Trifle
|
|
|
209
226
|
batch_size = 10
|
|
210
227
|
data.each_slice(batch_size) do |batch|
|
|
211
228
|
batch_data = batch.to_h
|
|
212
|
-
query = send("#{operation}_query", identifier: identifier, data: batch_data)
|
|
213
|
-
connection.execute(query)
|
|
229
|
+
query, params = send("#{operation}_query", identifier: identifier, data: batch_data)
|
|
230
|
+
connection.execute(query, params)
|
|
214
231
|
end
|
|
215
232
|
end
|
|
216
233
|
|
|
217
|
-
def
|
|
218
|
-
|
|
219
|
-
|
|
234
|
+
def query_params(identifier)
|
|
235
|
+
identifier.values.map { |value| query_param(value) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def query_param(value)
|
|
239
|
+
return format_time_value(value) if value.is_a?(Time) || value.is_a?(DateTime)
|
|
240
|
+
return value if value.is_a?(Integer) || value.is_a?(Float)
|
|
241
|
+
|
|
242
|
+
value.to_s
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def numeric_value(key, value)
|
|
246
|
+
return value.to_s if value.is_a?(Numeric)
|
|
247
|
+
|
|
248
|
+
raise ArgumentError, "increment requires numeric value for key #{key.inspect}"
|
|
249
|
+
end
|
|
220
250
|
|
|
221
|
-
|
|
251
|
+
def json_path_for(key)
|
|
252
|
+
"$.#{key.to_s.gsub("'", "''")}"
|
|
222
253
|
end
|
|
223
254
|
|
|
224
255
|
def format_time_value(value)
|
|
@@ -87,9 +87,9 @@ module Trifle
|
|
|
87
87
|
rescue ArgumentError
|
|
88
88
|
# Handle edge cases like Feb 29 in non-leap years
|
|
89
89
|
Time.new(
|
|
90
|
-
time.year +
|
|
90
|
+
time.year + offset,
|
|
91
91
|
time.month,
|
|
92
|
-
[time.day, days_in_month(time.year +
|
|
92
|
+
[time.day, days_in_month(time.year + offset, time.month)].min,
|
|
93
93
|
time.hour,
|
|
94
94
|
time.min,
|
|
95
95
|
time.sec,
|
|
@@ -26,6 +26,16 @@ module Trifle
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def perform
|
|
29
|
+
if config.driver.respond_to?(:direct_write)
|
|
30
|
+
return config.driver.direct_write(
|
|
31
|
+
operation: :track,
|
|
32
|
+
key: key,
|
|
33
|
+
at: @at,
|
|
34
|
+
values: values,
|
|
35
|
+
untracked: @untracked
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
29
39
|
payload = {
|
|
30
40
|
keys: config.granularities.map { |granularity| key_for(granularity: granularity) },
|
|
31
41
|
values: values
|
|
@@ -26,6 +26,16 @@ module Trifle
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def perform
|
|
29
|
+
if config.driver.respond_to?(:direct_write)
|
|
30
|
+
return config.driver.direct_write(
|
|
31
|
+
operation: :assert,
|
|
32
|
+
key: key,
|
|
33
|
+
at: @at,
|
|
34
|
+
values: values,
|
|
35
|
+
untracked: @untracked
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
29
39
|
payload = {
|
|
30
40
|
keys: config.granularities.map { |granularity| key_for(granularity: granularity) },
|
|
31
41
|
values: values
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trifle
|
|
4
|
+
module Stats
|
|
5
|
+
class Transponder
|
|
6
|
+
class Expression
|
|
7
|
+
include Trifle::Stats::Mixins::Packer
|
|
8
|
+
Trifle::Stats::Series.register_transponder(:expression, self)
|
|
9
|
+
|
|
10
|
+
def transpond(series:, paths:, expression:, response:)
|
|
11
|
+
normalized_paths, normalized_response = normalized_config(paths, response)
|
|
12
|
+
ast = ExpressionEngine.parse(expression: expression, paths: normalized_paths)
|
|
13
|
+
series[:values] = transformed_values(series[:values], normalized_paths, normalized_response, ast)
|
|
14
|
+
series
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def validate(paths:, expression:, response:)
|
|
18
|
+
normalized_paths, = normalized_config(paths, response)
|
|
19
|
+
ExpressionEngine.validate(paths: normalized_paths, expression: expression)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def normalized_config(paths, response)
|
|
25
|
+
normalized_paths = normalize_paths(paths)
|
|
26
|
+
normalized_response = normalize_response(response)
|
|
27
|
+
|
|
28
|
+
ensure_no_wildcards!(normalized_paths, normalized_response)
|
|
29
|
+
[normalized_paths, normalized_response]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def normalize_paths(paths)
|
|
33
|
+
raise ArgumentError, 'Paths must be an array.' unless paths.is_a?(Array)
|
|
34
|
+
|
|
35
|
+
cleaned = paths.map { |path| path.to_s.strip }.reject(&:empty?)
|
|
36
|
+
raise ArgumentError, 'At least one path is required.' if cleaned.empty?
|
|
37
|
+
|
|
38
|
+
cleaned
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def normalize_response(response)
|
|
42
|
+
normalized_response = response.to_s.strip
|
|
43
|
+
ensure_response!(normalized_response)
|
|
44
|
+
normalized_response
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ensure_response!(response)
|
|
48
|
+
raise ArgumentError, 'Response path is required.' if response.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ensure_no_wildcards!(paths, response)
|
|
52
|
+
wildcard = paths.any? { |path| path.include?('*') } || response.include?('*')
|
|
53
|
+
raise ArgumentError, 'Wildcard paths are not supported yet.' if wildcard
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_env(data, paths)
|
|
57
|
+
ExpressionEngine.allowed_vars(paths.length).zip(paths).to_h do |var, path|
|
|
58
|
+
[var, data.dig(*path.split('.'))]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def transformed_values(values, paths, response, ast)
|
|
63
|
+
values.map { |data| transform_row(data, paths, response, ast) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def transform_row(data, paths, response, ast)
|
|
67
|
+
env = build_env(data, paths)
|
|
68
|
+
value = ExpressionEngine.evaluate(ast: ast, env: env)
|
|
69
|
+
put_response(data, response, value)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def put_response(data, response, value)
|
|
73
|
+
keys = response.split('.')
|
|
74
|
+
raise ArgumentError, "Cannot write to response path #{response}." unless can_create_path?(data, keys)
|
|
75
|
+
|
|
76
|
+
updated = deep_dup(data)
|
|
77
|
+
target = updated
|
|
78
|
+
|
|
79
|
+
keys[0..-2].each do |key|
|
|
80
|
+
target[key] ||= {}
|
|
81
|
+
target = target[key]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
target[keys[-1]] = value
|
|
85
|
+
updated
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def can_create_path?(data, keys)
|
|
89
|
+
return true if keys.length <= 1
|
|
90
|
+
|
|
91
|
+
current = data
|
|
92
|
+
|
|
93
|
+
keys[0..-2].each do |key|
|
|
94
|
+
value = current[key]
|
|
95
|
+
return true if value.nil?
|
|
96
|
+
return false unless value.is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
current = value
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def deep_dup(value)
|
|
105
|
+
case value
|
|
106
|
+
when Hash
|
|
107
|
+
value.transform_values { |inner| deep_dup(inner) }
|
|
108
|
+
when Array
|
|
109
|
+
value.map { |inner| deep_dup(inner) }
|
|
110
|
+
else
|
|
111
|
+
value
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|