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 +4 -4
- data/.shipkit.yml +1 -1
- data/CHANGELOG.md +13 -1
- data/Gemfile.lock +1 -1
- data/README.md +72 -13
- data/app/components/mensa/row_action/component.html.erb +2 -1
- data/app/tables/mensa/base.rb +3 -1
- data/app/tables/mensa/column.rb +18 -3
- data/app/tables/mensa/config/column_dsl.rb +2 -1
- data/app/tables/mensa/filter.rb +26 -0
- 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 +32 -11
- data/lib/mensa/verifier.rb +313 -58
- 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: b38d819bbd96ef6b2cb38e7b89c07c9a487eb7b92e8a191a92f4fd7e09cf1f7f
|
|
4
|
+
data.tar.gz: 52856b77830db55218a63213c4dc90a685686046175059cd78c53d8fb40d5527
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a6dc8ccce0bed9be30b8b9c4b8d5f255b1d808d48a32c88e1813c40043378b0dd8d485abc5a34aa9b7b41f21335300f0f79f9cf6f7b1f1d7982f7f312027dad1
|
|
7
|
+
data.tar.gz: 36262b2128d07c1919dee7c08a82f0d15af44be791b012757afe41c28dadf5aafcc75e3be4907c0d9369d319217273297c98a20e2ab15380c03766da7b2e43ee
|
data/.shipkit.yml
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
---
|
|
2
|
-
previous_version: 0.6.
|
|
2
|
+
previous_version: 0.6.18
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
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
|
|
@@ -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
|
|
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
|
|
332
|
-
by a `visible` lambda are reported as
|
|
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
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
```
|
data/app/tables/mensa/base.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
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
|
@@ -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)
|
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,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
|
-
|
|
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 "#{
|
|
95
|
+
return "#{title}\n - skipped: #{checks.first&.message}" unless mensa_table?
|
|
75
96
|
|
|
76
|
-
lines = [
|
|
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} #{
|
|
117
|
+
"#<#{self.class.name} #{title} #{summary}>"
|
|
97
118
|
end
|
|
98
119
|
end
|
|
99
120
|
end
|
data/lib/mensa/verifier.rb
CHANGED
|
@@ -8,31 +8,45 @@ require "mensa/verifier/result"
|
|
|
8
8
|
module Mensa
|
|
9
9
|
# Checks that everything a table configures works against the real database:
|
|
10
10
|
# its scope, every column's SQL, sorting, rendering, filters, search, the CSV
|
|
11
|
-
# export, grouping/aggregates and its views.
|
|
11
|
+
# export, grouping/aggregates and its views. It also warns about N+1 queries
|
|
12
|
+
# and slow queries.
|
|
12
13
|
#
|
|
13
14
|
# result = Mensa::Verifier.new(UsersTable, user: User.first).call
|
|
14
15
|
# result.success? # => true
|
|
15
16
|
# puts result # per-check report
|
|
16
17
|
#
|
|
17
18
|
# Checks go through the same code paths as requests do (filtered_scope,
|
|
18
|
-
# ordered_scope, Mensa::Cell, Mensa::ExportJob
|
|
19
|
-
# runs in a read-only transaction that is rolled back, and every
|
|
20
|
-
# own savepoint so one failing query doesn't stop the others.
|
|
19
|
+
# ordered_scope, Mensa::Cell, the components, Mensa::ExportJob, ...).
|
|
20
|
+
# Everything runs in a read-only transaction that is rolled back, and every
|
|
21
|
+
# check in its own savepoint so one failing query doesn't stop the others.
|
|
21
22
|
#
|
|
22
23
|
# Tables often depend on request context, give it with user:, account: and
|
|
23
24
|
# context: (all set on Current while verifying), params: (the table's
|
|
24
25
|
# params) or a block, which is called with the verifier before the checks.
|
|
25
|
-
# Mensa.config.verifier_context supplies defaults for these
|
|
26
|
+
# Mensa.config.verifier_context supplies defaults for these, or a list of
|
|
27
|
+
# them to verify a table in several contexts (see Verifier.verify).
|
|
26
28
|
class Verifier
|
|
27
|
-
# Records
|
|
29
|
+
# Records sampled from the first and the last page
|
|
28
30
|
SAMPLE_SIZE = 5
|
|
31
|
+
LAST_PAGE_SAMPLE = 2
|
|
32
|
+
|
|
33
|
+
# Statements that aren't the table's queries
|
|
34
|
+
IGNORED_QUERY = /\A\s*(SAVEPOINT|RELEASE|ROLLBACK|BEGIN|COMMIT|SET|SHOW)\b/i
|
|
29
35
|
|
|
30
36
|
class Skip < StandardError; end
|
|
31
37
|
|
|
32
38
|
# A problem the verifier found itself, reported without the class name
|
|
33
39
|
class Failure < StandardError; end
|
|
34
40
|
|
|
35
|
-
|
|
41
|
+
# A record to render, and why it was sampled
|
|
42
|
+
Sample = Struct.new(:row, :reason) do
|
|
43
|
+
def description
|
|
44
|
+
id = row.record.id if row.record.respond_to?(:id)
|
|
45
|
+
[("record #{id}" if id), "(#{reason})"].compact.join(" ")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
attr_reader :table_class, :user, :account, :context, :params, :label, :result
|
|
36
50
|
|
|
37
51
|
class << self
|
|
38
52
|
# Accepts a table class, its name (e.g. "UsersTable") or Mensa's table
|
|
@@ -55,21 +69,44 @@ module Mensa
|
|
|
55
69
|
}.uniq.sort_by(&:name)
|
|
56
70
|
end
|
|
57
71
|
|
|
72
|
+
# Verifies +table+ in every context Mensa.config.verifier_context
|
|
73
|
+
# returns for it (a hash, or an array of them), with +options+ on top.
|
|
74
|
+
# Returns one result per context.
|
|
75
|
+
def verify(table, **options, &setup)
|
|
76
|
+
table_class = resolve_table_class(table)
|
|
77
|
+
options = options.compact
|
|
78
|
+
|
|
79
|
+
contexts_for(table_class).map do |defaults|
|
|
80
|
+
new(table_class, **options, defaults: defaults, &setup).call
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The option hashes Mensa.config.verifier_context gives for a table
|
|
85
|
+
def contexts_for(table_class)
|
|
86
|
+
return [{}] unless table_class.is_a?(Class) && table_class < Mensa::Base
|
|
87
|
+
|
|
88
|
+
contexts = Mensa.config.verifier_context&.call(table_class)
|
|
89
|
+
contexts = [contexts] unless contexts.is_a?(Array)
|
|
90
|
+
contexts.map { |context| context.is_a?(Hash) ? context.symbolize_keys : {} }.presence || [{}]
|
|
91
|
+
end
|
|
92
|
+
|
|
58
93
|
# Verifies the tables (all under app/tables when none are given), prints
|
|
59
94
|
# their reports to +io+ and returns true when all of them passed.
|
|
60
|
-
# Context comes from USER_ID, ACCOUNT_ID and PARAMS (JSON) in +env+,
|
|
61
|
-
# by the mensa:verify rake tasks.
|
|
95
|
+
# Context comes from USER_ID, ACCOUNT_ID and PARAMS (JSON) in +env+, and
|
|
96
|
+
# STRICT=1 makes warnings fail, used by the mensa:verify rake tasks.
|
|
62
97
|
def run(tables = nil, env: ENV, io: $stdout)
|
|
63
98
|
classes = tables.present? ? Array(tables).map { |table| resolve_table_class(table) } : table_classes
|
|
64
99
|
options = options_from_env(env)
|
|
65
100
|
|
|
66
|
-
results = classes.
|
|
67
|
-
|
|
101
|
+
results = classes.flat_map do |klass|
|
|
102
|
+
verify(klass, **options).each { |result| io.puts(result, "") }
|
|
68
103
|
end
|
|
69
104
|
|
|
70
|
-
|
|
105
|
+
by_table = results.select(&:mensa_table?).group_by(&:table_class)
|
|
106
|
+
others = results.reject(&:mensa_table?)
|
|
71
107
|
if results.size > 1
|
|
72
|
-
|
|
108
|
+
passed = by_table.count { |_table, table_results| table_results.all?(&:success?) }
|
|
109
|
+
io.puts ["#{passed}/#{by_table.size} tables passed", ("#{others.size} skipped, not a Mensa::Base subclass" if others.any?)].compact.join(", ")
|
|
73
110
|
end
|
|
74
111
|
results.all?(&:success?)
|
|
75
112
|
end
|
|
@@ -79,20 +116,26 @@ module Mensa
|
|
|
79
116
|
options[:user] = "User".constantize.find(env["USER_ID"]) if env["USER_ID"].present?
|
|
80
117
|
options[:account] = "Account".constantize.find(env["ACCOUNT_ID"]) if env["ACCOUNT_ID"].present?
|
|
81
118
|
options[:params] = JSON.parse(env["PARAMS"]) if env["PARAMS"].present?
|
|
119
|
+
options[:strict] = true if %w[1 true yes].include?(env["STRICT"].to_s.downcase)
|
|
82
120
|
options
|
|
83
121
|
end
|
|
84
122
|
end
|
|
85
123
|
|
|
86
|
-
|
|
124
|
+
# +strict+ makes warnings fail, +slow_query+ is the duration in seconds
|
|
125
|
+
# from which a query is reported (Mensa.config.verifier_slow_query).
|
|
126
|
+
# +defaults+ replaces the first context of Mensa.config.verifier_context.
|
|
127
|
+
def initialize(table, user: nil, account: nil, context: {}, params: nil, label: nil, strict: false, slow_query: nil, view_context: nil, defaults: nil, &setup)
|
|
87
128
|
@table_class = self.class.resolve_table_class(table)
|
|
88
|
-
defaults
|
|
129
|
+
defaults ||= self.class.contexts_for(@table_class).first
|
|
89
130
|
@user = user || defaults[:user]
|
|
90
131
|
@account = account || defaults[:account]
|
|
91
132
|
@context = (defaults[:context] || {}).merge(context || {})
|
|
92
133
|
@params = params || defaults[:params] || {}
|
|
134
|
+
@label = label || defaults[:label] || context_label
|
|
135
|
+
@slow_query = slow_query || defaults[:slow_query] || Mensa.config.verifier_slow_query
|
|
93
136
|
@view_context = view_context
|
|
94
137
|
@setup = setup
|
|
95
|
-
@result = Result.new(@table_class)
|
|
138
|
+
@result = Result.new(@table_class, strict: strict || defaults[:strict], label: @label)
|
|
96
139
|
end
|
|
97
140
|
|
|
98
141
|
def call
|
|
@@ -104,10 +147,15 @@ module Mensa
|
|
|
104
147
|
return result
|
|
105
148
|
end
|
|
106
149
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
150
|
+
@queries = []
|
|
151
|
+
@reported_n_plus_one = Set.new
|
|
152
|
+
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
|
-
|
|
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
|
|
175
|
-
# Quiet checks are only reported
|
|
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,
|
|
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
|
-
#
|
|
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 @
|
|
210
|
-
skip!("no records") if @
|
|
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
|
-
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
@
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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") {
|
|
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
|
-
|
|
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("
|
|
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
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
|