mensa 0.6.18 → 0.6.19

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9bbb252521ea00a7d6197e9ae12208d121ec069652b45b729095609a596bda2
4
- data.tar.gz: 92345f45e5b1336f1e101c2c64d9736c58637cb9792c333cc21f4620dadabf94
3
+ metadata.gz: b38d819bbd96ef6b2cb38e7b89c07c9a487eb7b92e8a191a92f4fd7e09cf1f7f
4
+ data.tar.gz: 52856b77830db55218a63213c4dc90a685686046175059cd78c53d8fb40d5527
5
5
  SHA512:
6
- metadata.gz: 2b97589e71ee52505369eb9cac3af5e49672c8af62d076d2f17465ea76b2c556ca2c5adbede3af5ad850db502bd47271e031c96a5292eb2ef191818e24e60435
7
- data.tar.gz: ba31a706fa85f69d30a1b4bfcb17d2c6674df561daf8379341b3f7dc31d21bd10ecc94fc36192f0f2302e602556898c72d9b3d203e330f2a60494c3741b004f2
6
+ metadata.gz: a6dc8ccce0bed9be30b8b9c4b8d5f255b1d808d48a32c88e1813c40043378b0dd8d485abc5a34aa9b7b41f21335300f0f79f9cf6f7b1f1d7982f7f312027dad1
7
+ data.tar.gz: 36262b2128d07c1919dee7c08a82f0d15af44be791b012757afe41c28dadf5aafcc75e3be4907c0d9369d319217273297c98a20e2ab15380c03766da7b2e43ee
data/.shipkit.yml CHANGED
@@ -1,2 +1,2 @@
1
1
  ---
2
- previous_version: 0.6.17
2
+ previous_version: 0.6.18
data/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.6.19
4
4
 
5
5
  - `Mensa::Verifier` checks a table against the database: scope, column SQL,
6
6
  sorting, rendering, filters, search, CSV export, grouping/aggregates and
@@ -8,6 +8,18 @@
8
8
  or from tests after `require "mensa/testing"` with
9
9
  `assert_mensa_table_valid` or `be_a_valid_mensa_table`. Set defaults for
10
10
  its context with `config.verifier_context`.
11
+ - The verifier samples records from the first and last page and records with
12
+ empty values, tries filters with invalid input, checks search, filters, sorting
13
+ and grouping together, and warns about N+1 queries (with a fix) and slow
14
+ queries. `strict: true` / `STRICT=1` makes warnings fail.
15
+ `config.verifier_context` can return an array to verify tables in several
16
+ contexts, `config.verifier_slow_query` sets when a query is slow.
17
+ - Columns without SQL (only a `render` block or a `method`, no `attribute` or
18
+ database column) are no longer sortable or filterable by default, sorting or
19
+ filtering on them produced invalid SQL. Use `sortable true` for a column whose
20
+ name is an alias the scope selects, or a filter `scope` to filter it.
21
+ - Filters with a value their column's type can't hold (e.g. "abc" for a number,
22
+ or an invalid date or uuid) match nothing instead of raising a database error.
11
23
  - `Mensa::ExportJob.write_csv(io, table, rows)` writes a table's CSV export and
12
24
  `Mensa::ExportJob#generate_file(export)` generates an export's file without
13
25
  saving it.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mensa (0.6.17)
4
+ mensa (0.6.18)
5
5
  csv
6
6
  importmap-rails
7
7
  pagy (>= 43)
data/README.md CHANGED
@@ -118,11 +118,49 @@ You can show your tables on the page using the following:
118
118
  Custom views are views not defined by the developer (SystemViews) but by the end-user by adding/removing filters.
119
119
  When you enable these, they are stored in the database and can be used across sessions, by the user who created them.
120
120
 
121
+ ### Sortable and filterable columns
122
+
123
+ Sorting and filtering happen in SQL, so whether a column can be sorted or filtered
124
+ depends on whether it has SQL: a database column of the model, or an `attribute`.
125
+
126
+ - **Columns with SQL** are sortable and filterable by default. Turn that off with
127
+ `sortable false` or `filter false`.
128
+ - **Columns without SQL**, which only show something with a `render` block or a
129
+ `method`, are *not* sortable or filterable by default. Their headers have no sort
130
+ link, they aren't offered in the filter menu, and sorting or filtering on them
131
+ from a URL or saved state is ignored.
132
+
133
+ ```ruby
134
+ column(:email) # sortable and filterable
135
+ column(:customer_name) do # sortable and filterable, it has SQL
136
+ attribute "customers.name"
137
+ end
138
+ column(:avatar) do # render only: not sortable, not filterable
139
+ render do
140
+ html { |user| image_tag(user.avatar_url) }
141
+ end
142
+ end
143
+ ```
144
+
145
+ To make a column without SQL filterable, give its filter a `scope` (see "Custom
146
+ filter scopes" below). Add `sortable true` only when its name can be sorted on in
147
+ SQL, e.g. an alias your table's `scope` selects:
148
+
149
+ ```ruby
150
+ column(:orders_count) do
151
+ sortable true # ORDER BY orders_count, selected in the scope below
152
+ end
153
+
154
+ scope { User.left_joins(:orders).group(:id).select("users.*, COUNT(orders.id) AS orders_count") }
155
+ ```
156
+
157
+ Better still, give such a column an `attribute`, so it's sortable and filterable.
158
+
121
159
  ### Filters
122
160
 
123
- Add `filter` to a column to let users filter on it. By default Mensa compares the
124
- column's attribute with the chosen value, using operators that fit the column type.
125
- The filter block takes these options:
161
+ Columns with SQL are filterable (see above). Use `filter` to configure how. By
162
+ default Mensa compares the column's attribute with the chosen value, using
163
+ operators that fit the column type. The filter block takes these options:
126
164
 
127
165
  ```ruby
128
166
  column(:role) do
@@ -325,15 +363,18 @@ If you're just using cron, you can add the following to your crontab:
325
363
  `Mensa::Verifier` checks that everything a table configures works against your
326
364
  real database, using the same code paths as requests do:
327
365
 
328
- - **Loads**: the table builds, its SQL builds and the first two pages load. A default
329
- `order` or `column_order` naming an unknown column fails.
366
+ - **Loads**: the table builds, its SQL builds and the first and last pages load. A
367
+ default `order` or `column_order` naming an unknown column fails. Records are
368
+ sampled for the checks below: some from the first and the last page, and one with
369
+ an empty value for each column, which often breaks render blocks.
330
370
  - **Columns**: every column (internal ones too) can be selected and, when sortable,
331
- sorted both ways. Its html and csv renders run for a few records. Columns hidden
332
- by a `visible` lambda are reported as skipped.
371
+ sorted both ways. Its html and csv renders run for the sampled records, and a
372
+ failure names the record. Columns hidden by a `visible` lambda are reported as
373
+ skipped.
333
374
  - **Filters**: every filterable column is applied with each of its operators, using
334
375
  the first values of its `collection` or a value that fits the column's type, and
335
- the filter pill's text is built. `is_current` is skipped when `Current` has no
336
- value for the column.
376
+ with input the column's type can't hold (e.g. "not a number"). The filter pill's
377
+ text is built. `is_current` is skipped when `Current` has no value for the column.
337
378
  - **Search**: basic and fuzzy search (when `pg_trgm` is enabled) for a word taken
338
379
  from the records.
339
380
  - **Rendering**: the `table` helper as your views call it, the rows and filter pills
@@ -341,12 +382,26 @@ real database, using the same code paths as requests do:
341
382
  - **Exports**: `Mensa::ExportJob` generates the first page of the export, also for
342
383
  each view and as a password protected zip. It needs the expected headers
343
384
  (skipped for `exportable false`).
385
+ - **Combined**: search, a filter on every column, sorting on every column and
386
+ grouping with aggregates, all at once.
344
387
  - **Grouping**: every groupable column and every aggregate, rendered with their
345
388
  group headers. A `group_by` or `aggregates` naming a column that doesn't allow
346
389
  it fails, because Mensa would silently ignore it.
347
390
  - **Views**: system views load, render and only refer to existing columns. With
348
391
  custom views enabled, saved views load too.
349
392
 
393
+ It also warns, without failing:
394
+
395
+ - **N+1 queries**: a query that a render block, the row link or a row action runs for
396
+ each record. The warning shows the query and how to avoid it for the association
397
+ it loads, e.g. preload it with `includes` (and `internal :customer_id`, since Mensa
398
+ only selects the table's columns), select the value in SQL with `attribute`, or
399
+ count in SQL.
400
+ - **Slow queries**: queries that take `config.verifier_slow_query` seconds (1.0 by
401
+ default) or more.
402
+
403
+ Pass `strict: true` (or `STRICT=1` to rake) to make warnings fail.
404
+
350
405
  Each check runs on its own, so one failure doesn't hide the others. Everything runs
351
406
  in a read-only transaction that is rolled back, with small limits, so it is safe to
352
407
  run against staging. It never writes.
@@ -390,20 +445,24 @@ params. Give the verifier that context:
390
445
  - `context: {organization: org}` sets any other `Current` attributes.
391
446
  - `params: {customer_id: 1}` sets the table's params (see "Table params").
392
447
  - A block is called with the verifier before the checks, for any other setup.
448
+ - `label: "admin"` names the context in the report.
393
449
 
394
450
  Html render blocks run in a view context of your `ApplicationController` (see
395
451
  `config.base_controller`), so url helpers and your helpers work.
396
452
 
397
453
  From rake, set `USER_ID=` (finds a `User`), `ACCOUNT_ID=` (finds an `Account`) and
398
454
  `PARAMS='{"customer_id":1}'` (JSON), or configure defaults for every table in
399
- your initializer. The options you pass yourself override these defaults:
455
+ your initializer. The options you pass yourself override these defaults. Return an array of them to
456
+ verify every table once for each, e.g. as different kinds of users:
400
457
 
401
458
  ```ruby
402
459
  Mensa.setup do |config|
403
460
  config.verifier_context = ->(table_class) {
404
- options = {user: User.find_by!(email: "admin@example.com")}
405
- options[:params] = {customer_id: Customer.first.id} if table_class == CustomerUsersTable
406
- options
461
+ params = {customer_id: Customer.first.id} if table_class == CustomerUsersTable
462
+ [
463
+ {label: "admin", user: User.find_by!(email: "admin@example.com"), params: params},
464
+ {label: "customer", user: User.find_by!(email: "customer@example.com"), params: params}
465
+ ]
407
466
  }
408
467
  end
409
468
  ```
@@ -6,4 +6,5 @@
6
6
  <% else %>
7
7
  <%= action.title %>
8
8
  <% end %>
9
- <% end %>
9
+ <% end %>
10
+ <% end %>
@@ -110,7 +110,9 @@ module Mensa
110
110
  def active_filters
111
111
  (config[:filters] || {}).filter_map do |column_name, filter_config|
112
112
  col = column(column_name)
113
- next unless col
113
+ # Columns without SQL can only be filtered with a custom scope, skip
114
+ # filters on them, e.g. from browser state saved earlier
115
+ next if col.nil? || !(col.sql? || col.filter?)
114
116
 
115
117
  definition = config.dig(:columns, col.name, :filter)
116
118
  definition = {} unless definition.is_a?(Hash)
@@ -14,7 +14,6 @@ module Mensa
14
14
  @table = table
15
15
  end
16
16
 
17
- config_reader :sortable?
18
17
  config_reader :sanitize?
19
18
  config_reader :visible?
20
19
  config_reader :internal?
@@ -96,9 +95,25 @@ module Mensa
96
95
  aggregates.any?
97
96
  end
98
97
 
99
- # Returns true if the column supports filtering
98
+ # Whether the column has SQL (an attribute or a database column). Columns
99
+ # without, e.g. only a render block or a method, can't be sorted or
100
+ # filtered in SQL.
101
+ def sql?
102
+ attribute_for_condition.present?
103
+ end
104
+
105
+ # Columns with SQL are sortable unless `sortable false`, others only with
106
+ # an explicit `sortable true`
107
+ def sortable?
108
+ config[:sortable].nil? ? sql? : !!config[:sortable]
109
+ end
110
+
111
+ # Returns true if the column supports filtering: columns with SQL unless
112
+ # `filter false`, others only with a filter that has its own scope
100
113
  def filter?
101
- config[:filter] != false
114
+ return false if config[:filter] == false
115
+
116
+ sql? || (config[:filter].is_a?(Hash) && config[:filter][:scope].present?)
102
117
  end
103
118
 
104
119
  def filter
@@ -4,7 +4,8 @@ module Mensa::Config
4
4
  class ColumnDsl
5
5
  include DslLogic
6
6
 
7
- option :sortable, default: true
7
+ # Defaults to whether the column has SQL, see Mensa::Column#sortable?
8
+ option :sortable
8
9
  option :sanitize, default: true
9
10
  # Allows for sql-parts too
10
11
  #
@@ -86,6 +86,9 @@ module Mensa
86
86
 
87
87
  # Nothing selected in a multi-select means no filter
88
88
  return record_scope if value.is_a?(Array) && value.compact_blank.empty?
89
+ # A value the database can't compare with the column, e.g. "abc" for
90
+ # a number, matches nothing instead of raising
91
+ return record_scope.none if operator_with_value? && invalid_value?
89
92
 
90
93
  query, hash = query_and_hash_for_operator
91
94
  record_scope = (having? ? record_scope.having(query, hash) : record_scope.where(query, hash)) if query.present?
@@ -224,6 +227,29 @@ module Mensa
224
227
  .where("#{column_sql} IN (#{duplicate_values_scope.to_sql})")
225
228
  end
226
229
 
230
+ UUID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
231
+
232
+ # Whether a value can't be read as the column's type. Values are sent to
233
+ # the database as they are, this only catches the ones it would reject.
234
+ def invalid_value?
235
+ Array(value).compact_blank.any? { |entry| !valid_for_type?(entry) }
236
+ end
237
+
238
+ def valid_for_type?(entry)
239
+ return true unless entry.is_a?(String)
240
+
241
+ case column.type
242
+ when :integer, :bigint then entry.strip.match?(/\A[+-]?\d+\z/)
243
+ when :decimal, :float then entry.strip.match?(/\A[+-]?(\d+\.?\d*|\.\d+)\z/)
244
+ when :date then Date.parse(entry).present?
245
+ when :datetime, :timestamp then Time.zone.parse(entry).present?
246
+ when :uuid then entry.strip.match?(UUID_FORMAT)
247
+ else true
248
+ end
249
+ rescue ArgumentError, RangeError
250
+ false
251
+ end
252
+
227
253
  # Aggregates are only known per group, so the duplicate values come from
228
254
  # a subquery of the grouped rows and are compared in the HAVING
229
255
  def apply_duplicate_having_filter(record_scope, base_scope, column_sql)
@@ -129,6 +129,9 @@ module Mensa
129
129
  # Skip orders on unknown names, e.g. from browser state saved for
130
130
  # different params.
131
131
  next unless col || model_attribute?(k)
132
+ # Columns without SQL can't be sorted on, unless they're explicitly
133
+ # sortable, e.g. on an alias the scope selects
134
+ next if col && !col.sql? && !col.sortable?
132
135
  next unless %w[asc desc].include?(v)
133
136
 
134
137
  attribute = col&.attribute_for_condition || k
@@ -146,10 +146,13 @@ module Mensa
146
146
  option :search, default: :basic
147
147
  # Default options for Mensa::Verifier (and so `rails mensa:verify`), called
148
148
  # with the table class. Return a hash with any of user:, account:,
149
- # context: (Current attributes) and params:, e.g.
149
+ # context: (Current attributes), params: and label:, or an array of them
150
+ # to verify the table in each of those contexts, e.g.
150
151
  #
151
152
  # config.verifier_context = ->(table_class) { {user: User.find_by!(email: "admin@example.com")} }
152
153
  option :verifier_context, default: nil
154
+ # Mensa::Verifier warns about queries that take this many seconds or more
155
+ option :verifier_slow_query, default: 1.0
153
156
 
154
157
  def initialize
155
158
  set_defaults!
data/lib/mensa/testing.rb CHANGED
@@ -8,19 +8,20 @@ module Mensa
8
8
  # assert_mensa_table_valid UsersTable, user: users(:admin) # Minitest
9
9
  # expect(UsersTable).to be_a_valid_mensa_table(user: admin) # RSpec
10
10
  #
11
- # Both take the verifier's options (user:, account:, context:, params:)
12
- # and fail with its report.
11
+ # Both take the verifier's options (user:, account:, context:, params:,
12
+ # strict:), verify the table in every context of
13
+ # Mensa.config.verifier_context and fail with the reports.
13
14
  module Testing
14
15
  module Assertions
15
16
  def assert_mensa_table_valid(table, message = nil, **options, &setup)
16
- result = Mensa::Verifier.new(table, **options, &setup).call
17
- assert result.success?, -> { [message, result.to_s].compact.join("\n\n") }
18
- result
17
+ results = Mensa::Verifier.verify(table, **options, &setup)
18
+ assert results.all?(&:success?), -> { [message, *results.reject(&:success?).map(&:to_s)].compact.join("\n\n") }
19
+ results
19
20
  end
20
21
  end
21
22
 
22
23
  class ValidTableMatcher
23
- attr_reader :result
24
+ attr_reader :results
24
25
 
25
26
  def initialize(**options, &setup)
26
27
  @options = options
@@ -28,16 +29,16 @@ module Mensa
28
29
  end
29
30
 
30
31
  def matches?(table)
31
- @result = Mensa::Verifier.new(table, **@options, &@setup).call
32
- @result.success?
32
+ @results = Mensa::Verifier.verify(table, **@options, &@setup)
33
+ @results.all?(&:success?)
33
34
  end
34
35
 
35
36
  def failure_message
36
- result.to_s
37
+ results.reject(&:success?).join("\n\n")
37
38
  end
38
39
 
39
40
  def failure_message_when_negated
40
- "expected #{result.table_class} not to be a valid Mensa table, but all its checks passed"
41
+ "expected #{results.first.table_class} not to be a valid Mensa table, but all its checks passed"
41
42
  end
42
43
 
43
44
  def description
@@ -5,7 +5,8 @@ module Mensa
5
5
  # One verified aspect of a table, e.g. sorting a column descending.
6
6
  #
7
7
  # +section+ groups checks in the report (:loads, :columns, :filters, ...),
8
- # +subject+ is the column, filter or view it relates to.
8
+ # +subject+ is the column, filter or view it relates to. Warnings (N+1
9
+ # queries, slow queries) don't fail the table unless it's verified strict.
9
10
  Check = Struct.new(:section, :subject, :name, :status, :message, :sql, :error) do
10
11
  def passed?
11
12
  status == :passed
@@ -19,6 +20,10 @@ module Mensa
19
20
  status == :skipped
20
21
  end
21
22
 
23
+ def warning?
24
+ status == :warning
25
+ end
26
+
22
27
  def label
23
28
  [subject, name].compact.join(": ")
24
29
  end
@@ -31,17 +36,21 @@ module Mensa
31
36
  filters: "Filters",
32
37
  search: "Search",
33
38
  render: "Rendering",
39
+ combined: "Combined",
34
40
  exports: "Exports",
35
41
  grouping: "Grouping",
36
42
  views: "Views"
37
43
  }.freeze
38
44
 
39
- SYMBOLS = {passed: "✓", failed: "✗", skipped: "-"}.freeze
45
+ SYMBOLS = {passed: "✓", failed: "✗", skipped: "-", warning: "!"}.freeze
40
46
 
41
- attr_reader :table_class, :checks
47
+ attr_reader :table_class, :checks, :label
42
48
 
43
- def initialize(table_class)
49
+ # +label+ names the context it was verified in, e.g. "user: User#1"
50
+ def initialize(table_class, strict: false, label: nil)
44
51
  @table_class = table_class
52
+ @strict = strict
53
+ @label = label
45
54
  @checks = []
46
55
  end
47
56
 
@@ -50,8 +59,12 @@ module Mensa
50
59
  table_class.is_a?(Class) && table_class < Mensa::Base
51
60
  end
52
61
 
62
+ def strict?
63
+ @strict
64
+ end
65
+
53
66
  def success?
54
- failures.empty?
67
+ failures.empty? && (!strict? || warnings.empty?)
55
68
  end
56
69
 
57
70
  def failures
@@ -66,20 +79,28 @@ module Mensa
66
79
  checks.select(&:skipped?)
67
80
  end
68
81
 
82
+ def warnings
83
+ checks.select(&:warning?)
84
+ end
85
+
69
86
  def summary
70
- "#{passes.size} passed, #{failures.size} failed, #{skips.size} skipped"
87
+ "#{passes.size} passed, #{failures.size} failed, #{skips.size} skipped, #{warnings.size} #{"warning".pluralize(warnings.size)}"
88
+ end
89
+
90
+ def title
91
+ [table_class.name || table_class.to_s, ("(#{label})" if label)].compact.join(" ")
71
92
  end
72
93
 
73
94
  def to_s
74
- return "#{table_class}\n - skipped: #{checks.first&.message}" unless mensa_table?
95
+ return "#{title}\n - skipped: #{checks.first&.message}" unless mensa_table?
75
96
 
76
- lines = [(table_class.name || table_class).to_s]
97
+ lines = [title]
77
98
 
78
99
  checks.group_by(&:section).each do |section, section_checks|
79
100
  lines << " #{SECTIONS.fetch(section, section.to_s.humanize)}"
80
101
  section_checks.each do |check|
81
102
  lines << " #{SYMBOLS[check.status]} #{check.label}#{" (skipped: #{check.message})" if check.skipped?}"
82
- next unless check.failed?
103
+ next unless check.failed? || check.warning?
83
104
 
84
105
  check.message.to_s.each_line { |line| lines << " #{line.chomp}" }
85
106
  lines << " SQL: #{check.sql}" if check.sql.present?
@@ -87,13 +108,13 @@ module Mensa
87
108
  end
88
109
 
89
110
  lines << ""
90
- lines << "#{success? ? "OK" : "FAILED"}: #{summary}"
111
+ lines << "#{success? ? "OK" : "FAILED"}: #{summary}#{" (strict: warnings fail)" if strict? && warnings.any?}"
91
112
  lines.join("\n")
92
113
  end
93
114
  alias_method :report, :to_s
94
115
 
95
116
  def inspect
96
- "#<#{self.class.name} #{table_class} #{summary}>"
117
+ "#<#{self.class.name} #{title} #{summary}>"
97
118
  end
98
119
  end
99
120
  end
@@ -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,15 @@ 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
+ thread = Thread.current
153
+ ActiveSupport::Notifications.subscribed(->(event) { record_query(event) if Thread.current == thread }, "sql.active_record") do
154
+ read_only_transaction do
155
+ with_current_attributes do
156
+ @setup&.call(self)
157
+ run_checks
158
+ end
111
159
  end
112
160
  end
113
161
  result
@@ -139,15 +187,18 @@ module Mensa
139
187
 
140
188
  private
141
189
 
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
190
  def default_host
148
191
  Rails.application.routes.default_url_options[:host] || "localhost"
149
192
  end
150
193
 
194
+ # Names the context in the report, e.g. "user: User#1, params: {...}"
195
+ def context_label
196
+ values = context.symbolize_keys.merge({user: user, account: account}.compact)
197
+ parts = values.map { |key, value| "#{key}: #{value.is_a?(ActiveRecord::Base) ? "#{value.class.name}##{value.id}" : value.inspect}" }
198
+ parts << "params: #{params.to_json}" if params.present?
199
+ parts.join(", ").presence
200
+ end
201
+
151
202
  def run_checks
152
203
  @table = check(:loads, nil, "build table") { build_table }
153
204
  unless @table
@@ -158,23 +209,31 @@ module Mensa
158
209
  # A table of its own, a relation can't be counted after its to_sql
159
210
  check(:loads, nil, "build SQL") { |c| c.sql = build_table.selected_scope.to_sql }
160
211
  @rows = check(:loads, nil, "load first page") { @table.rows }
161
- verify_second_page
212
+ verify_last_page
162
213
  verify_defaults
214
+ @samples = check(:loads, nil, "sample records") { |c| load_samples.tap { |samples| c.name = sample_name(samples) } } if @rows
163
215
 
164
216
  verify_columns
165
217
  verify_filters
166
218
  verify_search
167
219
  verify_rendering
220
+ verify_combined
168
221
  verify_exports
169
222
  verify_grouping
170
223
  verify_views
171
224
  end
172
225
 
226
+ # Checks
227
+
173
228
  # 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.
229
+ # outcome, then any warnings found while running it. Returns the block's
230
+ # value, nil when the check didn't pass. Quiet checks are only reported
231
+ # when they fail.
176
232
  def check(section, subject, name, quiet: false)
177
233
  c = Check.new(section: section, subject: subject, name: name)
234
+ outer_warnings = @warnings
235
+ @warnings = []
236
+ start = @queries.size
178
237
  value = nil
179
238
  savepoint { value = yield(c) }
180
239
  c.status = :passed
@@ -183,20 +242,28 @@ module Mensa
183
242
  c.status = :skipped
184
243
  c.message = e.message
185
244
  nil
186
- rescue StandardError, NotImplementedError => e
245
+ rescue StandardError, ScriptError => e
187
246
  c.status = :failed
188
247
  c.error = e
189
- c.message = e.is_a?(Failure) ? e.message : "#{e.class}: #{e.message}"
248
+ c.message = e.is_a?(Failure) ? e.message : "#{e.class}: #{e.message}#{error_hint(e)}"
190
249
  c.sql = e.sql if e.respond_to?(:sql) && e.sql.present?
191
250
  nil
192
251
  ensure
193
252
  result.checks << c unless quiet && c.passed?
253
+ warn_slow_queries(section, subject, @queries[start..]) if c.passed?
254
+ result.checks.concat(@warnings)
255
+ @warnings = outer_warnings
194
256
  end
195
257
 
196
258
  def skip!(reason)
197
259
  raise Skip, reason
198
260
  end
199
261
 
262
+ def warn!(section, subject, name, message, sql = nil)
263
+ warning = Check.new(section: section, subject: subject, name: name, status: :warning, message: message, sql: sql)
264
+ @warnings ? @warnings << warning : result.checks << warning
265
+ end
266
+
200
267
  def savepoint
201
268
  ActiveRecord::Base.transaction(requires_new: true) do
202
269
  yield
@@ -204,22 +271,159 @@ module Mensa
204
271
  end
205
272
  end
206
273
 
207
- # Record-level checks need loaded records
274
+ # The block's value, or nil when it raises
275
+ def attempt
276
+ value = nil
277
+ savepoint { value = yield }
278
+ value
279
+ rescue
280
+ nil
281
+ end
282
+
283
+ # Fixes for errors that have a common cause
284
+ def error_hint(error)
285
+ error = error.cause if error.is_a?(ActionView::Template::Error) && error.cause
286
+ return unless error.is_a?(ActiveModel::MissingAttributeError)
287
+
288
+ attribute = error.message[/missing attribute '?(\w+)'?/, 1]
289
+ "\nFix: Mensa only selects the table's columns, add `internal :#{attribute}` to select #{attribute} too." if attribute
290
+ end
291
+
292
+ # Queries
293
+
294
+ def record_query(event)
295
+ payload = event.payload
296
+ return if payload[:cached] || %w[SCHEMA TRANSACTION].include?(payload[:name]) || payload[:sql].to_s.match?(IGNORED_QUERY)
297
+
298
+ @queries << {sql: payload[:sql], duration: event.duration / 1000.0}
299
+ end
300
+
301
+ def warn_slow_queries(section, subject, queries)
302
+ slow = queries.select { |query| query[:duration] >= @slow_query }
303
+ return if slow.empty?
304
+
305
+ slowest = slow.max_by { |query| query[:duration] }
306
+ warn!(section, subject, "slow query",
307
+ "took #{format("%.2f", slowest[:duration])}s (reported from #{@slow_query}s#{", #{slow.size} slow queries" if slow.size > 1}).\n" \
308
+ "Fix: check its plan with EXPLAIN ANALYZE, e.g. for a missing index.", slowest[:sql])
309
+ end
310
+
311
+ # Records
312
+
313
+ # Samples to render: records from the first and the last page, and a
314
+ # record with an empty value for each column, where null values often
315
+ # break render blocks.
316
+ def load_samples
317
+ samples = @rows.first(SAMPLE_SIZE).map { |row| Sample.new(row, "first page") }
318
+
319
+ pages = @table.pagy_details.pages
320
+ build_table_on_page(pages).rows.last(LAST_PAGE_SAMPLE).each { |row| samples << Sample.new(row, "last page") } if pages > 1
321
+
322
+ @table.columns.reject(&:internal?).select(&:filter?).each do |column|
323
+ next if column.attribute_for_condition.blank?
324
+ next if samples.any? { |sample| attempt { sample.row.value(column).nil? } }
325
+
326
+ sample = attempt do
327
+ table = build_table(filters: {column.name => {operator: :is_empty}})
328
+ record = table.selected_scope.limit(1).first
329
+ Sample.new(Mensa::Row.new(table, record), "#{column.name} empty") if record
330
+ end
331
+ samples << sample if sample
332
+ end
333
+
334
+ samples.uniq { |sample| sample.row.record.respond_to?(:id) ? sample.row.record.id : sample.row.record.object_id }
335
+ end
336
+
337
+ def sample_name(samples)
338
+ "sample #{samples.size} #{"record".pluralize(samples.size)}#{" (#{samples.map(&:reason).uniq.join(", ")})" if samples.any?}"
339
+ end
340
+
341
+ def build_table_on_page(page)
342
+ table = build_table
343
+ table.request = ActionDispatch::Request.new(Rack::MockRequest.env_for("http://#{default_host}/?page=#{page}"))
344
+ table
345
+ end
346
+
347
+ # Record-level checks need sampled records
208
348
  def sample_rows!
209
- skip!("records could not be loaded") if @rows.nil?
210
- skip!("no records") if @rows.empty?
349
+ skip!("records could not be loaded") if @samples.nil?
350
+ skip!("no records") if @samples.empty?
351
+
352
+ @samples
353
+ end
354
+
355
+ # Renders every sample in its own savepoint. Fails naming the records that
356
+ # failed, and warns about queries that run for each record (N+1).
357
+ def render_samples(section, subject, what)
358
+ errors = []
359
+ queries_per_record = sample_rows!.map do |sample|
360
+ start = @queries.size
361
+ begin
362
+ savepoint { yield sample.row }
363
+ rescue StandardError, ScriptError => e
364
+ errors << "#{sample.description}: #{e.class}: #{e.message}#{error_hint(e)}"
365
+ end
366
+ @queries[start..].map { |query| query[:sql] }.uniq
367
+ end
211
368
 
212
- @rows.first(SAMPLE_SIZE)
369
+ warn_n_plus_one(section, subject, what, queries_per_record)
370
+ raise Failure, errors.join("\n") if errors.any?
213
371
  end
214
372
 
215
- def verify_second_page
216
- check(:loads, nil, "load page 2") do
373
+ def warn_n_plus_one(section, subject, what, queries_per_record)
374
+ return if queries_per_record.size < 2
375
+
376
+ queries_per_record.flatten.tally.each do |sql, records|
377
+ next if records < 2
378
+ next unless @reported_n_plus_one.add?([subject, sql])
379
+
380
+ warn!(section, subject, "N+1 query in #{what}",
381
+ "runs this query for each record (#{records} of #{queries_per_record.size} sampled records).#{n_plus_one_hint(sql)}", sql)
382
+ end
383
+ end
384
+
385
+ # How to avoid a query per record, from the association it loads
386
+ def n_plus_one_hint(sql)
387
+ model = @table.model
388
+ table = sql[/\bFROM\s+"?(\w+)"?/i, 1]
389
+ reflection = model.reflect_on_all_associations.find do |association|
390
+ !association.polymorphic? && association.klass.table_name == table
391
+ rescue NameError
392
+ false
393
+ end
394
+ return "\nFix: select the value in SQL with `attribute`, or preload what the block uses in `scope`." unless reflection
395
+
396
+ preload = "`scope { #{model.name}.includes(:#{reflection.name}) }`"
397
+ hints = if reflection.belongs_to?
398
+ foreign_key = reflection.foreign_key.to_s
399
+ selected = @table.columns.any? { |column| column.name.to_s == foreign_key }
400
+ [
401
+ "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}",
402
+ "or select the value in SQL with `attribute \"#{table}.<column>\"` and `left_outer_joins(:#{reflection.name})` in `scope`"
403
+ ]
404
+ else
405
+ hints = ["preload it with #{preload}"]
406
+ if sql.match?(/\bCOUNT\(/i) && reflection.collection?
407
+ hints << "and use `.size` instead of `.count`, which always queries"
408
+ unless reflection.through_reflection?
409
+ 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`"
410
+ end
411
+ end
412
+ hints
413
+ end
414
+ "\nFix: #{hints.join(",\n ")}."
415
+ end
416
+
417
+ # Loads
418
+
419
+ def verify_last_page
420
+ check(:loads, nil, "load last page") do
217
421
  skip!("records could not be loaded") if @rows.nil?
218
- skip!("only one page") if @table.pagy_details.pages < 2
219
422
 
220
- table = build_table
221
- table.request = ActionDispatch::Request.new(Rack::MockRequest.env_for("http://#{default_host}/?page=2"))
222
- table.rows
423
+ pages = @table.pagy_details.pages
424
+ skip!("only one page") if pages < 2
425
+
426
+ build_table_on_page(pages).rows
223
427
  end
224
428
  end
225
429
 
@@ -248,7 +452,11 @@ module Mensa
248
452
  check(:columns, column.name, "select") do |c|
249
453
  skip!("no SQL attribute, the value comes from #{column.method ? "method :#{column.method}" : "the record"}") if column.attribute.blank?
250
454
 
251
- relation = build_table.filtered_scope.select(Arel.sql(column.attribute)).limit(1)
455
+ # What Mensa selects besides the column: the id and internal columns,
456
+ # which preloads in the scope need
457
+ table = build_table
458
+ selects = (table.send(:id_column?) ? [:id] : []) + table.columns.select(&:internal?).filter_map(&:attribute)
459
+ relation = table.filtered_scope.select(*selects, Arel.sql(column.attribute)).limit(1)
252
460
  c.sql = relation.to_sql
253
461
  relation.load
254
462
  end
@@ -269,7 +477,7 @@ module Mensa
269
477
  check(:columns, column.name, "render #{format}") do
270
478
  skip!("not visible") unless visible
271
479
 
272
- sample_rows!.each { |row| Mensa::Cell.new(row: row, column: column).render(format) }
480
+ render_samples(:columns, column.name, "render #{format}") { |row| Mensa::Cell.new(row: row, column: column).render(format) }
273
481
  end
274
482
  end
275
483
  end
@@ -292,8 +500,12 @@ module Mensa
292
500
  end
293
501
  end
294
502
 
295
- check(:filters, column.name, filter_check_name(column, operators, values)) do |c|
503
+ invalid = invalid_value(column)
504
+ check(:filters, column.name, filter_check_name(column, operators, values, invalid)) do |c|
296
505
  errors = operators.filter_map { |operator, _label, with_value| filter_error(column, operator, filter_value(column, operator, with_value, values)) }
506
+ if invalid
507
+ errors += operators.select { |_operator, _label, with_value| with_value }.filter_map { |operator, *| filter_error(column, operator, invalid) }
508
+ end
297
509
  next if errors.empty?
298
510
 
299
511
  c.sql = errors.first[:sql]
@@ -302,8 +514,9 @@ module Mensa
302
514
  end
303
515
  end
304
516
 
305
- def filter_check_name(column, operators, values)
306
- "#{operators.map(&:first).join(", ")} (#{(column.filter.multiple? ? values : values.first).inspect})"
517
+ def filter_check_name(column, operators, values, invalid)
518
+ name = "#{operators.map(&:first).join(", ")} (#{(column.filter.multiple? ? values : values.first).inspect})"
519
+ invalid ? "#{name}, invalid input (#{invalid.inspect})" : name
307
520
  end
308
521
 
309
522
  # Applies the filter with one operator in its own savepoint, returns
@@ -318,7 +531,7 @@ module Mensa
318
531
  table.active_filters.each(&:to_s)
319
532
  end
320
533
  nil
321
- rescue StandardError, NotImplementedError => e
534
+ rescue StandardError, ScriptError => e
322
535
  sql = e.sql if e.respond_to?(:sql)
323
536
  sql ||= relation.to_sql if relation
324
537
  {message: "#{operator}#{" #{value.inspect}" if value}: #{e.class}: #{e.message}", sql: sql}
@@ -361,6 +574,15 @@ module Mensa
361
574
  end
362
575
  end
363
576
 
577
+ # A value a user could type that the column's type can't hold
578
+ def invalid_value(column)
579
+ case column.type
580
+ when :integer, :bigint, :decimal, :float then "not a number"
581
+ when :date, :datetime, :timestamp then "not a date"
582
+ when :uuid then "not a uuid"
583
+ end
584
+ end
585
+
364
586
  # Search
365
587
 
366
588
  # Both search modes, the app may switch between them
@@ -373,7 +595,8 @@ module Mensa
373
595
  table = build_table(query: term)
374
596
  skip!("needs the pg_trgm extension") if mode == :fuzzy && !table.send(:fuzzy_search?, table.scope)
375
597
 
376
- relation = table.ordered_scope.limit(SAMPLE_SIZE)
598
+ # Search uses every column, so load what requests load
599
+ relation = table.selected_scope.limit(SAMPLE_SIZE)
377
600
  c.sql = relation.to_sql
378
601
  relation.load
379
602
  end
@@ -391,15 +614,14 @@ module Mensa
391
614
 
392
615
  # A word from the first string value in the records, so it finds something
393
616
  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
617
+ @search_term ||= begin
618
+ words = (@rows || []).first(SAMPLE_SIZE).lazy.flat_map { |row|
619
+ @table.display_columns.map { |column| row.value(column) }
620
+ }.select { |value| value.is_a?(String) && value.split.first.present? }
621
+ words.first&.split&.first&.first(20) || "a"
622
+ rescue
623
+ "a"
399
624
  end
400
- "a"
401
- rescue
402
- "a"
403
625
  end
404
626
 
405
627
  # Rendering, like the app's views and Mensa's controllers do
@@ -408,13 +630,15 @@ module Mensa
408
630
  check(:render, nil, "table component") { view_context.table(table_name, params: params) }
409
631
  check(:render, nil, "rows and filter pills") { render_page(build_table, view: @table.default_system_view) }
410
632
 
411
- check(:render, nil, "row link") { sample_rows!.each(&:link) } if @table.link
633
+ check(:render, nil, "row link") { render_samples(:render, nil, "row link", &:link) } if @table.link
412
634
 
413
635
  return unless @table.actions?
414
636
 
415
637
  @table.actions.each do |action|
416
638
  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)) }
639
+ render_samples(:render, :actions, action.name.to_s) do |row|
640
+ view_context.render(Mensa::RowAction::Component.new(table: @table, row: row, action: action))
641
+ end
418
642
  end
419
643
  end
420
644
  end
@@ -426,6 +650,37 @@ module Mensa
426
650
  view_context.render(Mensa::View::Component.new(table))
427
651
  end
428
652
 
653
+ # Everything a user can combine at once: search, a filter on every
654
+ # column, sorting on every column and grouping with aggregates
655
+ def verify_combined
656
+ check(:combined, nil, "search, filters, sorting and grouping together") do |c|
657
+ config = {query: search_term, filters: combined_filters}
658
+ config[:order] = @table.columns.select { |column| column.sortable? && !column.internal? }.to_h { |column| [column.name, :asc] }
659
+
660
+ groupable = @table.columns.find(&:groupable?)
661
+ if groupable && @table.scope.is_a?(ActiveRecord::Relation)
662
+ config[:group_by] = groupable.name
663
+ config[:aggregates] = @table.columns.select(&:aggregatable?).reject(&:internal?).to_h { |column| [column.name, column.aggregates.first] }
664
+ end
665
+
666
+ table = build_table(config)
667
+ c.sql = table.selected_scope.to_sql
668
+ render_page(build_table(config))
669
+ end
670
+ end
671
+
672
+ def combined_filters
673
+ @table.columns.select(&:filter?).each_with_object({}) do |column, filters|
674
+ operators = attempt { column.filter.operators } || []
675
+ operator, _label, with_value = operators.find { |_operator, _label, with_value| with_value } ||
676
+ operators.find { |operator, *| %i[is_empty isnt_empty].include?(operator) }
677
+ next unless operator
678
+
679
+ values = attempt { filter_values(column) } || [synthetic_value(column)]
680
+ filters[column.name] = {operator: operator, value: filter_value(column, operator, with_value, values)}.compact
681
+ end
682
+ end
683
+
429
684
  # Exports, generated by Mensa::ExportJob like a user's export request,
430
685
  # for the first page of the table and of each view
431
686
 
@@ -473,7 +728,7 @@ module Mensa
473
728
 
474
729
  expected = build_table((table_view&.config || {}).deep_symbolize_keys.merge(config.deep_symbolize_keys))
475
730
  headers = expected.display_columns.map { |column| column.name.to_s }
476
- lines = CSV.parse(csv.force_encoding(Encoding::UTF_8).delete_prefix("\uFEFF"))
731
+ lines = CSV.parse(csv.force_encoding(Encoding::UTF_8).delete_prefix(""))
477
732
  raise Failure, "the export has no columns" if headers.empty?
478
733
  raise Failure, "the export has headers #{lines.first.inspect}, expected #{headers.inspect}" unless lines.first == headers
479
734
 
data/lib/mensa/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Mensa
2
- VERSION = "0.6.18"
2
+ VERSION = "0.6.19"
3
3
  end
data/lib/tasks/mensa.rake CHANGED
@@ -1,12 +1,12 @@
1
1
  namespace :mensa do
2
- desc "Verify a Mensa table against the database, e.g. mensa:verify[users] (context: USER_ID=, ACCOUNT_ID=, PARAMS='{...}')"
2
+ desc "Verify a Mensa table against the database, e.g. mensa:verify[users] (context: USER_ID=, ACCOUNT_ID=, PARAMS='{...}', STRICT=1 fails on warnings)"
3
3
  task :verify, [:table] => :environment do |_task, args|
4
4
  abort "Usage: bin/rails \"mensa:verify[table_name]\"" if args[:table].blank?
5
5
 
6
6
  exit(1) unless Mensa::Verifier.run(args[:table])
7
7
  end
8
8
 
9
- desc "Verify all Mensa tables in app/tables against the database (context: USER_ID=, ACCOUNT_ID=, PARAMS='{...}')"
9
+ desc "Verify all Mensa tables in app/tables against the database (context: USER_ID=, ACCOUNT_ID=, PARAMS='{...}', STRICT=1 fails on warnings)"
10
10
  task verify_all: :environment do
11
11
  exit(1) unless Mensa::Verifier.run
12
12
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mensa
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.18
4
+ version: 0.6.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom de Grunt