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