mensa 0.6.18 → 0.7.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/.gitignore +1 -1
- data/.shipkit.yml +1 -1
- data/CHANGELOG.md +39 -1
- data/Gemfile.lock +1 -1
- data/README.md +109 -15
- data/app/components/mensa/row_action/component.html.erb +2 -1
- data/app/tables/mensa/base.rb +12 -12
- data/app/tables/mensa/column.rb +18 -3
- data/app/tables/mensa/config/column_dsl.rb +2 -1
- data/app/tables/mensa/filter.rb +46 -3
- data/app/tables/mensa/scope.rb +3 -0
- data/lib/mensa/configuration.rb +4 -1
- data/lib/mensa/testing.rb +11 -10
- data/lib/mensa/verifier/result.rb +33 -11
- data/lib/mensa/verifier.rb +466 -64
- data/lib/mensa/version.rb +1 -1
- data/lib/tasks/mensa.rake +2 -2
- metadata +1 -1
data/lib/mensa/verifier.rb
CHANGED
|
@@ -8,31 +8,45 @@ require "mensa/verifier/result"
|
|
|
8
8
|
module Mensa
|
|
9
9
|
# Checks that everything a table configures works against the real database:
|
|
10
10
|
# its scope, every column's SQL, sorting, rendering, filters, search, the CSV
|
|
11
|
-
# export, grouping/aggregates and its views.
|
|
11
|
+
# export, grouping/aggregates and its views. It also warns about N+1 queries
|
|
12
|
+
# and slow queries.
|
|
12
13
|
#
|
|
13
14
|
# result = Mensa::Verifier.new(UsersTable, user: User.first).call
|
|
14
15
|
# result.success? # => true
|
|
15
16
|
# puts result # per-check report
|
|
16
17
|
#
|
|
17
18
|
# Checks go through the same code paths as requests do (filtered_scope,
|
|
18
|
-
# ordered_scope, Mensa::Cell, Mensa::ExportJob
|
|
19
|
-
# runs in a read-only transaction that is rolled back, and every
|
|
20
|
-
# own savepoint so one failing query doesn't stop the others.
|
|
19
|
+
# ordered_scope, Mensa::Cell, the components, Mensa::ExportJob, ...).
|
|
20
|
+
# Everything runs in a read-only transaction that is rolled back, and every
|
|
21
|
+
# check in its own savepoint so one failing query doesn't stop the others.
|
|
21
22
|
#
|
|
22
23
|
# Tables often depend on request context, give it with user:, account: and
|
|
23
24
|
# context: (all set on Current while verifying), params: (the table's
|
|
24
25
|
# params) or a block, which is called with the verifier before the checks.
|
|
25
|
-
# Mensa.config.verifier_context supplies defaults for these
|
|
26
|
+
# Mensa.config.verifier_context supplies defaults for these, or a list of
|
|
27
|
+
# them to verify a table in several contexts (see Verifier.verify).
|
|
26
28
|
class Verifier
|
|
27
|
-
# Records
|
|
29
|
+
# Records sampled from the first and the last page
|
|
28
30
|
SAMPLE_SIZE = 5
|
|
31
|
+
LAST_PAGE_SAMPLE = 2
|
|
32
|
+
|
|
33
|
+
# Statements that aren't the table's queries
|
|
34
|
+
IGNORED_QUERY = /\A\s*(SAVEPOINT|RELEASE|ROLLBACK|BEGIN|COMMIT|SET|SHOW)\b/i
|
|
29
35
|
|
|
30
36
|
class Skip < StandardError; end
|
|
31
37
|
|
|
32
38
|
# A problem the verifier found itself, reported without the class name
|
|
33
39
|
class Failure < StandardError; end
|
|
34
40
|
|
|
35
|
-
|
|
41
|
+
# A record to render, and why it was sampled
|
|
42
|
+
Sample = Struct.new(:row, :reason) do
|
|
43
|
+
def description
|
|
44
|
+
id = row.record.id if row.record.respond_to?(:id)
|
|
45
|
+
[("record #{id}" if id), "(#{reason})"].compact.join(" ")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
attr_reader :table_class, :user, :account, :context, :params, :label, :result
|
|
36
50
|
|
|
37
51
|
class << self
|
|
38
52
|
# Accepts a table class, its name (e.g. "UsersTable") or Mensa's table
|
|
@@ -55,21 +69,44 @@ module Mensa
|
|
|
55
69
|
}.uniq.sort_by(&:name)
|
|
56
70
|
end
|
|
57
71
|
|
|
72
|
+
# Verifies +table+ in every context Mensa.config.verifier_context
|
|
73
|
+
# returns for it (a hash, or an array of them), with +options+ on top.
|
|
74
|
+
# Returns one result per context.
|
|
75
|
+
def verify(table, **options, &setup)
|
|
76
|
+
table_class = resolve_table_class(table)
|
|
77
|
+
options = options.compact
|
|
78
|
+
|
|
79
|
+
contexts_for(table_class).map do |defaults|
|
|
80
|
+
new(table_class, **options, defaults: defaults, &setup).call
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The option hashes Mensa.config.verifier_context gives for a table
|
|
85
|
+
def contexts_for(table_class)
|
|
86
|
+
return [{}] unless table_class.is_a?(Class) && table_class < Mensa::Base
|
|
87
|
+
|
|
88
|
+
contexts = Mensa.config.verifier_context&.call(table_class)
|
|
89
|
+
contexts = [contexts] unless contexts.is_a?(Array)
|
|
90
|
+
contexts.map { |context| context.is_a?(Hash) ? context.symbolize_keys : {} }.presence || [{}]
|
|
91
|
+
end
|
|
92
|
+
|
|
58
93
|
# Verifies the tables (all under app/tables when none are given), prints
|
|
59
94
|
# their reports to +io+ and returns true when all of them passed.
|
|
60
|
-
# Context comes from USER_ID, ACCOUNT_ID and PARAMS (JSON) in +env+,
|
|
61
|
-
# by the mensa:verify rake tasks.
|
|
95
|
+
# Context comes from USER_ID, ACCOUNT_ID and PARAMS (JSON) in +env+, and
|
|
96
|
+
# STRICT=1 makes warnings fail, used by the mensa:verify rake tasks.
|
|
62
97
|
def run(tables = nil, env: ENV, io: $stdout)
|
|
63
98
|
classes = tables.present? ? Array(tables).map { |table| resolve_table_class(table) } : table_classes
|
|
64
99
|
options = options_from_env(env)
|
|
65
100
|
|
|
66
|
-
results = classes.
|
|
67
|
-
|
|
101
|
+
results = classes.flat_map do |klass|
|
|
102
|
+
verify(klass, **options).each { |result| io.puts(result, "") }
|
|
68
103
|
end
|
|
69
104
|
|
|
70
|
-
|
|
105
|
+
by_table = results.select(&:mensa_table?).group_by(&:table_class)
|
|
106
|
+
others = results.reject(&:mensa_table?)
|
|
71
107
|
if results.size > 1
|
|
72
|
-
|
|
108
|
+
passed = by_table.count { |_table, table_results| table_results.all?(&:success?) }
|
|
109
|
+
io.puts ["#{passed}/#{by_table.size} tables passed", ("#{others.size} skipped, not a Mensa::Base subclass" if others.any?)].compact.join(", ")
|
|
73
110
|
end
|
|
74
111
|
results.all?(&:success?)
|
|
75
112
|
end
|
|
@@ -79,20 +116,26 @@ module Mensa
|
|
|
79
116
|
options[:user] = "User".constantize.find(env["USER_ID"]) if env["USER_ID"].present?
|
|
80
117
|
options[:account] = "Account".constantize.find(env["ACCOUNT_ID"]) if env["ACCOUNT_ID"].present?
|
|
81
118
|
options[:params] = JSON.parse(env["PARAMS"]) if env["PARAMS"].present?
|
|
119
|
+
options[:strict] = true if %w[1 true yes].include?(env["STRICT"].to_s.downcase)
|
|
82
120
|
options
|
|
83
121
|
end
|
|
84
122
|
end
|
|
85
123
|
|
|
86
|
-
|
|
124
|
+
# +strict+ makes warnings fail, +slow_query+ is the duration in seconds
|
|
125
|
+
# from which a query is reported (Mensa.config.verifier_slow_query).
|
|
126
|
+
# +defaults+ replaces the first context of Mensa.config.verifier_context.
|
|
127
|
+
def initialize(table, user: nil, account: nil, context: {}, params: nil, label: nil, strict: false, slow_query: nil, view_context: nil, defaults: nil, &setup)
|
|
87
128
|
@table_class = self.class.resolve_table_class(table)
|
|
88
|
-
defaults
|
|
129
|
+
defaults ||= self.class.contexts_for(@table_class).first
|
|
89
130
|
@user = user || defaults[:user]
|
|
90
131
|
@account = account || defaults[:account]
|
|
91
132
|
@context = (defaults[:context] || {}).merge(context || {})
|
|
92
133
|
@params = params || defaults[:params] || {}
|
|
134
|
+
@label = label || defaults[:label] || context_label
|
|
135
|
+
@slow_query = slow_query || defaults[:slow_query] || Mensa.config.verifier_slow_query
|
|
93
136
|
@view_context = view_context
|
|
94
137
|
@setup = setup
|
|
95
|
-
@result = Result.new(@table_class)
|
|
138
|
+
@result = Result.new(@table_class, strict: strict || defaults[:strict], label: @label)
|
|
96
139
|
end
|
|
97
140
|
|
|
98
141
|
def call
|
|
@@ -104,10 +147,16 @@ module Mensa
|
|
|
104
147
|
return result
|
|
105
148
|
end
|
|
106
149
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
150
|
+
@queries = []
|
|
151
|
+
@reported_n_plus_one = Set.new
|
|
152
|
+
@per_record_renders = 0
|
|
153
|
+
thread = Thread.current
|
|
154
|
+
ActiveSupport::Notifications.subscribed(->(event) { record_query(event) if Thread.current == thread }, "sql.active_record") do
|
|
155
|
+
read_only_transaction do
|
|
156
|
+
with_current_attributes do
|
|
157
|
+
@setup&.call(self)
|
|
158
|
+
run_checks
|
|
159
|
+
end
|
|
111
160
|
end
|
|
112
161
|
end
|
|
113
162
|
result
|
|
@@ -139,15 +188,18 @@ module Mensa
|
|
|
139
188
|
|
|
140
189
|
private
|
|
141
190
|
|
|
142
|
-
def default_options
|
|
143
|
-
options = Mensa.config.verifier_context&.call(table_class)
|
|
144
|
-
options.is_a?(Hash) ? options.symbolize_keys : {}
|
|
145
|
-
end
|
|
146
|
-
|
|
147
191
|
def default_host
|
|
148
192
|
Rails.application.routes.default_url_options[:host] || "localhost"
|
|
149
193
|
end
|
|
150
194
|
|
|
195
|
+
# Names the context in the report, e.g. "user: User#1, params: {...}"
|
|
196
|
+
def context_label
|
|
197
|
+
values = context.symbolize_keys.merge({user: user, account: account}.compact)
|
|
198
|
+
parts = values.map { |key, value| "#{key}: #{value.is_a?(ActiveRecord::Base) ? "#{value.class.name}##{value.id}" : value.inspect}" }
|
|
199
|
+
parts << "params: #{params.to_json}" if params.present?
|
|
200
|
+
parts.join(", ").presence
|
|
201
|
+
end
|
|
202
|
+
|
|
151
203
|
def run_checks
|
|
152
204
|
@table = check(:loads, nil, "build table") { build_table }
|
|
153
205
|
unless @table
|
|
@@ -158,23 +210,33 @@ module Mensa
|
|
|
158
210
|
# A table of its own, a relation can't be counted after its to_sql
|
|
159
211
|
check(:loads, nil, "build SQL") { |c| c.sql = build_table.selected_scope.to_sql }
|
|
160
212
|
@rows = check(:loads, nil, "load first page") { @table.rows }
|
|
161
|
-
|
|
213
|
+
verify_last_page
|
|
162
214
|
verify_defaults
|
|
215
|
+
@samples = check(:loads, nil, "sample records") { |c| load_samples.tap { |samples| c.name = sample_name(samples) } } if @rows
|
|
163
216
|
|
|
164
217
|
verify_columns
|
|
165
218
|
verify_filters
|
|
166
219
|
verify_search
|
|
167
220
|
verify_rendering
|
|
221
|
+
verify_n_plus_one
|
|
222
|
+
verify_layout
|
|
223
|
+
verify_combined
|
|
168
224
|
verify_exports
|
|
169
225
|
verify_grouping
|
|
170
226
|
verify_views
|
|
171
227
|
end
|
|
172
228
|
|
|
229
|
+
# Checks
|
|
230
|
+
|
|
173
231
|
# Runs the block as one check in its own savepoint and records the
|
|
174
|
-
# outcome
|
|
175
|
-
# Quiet checks are only reported
|
|
232
|
+
# outcome, then any warnings found while running it. Returns the block's
|
|
233
|
+
# value, nil when the check didn't pass. Quiet checks are only reported
|
|
234
|
+
# when they fail.
|
|
176
235
|
def check(section, subject, name, quiet: false)
|
|
177
236
|
c = Check.new(section: section, subject: subject, name: name)
|
|
237
|
+
outer_warnings = @warnings
|
|
238
|
+
@warnings = []
|
|
239
|
+
start = @queries.size
|
|
178
240
|
value = nil
|
|
179
241
|
savepoint { value = yield(c) }
|
|
180
242
|
c.status = :passed
|
|
@@ -183,20 +245,28 @@ module Mensa
|
|
|
183
245
|
c.status = :skipped
|
|
184
246
|
c.message = e.message
|
|
185
247
|
nil
|
|
186
|
-
rescue StandardError,
|
|
248
|
+
rescue StandardError, ScriptError => e
|
|
187
249
|
c.status = :failed
|
|
188
250
|
c.error = e
|
|
189
|
-
c.message = e.is_a?(Failure) ? e.message : "#{e.class}: #{e.message}"
|
|
251
|
+
c.message = e.is_a?(Failure) ? e.message : "#{e.class}: #{e.message}#{error_hint(e)}"
|
|
190
252
|
c.sql = e.sql if e.respond_to?(:sql) && e.sql.present?
|
|
191
253
|
nil
|
|
192
254
|
ensure
|
|
193
255
|
result.checks << c unless quiet && c.passed?
|
|
256
|
+
warn_slow_queries(section, subject, @queries[start..]) if c.passed?
|
|
257
|
+
result.checks.concat(@warnings)
|
|
258
|
+
@warnings = outer_warnings
|
|
194
259
|
end
|
|
195
260
|
|
|
196
261
|
def skip!(reason)
|
|
197
262
|
raise Skip, reason
|
|
198
263
|
end
|
|
199
264
|
|
|
265
|
+
def warn!(section, subject, name, message, sql = nil)
|
|
266
|
+
warning = Check.new(section: section, subject: subject, name: name, status: :warning, message: message, sql: sql)
|
|
267
|
+
@warnings ? @warnings << warning : result.checks << warning
|
|
268
|
+
end
|
|
269
|
+
|
|
200
270
|
def savepoint
|
|
201
271
|
ActiveRecord::Base.transaction(requires_new: true) do
|
|
202
272
|
yield
|
|
@@ -204,22 +274,161 @@ module Mensa
|
|
|
204
274
|
end
|
|
205
275
|
end
|
|
206
276
|
|
|
207
|
-
#
|
|
277
|
+
# The block's value, or nil when it raises
|
|
278
|
+
def attempt
|
|
279
|
+
value = nil
|
|
280
|
+
savepoint { value = yield }
|
|
281
|
+
value
|
|
282
|
+
rescue
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Fixes for errors that have a common cause
|
|
287
|
+
def error_hint(error)
|
|
288
|
+
error = error.cause if error.is_a?(ActionView::Template::Error) && error.cause
|
|
289
|
+
return unless error.is_a?(ActiveModel::MissingAttributeError)
|
|
290
|
+
|
|
291
|
+
attribute = error.message[/missing attribute '?(\w+)'?/, 1]
|
|
292
|
+
"\nFix: Mensa only selects the table's columns, add `internal :#{attribute}` to select #{attribute} too." if attribute
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Queries
|
|
296
|
+
|
|
297
|
+
def record_query(event)
|
|
298
|
+
payload = event.payload
|
|
299
|
+
return if payload[:cached] || %w[SCHEMA TRANSACTION].include?(payload[:name]) || payload[:sql].to_s.match?(IGNORED_QUERY)
|
|
300
|
+
|
|
301
|
+
@queries << {sql: payload[:sql], duration: event.duration / 1000.0}
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def warn_slow_queries(section, subject, queries)
|
|
305
|
+
slow = queries.select { |query| query[:duration] >= @slow_query }
|
|
306
|
+
return if slow.empty?
|
|
307
|
+
|
|
308
|
+
slowest = slow.max_by { |query| query[:duration] }
|
|
309
|
+
warn!(section, subject, "slow query",
|
|
310
|
+
"took #{format("%.2f", slowest[:duration])}s (reported from #{@slow_query}s#{", #{slow.size} slow queries" if slow.size > 1}).\n" \
|
|
311
|
+
"Fix: check its plan with EXPLAIN ANALYZE, e.g. for a missing index.", slowest[:sql])
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Records
|
|
315
|
+
|
|
316
|
+
# Samples to render: records from the first and the last page, and a
|
|
317
|
+
# record with an empty value for each column, where null values often
|
|
318
|
+
# break render blocks.
|
|
319
|
+
def load_samples
|
|
320
|
+
samples = @rows.first(SAMPLE_SIZE).map { |row| Sample.new(row, "first page") }
|
|
321
|
+
|
|
322
|
+
pages = @table.pagy_details.pages
|
|
323
|
+
build_table_on_page(pages).rows.last(LAST_PAGE_SAMPLE).each { |row| samples << Sample.new(row, "last page") } if pages > 1
|
|
324
|
+
|
|
325
|
+
@table.columns.reject(&:internal?).select(&:filter?).each do |column|
|
|
326
|
+
next if column.attribute_for_condition.blank?
|
|
327
|
+
next if samples.any? { |sample| attempt { sample.row.value(column).nil? } }
|
|
328
|
+
|
|
329
|
+
sample = attempt do
|
|
330
|
+
table = build_table(filters: {column.name => {operator: :is_empty}})
|
|
331
|
+
record = table.selected_scope.limit(1).first
|
|
332
|
+
Sample.new(Mensa::Row.new(table, record), "#{column.name} empty") if record
|
|
333
|
+
end
|
|
334
|
+
samples << sample if sample
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
samples.uniq { |sample| sample.row.record.respond_to?(:id) ? sample.row.record.id : sample.row.record.object_id }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def sample_name(samples)
|
|
341
|
+
"sample #{samples.size} #{"record".pluralize(samples.size)}#{" (#{samples.map(&:reason).uniq.join(", ")})" if samples.any?}"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def build_table_on_page(page)
|
|
345
|
+
table = build_table
|
|
346
|
+
table.request = ActionDispatch::Request.new(Rack::MockRequest.env_for("http://#{default_host}/?page=#{page}"))
|
|
347
|
+
table
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Record-level checks need sampled records
|
|
208
351
|
def sample_rows!
|
|
209
|
-
skip!("records could not be loaded") if @
|
|
210
|
-
skip!("no records") if @
|
|
352
|
+
skip!("records could not be loaded") if @samples.nil?
|
|
353
|
+
skip!("no records") if @samples.empty?
|
|
354
|
+
|
|
355
|
+
@samples
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Renders every sample in its own savepoint. Fails naming the records that
|
|
359
|
+
# failed, and warns about queries that run for each record (N+1).
|
|
360
|
+
def render_samples(section, subject, what)
|
|
361
|
+
errors = []
|
|
362
|
+
queries_per_record = sample_rows!.map do |sample|
|
|
363
|
+
start = @queries.size
|
|
364
|
+
begin
|
|
365
|
+
savepoint { yield sample.row }
|
|
366
|
+
rescue StandardError, ScriptError => e
|
|
367
|
+
errors << "#{sample.description}: #{e.class}: #{e.message}#{error_hint(e)}"
|
|
368
|
+
end
|
|
369
|
+
@queries[start..].map { |query| query[:sql] }.uniq
|
|
370
|
+
end
|
|
211
371
|
|
|
212
|
-
|
|
372
|
+
warn_n_plus_one(section, subject, what, queries_per_record)
|
|
373
|
+
raise Failure, errors.join("\n") if errors.any?
|
|
213
374
|
end
|
|
214
375
|
|
|
215
|
-
def
|
|
216
|
-
|
|
376
|
+
def warn_n_plus_one(section, subject, what, queries_per_record)
|
|
377
|
+
return if queries_per_record.size < 2
|
|
378
|
+
|
|
379
|
+
@per_record_renders += 1
|
|
380
|
+
|
|
381
|
+
queries_per_record.flatten.tally.each do |sql, records|
|
|
382
|
+
next if records < 2
|
|
383
|
+
next unless @reported_n_plus_one.add?([subject, sql])
|
|
384
|
+
|
|
385
|
+
warn!(section, subject, "N+1 query in #{what}",
|
|
386
|
+
"runs this query for each record (#{records} of #{queries_per_record.size} sampled records).#{n_plus_one_hint(sql)}", sql)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# How to avoid a query per record, from the association it loads
|
|
391
|
+
def n_plus_one_hint(sql)
|
|
392
|
+
model = @table.model
|
|
393
|
+
table = sql[/\bFROM\s+"?(\w+)"?/i, 1]
|
|
394
|
+
reflection = model.reflect_on_all_associations.find do |association|
|
|
395
|
+
!association.polymorphic? && association.klass.table_name == table
|
|
396
|
+
rescue NameError
|
|
397
|
+
false
|
|
398
|
+
end
|
|
399
|
+
return "\nFix: select the value in SQL with `attribute`, or preload what the block uses in `scope`." unless reflection
|
|
400
|
+
|
|
401
|
+
preload = "`scope { #{model.name}.includes(:#{reflection.name}) }`"
|
|
402
|
+
hints = if reflection.belongs_to?
|
|
403
|
+
foreign_key = reflection.foreign_key.to_s
|
|
404
|
+
selected = @table.columns.any? { |column| column.name.to_s == foreign_key }
|
|
405
|
+
[
|
|
406
|
+
"preload it with #{preload}#{" and add `internal :#{foreign_key}`, Mensa only selects the table's columns and the preload needs the foreign key" unless selected}",
|
|
407
|
+
"or select the value in SQL with `attribute \"#{table}.<column>\"` and `left_outer_joins(:#{reflection.name})` in `scope`"
|
|
408
|
+
]
|
|
409
|
+
else
|
|
410
|
+
hints = ["preload it with #{preload}"]
|
|
411
|
+
if sql.match?(/\bCOUNT\(/i) && reflection.collection?
|
|
412
|
+
hints << "and use `.size` instead of `.count`, which always queries"
|
|
413
|
+
unless reflection.through_reflection?
|
|
414
|
+
hints << "or count in SQL with `attribute \"(SELECT COUNT(*) FROM #{table} WHERE #{table}.#{reflection.foreign_key} = #{model.table_name}.#{model.primary_key})\"` and `type :integer`"
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
hints
|
|
418
|
+
end
|
|
419
|
+
"\nFix: #{hints.join(",\n ")}."
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Loads
|
|
423
|
+
|
|
424
|
+
def verify_last_page
|
|
425
|
+
check(:loads, nil, "load last page") do
|
|
217
426
|
skip!("records could not be loaded") if @rows.nil?
|
|
218
|
-
skip!("only one page") if @table.pagy_details.pages < 2
|
|
219
427
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
428
|
+
pages = @table.pagy_details.pages
|
|
429
|
+
skip!("only one page") if pages < 2
|
|
430
|
+
|
|
431
|
+
build_table_on_page(pages).rows
|
|
223
432
|
end
|
|
224
433
|
end
|
|
225
434
|
|
|
@@ -248,12 +457,30 @@ module Mensa
|
|
|
248
457
|
check(:columns, column.name, "select") do |c|
|
|
249
458
|
skip!("no SQL attribute, the value comes from #{column.method ? "method :#{column.method}" : "the record"}") if column.attribute.blank?
|
|
250
459
|
|
|
251
|
-
|
|
460
|
+
# What Mensa selects besides the column: the id and internal columns,
|
|
461
|
+
# which preloads in the scope need
|
|
462
|
+
table = build_table
|
|
463
|
+
selects = (table.send(:id_column?) ? [:id] : []) + table.columns.select(&:internal?).filter_map(&:attribute)
|
|
464
|
+
relation = table.filtered_scope.select(*selects, Arel.sql(column.attribute)).limit(1)
|
|
252
465
|
c.sql = relation.to_sql
|
|
253
|
-
relation
|
|
466
|
+
result = table.model.connection.select_all(relation)
|
|
467
|
+
warn_untyped_attribute(column, result.column_types[column.name.to_s]&.type)
|
|
254
468
|
end
|
|
255
469
|
end
|
|
256
470
|
|
|
471
|
+
# The SQL type of an `attribute` decides which filter operators and values
|
|
472
|
+
# fit it. Without `type`, Mensa treats it as text: it offers LIKE and
|
|
473
|
+
# compares with text, which fails for e.g. a boolean expression.
|
|
474
|
+
def warn_untyped_attribute(column, sql_type)
|
|
475
|
+
return if column.config[:attribute].blank? || column.config[:type].present?
|
|
476
|
+
return if sql_type.nil? || Mensa::Filter::TEXT_TYPES.include?(sql_type)
|
|
477
|
+
|
|
478
|
+
type = {timestamptz: :datetime, timestamp: :datetime}.fetch(sql_type, sql_type)
|
|
479
|
+
warn!(:columns, column.name, "untyped attribute",
|
|
480
|
+
"its SQL is #{sql_type}, but without `type` Mensa treats it as text: filters offer text operators and compare with text.\n" \
|
|
481
|
+
"Fix: add `type :#{type}` to the column.")
|
|
482
|
+
end
|
|
483
|
+
|
|
257
484
|
def verify_column_sort(column)
|
|
258
485
|
check(:columns, column.name, "sort asc/desc") do
|
|
259
486
|
%i[asc desc].each { |direction| build_table(order: {column.name => direction}).ordered_scope.limit(1).load }
|
|
@@ -269,7 +496,7 @@ module Mensa
|
|
|
269
496
|
check(:columns, column.name, "render #{format}") do
|
|
270
497
|
skip!("not visible") unless visible
|
|
271
498
|
|
|
272
|
-
|
|
499
|
+
render_samples(:columns, column.name, "render #{format}") { |row| Mensa::Cell.new(row: row, column: column).render(format) }
|
|
273
500
|
end
|
|
274
501
|
end
|
|
275
502
|
end
|
|
@@ -292,8 +519,12 @@ module Mensa
|
|
|
292
519
|
end
|
|
293
520
|
end
|
|
294
521
|
|
|
295
|
-
|
|
522
|
+
invalid = invalid_value(column)
|
|
523
|
+
check(:filters, column.name, filter_check_name(column, operators, values, invalid)) do |c|
|
|
296
524
|
errors = operators.filter_map { |operator, _label, with_value| filter_error(column, operator, filter_value(column, operator, with_value, values)) }
|
|
525
|
+
if invalid
|
|
526
|
+
errors += operators.select { |_operator, _label, with_value| with_value }.filter_map { |operator, *| filter_error(column, operator, invalid) }
|
|
527
|
+
end
|
|
297
528
|
next if errors.empty?
|
|
298
529
|
|
|
299
530
|
c.sql = errors.first[:sql]
|
|
@@ -302,8 +533,9 @@ module Mensa
|
|
|
302
533
|
end
|
|
303
534
|
end
|
|
304
535
|
|
|
305
|
-
def filter_check_name(column, operators, values)
|
|
306
|
-
"#{operators.map(&:first).join(", ")} (#{(column.filter.multiple? ? values : values.first).inspect})"
|
|
536
|
+
def filter_check_name(column, operators, values, invalid)
|
|
537
|
+
name = "#{operators.map(&:first).join(", ")} (#{(column.filter.multiple? ? values : values.first).inspect})"
|
|
538
|
+
invalid ? "#{name}, invalid input (#{invalid.inspect})" : name
|
|
307
539
|
end
|
|
308
540
|
|
|
309
541
|
# Applies the filter with one operator in its own savepoint, returns
|
|
@@ -318,7 +550,7 @@ module Mensa
|
|
|
318
550
|
table.active_filters.each(&:to_s)
|
|
319
551
|
end
|
|
320
552
|
nil
|
|
321
|
-
rescue StandardError,
|
|
553
|
+
rescue StandardError, ScriptError => e
|
|
322
554
|
sql = e.sql if e.respond_to?(:sql)
|
|
323
555
|
sql ||= relation.to_sql if relation
|
|
324
556
|
{message: "#{operator}#{" #{value.inspect}" if value}: #{e.class}: #{e.message}", sql: sql}
|
|
@@ -354,13 +586,24 @@ module Mensa
|
|
|
354
586
|
case column.type
|
|
355
587
|
when :integer, :bigint, :decimal, :float then "1"
|
|
356
588
|
when :date then Date.current.iso8601
|
|
357
|
-
when :datetime, :timestamp, :
|
|
589
|
+
when :datetime, :timestamp, :timestamptz then Time.current.strftime("%Y-%m-%dT%H:%M")
|
|
590
|
+
when :time then "12:00"
|
|
358
591
|
when :boolean then "true"
|
|
359
592
|
when :uuid then SecureRandom.uuid
|
|
360
593
|
else "a"
|
|
361
594
|
end
|
|
362
595
|
end
|
|
363
596
|
|
|
597
|
+
# A value a user could type that the column's type can't hold
|
|
598
|
+
def invalid_value(column)
|
|
599
|
+
case column.type
|
|
600
|
+
when :integer, :bigint, :decimal, :float then "not a number"
|
|
601
|
+
when :date, :datetime, :timestamp, :timestamptz then "not a date"
|
|
602
|
+
when :time then "not a time"
|
|
603
|
+
when :uuid then "not a uuid"
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
364
607
|
# Search
|
|
365
608
|
|
|
366
609
|
# Both search modes, the app may switch between them
|
|
@@ -373,7 +616,8 @@ module Mensa
|
|
|
373
616
|
table = build_table(query: term)
|
|
374
617
|
skip!("needs the pg_trgm extension") if mode == :fuzzy && !table.send(:fuzzy_search?, table.scope)
|
|
375
618
|
|
|
376
|
-
|
|
619
|
+
# Search uses every column, so load what requests load
|
|
620
|
+
relation = table.selected_scope.limit(SAMPLE_SIZE)
|
|
377
621
|
c.sql = relation.to_sql
|
|
378
622
|
relation.load
|
|
379
623
|
end
|
|
@@ -391,15 +635,14 @@ module Mensa
|
|
|
391
635
|
|
|
392
636
|
# A word from the first string value in the records, so it finds something
|
|
393
637
|
def search_term
|
|
394
|
-
|
|
395
|
-
@
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
638
|
+
@search_term ||= begin
|
|
639
|
+
words = (@rows || []).first(SAMPLE_SIZE).lazy.flat_map { |row|
|
|
640
|
+
@table.display_columns.map { |column| row.value(column) }
|
|
641
|
+
}.select { |value| value.is_a?(String) && value.split.first.present? }
|
|
642
|
+
words.first&.split&.first&.first(20) || "a"
|
|
643
|
+
rescue
|
|
644
|
+
"a"
|
|
399
645
|
end
|
|
400
|
-
"a"
|
|
401
|
-
rescue
|
|
402
|
-
"a"
|
|
403
646
|
end
|
|
404
647
|
|
|
405
648
|
# Rendering, like the app's views and Mensa's controllers do
|
|
@@ -408,17 +651,34 @@ module Mensa
|
|
|
408
651
|
check(:render, nil, "table component") { view_context.table(table_name, params: params) }
|
|
409
652
|
check(:render, nil, "rows and filter pills") { render_page(build_table, view: @table.default_system_view) }
|
|
410
653
|
|
|
411
|
-
check(:render, nil, "row link") {
|
|
654
|
+
check(:render, nil, "row link") { render_samples(:render, nil, "row link", &:link) } if @table.link
|
|
412
655
|
|
|
413
656
|
return unless @table.actions?
|
|
414
657
|
|
|
415
658
|
@table.actions.each do |action|
|
|
416
659
|
check(:render, :actions, action.name.to_s) do
|
|
417
|
-
|
|
660
|
+
render_samples(:render, :actions, action.name.to_s) do |row|
|
|
661
|
+
view_context.render(Mensa::RowAction::Component.new(table: @table, row: row, action: action))
|
|
662
|
+
end
|
|
418
663
|
end
|
|
419
664
|
end
|
|
420
665
|
end
|
|
421
666
|
|
|
667
|
+
# Confirms no N+1 query was found, after everything rendered per record
|
|
668
|
+
# (column renders, the row link, row actions). Found ones are warnings.
|
|
669
|
+
def verify_n_plus_one
|
|
670
|
+
return if @reported_n_plus_one.any?
|
|
671
|
+
|
|
672
|
+
check(:render, nil, "N+1 queries") do |c|
|
|
673
|
+
skip!("records could not be loaded") if @samples.nil?
|
|
674
|
+
skip!("no records") if @samples.empty?
|
|
675
|
+
skip!("needs at least 2 sampled records, found 1") if @samples.one?
|
|
676
|
+
skip!("nothing is rendered per record") if @per_record_renders.zero?
|
|
677
|
+
|
|
678
|
+
c.name = "N+1 queries: none in #{@per_record_renders} #{"render".pluralize(@per_record_renders)} of #{@samples.size} sampled records"
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
422
682
|
# What Mensa::TablesController#show renders into the table's turbo frame
|
|
423
683
|
def render_page(table, view: nil)
|
|
424
684
|
table.table_view = view
|
|
@@ -426,6 +686,88 @@ module Mensa
|
|
|
426
686
|
view_context.render(Mensa::View::Component.new(table))
|
|
427
687
|
end
|
|
428
688
|
|
|
689
|
+
# Column order and visibility, as users change them with the column
|
|
690
|
+
# customizer, or as saved views and browser state replay them
|
|
691
|
+
|
|
692
|
+
def verify_layout
|
|
693
|
+
names = @table.display_columns.map(&:name)
|
|
694
|
+
|
|
695
|
+
check(:layout, nil, "reversed column order") do
|
|
696
|
+
skip!("no columns are shown") if names.empty?
|
|
697
|
+
|
|
698
|
+
verify_layout_config({column_order: names.reverse}, names.reverse)
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
check(:layout, nil, "column order listing only :#{names.last} (e.g. saved before the others were added)") do
|
|
702
|
+
skip!("fewer than 2 columns are shown") if names.size < 2
|
|
703
|
+
|
|
704
|
+
verify_layout_config({column_order: [names.last]}, [names.last] + names[0..-2],
|
|
705
|
+
hint: "columns a column_order doesn't list should follow the listed ones")
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
check(:layout, nil, "hide each column (#{names.size})") do
|
|
709
|
+
skip!("no columns are shown") if names.empty?
|
|
710
|
+
|
|
711
|
+
errors = names.filter_map do |name|
|
|
712
|
+
savepoint { verify_layout_config({hidden_columns: [name]}, names - [name]) }
|
|
713
|
+
nil
|
|
714
|
+
rescue StandardError, ScriptError => e
|
|
715
|
+
":#{name}: #{e.is_a?(Failure) ? e.message : "#{e.class}: #{e.message}"}"
|
|
716
|
+
end
|
|
717
|
+
raise Failure, errors.join("\n") if errors.any?
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
check(:layout, nil, "hide all columns") do
|
|
721
|
+
skip!("no columns are shown") if names.empty?
|
|
722
|
+
|
|
723
|
+
verify_layout_config({hidden_columns: names}, [])
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# Builds the table with +config+, checks the columns it shows, renders it
|
|
728
|
+
# and exports it
|
|
729
|
+
def verify_layout_config(config, expected, hint: nil)
|
|
730
|
+
table = build_table(config)
|
|
731
|
+
shown = table.display_columns.map(&:name)
|
|
732
|
+
unless shown == expected
|
|
733
|
+
raise Failure, "shows columns #{shown.inspect}, expected #{expected.inspect}#{"\n#{hint}" if hint}"
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
render_page(table)
|
|
737
|
+
verify_export(config, columns_required: expected.any?) if @table.exportable?
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# Everything a user can combine at once: search, a filter on every
|
|
741
|
+
# column, sorting on every column and grouping with aggregates
|
|
742
|
+
def verify_combined
|
|
743
|
+
check(:combined, nil, "search, filters, sorting and grouping together") do |c|
|
|
744
|
+
config = {query: search_term, filters: combined_filters}
|
|
745
|
+
config[:order] = @table.columns.select { |column| column.sortable? && !column.internal? }.to_h { |column| [column.name, :asc] }
|
|
746
|
+
|
|
747
|
+
groupable = @table.columns.find(&:groupable?)
|
|
748
|
+
if groupable && @table.scope.is_a?(ActiveRecord::Relation)
|
|
749
|
+
config[:group_by] = groupable.name
|
|
750
|
+
config[:aggregates] = @table.columns.select(&:aggregatable?).reject(&:internal?).to_h { |column| [column.name, column.aggregates.first] }
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
table = build_table(config)
|
|
754
|
+
c.sql = table.selected_scope.to_sql
|
|
755
|
+
render_page(build_table(config))
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def combined_filters
|
|
760
|
+
@table.columns.select(&:filter?).each_with_object({}) do |column, filters|
|
|
761
|
+
operators = attempt { column.filter.operators } || []
|
|
762
|
+
operator, _label, with_value = operators.find { |_operator, _label, with_value| with_value } ||
|
|
763
|
+
operators.find { |operator, *| %i[is_empty isnt_empty].include?(operator) }
|
|
764
|
+
next unless operator
|
|
765
|
+
|
|
766
|
+
values = attempt { filter_values(column) } || [synthetic_value(column)]
|
|
767
|
+
filters[column.name] = {operator: operator, value: filter_value(column, operator, with_value, values)}.compact
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
|
|
429
771
|
# Exports, generated by Mensa::ExportJob like a user's export request,
|
|
430
772
|
# for the first page of the table and of each view
|
|
431
773
|
|
|
@@ -456,7 +798,7 @@ module Mensa
|
|
|
456
798
|
@table.export_with_password? ? "csv (zip with password)" : "csv"
|
|
457
799
|
end
|
|
458
800
|
|
|
459
|
-
def verify_export(config, table_view: nil)
|
|
801
|
+
def verify_export(config, table_view: nil, columns_required: true)
|
|
460
802
|
config = JSON.parse({params: params.presence, page: 1}.compact.merge(config).to_json)
|
|
461
803
|
export = Mensa::Export.new(
|
|
462
804
|
table_name: table_name, table_view_id: table_view&.id, format: "plain_csv", scope: "current_page",
|
|
@@ -469,12 +811,14 @@ module Mensa
|
|
|
469
811
|
data = file.read
|
|
470
812
|
file.close!
|
|
471
813
|
csv = (content_type == "application/zip") ? unzip(data, export.password) : data
|
|
472
|
-
raise Failure, "#{filename} is empty" if csv.blank?
|
|
814
|
+
raise Failure, "#{filename} is empty" if csv.blank? && columns_required
|
|
473
815
|
|
|
474
816
|
expected = build_table((table_view&.config || {}).deep_symbolize_keys.merge(config.deep_symbolize_keys))
|
|
475
817
|
headers = expected.display_columns.map { |column| column.name.to_s }
|
|
476
|
-
lines = CSV.parse(csv.force_encoding(Encoding::UTF_8).delete_prefix("
|
|
477
|
-
raise Failure, "the export has no columns" if headers.empty?
|
|
818
|
+
lines = CSV.parse(csv.force_encoding(Encoding::UTF_8).delete_prefix(""))
|
|
819
|
+
raise Failure, "the export has no columns" if headers.empty? && columns_required
|
|
820
|
+
return if headers.empty?
|
|
821
|
+
|
|
478
822
|
raise Failure, "the export has headers #{lines.first.inspect}, expected #{headers.inspect}" unless lines.first == headers
|
|
479
823
|
|
|
480
824
|
records = expected.rows.size
|
|
@@ -557,13 +901,71 @@ module Mensa
|
|
|
557
901
|
end
|
|
558
902
|
end
|
|
559
903
|
|
|
560
|
-
|
|
904
|
+
unless @table.supports_custom_views?
|
|
905
|
+
check(:views, "custom view", "create") { skip!("supports_custom_views is off") }
|
|
906
|
+
return
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
verify_custom_view
|
|
561
910
|
|
|
562
911
|
saved_views.each do |view|
|
|
563
912
|
check(:views, "saved view #{view.id}", view.name.to_s) { render_page(build_table((view.config || {}).deep_symbolize_keys), view: view) }
|
|
564
913
|
end
|
|
565
914
|
end
|
|
566
915
|
|
|
916
|
+
# Creates a custom view like a user saving the table's state would, but
|
|
917
|
+
# doesn't save it: it's validated, then replayed and exported like a saved
|
|
918
|
+
# view.
|
|
919
|
+
def verify_custom_view
|
|
920
|
+
check(:views, "custom view", "create and replay (not saved)") do |c|
|
|
921
|
+
skip!("custom views are owned by Current.user, give the verifier a user:") if @table.current_user.blank?
|
|
922
|
+
|
|
923
|
+
view = custom_view
|
|
924
|
+
raise Failure, "the view is invalid: #{view.errors.full_messages.to_sentence}" unless view.valid?
|
|
925
|
+
|
|
926
|
+
c.name = "create and replay #{view.config.keys.join(", ")} (not saved)"
|
|
927
|
+
# Like Mensa::TablesController#show and the views controller
|
|
928
|
+
render_page(build_table(view.config.deep_transform_keys(&:to_sym)), view: view)
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
return unless @table.exportable? && @table.current_user.present?
|
|
932
|
+
|
|
933
|
+
check(:exports, "custom view", export_format) do
|
|
934
|
+
verify_export(custom_view.config.deep_symbolize_keys.slice(:filters, :column_order, :hidden_columns, :group_by, :order))
|
|
935
|
+
end
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# A view as Mensa::Tables::ViewsController#create builds it from the
|
|
939
|
+
# table's state in the browser
|
|
940
|
+
def custom_view
|
|
941
|
+
Mensa::TableView.new(
|
|
942
|
+
table_name: table_name,
|
|
943
|
+
name: "Mensa::Verifier",
|
|
944
|
+
description: "Created by Mensa::Verifier, not saved",
|
|
945
|
+
config: JSON.parse(custom_view_config.to_json),
|
|
946
|
+
user: @table.current_user
|
|
947
|
+
)
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
def custom_view_config
|
|
951
|
+
@custom_view_config ||= begin
|
|
952
|
+
columns = @table.display_columns
|
|
953
|
+
sortable = columns.find(&:sortable?)
|
|
954
|
+
groupable = @table.columns.find(&:groupable?)
|
|
955
|
+
|
|
956
|
+
config = {query: search_term, filters: combined_filters.first(2).to_h}
|
|
957
|
+
config[:order] = {sortable.name => :desc} if sortable
|
|
958
|
+
config[:column_order] = columns.map(&:name).reverse
|
|
959
|
+
config[:hidden_columns] = [columns.last.name] if columns.size > 1
|
|
960
|
+
if groupable && @table.scope.is_a?(ActiveRecord::Relation)
|
|
961
|
+
config[:group_by] = groupable.name
|
|
962
|
+
aggregatable = @table.columns.reject(&:internal?).find(&:aggregatable?)
|
|
963
|
+
config[:aggregates] = {aggregatable.name => aggregatable.aggregates.first} if aggregatable
|
|
964
|
+
end
|
|
965
|
+
config
|
|
966
|
+
end
|
|
967
|
+
end
|
|
968
|
+
|
|
567
969
|
def saved_views
|
|
568
970
|
Mensa::TableView.where(table_name: table_name, user: [nil, @table.current_user].uniq).order(:created_at).limit(25).to_a
|
|
569
971
|
end
|