skadi 0.4.0.beta.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 +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE.md +22 -0
- data/README.md +111 -0
- data/app/assets/builds/dashboard.css +2 -0
- data/app/assets/builds/dashboard.js +36 -0
- data/app/assets/builds/skadi.js +1 -0
- data/app/controllers/skadi/application_controller.rb +4 -0
- data/app/controllers/skadi/asset_controller.rb +28 -0
- data/app/controllers/skadi/dashboard_controller.rb +105 -0
- data/app/controllers/skadi/tracking_controller.rb +106 -0
- data/app/helpers/skadi/application_helper.rb +38 -0
- data/app/models/skadi/application_record.rb +5 -0
- data/app/models/skadi/dashboard.rb +83 -0
- data/app/models/skadi/dashboard_query.rb +314 -0
- data/app/models/skadi/dashboard_validator.rb +269 -0
- data/app/models/skadi/demographic.rb +31 -0
- data/app/models/skadi/event.rb +33 -0
- data/app/models/skadi/helpers/sql.rb +86 -0
- data/app/models/skadi/schema.rb +135 -0
- data/app/models/skadi/view.rb +9 -0
- data/app/models/skadi/visit.rb +59 -0
- data/app/views/skadi/dashboard/_layout.html.erb +21 -0
- data/app/views/skadi/dashboard/show.html.erb +18 -0
- data/config/routes.rb +14 -0
- data/lib/generators/skadi/install/USAGE +20 -0
- data/lib/generators/skadi/install/install_generator.rb +46 -0
- data/lib/generators/skadi/install/templates/skadi_migration.rb.erb +111 -0
- data/lib/skadi/analytics.rb +35 -0
- data/lib/skadi/anonymity_set.rb +38 -0
- data/lib/skadi/configuration.rb +232 -0
- data/lib/skadi/controller_delegate.rb +322 -0
- data/lib/skadi/cookie_manager.rb +81 -0
- data/lib/skadi/engine.rb +38 -0
- data/lib/skadi/url.rb +66 -0
- data/lib/skadi/user_agent.rb +458 -0
- data/lib/skadi/version.rb +3 -0
- data/lib/skadi.rb +26 -0
- metadata +203 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
module Skadi
|
|
2
|
+
class DashboardQuery
|
|
3
|
+
class << self
|
|
4
|
+
# Mimics app/frontend/dashboard/dashboardElements/DynamicTable.svelte:13
|
|
5
|
+
ITEMS_PER_PAGE = 10
|
|
6
|
+
PAGES_PER_CHUNK = 10
|
|
7
|
+
|
|
8
|
+
def chart_query(chart, untrusted_param_filters)
|
|
9
|
+
queries = chart["datasets"].filter_map do |dataset|
|
|
10
|
+
if dataset["type"] == "sql"
|
|
11
|
+
build_sql_query(dataset, chart, untrusted_param_filters)
|
|
12
|
+
elsif dataset["type"] == "percentage"
|
|
13
|
+
# Percentage charts are handled by the frontend using other datasets
|
|
14
|
+
next
|
|
15
|
+
elsif Schema.database_schema.key?(dataset["type"].to_sym)
|
|
16
|
+
schema = Schema.database_schema[dataset["type"].to_sym]
|
|
17
|
+
|
|
18
|
+
build_query_from_schema(dataset, chart, schema, untrusted_param_filters)
|
|
19
|
+
else
|
|
20
|
+
raise ::Skadi::Dashboard::DatasetConfigurationError.new("Unknown dataset type #{dataset["type"]}")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
return { data: [] } unless queries.any?
|
|
25
|
+
|
|
26
|
+
result = {
|
|
27
|
+
data: [],
|
|
28
|
+
}
|
|
29
|
+
Skadi::ApplicationRecord.connection.transaction do
|
|
30
|
+
if chart["type"] == "table"
|
|
31
|
+
# Note that the page starts with 1
|
|
32
|
+
page = if untrusted_param_filters["page"].is_a?(Integer) && untrusted_param_filters["page"] > 0
|
|
33
|
+
untrusted_param_filters["page"]
|
|
34
|
+
else
|
|
35
|
+
1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# We cannot union more than one query when selecting multiple fields for the table dataset so just take the first one as a precaution
|
|
39
|
+
query = queries.first
|
|
40
|
+
|
|
41
|
+
result[:resultCount] = Skadi::Visit.connection.select_one("
|
|
42
|
+
SELECT COUNT(*) as count FROM (#{query}) q
|
|
43
|
+
")["count"]
|
|
44
|
+
|
|
45
|
+
last_page = [ 1, (result[:resultCount].to_f / ITEMS_PER_PAGE).ceil ].max
|
|
46
|
+
offset = ([ page, last_page ].min - 1) * ITEMS_PER_PAGE
|
|
47
|
+
limit = ITEMS_PER_PAGE * PAGES_PER_CHUNK
|
|
48
|
+
|
|
49
|
+
result[:resultOffset] = offset
|
|
50
|
+
|
|
51
|
+
result[:data] = Skadi::Visit.connection.select_all("
|
|
52
|
+
SELECT * FROM (#{query}) q LIMIT #{limit} OFFSET #{offset}
|
|
53
|
+
")
|
|
54
|
+
else
|
|
55
|
+
result[:data] = Skadi::ApplicationRecord.connection.unprepared_statement do
|
|
56
|
+
Skadi::Visit.connection.select_all(queries.join(" UNION ALL "))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Rollback after the SQL is run, preventing some side effects
|
|
61
|
+
raise ActiveRecord::Rollback
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private def build_sql_query(dataset, chart, untrusted_param_filters)
|
|
68
|
+
model = Skadi::Visit
|
|
69
|
+
|
|
70
|
+
safe_group = [ "t.split" ]
|
|
71
|
+
safe_id = model.connection.quote(dataset["id"])
|
|
72
|
+
|
|
73
|
+
if chart["time_series"].present?
|
|
74
|
+
safe_aggregated_date = Helpers::Sql.time_series(chart["time_series"], model, "t.date")
|
|
75
|
+
safe_group << safe_aggregated_date
|
|
76
|
+
else
|
|
77
|
+
safe_aggregated_date = "NULL"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
safe_where = build_sql_where(chart, model, untrusted_param_filters)
|
|
81
|
+
|
|
82
|
+
return "
|
|
83
|
+
SELECT
|
|
84
|
+
#{safe_id} as id,
|
|
85
|
+
#{safe_aggregated_date} as date,
|
|
86
|
+
t.split as split,
|
|
87
|
+
SUM(t.count) as count
|
|
88
|
+
FROM
|
|
89
|
+
(#{dataset["sql"]}) AS t
|
|
90
|
+
#{safe_where}
|
|
91
|
+
GROUP BY
|
|
92
|
+
#{safe_group.join(", ")}
|
|
93
|
+
"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private def build_sql_where(chart, model, untrusted_param_filters)
|
|
97
|
+
safe_where = []
|
|
98
|
+
|
|
99
|
+
url_date_from = untrusted_param_filters["date_from"] if valid_date_string?(untrusted_param_filters["date_from"])
|
|
100
|
+
date_from = combine_dates(chart["date_from"], url_date_from, type: :from)
|
|
101
|
+
safe_where << "DATE(t.date) >= #{model.connection.quote(date_from)}" unless date_from.nil?
|
|
102
|
+
|
|
103
|
+
url_date_to = untrusted_param_filters["date_to"] if valid_date_string?(untrusted_param_filters["date_to"])
|
|
104
|
+
date_to = combine_dates(chart["date_to"], url_date_to, type: :to)
|
|
105
|
+
safe_where << "DATE(t.date) <= #{model.connection.quote(date_to)}" unless date_to.nil?
|
|
106
|
+
|
|
107
|
+
return safe_where.any? ? "WHERE #{safe_where.join(" AND ")}" : ""
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private def build_query_from_schema(dataset, chart, schema, untrusted_param_filters)
|
|
111
|
+
query = schema[:model].all
|
|
112
|
+
|
|
113
|
+
query = apply_schema_filters(dataset, schema, query)
|
|
114
|
+
query = apply_chart_filters(dataset, chart, schema, query, untrusted_param_filters)
|
|
115
|
+
|
|
116
|
+
query = if chart["type"] == "table"
|
|
117
|
+
select_table_query(schema, query)
|
|
118
|
+
else
|
|
119
|
+
select_split_and_group_chart_query(dataset, chart, schema, query)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
return query.to_sql
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
POSITIVE_OPERATORS = [ "=", ">", ">=", "<=", "<", "like" ].freeze
|
|
126
|
+
NEGATED_OPERATORS = [ "!=", "not like" ].freeze
|
|
127
|
+
|
|
128
|
+
private def apply_schema_filters(dataset, schema, query)
|
|
129
|
+
model = schema[:model]
|
|
130
|
+
|
|
131
|
+
if dataset["filters"].is_a?(Array)
|
|
132
|
+
dataset["filters"].each do |filter|
|
|
133
|
+
next unless filter.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
field = filter["field"]
|
|
136
|
+
field_config = schema[:fields][field.to_s.to_sym]
|
|
137
|
+
operator = filter["operator"]
|
|
138
|
+
value = filter["value"]
|
|
139
|
+
|
|
140
|
+
next if field_config.nil? || operator.nil? || !field_config[:filter]
|
|
141
|
+
|
|
142
|
+
field_type = field_config[:type] || :string
|
|
143
|
+
field_sql = if field_config[:sql]
|
|
144
|
+
field_config[:sql]
|
|
145
|
+
else
|
|
146
|
+
"#{model.table_name}.#{model.connection.quote_column_name(field)}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if field_type == :date
|
|
150
|
+
field_sql = "DATE(#{field_sql})"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if POSITIVE_OPERATORS.include?(operator)
|
|
154
|
+
operator = Helpers::Sql.operator(schema[:model], filter["operator"])
|
|
155
|
+
|
|
156
|
+
# For the non-negated operators, we consider an empty string the same as null
|
|
157
|
+
if field_type == :string && value == ""
|
|
158
|
+
query = query.where("#{field_sql} IS NULL OR #{field_sql} #{operator} ?", value)
|
|
159
|
+
else
|
|
160
|
+
query = query.where("#{field_sql} #{operator} ?", value)
|
|
161
|
+
end
|
|
162
|
+
elsif NEGATED_OPERATORS.include?(operator)
|
|
163
|
+
operator = Helpers::Sql.operator(schema[:model], filter["operator"])
|
|
164
|
+
|
|
165
|
+
# For the negated operators, we change the behaviour of SQL so that nulls are considered different (rather than defaulting to false)
|
|
166
|
+
if field_type == :string && value == ""
|
|
167
|
+
query = query.where("#{field_sql} #{operator} ?", value)
|
|
168
|
+
else
|
|
169
|
+
query = query.where("#{field_sql} IS NULL OR #{field_sql} #{operator} ?", value)
|
|
170
|
+
end
|
|
171
|
+
elsif operator == "empty"
|
|
172
|
+
if field_type == :string
|
|
173
|
+
query = query.where("#{field_sql} = '' OR #{field_sql} IS NULL")
|
|
174
|
+
else
|
|
175
|
+
query = query.where("#{field_sql} IS NULL")
|
|
176
|
+
end
|
|
177
|
+
elsif operator == "not empty"
|
|
178
|
+
if field_type == :string
|
|
179
|
+
query = query.where("#{field_sql} != '' AND #{field_sql} IS NOT NULL")
|
|
180
|
+
else
|
|
181
|
+
query = query.where("#{field_sql} IS NOT NULL")
|
|
182
|
+
end
|
|
183
|
+
else
|
|
184
|
+
raise Dashboard::UnsupportedOperatorError.new("Unknown operator #{operator}")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
return query
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private def apply_chart_filters(_dataset, chart, schema, query, untrusted_param_filters)
|
|
193
|
+
date_config = schema[:fields][:date]
|
|
194
|
+
|
|
195
|
+
if date_config
|
|
196
|
+
url_date_from = untrusted_param_filters["date_from"] if valid_date_string?(untrusted_param_filters["date_from"])
|
|
197
|
+
date_from = combine_dates(chart["date_from"], url_date_from, type: :from)
|
|
198
|
+
query = query.where("DATE(#{date_config[:sql]}) >= ?", date_from) unless date_from.nil?
|
|
199
|
+
|
|
200
|
+
url_date_to = untrusted_param_filters["date_to"] if valid_date_string?(untrusted_param_filters["date_to"])
|
|
201
|
+
date_to = combine_dates(chart["date_to"], url_date_to, type: :to)
|
|
202
|
+
query = query.where("DATE(#{date_config[:sql]}) <= ?", date_to) unless date_to.nil?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if schema[:visit_key]
|
|
206
|
+
# Join the visits if we need to
|
|
207
|
+
if chart["verified_visits"] == true || chart["visit_tracking"] || chart["unique_by"] == "visitor"
|
|
208
|
+
if schema[:model] != Skadi::Visit
|
|
209
|
+
query = query.joins(%(INNER JOIN skadi_visits ON skadi_visits.id = #{schema[:model].table_name}.#{schema[:visit_key]}))
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
if chart["verified_visits"] == true
|
|
214
|
+
query = query.where("skadi_visits.verified = TRUE")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
if chart["visit_tracking"] == "any"
|
|
218
|
+
query = query.where("skadi_visits.tracking_token IS NOT NULL")
|
|
219
|
+
elsif chart["visit_tracking"] == "anonymity_set"
|
|
220
|
+
query = query.where("skadi_visits.tracking_token IS NOT NULL AND skadi_visits.cookies_enabled = FALSE")
|
|
221
|
+
elsif chart["visit_tracking"] == "cookie"
|
|
222
|
+
query = query.where("skadi_visits.tracking_token IS NOT NULL AND skadi_visits.cookies_enabled = TRUE")
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
return query
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private def select_table_query(schema, query)
|
|
230
|
+
model = schema[:model]
|
|
231
|
+
select_fields = schema[:fields].map do |key, field_config|
|
|
232
|
+
quoted_field = model.connection.quote_column_name(key)
|
|
233
|
+
|
|
234
|
+
if field_config[:sql]
|
|
235
|
+
"#{field_config[:sql]} AS #{quoted_field}"
|
|
236
|
+
else
|
|
237
|
+
"#{model.table_name}.#{quoted_field}"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
return query.select(*select_fields)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private def select_split_and_group_chart_query(dataset, chart, schema, query)
|
|
245
|
+
model = schema[:model]
|
|
246
|
+
split_columns = safe_split_columns(dataset, schema)
|
|
247
|
+
|
|
248
|
+
safe_id = model.connection.quote(dataset["id"])
|
|
249
|
+
safe_date = "NULL"
|
|
250
|
+
safe_split = "NULL"
|
|
251
|
+
safe_count = "COUNT(*)"
|
|
252
|
+
safe_group = split_columns.dup
|
|
253
|
+
|
|
254
|
+
if chart["time_series"].present? && schema[:fields][:date]
|
|
255
|
+
safe_date = Helpers::Sql.time_series(chart["time_series"], model, schema[:fields][:date][:sql])
|
|
256
|
+
safe_group << safe_date
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# If there are split columns, we override the default value of split using them
|
|
260
|
+
if split_columns.length == 1
|
|
261
|
+
safe_split = split_columns.first
|
|
262
|
+
elsif split_columns.length > 1
|
|
263
|
+
# Concatenate the split columns, separating the columns with a token that can be processed in the front end
|
|
264
|
+
safe_split = "CONCAT(#{split_columns.join(", '|~|', ")})"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if schema[:count_sql]
|
|
268
|
+
safe_count = schema[:count_sql]
|
|
269
|
+
elsif chart["unique_by"] == "visit" && schema[:visit_key]
|
|
270
|
+
safe_count = "COUNT(DISTINCT #{schema[:model].table_name}.#{schema[:visit_key]})"
|
|
271
|
+
elsif chart["unique_by"] == "visitor" && schema[:visit_key]
|
|
272
|
+
safe_count = "COUNT(DISTINCT skadi_visits.tracking_token)"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
return query
|
|
276
|
+
.select(
|
|
277
|
+
"#{safe_id} AS id",
|
|
278
|
+
"#{safe_date} AS date",
|
|
279
|
+
"#{safe_split} AS split",
|
|
280
|
+
"#{safe_count} AS count",
|
|
281
|
+
)
|
|
282
|
+
.group(safe_group)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
private def safe_split_columns(dataset, schema)
|
|
286
|
+
safe_split = []
|
|
287
|
+
|
|
288
|
+
if dataset["split_by"].is_a?(Array)
|
|
289
|
+
dataset["split_by"].each do |field|
|
|
290
|
+
field_config = schema[:fields][field.to_sym]
|
|
291
|
+
if field_config && field_config[:split]
|
|
292
|
+
safe_field = schema[:model].connection.quote_column_name(field)
|
|
293
|
+
|
|
294
|
+
safe_split << (field_config[:sql] || %(#{schema[:model].table_name}.#{safe_field}))
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
return safe_split
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
private def valid_date_string?(date) = date.is_a?(String) && date.match(/\A\d{4}-[01]\d-[0-3]\d\z/)
|
|
303
|
+
|
|
304
|
+
# Combine dates for the date ranges, being conservative when combining time ranges
|
|
305
|
+
private def combine_dates(*dates, type:)
|
|
306
|
+
if type == :from
|
|
307
|
+
return dates.compact.max
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
return dates.compact.min
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
module Skadi
|
|
2
|
+
# Validates that a Dashboard's configuration matches the DashboardConfig type in app/frontend/types.d.ts
|
|
3
|
+
class DashboardValidator < ActiveModel::Validator
|
|
4
|
+
OneOf = Struct.new(:allowed_values, :allow_missing)
|
|
5
|
+
ArrayOf = Struct.new(:element_type, :minimum_elements)
|
|
6
|
+
Filter = Struct.new(:filters)
|
|
7
|
+
|
|
8
|
+
FILTER_TYPES = {
|
|
9
|
+
boolean: {
|
|
10
|
+
operators: [ "=", "!=", "empty", "not empty" ],
|
|
11
|
+
},
|
|
12
|
+
date: {
|
|
13
|
+
operators: [ "=", ">", ">=", "<=", "<", "!=", "empty", "not empty" ],
|
|
14
|
+
},
|
|
15
|
+
number: {
|
|
16
|
+
operators: [ "=", ">", ">=", "<=", "<", "!=", "empty", "not empty" ],
|
|
17
|
+
},
|
|
18
|
+
one_of: {
|
|
19
|
+
operators: [ "=", "!=", "empty", "not empty" ],
|
|
20
|
+
},
|
|
21
|
+
string: {
|
|
22
|
+
operators: [ "=", "!=", "like", "not like", "empty", "not empty" ],
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
COMMON_DATASET_FIELDS = {
|
|
27
|
+
id: :string,
|
|
28
|
+
label: :string,
|
|
29
|
+
visible: :boolean?,
|
|
30
|
+
axis: OneOf.new(allowed_values: %w[left right].freeze, allow_missing: true),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def self.types
|
|
34
|
+
return @types if defined?(@types)
|
|
35
|
+
|
|
36
|
+
@types = {
|
|
37
|
+
DashboardTabConfig: {
|
|
38
|
+
id: :string,
|
|
39
|
+
title: :string,
|
|
40
|
+
description: :string?,
|
|
41
|
+
date_from: :date?,
|
|
42
|
+
date_to: :date?,
|
|
43
|
+
children: ArrayOf.new(:ChartConfig),
|
|
44
|
+
},
|
|
45
|
+
ChartConfig: {
|
|
46
|
+
id: :string,
|
|
47
|
+
type: OneOf.new(allowed_values: %w[bar line table].freeze).freeze,
|
|
48
|
+
title: :string,
|
|
49
|
+
description: :string?,
|
|
50
|
+
time_series: OneOf.new(allowed_values: %w[daily weekly monthly].freeze, allow_missing: true).freeze,
|
|
51
|
+
date_from: :date?,
|
|
52
|
+
date_to: :date?,
|
|
53
|
+
verified_visits: :boolean?,
|
|
54
|
+
unique_by: OneOf.new(allowed_values: %w[visit visitor].freeze, allow_missing: true).freeze,
|
|
55
|
+
visit_tracking: OneOf.new(allowed_values: %w[any anonymity_set cookie].freeze, allow_missing: true).freeze,
|
|
56
|
+
datasets: ArrayOf.new(:Dataset, 1),
|
|
57
|
+
},
|
|
58
|
+
percentageDataset: {
|
|
59
|
+
**COMMON_DATASET_FIELDS,
|
|
60
|
+
type: "percentage",
|
|
61
|
+
|
|
62
|
+
numerator: :dataset_id,
|
|
63
|
+
denominator: :dataset_id,
|
|
64
|
+
},
|
|
65
|
+
sqlDataset: {
|
|
66
|
+
**COMMON_DATASET_FIELDS,
|
|
67
|
+
type: "sql",
|
|
68
|
+
|
|
69
|
+
sql: :sql,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
Schema.database_schema.each do |dataset_name, schema|
|
|
74
|
+
dataset_type = COMMON_DATASET_FIELDS.dup
|
|
75
|
+
dataset_type[:type] = dataset_name.to_s
|
|
76
|
+
|
|
77
|
+
split_by_fields = []
|
|
78
|
+
filters = {}
|
|
79
|
+
|
|
80
|
+
schema[:fields].each do |field, field_config|
|
|
81
|
+
split_by_fields << field.to_s if field_config[:split]
|
|
82
|
+
next unless field_config[:filter]
|
|
83
|
+
|
|
84
|
+
if field_config[:type] == :one_of
|
|
85
|
+
filters[field] = OneOf.new(allowed_values: field_config[:options], allow_missing: true)
|
|
86
|
+
else
|
|
87
|
+
filters[field] = field_config[:type] || :string
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
dataset_type[:split_by] = ArrayOf.new(OneOf.new(split_by_fields, false)) if split_by_fields.any?
|
|
92
|
+
dataset_type[:filters] = ArrayOf.new(Filter.new(filters))
|
|
93
|
+
|
|
94
|
+
@types[:"#{dataset_name}Dataset"] = dataset_type
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return @types
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
Context = Struct.new(:record, :allowed_sql_strings, :dataset_ids)
|
|
101
|
+
|
|
102
|
+
def validate(record)
|
|
103
|
+
context = Context.new(record, nil, [])
|
|
104
|
+
|
|
105
|
+
validate_type(ArrayOf.new(:DashboardTabConfig, 1), record.configuration, "configuration", context:)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private def validate_type(type, value, path, context:)
|
|
109
|
+
return validate_array_of(type, value, path, context:) if type.is_a?(ArrayOf)
|
|
110
|
+
return validate_hash(type, value, path, context:) if type.is_a?(Hash)
|
|
111
|
+
return validate_filter(type, value, path, context:) if type.is_a?(Filter)
|
|
112
|
+
return validate_one_of(type, value, path, context:) if type.is_a?(OneOf)
|
|
113
|
+
|
|
114
|
+
if type.is_a?(String)
|
|
115
|
+
add_error(path, "must be #{type}", context:) unless type == value
|
|
116
|
+
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if type.is_a?(Symbol) && type.end_with?("?")
|
|
121
|
+
return if value.nil?
|
|
122
|
+
|
|
123
|
+
type = type.to_s.delete_suffix("?").to_sym
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
case type
|
|
127
|
+
when :string
|
|
128
|
+
add_error(path, "must be a string", context:) unless value.is_a?(String)
|
|
129
|
+
return
|
|
130
|
+
when :boolean
|
|
131
|
+
add_error(path, "must be a boolean", context:) unless value == true || value == false
|
|
132
|
+
return
|
|
133
|
+
when :number
|
|
134
|
+
add_error(path, "must be a number", context:) unless value.is_a?(Numeric)
|
|
135
|
+
return
|
|
136
|
+
when :date
|
|
137
|
+
add_error(path, "must be a date", context:) unless value.is_a?(String) && value.match?(/\A\d{4}-[01]\d-[0-3]\d\z/)
|
|
138
|
+
return
|
|
139
|
+
when :dataset_id
|
|
140
|
+
add_error(path, "must be a dataset id", context:) unless context.dataset_ids.include?(value)
|
|
141
|
+
return
|
|
142
|
+
when :sql
|
|
143
|
+
return validate_sql(value, path, context:)
|
|
144
|
+
when :Dataset
|
|
145
|
+
return validate_dataset(value, path, context:)
|
|
146
|
+
else
|
|
147
|
+
return validate_custom_type(type, value, path, context:)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Array of a particular type
|
|
152
|
+
# @param [ArrayOf] type
|
|
153
|
+
private def validate_array_of(type, value, path, context:)
|
|
154
|
+
return if type.minimum_elements.nil? && value.nil?
|
|
155
|
+
return add_error(path, "must be an array", context:) unless value.is_a?(Array)
|
|
156
|
+
return add_error(path, "must have at least one element", context:) if type.minimum_elements == 1 && value.empty?
|
|
157
|
+
|
|
158
|
+
return value.each_with_index do |item, index|
|
|
159
|
+
validate_type(type.element_type, item, "#{path}[#{index}]", context:)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private def validate_hash(type, value, path, context:)
|
|
164
|
+
return add_error(path, "must be a hash", context:) unless value.is_a?(Hash)
|
|
165
|
+
|
|
166
|
+
extra_keys = value.keys.map(&:to_sym) - type.keys
|
|
167
|
+
extra_keys.each do |key|
|
|
168
|
+
add_error("#{path}.#{key}", "is not a valid key", context:)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
type.each do |item_key, item_type|
|
|
172
|
+
validate_type(item_type, value[item_key.to_s], "#{path}.#{item_key}", context:)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private def validate_filter(type, value, path, context:)
|
|
177
|
+
return add_error(path, "must be a Hash", context:) unless value.is_a?(Hash)
|
|
178
|
+
|
|
179
|
+
filters = type.filters
|
|
180
|
+
filter_field = value["field"].to_s.to_sym
|
|
181
|
+
return add_error("#{path}.field", "must be one of #{filters.keys.join(", ")}", context:) unless filters.key?(filter_field)
|
|
182
|
+
|
|
183
|
+
filter_type = filters[filter_field]
|
|
184
|
+
allowed_operators = FILTER_TYPES[filter_type.is_a?(OneOf) ? :one_of : filter_type][:operators]
|
|
185
|
+
filter_operator = value["operator"]
|
|
186
|
+
|
|
187
|
+
return add_error("#{path}.operator", "must be one of #{allowed_operators.join(", ")}", context:) unless allowed_operators.include?(filter_operator)
|
|
188
|
+
|
|
189
|
+
# These do not require a value to be specified
|
|
190
|
+
if [ "empty", "not empty" ].include?(filter_operator)
|
|
191
|
+
add_error("#{path}.value", "is not a valid key", context:) if value.key?("value")
|
|
192
|
+
|
|
193
|
+
return
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
return add_error("#{path}.value", "must be set", context:) if value["value"].nil?
|
|
197
|
+
|
|
198
|
+
return validate_type(filter_type, value["value"], "#{path}.value", context:)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private def validate_one_of(type, value, path, context:)
|
|
202
|
+
return if type.allow_missing && value.nil?
|
|
203
|
+
return if type.allowed_values.include?(value)
|
|
204
|
+
|
|
205
|
+
add_error(path, "#{value.inspect} must be one of #{type.allowed_values.map(&:inspect).join(", ")}", context:)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private def validate_sql(value, path, context:)
|
|
209
|
+
unless context.record.can_dangerously_use_sql
|
|
210
|
+
return add_error(path, "cannot be modified", context:) unless allowed_sql_strings(context:).include?(value)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
unless value.is_a?(String)
|
|
214
|
+
return add_error(path, "must be a string", context:)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
unless [ "date", "split", "count" ].all? { |it| value.include?(it) }
|
|
218
|
+
add_error(path, "must include the fields date, split and count", context:)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private def validate_dataset(value, path, context:)
|
|
223
|
+
return add_error(path, "must be a hash", context:) unless value.is_a?(Hash)
|
|
224
|
+
|
|
225
|
+
type = :"#{value["type"]}Dataset"
|
|
226
|
+
return add_error("#{path}.type", "is not a valid dataset type", context:) unless self.class.types.include?(type)
|
|
227
|
+
|
|
228
|
+
validate_type(type, value, path, context:)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private def validate_custom_type(type, value, path, context:)
|
|
232
|
+
return add_error(path, "Unknown type #{type.inspect}", context:) unless self.class.types.key?(type)
|
|
233
|
+
|
|
234
|
+
if type == :ChartConfig && value.is_a?(Hash)
|
|
235
|
+
context.dataset_ids = []
|
|
236
|
+
|
|
237
|
+
if value["datasets"].is_a?(Array)
|
|
238
|
+
# Special case for table chart type, limiting datasets to 1 and ruling out derived datasets
|
|
239
|
+
if value["type"] == "table"
|
|
240
|
+
if value["datasets"].is_a?(Array) && value["datasets"].length != 1
|
|
241
|
+
return add_error("#{path}.datasets", "must have one dataset for the table type", context:)
|
|
242
|
+
end
|
|
243
|
+
if value["datasets"].is_a?(Array) && value["datasets"].first.is_a?(Hash) && value["datasets"].first["type"] == "percentage"
|
|
244
|
+
return add_error("#{path}.datasets[0].type", "cannot be percentage for the table type", context:)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Store dataset ids for the current chart for the :dataset_id type
|
|
249
|
+
value["datasets"].each do |dataset|
|
|
250
|
+
context.dataset_ids << dataset["id"] if dataset.is_a?(Hash) && dataset["id"].is_a?(String)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
return validate_type(self.class.types[type], value, path, context:)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private def add_error(path, message, context:)
|
|
259
|
+
context.record.errors.add(:configuration, "#{path} #{message}")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private def allowed_sql_strings(context:)
|
|
263
|
+
# Return the cached SQL strings if already calculated
|
|
264
|
+
return context.allowed_sql_strings if context.allowed_sql_strings.is_a?(Array)
|
|
265
|
+
|
|
266
|
+
return context.allowed_sql_strings = context.record.sql_strings
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Skadi
|
|
2
|
+
class Demographic < ApplicationRecord
|
|
3
|
+
self.implicit_order_column = :recorded_on
|
|
4
|
+
|
|
5
|
+
validates :name, presence: true
|
|
6
|
+
validates :value, presence: true
|
|
7
|
+
|
|
8
|
+
validates :recorded_on, presence: true
|
|
9
|
+
|
|
10
|
+
def self.create_or_increment_all(demographics)
|
|
11
|
+
demographics_to_upsert = demographics.map do |demographic|
|
|
12
|
+
{
|
|
13
|
+
name: demographic[:name].strip[0, 255],
|
|
14
|
+
value: demographic[:value].strip[0, 255],
|
|
15
|
+
# SQL specifies NULL values are not equal, so we need to default the URI to an empty string
|
|
16
|
+
# to ensure the unique index works correctly
|
|
17
|
+
uri: demographic[:uri]&.strip&.[](0, 255) || "",
|
|
18
|
+
recorded_on: Time.current,
|
|
19
|
+
count: 1,
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Skadi::Demographic.upsert_all(
|
|
24
|
+
demographics_to_upsert,
|
|
25
|
+
unique_by: [ :uri, :name, :value, :recorded_on ],
|
|
26
|
+
on_duplicate: Arel.sql("count = skadi_demographics.count + 1"),
|
|
27
|
+
returning: false,
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Skadi
|
|
2
|
+
class Event < ApplicationRecord
|
|
3
|
+
belongs_to :visit, class_name: "Skadi::Visit", optional: true, inverse_of: :events
|
|
4
|
+
belongs_to :view, class_name: "Skadi::View", optional: true, inverse_of: :events
|
|
5
|
+
|
|
6
|
+
validates :name, presence: true
|
|
7
|
+
|
|
8
|
+
# Redacts events that are marked as sensitive
|
|
9
|
+
def self.redact_and_insert(events, view:, visit:)
|
|
10
|
+
events.each do |event|
|
|
11
|
+
# Note: both paths must have the same set of keys, which is a requirement for upsert_all
|
|
12
|
+
if event[:sensitive]
|
|
13
|
+
event[:view_id] = nil
|
|
14
|
+
event[:visit_id] = nil
|
|
15
|
+
|
|
16
|
+
# Sensitive events should have their created date redacted so they cannot be linked to views/visits based on timings
|
|
17
|
+
event[:created_at] = Time.current.beginning_of_day
|
|
18
|
+
else
|
|
19
|
+
# Attach events to the current visit and view, only if it is not marked as sensitive
|
|
20
|
+
event[:view_id] = view.id
|
|
21
|
+
event[:visit_id] = visit&.id
|
|
22
|
+
event[:created_at] = Time.current
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
event[:name] = event[:name].strip[0, 255]
|
|
26
|
+
|
|
27
|
+
event.delete(:sensitive)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Event.insert_all(events)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|