mensa 0.6.19 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -1
- data/.shipkit.yml +1 -1
- data/CHANGELOG.md +26 -0
- data/Gemfile.lock +1 -1
- data/README.md +38 -3
- data/app/tables/mensa/base.rb +9 -11
- data/app/tables/mensa/filter.rb +21 -4
- data/lib/mensa/verifier/result.rb +1 -0
- data/lib/mensa/verifier.rb +154 -7
- data/lib/mensa/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 064c836f400dd147fc8fde3fb1f38a49f7bd447c44e6d7493c055cd6dede6b19
|
|
4
|
+
data.tar.gz: b6fdbbdce0951fb43fdfd8f574cf6888ebe1c9f4e369752f4a497de85ca8b8f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f96aca8ee7039304c3292456e0be10722faba2eecac2ba41030f632317998bbf0d1e4d3f649f5a46d675a4d06268dae07d216e536b80294c4990c805160df0b3
|
|
7
|
+
data.tar.gz: f629a69b4a6177d8aedc58f931fa00d328af953dca50cfd80c282989c40fd2434e2e655b5c1f55611568f893d016b5467b16c2925e8fff293b977be9f53d36fc
|
data/.gitignore
CHANGED
data/.shipkit.yml
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
---
|
|
2
|
-
previous_version: 0.6.
|
|
2
|
+
previous_version: 0.6.19
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
- Filters only offer the operators that work for the column's type in SQL,
|
|
6
|
+
picking one no longer raises. `matches`/`does_not_match` (`LIKE`) are only
|
|
7
|
+
offered for string, text, citext and untyped attribute columns, so boolean,
|
|
8
|
+
float, decimal, uuid, time and other columns no longer get them.
|
|
9
|
+
`gt`/`gteq`/`lt`/`lteq` are only offered for number, date and time columns,
|
|
10
|
+
so boolean, uuid and text columns no longer get them. json/jsonb columns only
|
|
11
|
+
get `is_empty`/`isnt_empty` (and jsonb `is_duplicate`). Host apps no longer
|
|
12
|
+
need to patch `Mensa::Filter#operators` or list `operators` per column to
|
|
13
|
+
avoid these. An explicit `operators` list is used as given.
|
|
14
|
+
- Time columns use a time input, and a filter value that isn't a time matches
|
|
15
|
+
nothing instead of raising.
|
|
16
|
+
- The verifier warns when an `attribute` has no `type` but its SQL isn't text,
|
|
17
|
+
e.g. a boolean `EXISTS (...)`, and says which `type` to add.
|
|
18
|
+
- Columns a `column_order` doesn't list are kept and shown after the listed
|
|
19
|
+
ones, instead of being dropped. Browser state or views saved before a column
|
|
20
|
+
was added no longer hide that column (also from the column customizer),
|
|
21
|
+
ignore filters and sorting on it, or leave its attribute unselected.
|
|
22
|
+
- The verifier checks column order and visibility: a reversed order, an order
|
|
23
|
+
that lists only one column (like state saved before columns were added),
|
|
24
|
+
hiding each column and hiding all of them, each rendered and exported.
|
|
25
|
+
- The verifier creates a custom view like a user saving the table's state
|
|
26
|
+
would, without saving it, and validates, replays and exports it.
|
|
27
|
+
- The verifier confirms when it found no N+1 queries.
|
|
28
|
+
|
|
3
29
|
## 0.6.19
|
|
4
30
|
|
|
5
31
|
- `Mensa::Verifier` checks a table against the database: scope, column SQL,
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -175,7 +175,31 @@ end
|
|
|
175
175
|
```
|
|
176
176
|
|
|
177
177
|
The operators are `is`, `isnt`, `matches`, `does_not_match`, `gt`, `gteq`, `lt`,
|
|
178
|
-
`lteq`, `is_duplicate`, `is_current`, `is_empty` and `isnt_empty`.
|
|
178
|
+
`lteq`, `is_duplicate`, `is_current`, `is_empty` and `isnt_empty`. Without an
|
|
179
|
+
`operators` list, a column gets the ones that work for its type in SQL:
|
|
180
|
+
|
|
181
|
+
| Column type | Operators besides `is_duplicate`, `is_empty` and `isnt_empty` |
|
|
182
|
+
|---|---|
|
|
183
|
+
| string, text, citext, or an `attribute` without `type` | `is`, `isnt`, `matches`, `does_not_match` |
|
|
184
|
+
| integer, float, decimal, time | `is`, `isnt`, `gt`, `gteq`, `lt`, `lteq` |
|
|
185
|
+
| date, datetime | `gt`, `gteq`, `lt`, `lteq` |
|
|
186
|
+
| boolean, uuid, enums and other types | `is`, `isnt` |
|
|
187
|
+
| jsonb, json | none (json also has no `is_duplicate`) |
|
|
188
|
+
|
|
189
|
+
`matches` and `does_not_match` are left out when the filter has a `collection`, and
|
|
190
|
+
`is_current` is only offered when `Current` has a method named after the column.
|
|
191
|
+
|
|
192
|
+
Mensa reads the type from the database column. Give an `attribute` that isn't text a
|
|
193
|
+
`type`, otherwise it's treated as text and gets the text operators:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
column(:has_orders) do
|
|
197
|
+
attribute "EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)"
|
|
198
|
+
type :boolean
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
An `operators` list is used as given, also when it lists operators the type can't run.
|
|
179
203
|
|
|
180
204
|
#### Custom filter scopes
|
|
181
205
|
|
|
@@ -382,13 +406,20 @@ real database, using the same code paths as requests do:
|
|
|
382
406
|
- **Exports**: `Mensa::ExportJob` generates the first page of the export, also for
|
|
383
407
|
each view and as a password protected zip. It needs the expected headers
|
|
384
408
|
(skipped for `exportable false`).
|
|
409
|
+
- **Column order and visibility**: the columns shown, rendered and exported in a
|
|
410
|
+
reversed order, with an order that lists only one column (like browser state or a
|
|
411
|
+
view saved before the other columns were added, which must still show them all),
|
|
412
|
+
with each column hidden and with all of them hidden.
|
|
385
413
|
- **Combined**: search, a filter on every column, sorting on every column and
|
|
386
414
|
grouping with aggregates, all at once.
|
|
387
415
|
- **Grouping**: every groupable column and every aggregate, rendered with their
|
|
388
416
|
group headers. A `group_by` or `aggregates` naming a column that doesn't allow
|
|
389
417
|
it fails, because Mensa would silently ignore it.
|
|
390
418
|
- **Views**: system views load, render and only refer to existing columns. With
|
|
391
|
-
custom views enabled, saved views load too
|
|
419
|
+
custom views enabled, saved views load too, and a custom view is created like a
|
|
420
|
+
user saving the table's state would (search, filters, sorting, column order, a
|
|
421
|
+
hidden column and grouping), then validated, replayed and exported. It's never
|
|
422
|
+
saved, and needs a user to own it (`user:`).
|
|
392
423
|
|
|
393
424
|
It also warns, without failing:
|
|
394
425
|
|
|
@@ -396,7 +427,11 @@ It also warns, without failing:
|
|
|
396
427
|
each record. The warning shows the query and how to avoid it for the association
|
|
397
428
|
it loads, e.g. preload it with `includes` (and `internal :customer_id`, since Mensa
|
|
398
429
|
only selects the table's columns), select the value in SQL with `attribute`, or
|
|
399
|
-
count in SQL.
|
|
430
|
+
count in SQL. When none is found, the report confirms it under Rendering, e.g.
|
|
431
|
+
`✓ N+1 queries: none in 18 renders of 7 sampled records`. It needs at least two
|
|
432
|
+
records to tell.
|
|
433
|
+
- **Untyped attributes**: an `attribute` without `type` whose SQL isn't text, e.g. a
|
|
434
|
+
boolean `EXISTS (...)`. The warning names the `type` to add.
|
|
400
435
|
- **Slow queries**: queries that take `config.verifier_slow_query` seconds (1.0 by
|
|
401
436
|
default) or more.
|
|
402
437
|
|
data/app/tables/mensa/base.rb
CHANGED
|
@@ -38,18 +38,16 @@ module Mensa
|
|
|
38
38
|
order = order&.map(&:to_sym)
|
|
39
39
|
return order if order.nil?
|
|
40
40
|
|
|
41
|
-
# Internal columns are never shown in the column customizer UI, so they
|
|
42
|
-
# are absent from any URL-supplied column_order. Always append them so
|
|
43
|
-
# that columns and selected_scope include their attributes.
|
|
44
41
|
all_keys = (config[:columns]&.keys || []).map(&:to_sym)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
return order if all_keys.empty?
|
|
43
|
+
|
|
44
|
+
# A column_order can come from browser state or a view saved for
|
|
45
|
+
# different params, or before columns were added, so drop names that
|
|
46
|
+
# aren't columns here and append the columns it doesn't list. Internal
|
|
47
|
+
# columns are never in it, as the column customizer doesn't show them.
|
|
48
|
+
# Appending keeps every column in columns and selected_scope, so no
|
|
49
|
+
# column disappears and their filters and attributes keep working.
|
|
50
|
+
(order & all_keys) | all_keys
|
|
53
51
|
end
|
|
54
52
|
|
|
55
53
|
# Returns all columns
|
data/app/tables/mensa/filter.rb
CHANGED
|
@@ -14,6 +14,15 @@ module Mensa
|
|
|
14
14
|
config_reader :multiple
|
|
15
15
|
config_reader :having?
|
|
16
16
|
|
|
17
|
+
# Types that LIKE works on, nil is an attribute without a type
|
|
18
|
+
TEXT_TYPES = [:string, :text, :citext, nil].freeze
|
|
19
|
+
# Types with a meaningful order, for gt, gteq, lt and lteq
|
|
20
|
+
RANGE_TYPES = %i[integer bigint float decimal money date datetime timestamp timestamptz time interval].freeze
|
|
21
|
+
# Types where an exact match on a value is of little use
|
|
22
|
+
POINT_IN_TIME_TYPES = %i[date datetime timestamp timestamptz].freeze
|
|
23
|
+
# Types that can't be compared with a value the user types
|
|
24
|
+
STRUCTURED_TYPES = %i[json jsonb].freeze
|
|
25
|
+
|
|
17
26
|
class << self
|
|
18
27
|
def OPERATORS
|
|
19
28
|
[
|
|
@@ -102,10 +111,15 @@ module Mensa
|
|
|
102
111
|
operators = operators.select { |op| config[:operators].include?(op[0]) }
|
|
103
112
|
else
|
|
104
113
|
operators.delete_if { |op| op[0] == :is_current } unless Current.method_defined?(column.name, false)
|
|
114
|
+
type = column.type
|
|
105
115
|
operators.delete_if { |op| [:matches, :does_not_match].include?(op[0]) } if collection.present?
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
operators.delete_if { |op| [:
|
|
116
|
+
# Only these operators work for the column's type in SQL, the others
|
|
117
|
+
# raise, e.g. LIKE on a boolean
|
|
118
|
+
operators.delete_if { |op| [:matches, :does_not_match].include?(op[0]) } unless TEXT_TYPES.include?(type)
|
|
119
|
+
operators.delete_if { |op| [:gt, :lt, :gteq, :lteq].include?(op[0]) } unless RANGE_TYPES.include?(type)
|
|
120
|
+
operators.delete_if { |op| [:is, :isnt].include?(op[0]) } if POINT_IN_TIME_TYPES.include?(type) || STRUCTURED_TYPES.include?(type)
|
|
121
|
+
# json has no equality, so duplicates can't be grouped
|
|
122
|
+
operators.delete_if { |op| op[0] == :is_duplicate } if type == :json
|
|
109
123
|
end
|
|
110
124
|
operators
|
|
111
125
|
end
|
|
@@ -162,6 +176,8 @@ module Mensa
|
|
|
162
176
|
"date"
|
|
163
177
|
when :datetime
|
|
164
178
|
"datetime-local"
|
|
179
|
+
when :time
|
|
180
|
+
"time"
|
|
165
181
|
else
|
|
166
182
|
"text"
|
|
167
183
|
end
|
|
@@ -242,7 +258,8 @@ module Mensa
|
|
|
242
258
|
when :integer, :bigint then entry.strip.match?(/\A[+-]?\d+\z/)
|
|
243
259
|
when :decimal, :float then entry.strip.match?(/\A[+-]?(\d+\.?\d*|\.\d+)\z/)
|
|
244
260
|
when :date then Date.parse(entry).present?
|
|
245
|
-
when :datetime, :timestamp then Time.zone.parse(entry).present?
|
|
261
|
+
when :datetime, :timestamp, :timestamptz then Time.zone.parse(entry).present?
|
|
262
|
+
when :time then entry.strip.match?(/\A\d{1,2}:\d{2}(:\d{2}(\.\d+)?)?\z/)
|
|
246
263
|
when :uuid then entry.strip.match?(UUID_FORMAT)
|
|
247
264
|
else true
|
|
248
265
|
end
|
data/lib/mensa/verifier.rb
CHANGED
|
@@ -149,6 +149,7 @@ module Mensa
|
|
|
149
149
|
|
|
150
150
|
@queries = []
|
|
151
151
|
@reported_n_plus_one = Set.new
|
|
152
|
+
@per_record_renders = 0
|
|
152
153
|
thread = Thread.current
|
|
153
154
|
ActiveSupport::Notifications.subscribed(->(event) { record_query(event) if Thread.current == thread }, "sql.active_record") do
|
|
154
155
|
read_only_transaction do
|
|
@@ -217,6 +218,8 @@ module Mensa
|
|
|
217
218
|
verify_filters
|
|
218
219
|
verify_search
|
|
219
220
|
verify_rendering
|
|
221
|
+
verify_n_plus_one
|
|
222
|
+
verify_layout
|
|
220
223
|
verify_combined
|
|
221
224
|
verify_exports
|
|
222
225
|
verify_grouping
|
|
@@ -373,6 +376,8 @@ module Mensa
|
|
|
373
376
|
def warn_n_plus_one(section, subject, what, queries_per_record)
|
|
374
377
|
return if queries_per_record.size < 2
|
|
375
378
|
|
|
379
|
+
@per_record_renders += 1
|
|
380
|
+
|
|
376
381
|
queries_per_record.flatten.tally.each do |sql, records|
|
|
377
382
|
next if records < 2
|
|
378
383
|
next unless @reported_n_plus_one.add?([subject, sql])
|
|
@@ -458,10 +463,24 @@ module Mensa
|
|
|
458
463
|
selects = (table.send(:id_column?) ? [:id] : []) + table.columns.select(&:internal?).filter_map(&:attribute)
|
|
459
464
|
relation = table.filtered_scope.select(*selects, Arel.sql(column.attribute)).limit(1)
|
|
460
465
|
c.sql = relation.to_sql
|
|
461
|
-
relation
|
|
466
|
+
result = table.model.connection.select_all(relation)
|
|
467
|
+
warn_untyped_attribute(column, result.column_types[column.name.to_s]&.type)
|
|
462
468
|
end
|
|
463
469
|
end
|
|
464
470
|
|
|
471
|
+
# The SQL type of an `attribute` decides which filter operators and values
|
|
472
|
+
# fit it. Without `type`, Mensa treats it as text: it offers LIKE and
|
|
473
|
+
# compares with text, which fails for e.g. a boolean expression.
|
|
474
|
+
def warn_untyped_attribute(column, sql_type)
|
|
475
|
+
return if column.config[:attribute].blank? || column.config[:type].present?
|
|
476
|
+
return if sql_type.nil? || Mensa::Filter::TEXT_TYPES.include?(sql_type)
|
|
477
|
+
|
|
478
|
+
type = {timestamptz: :datetime, timestamp: :datetime}.fetch(sql_type, sql_type)
|
|
479
|
+
warn!(:columns, column.name, "untyped attribute",
|
|
480
|
+
"its SQL is #{sql_type}, but without `type` Mensa treats it as text: filters offer text operators and compare with text.\n" \
|
|
481
|
+
"Fix: add `type :#{type}` to the column.")
|
|
482
|
+
end
|
|
483
|
+
|
|
465
484
|
def verify_column_sort(column)
|
|
466
485
|
check(:columns, column.name, "sort asc/desc") do
|
|
467
486
|
%i[asc desc].each { |direction| build_table(order: {column.name => direction}).ordered_scope.limit(1).load }
|
|
@@ -567,7 +586,8 @@ module Mensa
|
|
|
567
586
|
case column.type
|
|
568
587
|
when :integer, :bigint, :decimal, :float then "1"
|
|
569
588
|
when :date then Date.current.iso8601
|
|
570
|
-
when :datetime, :timestamp, :
|
|
589
|
+
when :datetime, :timestamp, :timestamptz then Time.current.strftime("%Y-%m-%dT%H:%M")
|
|
590
|
+
when :time then "12:00"
|
|
571
591
|
when :boolean then "true"
|
|
572
592
|
when :uuid then SecureRandom.uuid
|
|
573
593
|
else "a"
|
|
@@ -578,7 +598,8 @@ module Mensa
|
|
|
578
598
|
def invalid_value(column)
|
|
579
599
|
case column.type
|
|
580
600
|
when :integer, :bigint, :decimal, :float then "not a number"
|
|
581
|
-
when :date, :datetime, :timestamp then "not a date"
|
|
601
|
+
when :date, :datetime, :timestamp, :timestamptz then "not a date"
|
|
602
|
+
when :time then "not a time"
|
|
582
603
|
when :uuid then "not a uuid"
|
|
583
604
|
end
|
|
584
605
|
end
|
|
@@ -643,6 +664,21 @@ module Mensa
|
|
|
643
664
|
end
|
|
644
665
|
end
|
|
645
666
|
|
|
667
|
+
# Confirms no N+1 query was found, after everything rendered per record
|
|
668
|
+
# (column renders, the row link, row actions). Found ones are warnings.
|
|
669
|
+
def verify_n_plus_one
|
|
670
|
+
return if @reported_n_plus_one.any?
|
|
671
|
+
|
|
672
|
+
check(:render, nil, "N+1 queries") do |c|
|
|
673
|
+
skip!("records could not be loaded") if @samples.nil?
|
|
674
|
+
skip!("no records") if @samples.empty?
|
|
675
|
+
skip!("needs at least 2 sampled records, found 1") if @samples.one?
|
|
676
|
+
skip!("nothing is rendered per record") if @per_record_renders.zero?
|
|
677
|
+
|
|
678
|
+
c.name = "N+1 queries: none in #{@per_record_renders} #{"render".pluralize(@per_record_renders)} of #{@samples.size} sampled records"
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
646
682
|
# What Mensa::TablesController#show renders into the table's turbo frame
|
|
647
683
|
def render_page(table, view: nil)
|
|
648
684
|
table.table_view = view
|
|
@@ -650,6 +686,57 @@ module Mensa
|
|
|
650
686
|
view_context.render(Mensa::View::Component.new(table))
|
|
651
687
|
end
|
|
652
688
|
|
|
689
|
+
# Column order and visibility, as users change them with the column
|
|
690
|
+
# customizer, or as saved views and browser state replay them
|
|
691
|
+
|
|
692
|
+
def verify_layout
|
|
693
|
+
names = @table.display_columns.map(&:name)
|
|
694
|
+
|
|
695
|
+
check(:layout, nil, "reversed column order") do
|
|
696
|
+
skip!("no columns are shown") if names.empty?
|
|
697
|
+
|
|
698
|
+
verify_layout_config({column_order: names.reverse}, names.reverse)
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
check(:layout, nil, "column order listing only :#{names.last} (e.g. saved before the others were added)") do
|
|
702
|
+
skip!("fewer than 2 columns are shown") if names.size < 2
|
|
703
|
+
|
|
704
|
+
verify_layout_config({column_order: [names.last]}, [names.last] + names[0..-2],
|
|
705
|
+
hint: "columns a column_order doesn't list should follow the listed ones")
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
check(:layout, nil, "hide each column (#{names.size})") do
|
|
709
|
+
skip!("no columns are shown") if names.empty?
|
|
710
|
+
|
|
711
|
+
errors = names.filter_map do |name|
|
|
712
|
+
savepoint { verify_layout_config({hidden_columns: [name]}, names - [name]) }
|
|
713
|
+
nil
|
|
714
|
+
rescue StandardError, ScriptError => e
|
|
715
|
+
":#{name}: #{e.is_a?(Failure) ? e.message : "#{e.class}: #{e.message}"}"
|
|
716
|
+
end
|
|
717
|
+
raise Failure, errors.join("\n") if errors.any?
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
check(:layout, nil, "hide all columns") do
|
|
721
|
+
skip!("no columns are shown") if names.empty?
|
|
722
|
+
|
|
723
|
+
verify_layout_config({hidden_columns: names}, [])
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# Builds the table with +config+, checks the columns it shows, renders it
|
|
728
|
+
# and exports it
|
|
729
|
+
def verify_layout_config(config, expected, hint: nil)
|
|
730
|
+
table = build_table(config)
|
|
731
|
+
shown = table.display_columns.map(&:name)
|
|
732
|
+
unless shown == expected
|
|
733
|
+
raise Failure, "shows columns #{shown.inspect}, expected #{expected.inspect}#{"\n#{hint}" if hint}"
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
render_page(table)
|
|
737
|
+
verify_export(config, columns_required: expected.any?) if @table.exportable?
|
|
738
|
+
end
|
|
739
|
+
|
|
653
740
|
# Everything a user can combine at once: search, a filter on every
|
|
654
741
|
# column, sorting on every column and grouping with aggregates
|
|
655
742
|
def verify_combined
|
|
@@ -711,7 +798,7 @@ module Mensa
|
|
|
711
798
|
@table.export_with_password? ? "csv (zip with password)" : "csv"
|
|
712
799
|
end
|
|
713
800
|
|
|
714
|
-
def verify_export(config, table_view: nil)
|
|
801
|
+
def verify_export(config, table_view: nil, columns_required: true)
|
|
715
802
|
config = JSON.parse({params: params.presence, page: 1}.compact.merge(config).to_json)
|
|
716
803
|
export = Mensa::Export.new(
|
|
717
804
|
table_name: table_name, table_view_id: table_view&.id, format: "plain_csv", scope: "current_page",
|
|
@@ -724,12 +811,14 @@ module Mensa
|
|
|
724
811
|
data = file.read
|
|
725
812
|
file.close!
|
|
726
813
|
csv = (content_type == "application/zip") ? unzip(data, export.password) : data
|
|
727
|
-
raise Failure, "#{filename} is empty" if csv.blank?
|
|
814
|
+
raise Failure, "#{filename} is empty" if csv.blank? && columns_required
|
|
728
815
|
|
|
729
816
|
expected = build_table((table_view&.config || {}).deep_symbolize_keys.merge(config.deep_symbolize_keys))
|
|
730
817
|
headers = expected.display_columns.map { |column| column.name.to_s }
|
|
731
818
|
lines = CSV.parse(csv.force_encoding(Encoding::UTF_8).delete_prefix(""))
|
|
732
|
-
raise Failure, "the export has no columns" if headers.empty?
|
|
819
|
+
raise Failure, "the export has no columns" if headers.empty? && columns_required
|
|
820
|
+
return if headers.empty?
|
|
821
|
+
|
|
733
822
|
raise Failure, "the export has headers #{lines.first.inspect}, expected #{headers.inspect}" unless lines.first == headers
|
|
734
823
|
|
|
735
824
|
records = expected.rows.size
|
|
@@ -812,13 +901,71 @@ module Mensa
|
|
|
812
901
|
end
|
|
813
902
|
end
|
|
814
903
|
|
|
815
|
-
|
|
904
|
+
unless @table.supports_custom_views?
|
|
905
|
+
check(:views, "custom view", "create") { skip!("supports_custom_views is off") }
|
|
906
|
+
return
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
verify_custom_view
|
|
816
910
|
|
|
817
911
|
saved_views.each do |view|
|
|
818
912
|
check(:views, "saved view #{view.id}", view.name.to_s) { render_page(build_table((view.config || {}).deep_symbolize_keys), view: view) }
|
|
819
913
|
end
|
|
820
914
|
end
|
|
821
915
|
|
|
916
|
+
# Creates a custom view like a user saving the table's state would, but
|
|
917
|
+
# doesn't save it: it's validated, then replayed and exported like a saved
|
|
918
|
+
# view.
|
|
919
|
+
def verify_custom_view
|
|
920
|
+
check(:views, "custom view", "create and replay (not saved)") do |c|
|
|
921
|
+
skip!("custom views are owned by Current.user, give the verifier a user:") if @table.current_user.blank?
|
|
922
|
+
|
|
923
|
+
view = custom_view
|
|
924
|
+
raise Failure, "the view is invalid: #{view.errors.full_messages.to_sentence}" unless view.valid?
|
|
925
|
+
|
|
926
|
+
c.name = "create and replay #{view.config.keys.join(", ")} (not saved)"
|
|
927
|
+
# Like Mensa::TablesController#show and the views controller
|
|
928
|
+
render_page(build_table(view.config.deep_transform_keys(&:to_sym)), view: view)
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
return unless @table.exportable? && @table.current_user.present?
|
|
932
|
+
|
|
933
|
+
check(:exports, "custom view", export_format) do
|
|
934
|
+
verify_export(custom_view.config.deep_symbolize_keys.slice(:filters, :column_order, :hidden_columns, :group_by, :order))
|
|
935
|
+
end
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# A view as Mensa::Tables::ViewsController#create builds it from the
|
|
939
|
+
# table's state in the browser
|
|
940
|
+
def custom_view
|
|
941
|
+
Mensa::TableView.new(
|
|
942
|
+
table_name: table_name,
|
|
943
|
+
name: "Mensa::Verifier",
|
|
944
|
+
description: "Created by Mensa::Verifier, not saved",
|
|
945
|
+
config: JSON.parse(custom_view_config.to_json),
|
|
946
|
+
user: @table.current_user
|
|
947
|
+
)
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
def custom_view_config
|
|
951
|
+
@custom_view_config ||= begin
|
|
952
|
+
columns = @table.display_columns
|
|
953
|
+
sortable = columns.find(&:sortable?)
|
|
954
|
+
groupable = @table.columns.find(&:groupable?)
|
|
955
|
+
|
|
956
|
+
config = {query: search_term, filters: combined_filters.first(2).to_h}
|
|
957
|
+
config[:order] = {sortable.name => :desc} if sortable
|
|
958
|
+
config[:column_order] = columns.map(&:name).reverse
|
|
959
|
+
config[:hidden_columns] = [columns.last.name] if columns.size > 1
|
|
960
|
+
if groupable && @table.scope.is_a?(ActiveRecord::Relation)
|
|
961
|
+
config[:group_by] = groupable.name
|
|
962
|
+
aggregatable = @table.columns.reject(&:internal?).find(&:aggregatable?)
|
|
963
|
+
config[:aggregates] = {aggregatable.name => aggregatable.aggregates.first} if aggregatable
|
|
964
|
+
end
|
|
965
|
+
config
|
|
966
|
+
end
|
|
967
|
+
end
|
|
968
|
+
|
|
822
969
|
def saved_views
|
|
823
970
|
Mensa::TableView.where(table_name: table_name, user: [nil, @table.current_user].uniq).order(:created_at).limit(25).to_a
|
|
824
971
|
end
|
data/lib/mensa/version.rb
CHANGED