ward 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 (92) hide show
  1. data/.document +5 -0
  2. data/.gitignore +28 -0
  3. data/LICENSE +19 -0
  4. data/README.markdown +99 -0
  5. data/Rakefile +47 -0
  6. data/VERSION +1 -0
  7. data/features/acceptance_matcher.feature +78 -0
  8. data/features/attribute_keyword.feature +13 -0
  9. data/features/close_to_matcher.feature +130 -0
  10. data/features/context_arguments.feature +47 -0
  11. data/features/equal_to_matcher.feature +25 -0
  12. data/features/error_messages.feature +69 -0
  13. data/features/external_validation.feature +15 -0
  14. data/features/has_matcher.feature +72 -0
  15. data/features/has_matcher_initialized_with_expectation.feature +94 -0
  16. data/features/has_matcher_relativities.feature +171 -0
  17. data/features/include_matcher.feature +28 -0
  18. data/features/is_keyword.feature +42 -0
  19. data/features/is_not_keyword.feature +62 -0
  20. data/features/match_matcher.feature +49 -0
  21. data/features/multiple_validators.feature +29 -0
  22. data/features/nil_matcher.feature +25 -0
  23. data/features/predicate_matcher.feature +23 -0
  24. data/features/present_matcher.feature +59 -0
  25. data/features/satisfy_matcher.feature +80 -0
  26. data/features/scenario_validation.feature +81 -0
  27. data/features/step_definitions/external_validation_steps.rb +69 -0
  28. data/features/step_definitions/generic_validation_steps.rb +33 -0
  29. data/features/step_definitions/object_definition_steps.rb +43 -0
  30. data/features/support/env.rb +12 -0
  31. data/features/support/object_builder.rb +33 -0
  32. data/features/support/struct.rb +38 -0
  33. data/lang/en.yml +56 -0
  34. data/lib/ward.rb +26 -0
  35. data/lib/ward/context.rb +70 -0
  36. data/lib/ward/context_chain.rb +87 -0
  37. data/lib/ward/dsl.rb +7 -0
  38. data/lib/ward/dsl/validation_block.rb +73 -0
  39. data/lib/ward/dsl/validation_builder.rb +190 -0
  40. data/lib/ward/errors.rb +213 -0
  41. data/lib/ward/matchers.rb +97 -0
  42. data/lib/ward/matchers/acceptance.rb +43 -0
  43. data/lib/ward/matchers/close_to.rb +60 -0
  44. data/lib/ward/matchers/equal_to.rb +33 -0
  45. data/lib/ward/matchers/has.rb +283 -0
  46. data/lib/ward/matchers/include.rb +54 -0
  47. data/lib/ward/matchers/match.rb +29 -0
  48. data/lib/ward/matchers/matcher.rb +68 -0
  49. data/lib/ward/matchers/nil.rb +30 -0
  50. data/lib/ward/matchers/predicate.rb +31 -0
  51. data/lib/ward/matchers/present.rb +56 -0
  52. data/lib/ward/matchers/satisfy.rb +65 -0
  53. data/lib/ward/spec.rb +17 -0
  54. data/lib/ward/spec/matcher_matcher.rb +114 -0
  55. data/lib/ward/support.rb +7 -0
  56. data/lib/ward/support/basic_object.rb +55 -0
  57. data/lib/ward/support/result.rb +49 -0
  58. data/lib/ward/validator.rb +147 -0
  59. data/lib/ward/validator_set.rb +115 -0
  60. data/lib/ward/version.rb +3 -0
  61. data/spec/lib/has_matcher_relativity_examples.rb +15 -0
  62. data/spec/lib/have_public_method_defined.rb +22 -0
  63. data/spec/rcov.opts +8 -0
  64. data/spec/spec.opts +4 -0
  65. data/spec/spec_helper.rb +19 -0
  66. data/spec/ward/context_chain_spec.rb +178 -0
  67. data/spec/ward/context_spec.rb +57 -0
  68. data/spec/ward/dsl/validation_block_spec.rb +27 -0
  69. data/spec/ward/dsl/validation_builder_spec.rb +212 -0
  70. data/spec/ward/errors_spec.rb +149 -0
  71. data/spec/ward/matchers/acceptance_spec.rb +16 -0
  72. data/spec/ward/matchers/close_to_spec.rb +57 -0
  73. data/spec/ward/matchers/equal_to_spec.rb +16 -0
  74. data/spec/ward/matchers/has_spec.rb +175 -0
  75. data/spec/ward/matchers/include_spec.rb +41 -0
  76. data/spec/ward/matchers/match_spec.rb +21 -0
  77. data/spec/ward/matchers/matcher_spec.rb +54 -0
  78. data/spec/ward/matchers/nil_spec.rb +16 -0
  79. data/spec/ward/matchers/predicate_spec.rb +19 -0
  80. data/spec/ward/matchers/present_spec.rb +16 -0
  81. data/spec/ward/matchers/satisfy_spec.rb +68 -0
  82. data/spec/ward/matchers_spec.rb +51 -0
  83. data/spec/ward/spec/have_public_method_defined_spec.rb +31 -0
  84. data/spec/ward/spec/matcher_matcher_spec.rb +217 -0
  85. data/spec/ward/validator_set_spec.rb +178 -0
  86. data/spec/ward/validator_spec.rb +264 -0
  87. data/tasks/features.rake +15 -0
  88. data/tasks/rcov.rake +24 -0
  89. data/tasks/spec.rake +18 -0
  90. data/tasks/yard.rake +9 -0
  91. data/ward.gemspec +176 -0
  92. metadata +239 -0
@@ -0,0 +1,7 @@
1
+ require 'ward/dsl/validation_block'
2
+ require 'ward/dsl/validation_builder'
3
+
4
+ module Ward
5
+ # Classes which provide the Ward validator DSL.
6
+ module DSL; end
7
+ end # Ward
@@ -0,0 +1,73 @@
1
+ module Ward
2
+ module DSL
3
+ # Creates one or more validators using a block.
4
+ #
5
+ # @see Ward::ValidatorSet.build
6
+ #
7
+ # @example
8
+ #
9
+ # # Builds a validation set with two validators
10
+ # #
11
+ # # * One for "author.name" and the EqualTo matcher.
12
+ # # * One for "title" with the Match matcher.
13
+ #
14
+ # ValidationBlock.new do |object|
15
+ # object.author.name.is.equal_to('Michael Scarn')
16
+ # object.title.match(/something/)
17
+ # end
18
+ #
19
+ class ValidationBlock < Support::BasicObject
20
+
21
+ # Creates a new ValidationBlock instance.
22
+ #
23
+ # NOTE: Providing an existing ValidatorSet will result in a copy of that
24
+ # set being mutated; the original will not be changed.
25
+ #
26
+ # @param [Ward::ValidatorSet] set
27
+ # A ValidatorSet to which the built validators should be added.
28
+ #
29
+ def initialize(set = nil, &block)
30
+ @set = if set.nil? then Ward::ValidatorSet.new else set.dup end
31
+ run(&block) if block
32
+ end
33
+
34
+ # Returns the ValidatorSet created by the DSL.
35
+ #
36
+ # @return [Ward::ValidatorSet]
37
+ #
38
+ def to_validator_set
39
+ @set
40
+ end
41
+
42
+ private
43
+
44
+ # Runs a block, creating the appropriate validators.
45
+ #
46
+ # @todo Ensure that each matcher was correctly set up.
47
+ #
48
+ def run
49
+ @builders = []
50
+ yield self
51
+ @set.merge!(@builders.map { |builder| builder.to_validator })
52
+ @builders = nil
53
+ end
54
+
55
+ # Provides the DSL.
56
+ #
57
+ # Will take the given message and creates a new ValidationBuilder.
58
+ #
59
+ # @return [Ward::DSL::ValidationBuilder]
60
+ # Returns the created builder.
61
+ #
62
+ def method_missing(method, *extra_args, &block)
63
+ raise 'ValidationBlock can only be used when provided ' \
64
+ 'with a block' if @builders.nil?
65
+
66
+ builder = ValidationBuilder.new.__send__(method, *extra_args, &block)
67
+ @builders.push(builder)
68
+ builder
69
+ end
70
+
71
+ end # ValidationBlock
72
+ end # DSL
73
+ end # Ward
@@ -0,0 +1,190 @@
1
+ module Ward
2
+ module DSL
3
+ # Creates a single {Validator}. Any message received which doesn't
4
+ # correspond with a matcher will be assumed to be part of the context.
5
+ #
6
+ # @example
7
+ #
8
+ # # Builds a validation whose context is "author.name" and uses the
9
+ # # EqualTo matcher to ensure that the "author.name" is "Michel Scarn".
10
+ #
11
+ # ValidationBuilder.new.author.name.is.equal_to('Michael Scarn')
12
+ #
13
+ class ValidationBuilder < Support::BasicObject
14
+
15
+ # Creates a new ValidationBuilder instance.
16
+ #
17
+ def initialize
18
+ @context = Ward::ContextChain.new
19
+ @matcher, @message, @scenarios, @negative = nil, nil, nil, false
20
+ end
21
+
22
+ # Sets the error message to be used if the validation fails.
23
+ #
24
+ # This can be one of three possibilities:
25
+ #
26
+ # * A Hash. If the matcher used returns a different error state in
27
+ # order to provide more details about what failed (such as the Has
28
+ # matcher), you may provide a hash with custom error messages for
29
+ # each error state.
30
+ #
31
+ # * A String which will be used whenever the validation fails,
32
+ # regardless of what went wrong.
33
+ #
34
+ # * nil (default). The validation will use the default error message
35
+ # for the matcher.
36
+ #
37
+ # @param [Hash{Symbol => String}, String, nil] message
38
+ #
39
+ # @return [Ward::DSL::ValidatorBuilder]
40
+ # Returns self.
41
+ #
42
+ # @example Setting an explicit error message.
43
+ #
44
+ # validate do |person|
45
+ # person.name.is.present.message('You must enter a name!')
46
+ # end
47
+ #
48
+ # @example Setting an explicit error message with a Hash.
49
+ #
50
+ # validate do |person|
51
+ # person.name.length.is(1..50).message(
52
+ # :too_short => "Your name must be at least 1 character long",
53
+ # :too_long => "That's an interesting name!"
54
+ # )
55
+ # end
56
+ #
57
+ def message(message)
58
+ @message = message
59
+ self
60
+ end
61
+
62
+ # Sets the name to be used for the context in error messages.
63
+ #
64
+ # When Ward generates error messages for you, it determines the
65
+ # 'context name' by joining the method names; for example 'name.length'
66
+ # becomes 'name length'.
67
+ #
68
+ # This isn't much use if you want to support languages other than
69
+ # English in your application, so the 'context' method allows you to set
70
+ # a custom string to be used. You may provide a String, in which case it
71
+ # will be used literally, a Hash of +language => String+, or a Symbol
72
+ # identifying a string to be used from a language file.
73
+ #
74
+ # See the localisation documentation for more examples.
75
+ #
76
+ def context(name)
77
+ @context_name = name
78
+ self
79
+ end
80
+
81
+ # Sets the scenarios under which the built validator should run.
82
+ #
83
+ # @param [Symbol, ...] scenarios
84
+ # The scenarios as Symbols.
85
+ #
86
+ # @return [Ward::DSL::ValidatorBuilder]
87
+ # Returns self.
88
+ #
89
+ def scenarios(*scenarios)
90
+ @scenarios = scenarios.flatten
91
+ self
92
+ end
93
+
94
+ alias_method :scenario, :scenarios
95
+
96
+ # Adds an attribute to the context chain.
97
+ #
98
+ # Useful your classes have attribute which you want to validate, and
99
+ # their name conflicts with a method on the validator DSL (e.g.
100
+ # "message").
101
+ #
102
+ # @param [Symbol] attribute
103
+ # The attribute to be validated.
104
+ #
105
+ # @return [Ward::DSL::ValidatorBuilder]
106
+ # Returns self.
107
+ #
108
+ def attribute(attribute, *args, &block)
109
+ @context << Ward::Context.new(attribute, *args, &block)
110
+ self
111
+ end
112
+
113
+ # Set this as a positive expectation. Can be omitted.
114
+ #
115
+ # @example
116
+ # object.name.is.blank
117
+ #
118
+ # @return [Ward::DSL::ValidatorBuilder]
119
+ # Returns self.
120
+ #
121
+ def is(*args)
122
+ equal_to(*args) unless args.empty?
123
+ self
124
+ end
125
+
126
+ # Set this as a negative expectation.
127
+ #
128
+ # @example
129
+ # object.name.is_not.blank
130
+ #
131
+ # @return [Ward::DSL::ValidatorBuilder]
132
+ # Returns self.
133
+ #
134
+ def is_not(*args)
135
+ @negative = true
136
+ is(*args)
137
+ end
138
+
139
+ alias_method :does_not, :is_not
140
+
141
+ # Provides the DSL.
142
+ #
143
+ # Will take the given message and use it to customise the matcher (if
144
+ # one is set), set a matcher, or extend the context.
145
+ #
146
+ # @return [Ward::DSL::ValidatorBuilder]
147
+ # Returns self.
148
+ #
149
+ def method_missing(method, *args, &block)
150
+ # I'd normally shy away from using method_missing, but there's no
151
+ # good alternative here since a user may register their own matchers
152
+ # later in the load process.
153
+
154
+ if @matcher
155
+ @matcher.__send__(method, *args, &block)
156
+ elsif Ward::Matchers.matchers.has_key?(method)
157
+ @matcher = Ward::Matchers.matchers[method].new(*args, &block)
158
+ elsif method.to_s =~ /\?$/
159
+ @matcher = Ward::Matchers::Predicate.new(method, *args, &block)
160
+ else
161
+ attribute(method, *args, &block)
162
+ end
163
+
164
+ self
165
+ end
166
+
167
+ # Converts the builder to a Validator instance.
168
+ #
169
+ # @return [Ward::Validator]
170
+ #
171
+ # @raise [IncompleteValidation]
172
+ # An IncompleteValidationError will be raised if builder does not have
173
+ # all of the needed information in order to create the necessary
174
+ # validation (for example, if no matcher has been set).
175
+ #
176
+ # @todo
177
+ # More descriptive error messages.
178
+ #
179
+ def to_validator
180
+ raise Ward::IncompleteValidator,
181
+ 'Validator was missing a matcher' if @matcher.nil?
182
+
183
+ Ward::Validator.new(@context, @matcher, :message => @message,
184
+ :scenarios => @scenarios, :negative => @negative,
185
+ :context_name => @context_name)
186
+ end
187
+
188
+ end # Validate
189
+ end # DSL
190
+ end # Ward
@@ -0,0 +1,213 @@
1
+ module Ward
2
+ # Holds errors associated with a valid? call.
3
+ #
4
+ class Errors
5
+
6
+ include Enumerable
7
+
8
+ class << self
9
+
10
+ # Returns a localisation message.
11
+ #
12
+ # @param [keys] String
13
+ # The key of the message to be returned from the current language
14
+ # file.
15
+ #
16
+ # @return [String, nil]
17
+ # Returns the message or nil if it couldn't be found.
18
+ #
19
+ # @example
20
+ #
21
+ # Ward::Errors.message('has.eql.positive')
22
+ # # => '%{context} should have %{expected} %{collection}'
23
+ #
24
+ # @example Passing multiple keys as fallbacks.
25
+ #
26
+ # Ward::Errors.message(
27
+ # 'does.not.exist', 'has.eql.negative', 'has.eql.positive')
28
+ #
29
+ # # => '%{context} should not have %{expected} %{collection}'
30
+ #
31
+ def message(*keys)
32
+ messages[ keys.detect { |key| messages.has_key?(key) } ]
33
+ end
34
+
35
+ # Returns the unformatted error message for a matcher.
36
+ #
37
+ # @param [Ward::Matchers::Matcher] matcher
38
+ # The matcher.
39
+ # @param [Boolean] negative
40
+ # Whether to return a negative message, rather than a positive.
41
+ # @param [nil, Symbol, String] key
42
+ # If a string is supplied, +error_for+ will assume that the string
43
+ # should be used as the error. A symbol will be assumed to be a 'key'
44
+ # from the language file, while nil will result in the validator using
45
+ # the default error message for the matcher.
46
+ #
47
+ # @return [String]
48
+ #
49
+ def error_for(matcher, negative, key = nil)
50
+ return key if key.is_a?(String)
51
+
52
+ language_key = if key.nil?
53
+ "#{matcher.class.error_id}."
54
+ else
55
+ "#{matcher.class.error_id}.#{key}."
56
+ end
57
+
58
+ language_key << (negative ? 'negative' : 'positive')
59
+
60
+ message(language_key) || '%{context} is invalid'
61
+ end
62
+
63
+ # Receives an array and formats it nicely, assuming that only one value
64
+ # is expected.
65
+ #
66
+ # @example One member
67
+ # format_exclusive_list([1]) # => '1'
68
+ #
69
+ # @example Two members
70
+ # format_exclusive_list([1, 2]) # => '1 or 2'
71
+ #
72
+ # @example Many members
73
+ # format_exclusive_list([1, 2, 3]) # => '1, 2, or 3'
74
+ #
75
+ # @param [Enumerable] list
76
+ # The list to be formatted.
77
+ #
78
+ # @return [String]
79
+ #
80
+ def format_exclusive_list(list)
81
+ format_list(list, message('generic.exclusive_conjunction'))
82
+ end
83
+
84
+ # Receives an array and formats it nicely, assuming that all values are
85
+ # expected.
86
+ #
87
+ # @example One member
88
+ # format_inclusive_list([1]) # => '1'
89
+ #
90
+ # @example Two members
91
+ # format_inclusive_list([1, 2]) # => '1 and 2'
92
+ #
93
+ # @example Many members
94
+ # format_inclusive_list([1, 2, 3]) # => '1, 2, and 3'
95
+ #
96
+ # @param [Enumerable] list
97
+ # The list to be formatted.
98
+ #
99
+ # @return [String]
100
+ #
101
+ def format_inclusive_list(list)
102
+ format_list(list, message('generic.inclusive_conjunction'))
103
+ end
104
+
105
+ private
106
+
107
+ # Formats a list.
108
+ #
109
+ # @see Ward::Errors.format_exclusive_list
110
+ # @see Ward::Errors.format_inclusive_list
111
+ #
112
+ def format_list(list, conjunction)
113
+ case list.size
114
+ when 0 then ''
115
+ when 1 then list.first.to_s
116
+ when 2 then "#{list.first.to_s} #{conjunction} #{list.last.to_s}"
117
+ else
118
+ as_strings = list.map { |value| value.to_s }
119
+ as_strings[-1] = "#{conjunction} #{as_strings[-1]}"
120
+ as_strings.join("#{message('generic.list_seperator')} ")
121
+ end
122
+ end
123
+
124
+ # Returns the en-US error message hash. TEMPORARY.
125
+ #
126
+ # @return [Hash]
127
+ #
128
+ def messages
129
+ @error_messages ||= normalise_messages(YAML.load(
130
+ File.read(File.expand_path('../../../lang/en.yml', __FILE__)) ))
131
+ end
132
+
133
+ # Transforms a hash of messages to a single hash using dot notation.
134
+ #
135
+ def normalise_messages(messages, transformed = {}, key_prefix = '')
136
+ messages.each do |key, value|
137
+ item_key = key_prefix.empty? ? key : "#{key_prefix}.#{key}"
138
+
139
+ if value.is_a?(Hash)
140
+ normalise_messages(value, transformed, item_key)
141
+ else
142
+ transformed[item_key] = value
143
+ end
144
+ end
145
+
146
+ transformed
147
+ end
148
+
149
+ end # class << self
150
+
151
+ # Creates a new Errors instance.
152
+ #
153
+ def initialize
154
+ @errors = {}
155
+ end
156
+
157
+ # Adds an error message to the instance.
158
+ #
159
+ # @param [Symbol, Ward::Context, Ward::ContextChain] attribute
160
+ # The attribute or context for the error.
161
+ # @param [String] message
162
+ # The error message to add.
163
+ #
164
+ # @return [String]
165
+ # Returns the error message which was set.
166
+ #
167
+ # @todo
168
+ # Support symbols for i18n.
169
+ #
170
+ def add(attribute, message)
171
+ if attribute.kind_of?(Context) or attribute.kind_of?(ContextChain)
172
+ attribute = attribute.attribute
173
+ end
174
+
175
+ @errors[attribute] ||= []
176
+ @errors[attribute] << message
177
+ message
178
+ end
179
+
180
+ # Returns an array of the errors present on an an attribute.
181
+ #
182
+ # @param [Symbol] attribute
183
+ # The attribute whose errors you wish to retrieve.
184
+ #
185
+ # @return [Array]
186
+ # Returns the error messages for an attribute, or nil if there are none.
187
+ #
188
+ def on(attribute)
189
+ @errors[attribute]
190
+ end
191
+
192
+ # Iterates through each attribute and the errors.
193
+ #
194
+ # @yieldparam [Symbol] attribute
195
+ # The attribute name.
196
+ # @yieldparam [Array, nil] messages
197
+ # An array with each error message for the attribute, or nil if the
198
+ # attribute has no errors.
199
+ #
200
+ def each(&block)
201
+ @errors.each(&block)
202
+ end
203
+
204
+ # Returns if there are no errors contained.
205
+ #
206
+ # @return [Boolean]
207
+ #
208
+ def empty?
209
+ @errors.empty?
210
+ end
211
+
212
+ end # Errors
213
+ end # Ward