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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9bbb252521ea00a7d6197e9ae12208d121ec069652b45b729095609a596bda2
4
- data.tar.gz: 92345f45e5b1336f1e101c2c64d9736c58637cb9792c333cc21f4620dadabf94
3
+ metadata.gz: 064c836f400dd147fc8fde3fb1f38a49f7bd447c44e6d7493c055cd6dede6b19
4
+ data.tar.gz: b6fdbbdce0951fb43fdfd8f574cf6888ebe1c9f4e369752f4a497de85ca8b8f4
5
5
  SHA512:
6
- metadata.gz: 2b97589e71ee52505369eb9cac3af5e49672c8af62d076d2f17465ea76b2c556ca2c5adbede3af5ad850db502bd47271e031c96a5292eb2ef191818e24e60435
7
- data.tar.gz: ba31a706fa85f69d30a1b4bfcb17d2c6674df561daf8379341b3f7dc31d21bd10ecc94fc36192f0f2302e602556898c72d9b3d203e330f2a60494c3741b004f2
6
+ metadata.gz: f96aca8ee7039304c3292456e0be10722faba2eecac2ba41030f632317998bbf0d1e4d3f649f5a46d675a4d06268dae07d216e536b80294c4990c805160df0b3
7
+ data.tar.gz: f629a69b4a6177d8aedc58f931fa00d328af953dca50cfd80c282989c40fd2434e2e655b5c1f55611568f893d016b5467b16c2925e8fff293b977be9f53d36fc
data/.gitignore CHANGED
@@ -6,7 +6,7 @@
6
6
  /tmp/
7
7
  /test/dummy/db/*.sqlite3
8
8
  /test/dummy/db/*.sqlite3-*
9
- /test/dummy/log/*.log
9
+ /test/dummy/log/*
10
10
  /test/dummy/storage/
11
11
  /test/dummy/app/assets/builds/
12
12
  /test/dummy/tmp/
data/.shipkit.yml CHANGED
@@ -1,2 +1,2 @@
1
1
  ---
2
- previous_version: 0.6.17
2
+ previous_version: 0.6.19
data/CHANGELOG.md CHANGED
@@ -1,6 +1,32 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.7.0
4
+
5
+ - Filters only offer the operators that work for the column's type in SQL,
6
+ picking one no longer raises. `matches`/`does_not_match` (`LIKE`) are only
7
+ offered for string, text, citext and untyped attribute columns, so boolean,
8
+ float, decimal, uuid, time and other columns no longer get them.
9
+ `gt`/`gteq`/`lt`/`lteq` are only offered for number, date and time columns,
10
+ so boolean, uuid and text columns no longer get them. json/jsonb columns only
11
+ get `is_empty`/`isnt_empty` (and jsonb `is_duplicate`). Host apps no longer
12
+ need to patch `Mensa::Filter#operators` or list `operators` per column to
13
+ avoid these. An explicit `operators` list is used as given.
14
+ - Time columns use a time input, and a filter value that isn't a time matches
15
+ nothing instead of raising.
16
+ - The verifier warns when an `attribute` has no `type` but its SQL isn't text,
17
+ e.g. a boolean `EXISTS (...)`, and says which `type` to add.
18
+ - Columns a `column_order` doesn't list are kept and shown after the listed
19
+ ones, instead of being dropped. Browser state or views saved before a column
20
+ was added no longer hide that column (also from the column customizer),
21
+ ignore filters and sorting on it, or leave its attribute unselected.
22
+ - The verifier checks column order and visibility: a reversed order, an order
23
+ that lists only one column (like state saved before columns were added),
24
+ hiding each column and hiding all of them, each rendered and exported.
25
+ - The verifier creates a custom view like a user saving the table's state
26
+ would, without saving it, and validates, replays and exports it.
27
+ - The verifier confirms when it found no N+1 queries.
28
+
29
+ ## 0.6.19
4
30
 
5
31
  - `Mensa::Verifier` checks a table against the database: scope, column SQL,
6
32
  sorting, rendering, filters, search, CSV export, grouping/aggregates and
@@ -8,6 +34,18 @@
8
34
  or from tests after `require "mensa/testing"` with
9
35
  `assert_mensa_table_valid` or `be_a_valid_mensa_table`. Set defaults for
10
36
  its context with `config.verifier_context`.
37
+ - The verifier samples records from the first and last page and records with
38
+ empty values, tries filters with invalid input, checks search, filters, sorting
39
+ and grouping together, and warns about N+1 queries (with a fix) and slow
40
+ queries. `strict: true` / `STRICT=1` makes warnings fail.
41
+ `config.verifier_context` can return an array to verify tables in several
42
+ contexts, `config.verifier_slow_query` sets when a query is slow.
43
+ - Columns without SQL (only a `render` block or a `method`, no `attribute` or
44
+ database column) are no longer sortable or filterable by default, sorting or
45
+ filtering on them produced invalid SQL. Use `sortable true` for a column whose
46
+ name is an alias the scope selects, or a filter `scope` to filter it.
47
+ - Filters with a value their column's type can't hold (e.g. "abc" for a number,
48
+ or an invalid date or uuid) match nothing instead of raising a database error.
11
49
  - `Mensa::ExportJob.write_csv(io, table, rows)` writes a table's CSV export and
12
50
  `Mensa::ExportJob#generate_file(export)` generates an export's file without
13
51
  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.19)
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
@@ -137,7 +175,31 @@ end
137
175
  ```
138
176
 
139
177
  The operators are `is`, `isnt`, `matches`, `does_not_match`, `gt`, `gteq`, `lt`,
140
- `lteq`, `is_duplicate`, `is_current`, `is_empty` and `isnt_empty`.
178
+ `lteq`, `is_duplicate`, `is_current`, `is_empty` and `isnt_empty`. Without an
179
+ `operators` list, a column gets the ones that work for its type in SQL:
180
+
181
+ | Column type | Operators besides `is_duplicate`, `is_empty` and `isnt_empty` |
182
+ |---|---|
183
+ | string, text, citext, or an `attribute` without `type` | `is`, `isnt`, `matches`, `does_not_match` |
184
+ | integer, float, decimal, time | `is`, `isnt`, `gt`, `gteq`, `lt`, `lteq` |
185
+ | date, datetime | `gt`, `gteq`, `lt`, `lteq` |
186
+ | boolean, uuid, enums and other types | `is`, `isnt` |
187
+ | jsonb, json | none (json also has no `is_duplicate`) |
188
+
189
+ `matches` and `does_not_match` are left out when the filter has a `collection`, and
190
+ `is_current` is only offered when `Current` has a method named after the column.
191
+
192
+ Mensa reads the type from the database column. Give an `attribute` that isn't text a
193
+ `type`, otherwise it's treated as text and gets the text operators:
194
+
195
+ ```ruby
196
+ column(:has_orders) do
197
+ attribute "EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)"
198
+ type :boolean
199
+ end
200
+ ```
201
+
202
+ An `operators` list is used as given, also when it lists operators the type can't run.
141
203
 
142
204
  #### Custom filter scopes
143
205
 
@@ -325,15 +387,18 @@ If you're just using cron, you can add the following to your crontab:
325
387
  `Mensa::Verifier` checks that everything a table configures works against your
326
388
  real database, using the same code paths as requests do:
327
389
 
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.
390
+ - **Loads**: the table builds, its SQL builds and the first and last pages load. A
391
+ default `order` or `column_order` naming an unknown column fails. Records are
392
+ sampled for the checks below: some from the first and the last page, and one with
393
+ an empty value for each column, which often breaks render blocks.
330
394
  - **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.
395
+ sorted both ways. Its html and csv renders run for the sampled records, and a
396
+ failure names the record. Columns hidden by a `visible` lambda are reported as
397
+ skipped.
333
398
  - **Filters**: every filterable column is applied with each of its operators, using
334
399
  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.
400
+ with input the column's type can't hold (e.g. "not a number"). The filter pill's
401
+ text is built. `is_current` is skipped when `Current` has no value for the column.
337
402
  - **Search**: basic and fuzzy search (when `pg_trgm` is enabled) for a word taken
338
403
  from the records.
339
404
  - **Rendering**: the `table` helper as your views call it, the rows and filter pills
@@ -341,11 +406,36 @@ real database, using the same code paths as requests do:
341
406
  - **Exports**: `Mensa::ExportJob` generates the first page of the export, also for
342
407
  each view and as a password protected zip. It needs the expected headers
343
408
  (skipped for `exportable false`).
409
+ - **Column order and visibility**: the columns shown, rendered and exported in a
410
+ reversed order, with an order that lists only one column (like browser state or a
411
+ view saved before the other columns were added, which must still show them all),
412
+ with each column hidden and with all of them hidden.
413
+ - **Combined**: search, a filter on every column, sorting on every column and
414
+ grouping with aggregates, all at once.
344
415
  - **Grouping**: every groupable column and every aggregate, rendered with their
345
416
  group headers. A `group_by` or `aggregates` naming a column that doesn't allow
346
417
  it fails, because Mensa would silently ignore it.
347
418
  - **Views**: system views load, render and only refer to existing columns. With
348
- custom views enabled, saved views load too.
419
+ custom views enabled, saved views load too, and a custom view is created like a
420
+ user saving the table's state would (search, filters, sorting, column order, a
421
+ hidden column and grouping), then validated, replayed and exported. It's never
422
+ saved, and needs a user to own it (`user:`).
423
+
424
+ It also warns, without failing:
425
+
426
+ - **N+1 queries**: a query that a render block, the row link or a row action runs for
427
+ each record. The warning shows the query and how to avoid it for the association
428
+ it loads, e.g. preload it with `includes` (and `internal :customer_id`, since Mensa
429
+ only selects the table's columns), select the value in SQL with `attribute`, or
430
+ count in SQL. When none is found, the report confirms it under Rendering, e.g.
431
+ `✓ N+1 queries: none in 18 renders of 7 sampled records`. It needs at least two
432
+ records to tell.
433
+ - **Untyped attributes**: an `attribute` without `type` whose SQL isn't text, e.g. a
434
+ boolean `EXISTS (...)`. The warning names the `type` to add.
435
+ - **Slow queries**: queries that take `config.verifier_slow_query` seconds (1.0 by
436
+ default) or more.
437
+
438
+ Pass `strict: true` (or `STRICT=1` to rake) to make warnings fail.
349
439
 
350
440
  Each check runs on its own, so one failure doesn't hide the others. Everything runs
351
441
  in a read-only transaction that is rolled back, with small limits, so it is safe to
@@ -390,20 +480,24 @@ params. Give the verifier that context:
390
480
  - `context: {organization: org}` sets any other `Current` attributes.
391
481
  - `params: {customer_id: 1}` sets the table's params (see "Table params").
392
482
  - A block is called with the verifier before the checks, for any other setup.
483
+ - `label: "admin"` names the context in the report.
393
484
 
394
485
  Html render blocks run in a view context of your `ApplicationController` (see
395
486
  `config.base_controller`), so url helpers and your helpers work.
396
487
 
397
488
  From rake, set `USER_ID=` (finds a `User`), `ACCOUNT_ID=` (finds an `Account`) and
398
489
  `PARAMS='{"customer_id":1}'` (JSON), or configure defaults for every table in
399
- your initializer. The options you pass yourself override these defaults:
490
+ your initializer. The options you pass yourself override these defaults. Return an array of them to
491
+ verify every table once for each, e.g. as different kinds of users:
400
492
 
401
493
  ```ruby
402
494
  Mensa.setup do |config|
403
495
  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
496
+ params = {customer_id: Customer.first.id} if table_class == CustomerUsersTable
497
+ [
498
+ {label: "admin", user: User.find_by!(email: "admin@example.com"), params: params},
499
+ {label: "customer", user: User.find_by!(email: "customer@example.com"), params: params}
500
+ ]
407
501
  }
408
502
  end
409
503
  ```
@@ -6,4 +6,5 @@
6
6
  <% else %>
7
7
  <%= action.title %>
8
8
  <% end %>
9
- <% end %>
9
+ <% end %>
10
+ <% end %>
@@ -38,18 +38,16 @@ module Mensa
38
38
  order = order&.map(&:to_sym)
39
39
  return order if order.nil?
40
40
 
41
- # Internal columns are never shown in the column customizer UI, so they
42
- # are absent from any URL-supplied column_order. Always append them so
43
- # that columns and selected_scope include their attributes.
44
41
  all_keys = (config[:columns]&.keys || []).map(&:to_sym)
45
- # A column_order can come from browser state saved for different params
46
- # (with different columns), so drop names that aren't columns here.
47
- if all_keys.any?
48
- order &= all_keys
49
- order = all_keys if order.empty?
50
- end
51
- internal_keys = all_keys.select { |key| config.dig(:columns, key, :internal) }
52
- (order | internal_keys)
42
+ return order if all_keys.empty?
43
+
44
+ # A column_order can come from browser state or a view saved for
45
+ # different params, or before columns were added, so drop names that
46
+ # aren't columns here and append the columns it doesn't list. Internal
47
+ # columns are never in it, as the column customizer doesn't show them.
48
+ # Appending keeps every column in columns and selected_scope, so no
49
+ # column disappears and their filters and attributes keep working.
50
+ (order & all_keys) | all_keys
53
51
  end
54
52
 
55
53
  # Returns all columns
@@ -110,7 +108,9 @@ module Mensa
110
108
  def active_filters
111
109
  (config[:filters] || {}).filter_map do |column_name, filter_config|
112
110
  col = column(column_name)
113
- next unless col
111
+ # Columns without SQL can only be filtered with a custom scope, skip
112
+ # filters on them, e.g. from browser state saved earlier
113
+ next if col.nil? || !(col.sql? || col.filter?)
114
114
 
115
115
  definition = config.dig(:columns, col.name, :filter)
116
116
  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
  #
@@ -14,6 +14,15 @@ module Mensa
14
14
  config_reader :multiple
15
15
  config_reader :having?
16
16
 
17
+ # Types that LIKE works on, nil is an attribute without a type
18
+ TEXT_TYPES = [:string, :text, :citext, nil].freeze
19
+ # Types with a meaningful order, for gt, gteq, lt and lteq
20
+ RANGE_TYPES = %i[integer bigint float decimal money date datetime timestamp timestamptz time interval].freeze
21
+ # Types where an exact match on a value is of little use
22
+ POINT_IN_TIME_TYPES = %i[date datetime timestamp timestamptz].freeze
23
+ # Types that can't be compared with a value the user types
24
+ STRUCTURED_TYPES = %i[json jsonb].freeze
25
+
17
26
  class << self
18
27
  def OPERATORS
19
28
  [
@@ -86,6 +95,9 @@ module Mensa
86
95
 
87
96
  # Nothing selected in a multi-select means no filter
88
97
  return record_scope if value.is_a?(Array) && value.compact_blank.empty?
98
+ # A value the database can't compare with the column, e.g. "abc" for
99
+ # a number, matches nothing instead of raising
100
+ return record_scope.none if operator_with_value? && invalid_value?
89
101
 
90
102
  query, hash = query_and_hash_for_operator
91
103
  record_scope = (having? ? record_scope.having(query, hash) : record_scope.where(query, hash)) if query.present?
@@ -99,10 +111,15 @@ module Mensa
99
111
  operators = operators.select { |op| config[:operators].include?(op[0]) }
100
112
  else
101
113
  operators.delete_if { |op| op[0] == :is_current } unless Current.method_defined?(column.name, false)
114
+ type = column.type
102
115
  operators.delete_if { |op| [:matches, :does_not_match].include?(op[0]) } if collection.present?
103
- operators.delete_if { |op| [:matches, :does_not_match].include?(op[0]) } if column.type == :integer || column.type == :date || column.type == :datetime
104
- operators.delete_if { |op| [:is, :isnt].include?(op[0]) } if column.type == :date || column.type == :datetime
105
- operators.delete_if { |op| [:gt, :lt, :gteq, :lteq].include?(op[0]) } if column.type == :string || column.type.blank?
116
+ # Only these operators work for the column's type in SQL, the others
117
+ # raise, e.g. LIKE on a boolean
118
+ operators.delete_if { |op| [:matches, :does_not_match].include?(op[0]) } unless TEXT_TYPES.include?(type)
119
+ operators.delete_if { |op| [:gt, :lt, :gteq, :lteq].include?(op[0]) } unless RANGE_TYPES.include?(type)
120
+ operators.delete_if { |op| [:is, :isnt].include?(op[0]) } if POINT_IN_TIME_TYPES.include?(type) || STRUCTURED_TYPES.include?(type)
121
+ # json has no equality, so duplicates can't be grouped
122
+ operators.delete_if { |op| op[0] == :is_duplicate } if type == :json
106
123
  end
107
124
  operators
108
125
  end
@@ -159,6 +176,8 @@ module Mensa
159
176
  "date"
160
177
  when :datetime
161
178
  "datetime-local"
179
+ when :time
180
+ "time"
162
181
  else
163
182
  "text"
164
183
  end
@@ -224,6 +243,30 @@ module Mensa
224
243
  .where("#{column_sql} IN (#{duplicate_values_scope.to_sql})")
225
244
  end
226
245
 
246
+ UUID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
247
+
248
+ # Whether a value can't be read as the column's type. Values are sent to
249
+ # the database as they are, this only catches the ones it would reject.
250
+ def invalid_value?
251
+ Array(value).compact_blank.any? { |entry| !valid_for_type?(entry) }
252
+ end
253
+
254
+ def valid_for_type?(entry)
255
+ return true unless entry.is_a?(String)
256
+
257
+ case column.type
258
+ when :integer, :bigint then entry.strip.match?(/\A[+-]?\d+\z/)
259
+ when :decimal, :float then entry.strip.match?(/\A[+-]?(\d+\.?\d*|\.\d+)\z/)
260
+ when :date then Date.parse(entry).present?
261
+ when :datetime, :timestamp, :timestamptz then Time.zone.parse(entry).present?
262
+ when :time then entry.strip.match?(/\A\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?\z/)
263
+ when :uuid then entry.strip.match?(UUID_FORMAT)
264
+ else true
265
+ end
266
+ rescue ArgumentError, RangeError
267
+ false
268
+ end
269
+
227
270
  # Aggregates are only known per group, so the duplicate values come from
228
271
  # a subquery of the grouped rows and are compared in the HAVING
229
272
  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,22 @@ module Mensa
31
36
  filters: "Filters",
32
37
  search: "Search",
33
38
  render: "Rendering",
39
+ layout: "Column order and visibility",
40
+ combined: "Combined",
34
41
  exports: "Exports",
35
42
  grouping: "Grouping",
36
43
  views: "Views"
37
44
  }.freeze
38
45
 
39
- SYMBOLS = {passed: "✓", failed: "✗", skipped: "-"}.freeze
46
+ SYMBOLS = {passed: "✓", failed: "✗", skipped: "-", warning: "!"}.freeze
40
47
 
41
- attr_reader :table_class, :checks
48
+ attr_reader :table_class, :checks, :label
42
49
 
43
- def initialize(table_class)
50
+ # +label+ names the context it was verified in, e.g. "user: User#1"
51
+ def initialize(table_class, strict: false, label: nil)
44
52
  @table_class = table_class
53
+ @strict = strict
54
+ @label = label
45
55
  @checks = []
46
56
  end
47
57
 
@@ -50,8 +60,12 @@ module Mensa
50
60
  table_class.is_a?(Class) && table_class < Mensa::Base
51
61
  end
52
62
 
63
+ def strict?
64
+ @strict
65
+ end
66
+
53
67
  def success?
54
- failures.empty?
68
+ failures.empty? && (!strict? || warnings.empty?)
55
69
  end
56
70
 
57
71
  def failures
@@ -66,20 +80,28 @@ module Mensa
66
80
  checks.select(&:skipped?)
67
81
  end
68
82
 
83
+ def warnings
84
+ checks.select(&:warning?)
85
+ end
86
+
69
87
  def summary
70
- "#{passes.size} passed, #{failures.size} failed, #{skips.size} skipped"
88
+ "#{passes.size} passed, #{failures.size} failed, #{skips.size} skipped, #{warnings.size} #{"warning".pluralize(warnings.size)}"
89
+ end
90
+
91
+ def title
92
+ [table_class.name || table_class.to_s, ("(#{label})" if label)].compact.join(" ")
71
93
  end
72
94
 
73
95
  def to_s
74
- return "#{table_class}\n - skipped: #{checks.first&.message}" unless mensa_table?
96
+ return "#{title}\n - skipped: #{checks.first&.message}" unless mensa_table?
75
97
 
76
- lines = [(table_class.name || table_class).to_s]
98
+ lines = [title]
77
99
 
78
100
  checks.group_by(&:section).each do |section, section_checks|
79
101
  lines << " #{SECTIONS.fetch(section, section.to_s.humanize)}"
80
102
  section_checks.each do |check|
81
103
  lines << " #{SYMBOLS[check.status]} #{check.label}#{" (skipped: #{check.message})" if check.skipped?}"
82
- next unless check.failed?
104
+ next unless check.failed? || check.warning?
83
105
 
84
106
  check.message.to_s.each_line { |line| lines << " #{line.chomp}" }
85
107
  lines << " SQL: #{check.sql}" if check.sql.present?
@@ -87,13 +109,13 @@ module Mensa
87
109
  end
88
110
 
89
111
  lines << ""
90
- lines << "#{success? ? "OK" : "FAILED"}: #{summary}"
112
+ lines << "#{success? ? "OK" : "FAILED"}: #{summary}#{" (strict: warnings fail)" if strict? && warnings.any?}"
91
113
  lines.join("\n")
92
114
  end
93
115
  alias_method :report, :to_s
94
116
 
95
117
  def inspect
96
- "#<#{self.class.name} #{table_class} #{summary}>"
118
+ "#<#{self.class.name} #{title} #{summary}>"
97
119
  end
98
120
  end
99
121
  end