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