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.
@@ -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
- values = identifier.values.map { |v| format_value(v) }.join(', ')
76
- conflict_columns = identifier.keys.join(', ')
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 (#{values}, json('#{data.to_json}'))
80
- ON CONFLICT (#{conflict_columns}) DO UPDATE SET data =
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
- values = identifier.values.map { |v| format_value(v) }.join(', ')
110
- conflict_columns = identifier.keys.join(', ')
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 (#{values}, json('#{data.to_json}'))
114
- ON CONFLICT (#{conflict_columns}) DO UPDATE SET data =
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
- results = client.execute(get_query(identifiers: identifiers)).to_a
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
- operation = ping_query(key: key.key, at: key.at, data: data)
162
+ query, params = ping_query(key: key.key, at: key.at, data: data)
153
163
  client.transaction do |c|
154
- c.execute(operation) # TODO: should use batch_data_operations
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 ('#{key}', '#{at_formatted}', json('#{data.to_json}'))
163
- ON CONFLICT (key) DO UPDATE SET at = '#{at_formatted}', data = json('#{data.to_json}');
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
- result = client.execute(scan_query(key: key.key)).first
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 = '#{key}' ORDER BY at DESC LIMIT 1;
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
- return "#{key} = #{format_value(value)}" unless key == :at
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
- with_microseconds = formatted.sub('Z', '.000000Z')
216
+ params << formatted
217
+ params << formatted.sub('Z', '.000000Z')
201
218
 
202
- "(at = '#{formatted}' OR at = '#{with_microseconds}')"
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 format_value(value)
218
- return "'#{format_time_value(value)}'" if value.is_a?(Time) || value.is_a?(DateTime)
219
- return value.to_s if value.is_a?(Integer) || value.is_a?(Float)
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
- "'#{value}'"
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 + amount,
90
+ time.year + offset,
91
91
  time.month,
92
- [time.day, days_in_month(time.year + amount, time.month)].min,
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