grape-listing 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f7ada8749162c0e09f2a0dad2712dbd31e3296d302266449e4c5edd7cea9b1d6
4
+ data.tar.gz: 51afe0e601ad9e8f5d22d4c0d07031078cc9b6d1161cfd092a15dfb18265cd8b
5
+ SHA512:
6
+ metadata.gz: ff72f5cf03c2394347d8745e8c68429d85f793ce64f29eddc7d808f6b75fb81c4a4cb6d12bf5af8f707a999e2fe90d5269b4b310ebd6794819d65d0bfbeb7fe0
7
+ data.tar.gz: 98e6cec6b17f9efa1fcbd1890a3d376539d2662240e6dfddb107ae729a96b1f8e2aea569f2adb183fb52639945159a3dcc7b0b24ce7e5ada098d055c2d1b1066
data/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # Grape Listing
2
+ [![Gem Version](https://badge.fury.io/rb/paginated.svg)](https://badge.fury.io/rb/paginated)
3
+
4
+ Гем для пагинации и фильтрации записей с возможностью формирования электронных таблиц (XLSX) на базе Grape.
5
+
6
+ ## Установка
7
+
8
+ Добавить в Gemfile
9
+
10
+ ```
11
+ gem 'grape-listing'
12
+ ```
13
+
14
+ И выполнить:
15
+
16
+ ```
17
+ bundle
18
+ ```
19
+
20
+ ## Использование
21
+
22
+ Использование с помощью вызова метода-хелпера `listing` и передачи в него необходимых опций.
23
+
24
+
25
+ Например:
26
+
27
+ ```
28
+ get 'users' do
29
+ listing model: User,
30
+ entity: UserEntity,
31
+ search: %w[email name|ilike role|custom.for_role]
32
+ end
33
+
34
+ ```
35
+
36
+ Опции (с примерами):
37
+
38
+ `model: User` - модель, записи которой нужно обработать.
39
+
40
+ `entity: UserEntity` - класс Grape Entity, который обработает каждую запись (передается вместо `fields`).
41
+
42
+ `scopes: proc {...}` - блок кода для применения
43
+
44
+ `fields: %i[...]` - список полей, которые должны присутствовать в списке записей (передается вместо `entity`).
45
+
46
+ `search: %w[...]` - список полей, по которым должна осуществляться фильтрация (поиск).
47
+
48
+ ## Параметры HTTP запроса
49
+
50
+ Некоторые функции, такие, как поиск (фильтрация), сортировка и формирование эл. таблиц осуществляется путем обработки параметров HTTP запроса.
51
+
52
+ ### Поиск
53
+
54
+ Для поиска по полям необходимо передать их в запросе в виде `?field=value`. Для поиска по нескольким полям, параметры должны быть перечислены через `&`, например: `?field_1=value&field_2=value`.
55
+
56
+ ### Сортировка
57
+
58
+ Для сортировки выдачи необходимо передать в параметрах запроса:
59
+
60
+ - `sort_by` - название поля, по которому должна осуществляться сортировка.
61
+
62
+ - `sort_order` направление, по которому должна осуществляться сортировка (`asc/desc`).
63
+
64
+ По умолчанию сортировка осуществляется по `id` записей в направлении `DESC`.
65
+
66
+ ### Ограничение полей
67
+
68
+ При передаче параметра `columns[]=` результаты в ответе ограничиваются переданным массив колонок.
69
+
70
+ ### Формирование эл. таблиц
71
+
72
+ При передаче параметра `spreadsheet=true` происходит формирование эл. таблицы в виде XLSX файла с учетом всех остальных переданных параметров. Параметр `columns` при этом является обязательным.
73
+
74
+ Заголовки эл. таблицы будут взяты из описаний полей таблицы БД с соответствующими названиями. Значения будут сформированы путем обработки переданного списка колонок как методов.
75
+
76
+ ## Конфигурация
77
+
78
+ Добавьте файл конфигурации `config/initializers/grape_listing.rb` с содержимым:
79
+
80
+ ```
81
+ GrapeListing.configure do |config|
82
+ end
83
+ ```
@@ -0,0 +1,7 @@
1
+ class Object
2
+
3
+ def send_chain(arr)
4
+ Array(arr).inject(self) { |o, a| o.send(*a) }
5
+ end
6
+
7
+ end
data/lib/grape/dsl.rb ADDED
@@ -0,0 +1,55 @@
1
+ module Grape
2
+ module DSL
3
+ module InsideRoute
4
+
5
+ def listing(model:, entity:, scopes: nil, search: nil)
6
+ opts = listing_opts(model, entity, scopes, search)
7
+
8
+ if params[:spreadsheet]
9
+ listing_spreadsheet(**opts)
10
+ else
11
+ GrapeListingService.paginated(**opts)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def listing_opts(model, entity, scopes, search)
18
+ # стандартные опции
19
+ opts = { model:, entity:, scopes:, search:, params:, current_user: }
20
+
21
+ # требуемый список полей (+стандартные) для фильтрации в Grape Entity
22
+ if params[:columns] && !params[:spreadsheet]
23
+ opts[:only_columns] = params[:columns] + %w[id deleted_at]
24
+ end
25
+
26
+ opts
27
+ end
28
+
29
+ def listing_spreadsheet(opts)
30
+ Dir.mktmpdir do |dir|
31
+ opts[:tempdir] = dir
32
+ file_path = GrapeListingService.spreadsheet(**opts)
33
+
34
+ spreadsheet_file_headers File.basename(file_path)
35
+ File.read(file_path)
36
+ end
37
+ end
38
+
39
+ def spreadsheet_file_headers(filename)
40
+ header['Content-Type'] =
41
+ case File.extname(filename)
42
+ when '.csv'
43
+ 'application/csv'
44
+ when '.xlsx'
45
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
46
+ end
47
+
48
+ header['Access-Control-Expose-Headers'] = 'Content-Disposition'
49
+ header['Content-Disposition'] = "attachment; filename=#{Addressable::URI.escape(filename)}"
50
+ env['api.format'] = :binary
51
+ end
52
+
53
+ end
54
+ end
55
+ end
@@ -0,0 +1 @@
1
+ require 'grape_listing'
@@ -0,0 +1,14 @@
1
+ module GrapeListing
2
+ class Configuration
3
+ end
4
+
5
+ def self.configure
6
+ yield(config)
7
+ config
8
+ end
9
+
10
+ def self.config
11
+ @config ||= Configuration.new
12
+ end
13
+
14
+ end
@@ -0,0 +1,7 @@
1
+ require 'extensions/object'
2
+ require 'grape/dsl'
3
+ require 'grape_listing/configuration'
4
+ require 'grape_listing_service'
5
+
6
+ module GrapeListing
7
+ end
@@ -0,0 +1,27 @@
1
+ require 'listing_service/args_handling'
2
+ require 'listing_service/collection'
3
+ require 'listing_service/search'
4
+ require 'listing_service/sorting'
5
+ require 'listing_service/spreadsheet'
6
+
7
+ class GrapeListingService
8
+
9
+ include GrapeListing::ArgsHandling
10
+ include GrapeListing::Collection
11
+ include GrapeListing::Search
12
+ include GrapeListing::Sorting
13
+ include GrapeListing::Spreadsheet
14
+
15
+ def initialize(**args)
16
+ handle_args(**args)
17
+ end
18
+
19
+ def self.paginated(**args)
20
+ new(**args).paginated
21
+ end
22
+
23
+ def self.spreadsheet(**args)
24
+ new(**args).spreadsheet
25
+ end
26
+
27
+ end
@@ -0,0 +1,65 @@
1
+ module GrapeListing
2
+ module ArgsHandling
3
+
4
+ private
5
+
6
+ def handle_args(**args)
7
+ # обрабатываемая Rails модель
8
+ @model = args[:model]
9
+
10
+ # параметры запроса и текущий пользователь
11
+ @params = args[:params].stringify_keys
12
+ @current_user = args[:current_user]
13
+
14
+ # поля, по которым возможен поиск
15
+ @search_fields = args[:search]
16
+
17
+ # сериализация - через Grape Entity или список полей
18
+ @grape_entity = args[:entity]
19
+ @fields = args[:fields]
20
+
21
+ # Rails scopes для применения
22
+ @scopes = args[:scopes] || {}
23
+
24
+ # offset / limit
25
+ @offset = @params['offset'].to_i
26
+ @limit = @params['limit'] || 20
27
+
28
+ # проверка максимального значения лимита
29
+ if @limit.to_i > 100
30
+ raise StandardError.new('Лимит слишком большой')
31
+ end
32
+
33
+ # требуемый список полей для коллекции
34
+ @only_columns = args[:only_columns]
35
+
36
+ # сортировка
37
+ @sort_by = @params['sort_by'] || :id
38
+ @sort_order = @params['sort_order'] || :desc
39
+
40
+ # подсчет кол-ва записей
41
+ @objects_count = records_count
42
+
43
+ # временная директория (для генерации файлов)
44
+ @tempdir = args[:tempdir]
45
+ end
46
+
47
+ def records_count
48
+ # получение кол-ва записей из кеша
49
+ cache_key = "#{@model}_records_count"
50
+ cached = Rails.cache.read(cache_key)
51
+ return cached if cached
52
+
53
+ # получение кол-ва записей из запроса к БД
54
+ count = @model.merge(@scopes).count(:id)
55
+
56
+ # кеширование кол-ва записей при превышении порогового значения
57
+ if count >= 1_000
58
+ Rails.cache.write(cache_key, count, expires_in: 30.minutes)
59
+ end
60
+
61
+ count
62
+ end
63
+
64
+ end
65
+ end
@@ -0,0 +1,58 @@
1
+ module GrapeListing
2
+ module Collection
3
+
4
+ def paginated
5
+ if @objects_count > 0
6
+ search
7
+ serialize
8
+ else
9
+ @objects = []
10
+ end
11
+
12
+ # результат с пагинацией
13
+ { count: @objects_count, objects: @objects }
14
+ end
15
+
16
+ private
17
+
18
+ def serialize
19
+ # коллекция записей ActiveRecord для применения аггрегирования
20
+ list = @objects || @model.unscoped.merge(@scopes)
21
+
22
+ # применение сортировки и пагинации к коллекции записей
23
+ @objects = list.offset(@offset).merge(sort_proc).limit(@limit)
24
+
25
+ # сериализация с помощью переданных полей или сериалайзера
26
+ if @fields
27
+ serialize_with_fields
28
+ elsif @grape_entity
29
+ serialize_with_entity
30
+ end
31
+ end
32
+
33
+ def serialize_with_fields
34
+ # добавление ID в список полей по умолчанию
35
+ @fields.push(:id)
36
+
37
+ # список массивов значений
38
+ @objects = @objects.map { |i| obtain_fields_values(i) }
39
+
40
+ # трансформация массивов значений в объекты (ключ-значение)
41
+ @objects = @objects.map { |i| @fields.zip(i).to_h }
42
+ end
43
+
44
+ def serialize_with_entity
45
+ opts = { current_user: @current_user }
46
+
47
+ # требуемый список полей (если был передан)
48
+ opts[:only] = @only_columns if @only_columns
49
+
50
+ @objects = @objects.map { |i| @grape_entity.represent(i, opts).as_json }
51
+ end
52
+
53
+ def obtain_fields_values(record)
54
+ @fields.map { |i| record.send(i) }
55
+ end
56
+
57
+ end
58
+ end
@@ -0,0 +1,99 @@
1
+ module GrapeListing
2
+ module Search
3
+
4
+ private
5
+
6
+ def search
7
+ @search_fields&.each do |field|
8
+ # коллекция записей для дальнейшей фильтрации
9
+ list = @objects || @model.unscoped.merge(@scopes)
10
+
11
+ # оператор и значение для поиска
12
+ operator, value = search_operands(field)
13
+
14
+ # пропускаем, если параметр для поиска не передан
15
+ next if value.blank? || value == 'null'
16
+
17
+ # кастомный поиск
18
+ if operator.include?('custom') && value.present?
19
+ # название скоупа из модели, который нужно применить
20
+ scope_name = operator.split('.')[1]
21
+ # применение скоупа
22
+ @objects = list.send(scope_name, value)
23
+ @objects_count = @objects.count
24
+ next
25
+ end
26
+
27
+ # форматирование кавычек
28
+ if operator != 'IN' && !@model.defined_enums.key?(field)
29
+ value = quotations_formatting(value)
30
+ end
31
+
32
+ if field.include?('.')
33
+ # нужно приджойнить таблицу
34
+ join_assoc, field = field.split('.')
35
+ table = join_assoc.tableize
36
+ list = list.joins(join_assoc.to_sym)
37
+ else
38
+ table = @model.table_name
39
+ field = field.split('|')[0]
40
+ end
41
+
42
+ query = where_query(table, field, operator, value)
43
+ @objects = list.where(query)
44
+ @objects_count = @objects.count
45
+ end
46
+ end
47
+
48
+ def search_operands(field)
49
+ value = default_param_value(field)
50
+
51
+ # WHERE query operator
52
+ operator = field.split('|').size > 1 ? field.split('|').last : '='
53
+
54
+ # условие для поиска ILIKE
55
+ value = "%#{value}%" if operator == 'ilike' && value.present?
56
+
57
+ # проверка мульти-поиска по параметру с массивом
58
+ if operator == 'multi' && !value.blank?
59
+ operator = 'IN'
60
+ value = "(#{value.join(', ')})"
61
+ end
62
+
63
+ # проверка диапазона (MIN/MAX)
64
+ if %w[min max].include?(operator)
65
+ range_field = field.split('.').last.split(':')[0].try { |i| i.split('|')[0] }
66
+ value = @params["#{range_field}_#{operator}"]
67
+ operator = operator == 'min' ? '>=' : '<='
68
+ end
69
+
70
+ # check array operator
71
+ if operator == 'array' && value.present?
72
+ operator = '='
73
+ value = "{#{value}}"
74
+ end
75
+
76
+ [operator, value]
77
+ end
78
+
79
+ def default_param_value(field)
80
+ # название ключа-поля без оператора (напр. ilike)
81
+ key = field.split('|')[0]
82
+
83
+ @params[key]
84
+ end
85
+
86
+ def where_query(table, field, operator, value)
87
+ "#{table}.#{field} #{operator} #{value}"
88
+ end
89
+
90
+ def quotations_formatting(string)
91
+ # удаление одинарных кавычек (напр. внутри строки)
92
+ value = string.to_s.gsub("'", '')
93
+
94
+ # добавление одинарных кавычек вокруг строки
95
+ "'#{value}'"
96
+ end
97
+
98
+ end
99
+ end
@@ -0,0 +1,12 @@
1
+ module GrapeListing
2
+ module Sorting
3
+
4
+ private
5
+
6
+ def sort_proc
7
+ query = Arel.sql("#{@sort_by} #{@sort_order} NULLS LAST")
8
+ proc { order(query) }
9
+ end
10
+
11
+ end
12
+ end
@@ -0,0 +1,77 @@
1
+ require 'fast_excel'
2
+
3
+ module GrapeListing
4
+ module Spreadsheet
5
+
6
+ def spreadsheet
7
+ # поиск и фильтрация
8
+ search
9
+
10
+ # коллекция записей ActiveRecord для формирования эл. таблицы
11
+ records = @objects || @model.unscoped.merge(@scopes)
12
+
13
+ # ограничение записей
14
+ records = records.order(:id).offset(@offset).limit(@limit)
15
+
16
+ # формирования файла эл. таблицы
17
+ generate_spreadsheet(records)
18
+ end
19
+
20
+ private
21
+
22
+ def generate_spreadsheet(records)
23
+ filepath = "#{@tempdir}/#{Time.now.strftime('%Y%m%d_%H%M%S')}.xlsx"
24
+
25
+ # создание таблицы и ее конфигурация
26
+ workbook = FastExcel.open(filepath, constant_memory: true)
27
+ workbook.default_format.set(
28
+ font_size: 0,
29
+ font_family: 'Arial'
30
+ )
31
+
32
+ # стили
33
+ bold = workbook.bold_format
34
+
35
+ # лист
36
+ worksheet = workbook.add_worksheet('Основной')
37
+ worksheet.auto_width = true
38
+
39
+ # заголовки
40
+ worksheet.append_row(spreadsheet_titles, bold)
41
+
42
+ # содержимое
43
+ cols = spreadsheet_columns
44
+ records.each do |record|
45
+ values = cols.map { |col| record.send_chain(col) }
46
+ worksheet.append_row(values)
47
+ end
48
+
49
+ # закрытие/сохранение файла
50
+ workbook.close
51
+
52
+ # путь до файла для дальнейшей обработки
53
+ filepath
54
+ end
55
+
56
+ def spreadsheet_titles
57
+ model_cols = @model.columns.to_h { |i| [i.name, i] }
58
+
59
+ @params['columns'].map do |column|
60
+ db_col = model_cols[column]
61
+
62
+ db_col.comment || column
63
+ end
64
+ end
65
+
66
+ def spreadsheet_columns
67
+ entity_doc = @grape_entity.documentation
68
+
69
+ @params['columns'].map do |column|
70
+ doc = entity_doc[column.to_sym]
71
+
72
+ doc && doc[:sheet_method] ? doc[:sheet_method].split('.') : column
73
+ end
74
+ end
75
+
76
+ end
77
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: grape-listing
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Павел Бабин
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-03-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: fast_excel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.5.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.5.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: grape
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.0.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.0.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: grape-entity
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 1.0.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 1.0.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: rails
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '7.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '7.0'
69
+ description: Простая пагинация, поиск и формирование эл. таблиц для API на базе Grape
70
+ email: babin359@gmail.com
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - README.md
76
+ - lib/extensions/object.rb
77
+ - lib/grape-listing.rb
78
+ - lib/grape/dsl.rb
79
+ - lib/grape_listing.rb
80
+ - lib/grape_listing/configuration.rb
81
+ - lib/grape_listing_service.rb
82
+ - lib/listing_service/args_handling.rb
83
+ - lib/listing_service/collection.rb
84
+ - lib/listing_service/search.rb
85
+ - lib/listing_service/sorting.rb
86
+ - lib/listing_service/spreadsheet.rb
87
+ homepage:
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ rubygems_mfa_required: 'true'
92
+ post_install_message:
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 3.1.0
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 3.0.6
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: Формирование списков записей для API на базе Grape
111
+ test_files: []