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,29 @@
1
+ module Mutations
2
+ class InputFilter
3
+ @default_options = {}
4
+
5
+ def self.default_options
6
+ @default_options
7
+ end
8
+
9
+ attr_accessor :options
10
+
11
+ def initialize(opts = {})
12
+ self.options = (self.class.default_options || {}).merge(opts)
13
+ end
14
+
15
+ # returns -> [sanitized data, error]
16
+ # If an error is returned, then data will be nil
17
+ def filter(data)
18
+ [data, nil]
19
+ end
20
+
21
+ def has_default?
22
+ self.options.has_key?(:default)
23
+ end
24
+
25
+ def default
26
+ self.options[:default]
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,34 @@
1
+ module Mutations
2
+ class IntegerFilter < InputFilter
3
+ @default_options = {
4
+ nils: false, # true allows an explicit nil to be valid. Overrides any other options
5
+ # TODO: add strict
6
+ min: nil, # lowest value, inclusive
7
+ max: nil # highest value, inclusive
8
+ }
9
+
10
+ def filter(data)
11
+
12
+ # Handle nil case
13
+ if data.nil?
14
+ return [nil, nil] if options[:nils]
15
+ return [nil, :nils]
16
+ end
17
+
18
+ # Ensure it's the correct data type (Fixnum)
19
+ if !data.is_a?(Fixnum)
20
+ if data.is_a?(String) && data =~ /^-?\d/
21
+ data = data.to_i
22
+ else
23
+ return [data, :integer]
24
+ end
25
+ end
26
+
27
+ return [data, :min] if options[:min] && data < options[:min]
28
+ return [data, :max] if options[:max] && data > options[:max]
29
+
30
+ # We win, it's valid!
31
+ [data, nil]
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,52 @@
1
+ module Mutations
2
+ class ModelFilter < InputFilter
3
+ @default_options = {
4
+ nils: false, # true allows an explicit nil to be valid. Overrides any other options
5
+ class: nil, # default is the attribute name.to_s.camelize.constantize. This overrides it with class or class.constantize
6
+ builder: nil, # Could be a class or a string which will be constantized. If present, and a hash is passed, then we use that to construct a model
7
+ new_records: false, # If false, unsaved AR records are not valid. Things that don't respond to new_record? are valid. true: anything is valid
8
+ }
9
+
10
+ def initialize(name, opts = {})
11
+ super(opts)
12
+ @name = name
13
+
14
+ class_const = options[:class] || @name.to_s.camelize
15
+ class_const = class_const.constantize if class_const.is_a?(String)
16
+ self.options[:class] = class_const
17
+
18
+ if options[:builder]
19
+ options[:builder] = options[:builder].constantize if options[:builder].is_a?(String)
20
+ end
21
+ end
22
+
23
+ def filter(data)
24
+
25
+ # Handle nil case
26
+ if data.nil?
27
+ return [nil, nil] if options[:nils]
28
+ return [nil, :nils]
29
+ end
30
+
31
+ # Passing in attributes. Let's see if we have a builder
32
+ if data.is_a?(Hash) && options[:builder]
33
+ ret = options[:builder].run(data)
34
+
35
+ if ret.success?
36
+ data = ret.result
37
+ else
38
+ return [data, ret.errors]
39
+ end
40
+ end
41
+
42
+ # We have a winner, someone passed in the correct data type!
43
+ if data.is_a?(options[:class])
44
+ return [data, :new_records] if !options[:new_records] && (data.respond_to?(:new_record?) && data.new_record?)
45
+ return [data, nil]
46
+ end
47
+
48
+ return [data, :model]
49
+ end
50
+
51
+ end
52
+ end
@@ -0,0 +1,19 @@
1
+ module Mutations
2
+ class Outcome
3
+ def initialize(is_success, result, errors)
4
+ @success, @result, @errors = is_success, result, errors
5
+ end
6
+
7
+ def success?
8
+ @success
9
+ end
10
+
11
+ def result
12
+ @result
13
+ end
14
+
15
+ def errors
16
+ @errors
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,54 @@
1
+ module Mutations
2
+ class StringFilter < InputFilter
3
+ @default_options = {
4
+ strip: true, # true calls data.strip if data is a string
5
+ strict: false, # If false, then symbols, numbers, and booleans are converted to a string with to_s. # TODO: TEST
6
+ nils: false, # true allows an explicit nil to be valid. Overrides any other options
7
+ empty: false, # false disallows "". true allows "" and overrides any other validations (b/c they couldn't be true if it's empty)
8
+ min_length: nil, # Can be a number like 5, meaning that 5 codepoints are required
9
+ max_length: nil, # Can be a number like 10, meaning that at most 10 codepoints are permitted
10
+ matches: nil, # Can be a regexp
11
+ in: nil # Can be an array like %w(red blue green)
12
+ }
13
+
14
+ def filter(data)
15
+
16
+ # Handle nil case
17
+ if data.nil?
18
+ return [nil, nil] if options[:nils]
19
+ return [nil, :nils]
20
+ end
21
+
22
+ # At this point, data is not nil. If it's not a string, convert it to a string for some standard classes
23
+ data = data.to_s if !options[:strict] && [TrueClass, FalseClass, Fixnum, Symbol].include?(data.class)
24
+
25
+ # Now ensure it's a string:
26
+ return [data, :string] unless data.is_a?(String)
27
+
28
+ # At this point, data is a string. Now transform it using strip:
29
+ data = data.strip if options[:strip]
30
+
31
+ # Now check if it's blank:
32
+ if data == ""
33
+ if options[:empty]
34
+ return [data, nil]
35
+ else
36
+ return [data, :empty]
37
+ end
38
+ end
39
+
40
+ # Now check to see if it's the correct size:
41
+ return [data, :min_length] if options[:min_length] && data.length < options[:min_length]
42
+ return [data, :max_length] if options[:max_length] && data.length > options[:max_length]
43
+
44
+ # Ensure it match
45
+ return [data, :in] if options[:in] && !options[:in].include?(data)
46
+
47
+ # Ensure it matches the regexp
48
+ return [data, :matches] if options[:matches] && (options[:matches] !~ data)
49
+
50
+ # We win, it's valid!
51
+ [data, nil]
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,3 @@
1
+ module Mutations
2
+ VERSION = "0.5.0"
3
+ end
data/mutations.gemspec ADDED
@@ -0,0 +1,19 @@
1
+ require './lib/mutations/version'
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'mutations'
5
+ s.version = Mutations::VERSION
6
+ s.author = 'Jonathan Novak'
7
+ s.email = 'jnovak@gmail.com'
8
+ s.homepage = 'http://github.com/cypriss/mutations'
9
+ s.summary = s.description = 'Compose your business logic into commands that sanitize and validate input.'
10
+
11
+ s.files = `git ls-files`.split("\n")
12
+ s.test_files = `git ls-files test`.split("\n")
13
+ s.require_path = 'lib'
14
+
15
+ s.add_dependency 'activesupport'
16
+ s.add_development_dependency 'minitest', '~> 4'
17
+
18
+ s.required_ruby_version = '>= 1.9.2'
19
+ end
@@ -0,0 +1,150 @@
1
+ require_relative 'spec_helper'
2
+
3
+ describe "Mutations::ArrayFilter" do
4
+
5
+ it "allows arrays" do
6
+ f = Mutations::ArrayFilter.new(:arr)
7
+ filtered, errors = f.filter([1])
8
+ assert_equal [1], filtered
9
+ assert_equal nil, errors
10
+ end
11
+
12
+ it "considers non-arrays to be invalid" do
13
+ f = Mutations::ArrayFilter.new(:arr)
14
+ ['hi', true, 1, {a: "1"}, Object.new].each do |thing|
15
+ filtered, errors = f.filter(thing)
16
+ assert_equal :array, errors
17
+ end
18
+ end
19
+
20
+ it "considers nil to be invalid" do
21
+ f = Mutations::ArrayFilter.new(:arr, nils: false)
22
+ filtered, errors = f.filter(nil)
23
+ assert_equal nil, filtered
24
+ assert_equal :nils, errors
25
+ end
26
+
27
+ it "considers nil to be valid" do
28
+ f = Mutations::ArrayFilter.new(:arr, nils: true)
29
+ filtered, errors = f.filter(nil)
30
+ filtered, errors = f.filter(nil)
31
+ assert_equal nil, errors
32
+ end
33
+
34
+ it "lets you specify a class, and has valid elements" do
35
+ f = Mutations::ArrayFilter.new(:arr, class: Fixnum)
36
+ filtered, errors = f.filter([1,2,3])
37
+ assert_equal nil, errors
38
+ assert_equal [1,2,3], filtered
39
+ end
40
+
41
+ it "lets you specify a class as a string, and has valid elements" do
42
+ f = Mutations::ArrayFilter.new(:arr, class: 'Fixnum')
43
+ filtered, errors = f.filter([1,2,3])
44
+ assert_equal nil, errors
45
+ assert_equal [1,2,3], filtered
46
+ end
47
+
48
+ it "lets you specify a class, and has invalid elements" do
49
+ f = Mutations::ArrayFilter.new(:arr, class: Fixnum)
50
+ filtered, errors = f.filter([1, "bob"])
51
+ assert_equal [nil, :class], errors.symbolic
52
+ assert_equal [1,"bob"], filtered
53
+ end
54
+
55
+ it "lets you use a block to supply an element filter" do
56
+ f = Mutations::ArrayFilter.new(:arr) { string }
57
+
58
+ filtered, errors = f.filter(["hi", {stuff: "ok"}])
59
+ assert_nil errors[0]
60
+ assert_equal :string, errors[1].symbolic
61
+ end
62
+
63
+ it "lets you array-ize everything" do
64
+ f = Mutations::ArrayFilter.new(:arr, arrayize: true) { string }
65
+
66
+ filtered, errors = f.filter("foo")
67
+ assert_equal ["foo"], filtered
68
+ assert_nil errors
69
+ end
70
+
71
+ it "lets you array-ize an empty string" do
72
+ f = Mutations::ArrayFilter.new(:arr, arrayize: true) { string }
73
+
74
+ filtered, errors = f.filter("")
75
+ assert_equal [], filtered
76
+ assert_nil errors
77
+ end
78
+
79
+ it "lets you pass integers in arrays" do
80
+ f = Mutations::ArrayFilter.new(:arr) { integer min: 4 }
81
+
82
+ filtered, errors = f.filter([5,6,1,"bob"])
83
+ assert_equal [5,6,1,"bob"], filtered
84
+ assert_equal [nil, nil, :min, :integer], errors.symbolic
85
+ end
86
+
87
+ it "lets you pass booleans in arrays" do
88
+ f = Mutations::ArrayFilter.new(:arr) { boolean }
89
+
90
+ filtered, errors = f.filter([true, false, "1"])
91
+ assert_equal [true, false, true], filtered
92
+ assert_equal nil, errors
93
+ end
94
+
95
+ it "lets you pass model in arrays" do
96
+ f = Mutations::ArrayFilter.new(:arr) { model :string }
97
+
98
+ filtered, errors = f.filter(["hey"])
99
+ assert_equal ["hey"], filtered
100
+ assert_equal nil, errors
101
+ end
102
+
103
+ it "lets you pass hashes in arrays" do
104
+ f = Mutations::ArrayFilter.new(:arr) do
105
+ hash do
106
+ required do
107
+ string :foo
108
+ integer :bar
109
+ end
110
+
111
+ optional do
112
+ boolean :baz
113
+ end
114
+ end
115
+ end
116
+
117
+ filtered, errors = f.filter([{foo: "f", bar: 3, baz: true}, {foo: "f", bar: 3}, {foo: "f"}])
118
+ assert_equal [{:foo=>"f", :bar=>3, :baz=>true}, {:foo=>"f", :bar=>3}, {:foo=>"f"}], filtered
119
+
120
+ assert_equal nil, errors[0]
121
+ assert_equal nil, errors[1]
122
+ assert_equal ({"bar"=>:required}), errors[2].symbolic
123
+ end
124
+
125
+ it "lets you pass arrays of arrays" do
126
+ f = Mutations::ArrayFilter.new(:arr) do
127
+ array do
128
+ string
129
+ end
130
+ end
131
+
132
+ filtered, errors = f.filter([["h", "e"], ["l"], [], ["lo"]])
133
+ assert_equal filtered, [["h", "e"], ["l"], [], ["lo"]]
134
+ assert_equal nil, errors
135
+ end
136
+
137
+ it "handles errors for arrays of arrays" do
138
+ f = Mutations::ArrayFilter.new(:arr) do
139
+ array do
140
+ string
141
+ end
142
+ end
143
+
144
+ filtered, errors = f.filter([["h", "e", {}], ["l"], [], [""]])
145
+ assert_equal [[nil, nil, :string], nil, nil, [:empty]], errors.symbolic
146
+ assert_equal [[nil, nil, "Array[2] isn't a string"], nil, nil, ["Array[0] can't be blank"]], errors.message
147
+ assert_equal ["Array[2] isn't a string", "Array[0] can't be blank"], errors.message_list
148
+ end
149
+
150
+ end
@@ -0,0 +1,55 @@
1
+ require_relative 'spec_helper'
2
+
3
+ describe "Mutations::BooleanFilter" do
4
+
5
+ it "allows booleans" do
6
+ f = Mutations::BooleanFilter.new
7
+ filtered, errors = f.filter(true)
8
+ assert_equal true, filtered
9
+ assert_equal nil, errors
10
+
11
+ filtered, errors = f.filter(false)
12
+ assert_equal false, filtered
13
+ assert_equal nil, errors
14
+ end
15
+
16
+ it "considers non-booleans to be invalid" do
17
+ f = Mutations::BooleanFilter.new
18
+ [[true], {a: "1"}, Object.new].each do |thing|
19
+ filtered, errors = f.filter(thing)
20
+ assert_equal :boolean, errors
21
+ end
22
+ end
23
+
24
+ it "considers nil to be invalid" do
25
+ f = Mutations::BooleanFilter.new(nils: false)
26
+ filtered, errors = f.filter(nil)
27
+ assert_equal nil, filtered
28
+ assert_equal :nils, errors
29
+ end
30
+
31
+ it "considers nil to be valid" do
32
+ f = Mutations::BooleanFilter.new(nils: true)
33
+ filtered, errors = f.filter(nil)
34
+ assert_equal nil, filtered
35
+ assert_equal nil, errors
36
+ end
37
+
38
+ it "considers certain strings to be valid booleans" do
39
+ f = Mutations::BooleanFilter.new
40
+ [["true", true], ["TRUE", true], ["TrUe", true], ["1", true], ["false", false], ["FALSE", false], ["FalSe", false], ["0", false], [0, false], [1, true]].each do |(str, v)|
41
+ filtered, errors = f.filter(str)
42
+ assert_equal v, filtered
43
+ assert_equal nil, errors
44
+ end
45
+ end
46
+
47
+ it "considers other string to be invalid" do
48
+ f = Mutations::BooleanFilter.new
49
+ ["", "truely", "2"].each do |str|
50
+ filtered, errors = f.filter(str)
51
+ assert_equal str, filtered
52
+ assert_equal :boolean, errors
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,183 @@
1
+ require_relative 'spec_helper'
2
+ require 'simple_command'
3
+
4
+ describe "Command" do
5
+
6
+ describe "SimpleCommand" do
7
+ it "should allow valid in put in" do
8
+ outcome = SimpleCommand.run(name: "John", email: "john@gmail.com", amount: 5)
9
+
10
+ assert outcome.success?
11
+ assert_equal ({name: "John", email: "john@gmail.com", amount: 5}).stringify_keys, outcome.result
12
+ assert_equal nil, outcome.errors
13
+ end
14
+
15
+ it "should filter out spurious params" do
16
+ outcome = SimpleCommand.run(name: "John", email: "john@gmail.com", amount: 5, buggers: true)
17
+
18
+ assert outcome.success?
19
+ assert_equal ({name: "John", email: "john@gmail.com", amount: 5}).stringify_keys, outcome.result
20
+ assert_equal nil, outcome.errors
21
+ end
22
+
23
+ it "should discover errors in inputs" do
24
+ outcome = SimpleCommand.run(name: "JohnTooLong", email: "john@gmail.com")
25
+
26
+ assert !outcome.success?
27
+ assert :length, outcome.errors.symbolic[:name]
28
+ end
29
+
30
+ it "shouldn't throw an exception with run!" do
31
+ result = SimpleCommand.run!(name: "John", email: "john@gmail.com", amount: 5)
32
+ assert_equal ({name: "John", email: "john@gmail.com", amount: 5}).stringify_keys, result
33
+ end
34
+
35
+ it "should throw an exception with run!" do
36
+ assert_raises Mutations::ValidationException do
37
+ result = SimpleCommand.run!(name: "John", email: "john@gmail.com", amount: "bob")
38
+ end
39
+ end
40
+
41
+ it "should merge multiple hashes" do
42
+ outcome = SimpleCommand.run({name: "John", email: "john@gmail.com"}, {email: "bob@jones.com", amount: 5})
43
+
44
+ assert outcome.success?
45
+ assert_equal ({name: "John", email: "bob@jones.com", amount: 5}).stringify_keys, outcome.result
46
+ end
47
+
48
+ it "should merge hashes indifferently" do
49
+ outcome = SimpleCommand.run({name: "John", email: "john@gmail.com"}, {"email" => "bob@jones.com", "amount" => 5})
50
+
51
+ assert outcome.success?
52
+ assert_equal ({name: "John", email: "bob@jones.com", amount: 5}).stringify_keys, outcome.result
53
+ end
54
+
55
+ it "shouldn't accept non-hashes" do
56
+ assert_raises ArgumentError do
57
+ outcome = SimpleCommand.run(nil)
58
+ end
59
+
60
+ assert_raises ArgumentError do
61
+ outcome = SimpleCommand.run(1)
62
+ end
63
+ end
64
+
65
+ it "should accept nothing at all" do
66
+ SimpleCommand.run # make sure nothing is raised
67
+ end
68
+ end
69
+
70
+ describe "EigenCommand" do
71
+ class EigenCommand < Mutations::Command
72
+
73
+ required { string :name }
74
+ optional { string :email }
75
+
76
+ def execute
77
+ {name: name, email: email}
78
+ end
79
+ end
80
+
81
+ it "should define getter methods on params" do
82
+ mutation = EigenCommand.run(name: "John", email: "john@gmail.com")
83
+ assert_equal ({name: "John", email: "john@gmail.com"}), mutation.result
84
+ end
85
+ end
86
+
87
+ describe "MutatatedCommand" do
88
+ class MutatatedCommand < Mutations::Command
89
+
90
+ required { string :name }
91
+ optional { string :email }
92
+
93
+ def execute
94
+ self.name, self.email = "bob", "bob@jones.com"
95
+ {name: inputs[:name], email: inputs[:email]}
96
+ end
97
+ end
98
+
99
+ it "should define setter methods on params" do
100
+ mutation = MutatatedCommand.run(name: "John", email: "john@gmail.com")
101
+ assert_equal ({name: "bob", email: "bob@jones.com"}), mutation.result
102
+ end
103
+ end
104
+
105
+ describe "ErrorfulCommand" do
106
+ class ErrorfulCommand < Mutations::Command
107
+
108
+ required { string :name }
109
+ optional { string :email }
110
+
111
+ def execute
112
+ add_error("bob", :is_a_bob)
113
+
114
+ 1
115
+ end
116
+ end
117
+
118
+ it "should let you add errors" do
119
+ outcome = ErrorfulCommand.run(name: "John", email: "john@gmail.com")
120
+
121
+ assert !outcome.success?
122
+ assert_nil outcome.result
123
+ assert :is_a_bob, outcome.errors.symbolic[:bob]
124
+ end
125
+ end
126
+
127
+ describe "MultiErrorCommand" do
128
+ class ErrorfulCommand < Mutations::Command
129
+
130
+ required { string :name }
131
+ optional { string :email }
132
+
133
+ def execute
134
+ moar_errors = Mutations::ErrorHash.new
135
+ moar_errors[:bob] = Mutations::ErrorAtom.new(:bob, :is_short)
136
+ moar_errors[:sally] = Mutations::ErrorAtom.new(:sally, :is_fat)
137
+
138
+ merge_errors(moar_errors)
139
+
140
+ 1
141
+ end
142
+ end
143
+
144
+ it "should let you merge errors" do
145
+ outcome = ErrorfulCommand.run(name: "John", email: "john@gmail.com")
146
+
147
+ assert !outcome.success?
148
+ assert_nil outcome.result
149
+ assert :is_short, outcome.errors.symbolic[:bob]
150
+ assert :is_fat, outcome.errors.symbolic[:sally]
151
+ end
152
+ end
153
+
154
+ describe "PresentCommand" do
155
+ class PresentCommand < Mutations::Command
156
+
157
+ optional do
158
+ string :email
159
+ string :name
160
+ end
161
+
162
+ def execute
163
+ if name_present? && email_present?
164
+ 1
165
+ elsif !name_present? && email_present?
166
+ 2
167
+ elsif name_present? && !email_present?
168
+ 3
169
+ else
170
+ 4
171
+ end
172
+ end
173
+ end
174
+
175
+ it "should handle *_present? methods" do
176
+ assert_equal 1, PresentCommand.run!(name: "John", email: "john@gmail.com")
177
+ assert_equal 2, PresentCommand.run!(email: "john@gmail.com")
178
+ assert_equal 3, PresentCommand.run!(name: "John")
179
+ assert_equal 4, PresentCommand.run!
180
+ end
181
+ end
182
+
183
+ end