grape-listing 1.1.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 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: []