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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/LICENSE.md +22 -0
  4. data/README.md +111 -0
  5. data/app/assets/builds/dashboard.css +2 -0
  6. data/app/assets/builds/dashboard.js +36 -0
  7. data/app/assets/builds/skadi.js +1 -0
  8. data/app/controllers/skadi/application_controller.rb +4 -0
  9. data/app/controllers/skadi/asset_controller.rb +28 -0
  10. data/app/controllers/skadi/dashboard_controller.rb +105 -0
  11. data/app/controllers/skadi/tracking_controller.rb +106 -0
  12. data/app/helpers/skadi/application_helper.rb +38 -0
  13. data/app/models/skadi/application_record.rb +5 -0
  14. data/app/models/skadi/dashboard.rb +83 -0
  15. data/app/models/skadi/dashboard_query.rb +314 -0
  16. data/app/models/skadi/dashboard_validator.rb +269 -0
  17. data/app/models/skadi/demographic.rb +31 -0
  18. data/app/models/skadi/event.rb +33 -0
  19. data/app/models/skadi/helpers/sql.rb +86 -0
  20. data/app/models/skadi/schema.rb +135 -0
  21. data/app/models/skadi/view.rb +9 -0
  22. data/app/models/skadi/visit.rb +59 -0
  23. data/app/views/skadi/dashboard/_layout.html.erb +21 -0
  24. data/app/views/skadi/dashboard/show.html.erb +18 -0
  25. data/config/routes.rb +14 -0
  26. data/lib/generators/skadi/install/USAGE +20 -0
  27. data/lib/generators/skadi/install/install_generator.rb +46 -0
  28. data/lib/generators/skadi/install/templates/skadi_migration.rb.erb +111 -0
  29. data/lib/skadi/analytics.rb +35 -0
  30. data/lib/skadi/anonymity_set.rb +38 -0
  31. data/lib/skadi/configuration.rb +232 -0
  32. data/lib/skadi/controller_delegate.rb +322 -0
  33. data/lib/skadi/cookie_manager.rb +81 -0
  34. data/lib/skadi/engine.rb +38 -0
  35. data/lib/skadi/url.rb +66 -0
  36. data/lib/skadi/user_agent.rb +458 -0
  37. data/lib/skadi/version.rb +3 -0
  38. data/lib/skadi.rb +26 -0
  39. 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