rice_cooker 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +51 -0
- data/.rubocop.yml +22 -0
- data/.travis.yml +6 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +224 -0
- data/LICENSE +21 -0
- data/README.md +38 -0
- data/Rakefile +28 -0
- data/lib/rice_cooker/filter.rb +97 -0
- data/lib/rice_cooker/helpers.rb +287 -0
- data/lib/rice_cooker/range.rb +36 -0
- data/lib/rice_cooker/sort.rb +49 -0
- data/lib/rice_cooker/version.rb +3 -0
- data/lib/rice_cooker.rb +17 -0
- data/lib/tasks/rice_cooker_tasks.rake +4 -0
- data/rice_cooker-0.1.0.gem +0 -0
- data/rice_cooker.gemspec +42 -0
- data/spec/filter/filter_spec.rb +264 -0
- data/spec/mocks/config.rb +2 -0
- data/spec/mocks/controllers/users_controller.rb +58 -0
- data/spec/mocks/mocks.rb +4 -0
- data/spec/mocks/models/user.rb +31 -0
- data/spec/range/range_spec.rb +265 -0
- data/spec/sort/sort_spec.rb +117 -0
- data/spec/spec_helper.rb +23 -0
- metadata +381 -0
@@ -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
|
data/lib/rice_cooker.rb
ADDED
@@ -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
|
Binary file
|
data/rice_cooker.gemspec
ADDED
@@ -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
|