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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b38d819bbd96ef6b2cb38e7b89c07c9a487eb7b92e8a191a92f4fd7e09cf1f7f
4
- data.tar.gz: 52856b77830db55218a63213c4dc90a685686046175059cd78c53d8fb40d5527
3
+ metadata.gz: 064c836f400dd147fc8fde3fb1f38a49f7bd447c44e6d7493c055cd6dede6b19
4
+ data.tar.gz: b6fdbbdce0951fb43fdfd8f574cf6888ebe1c9f4e369752f4a497de85ca8b8f4
5
5
  SHA512:
6
- metadata.gz: a6dc8ccce0bed9be30b8b9c4b8d5f255b1d808d48a32c88e1813c40043378b0dd8d485abc5a34aa9b7b41f21335300f0f79f9cf6f7b1f1d7982f7f312027dad1
7
- data.tar.gz: 36262b2128d07c1919dee7c08a82f0d15af44be791b012757afe41c28dadf5aafcc75e3be4907c0d9369d319217273297c98a20e2ab15380c03766da7b2e43ee
6
+ metadata.gz: f96aca8ee7039304c3292456e0be10722faba2eecac2ba41030f632317998bbf0d1e4d3f649f5a46d675a4d06268dae07d216e536b80294c4990c805160df0b3
7
+ data.tar.gz: f629a69b4a6177d8aedc58f931fa00d328af953dca50cfd80c282989c40fd2434e2e655b5c1f55611568f893d016b5467b16c2925e8fff293b977be9f53d36fc
data/.gitignore CHANGED
@@ -6,7 +6,7 @@
6
6
  /tmp/
7
7
  /test/dummy/db/*.sqlite3
8
8
  /test/dummy/db/*.sqlite3-*
9
- /test/dummy/log/*.log
9
+ /test/dummy/log/*
10
10
  /test/dummy/storage/
11
11
  /test/dummy/app/assets/builds/
12
12
  /test/dummy/tmp/
data/.shipkit.yml CHANGED
@@ -1,2 +1,2 @@
1
1
  ---
2
- previous_version: 0.6.18
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mensa (0.6.18)
4
+ mensa (0.6.19)
5
5
  csv
6
6
  importmap-rails
7
7
  pagy (>= 43)
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
 
@@ -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
- # A column_order can come from browser state saved for different params
46
- # (with different columns), so drop names that aren't columns here.
47
- if all_keys.any?
48
- order &= all_keys
49
- order = all_keys if order.empty?
50
- end
51
- internal_keys = all_keys.select { |key| config.dig(:columns, key, :internal) }
52
- (order | internal_keys)
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
@@ -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
- operators.delete_if { |op| [:matches, :does_not_match].include?(op[0]) } if column.type == :integer || column.type == :date || column.type == :datetime
107
- operators.delete_if { |op| [:is, :isnt].include?(op[0]) } if column.type == :date || column.type == :datetime
108
- operators.delete_if { |op| [:gt, :lt, :gteq, :lteq].include?(op[0]) } if column.type == :string || column.type.blank?
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
@@ -36,6 +36,7 @@ module Mensa
36
36
  filters: "Filters",
37
37
  search: "Search",
38
38
  render: "Rendering",
39
+ layout: "Column order and visibility",
39
40
  combined: "Combined",
40
41
  exports: "Exports",
41
42
  grouping: "Grouping",
@@ -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.load
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, :time then Time.current.strftime("%Y-%m-%dT%H:%M")
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
- return unless @table.supports_custom_views?
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
@@ -1,3 +1,3 @@
1
1
  module Mensa
2
- VERSION = "0.6.19"
2
+ VERSION = "0.7.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mensa
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.19
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom de Grunt