objective 0.1.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/lib/objective/allow.rb +10 -0
  3. data/lib/objective/deny.rb +10 -0
  4. data/lib/objective/discard.rb +10 -0
  5. data/lib/objective/errors/error_array.rb +21 -0
  6. data/lib/objective/errors/error_atom.rb +27 -0
  7. data/lib/objective/errors/error_hash.rb +65 -0
  8. data/lib/objective/errors/error_message_creator.rb +46 -0
  9. data/lib/objective/errors/validation_error.rb +14 -0
  10. data/lib/objective/filter.rb +166 -0
  11. data/lib/objective/filters/any_filter.rb +7 -0
  12. data/lib/objective/filters/array_filter.rb +49 -0
  13. data/lib/objective/filters/boolean_filter.rb +21 -0
  14. data/lib/objective/filters/date_filter.rb +29 -0
  15. data/lib/objective/filters/decimal_filter.rb +46 -0
  16. data/lib/objective/filters/duck_filter.rb +18 -0
  17. data/lib/objective/filters/file_filter.rb +22 -0
  18. data/lib/objective/filters/float_filter.rb +45 -0
  19. data/lib/objective/filters/hash_filter.rb +42 -0
  20. data/lib/objective/filters/integer_filter.rb +60 -0
  21. data/lib/objective/filters/model_filter.rb +23 -0
  22. data/lib/objective/filters/root_filter.rb +54 -0
  23. data/lib/objective/filters/string_filter.rb +30 -0
  24. data/lib/objective/filters/time_filter.rb +30 -0
  25. data/lib/objective/filters.rb +140 -0
  26. data/lib/objective/none.rb +10 -0
  27. data/lib/objective/outcome.rb +5 -0
  28. data/lib/objective/unit.rb +103 -0
  29. data/lib/objective.rb +53 -0
  30. data/spec/default_spec.rb +33 -0
  31. data/spec/errors_spec.rb +135 -0
  32. data/spec/filters/any_filter_spec.rb +34 -0
  33. data/spec/filters/array_filter_spec.rb +195 -0
  34. data/spec/filters/boolean_filter_spec.rb +66 -0
  35. data/spec/filters/date_filter_spec.rb +145 -0
  36. data/spec/filters/decimal_filter_spec.rb +199 -0
  37. data/spec/filters/duck_filter_spec.rb +49 -0
  38. data/spec/filters/file_filter_spec.rb +93 -0
  39. data/spec/filters/float_filter_spec.rb +140 -0
  40. data/spec/filters/hash_filter_spec.rb +65 -0
  41. data/spec/filters/integer_filter_spec.rb +150 -0
  42. data/spec/filters/model_filter_spec.rb +98 -0
  43. data/spec/filters/root_filter_spec.rb +113 -0
  44. data/spec/filters/string_filter_spec.rb +251 -0
  45. data/spec/filters/time_filter_spec.rb +123 -0
  46. data/spec/inheritance_spec.rb +36 -0
  47. data/spec/simple_unit.rb +18 -0
  48. data/spec/spec_helper.rb +9 -0
  49. data/spec/unit_spec.rb +244 -0
  50. metadata +167 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 07f72b29c73d8dc130b6aad5d85b508d3cb3e574
4
+ data.tar.gz: '09adcd4a2daa8733a07dfd011cdb9aedeb0d5f41'
5
+ SHA512:
6
+ metadata.gz: 549dc86b4ae3352c525da82fb706a5f939d3e27a719638a54356439adf4e3fdbb8d4bb6708cfcfee1e42edfab614dcf0b09007bcc695ecab96c64fe619a2baab
7
+ data.tar.gz: e7fe8fe0504f224627566395b1aaad7ed17301d7a540b5017463850889cc8396e478ef1bf25b04cadac9f3eb23f7cd53734373bcc4e038ca1a30bde5ac9e05ca
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ require 'singleton'
3
+
4
+ module Objective
5
+ class Allow
6
+ include Singleton
7
+ end
8
+ end
9
+
10
+ Objective::ALLOW = Objective::Allow.instance.freeze
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ require 'singleton'
3
+
4
+ module Objective
5
+ class Deny
6
+ include Singleton
7
+ end
8
+ end
9
+
10
+ Objective::DENY = Objective::Deny.instance.freeze
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ require 'singleton'
3
+
4
+ module Objective
5
+ class Discard
6
+ include Singleton
7
+ end
8
+ end
9
+
10
+ Objective::DISCARD = Objective::Discard.instance.freeze
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Errors
4
+ class ErrorArray < Array
5
+ def codes
6
+ map { |e| e&.codes }
7
+ end
8
+
9
+ def message(parent_key = nil, _index = nil)
10
+ each_with_index.map { |e, i| e&.message(parent_key, i) }
11
+ end
12
+
13
+ def message_list(parent_key = nil, _index = nil)
14
+ each_with_index.map do |e, i|
15
+ next if e.nil?
16
+ e.message_list(parent_key, i)
17
+ end.flatten.compact
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Errors
4
+ class ErrorAtom
5
+ attr_reader :key, :codes, :type, :datum, :bound
6
+
7
+ # ErrorAtom.new(:name, :too_short)
8
+ # ErrorAtom.new(:name, :too_short, message: "is too short")
9
+ def initialize(key, codes, options = {})
10
+ @key = key # attribute
11
+ @codes = codes
12
+ @message = options[:message]
13
+ @type = options[:type] # target class/filter of coercion
14
+ @value = options[:value] # value given
15
+ @bound = options[:bound] # value of validator
16
+ end
17
+
18
+ def message(parent_key = nil, index = nil)
19
+ @message ||= Objective.error_message_creator.message(self, parent_key, index)
20
+ end
21
+
22
+ def message_list(parent_key = nil, index = nil)
23
+ Array.wrap(message(parent_key, index))
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Errors
4
+ class ErrorHash < HashWithIndifferentAccess
5
+ # objective.errors is an ErrorHash instance like this:
6
+ # {
7
+ # email: ErrorAtom(:matches),
8
+ # name: ErrorAtom(:too_weird, message: "is too weird"),
9
+ # adddress: { # Nested ErrorHash object
10
+ # city: ErrorAtom(:not_found, message: "That's not a city, silly!"),
11
+ # state: ErrorAtom(:in)
12
+ # }
13
+ # }
14
+
15
+ # Returns a nested HashWithIndifferentAccess where the values are symbols. Eg:
16
+ # {
17
+ # email: :matches,
18
+ # name: :too_weird,
19
+ # adddress: {
20
+ # city: :not_found,
21
+ # state: :in
22
+ # }
23
+ # }
24
+ def codes
25
+ HashWithIndifferentAccess.new.tap do |hash|
26
+ each do |k, v|
27
+ hash[k] = v.codes
28
+ end
29
+ end
30
+ end
31
+
32
+ # Returns a nested HashWithIndifferentAccess where the values are messages. Eg:
33
+ # {
34
+ # email: "isn't in the right format",
35
+ # name: "is too weird",
36
+ # adddress: {
37
+ # city: "is not a city",
38
+ # state: "isn't a valid option"
39
+ # }
40
+ # }
41
+ def message(_parent_key = nil, _index = nil)
42
+ HashWithIndifferentAccess.new.tap do |hash|
43
+ each do |k, v|
44
+ hash[k] = v.message(k)
45
+ end
46
+ end
47
+ end
48
+
49
+ # Returns a flat array where each element is a full sentence. Eg:
50
+ # [
51
+ # "Email isn't in the right format.",
52
+ # "Name is too weird",
53
+ # "That's not a city, silly!",
54
+ # "State isn't a valid option."
55
+ # ]
56
+ def message_list(_parent_key = nil, _index = nil)
57
+ list = []
58
+ each do |k, v|
59
+ list.concat(v.message_list(k))
60
+ end
61
+ list
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Errors
4
+ class ErrorMessageCreator
5
+ MESSAGES = Hash.new('is invalid').tap do |h|
6
+ h.merge!(
7
+ nils: 'cannot be nil',
8
+ required: 'is required',
9
+
10
+ string: 'must be a string',
11
+ integer: 'must be an integer',
12
+ decimal: 'must be a number',
13
+ boolean: 'must be a boolean',
14
+ hash: 'must be a hash',
15
+ array: 'must be an array',
16
+ model: 'must be the right class',
17
+ date: 'date does non exist',
18
+
19
+ before: 'must be before given date',
20
+ after: 'must be after given date',
21
+ empty: 'cannot be empty',
22
+ matches: 'has an incorrect format',
23
+ in: 'is not an available option',
24
+ min: 'is too small',
25
+ max: 'is too big',
26
+
27
+ new_records: 'model must be saved'
28
+ )
29
+ end
30
+
31
+ def message(atom, parent_key, index)
32
+ [
33
+ index_ordinal(index),
34
+ (atom.key || parent_key || 'item').to_s.titleize,
35
+ MESSAGES[atom.codes]
36
+ ]
37
+ .compact
38
+ .join(' ')
39
+ end
40
+
41
+ def index_ordinal(index)
42
+ index&.+(1)&.ordinalize
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ class ValidationError < StandardError
4
+ attr_accessor :errors
5
+
6
+ def initialize(errors)
7
+ self.errors = errors
8
+ end
9
+
10
+ def to_s
11
+ errors.message_list.join('; ').to_s
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ class Filter
4
+ def self.inherited(child_class)
5
+ method_name = filter_name(child_class)
6
+
7
+ define_method(method_name) do |*args, &block|
8
+ args.unshift(nil) if args[0].is_a?(Hash)
9
+ new_filter = child_class.new(*args, &block)
10
+ sub_filters.push(new_filter)
11
+ end
12
+ end
13
+
14
+ def self.filter_name(klass = self)
15
+ filter_name = klass.name.match(/\AObjective::Filters::(.*)Filter\z/)&.[](1)&.underscore
16
+ raise 'filename error in filters folder' unless filter_name
17
+ filter_name
18
+ end
19
+
20
+ attr_reader :key
21
+ attr_reader :sub_filters
22
+
23
+ def initialize(key = nil, opts = {}, &block)
24
+ @key = key
25
+ @given_options = opts
26
+ @sub_filters ||= []
27
+
28
+ instance_eval(&block) if block_given?
29
+ end
30
+
31
+ def options
32
+ @options ||= OpenStruct.new(type_specific_options_hash.merge(@given_options))
33
+ end
34
+
35
+ def type_specific_options_hash
36
+ Objective::Filters::Config[self.class.filter_name].to_h
37
+ end
38
+
39
+ def sub_filters_hash
40
+ sub_filters.each_with_object({}) { |sf, result| result[sf.key] = sf }
41
+ end
42
+
43
+ def dup
44
+ dupped = self.class.new
45
+ sub_filters.each { |sf| dupped.sub_filters.push(sf) }
46
+ dupped
47
+ end
48
+
49
+ def default?
50
+ options.to_h.key?(:default)
51
+ end
52
+
53
+ def default
54
+ options.default
55
+ end
56
+
57
+ def feed(raw)
58
+ return feed_none if raw == Objective::NONE
59
+ return feed_nil if raw.nil?
60
+
61
+ coerced = options.strict == true ? raw : coerce(raw)
62
+ errors = coerce_error(coerced)
63
+ return feed_invalid(errors, raw, raw) if errors
64
+
65
+ errors = validate(coerced)
66
+ return feed_empty(raw, coerced) if errors == :empty
67
+
68
+ feed_result(errors, raw, coerced)
69
+ end
70
+
71
+ def feed_none
72
+ case options.none
73
+ when Objective::ALLOW
74
+ return Objective::DISCARD
75
+ when Objective::DENY
76
+ coerced = Objective::NONE
77
+ errors = :required
78
+ when Objective::DISCARD
79
+ raise 'the none option cannot be discarded — did you mean to use allow instead?'
80
+ else
81
+ coerced = options.none
82
+ end
83
+
84
+ feed_result(errors, Objective::NONE, coerced)
85
+ end
86
+
87
+ def feed_nil
88
+ case options.nils
89
+ when Objective::ALLOW
90
+ coerced = nil
91
+ errors = nil
92
+ when Objective::DENY
93
+ coerced = nil
94
+ errors = :nils
95
+ when Objective::DISCARD
96
+ return Objective::DISCARD
97
+ else
98
+ coerced = options.nils
99
+ end
100
+
101
+ feed_result(errors, nil, coerced)
102
+ end
103
+
104
+ def feed_invalid(errors, raw, coerced)
105
+ case options.invalid
106
+ when Objective::DENY
107
+ # nothing
108
+ when Objective::DISCARD
109
+ return Objective::DISCARD
110
+ else
111
+ coerced = options.invalid
112
+ end
113
+
114
+ feed_result(errors, raw, coerced)
115
+ end
116
+
117
+ def feed_empty(raw, coerced)
118
+ case options.empty
119
+ when Objective::ALLOW
120
+ errors = nil
121
+ when Objective::DENY
122
+ errors = :empty
123
+ when Objective::DISCARD
124
+ return Objective::DISCARD
125
+ else
126
+ coerced = options.empty
127
+ end
128
+
129
+ feed_result(errors, raw, coerced)
130
+ end
131
+
132
+ def feed_result(errors, raw, coerced)
133
+ OpenStruct.new(
134
+ errors: errors,
135
+ raw: raw,
136
+ coerced: coerced,
137
+ inputs: coerced
138
+ )
139
+ end
140
+
141
+ def coerce(raw)
142
+ raw
143
+ end
144
+
145
+ def coerce_error(coerced)
146
+ end
147
+
148
+ def validate(coerced)
149
+ end
150
+
151
+ def handle_errors(given_error)
152
+ return if given_error.nil?
153
+
154
+ case given_error
155
+ when Objective::Errors::ErrorHash
156
+ given_error
157
+ when Objective::Errors::ErrorArray
158
+ given_error
159
+ when Objective::Errors::ErrorAtom
160
+ given_error
161
+ else
162
+ Objective::Errors::ErrorAtom.new(key, given_error)
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Filters
4
+ class AnyFilter < Objective::Filter
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Filters
4
+ class ArrayFilter < Objective::Filter
5
+ def feed(raw)
6
+ result = super(raw)
7
+ return result if result == Objective::DISCARD || result.errors || result.inputs.nil?
8
+
9
+ inputs = []
10
+ errors = Objective::Errors::ErrorArray.new
11
+
12
+ sub_filter = sub_filters.first
13
+
14
+ index_shift = 0
15
+
16
+ data = result.coerced
17
+ data.each_with_index do |sub_data, index|
18
+ sub_result = sub_filter.feed(sub_data)
19
+ if sub_result == Objective::DISCARD
20
+ index_shift += 1
21
+ next
22
+ end
23
+
24
+ sub_data = sub_result.inputs
25
+ sub_error = sub_result.errors
26
+
27
+ unless sub_error.nil?
28
+ errors[index - index_shift] = sub_filter.handle_errors(sub_error)
29
+ end
30
+
31
+ inputs[index - index_shift] = sub_data
32
+ end
33
+
34
+ result.inputs = inputs
35
+ result.errors = errors.present? ? errors : nil
36
+ result
37
+ end
38
+
39
+ def coerce(raw)
40
+ return Array.wrap(raw) if options.wrap
41
+ raw
42
+ end
43
+
44
+ def coerce_error(coerced)
45
+ return :array unless coerced.is_a?(Array)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Filters
4
+ class BooleanFilter < Objective::Filter
5
+ private
6
+
7
+ def coerce(raw)
8
+ return raw if boolean?(raw)
9
+ options.coercion_map[raw.to_s.downcase] if raw.respond_to?(:to_s)
10
+ end
11
+
12
+ def coerce_error(coerced)
13
+ return :boolean unless boolean?(coerced)
14
+ end
15
+
16
+ def boolean?(datum)
17
+ datum == true || datum == false
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Filters
4
+ class DateFilter < Objective::Filter
5
+ private
6
+
7
+ def coerce(raw)
8
+ return raw if raw.is_a?(Date) # Date and DateTime
9
+ return raw.to_date if raw.respond_to?(:to_date)
10
+ parse(raw) if raw.is_a?(String)
11
+ end
12
+
13
+ def parse(data)
14
+ options.format ? Date.strptime(data, options.format) : Date.parse(data)
15
+ rescue ArgumentError
16
+ nil
17
+ end
18
+
19
+ def coerce_error(coerced)
20
+ return :date unless coerced.is_a?(Date)
21
+ end
22
+
23
+ def validate(coerced)
24
+ return :after if options.after && coerced <= options.after
25
+ return :before if options.before && coerced >= options.before
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Filters
4
+ class DecimalFilter < Objective::Filter
5
+ private
6
+
7
+ # TODO: the Rational class should be coerced - it requires a precision argument
8
+ def coerce(datum)
9
+ return datum if datum.blank?
10
+
11
+ return datum.to_d if datum.is_a?(Integer) || datum.is_a?(Float)
12
+
13
+ return datum unless datum.is_a?(String)
14
+
15
+ clean_str = datum.tr(options.delimiter, '').tr(options.decimal_mark, '.')
16
+ return datum unless clean_str =~ /\A[-+]?\d*\.?\d*\z/
17
+ clean_str.to_d
18
+ end
19
+
20
+ def coerce_error(coerced)
21
+ return :decimal unless coerced.is_a?(BigDecimal)
22
+ end
23
+
24
+ def validate(datum)
25
+ return :min unless above_min?(datum)
26
+ return :max unless below_max?(datum)
27
+ return :scale unless within_scale?(datum)
28
+ end
29
+
30
+ def above_min?(datum)
31
+ return true if options.min.nil?
32
+ datum >= options.min
33
+ end
34
+
35
+ def below_max?(datum)
36
+ return true if options.max.nil?
37
+ datum <= options.max
38
+ end
39
+
40
+ def within_scale?(datum)
41
+ return true if options.scale.nil?
42
+ (datum - datum.round(options.scale)).zero?
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Filters
4
+ class DuckFilter < Objective::Filter
5
+ private
6
+
7
+ def coerce_error(coerced)
8
+ return :duck unless respond_to_all?(coerced)
9
+ end
10
+
11
+ def respond_to_all?(coerced)
12
+ Array.wrap(options[:methods]).map do |method|
13
+ coerced.respond_to?(method)
14
+ end.all?
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Filters
4
+ class FileFilter < Objective::Filter
5
+ private
6
+
7
+ def coerce_error(coerced)
8
+ return :file unless respond_to_all?(coerced)
9
+ end
10
+
11
+ def respond_to_all?(coerced)
12
+ methods = %i(read size)
13
+ methods.concat(%i(original_filename content_type)) if options.upload
14
+ methods.map { |method| coerced.respond_to?(method) }.all?
15
+ end
16
+
17
+ def validate(coerced)
18
+ return :size if options.size && coerced.size > options.size
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Filters
4
+ class FloatFilter < Objective::Filter
5
+ private
6
+
7
+ def coerce(datum)
8
+ return datum if datum.blank?
9
+
10
+ return datum.to_f if datum.is_a?(Integer) || datum.is_a?(BigDecimal)
11
+
12
+ return datum unless datum.is_a?(String)
13
+
14
+ clean_str = datum.tr(options.delimiter, '').tr(options.decimal_mark, '.')
15
+ return datum unless clean_str =~ /\A[-+]?\d*\.?\d*\z/
16
+ clean_str.to_f
17
+ end
18
+
19
+ def coerce_error(coerced)
20
+ return :float unless coerced.is_a?(Float)
21
+ end
22
+
23
+ def validate(coerced)
24
+ return :min unless above_min?(coerced)
25
+ return :max unless below_max?(coerced)
26
+ return :scale unless within_scale?(coerced)
27
+ end
28
+
29
+ def above_min?(datum)
30
+ return true if options.min.nil?
31
+ datum >= options.min
32
+ end
33
+
34
+ def below_max?(datum)
35
+ return true if options.max.nil?
36
+ datum <= options.max
37
+ end
38
+
39
+ def within_scale?(datum)
40
+ return true if options.scale.nil?
41
+ (datum - datum.round(options.scale)).zero?
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+ module Objective
3
+ module Filters
4
+ class HashFilter < Objective::Filter
5
+ def feed(raw)
6
+ result = super(raw)
7
+ return result if result == Objective::DISCARD || result.errors || result.inputs.nil?
8
+
9
+ errors = Objective::Errors::ErrorHash.new
10
+ inputs = HashWithIndifferentAccess.new
11
+
12
+ data = result.coerced
13
+ sub_filters_hash.each_pair do |key, key_filter|
14
+ datum = data.key?(key) ? data[key] : Objective::NONE
15
+ key_filter_result = key_filter.feed(datum)
16
+ next if key_filter_result == Objective::DISCARD
17
+
18
+ sub_data = key_filter_result.inputs
19
+ sub_error = key_filter_result.errors
20
+
21
+ if sub_error.nil?
22
+ inputs[key] = sub_data
23
+ else
24
+ errors[key] = key_filter.handle_errors(sub_error)
25
+ end
26
+ end
27
+
28
+ result.inputs = inputs
29
+ result.errors = errors.present? ? errors : nil
30
+ result
31
+ end
32
+
33
+ def coerce(raw)
34
+ raw.try(:with_indifferent_access)
35
+ end
36
+
37
+ def coerce_error(coerced)
38
+ return :hash unless coerced.is_a?(Hash)
39
+ end
40
+ end
41
+ end
42
+ end