rice_cooker 0.1.1

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.
@@ -0,0 +1,287 @@
1
+ require 'active_support'
2
+
3
+ module RiceCooker
4
+ # Will be thrown when invalid sort param
5
+ class InvalidSortException < Exception
6
+ end
7
+
8
+ class InvalidFilterException < Exception
9
+ end
10
+
11
+ class InvalidFilterValueException < Exception
12
+ end
13
+
14
+ class InvalidRangeException < Exception
15
+ end
16
+
17
+ class InvalidRangeValueException < Exception
18
+ end
19
+
20
+ module Helpers
21
+ extend ActiveSupport::Concern
22
+
23
+ # From https://github.com/josevalim/inherited_resources/blob/master/lib/inherited_resources/class_methods.rb#L315
24
+ def controller_resource_class(controller)
25
+ detected_resource_class = nil
26
+
27
+ if controller.respond_to? :name
28
+ detected_resource_class ||= begin
29
+ namespaced_class = controller.name.sub(/Controller$/, '').singularize
30
+ namespaced_class.constantize
31
+ rescue NameError
32
+ nil
33
+ end
34
+
35
+ # Second priority is the top namespace model, e.g. EngineName::Article for EngineName::Admin::ArticlesController
36
+ detected_resource_class ||= begin
37
+ namespaced_classes = controller.name.sub(/Controller$/, '').split('::')
38
+ namespaced_class = [namespaced_classes.first, namespaced_classes.last].join('::').singularize
39
+ namespaced_class.constantize
40
+ rescue NameError
41
+ nil
42
+ end
43
+
44
+ # Third priority the camelcased c, i.e. UserGroup
45
+ detected_resource_class ||= begin
46
+ camelcased_class = controller.name.sub(/Controller$/, '').gsub('::', '').singularize
47
+ camelcased_class.constantize
48
+ rescue NameError
49
+ nil
50
+ end
51
+
52
+ elsif controller.respond_to? :controller_name
53
+ # Otherwise use the Group class, or fail
54
+ detected_resource_class ||= begin
55
+ class_name = controller.controller_name.classify
56
+ class_name.constantize
57
+ rescue NameError => e
58
+ raise unless e.message.include?(class_name)
59
+ nil
60
+ end
61
+ else
62
+ detected_resource_class = nil
63
+ end
64
+ detected_resource_class
65
+ end
66
+
67
+ # Overridable method for available sortable fields
68
+ def sortable_fields_for(model)
69
+ if model.respond_to?(:sortable_fields)
70
+ model.sortable_fields.map(&:to_sym)
71
+ elsif model.respond_to?(:column_names)
72
+ model.column_names.map(&:to_sym)
73
+ else
74
+ []
75
+ end
76
+ end
77
+
78
+ # Overridable method for available filterable fields
79
+ def filterable_fields_for(model)
80
+ if model.respond_to?(:filterable_fields)
81
+ model.filterable_fields.map(&:to_sym)
82
+ elsif model.respond_to?(:column_names)
83
+ model.column_names.map(&:to_sym)
84
+ else
85
+ []
86
+ end
87
+ end
88
+
89
+ # Overridable method for available rangeable fields
90
+ def rangeable_fields_for(model)
91
+ if model.respond_to?(:rangeable_fields)
92
+ model.rangeable_fields.map(&:to_sym)
93
+ else
94
+ filterable_fields_for(model)
95
+ end
96
+ end
97
+
98
+ # ------------------------ Sort helpers --------------------
99
+
100
+ # model -> resource_class with inherited resources
101
+ def parse_sorting_param(sorting_param, model)
102
+ return {} unless sorting_param.present?
103
+
104
+ sorting_params = CSV.parse_line(URI.unescape(sorting_param)).collect do |sort|
105
+ if sort.start_with?('-')
106
+ sorting_param = { field: sort[1..-1].to_s.to_sym, direction: :desc}
107
+ else
108
+ sorting_param = { field: sort.to_s.to_sym, direction: :asc}
109
+ end
110
+
111
+ check_sorting_param(model, sorting_param)
112
+ sorting_param
113
+ end
114
+ sorting_params.map { |par| [par[:field], par[:direction]] }.to_h
115
+ end
116
+
117
+ def check_sorting_param(model, sorting_param)
118
+ sort_field = sorting_param[:field]
119
+ sortable_fields = sortable_fields_for(model)
120
+
121
+ unless sortable_fields.include? sort_field.to_sym
122
+ raise InvalidSortException, "The #{sort_field} field is not sortable"
123
+ end
124
+ end
125
+
126
+ def param_from_defaults(sorting_params)
127
+ sorting_params.map { |k, v| "#{v == :desc ? '-' : ''}#{k}" }.join(',')
128
+ end
129
+
130
+ def apply_sort_to_collection(collection, sorting_params)
131
+ return collection unless collection.any?
132
+ # p "Before apply: #{sorting_params.inspect}"
133
+ collection.order(sorting_params)
134
+ end
135
+
136
+ # ------------------------ Filter helpers --------------------
137
+
138
+ # Va transformer le param url en hash exploitable
139
+ def parse_filtering_param(filtering_param, allowed_params)
140
+ return {} unless filtering_param.present?
141
+
142
+ fields = {}
143
+
144
+ # Extract the fields for each type from the fields parameters
145
+ if filtering_param.is_a?(Hash)
146
+ filtering_param.each do |field, value|
147
+ resource_fields = value.split(',') unless value.nil? || value.empty?
148
+ fields[field.to_sym] = resource_fields
149
+ end
150
+ else
151
+ raise InvalidFilterException, "Invalid filter format for #{filtering_param}"
152
+ end
153
+ check_filtering_param(fields, allowed_params)
154
+ fields
155
+ end
156
+
157
+ # Our little barrier <3
158
+ def check_filtering_param(filtering_param, allowed)
159
+ 🔞 = filtering_param.keys.map(&:to_sym) - allowed.map(&:to_sym)
160
+ raise InvalidFilterException, "Attributes #{🔞.map(&:to_s).to_sentence} doesn't exists or aren't filterables. Available filters are: #{allowed.to_sentence}" if 🔞.any?
161
+ end
162
+
163
+ # On va essayer de garder un format commun, qui est:
164
+ #
165
+ # ```
166
+ # filter: {
167
+ # proc: -> (values) { * je fais des trucs avec les values * },
168
+ # all: ['les', 'valeurs', 'aceptées'],
169
+ # description: "La description dans la doc"
170
+ # }
171
+ # ```
172
+ #
173
+ # On va donc transformer `additional` dans le format ci-dessus
174
+ #
175
+ def format_additional_param(additional, context_format = 'filtering')
176
+ if additional.is_a? Hash
177
+ additional = additional.map do |field, value|
178
+ if value.is_a?(Hash)
179
+ value = {
180
+ proc: nil,
181
+ all: [],
182
+ description: ''
183
+ }.merge(value)
184
+ elsif value.is_a? Array
185
+ value = {
186
+ proc: value.try(:at, 0),
187
+ all: value.try(:at, 1) || [],
188
+ description: value.try(:at, 2) || ''
189
+ }
190
+ elsif value.is_a? Proc
191
+ value = {
192
+ proc: value,
193
+ all: [],
194
+ description: ''
195
+ }
196
+ else
197
+ raise "Unable to format addional #{context_format} params (got #{additional})"
198
+ end
199
+ [field, value]
200
+ end.to_h
201
+ end
202
+ additional
203
+ end
204
+
205
+ def apply_filter_to_collection(collection, filtering_params, additional = {})
206
+ return collection if collection.nil?
207
+
208
+ filtering_params.each do |field, value|
209
+ if additional.key?(field) && additional[field].key?(:proc)
210
+
211
+ # Si on a fourni des valeurs, on verifie qu'elle matchent
212
+ if additional[field].key?(:all) && additional[field][:all].try(:any?)
213
+ allowed = additional[field][:all].map(&:to_s)
214
+ raise InvalidFilterValueException, "Value #{(value - allowed).to_sentence} is not allowed for filter #{field}, can be #{allowed.to_sentence}" if (value - allowed).any?
215
+ end
216
+
217
+ collection = collection.instance_exec(value, &(additional[field][:proc]))
218
+ elsif value.is_a?(String) || value.is_a?(Array)
219
+ collection = collection.where(field => value)
220
+ elsif value.is_a?(Hash) && value.key?(:proc)
221
+ collection
222
+ end
223
+ end
224
+ collection
225
+ end
226
+
227
+ # ------------------------ Range helpers --------------------
228
+
229
+ # Va transformer le param url en hash exploitable
230
+ def parse_ranged_param(ranged_param, allowed_params)
231
+ return {} unless ranged_param.present?
232
+
233
+ fields = {}
234
+
235
+ # Extract the fields for each type from the fields parameters
236
+ if ranged_param.is_a?(Hash)
237
+ ranged_param.each do |field, value|
238
+ resource_fields = value.split(',') unless value.nil? || value.empty?
239
+ raise InvalidRangeException, "Invalid range format for #{ranged_param}. Too many arguments for filter (#{resource_fields})." if resource_fields.length > 2
240
+ raise InvalidRangeException, "Invalid range format for #{ranged_param}. Begin and end must be separated by a comma (,)." if resource_fields.length < 2
241
+ fields[field.to_sym] = resource_fields
242
+ end
243
+ else
244
+ raise InvalidRangeException, "Invalid range format for #{ranged_param}"
245
+ end
246
+ check_ranged_param(fields, allowed_params)
247
+ fields
248
+ end
249
+
250
+ # Our little barrier <3
251
+ def check_ranged_param(ranged_param, allowed)
252
+ 🔞 = ranged_param.keys.map(&:to_sym) - allowed.map(&:to_sym)
253
+ raise InvalidRangeException, "Attributes #{🔞.map(&:to_s).to_sentence} doesn't exists or aren't rangeables. Available ranges are: #{allowed.to_sentence}" if 🔞.any?
254
+ end
255
+
256
+
257
+ def apply_range_to_collection(collection, ranged_params, additional = {})
258
+ return collection if collection.nil?
259
+
260
+ ranged_params.each do |field, value|
261
+ if additional.key?(field) && additional[field].key?(:proc)
262
+
263
+ # Si on a fourni des valeurs, on verifie qu'elle matchent
264
+ if additional[field].key?(:all) && additional[field][:all].try(:any?)
265
+ allowed = additional[field][:all].map(&:to_s)
266
+ raise InvalidRangeValueException, "
267
+ Value #{(value - allowed).to_sentence} is not allowed for range #{field}, can be #{allowed.to_sentence}
268
+ " if (value - allowed).any?
269
+ end
270
+ collection = collection.instance_exec(value.try(:first), value.try(:last), &(additional[field][:proc]))
271
+ elsif value.is_a? Array
272
+ from, to = value.slice(0, 2)
273
+ begin
274
+ collection = collection.where(field => from..to)
275
+ rescue ArgumentError
276
+ raise InvalidRangeValueException, "
277
+ Unable to create a range between values '#{from}' and '#{to}'
278
+ "
279
+ end
280
+ elsif value.is_a?(Hash) && value.key?(:proc)
281
+ collection
282
+ end
283
+ end
284
+ collection
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,36 @@
1
+ require 'active_support'
2
+
3
+ module RiceCooker
4
+ module Range
5
+ extend ActiveSupport::Concern
6
+
7
+ FILTER_PARAM = :range
8
+
9
+ module ClassMethods
10
+ include Helpers
11
+
12
+ def ranged(additional_ranged_params = {})
13
+ cattr_accessor :ranged_keys
14
+ cattr_accessor :custom_ranges
15
+
16
+ resource_class ||= controller_resource_class(self) unless respond_to?(:resource_class)
17
+
18
+ # On normalize tout ca
19
+ additional_ranged_params = format_additional_param(additional_ranged_params, 'ranged')
20
+
21
+ # On recupere tous les filtres autorisés
22
+ allowed_keys = (rangeable_fields_for(resource_class) + additional_ranged_params.keys)
23
+
24
+ # On recupere le default
25
+ self.ranged_keys = allowed_keys
26
+ self.custom_ranges = additional_ranged_params
27
+
28
+ has_scope :range, type: :hash, only: [:index] do |_controller, scope, value|
29
+ params = parse_ranged_param(value, ranged_keys)
30
+ scope = apply_range_to_collection(scope, params, custom_ranges)
31
+ scope
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,49 @@
1
+ require 'csv'
2
+ require 'active_support'
3
+
4
+ module RiceCooker
5
+ module Sort
6
+ extend ActiveSupport::Concern
7
+
8
+ SORT_PARAM = :sort
9
+
10
+ module ClassMethods
11
+ include Helpers
12
+
13
+ #
14
+ # Will handle collection (index) sorting on inherited resource controllers
15
+ #
16
+ # All endpoints support multiple sort fields by allowing comma-separated (`,`) sort fields.
17
+ # Sort fields are applied in the order specified.
18
+ # The sort order for each sort field is ascending unless it is prefixed with a minus (U+002D HYPHEN-MINUS, “-“), in which case it is descending.
19
+ #
20
+ def sorted(default_sorting_params = { id: :desc })
21
+ cattr_accessor :default_order
22
+ cattr_accessor :sorted_keys
23
+
24
+ resource_class ||= controller_resource_class(self) unless respond_to?(:resource_class)
25
+
26
+ return unless sorted_keys.nil? || resource_class.nil?
27
+
28
+ default_sorting_params = { default_sorting_params => :asc } if default_sorting_params.is_a? Symbol
29
+
30
+ # On recupere le default
31
+ self.default_order = default_sorting_params
32
+ self.sorted_keys = (resource_class.respond_to?(:sortable_fields) ? resource_class.sortable_fields : [])
33
+ default_sort = param_from_defaults(default_sorting_params)
34
+
35
+ has_scope :sort, default: default_sort, only: [:index] do |controller, scope, value|
36
+ if controller.params[SORT_PARAM].present?
37
+ scope = apply_sort_to_collection(scope, parse_sorting_param(value, resource_class))
38
+ else
39
+ scope = apply_sort_to_collection(scope, default_sorting_params)
40
+ end
41
+ scope
42
+ end
43
+
44
+ rescue NoMethodError => e
45
+ "Just wanna die ⚓️ #{e}"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,3 @@
1
+ module RiceCooker
2
+ VERSION = '0.1.1'.freeze
3
+ end
@@ -0,0 +1,17 @@
1
+ require 'action_controller'
2
+
3
+ module RiceCooker
4
+ autoload :Helpers, 'rice_cooker/helpers'
5
+ autoload :Filter, 'rice_cooker/filter'
6
+ autoload :Sort, 'rice_cooker/sort'
7
+ autoload :Range, 'rice_cooker/range'
8
+ autoload :VERSION, 'rice_cooker/version'
9
+ end
10
+
11
+ module ActionController
12
+ class Base
13
+ include RiceCooker::Sort
14
+ include RiceCooker::Filter
15
+ include RiceCooker::Range
16
+ end
17
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :rice_cooker do
3
+ # # Task goes here
4
+ # end
Binary file
@@ -0,0 +1,42 @@
1
+ $LOAD_PATH.push File.expand_path('../lib', __FILE__)
2
+
3
+ # Maintain your gem's version:
4
+ require 'rice_cooker/version'
5
+
6
+ # Describe your gem and declare its dependencies:
7
+ Gem::Specification.new do |s|
8
+ s.name = 'rice_cooker'
9
+ s.version = RiceCooker::VERSION
10
+ s.authors = ['Andre Aubin']
11
+ s.email = ['andre.aubin@lambdaweb.fr']
12
+ s.homepage = 'https://github.com/lambda2/rice_cooker'
13
+ s.summary = 'A collection manager for Rails API\'s'
14
+ s.description = 'Handle sort, filters, searches, and ranges on Rails collections.'
15
+ s.license = 'MIT'
16
+
17
+ s.files = `git ls-files`.split(/\n/)
18
+ s.test_files = `git ls-files -- spec/*`.split(/\n/)
19
+ s.require_paths = ['lib']
20
+
21
+ s.add_dependency 'rails', '~> 5.0.0', '< 5.1'
22
+ s.add_dependency 'actionpack', '~> 5.0.0', '< 5.1'
23
+ s.add_dependency 'railties', '~> 5.0.0', '< 5.1'
24
+ s.add_dependency 'has_scope', '~> 0.7.0', '>= 0.6.0'
25
+
26
+ s.add_development_dependency 'sqlite3'
27
+ s.add_development_dependency 'rspec'
28
+ s.add_development_dependency 'rspec-rails'
29
+ s.add_development_dependency 'rspec-activemodel-mocks'
30
+ s.add_development_dependency 'mocha', '1.1.0'
31
+ s.add_development_dependency 'ruby-prof', '0.15.8'
32
+ s.add_development_dependency 'test-unit', '3.1.3'
33
+ s.add_development_dependency 'rails-perftest', '0.0.6'
34
+ s.add_development_dependency 'simplecov', '0.11.1'
35
+ s.add_development_dependency 'factory_girl_rails', '~> 4.0'
36
+ s.add_development_dependency 'database_cleaner'
37
+ s.add_development_dependency 'faker', '1.6.1'
38
+ s.add_development_dependency 'pry'
39
+ s.add_development_dependency 'derailed'
40
+ s.add_development_dependency 'stackprof'
41
+ s.add_development_dependency 'rubocop', '~> 0.40.0'
42
+ end