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.
@@ -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.write_csv, ...). Everything
19
- # runs in a read-only transaction that is rolled back, and every check in its
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 loaded for record-level checks, and per query
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
- attr_reader :table_class, :user, :account, :context, :params, :result
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+, used
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.map do |klass|
67
- new(klass, **options).call.tap { |result| io.puts(result, "") }
101
+ results = classes.flat_map do |klass|
102
+ verify(klass, **options).each { |result| io.puts(result, "") }
68
103
  end
69
104
 
70
- tables, others = results.partition(&:mensa_table?)
105
+ by_table = results.select(&:mensa_table?).group_by(&:table_class)
106
+ others = results.reject(&:mensa_table?)
71
107
  if results.size > 1
72
- io.puts ["#{tables.count(&:success?)}/#{tables.size} tables passed", ("#{others.size} skipped, not a Mensa::Base subclass" if others.any?)].compact.join(", ")
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
- def initialize(table, user: nil, account: nil, context: {}, params: nil, view_context: nil, &setup)
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 = default_options
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
- read_only_transaction do
108
- with_current_attributes do
109
- @setup&.call(self)
110
- run_checks
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
- verify_second_page
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. Returns the block's value, nil when the check didn't pass.
175
- # Quiet checks are only reported when they fail.
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, NotImplementedError => e
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
- # Record-level checks need loaded records
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 @rows.nil?
210
- skip!("no records") if @rows.empty?
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
- @rows.first(SAMPLE_SIZE)
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 verify_second_page
216
- check(:loads, nil, "load page 2") do
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
- table = build_table
221
- table.request = ActionDispatch::Request.new(Rack::MockRequest.env_for("http://#{default_host}/?page=2"))
222
- table.rows
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
- relation = build_table.filtered_scope.select(Arel.sql(column.attribute)).limit(1)
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.load
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
- sample_rows!.each { |row| Mensa::Cell.new(row: row, column: column).render(format) }
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
- check(:filters, column.name, filter_check_name(column, operators, values)) do |c|
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, NotImplementedError => e
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, :time then Time.current.strftime("%Y-%m-%dT%H:%M")
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
- relation = table.ordered_scope.limit(SAMPLE_SIZE)
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
- (@rows || []).first(SAMPLE_SIZE).each do |row|
395
- @table.display_columns.each do |column|
396
- value = row.value(column)
397
- return value.split.first.first(20) if value.is_a?(String) && value.split.first.present?
398
- end
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") { sample_rows!.each(&:link) } if @table.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
- sample_rows!.each { |row| view_context.render(Mensa::RowAction::Component.new(table: @table, row: row, action: action)) }
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("\uFEFF"))
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
- return unless @table.supports_custom_views?
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