might 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.hound.yml +2 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +19 -0
  6. data/.travis.yml +14 -0
  7. data/Gemfile +6 -0
  8. data/LICENSE +202 -0
  9. data/README.md +301 -0
  10. data/Rakefile +6 -0
  11. data/bin/console +14 -0
  12. data/bin/setup +7 -0
  13. data/config/locales/en.yml +7 -0
  14. data/lib/might.rb +4 -0
  15. data/lib/might/fetcher.rb +252 -0
  16. data/lib/might/fetcher_error.rb +4 -0
  17. data/lib/might/filter_middleware.rb +28 -0
  18. data/lib/might/filter_parameter.rb +68 -0
  19. data/lib/might/filter_parameter_definition.rb +82 -0
  20. data/lib/might/filter_parameters_extractor.rb +78 -0
  21. data/lib/might/filter_parameters_validator.rb +26 -0
  22. data/lib/might/filter_predicates.rb +26 -0
  23. data/lib/might/filter_undefined_parameter.rb +15 -0
  24. data/lib/might/filter_value_validator.rb +43 -0
  25. data/lib/might/pagination_middleware.rb +60 -0
  26. data/lib/might/pagination_parameters_validator.rb +55 -0
  27. data/lib/might/paginator.rb +58 -0
  28. data/lib/might/railtie.rb +8 -0
  29. data/lib/might/ransackable_filter.rb +24 -0
  30. data/lib/might/ransackable_filter_parameters_adapter.rb +61 -0
  31. data/lib/might/ransackable_sort.rb +27 -0
  32. data/lib/might/ransackable_sort_parameters_adapter.rb +23 -0
  33. data/lib/might/result.rb +68 -0
  34. data/lib/might/sort_middleware.rb +31 -0
  35. data/lib/might/sort_parameter.rb +54 -0
  36. data/lib/might/sort_parameter_definition.rb +54 -0
  37. data/lib/might/sort_parameters_extractor.rb +62 -0
  38. data/lib/might/sort_parameters_validator.rb +26 -0
  39. data/lib/might/sort_undefined_parameter.rb +15 -0
  40. data/lib/might/sort_value_validator.rb +43 -0
  41. data/lib/might/version.rb +4 -0
  42. data/might.gemspec +33 -0
  43. 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,15 @@
1
+ require_relative 'filter_parameter_definition'
2
+
3
+ module Might
4
+ # Null object for ParameterDefinition
5
+ #
6
+ class FilterUndefinedParameter < FilterParameterDefinition
7
+ def required?
8
+ false
9
+ end
10
+
11
+ def defined?
12
+ false
13
+ end
14
+ end
15
+ 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,8 @@
1
+ require 'rails/railtie'
2
+
3
+ #
4
+ module Might
5
+ # Load railtie to get translations
6
+ class Railtie < Rails::Railtie
7
+ end
8
+ 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