mensa 0.6.16 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a88070a879c44aa30934fd328a74c21546c2517999731ad033dc10022d2a4ee
4
- data.tar.gz: 3940bc3931ce9f0a0aad748c378cbd7c3231616cedea960f7c24ab7bdc974cf2
3
+ metadata.gz: a9bbb252521ea00a7d6197e9ae12208d121ec069652b45b729095609a596bda2
4
+ data.tar.gz: 92345f45e5b1336f1e101c2c64d9736c58637cb9792c333cc21f4620dadabf94
5
5
  SHA512:
6
- metadata.gz: 77fe83cd050e611f24086db47cd0a0f9330459289390df37361a97f82950819a0b22a0d6573a094be0cf4d7f6455624ccf08c44fd4f7d8c1c4398077df3fb192
7
- data.tar.gz: a5485bbe47e59950753d270fde0ffc7618ec760837b3c2f435fa4b9f798c81822e514dab1e9688a090b2d31cc13929b2ecdd4fe3038ec57db6581eecb61b18aa
6
+ metadata.gz: 2b97589e71ee52505369eb9cac3af5e49672c8af62d076d2f17465ea76b2c556ca2c5adbede3af5ad850db502bd47271e031c96a5292eb2ef191818e24e60435
7
+ data.tar.gz: ba31a706fa85f69d30a1b4bfcb17d2c6674df561daf8379341b3f7dc31d21bd10ecc94fc36192f0f2302e602556898c72d9b3d203e330f2a60494c3741b004f2
data/.shipkit.yml CHANGED
@@ -1,2 +1,2 @@
1
1
  ---
2
- previous_version: 0.6.15
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mensa (0.6.15)
4
+ mensa (0.6.17)
5
5
  csv
6
6
  importmap-rails
7
7
  pagy (>= 43)
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
  ```
@@ -4,7 +4,6 @@
4
4
  <% if icon = action.icon(row.record) %>
5
5
  <i class="fa <%= icon %>"></i>
6
6
  <% else %>
7
- <%= name %>
7
+ <%= action.title %>
8
8
  <% end %>
9
- <% end %>
10
- <% end %>
9
+ <% end %>
@@ -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
- if method.to_s.ends_with?("_url") || method.to_s.ends_with?("_path") && main_app.respond_to?(method)
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
- main_app.send(method, *args, **kwargs, &block)
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
- table = build_table(export)
19
- unless table.exportable?
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
- csv = CSV.new(tempfile)
82
- csv << table.display_columns.map(&:name)
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
@@ -119,6 +119,7 @@ module Mensa
119
119
  when :isnt_empty
120
120
  (column.type == :string) ? "#{col} IS NOT NULL AND #{col} != ''" : "#{col} IS NOT NULL"
121
121
  when :is_current
122
+ hash[:value] = Current.public_send(column.name)
122
123
  "#{col} = :value"
123
124
  when :matches
124
125
  "#{col} LIKE :value"
@@ -210,6 +211,7 @@ module Mensa
210
211
  def apply_duplicate_filter(record_scope)
211
212
  column_sql = column.attribute_for_condition.to_s
212
213
  base_scope = record_scope.except(:select, :order, :limit, :offset)
214
+ return apply_duplicate_having_filter(record_scope, base_scope, column_sql) if having?
213
215
 
214
216
  duplicate_values_scope = base_scope
215
217
  .where("#{column_sql} IS NOT NULL")
@@ -222,6 +224,19 @@ module Mensa
222
224
  .where("#{column_sql} IN (#{duplicate_values_scope.to_sql})")
223
225
  end
224
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
+
225
240
  # Unused at the moment
226
241
  def normalize(query)
227
242
  query
@@ -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
- searchable_attributes = columns.filter_map(&:attribute_for_condition).uniq
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",
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Mensa
2
- VERSION = "0.6.16"
2
+ VERSION = "0.6.18"
3
3
  end
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.16
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-13 00:00:00.000000000 Z
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