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.
- 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
|