mutations 0.5.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.
@@ -0,0 +1,149 @@
1
+ # IDEA i just had (protected parameters):
2
+ # optional do
3
+ # boolean :skip_confirmation, protected: true
4
+ # end
5
+ # Given the above, skip_confirmation is only accepted as a parameter if it's passed in a later hash, eg this would make it take:
6
+ # User::ChangeEmail.run!(params, user: current_user, skip_confirmation: true)
7
+ # But this would not:
8
+ # params = {user: current_user, skip_confirmation: true}
9
+ # User::ChangeEmail.run!(params)
10
+
11
+
12
+ module Mutations
13
+ class Command
14
+
15
+ ##
16
+ ##
17
+ ##
18
+ class << self
19
+ def required(&block)
20
+ self.input_filters.required(&block)
21
+
22
+ self.input_filters.required_keys.each do |key|
23
+ define_method(key) do
24
+ @filtered_input[key]
25
+ end
26
+
27
+ define_method("#{key}_present?") do
28
+ @filtered_input.has_key?(key)
29
+ end
30
+
31
+ define_method("#{key}=") do |v|
32
+ @filtered_input[key] = v
33
+ end
34
+ end
35
+ end
36
+
37
+ def optional(&block)
38
+ self.input_filters.optional(&block)
39
+
40
+ self.input_filters.optional_keys.each do |key|
41
+ define_method(key) do
42
+ @filtered_input[key]
43
+ end
44
+
45
+ define_method("#{key}_present?") do
46
+ @filtered_input.has_key?(key)
47
+ end
48
+
49
+ define_method("#{key}=") do |v|
50
+ @filtered_input[key] = v
51
+ end
52
+ end
53
+ end
54
+
55
+ def run(*args)
56
+ new(*args).execute!
57
+ end
58
+
59
+ def run!(*args)
60
+ m = run(*args)
61
+ if m.success?
62
+ m.result
63
+ else
64
+ raise ValidationException.new(m.errors)
65
+ end
66
+ end
67
+
68
+ def input_filters
69
+ @input_filters ||= begin
70
+ if Command == self.superclass
71
+ HashFilter.new
72
+ else
73
+ self.superclass.input_filters.dup
74
+ end
75
+ end
76
+ end
77
+
78
+ end
79
+
80
+ # Instance methods
81
+ def initialize(*args)
82
+ if args.length == 0
83
+ @original_hash = {}
84
+ else
85
+ @original_hash = args.shift
86
+ raise ArgumentError.new("All arguments must be hashes") unless @original_hash.is_a?(Hash)
87
+ @original_hash = @original_hash.with_indifferent_access
88
+ end
89
+
90
+ args.each do |a|
91
+ raise ArgumentError.new("All arguments must be hashes") unless a.is_a?(Hash)
92
+ @original_hash.merge!(a)
93
+ end
94
+ end
95
+
96
+ def input_filters
97
+ self.class.input_filters
98
+ end
99
+
100
+ def execute!
101
+ @filtered_input, @errors = self.input_filters.filter(@original_hash)
102
+ return Outcome.new(false, nil, @errors) if @errors
103
+
104
+ # IDEA/TODO: run validate block
105
+
106
+ r = execute
107
+ if @errors # Execute can add errors
108
+ return Outcome.new(false, nil, @errors)
109
+ else
110
+ return Outcome.new(true, r, nil)
111
+ end
112
+ end
113
+
114
+ # add_error("name", :too_short)
115
+ # add_error("colors.foreground", :not_a_color) # => to create errors = {colors: {foreground: :not_a_color}}
116
+ # or, supply a custom message:
117
+ # add_error("name", :too_short, "The name 'blahblahblah' is too short!")
118
+ def add_error(key, kind, message = nil)
119
+ raise ArgumentError.new("Invalid kind") unless kind.is_a?(Symbol)
120
+
121
+ @errors ||= ErrorHash.new
122
+ cur_errors = @errors
123
+ parts = key.to_s.split(".")
124
+ while part = parts.shift
125
+ part = part.to_sym
126
+ if parts.length > 0
127
+ cur_errors[part] = ErrorHash.new unless cur_errors[part].is_a?(ErrorHash)
128
+ cur_errors = cur_errors[part]
129
+ else
130
+ cur_errors[part] = ErrorAtom.new(key, kind, message: message)
131
+ end
132
+ end
133
+ @errors
134
+ end
135
+
136
+ def merge_errors(hash)
137
+ @errors ||= ErrorHash.new
138
+ @errors.merge!(hash)
139
+ end
140
+
141
+ def inputs
142
+ @filtered_input
143
+ end
144
+
145
+ def execute
146
+ # Meant to be overridden
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,152 @@
1
+ module Mutations
2
+
3
+ # Offers a non-localized, english only, non configurable way to get error messages. This probably isnt good enough for users as-is.
4
+ class DefaultErrorMessageCreator
5
+
6
+ MESSAGES = Hash.new("is invalid").tap do |h|
7
+ h.merge!(
8
+ # General
9
+ nils: "can't be nil",
10
+ required: "is required",
11
+
12
+ # Datatypes
13
+ string: "isn't a string",
14
+ integer: "isn't an integer",
15
+ boolean: "isn't a boolean",
16
+ hash: "isn't a hash",
17
+ array: "isn't an array",
18
+ model: "isn't the right class",
19
+
20
+ # String
21
+ empty: "can't be blank",
22
+ max_length: "is too long",
23
+ min_length: "is too short",
24
+ matches: "isn't in the right format",
25
+ in: "isn't an option",
26
+
27
+ # Array
28
+ class: "isn't the right class",
29
+
30
+ # Integer
31
+ min: "is too small",
32
+ max: "is too big",
33
+
34
+ # Model
35
+ new_records: "isn't a saved model"
36
+ )
37
+ end
38
+
39
+ # Key is either a symbol or a fixnum
40
+ def message(key, error_symbol, options = {})
41
+ if options[:index]
42
+ "#{(key || 'array').to_s.titleize}[#{options[:index]}] #{MESSAGES[error_symbol]}"
43
+ else
44
+ "#{key.to_s.titleize} #{MESSAGES[error_symbol]}"
45
+ end
46
+ end
47
+ end
48
+
49
+
50
+ class ErrorAtom
51
+
52
+ # NOTE: in the future, could also pass in:
53
+ # - error type
54
+ # - value (eg, string :name, length: 5 # value=5)
55
+
56
+ # ErrorAtom.new(:name, :too_short)
57
+ # ErrorAtom.new(:name, :too_short, message: "is too short")
58
+ def initialize(key, error_symbol, options = {})
59
+ @key = key
60
+ @symbol = error_symbol
61
+ @message = options[:message]
62
+ @index = options[:index]
63
+ end
64
+
65
+ def symbolic
66
+ @symbol
67
+ end
68
+
69
+ def message
70
+ @message ||= Mutations.error_message_creator.message(@key, @symbol, index: @index)
71
+ end
72
+
73
+ def message_list
74
+ Array(message)
75
+ end
76
+ end
77
+
78
+ # mutation.errors is an ErrorHash instance like this:
79
+ # {
80
+ # email: ErrorAtom(:matches),
81
+ # name: ErrorAtom(:too_weird, message: "is too weird"),
82
+ # adddress: { # Nested ErrorHash object
83
+ # city: ErrorAtom(:not_found, message: "That's not a city, silly!"),
84
+ # state: ErrorAtom(:in)
85
+ # }
86
+ # }
87
+ class ErrorHash < HashWithIndifferentAccess
88
+
89
+ # Returns a nested HashWithIndifferentAccess where the values are symbols. Eg:
90
+ # {
91
+ # email: :matches,
92
+ # name: :too_weird,
93
+ # adddress: {
94
+ # city: :not_found,
95
+ # state: :in
96
+ # }
97
+ # }
98
+ def symbolic
99
+ HashWithIndifferentAccess.new.tap do |hash|
100
+ each do |k, v|
101
+ hash[k] = v.symbolic
102
+ end
103
+ end
104
+ end
105
+
106
+ # Returns a nested HashWithIndifferentAccess where the values are messages. Eg:
107
+ # {
108
+ # email: "isn't in the right format",
109
+ # name: "is too weird",
110
+ # adddress: {
111
+ # city: "is not a city",
112
+ # state: "isn't a valid option"
113
+ # }
114
+ # }
115
+ def message
116
+ HashWithIndifferentAccess.new.tap do |hash|
117
+ each do |k, v|
118
+ hash[k] = v.message
119
+ end
120
+ end
121
+ end
122
+
123
+ # Returns a flat array where each element is a full sentence. Eg:
124
+ # [
125
+ # "Email isn't in the right format.",
126
+ # "Name is too weird",
127
+ # "That's not a city, silly!",
128
+ # "State isn't a valid option."
129
+ # ]
130
+ def message_list
131
+ list = []
132
+ each do |k, v|
133
+ list.concat(v.message_list)
134
+ end
135
+ list
136
+ end
137
+ end
138
+
139
+ class ErrorArray < Array
140
+ def symbolic
141
+ map {|e| e && e.symbolic }
142
+ end
143
+
144
+ def message
145
+ map {|e| e && e.message }
146
+ end
147
+
148
+ def message_list
149
+ compact.map {|e| e.message_list }.flatten
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,13 @@
1
+ module Mutations
2
+ class ValidationException < ::StandardError
3
+ attr_accessor :errors
4
+
5
+ def initialize(errors)
6
+ self.errors = errors
7
+ end
8
+
9
+ def to_s
10
+ "Mutations::ValidationException: #{self.errors.message_list.join('; ')}"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,131 @@
1
+ module Mutations
2
+ class HashFilter < InputFilter
3
+ @default_options = {
4
+ nils: false, # true allows an explicit nil to be valid. Overrides any other options
5
+ }
6
+
7
+ attr_accessor :optional_inputs
8
+ attr_accessor :required_inputs
9
+
10
+ def initialize(opts = {}, &block)
11
+ super(opts)
12
+
13
+ @optional_inputs = {}
14
+ @required_inputs = {}
15
+ @current_inputs = @required_inputs
16
+
17
+ if block_given?
18
+ instance_eval &block
19
+ end
20
+ end
21
+
22
+ def dup
23
+ dupped = HashFilter.new
24
+ @optional_inputs.each_pair do |k, v|
25
+ dupped.optional_inputs[k] = v
26
+ end
27
+ @required_inputs.each_pair do |k, v|
28
+ dupped.required_inputs[k] = v
29
+ end
30
+ dupped
31
+ end
32
+
33
+ def required(&block)
34
+ # TODO: raise if nesting is wrong
35
+ @current_inputs = @required_inputs
36
+ instance_eval &block
37
+ end
38
+
39
+ def optional(&block)
40
+ # TODO: raise if nesting is wrong
41
+ @current_inputs = @optional_inputs
42
+ instance_eval &block
43
+ end
44
+
45
+ def required_keys
46
+ @required_inputs.keys
47
+ end
48
+
49
+ def optional_keys
50
+ @optional_inputs.keys
51
+ end
52
+
53
+ # Basic types:
54
+ def string(name, options = {})
55
+ @current_inputs[name.to_sym] = StringFilter.new(options)
56
+ end
57
+
58
+ def integer(name, options = {})
59
+ @current_inputs[name.to_sym] = IntegerFilter.new(options)
60
+ end
61
+
62
+ def boolean(name, options = {})
63
+ @current_inputs[name.to_sym] = BooleanFilter.new(options)
64
+ end
65
+
66
+ def hash(name, options = {}, &block)
67
+ @current_inputs[name.to_sym] = HashFilter.new(options, &block)
68
+ end
69
+
70
+ # Advanced types
71
+ def model(name, options = {})
72
+ name_sym = name.to_sym
73
+ @current_inputs[name_sym] = ModelFilter.new(name_sym, options)
74
+ end
75
+
76
+ def array(name, options = {}, &block)
77
+ name_sym = name.to_sym
78
+ @current_inputs[name.to_sym] = ArrayFilter.new(name_sym, options, &block)
79
+ end
80
+
81
+ def filter(data)
82
+
83
+ # Handle nil case
84
+ if data.nil?
85
+ return [nil, nil] if options[:nils]
86
+ return [nil, :nils]
87
+ end
88
+
89
+ # Ensure it's a hash
90
+ return [data, :hash] unless data.is_a?(Hash)
91
+
92
+ # We always want a hash with indiffernet access
93
+ unless data.is_a?(HashWithIndifferentAccess)
94
+ data = data.with_indifferent_access
95
+ end
96
+
97
+ errors = ErrorHash.new
98
+ filtered_data = HashWithIndifferentAccess.new
99
+
100
+ [[@required_inputs, true], [@optional_inputs, false]].each do |(inputs, is_required)|
101
+ inputs.each_pair do |key, filterer|
102
+ data_element = data[key]
103
+ default_used = false
104
+ if !data.has_key?(key) && filterer.has_default?
105
+ data_element = filterer.default
106
+ default_used = true
107
+ end
108
+
109
+ if data.has_key?(key) || default_used
110
+ sub_data, sub_error = filterer.filter(data_element)
111
+
112
+ if sub_error.nil?
113
+ filtered_data[key] = sub_data
114
+ else
115
+ sub_error = ErrorAtom.new(key, sub_error) if sub_error.is_a?(Symbol)
116
+ errors[key] = sub_error
117
+ end
118
+ elsif is_required
119
+ errors[key] = ErrorAtom.new(key, :required)
120
+ end
121
+ end
122
+ end
123
+
124
+ if errors.any?
125
+ [data, errors]
126
+ else
127
+ [filtered_data, nil] # We win, it's valid!
128
+ end
129
+ end
130
+ end
131
+ end