mutations 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +16 -0
- data/README.md +271 -0
- data/Rakefile +8 -0
- data/lib/mutations.rb +24 -0
- data/lib/mutations/array_filter.rb +102 -0
- data/lib/mutations/boolean_filter.rb +33 -0
- data/lib/mutations/command.rb +149 -0
- data/lib/mutations/errors.rb +152 -0
- data/lib/mutations/exception.rb +13 -0
- data/lib/mutations/hash_filter.rb +131 -0
- data/lib/mutations/input_filter.rb +29 -0
- data/lib/mutations/integer_filter.rb +34 -0
- data/lib/mutations/model_filter.rb +52 -0
- data/lib/mutations/outcome.rb +19 -0
- data/lib/mutations/string_filter.rb +54 -0
- data/lib/mutations/version.rb +3 -0
- data/mutations.gemspec +19 -0
- data/spec/array_filter_spec.rb +150 -0
- data/spec/boolean_filter_spec.rb +55 -0
- data/spec/command_spec.rb +183 -0
- data/spec/default_spec.rb +0 -0
- data/spec/errors_spec.rb +93 -0
- data/spec/inheritance_spec.rb +39 -0
- data/spec/integer_filter_spec.rb +76 -0
- data/spec/model_filter_spec.rb +92 -0
- data/spec/mutations_spec.rb +9 -0
- data/spec/simple_command.rb +15 -0
- data/spec/spec_helper.rb +9 -0
- data/spec/string_filter_spec.rb +138 -0
- metadata +108 -0
@@ -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,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
|