mensa 0.6.18 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -1
- data/.shipkit.yml +1 -1
- data/CHANGELOG.md +39 -1
- data/Gemfile.lock +1 -1
- data/README.md +109 -15
- data/app/components/mensa/row_action/component.html.erb +2 -1
- data/app/tables/mensa/base.rb +12 -12
- data/app/tables/mensa/column.rb +18 -3
- data/app/tables/mensa/config/column_dsl.rb +2 -1
- data/app/tables/mensa/filter.rb +46 -3
- data/app/tables/mensa/scope.rb +3 -0
- data/lib/mensa/configuration.rb +4 -1
- data/lib/mensa/testing.rb +11 -10
- data/lib/mensa/verifier/result.rb +33 -11
- data/lib/mensa/verifier.rb +466 -64
- data/lib/mensa/version.rb +1 -1
- data/lib/tasks/mensa.rake +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 064c836f400dd147fc8fde3fb1f38a49f7bd447c44e6d7493c055cd6dede6b19
|
|
4
|
+
data.tar.gz: b6fdbbdce0951fb43fdfd8f574cf6888ebe1c9f4e369752f4a497de85ca8b8f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f96aca8ee7039304c3292456e0be10722faba2eecac2ba41030f632317998bbf0d1e4d3f649f5a46d675a4d06268dae07d216e536b80294c4990c805160df0b3
|
|
7
|
+
data.tar.gz: f629a69b4a6177d8aedc58f931fa00d328af953dca50cfd80c282989c40fd2434e2e655b5c1f55611568f893d016b5467b16c2925e8fff293b977be9f53d36fc
|
data/.gitignore
CHANGED
data/.shipkit.yml
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
---
|
|
2
|
-
previous_version: 0.6.
|
|
2
|
+
previous_version: 0.6.19
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
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
|
-
|
|
124
|
-
column's attribute with the chosen value, using
|
|
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
|
|
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
|
|
332
|
-
by a `visible` lambda are reported as
|
|
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
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
```
|
data/app/tables/mensa/base.rb
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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)
|
data/app/tables/mensa/column.rb
CHANGED
|
@@ -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
|
-
#
|
|
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]
|
|
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
|
data/app/tables/mensa/filter.rb
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
operators.delete_if { |op| [:
|
|
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)
|
data/app/tables/mensa/scope.rb
CHANGED
|
@@ -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
|
data/lib/mensa/configuration.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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
|
-
|
|
17
|
-
assert
|
|
18
|
-
|
|
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 :
|
|
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
|
-
@
|
|
32
|
-
@
|
|
32
|
+
@results = Mensa::Verifier.verify(table, **@options, &@setup)
|
|
33
|
+
@results.all?(&:success?)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def failure_message
|
|
36
|
-
|
|
37
|
+
results.reject(&:success?).join("\n\n")
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def failure_message_when_negated
|
|
40
|
-
"expected #{
|
|
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
|
-
|
|
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 "#{
|
|
96
|
+
return "#{title}\n - skipped: #{checks.first&.message}" unless mensa_table?
|
|
75
97
|
|
|
76
|
-
lines = [
|
|
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} #{
|
|
118
|
+
"#<#{self.class.name} #{title} #{summary}>"
|
|
97
119
|
end
|
|
98
120
|
end
|
|
99
121
|
end
|