mensa 0.6.17 → 0.6.18
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 +22 -0
- data/Gemfile.lock +1 -1
- data/README.md +88 -0
- data/app/components/mensa/row_action/component.html.erb +2 -3
- data/app/helpers/mensa/application_helper.rb +11 -6
- data/app/jobs/mensa/export_job.rb +28 -10
- data/app/tables/mensa/filter.rb +14 -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 +6 -0
- data/lib/mensa/testing.rb +62 -0
- data/lib/mensa/verifier/result.rb +100 -0
- data/lib/mensa/verifier.rb +620 -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: a9bbb252521ea00a7d6197e9ae12208d121ec069652b45b729095609a596bda2
|
|
4
|
+
data.tar.gz: 92345f45e5b1336f1e101c2c64d9736c58637cb9792c333cc21f4620dadabf94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b97589e71ee52505369eb9cac3af5e49672c8af62d076d2f17465ea76b2c556ca2c5adbede3af5ad850db502bd47271e031c96a5292eb2ef191818e24e60435
|
|
7
|
+
data.tar.gz: ba31a706fa85f69d30a1b4bfcb17d2c6674df561daf8379341b3f7dc31d21bd10ecc94fc36192f0f2302e602556898c72d9b3d203e330f2a60494c3741b004f2
|
data/.shipkit.yml
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
---
|
|
2
|
-
previous_version: 0.6.
|
|
2
|
+
previous_version: 0.6.17
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
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
|
+
- `Mensa::ExportJob.write_csv(io, table, rows)` writes a table's CSV export and
|
|
12
|
+
`Mensa::ExportJob#generate_file(export)` generates an export's file without
|
|
13
|
+
saving it.
|
|
14
|
+
- Exports of tables with a `csv` render block no longer fail: the export job
|
|
15
|
+
renders with a view context of `config.base_controller`
|
|
16
|
+
(`Mensa.offline_view_context`).
|
|
17
|
+
- Row actions without an `icon` show their title instead of raising.
|
|
18
|
+
- An unknown helper in a render block raises `NoMethodError` instead of
|
|
19
|
+
`SystemStackError`. Only the app's `*_path`/`*_url` helpers are forwarded to
|
|
20
|
+
`main_app`.
|
|
21
|
+
- Search leaves out columns with a `having` filter (aggregates), which broke the
|
|
22
|
+
query.
|
|
23
|
+
- `is_duplicate` works for columns with a `having` filter.
|
|
24
|
+
|
|
3
25
|
## 0.6.16
|
|
4
26
|
|
|
5
27
|
- Custom filter `scope` blocks can take the operator as a second argument,
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -320,6 +320,94 @@ If you're just using cron, you can add the following to your crontab:
|
|
|
320
320
|
0 0 * * * rails runner "Mensa::RecurringExportsJob.perform_later"
|
|
321
321
|
```
|
|
322
322
|
|
|
323
|
+
### Verifying tables
|
|
324
|
+
|
|
325
|
+
`Mensa::Verifier` checks that everything a table configures works against your
|
|
326
|
+
real database, using the same code paths as requests do:
|
|
327
|
+
|
|
328
|
+
- **Loads**: the table builds, its SQL builds and the first two pages load. A default
|
|
329
|
+
`order` or `column_order` naming an unknown column fails.
|
|
330
|
+
- **Columns**: every column (internal ones too) can be selected and, when sortable,
|
|
331
|
+
sorted both ways. Its html and csv renders run for a few records. Columns hidden
|
|
332
|
+
by a `visible` lambda are reported as skipped.
|
|
333
|
+
- **Filters**: every filterable column is applied with each of its operators, using
|
|
334
|
+
the first values of its `collection` or a value that fits the column's type, and
|
|
335
|
+
the filter pill's text is built. `is_current` is skipped when `Current` has no
|
|
336
|
+
value for the column.
|
|
337
|
+
- **Search**: basic and fuzzy search (when `pg_trgm` is enabled) for a word taken
|
|
338
|
+
from the records.
|
|
339
|
+
- **Rendering**: the `table` helper as your views call it, the rows and filter pills
|
|
340
|
+
as Mensa's controller renders them, the row link and every row action.
|
|
341
|
+
- **Exports**: `Mensa::ExportJob` generates the first page of the export, also for
|
|
342
|
+
each view and as a password protected zip. It needs the expected headers
|
|
343
|
+
(skipped for `exportable false`).
|
|
344
|
+
- **Grouping**: every groupable column and every aggregate, rendered with their
|
|
345
|
+
group headers. A `group_by` or `aggregates` naming a column that doesn't allow
|
|
346
|
+
it fails, because Mensa would silently ignore it.
|
|
347
|
+
- **Views**: system views load, render and only refer to existing columns. With
|
|
348
|
+
custom views enabled, saved views load too.
|
|
349
|
+
|
|
350
|
+
Each check runs on its own, so one failure doesn't hide the others. Everything runs
|
|
351
|
+
in a read-only transaction that is rolled back, with small limits, so it is safe to
|
|
352
|
+
run against staging. It never writes.
|
|
353
|
+
|
|
354
|
+
#### From rake
|
|
355
|
+
|
|
356
|
+
```sh
|
|
357
|
+
bin/rails "mensa:verify[inventories]" # or InventoriesTable
|
|
358
|
+
bin/rails mensa:verify_all # every table in app/tables
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The tasks print a report (✓ passed, ✗ failed with the error and SQL, - skipped) and
|
|
362
|
+
exit non-zero when a check failed. Classes and modules that don't descend from
|
|
363
|
+
`Mensa::Base` (e.g. helpers in `app/tables`) are reported as skipped.
|
|
364
|
+
|
|
365
|
+
#### From tests
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
# test/test_helper.rb or spec/rails_helper.rb
|
|
369
|
+
require "mensa/testing"
|
|
370
|
+
|
|
371
|
+
# Minitest
|
|
372
|
+
assert_mensa_table_valid InventoriesTable, user: users(:admin)
|
|
373
|
+
|
|
374
|
+
# RSpec
|
|
375
|
+
expect(InventoriesTable).to be_a_valid_mensa_table(user: admin)
|
|
376
|
+
|
|
377
|
+
# Plain Ruby
|
|
378
|
+
result = Mensa::Verifier.new(InventoriesTable, user: admin).call
|
|
379
|
+
result.success? # => true
|
|
380
|
+
result.failures # => [#<struct Mensa::Verifier::Check section: :filters, subject: :status, ...>]
|
|
381
|
+
puts result # the report, also the failure message of the helpers
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
#### Context
|
|
385
|
+
|
|
386
|
+
Tables often depend on the request: `Current.user`, policy scopes, or the table's
|
|
387
|
+
params. Give the verifier that context:
|
|
388
|
+
|
|
389
|
+
- `user:` and `account:` set `Current.user` and `Current.account` while verifying.
|
|
390
|
+
- `context: {organization: org}` sets any other `Current` attributes.
|
|
391
|
+
- `params: {customer_id: 1}` sets the table's params (see "Table params").
|
|
392
|
+
- A block is called with the verifier before the checks, for any other setup.
|
|
393
|
+
|
|
394
|
+
Html render blocks run in a view context of your `ApplicationController` (see
|
|
395
|
+
`config.base_controller`), so url helpers and your helpers work.
|
|
396
|
+
|
|
397
|
+
From rake, set `USER_ID=` (finds a `User`), `ACCOUNT_ID=` (finds an `Account`) and
|
|
398
|
+
`PARAMS='{"customer_id":1}'` (JSON), or configure defaults for every table in
|
|
399
|
+
your initializer. The options you pass yourself override these defaults:
|
|
400
|
+
|
|
401
|
+
```ruby
|
|
402
|
+
Mensa.setup do |config|
|
|
403
|
+
config.verifier_context = ->(table_class) {
|
|
404
|
+
options = {user: User.find_by!(email: "admin@example.com")}
|
|
405
|
+
options[:params] = {customer_id: Customer.first.id} if table_class == CustomerUsersTable
|
|
406
|
+
options
|
|
407
|
+
}
|
|
408
|
+
end
|
|
409
|
+
```
|
|
410
|
+
|
|
323
411
|
## Contributing
|
|
324
412
|
|
|
325
413
|
```
|
|
@@ -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/filter.rb
CHANGED
|
@@ -211,6 +211,7 @@ module Mensa
|
|
|
211
211
|
def apply_duplicate_filter(record_scope)
|
|
212
212
|
column_sql = column.attribute_for_condition.to_s
|
|
213
213
|
base_scope = record_scope.except(:select, :order, :limit, :offset)
|
|
214
|
+
return apply_duplicate_having_filter(record_scope, base_scope, column_sql) if having?
|
|
214
215
|
|
|
215
216
|
duplicate_values_scope = base_scope
|
|
216
217
|
.where("#{column_sql} IS NOT NULL")
|
|
@@ -223,6 +224,19 @@ module Mensa
|
|
|
223
224
|
.where("#{column_sql} IN (#{duplicate_values_scope.to_sql})")
|
|
224
225
|
end
|
|
225
226
|
|
|
227
|
+
# Aggregates are only known per group, so the duplicate values come from
|
|
228
|
+
# a subquery of the grouped rows and are compared in the HAVING
|
|
229
|
+
def apply_duplicate_having_filter(record_scope, base_scope, column_sql)
|
|
230
|
+
duplicate_values_scope = base_scope.model.unscoped
|
|
231
|
+
.from(base_scope.select(Arel.sql("#{column_sql} AS mensa_duplicate_value")), :mensa_values)
|
|
232
|
+
.where("mensa_values.mensa_duplicate_value IS NOT NULL")
|
|
233
|
+
.group("mensa_values.mensa_duplicate_value")
|
|
234
|
+
.having("COUNT(*) > 1")
|
|
235
|
+
.select("mensa_values.mensa_duplicate_value")
|
|
236
|
+
|
|
237
|
+
record_scope.having("#{column_sql} IN (#{duplicate_values_scope.to_sql})")
|
|
238
|
+
end
|
|
239
|
+
|
|
226
240
|
# Unused at the moment
|
|
227
241
|
def normalize(query)
|
|
228
242
|
query
|
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,12 @@ 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) and params:, e.g.
|
|
150
|
+
#
|
|
151
|
+
# config.verifier_context = ->(table_class) { {user: User.find_by!(email: "admin@example.com")} }
|
|
152
|
+
option :verifier_context, default: nil
|
|
147
153
|
|
|
148
154
|
def initialize
|
|
149
155
|
set_defaults!
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
# and fail with its report.
|
|
13
|
+
module Testing
|
|
14
|
+
module Assertions
|
|
15
|
+
def assert_mensa_table_valid(table, message = nil, **options, &setup)
|
|
16
|
+
result = Mensa::Verifier.new(table, **options, &setup).call
|
|
17
|
+
assert result.success?, -> { [message, result.to_s].compact.join("\n\n") }
|
|
18
|
+
result
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class ValidTableMatcher
|
|
23
|
+
attr_reader :result
|
|
24
|
+
|
|
25
|
+
def initialize(**options, &setup)
|
|
26
|
+
@options = options
|
|
27
|
+
@setup = setup
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def matches?(table)
|
|
31
|
+
@result = Mensa::Verifier.new(table, **@options, &@setup).call
|
|
32
|
+
@result.success?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def failure_message
|
|
36
|
+
result.to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def failure_message_when_negated
|
|
40
|
+
"expected #{result.table_class} not to be a valid Mensa table, but all its checks passed"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def description
|
|
44
|
+
"be a valid Mensa table"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
module Matchers
|
|
49
|
+
def be_a_valid_mensa_table(**options, &setup)
|
|
50
|
+
ValidTableMatcher.new(**options, &setup)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if defined?(Minitest::Test)
|
|
57
|
+
Minitest::Test.include(Mensa::Testing::Assertions)
|
|
58
|
+
else
|
|
59
|
+
ActiveSupport.on_load(:active_support_test_case) { include Mensa::Testing::Assertions }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
RSpec.configure { |config| config.include(Mensa::Testing::Matchers) } if defined?(RSpec) && RSpec.respond_to?(:configure)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mensa
|
|
4
|
+
class Verifier
|
|
5
|
+
# One verified aspect of a table, e.g. sorting a column descending.
|
|
6
|
+
#
|
|
7
|
+
# +section+ groups checks in the report (:loads, :columns, :filters, ...),
|
|
8
|
+
# +subject+ is the column, filter or view it relates to.
|
|
9
|
+
Check = Struct.new(:section, :subject, :name, :status, :message, :sql, :error) do
|
|
10
|
+
def passed?
|
|
11
|
+
status == :passed
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def failed?
|
|
15
|
+
status == :failed
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def skipped?
|
|
19
|
+
status == :skipped
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def label
|
|
23
|
+
[subject, name].compact.join(": ")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Result
|
|
28
|
+
SECTIONS = {
|
|
29
|
+
loads: "Loads",
|
|
30
|
+
columns: "Columns",
|
|
31
|
+
filters: "Filters",
|
|
32
|
+
search: "Search",
|
|
33
|
+
render: "Rendering",
|
|
34
|
+
exports: "Exports",
|
|
35
|
+
grouping: "Grouping",
|
|
36
|
+
views: "Views"
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
SYMBOLS = {passed: "✓", failed: "✗", skipped: "-"}.freeze
|
|
40
|
+
|
|
41
|
+
attr_reader :table_class, :checks
|
|
42
|
+
|
|
43
|
+
def initialize(table_class)
|
|
44
|
+
@table_class = table_class
|
|
45
|
+
@checks = []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# False for a class that isn't a Mensa::Base subclass, which is skipped
|
|
49
|
+
def mensa_table?
|
|
50
|
+
table_class.is_a?(Class) && table_class < Mensa::Base
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def success?
|
|
54
|
+
failures.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def failures
|
|
58
|
+
checks.select(&:failed?)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def passes
|
|
62
|
+
checks.select(&:passed?)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def skips
|
|
66
|
+
checks.select(&:skipped?)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def summary
|
|
70
|
+
"#{passes.size} passed, #{failures.size} failed, #{skips.size} skipped"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_s
|
|
74
|
+
return "#{table_class}\n - skipped: #{checks.first&.message}" unless mensa_table?
|
|
75
|
+
|
|
76
|
+
lines = [(table_class.name || table_class).to_s]
|
|
77
|
+
|
|
78
|
+
checks.group_by(&:section).each do |section, section_checks|
|
|
79
|
+
lines << " #{SECTIONS.fetch(section, section.to_s.humanize)}"
|
|
80
|
+
section_checks.each do |check|
|
|
81
|
+
lines << " #{SYMBOLS[check.status]} #{check.label}#{" (skipped: #{check.message})" if check.skipped?}"
|
|
82
|
+
next unless check.failed?
|
|
83
|
+
|
|
84
|
+
check.message.to_s.each_line { |line| lines << " #{line.chomp}" }
|
|
85
|
+
lines << " SQL: #{check.sql}" if check.sql.present?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
lines << ""
|
|
90
|
+
lines << "#{success? ? "OK" : "FAILED"}: #{summary}"
|
|
91
|
+
lines.join("\n")
|
|
92
|
+
end
|
|
93
|
+
alias_method :report, :to_s
|
|
94
|
+
|
|
95
|
+
def inspect
|
|
96
|
+
"#<#{self.class.name} #{table_class} #{summary}>"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "json"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "mensa/verifier/result"
|
|
7
|
+
|
|
8
|
+
module Mensa
|
|
9
|
+
# Checks that everything a table configures works against the real database:
|
|
10
|
+
# its scope, every column's SQL, sorting, rendering, filters, search, the CSV
|
|
11
|
+
# export, grouping/aggregates and its views.
|
|
12
|
+
#
|
|
13
|
+
# result = Mensa::Verifier.new(UsersTable, user: User.first).call
|
|
14
|
+
# result.success? # => true
|
|
15
|
+
# puts result # per-check report
|
|
16
|
+
#
|
|
17
|
+
# Checks go through the same code paths as requests do (filtered_scope,
|
|
18
|
+
# ordered_scope, Mensa::Cell, Mensa::ExportJob.write_csv, ...). Everything
|
|
19
|
+
# runs in a read-only transaction that is rolled back, and every check in its
|
|
20
|
+
# own savepoint so one failing query doesn't stop the others.
|
|
21
|
+
#
|
|
22
|
+
# Tables often depend on request context, give it with user:, account: and
|
|
23
|
+
# context: (all set on Current while verifying), params: (the table's
|
|
24
|
+
# params) or a block, which is called with the verifier before the checks.
|
|
25
|
+
# Mensa.config.verifier_context supplies defaults for these.
|
|
26
|
+
class Verifier
|
|
27
|
+
# Records loaded for record-level checks, and per query
|
|
28
|
+
SAMPLE_SIZE = 5
|
|
29
|
+
|
|
30
|
+
class Skip < StandardError; end
|
|
31
|
+
|
|
32
|
+
# A problem the verifier found itself, reported without the class name
|
|
33
|
+
class Failure < StandardError; end
|
|
34
|
+
|
|
35
|
+
attr_reader :table_class, :user, :account, :context, :params, :result
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Accepts a table class, its name (e.g. "UsersTable") or Mensa's table
|
|
39
|
+
# name (e.g. "users", "admin_users").
|
|
40
|
+
def resolve_table_class(table)
|
|
41
|
+
return table if table.is_a?(Module)
|
|
42
|
+
|
|
43
|
+
name = table.to_s
|
|
44
|
+
(name.safe_constantize if name.match?(/\A[A-Z]/)) || Mensa.class_for_name(name.underscore.delete_suffix("_table"))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The classes and modules defined by the files under +root+ (the app's
|
|
48
|
+
# app/tables), including any that aren't Mensa tables, which are
|
|
49
|
+
# reported as skipped.
|
|
50
|
+
def table_classes(root = Rails.root.join("app/tables"))
|
|
51
|
+
loader = Rails.autoloaders.main
|
|
52
|
+
|
|
53
|
+
Dir[File.join(root.to_s, "**/*.rb")].filter_map { |path|
|
|
54
|
+
loader.cpath_expected_at(path)&.safe_constantize
|
|
55
|
+
}.uniq.sort_by(&:name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Verifies the tables (all under app/tables when none are given), prints
|
|
59
|
+
# their reports to +io+ and returns true when all of them passed.
|
|
60
|
+
# Context comes from USER_ID, ACCOUNT_ID and PARAMS (JSON) in +env+, used
|
|
61
|
+
# by the mensa:verify rake tasks.
|
|
62
|
+
def run(tables = nil, env: ENV, io: $stdout)
|
|
63
|
+
classes = tables.present? ? Array(tables).map { |table| resolve_table_class(table) } : table_classes
|
|
64
|
+
options = options_from_env(env)
|
|
65
|
+
|
|
66
|
+
results = classes.map do |klass|
|
|
67
|
+
new(klass, **options).call.tap { |result| io.puts(result, "") }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
tables, others = results.partition(&:mensa_table?)
|
|
71
|
+
if results.size > 1
|
|
72
|
+
io.puts ["#{tables.count(&:success?)}/#{tables.size} tables passed", ("#{others.size} skipped, not a Mensa::Base subclass" if others.any?)].compact.join(", ")
|
|
73
|
+
end
|
|
74
|
+
results.all?(&:success?)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def options_from_env(env)
|
|
78
|
+
options = {}
|
|
79
|
+
options[:user] = "User".constantize.find(env["USER_ID"]) if env["USER_ID"].present?
|
|
80
|
+
options[:account] = "Account".constantize.find(env["ACCOUNT_ID"]) if env["ACCOUNT_ID"].present?
|
|
81
|
+
options[:params] = JSON.parse(env["PARAMS"]) if env["PARAMS"].present?
|
|
82
|
+
options
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def initialize(table, user: nil, account: nil, context: {}, params: nil, view_context: nil, &setup)
|
|
87
|
+
@table_class = self.class.resolve_table_class(table)
|
|
88
|
+
defaults = default_options
|
|
89
|
+
@user = user || defaults[:user]
|
|
90
|
+
@account = account || defaults[:account]
|
|
91
|
+
@context = (defaults[:context] || {}).merge(context || {})
|
|
92
|
+
@params = params || defaults[:params] || {}
|
|
93
|
+
@view_context = view_context
|
|
94
|
+
@setup = setup
|
|
95
|
+
@result = Result.new(@table_class)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def call
|
|
99
|
+
return result if @called
|
|
100
|
+
|
|
101
|
+
@called = true
|
|
102
|
+
unless result.mensa_table?
|
|
103
|
+
result.checks << Check.new(section: :loads, name: "Mensa table", status: :skipped, message: "#{table_class} does not descend from Mensa::Base")
|
|
104
|
+
return result
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
read_only_transaction do
|
|
108
|
+
with_current_attributes do
|
|
109
|
+
@setup&.call(self)
|
|
110
|
+
run_checks
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
result
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Mensa's name for the table, as used in its routes and saved views
|
|
117
|
+
def table_name
|
|
118
|
+
@table_name ||= table_class.name.underscore.delete_suffix("_table").tr("/", "_")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Builds the table like a request would, with the request config on top
|
|
122
|
+
def build_table(config = {})
|
|
123
|
+
table = table_class.new({params: params}.merge(config))
|
|
124
|
+
table.name = table_name
|
|
125
|
+
table.request = request
|
|
126
|
+
table.original_view_context = view_context
|
|
127
|
+
table
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def request
|
|
131
|
+
@request ||= ActionDispatch::Request.new(Rack::MockRequest.env_for("http://#{default_host}/"))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A view context of the app's base controller, so html render blocks have
|
|
135
|
+
# the app's helpers and url helpers.
|
|
136
|
+
def view_context
|
|
137
|
+
@view_context ||= Mensa.offline_view_context(request)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
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
|
+
def default_host
|
|
148
|
+
Rails.application.routes.default_url_options[:host] || "localhost"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def run_checks
|
|
152
|
+
@table = check(:loads, nil, "build table") { build_table }
|
|
153
|
+
unless @table
|
|
154
|
+
result.checks << Check.new(section: :loads, name: "remaining checks", status: :skipped, message: "the table could not be built")
|
|
155
|
+
return
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A table of its own, a relation can't be counted after its to_sql
|
|
159
|
+
check(:loads, nil, "build SQL") { |c| c.sql = build_table.selected_scope.to_sql }
|
|
160
|
+
@rows = check(:loads, nil, "load first page") { @table.rows }
|
|
161
|
+
verify_second_page
|
|
162
|
+
verify_defaults
|
|
163
|
+
|
|
164
|
+
verify_columns
|
|
165
|
+
verify_filters
|
|
166
|
+
verify_search
|
|
167
|
+
verify_rendering
|
|
168
|
+
verify_exports
|
|
169
|
+
verify_grouping
|
|
170
|
+
verify_views
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Runs the block as one check in its own savepoint and records the
|
|
174
|
+
# outcome. Returns the block's value, nil when the check didn't pass.
|
|
175
|
+
# Quiet checks are only reported when they fail.
|
|
176
|
+
def check(section, subject, name, quiet: false)
|
|
177
|
+
c = Check.new(section: section, subject: subject, name: name)
|
|
178
|
+
value = nil
|
|
179
|
+
savepoint { value = yield(c) }
|
|
180
|
+
c.status = :passed
|
|
181
|
+
value
|
|
182
|
+
rescue Skip => e
|
|
183
|
+
c.status = :skipped
|
|
184
|
+
c.message = e.message
|
|
185
|
+
nil
|
|
186
|
+
rescue StandardError, NotImplementedError => e
|
|
187
|
+
c.status = :failed
|
|
188
|
+
c.error = e
|
|
189
|
+
c.message = e.is_a?(Failure) ? e.message : "#{e.class}: #{e.message}"
|
|
190
|
+
c.sql = e.sql if e.respond_to?(:sql) && e.sql.present?
|
|
191
|
+
nil
|
|
192
|
+
ensure
|
|
193
|
+
result.checks << c unless quiet && c.passed?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def skip!(reason)
|
|
197
|
+
raise Skip, reason
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def savepoint
|
|
201
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
202
|
+
yield
|
|
203
|
+
raise ActiveRecord::Rollback
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Record-level checks need loaded records
|
|
208
|
+
def sample_rows!
|
|
209
|
+
skip!("records could not be loaded") if @rows.nil?
|
|
210
|
+
skip!("no records") if @rows.empty?
|
|
211
|
+
|
|
212
|
+
@rows.first(SAMPLE_SIZE)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def verify_second_page
|
|
216
|
+
check(:loads, nil, "load page 2") do
|
|
217
|
+
skip!("records could not be loaded") if @rows.nil?
|
|
218
|
+
skip!("only one page") if @table.pagy_details.pages < 2
|
|
219
|
+
|
|
220
|
+
table = build_table
|
|
221
|
+
table.request = ActionDispatch::Request.new(Rack::MockRequest.env_for("http://#{default_host}/?page=2"))
|
|
222
|
+
table.rows
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Defaults on the table itself that Mensa would silently ignore
|
|
227
|
+
def verify_defaults
|
|
228
|
+
config = @table.config.slice(:order, :column_order).compact_blank
|
|
229
|
+
return if config.empty?
|
|
230
|
+
|
|
231
|
+
check(:loads, nil, "configured #{config.keys.join(", ")}") do
|
|
232
|
+
problems = config_problems(config)
|
|
233
|
+
raise Failure, problems.join("\n") if problems.any?
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Columns
|
|
238
|
+
|
|
239
|
+
def verify_columns
|
|
240
|
+
@table.columns.each do |column|
|
|
241
|
+
verify_column_select(column)
|
|
242
|
+
verify_column_sort(column) if column.sortable? && !column.internal?
|
|
243
|
+
verify_column_render(column) unless column.internal?
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def verify_column_select(column)
|
|
248
|
+
check(:columns, column.name, "select") do |c|
|
|
249
|
+
skip!("no SQL attribute, the value comes from #{column.method ? "method :#{column.method}" : "the record"}") if column.attribute.blank?
|
|
250
|
+
|
|
251
|
+
relation = build_table.filtered_scope.select(Arel.sql(column.attribute)).limit(1)
|
|
252
|
+
c.sql = relation.to_sql
|
|
253
|
+
relation.load
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def verify_column_sort(column)
|
|
258
|
+
check(:columns, column.name, "sort asc/desc") do
|
|
259
|
+
%i[asc desc].each { |direction| build_table(order: {column.name => direction}).ordered_scope.limit(1).load }
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def verify_column_render(column)
|
|
264
|
+
visible = check(:columns, column.name, "visible", quiet: true) { column.visible? }
|
|
265
|
+
return if visible.nil?
|
|
266
|
+
|
|
267
|
+
formats = @table.exportable? ? %i[html csv] : %i[html]
|
|
268
|
+
formats.each do |format|
|
|
269
|
+
check(:columns, column.name, "render #{format}") do
|
|
270
|
+
skip!("not visible") unless visible
|
|
271
|
+
|
|
272
|
+
sample_rows!.each { |row| Mensa::Cell.new(row: row, column: column).render(format) }
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Filters
|
|
278
|
+
|
|
279
|
+
def verify_filters
|
|
280
|
+
@table.columns.select(&:filter?).each do |column|
|
|
281
|
+
operators = check(:filters, column.name, "operators", quiet: true) { column.filter.operators }
|
|
282
|
+
next unless operators
|
|
283
|
+
|
|
284
|
+
values = check(:filters, column.name, "collection", quiet: true) { filter_values(column) }
|
|
285
|
+
next unless values
|
|
286
|
+
|
|
287
|
+
if operators.any? { |operator, *| operator == :is_current }
|
|
288
|
+
reason = check(:filters, column.name, "is_current", quiet: true) { missing_current_reason(column) }
|
|
289
|
+
if reason
|
|
290
|
+
operators = operators.reject { |operator, *| operator == :is_current }
|
|
291
|
+
check(:filters, column.name, "is_current") { skip!(reason) }
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
check(:filters, column.name, filter_check_name(column, operators, values)) do |c|
|
|
296
|
+
errors = operators.filter_map { |operator, _label, with_value| filter_error(column, operator, filter_value(column, operator, with_value, values)) }
|
|
297
|
+
next if errors.empty?
|
|
298
|
+
|
|
299
|
+
c.sql = errors.first[:sql]
|
|
300
|
+
raise Failure, errors.map { |error| error[:message] }.join("\n")
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def filter_check_name(column, operators, values)
|
|
306
|
+
"#{operators.map(&:first).join(", ")} (#{(column.filter.multiple? ? values : values.first).inspect})"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Applies the filter with one operator in its own savepoint, returns
|
|
310
|
+
# {message:, sql:} when it fails
|
|
311
|
+
def filter_error(column, operator, value)
|
|
312
|
+
relation = nil
|
|
313
|
+
savepoint do
|
|
314
|
+
table = build_table(filters: {column.name => {operator: operator, value: value}.compact})
|
|
315
|
+
relation = table.filtered_scope.limit(SAMPLE_SIZE)
|
|
316
|
+
relation.load
|
|
317
|
+
# The filter pill's text
|
|
318
|
+
table.active_filters.each(&:to_s)
|
|
319
|
+
end
|
|
320
|
+
nil
|
|
321
|
+
rescue StandardError, NotImplementedError => e
|
|
322
|
+
sql = e.sql if e.respond_to?(:sql)
|
|
323
|
+
sql ||= relation.to_sql if relation
|
|
324
|
+
{message: "#{operator}#{" #{value.inspect}" if value}: #{e.class}: #{e.message}", sql: sql}
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# is_current compares with Current.<column>, which needs context such as
|
|
328
|
+
# a user. Returns why it can't be checked, nil when it can.
|
|
329
|
+
def missing_current_reason(column)
|
|
330
|
+
value = begin
|
|
331
|
+
::Current.public_send(column.name)
|
|
332
|
+
rescue NoMethodError => e
|
|
333
|
+
return "Current.#{column.name} raised #{e.message}, give the verifier a context (user:, context:)"
|
|
334
|
+
end
|
|
335
|
+
"Current.#{column.name} is nil, give the verifier a context (user:, context:)" if value.nil?
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# The first values of the filter's collection, or a synthetic value
|
|
339
|
+
def filter_values(column)
|
|
340
|
+
collection = Array(column.filter.collection).filter_map { |item| item.is_a?(Array) ? item.last : item }
|
|
341
|
+
values = collection.compact_blank.first(2).map(&:to_s)
|
|
342
|
+
values.presence || [synthetic_value(column)]
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def filter_value(column, operator, with_value, values)
|
|
346
|
+
return unless with_value
|
|
347
|
+
return values if column.filter.multiple? && %i[is isnt].include?(operator)
|
|
348
|
+
|
|
349
|
+
values.first
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# A value as the filter popover would send it for the column's type
|
|
353
|
+
def synthetic_value(column)
|
|
354
|
+
case column.type
|
|
355
|
+
when :integer, :bigint, :decimal, :float then "1"
|
|
356
|
+
when :date then Date.current.iso8601
|
|
357
|
+
when :datetime, :timestamp, :time then Time.current.strftime("%Y-%m-%dT%H:%M")
|
|
358
|
+
when :boolean then "true"
|
|
359
|
+
when :uuid then SecureRandom.uuid
|
|
360
|
+
else "a"
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Search
|
|
365
|
+
|
|
366
|
+
# Both search modes, the app may switch between them
|
|
367
|
+
def verify_search
|
|
368
|
+
term = search_term
|
|
369
|
+
|
|
370
|
+
%i[basic fuzzy].each do |mode|
|
|
371
|
+
check(:search, nil, "#{mode} #{term.inspect}") do |c|
|
|
372
|
+
with_search_mode(mode) do
|
|
373
|
+
table = build_table(query: term)
|
|
374
|
+
skip!("needs the pg_trgm extension") if mode == :fuzzy && !table.send(:fuzzy_search?, table.scope)
|
|
375
|
+
|
|
376
|
+
relation = table.ordered_scope.limit(SAMPLE_SIZE)
|
|
377
|
+
c.sql = relation.to_sql
|
|
378
|
+
relation.load
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def with_search_mode(mode)
|
|
385
|
+
previous = Mensa.config.search
|
|
386
|
+
Mensa.config.search = mode
|
|
387
|
+
yield
|
|
388
|
+
ensure
|
|
389
|
+
Mensa.config.search = previous
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# A word from the first string value in the records, so it finds something
|
|
393
|
+
def search_term
|
|
394
|
+
(@rows || []).first(SAMPLE_SIZE).each do |row|
|
|
395
|
+
@table.display_columns.each do |column|
|
|
396
|
+
value = row.value(column)
|
|
397
|
+
return value.split.first.first(20) if value.is_a?(String) && value.split.first.present?
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
"a"
|
|
401
|
+
rescue
|
|
402
|
+
"a"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Rendering, like the app's views and Mensa's controllers do
|
|
406
|
+
|
|
407
|
+
def verify_rendering
|
|
408
|
+
check(:render, nil, "table component") { view_context.table(table_name, params: params) }
|
|
409
|
+
check(:render, nil, "rows and filter pills") { render_page(build_table, view: @table.default_system_view) }
|
|
410
|
+
|
|
411
|
+
check(:render, nil, "row link") { sample_rows!.each(&:link) } if @table.link
|
|
412
|
+
|
|
413
|
+
return unless @table.actions?
|
|
414
|
+
|
|
415
|
+
@table.actions.each do |action|
|
|
416
|
+
check(:render, :actions, action.name.to_s) do
|
|
417
|
+
sample_rows!.each { |row| view_context.render(Mensa::RowAction::Component.new(table: @table, row: row, action: action)) }
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# What Mensa::TablesController#show renders into the table's turbo frame
|
|
423
|
+
def render_page(table, view: nil)
|
|
424
|
+
table.table_view = view
|
|
425
|
+
view_context.render(Mensa::FilterPillList::Component.new(table: table))
|
|
426
|
+
view_context.render(Mensa::View::Component.new(table))
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Exports, generated by Mensa::ExportJob like a user's export request,
|
|
430
|
+
# for the first page of the table and of each view
|
|
431
|
+
|
|
432
|
+
def verify_exports
|
|
433
|
+
unless @table.exportable?
|
|
434
|
+
check(:exports, nil, "csv") { skip!("exportable false") }
|
|
435
|
+
return
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
check(:exports, nil, export_format) { verify_export({}) }
|
|
439
|
+
|
|
440
|
+
@table.system_views.each do |view|
|
|
441
|
+
# The browser sends the view's state along with the export request
|
|
442
|
+
config = (view.config || {}).deep_symbolize_keys.slice(:filters, :column_order, :hidden_columns, :group_by, :order)
|
|
443
|
+
next if config.empty?
|
|
444
|
+
|
|
445
|
+
check(:exports, view.id, export_format) { verify_export(config) }
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
return unless @table.supports_custom_views?
|
|
449
|
+
|
|
450
|
+
saved_views.each do |view|
|
|
451
|
+
check(:exports, "saved view #{view.id}", export_format) { verify_export({}, table_view: view) }
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def export_format
|
|
456
|
+
@table.export_with_password? ? "csv (zip with password)" : "csv"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def verify_export(config, table_view: nil)
|
|
460
|
+
config = JSON.parse({params: params.presence, page: 1}.compact.merge(config).to_json)
|
|
461
|
+
export = Mensa::Export.new(
|
|
462
|
+
table_name: table_name, table_view_id: table_view&.id, format: "plain_csv", scope: "current_page",
|
|
463
|
+
config: config, created_at: Time.current
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
file, filename, content_type = Mensa::ExportJob.new.generate_file(export)
|
|
467
|
+
raise Failure, "the export job generated no file" unless file
|
|
468
|
+
|
|
469
|
+
data = file.read
|
|
470
|
+
file.close!
|
|
471
|
+
csv = (content_type == "application/zip") ? unzip(data, export.password) : data
|
|
472
|
+
raise Failure, "#{filename} is empty" if csv.blank?
|
|
473
|
+
|
|
474
|
+
expected = build_table((table_view&.config || {}).deep_symbolize_keys.merge(config.deep_symbolize_keys))
|
|
475
|
+
headers = expected.display_columns.map { |column| column.name.to_s }
|
|
476
|
+
lines = CSV.parse(csv.force_encoding(Encoding::UTF_8).delete_prefix("\uFEFF"))
|
|
477
|
+
raise Failure, "the export has no columns" if headers.empty?
|
|
478
|
+
raise Failure, "the export has headers #{lines.first.inspect}, expected #{headers.inspect}" unless lines.first == headers
|
|
479
|
+
|
|
480
|
+
records = expected.rows.size
|
|
481
|
+
raise Failure, "the export has #{lines.size - 1} records, expected #{records}" unless lines.size - 1 == records
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def unzip(data, password)
|
|
485
|
+
require "zip"
|
|
486
|
+
|
|
487
|
+
Zip::InputStream.open(StringIO.new(data), decrypter: Zip::TraditionalDecrypter.new(password)) do |zip|
|
|
488
|
+
zip.get_next_entry
|
|
489
|
+
zip.read
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Grouping and aggregates
|
|
494
|
+
|
|
495
|
+
def verify_grouping
|
|
496
|
+
groupable = @table.columns.select(&:groupable?)
|
|
497
|
+
relation = @table.scope.is_a?(ActiveRecord::Relation)
|
|
498
|
+
|
|
499
|
+
if @table.config[:group_by].present? || @table.config[:aggregates].present?
|
|
500
|
+
check(:grouping, nil, "configured group_by/aggregates") do
|
|
501
|
+
problems = grouping_problems(@table, @table.config)
|
|
502
|
+
raise Failure, problems.join("\n") if problems.any?
|
|
503
|
+
|
|
504
|
+
render_page(build_table)
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
groupable.each do |column|
|
|
509
|
+
check(:grouping, column.name, "group by") do
|
|
510
|
+
skip!("the scope is not an ActiveRecord::Relation") unless relation
|
|
511
|
+
|
|
512
|
+
render_page(build_table(group_by: column.name, aggregates: {}))
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
@table.columns.select(&:aggregatable?).reject(&:internal?).each do |column|
|
|
517
|
+
column.aggregates.each do |function|
|
|
518
|
+
check(:grouping, column.name, function.to_s) do
|
|
519
|
+
skip!("no groupable column to group by") if groupable.empty?
|
|
520
|
+
skip!("the scope is not an ActiveRecord::Relation") unless relation
|
|
521
|
+
|
|
522
|
+
render_page(build_table(group_by: groupable.first.name, aggregates: {column.name => function}))
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Mensa ignores a group_by or aggregates it can't use, report them instead
|
|
529
|
+
def grouping_problems(table, config)
|
|
530
|
+
problems = []
|
|
531
|
+
|
|
532
|
+
group_by = config[:group_by].presence
|
|
533
|
+
if group_by && !table.column(group_by)&.groupable?
|
|
534
|
+
problems << "group_by :#{group_by} is not a groupable column (groupable true, with an SQL attribute)"
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
(config[:aggregates].presence || {}).to_h.each do |name, function|
|
|
538
|
+
column = table.column(name)
|
|
539
|
+
unless column&.aggregates&.map(&:to_s)&.include?(function.to_s)
|
|
540
|
+
problems << "aggregates #{name}: :#{function} is not allowed, the column allows #{column&.aggregates.presence&.inspect || "none"}"
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
problems
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Views
|
|
548
|
+
|
|
549
|
+
def verify_views
|
|
550
|
+
@table.system_views.each do |view|
|
|
551
|
+
check(:views, view.id, view.name.to_s) do
|
|
552
|
+
config = (view.config || {}).deep_symbolize_keys
|
|
553
|
+
problems = config_problems(config)
|
|
554
|
+
raise Failure, problems.join("\n") if problems.any?
|
|
555
|
+
|
|
556
|
+
render_page(build_table(config), view: view)
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
return unless @table.supports_custom_views?
|
|
561
|
+
|
|
562
|
+
saved_views.each do |view|
|
|
563
|
+
check(:views, "saved view #{view.id}", view.name.to_s) { render_page(build_table((view.config || {}).deep_symbolize_keys), view: view) }
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def saved_views
|
|
568
|
+
Mensa::TableView.where(table_name: table_name, user: [nil, @table.current_user].uniq).order(:created_at).limit(25).to_a
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# References to columns (in a view or the table's defaults) that Mensa
|
|
572
|
+
# would silently ignore
|
|
573
|
+
def config_problems(config)
|
|
574
|
+
names = @table.columns.map(&:name)
|
|
575
|
+
problems = []
|
|
576
|
+
|
|
577
|
+
(config[:filters] || {}).each_key { |name| problems << "filter on unknown column :#{name}" unless names.include?(name.to_sym) }
|
|
578
|
+
Array(config[:hidden_columns]).each { |name| problems << "hidden_columns has unknown column :#{name}" unless names.include?(name.to_sym) }
|
|
579
|
+
Array(config[:column_order]).each { |name| problems << "column_order has unknown column :#{name}" unless names.include?(name.to_sym) }
|
|
580
|
+
(config[:order] || {}).each do |name, direction|
|
|
581
|
+
problems << "order on unknown column :#{name}" unless names.include?(name.to_sym) || @table.model.column_names.include?(name.to_s)
|
|
582
|
+
problems << "order :#{name} has direction #{direction.inspect}, expected :asc or :desc" unless %w[asc desc].include?(direction.to_s.downcase)
|
|
583
|
+
end
|
|
584
|
+
problems + grouping_problems(@table, config)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Transaction and context
|
|
588
|
+
|
|
589
|
+
def read_only_transaction
|
|
590
|
+
ActiveRecord::Base.while_preventing_writes do
|
|
591
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
592
|
+
connection = ActiveRecord::Base.connection
|
|
593
|
+
connection.execute("SET LOCAL transaction_read_only = on") if connection.adapter_name.downcase.include?("postg")
|
|
594
|
+
yield
|
|
595
|
+
raise ActiveRecord::Rollback
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def with_current_attributes(&)
|
|
601
|
+
attributes = context.symbolize_keys
|
|
602
|
+
attributes[:user] = user if user
|
|
603
|
+
attributes[:account] = account if account
|
|
604
|
+
return yield if attributes.empty?
|
|
605
|
+
|
|
606
|
+
raise ArgumentError, "Mensa::Verifier: setting #{attributes.keys.join(", ")} needs a Current class" unless defined?(::Current)
|
|
607
|
+
|
|
608
|
+
unknown = attributes.keys.reject { |key| ::Current.respond_to?(:"#{key}=") }
|
|
609
|
+
raise ArgumentError, "Mensa::Verifier: Current has no attribute(s) #{unknown.join(", ")}" if unknown.any?
|
|
610
|
+
|
|
611
|
+
# The stored values, Current.set would restore what the getters return
|
|
612
|
+
stored = ::Current.attributes.symbolize_keys if ::Current.respond_to?(:attributes)
|
|
613
|
+
previous = attributes.keys.to_h { |key| [key, stored ? stored[key] : ::Current.public_send(key)] }
|
|
614
|
+
attributes.each { |key, value| ::Current.public_send(:"#{key}=", value) }
|
|
615
|
+
yield
|
|
616
|
+
ensure
|
|
617
|
+
previous&.each { |key, value| ::Current.public_send(:"#{key}=", value) }
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
end
|
data/lib/mensa/version.rb
CHANGED
data/lib/mensa.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "mensa/version"
|
|
2
2
|
require "mensa/engine"
|
|
3
3
|
require "mensa/configuration"
|
|
4
|
+
require "mensa/verifier"
|
|
4
5
|
require "importmap-rails"
|
|
5
6
|
|
|
6
7
|
module Mensa
|
|
@@ -13,6 +14,16 @@ module Mensa
|
|
|
13
14
|
instance
|
|
14
15
|
end
|
|
15
16
|
|
|
17
|
+
# A view context of the app's base controller, for rendering tables outside
|
|
18
|
+
# a request (exports, Mensa::Verifier), with the app's (url) helpers
|
|
19
|
+
def offline_view_context(request = nil)
|
|
20
|
+
request ||= ActionDispatch::Request.new(Rack::MockRequest.env_for("http://#{Rails.application.routes.default_url_options[:host] || "localhost"}/"))
|
|
21
|
+
controller = config.base_controller.constantize.new
|
|
22
|
+
controller.set_request!(request)
|
|
23
|
+
controller.set_response!(ActionDispatch::Response.new)
|
|
24
|
+
controller.view_context
|
|
25
|
+
end
|
|
26
|
+
|
|
16
27
|
def class_for_name(name)
|
|
17
28
|
class_name = "#{name}_table".camelcase
|
|
18
29
|
unless class_name.safe_constantize
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
namespace :mensa do
|
|
2
|
+
desc "Verify a Mensa table against the database, e.g. mensa:verify[users] (context: USER_ID=, ACCOUNT_ID=, PARAMS='{...}')"
|
|
3
|
+
task :verify, [:table] => :environment do |_task, args|
|
|
4
|
+
abort "Usage: bin/rails \"mensa:verify[table_name]\"" if args[:table].blank?
|
|
5
|
+
|
|
6
|
+
exit(1) unless Mensa::Verifier.run(args[:table])
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
desc "Verify all Mensa tables in app/tables against the database (context: USER_ID=, ACCOUNT_ID=, PARAMS='{...}')"
|
|
10
|
+
task verify_all: :environment do
|
|
11
|
+
exit(1) unless Mensa::Verifier.run
|
|
12
|
+
end
|
|
13
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mensa
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.18
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom de Grunt
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -323,7 +323,11 @@ files:
|
|
|
323
323
|
- lib/mensa.rb
|
|
324
324
|
- lib/mensa/configuration.rb
|
|
325
325
|
- lib/mensa/engine.rb
|
|
326
|
+
- lib/mensa/testing.rb
|
|
327
|
+
- lib/mensa/verifier.rb
|
|
328
|
+
- lib/mensa/verifier/result.rb
|
|
326
329
|
- lib/mensa/version.rb
|
|
330
|
+
- lib/tasks/mensa.rake
|
|
327
331
|
- lib/tasks/mensa_tasks.rake
|
|
328
332
|
- mensa.gemspec
|
|
329
333
|
- mise.toml
|