might 0.3.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 +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
|