mensa 0.6.17 → 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 +34 -0
- data/Gemfile.lock +1 -1
- data/README.md +150 -3
- data/app/components/mensa/row_action/component.html.erb +1 -1
- data/app/helpers/mensa/application_helper.rb +11 -6
- data/app/jobs/mensa/export_job.rb +28 -10
- 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 +40 -0
- data/app/tables/mensa/scope.rb +3 -0
- data/app/tables/mensa/search.rb +2 -1
- data/lib/generators/mensa/templates/config/initializers/mensa.rb +4 -0
- data/lib/mensa/configuration.rb +9 -0
- data/lib/mensa/testing.rb +63 -0
- data/lib/mensa/verifier/result.rb +121 -0
- data/lib/mensa/verifier.rb +875 -0
- data/lib/mensa/version.rb +1 -1
- data/lib/mensa.rb +11 -0
- data/lib/tasks/mensa.rake +13 -0
- metadata +6 -2
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,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.19
|
|
4
|
+
|
|
5
|
+
- `Mensa::Verifier` checks a table against the database: scope, column SQL,
|
|
6
|
+
sorting, rendering, filters, search, CSV export, grouping/aggregates and
|
|
7
|
+
views. Run it with `bin/rails "mensa:verify[table]"` / `mensa:verify_all`,
|
|
8
|
+
or from tests after `require "mensa/testing"` with
|
|
9
|
+
`assert_mensa_table_valid` or `be_a_valid_mensa_table`. Set defaults for
|
|
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.
|
|
23
|
+
- `Mensa::ExportJob.write_csv(io, table, rows)` writes a table's CSV export and
|
|
24
|
+
`Mensa::ExportJob#generate_file(export)` generates an export's file without
|
|
25
|
+
saving it.
|
|
26
|
+
- Exports of tables with a `csv` render block no longer fail: the export job
|
|
27
|
+
renders with a view context of `config.base_controller`
|
|
28
|
+
(`Mensa.offline_view_context`).
|
|
29
|
+
- Row actions without an `icon` show their title instead of raising.
|
|
30
|
+
- An unknown helper in a render block raises `NoMethodError` instead of
|
|
31
|
+
`SystemStackError`. Only the app's `*_path`/`*_url` helpers are forwarded to
|
|
32
|
+
`main_app`.
|
|
33
|
+
- Search leaves out columns with a `having` filter (aggregates), which broke the
|
|
34
|
+
query.
|
|
35
|
+
- `is_duplicate` works for columns with a `having` filter.
|
|
36
|
+
|
|
3
37
|
## 0.6.16
|
|
4
38
|
|
|
5
39
|
- Custom filter `scope` blocks can take the operator as a second argument,
|
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
|
|
@@ -320,6 +358,115 @@ If you're just using cron, you can add the following to your crontab:
|
|
|
320
358
|
0 0 * * * rails runner "Mensa::RecurringExportsJob.perform_later"
|
|
321
359
|
```
|
|
322
360
|
|
|
361
|
+
### Verifying tables
|
|
362
|
+
|
|
363
|
+
`Mensa::Verifier` checks that everything a table configures works against your
|
|
364
|
+
real database, using the same code paths as requests do:
|
|
365
|
+
|
|
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.
|
|
370
|
+
- **Columns**: every column (internal ones too) can be selected and, when sortable,
|
|
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.
|
|
374
|
+
- **Filters**: every filterable column is applied with each of its operators, using
|
|
375
|
+
the first values of its `collection` or a value that fits the column's type, and
|
|
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.
|
|
378
|
+
- **Search**: basic and fuzzy search (when `pg_trgm` is enabled) for a word taken
|
|
379
|
+
from the records.
|
|
380
|
+
- **Rendering**: the `table` helper as your views call it, the rows and filter pills
|
|
381
|
+
as Mensa's controller renders them, the row link and every row action.
|
|
382
|
+
- **Exports**: `Mensa::ExportJob` generates the first page of the export, also for
|
|
383
|
+
each view and as a password protected zip. It needs the expected headers
|
|
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.
|
|
387
|
+
- **Grouping**: every groupable column and every aggregate, rendered with their
|
|
388
|
+
group headers. A `group_by` or `aggregates` naming a column that doesn't allow
|
|
389
|
+
it fails, because Mensa would silently ignore it.
|
|
390
|
+
- **Views**: system views load, render and only refer to existing columns. With
|
|
391
|
+
custom views enabled, saved views load too.
|
|
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
|
+
|
|
405
|
+
Each check runs on its own, so one failure doesn't hide the others. Everything runs
|
|
406
|
+
in a read-only transaction that is rolled back, with small limits, so it is safe to
|
|
407
|
+
run against staging. It never writes.
|
|
408
|
+
|
|
409
|
+
#### From rake
|
|
410
|
+
|
|
411
|
+
```sh
|
|
412
|
+
bin/rails "mensa:verify[inventories]" # or InventoriesTable
|
|
413
|
+
bin/rails mensa:verify_all # every table in app/tables
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
The tasks print a report (✓ passed, ✗ failed with the error and SQL, - skipped) and
|
|
417
|
+
exit non-zero when a check failed. Classes and modules that don't descend from
|
|
418
|
+
`Mensa::Base` (e.g. helpers in `app/tables`) are reported as skipped.
|
|
419
|
+
|
|
420
|
+
#### From tests
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
# test/test_helper.rb or spec/rails_helper.rb
|
|
424
|
+
require "mensa/testing"
|
|
425
|
+
|
|
426
|
+
# Minitest
|
|
427
|
+
assert_mensa_table_valid InventoriesTable, user: users(:admin)
|
|
428
|
+
|
|
429
|
+
# RSpec
|
|
430
|
+
expect(InventoriesTable).to be_a_valid_mensa_table(user: admin)
|
|
431
|
+
|
|
432
|
+
# Plain Ruby
|
|
433
|
+
result = Mensa::Verifier.new(InventoriesTable, user: admin).call
|
|
434
|
+
result.success? # => true
|
|
435
|
+
result.failures # => [#<struct Mensa::Verifier::Check section: :filters, subject: :status, ...>]
|
|
436
|
+
puts result # the report, also the failure message of the helpers
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
#### Context
|
|
440
|
+
|
|
441
|
+
Tables often depend on the request: `Current.user`, policy scopes, or the table's
|
|
442
|
+
params. Give the verifier that context:
|
|
443
|
+
|
|
444
|
+
- `user:` and `account:` set `Current.user` and `Current.account` while verifying.
|
|
445
|
+
- `context: {organization: org}` sets any other `Current` attributes.
|
|
446
|
+
- `params: {customer_id: 1}` sets the table's params (see "Table params").
|
|
447
|
+
- A block is called with the verifier before the checks, for any other setup.
|
|
448
|
+
- `label: "admin"` names the context in the report.
|
|
449
|
+
|
|
450
|
+
Html render blocks run in a view context of your `ApplicationController` (see
|
|
451
|
+
`config.base_controller`), so url helpers and your helpers work.
|
|
452
|
+
|
|
453
|
+
From rake, set `USER_ID=` (finds a `User`), `ACCOUNT_ID=` (finds an `Account`) and
|
|
454
|
+
`PARAMS='{"customer_id":1}'` (JSON), or configure defaults for every table in
|
|
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:
|
|
457
|
+
|
|
458
|
+
```ruby
|
|
459
|
+
Mensa.setup do |config|
|
|
460
|
+
config.verifier_context = ->(table_class) {
|
|
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
|
+
]
|
|
466
|
+
}
|
|
467
|
+
end
|
|
468
|
+
```
|
|
469
|
+
|
|
323
470
|
## Contributing
|
|
324
471
|
|
|
325
472
|
```
|
|
@@ -6,16 +6,21 @@ module Mensa
|
|
|
6
6
|
render(component)
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
+
# The app's url helpers, also inside Mensa's engine views
|
|
9
10
|
def respond_to_missing?(method, include_private = false)
|
|
10
|
-
|
|
11
|
-
true
|
|
12
|
-
else
|
|
13
|
-
super
|
|
14
|
-
end
|
|
11
|
+
main_app_url_helper?(method) || super
|
|
15
12
|
end
|
|
16
13
|
|
|
17
14
|
def method_missing(method, *args, **kwargs, &block)
|
|
18
|
-
|
|
15
|
+
return super unless main_app_url_helper?(method)
|
|
16
|
+
|
|
17
|
+
main_app.public_send(method, *args, **kwargs, &block)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def main_app_url_helper?(method)
|
|
23
|
+
method.to_s.end_with?("_url", "_path") && main_app.respond_to?(method)
|
|
19
24
|
end
|
|
20
25
|
end
|
|
21
26
|
end
|
|
@@ -9,20 +9,30 @@ module Mensa
|
|
|
9
9
|
class ExportJob < ApplicationJob
|
|
10
10
|
queue_as :default
|
|
11
11
|
|
|
12
|
+
# Writes the CSV for +rows+ (Mensa::Row) of +table+ to +io+: a header row
|
|
13
|
+
# with the display column names, then one line per row.
|
|
14
|
+
def self.write_csv(io, table, rows)
|
|
15
|
+
csv = CSV.new(io)
|
|
16
|
+
csv << table.display_columns.map(&:name)
|
|
17
|
+
rows.each do |row|
|
|
18
|
+
csv << table.display_columns.map { |column| Mensa::Cell.new(row: row, column: column).render(:csv) }
|
|
19
|
+
end
|
|
20
|
+
csv.flush
|
|
21
|
+
io
|
|
22
|
+
end
|
|
23
|
+
|
|
12
24
|
def perform(export)
|
|
13
25
|
return unless export
|
|
14
26
|
|
|
15
27
|
export.update(status: "processing")
|
|
16
28
|
Mensa.config.run_callback(:export_started, export)
|
|
17
29
|
|
|
18
|
-
|
|
19
|
-
unless
|
|
30
|
+
tempfile, filename, content_type = generate_file(export)
|
|
31
|
+
unless tempfile
|
|
20
32
|
finalize(export, status: "failed")
|
|
21
33
|
return
|
|
22
34
|
end
|
|
23
35
|
|
|
24
|
-
tempfile, filename, content_type = generate(table, export)
|
|
25
|
-
|
|
26
36
|
export.asset.purge if export.asset.attached?
|
|
27
37
|
export.asset.attach(io: tempfile, filename: filename, content_type: content_type)
|
|
28
38
|
finalize(export, status: "completed", filename: filename)
|
|
@@ -36,6 +46,16 @@ module Mensa
|
|
|
36
46
|
tempfile&.close!
|
|
37
47
|
end
|
|
38
48
|
|
|
49
|
+
# Builds the table for +export+ and generates its file. Returns
|
|
50
|
+
# [tempfile, filename, content_type], or nil when the table isn't
|
|
51
|
+
# exportable. Doesn't touch the export's record or asset.
|
|
52
|
+
def generate_file(export)
|
|
53
|
+
table = build_table(export)
|
|
54
|
+
return unless table.exportable?
|
|
55
|
+
|
|
56
|
+
generate(table, export)
|
|
57
|
+
end
|
|
58
|
+
|
|
39
59
|
private
|
|
40
60
|
|
|
41
61
|
# Rebuilds the table the export was requested for, layering the view
|
|
@@ -54,6 +74,8 @@ module Mensa
|
|
|
54
74
|
table = Mensa.for_name(export.table_name, config)
|
|
55
75
|
table.request = ActionDispatch::Request.new({})
|
|
56
76
|
table.request.set_header("action_dispatch.request.query_parameters", {"page" => config[:page]})
|
|
77
|
+
# Render blocks (e.g. csv) run in a view context
|
|
78
|
+
table.original_view_context = Mensa.offline_view_context
|
|
57
79
|
table
|
|
58
80
|
end
|
|
59
81
|
|
|
@@ -78,12 +100,8 @@ module Mensa
|
|
|
78
100
|
# with programmatic consumers.
|
|
79
101
|
tempfile.write("\uFEFF") if export.format == "csv_excel"
|
|
80
102
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
export_rows(table, export).each do |row|
|
|
84
|
-
csv << table.display_columns.map { |column| Mensa::Cell.new(row: row, column: column).render(:csv) }
|
|
85
|
-
end
|
|
86
|
-
csv.close
|
|
103
|
+
self.class.write_csv(tempfile, table, export_rows(table, export))
|
|
104
|
+
tempfile.close
|
|
87
105
|
tempfile.open
|
|
88
106
|
tempfile.binmode
|
|
89
107
|
tempfile.rewind
|
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?
|
|
@@ -211,6 +214,7 @@ module Mensa
|
|
|
211
214
|
def apply_duplicate_filter(record_scope)
|
|
212
215
|
column_sql = column.attribute_for_condition.to_s
|
|
213
216
|
base_scope = record_scope.except(:select, :order, :limit, :offset)
|
|
217
|
+
return apply_duplicate_having_filter(record_scope, base_scope, column_sql) if having?
|
|
214
218
|
|
|
215
219
|
duplicate_values_scope = base_scope
|
|
216
220
|
.where("#{column_sql} IS NOT NULL")
|
|
@@ -223,6 +227,42 @@ module Mensa
|
|
|
223
227
|
.where("#{column_sql} IN (#{duplicate_values_scope.to_sql})")
|
|
224
228
|
end
|
|
225
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
|
+
|
|
253
|
+
# Aggregates are only known per group, so the duplicate values come from
|
|
254
|
+
# a subquery of the grouped rows and are compared in the HAVING
|
|
255
|
+
def apply_duplicate_having_filter(record_scope, base_scope, column_sql)
|
|
256
|
+
duplicate_values_scope = base_scope.model.unscoped
|
|
257
|
+
.from(base_scope.select(Arel.sql("#{column_sql} AS mensa_duplicate_value")), :mensa_values)
|
|
258
|
+
.where("mensa_values.mensa_duplicate_value IS NOT NULL")
|
|
259
|
+
.group("mensa_values.mensa_duplicate_value")
|
|
260
|
+
.having("COUNT(*) > 1")
|
|
261
|
+
.select("mensa_values.mensa_duplicate_value")
|
|
262
|
+
|
|
263
|
+
record_scope.having("#{column_sql} IN (#{duplicate_values_scope.to_sql})")
|
|
264
|
+
end
|
|
265
|
+
|
|
226
266
|
# Unused at the moment
|
|
227
267
|
def normalize(query)
|
|
228
268
|
query
|
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/app/tables/mensa/search.rb
CHANGED
|
@@ -10,7 +10,8 @@ module Mensa
|
|
|
10
10
|
return record_scope if query.blank?
|
|
11
11
|
return record_scope unless record_scope.is_a?(ActiveRecord::Relation)
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# Aggregates (filter having) can't be in a WHERE
|
|
14
|
+
searchable_attributes = columns.reject { |column| column.filter&.having? }.filter_map(&:attribute_for_condition).uniq
|
|
14
15
|
return record_scope if searchable_attributes.empty?
|
|
15
16
|
|
|
16
17
|
@search_order_clause = nil
|
|
@@ -7,6 +7,10 @@ Mensa.setup do |config|
|
|
|
7
7
|
end
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
# Context for `rails mensa:verify` and the test helpers (user:, account:,
|
|
11
|
+
# context:, params:)
|
|
12
|
+
# config.verifier_context = ->(table_class) { {user: User.find_by!(email: "admin@example.com")} }
|
|
13
|
+
|
|
10
14
|
# Override icons in use
|
|
11
15
|
config.icons = {
|
|
12
16
|
add_filter_trigger: "fa-solid fa-circle-plus",
|
data/lib/mensa/configuration.rb
CHANGED
|
@@ -144,6 +144,15 @@ module Mensa
|
|
|
144
144
|
option :row_actions_position, default: :back
|
|
145
145
|
# It's either :basic or :fuzzy, for fuzzy search you need to have `pg_trgm` extension installed
|
|
146
146
|
option :search, default: :basic
|
|
147
|
+
# Default options for Mensa::Verifier (and so `rails mensa:verify`), called
|
|
148
|
+
# with the table class. Return a hash with any of user:, account:,
|
|
149
|
+
# context: (Current attributes), params: and label:, or an array of them
|
|
150
|
+
# to verify the table in each of those contexts, e.g.
|
|
151
|
+
#
|
|
152
|
+
# config.verifier_context = ->(table_class) { {user: User.find_by!(email: "admin@example.com")} }
|
|
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
|
|
147
156
|
|
|
148
157
|
def initialize
|
|
149
158
|
set_defaults!
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mensa/verifier"
|
|
4
|
+
|
|
5
|
+
module Mensa
|
|
6
|
+
# Test helpers on top of Mensa::Verifier, opt in with `require "mensa/testing"`.
|
|
7
|
+
#
|
|
8
|
+
# assert_mensa_table_valid UsersTable, user: users(:admin) # Minitest
|
|
9
|
+
# expect(UsersTable).to be_a_valid_mensa_table(user: admin) # RSpec
|
|
10
|
+
#
|
|
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.
|
|
14
|
+
module Testing
|
|
15
|
+
module Assertions
|
|
16
|
+
def assert_mensa_table_valid(table, message = nil, **options, &setup)
|
|
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
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class ValidTableMatcher
|
|
24
|
+
attr_reader :results
|
|
25
|
+
|
|
26
|
+
def initialize(**options, &setup)
|
|
27
|
+
@options = options
|
|
28
|
+
@setup = setup
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def matches?(table)
|
|
32
|
+
@results = Mensa::Verifier.verify(table, **@options, &@setup)
|
|
33
|
+
@results.all?(&:success?)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def failure_message
|
|
37
|
+
results.reject(&:success?).join("\n\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def failure_message_when_negated
|
|
41
|
+
"expected #{results.first.table_class} not to be a valid Mensa table, but all its checks passed"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def description
|
|
45
|
+
"be a valid Mensa table"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
module Matchers
|
|
50
|
+
def be_a_valid_mensa_table(**options, &setup)
|
|
51
|
+
ValidTableMatcher.new(**options, &setup)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if defined?(Minitest::Test)
|
|
58
|
+
Minitest::Test.include(Mensa::Testing::Assertions)
|
|
59
|
+
else
|
|
60
|
+
ActiveSupport.on_load(:active_support_test_case) { include Mensa::Testing::Assertions }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
RSpec.configure { |config| config.include(Mensa::Testing::Matchers) } if defined?(RSpec) && RSpec.respond_to?(:configure)
|