might 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.hound.yml +2 -0
- data/.rspec +3 -0
- data/.rubocop.yml +19 -0
- data/.travis.yml +14 -0
- data/Gemfile +6 -0
- data/LICENSE +202 -0
- data/README.md +301 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/config/locales/en.yml +7 -0
- data/lib/might.rb +4 -0
- data/lib/might/fetcher.rb +252 -0
- data/lib/might/fetcher_error.rb +4 -0
- data/lib/might/filter_middleware.rb +28 -0
- data/lib/might/filter_parameter.rb +68 -0
- data/lib/might/filter_parameter_definition.rb +82 -0
- data/lib/might/filter_parameters_extractor.rb +78 -0
- data/lib/might/filter_parameters_validator.rb +26 -0
- data/lib/might/filter_predicates.rb +26 -0
- data/lib/might/filter_undefined_parameter.rb +15 -0
- data/lib/might/filter_value_validator.rb +43 -0
- data/lib/might/pagination_middleware.rb +60 -0
- data/lib/might/pagination_parameters_validator.rb +55 -0
- data/lib/might/paginator.rb +58 -0
- data/lib/might/railtie.rb +8 -0
- data/lib/might/ransackable_filter.rb +24 -0
- data/lib/might/ransackable_filter_parameters_adapter.rb +61 -0
- data/lib/might/ransackable_sort.rb +27 -0
- data/lib/might/ransackable_sort_parameters_adapter.rb +23 -0
- data/lib/might/result.rb +68 -0
- data/lib/might/sort_middleware.rb +31 -0
- data/lib/might/sort_parameter.rb +54 -0
- data/lib/might/sort_parameter_definition.rb +54 -0
- data/lib/might/sort_parameters_extractor.rb +62 -0
- data/lib/might/sort_parameters_validator.rb +26 -0
- data/lib/might/sort_undefined_parameter.rb +15 -0
- data/lib/might/sort_value_validator.rb +43 -0
- data/lib/might/version.rb +4 -0
- data/might.gemspec +33 -0
- metadata +240 -0
@@ -0,0 +1,78 @@
|
|
1
|
+
require_relative 'filter_predicates'
|
2
|
+
require_relative 'filter_undefined_parameter'
|
3
|
+
require_relative 'filter_parameter'
|
4
|
+
|
5
|
+
module Might
|
6
|
+
# User provided filters syntax:
|
7
|
+
# * `{ 'name_eq' => 'Martin' }` - name is equal to Martin
|
8
|
+
# * `{ 'name_in' => 'Martin,Bob' }` - name is either Martin or Bob
|
9
|
+
#
|
10
|
+
# This middleware parses filters hash and builds Parameter array
|
11
|
+
# @see Might::FilterParameter
|
12
|
+
#
|
13
|
+
# If user passes not defined filter, it yields to `UndefinedParameter`, so you may
|
14
|
+
# validate it.
|
15
|
+
#
|
16
|
+
class FilterParametersExtractor
|
17
|
+
# @param app [#call]
|
18
|
+
# @param parameters_definition [Set<Might::FilterParameterDefinition>]
|
19
|
+
def initialize(app, parameters_definition)
|
20
|
+
@app = app
|
21
|
+
@parameters_definition = parameters_definition
|
22
|
+
end
|
23
|
+
|
24
|
+
# @param env [<{:filter => Hash}, []>]
|
25
|
+
# * first element is a scope to be filtered
|
26
|
+
# * second is a hash with user provided filters
|
27
|
+
# @return [<{:filter => <Might::FilterParameter>, []}]
|
28
|
+
#
|
29
|
+
def call(env)
|
30
|
+
params, errors = env
|
31
|
+
|
32
|
+
provided_parameters = Hash(params[:filter]).each_with_object([]) do |(name, value), parameters|
|
33
|
+
type_casted_value = type_cast_value(name, value)
|
34
|
+
parameters << extract_parameter(name, type_casted_value)
|
35
|
+
end
|
36
|
+
|
37
|
+
not_provided_parameters = parameters_definition - provided_parameters.map(&:definition)
|
38
|
+
|
39
|
+
undefined_parameters = not_provided_parameters.map do |definition|
|
40
|
+
FilterParameter.new(nil, nil, definition)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Keep even undefined parameters to validate required ones
|
44
|
+
filters = provided_parameters + undefined_parameters
|
45
|
+
|
46
|
+
app.call([params.merge(filter: filters), errors])
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
attr_reader :parameters_definition, :app
|
52
|
+
|
53
|
+
def name_and_predicate(name_with_predicate)
|
54
|
+
predicate = FilterPredicates::ALL.detect { |p| name_with_predicate.end_with?("_#{p}") }
|
55
|
+
[
|
56
|
+
name_with_predicate.chomp("_#{predicate}"),
|
57
|
+
predicate
|
58
|
+
]
|
59
|
+
end
|
60
|
+
|
61
|
+
# @param [String] name
|
62
|
+
# @param [String, Array<String>] value
|
63
|
+
#
|
64
|
+
def extract_parameter(name, value)
|
65
|
+
name, predicate = name_and_predicate(name)
|
66
|
+
definition = parameters_definition.detect { |d| d.as == name } || FilterUndefinedParameter.new(name)
|
67
|
+
FilterParameter.new(value, predicate, definition)
|
68
|
+
end
|
69
|
+
|
70
|
+
def type_cast_value(name, value)
|
71
|
+
if name.end_with?(*FilterPredicates::ON_ARRAY)
|
72
|
+
value.split(',')
|
73
|
+
else
|
74
|
+
value
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Might
|
2
|
+
# Validates filters and raises error if one of them is invalid
|
3
|
+
#
|
4
|
+
class FilterParametersValidator
|
5
|
+
# @param app [#call]
|
6
|
+
def initialize(app)
|
7
|
+
@app = app
|
8
|
+
end
|
9
|
+
|
10
|
+
# @param env [<{:filter => <Might::FilterParameter>, Array>}]
|
11
|
+
# @return [<{:filter => <Might::FilterParameter>, Array>}]
|
12
|
+
#
|
13
|
+
def call(env)
|
14
|
+
params, errors = env
|
15
|
+
|
16
|
+
invalid_filters = Array(params[:filter]).select(&:invalid?)
|
17
|
+
messages = invalid_filters.flat_map(&:errors)
|
18
|
+
|
19
|
+
app.call([params, errors.concat(messages)])
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
attr_reader :app
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Might
|
2
|
+
# Contains contains with all supported predicates
|
3
|
+
#
|
4
|
+
module FilterPredicates
|
5
|
+
ON_VALUE = %w(
|
6
|
+
not_eq eq
|
7
|
+
does_not_match matches
|
8
|
+
gt lt
|
9
|
+
gteq lteq
|
10
|
+
not_cont cont
|
11
|
+
not_start start
|
12
|
+
not_end end
|
13
|
+
not_true true
|
14
|
+
not_false false
|
15
|
+
blank present
|
16
|
+
not_null null
|
17
|
+
)
|
18
|
+
|
19
|
+
ON_ARRAY = %w(
|
20
|
+
not_in in
|
21
|
+
not_cont_any cont_any
|
22
|
+
)
|
23
|
+
|
24
|
+
ALL = ON_VALUE + ON_ARRAY
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'active_support/core_ext/module/delegation'
|
3
|
+
require 'active_model/validator'
|
4
|
+
require 'active_model/validations'
|
5
|
+
require 'active_model/naming'
|
6
|
+
require 'active_model/callbacks'
|
7
|
+
require 'active_model/translation'
|
8
|
+
require 'active_model/errors'
|
9
|
+
|
10
|
+
module Might
|
11
|
+
# Build singleton validation class for specified attribute name
|
12
|
+
# @example you need a nice validator for a first_name
|
13
|
+
# validator_klass = ValueValidator.build('first_name', presence: true, length: { minimum: 3 })
|
14
|
+
# validator = validator_klass.new('Bo')
|
15
|
+
# validator.valid? #=> false
|
16
|
+
# validator.errors.full_messages #=> ['First name is too short (minimum is 3 characters)']
|
17
|
+
#
|
18
|
+
module FilterValueValidator
|
19
|
+
module_function
|
20
|
+
|
21
|
+
# Validates if Parameter is undefined or not
|
22
|
+
class DefinedValidator < ActiveModel::EachValidator
|
23
|
+
def validate_each(record, attribute, _value)
|
24
|
+
record.errors.add(attribute, :undefined_filter) if record.undefined?
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def build(definition)
|
29
|
+
Struct.new(definition.as.to_sym) do
|
30
|
+
include ActiveModel::Validations
|
31
|
+
|
32
|
+
validates(definition.as, definition.validations) if definition.validations.present?
|
33
|
+
validates(definition.as, 'might/filter_value_validator/defined': true)
|
34
|
+
|
35
|
+
define_method(:undefined?) { definition.undefined? }
|
36
|
+
|
37
|
+
def self.model_name
|
38
|
+
ActiveModel::Name.new(Might, nil, 'Might')
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# require_relative 'validator'
|
2
|
+
require 'might/paginator'
|
3
|
+
|
4
|
+
module Might
|
5
|
+
# Pagination middleware
|
6
|
+
#
|
7
|
+
class PaginationMiddleware
|
8
|
+
# @param app [#call, Proc]
|
9
|
+
# @param max_per_page [Integer]
|
10
|
+
# @param per_page [Integer]
|
11
|
+
#
|
12
|
+
def initialize(app, max_per_page: false, per_page: 50)
|
13
|
+
@app = app
|
14
|
+
@max_per_page = max_per_page
|
15
|
+
@per_page = per_page
|
16
|
+
end
|
17
|
+
|
18
|
+
# @param [Array(ActiveRecord::Relation, Hash)] env
|
19
|
+
# First argument is a ActiveRecord relation which must be paginated
|
20
|
+
# Second argument is a request parameters provided by user
|
21
|
+
#
|
22
|
+
def call(env)
|
23
|
+
scope, params = env
|
24
|
+
# validate_parameters!(params)
|
25
|
+
paginated_scope = Paginator.new(pagination_options(params)).paginate(scope)
|
26
|
+
app.call([paginated_scope, params])
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
attr_reader :app
|
32
|
+
|
33
|
+
# def validate_parameters!(params)
|
34
|
+
# validator = Validator.new(params[:page])
|
35
|
+
# fail(PaginationValidationFailed, validator.errors) unless validator.valid?
|
36
|
+
# end
|
37
|
+
|
38
|
+
# @param [Hash] params
|
39
|
+
# @option params [Hash] (nil) :limit
|
40
|
+
# @option params [Hash] (nil) :offset
|
41
|
+
#
|
42
|
+
def pagination_options(params)
|
43
|
+
options = default_pagination_options.merge(Hash(params[:page]))
|
44
|
+
max_per_page = @max_per_page
|
45
|
+
|
46
|
+
if max_per_page && options[:limit] > max_per_page
|
47
|
+
options.merge(limit: max_per_page)
|
48
|
+
else
|
49
|
+
options
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def default_pagination_options
|
54
|
+
{
|
55
|
+
limit: @per_page,
|
56
|
+
offset: 0
|
57
|
+
}.with_indifferent_access
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
require 'active_support/core_ext/module/delegation'
|
3
|
+
require 'active_support/callbacks'
|
4
|
+
require 'active_model/naming'
|
5
|
+
require 'active_model/validator'
|
6
|
+
require 'active_model/callbacks'
|
7
|
+
require 'active_model/translation'
|
8
|
+
require 'active_model/errors'
|
9
|
+
require 'active_model/validations'
|
10
|
+
|
11
|
+
module Might
|
12
|
+
#
|
13
|
+
class PaginationParametersValidator
|
14
|
+
# Validates pagination parameters
|
15
|
+
# @param [Hash, nil] page
|
16
|
+
# @option page [Number, nil] :limit
|
17
|
+
# @option page [Number, nil] :offset
|
18
|
+
#
|
19
|
+
Validator = Struct.new(:page) do
|
20
|
+
include ActiveModel::Validations
|
21
|
+
|
22
|
+
validate :page_is_a_hash
|
23
|
+
validates :limit, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
24
|
+
validates :offset, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
25
|
+
|
26
|
+
def page_is_a_hash
|
27
|
+
return if page.is_a?(Hash) || page.nil?
|
28
|
+
errors.add(:page, :invalid_page_type)
|
29
|
+
end
|
30
|
+
|
31
|
+
def limit
|
32
|
+
page[:limit] if page.is_a?(Hash)
|
33
|
+
end
|
34
|
+
|
35
|
+
def offset
|
36
|
+
page[:offset] if page.is_a?(Hash)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def initialize(app)
|
41
|
+
@app = app
|
42
|
+
end
|
43
|
+
|
44
|
+
def call(env)
|
45
|
+
params, errors = env
|
46
|
+
|
47
|
+
validator = Validator.new(params[:page]).tap(&:validate)
|
48
|
+
messages = validator.errors.full_messages
|
49
|
+
|
50
|
+
app.call([params, errors.concat(messages)])
|
51
|
+
end
|
52
|
+
|
53
|
+
attr_reader :app
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# require 'active_support/core_ext/hash/keys'
|
2
|
+
|
3
|
+
module Might
|
4
|
+
# Paginates ActiveRecord scopes
|
5
|
+
# @example
|
6
|
+
#
|
7
|
+
# Paginator.new(limit: 10, offset: 100).paginate(Movie.all)
|
8
|
+
#
|
9
|
+
# As a side effect of pagination it defines the following methods on collection:
|
10
|
+
# collection#limit
|
11
|
+
# collection#offset
|
12
|
+
# collection#count
|
13
|
+
# collection#total_count
|
14
|
+
#
|
15
|
+
class Paginator
|
16
|
+
InvalidLimitOrOffset = Class.new(StandardError)
|
17
|
+
|
18
|
+
attr_reader :limit, :offset
|
19
|
+
|
20
|
+
# @param [{Symbol => Integer}] params
|
21
|
+
# @option params [Integer] :limit
|
22
|
+
# @option params [Integer] :offset
|
23
|
+
#
|
24
|
+
def initialize(options = {})
|
25
|
+
@limit = Integer(options.fetch(:limit))
|
26
|
+
@offset = Integer(options.fetch(:offset))
|
27
|
+
|
28
|
+
fail InvalidLimitOrOffset if @limit < 0 || @offset < 0
|
29
|
+
end
|
30
|
+
|
31
|
+
# Paginate given collection
|
32
|
+
# @param [ActiveRecord::CollectionProxy, ActiveRecord::Base] collection
|
33
|
+
# @return [ActiveRecord::CollectionProxy]
|
34
|
+
#
|
35
|
+
def paginate(collection)
|
36
|
+
paginated_collection = collection.offset(offset).limit(limit)
|
37
|
+
|
38
|
+
pagination_hash = pagination(collection, paginated_collection)
|
39
|
+
|
40
|
+
paginated_collection.define_singleton_method(:pagination) do
|
41
|
+
pagination_hash
|
42
|
+
end
|
43
|
+
|
44
|
+
paginated_collection
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def pagination(collection, paginated_collection)
|
50
|
+
{
|
51
|
+
limit: limit,
|
52
|
+
offset: offset,
|
53
|
+
count: paginated_collection.count(:all),
|
54
|
+
total: collection.count(:all)
|
55
|
+
}
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Might
|
2
|
+
#
|
3
|
+
class RansackableFilter
|
4
|
+
# @param app [#call]
|
5
|
+
def initialize(app)
|
6
|
+
@app = app
|
7
|
+
end
|
8
|
+
|
9
|
+
# @param env [<ActiveRecord::Relation, Hash]
|
10
|
+
# * first element is a scope to be filtered
|
11
|
+
# * second is a hash with user provided filters
|
12
|
+
# @return [<ActiveRecord::Relation, Hash]
|
13
|
+
#
|
14
|
+
def call(env)
|
15
|
+
scope, params = env
|
16
|
+
filtered_scope = scope.ransack(params[:filter]).result
|
17
|
+
app.call([filtered_scope, params])
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
attr_reader :app
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Might
|
2
|
+
# Converts array of parameters to hash familiar to ransack gem
|
3
|
+
#
|
4
|
+
class RansackableFilterParametersAdapter
|
5
|
+
def initialize(app)
|
6
|
+
@app = app
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(env)
|
10
|
+
scope, params = env
|
11
|
+
|
12
|
+
ransackable_parameters = Array(params[:filter]).reject { |f| f.predicate.nil? }
|
13
|
+
.each_with_object({}) do |filter, ransackable_filters|
|
14
|
+
ransackable_filters[canonical_name_for(filter)] = filter.value
|
15
|
+
end
|
16
|
+
|
17
|
+
app.call([scope, params.merge(filter: ransackable_parameters)])
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
attr_reader :app
|
23
|
+
|
24
|
+
# @return [String, nil] filter with predicate. E.g. `first_name_eq`
|
25
|
+
# nil value means that
|
26
|
+
#
|
27
|
+
def canonical_name_for(filter)
|
28
|
+
if filter.on.is_a?(Hash)
|
29
|
+
name_for_polymorphic(filter)
|
30
|
+
else
|
31
|
+
[filter.on, filter.name, filter.predicate].compact.join('_')
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Build query method for polymorphic association
|
36
|
+
# @see https://github.com/activerecord-hackery/ransack/wiki/Polymorphic-searches
|
37
|
+
# @see https://github.com/activerecord-hackery/ransack/commit/c156fa4a7ac6e1a8d730791c49bf4403aa0f3af7#diff-a26803e1ff6e56eb67b80c91d79a063eR34
|
38
|
+
# @param [SortParameter] filter
|
39
|
+
# @return [String]
|
40
|
+
#
|
41
|
+
# @example
|
42
|
+
#
|
43
|
+
# definition = ParameterDefinition.new('genre_name', on: { resource: ['Movie', 'Channel'] })
|
44
|
+
# parameter = Parameter.new('Horror', 'eq', definition)
|
45
|
+
# name_for_polymorphic(parameter)
|
46
|
+
# #=> 'resource_of_Movie_type_genre_name_or_resource_of_Channel_type_genre_name_eq'
|
47
|
+
#
|
48
|
+
def name_for_polymorphic(filter)
|
49
|
+
unless filter.on.size == 1
|
50
|
+
fail ArgumentError, 'Polymorphic association must be defined as Hash with single value'
|
51
|
+
end
|
52
|
+
polymorphic_name = filter.on.keys.first
|
53
|
+
|
54
|
+
name = Array(filter.on.values.first)
|
55
|
+
.map { |polymorphic_type| "#{polymorphic_name}_of_#{polymorphic_type}_type_#{filter.name}" }
|
56
|
+
.join('_or_')
|
57
|
+
|
58
|
+
"#{name}_#{filter.predicate}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|