stannum 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/constraints/properties'
4
+ require 'stannum/constraints/properties/matching'
5
+
6
+ module Stannum::Constraints::Properties
7
+ # Compares the properties of the given object with the specified property.
8
+ #
9
+ # If all of the property values equal the expected value, the constraint will
10
+ # match the object; otherwise, if there are any non-matching values, the
11
+ # constraint will not match.
12
+ #
13
+ # @example Using an Properties::Match constraint
14
+ # ConfirmPassword = Struct.new(:password, :confirmation)
15
+ # constraint = Stannum::Constraints::Properties::MatchProperty.new(
16
+ # :password,
17
+ # :confirmation
18
+ # )
19
+ #
20
+ # params = ConfirmPassword.new('tronlives', 'ifightfortheusers')
21
+ # constraint.matches?(params)
22
+ # #=> false
23
+ # constraint.errors_for(params)
24
+ # #=> [
25
+ # {
26
+ # path: [:confirmation],
27
+ # type: 'stannum.constraints.is_not_equal_to',
28
+ # data: { expected: '[FILTERED]', actual: '[FILTERED]' }
29
+ # }
30
+ # ]
31
+ #
32
+ # params = ConfirmPassword.new('tronlives', 'tronlives')
33
+ # constraint.matches?(params)
34
+ # #=> true
35
+ class MatchProperty < Stannum::Constraints::Properties::Matching
36
+ # The :type of the error generated for a matching object.
37
+ NEGATED_TYPE = Stannum::Constraints::Equality::NEGATED_TYPE
38
+
39
+ # The :type of the error generated for a non-matching object.
40
+ TYPE = Stannum::Constraints::Equality::TYPE
41
+
42
+ # @return [true, false] false if any of the property values match the
43
+ # reference property value; otherwise true.
44
+ def does_not_match?(actual)
45
+ return false unless can_match_properties?(actual)
46
+
47
+ expected = expected_value(actual)
48
+
49
+ return false if skip_property?(expected)
50
+
51
+ each_matching_property(
52
+ actual: actual,
53
+ expected: expected,
54
+ include_all: true
55
+ )
56
+ .none?
57
+ end
58
+
59
+ # (see Stannum::Constraints::Base#errors_for)
60
+ def errors_for(actual, errors: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
61
+ errors ||= Stannum::Errors.new
62
+
63
+ return invalid_object_errors(errors) unless can_match_properties?(actual)
64
+
65
+ expected = expected_value(actual)
66
+ matching = each_non_matching_property(actual: actual, expected: expected)
67
+
68
+ return generic_errors(errors) if matching.count.zero?
69
+
70
+ matching.each do |property_name, value|
71
+ errors[property_name].add(
72
+ type,
73
+ message: message,
74
+ expected: filter_parameters? ? '[FILTERED]' : expected_value(actual),
75
+ actual: filter_parameters? ? '[FILTERED]' : value
76
+ )
77
+ end
78
+
79
+ errors
80
+ end
81
+
82
+ # @return [true, false] true if the property values match the reference
83
+ # property value; otherwise false.
84
+ def matches?(actual)
85
+ return false unless can_match_properties?(actual)
86
+
87
+ expected = expected_value(actual)
88
+
89
+ return true if skip_property?(expected)
90
+
91
+ each_non_matching_property(actual: actual, expected: expected).none?
92
+ end
93
+ alias match? matches?
94
+
95
+ # (see Stannum::Constraints::Base#negated_errors_for)
96
+ def negated_errors_for(actual, errors: nil) # rubocop:disable Metrics/MethodLength
97
+ errors ||= Stannum::Errors.new
98
+
99
+ return invalid_object_errors(errors) unless can_match_properties?(actual)
100
+
101
+ expected = expected_value(actual)
102
+ matching = each_matching_property(
103
+ actual: actual,
104
+ expected: expected,
105
+ include_all: true
106
+ )
107
+
108
+ return generic_errors(errors) if matching.count.zero?
109
+
110
+ matching.each do |property_name, _|
111
+ errors[property_name].add(negated_type, message: negated_message)
112
+ end
113
+
114
+ errors
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/constraints/properties'
4
+ require 'stannum/constraints/properties/base'
5
+
6
+ module Stannum::Constraints::Properties
7
+ # Abstract base class for property matching constraints.
8
+ class Matching < Stannum::Constraints::Properties::Base
9
+ # @param reference_name [String, Symbol] the name of the reference property
10
+ # to compare to.
11
+ # @param property_names [Array<String, Symbol>] the name or names of the
12
+ # properties to compare.
13
+ # @param options [Hash<Symbol, Object>] configuration options for the
14
+ # constraint. Defaults to an empty Hash.
15
+ #
16
+ # @option options allow_empty [true, false] if true, will match against an
17
+ # object with empty property values, such as an empty string.
18
+ # @option options allow_nil [true, false] if true, will match against an
19
+ # object with nil property values.
20
+ def initialize(reference_name, *property_names, **options)
21
+ @reference_name = reference_name
22
+
23
+ validate_reference_name
24
+
25
+ super(*property_names, reference_name: reference_name, **options)
26
+ end
27
+
28
+ # @return [String, Symbol] the name of the reference property to compare to.
29
+ attr_reader :reference_name
30
+
31
+ private
32
+
33
+ def each_matching_property( # rubocop:disable Metrics/MethodLength
34
+ actual:,
35
+ expected:,
36
+ include_all: false,
37
+ &block
38
+ )
39
+ unless block_given?
40
+ return to_enum(
41
+ __method__,
42
+ actual: actual,
43
+ expected: expected,
44
+ include_all: include_all
45
+ )
46
+ end
47
+
48
+ enumerator = each_property(actual)
49
+
50
+ unless include_all
51
+ enumerator = enumerator.reject { |_, value| skip_property?(value) }
52
+ end
53
+
54
+ enumerator = enumerator.select { |_, value| valid?(expected, value) }
55
+
56
+ block_given? ? enumerator.each(&block) : enumerator
57
+ end
58
+
59
+ def each_non_matching_property( # rubocop:disable Metrics/MethodLength
60
+ actual:,
61
+ expected:,
62
+ include_all: false,
63
+ &block
64
+ )
65
+ unless block_given?
66
+ return to_enum(
67
+ __method__,
68
+ actual: actual,
69
+ expected: expected,
70
+ include_all: include_all
71
+ )
72
+ end
73
+
74
+ enumerator = each_property(actual)
75
+
76
+ unless include_all
77
+ enumerator = enumerator.reject { |_, value| skip_property?(value) }
78
+ end
79
+
80
+ enumerator = enumerator.reject { |_, value| valid?(expected, value) }
81
+
82
+ block_given? ? enumerator.each(&block) : enumerator
83
+ end
84
+
85
+ def expected_value(actual)
86
+ actual[reference_name]
87
+ end
88
+
89
+ def filter_parameters?
90
+ return @filter_parameters unless @filter_parameters.nil?
91
+
92
+ filters = filtered_parameters.map { |param| Regexp.new(param.to_s) }
93
+
94
+ @filter_parameters =
95
+ [reference_name, *property_names].any? do |property_name|
96
+ filters.any? { |filter| filter.match?(property_name.to_s) }
97
+ end
98
+ end
99
+
100
+ def generic_errors(errors)
101
+ errors.add(Stannum::Constraints::Base::NEGATED_TYPE)
102
+ end
103
+
104
+ def valid?(expected, value)
105
+ value == expected
106
+ end
107
+
108
+ def validate_reference_name
109
+ tools.assertions.validate_name(reference_name, as: 'reference name')
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/constraints'
4
+
5
+ module Stannum::Constraints
6
+ # Namespace for Object property-specific constraints.
7
+ module Properties
8
+ autoload :Base,
9
+ 'stannum/constraints/properties/base'
10
+ autoload :DoNotMatchProperty,
11
+ 'stannum/constraints/properties/do_not_match_property'
12
+ autoload :MatchProperty,
13
+ 'stannum/constraints/properties/match_property'
14
+ autoload :Matching,
15
+ 'stannum/constraints/properties/matching'
16
+ end
17
+ end
@@ -78,7 +78,7 @@ module Stannum::Constraints::Tuples
78
78
  def each_extra_item(actual, &block)
79
79
  return if matches?(actual)
80
80
 
81
- actual[expected_count..-1].each.with_index(expected_count, &block)
81
+ actual[expected_count..].each.with_index(expected_count, &block)
82
82
  end
83
83
  end
84
84
  end
@@ -107,7 +107,7 @@ module Stannum::Constraints
107
107
 
108
108
  raise ArgumentError,
109
109
  'expected type must be a Class or Module',
110
- caller[1..-1]
110
+ caller[1..]
111
111
  end
112
112
  end
113
113
  end
@@ -17,6 +17,7 @@ module Stannum
17
17
  autoload :Nothing, 'stannum/constraints/nothing'
18
18
  autoload :Parameters, 'stannum/constraints/parameters'
19
19
  autoload :Presence, 'stannum/constraints/presence'
20
+ autoload :Properties, 'stannum/constraints/properties'
20
21
  autoload :Signature, 'stannum/constraints/signature'
21
22
  autoload :Signatures, 'stannum/constraints/signatures'
22
23
  autoload :Tuples, 'stannum/constraints/tuples'
@@ -18,6 +18,17 @@ module Stannum::Contracts
18
18
  # @return [Stannum::Contract] The contract to which constraints are added.
19
19
  attr_reader :contract
20
20
 
21
+ # Concatenate the constraints from the given other contract.
22
+ #
23
+ # @param other [Stannum::Contract] the other contract.
24
+ #
25
+ # @return [self] the contract builder.
26
+ #
27
+ # @see Stannum::Contracts::Base#concat.
28
+ def concat(other)
29
+ contract.concat(other)
30
+ end
31
+
21
32
  # Adds a constraint to the contract.
22
33
  #
23
34
  # @overload constraint(constraint, **options)
@@ -47,8 +58,8 @@ module Stannum::Contracts
47
58
  private
48
59
 
49
60
  def ambiguous_values_error(constraint)
50
- 'expected either a block or a constraint instance, but received both a' \
51
- " block and #{constraint.inspect}"
61
+ 'expected either a block or a constraint instance, but received both a ' \
62
+ "block and #{constraint.inspect}"
52
63
  end
53
64
 
54
65
  def resolve_constraint(constraint = nil, **options, &block)
@@ -74,5 +74,18 @@ module Stannum::Contracts
74
74
  actual.fetch(property) { actual[property.to_s] }
75
75
  end
76
76
  end
77
+
78
+ private
79
+
80
+ def add_extra_keys_constraint
81
+ return if options[:allow_extra_keys]
82
+
83
+ keys = -> { expected_keys }
84
+
85
+ add_constraint(
86
+ Stannum::Constraints::Hashes::IndifferentExtraKeys.new(keys),
87
+ concatenatable: false
88
+ )
89
+ end
77
90
  end
78
91
  end
@@ -168,7 +168,7 @@ module Stannum::Contracts
168
168
 
169
169
  index = 1 + definition.options.fetch(:property, -1)
170
170
 
171
- index > count ? index : count
171
+ [index, count].max
172
172
  end
173
173
  end
174
174
 
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/entities'
4
+ require 'stannum/schema'
5
+
6
+ module Stannum::Entities
7
+ # Methods for defining and accessing entity attributes.
8
+ module Attributes
9
+ # Class methods to extend the class when including Attributes.
10
+ module ClassMethods
11
+ # Defines an attribute on the entity.
12
+ #
13
+ # When an attribute is defined, each of the following steps is executed:
14
+ #
15
+ # - Adds the attribute to ::Attributes and the .attributes class method.
16
+ # - Adds the attribute to #attributes and the associated methods, such as
17
+ # #assign_attributes, #[] and #[]=.
18
+ # - Defines reader and writer methods.
19
+ #
20
+ # @param attr_name [String, Symbol] The name of the attribute. Must be a
21
+ # non-empty String or Symbol.
22
+ # @param attr_type [Class, String] The type of the attribute. Must be a
23
+ # Class or Module, or the name of a class or module.
24
+ # @param options [Hash] Additional options for the attribute.
25
+ #
26
+ # @option options [Object] :default The default value for the attribute.
27
+ # Defaults to nil.
28
+ #
29
+ # @return [Symbol] The attribute name as a symbol.
30
+ def attribute(attr_name, attr_type, **options)
31
+ attributes.define_attribute(
32
+ name: attr_name,
33
+ type: attr_type,
34
+ options: options
35
+ )
36
+
37
+ attr_name.intern
38
+ end
39
+
40
+ # @return [Stannum::Schema] The attributes Schema object for the Entity.
41
+ def attributes
42
+ self::Attributes
43
+ end
44
+
45
+ private
46
+
47
+ def included(other)
48
+ super
49
+
50
+ other.include(Stannum::Entities::Attributes)
51
+
52
+ Stannum::Entities::Attributes.apply(other) if other.is_a?(Class)
53
+ end
54
+
55
+ def inherited(other)
56
+ super
57
+
58
+ Stannum::Entities::Attributes.apply(other)
59
+ end
60
+ end
61
+
62
+ class << self
63
+ # Generates Attributes schema for the class.
64
+ #
65
+ # Creates a new Stannum::Schema and sets it as the class's :Attributes
66
+ # constant. If the superclass is an entity class (and already defines its
67
+ # own Attributes, includes the superclass Attributes in the class
68
+ # Attributes). Finally, includes the class Attributes in the class.
69
+ #
70
+ # @param other [Class] the class to which attributes are added.
71
+ def apply(other)
72
+ return unless other.is_a?(Class)
73
+
74
+ return if entity_class?(other)
75
+
76
+ other.const_set(:Attributes, Stannum::Schema.new)
77
+
78
+ if entity_class?(other.superclass)
79
+ other::Attributes.include(other.superclass::Attributes)
80
+ end
81
+
82
+ other.include(other::Attributes)
83
+ end
84
+
85
+ private
86
+
87
+ def entity_class?(other)
88
+ other.const_defined?(:Attributes, false)
89
+ end
90
+
91
+ def included(other)
92
+ super
93
+
94
+ other.extend(self::ClassMethods)
95
+
96
+ apply(other) if other.is_a?(Class)
97
+ end
98
+ end
99
+
100
+ # @param properties [Hash] the properties used to initialize the entity.
101
+ def initialize(**properties)
102
+ @attributes = {}
103
+
104
+ super
105
+ end
106
+
107
+ # Updates the struct's attributes with the given values.
108
+ #
109
+ # This method is used to update some (but not all) of the attributes of the
110
+ # struct. For each key in the hash, it calls the corresponding writer method
111
+ # with the value for that attribute. If the value is nil, this will set the
112
+ # attribute value to the default for that attribute.
113
+ #
114
+ # Any attributes that are not in the given hash are unchanged, as are any
115
+ # properties that are not attributes.
116
+ #
117
+ # If the attributes hash includes any keys that do not correspond to an
118
+ # attribute, the struct will raise an error.
119
+ #
120
+ # @param attributes [Hash] The initial attributes for the struct.
121
+ #
122
+ # @raise ArgumentError if the key is not a valid attribute.
123
+ #
124
+ # @see #attributes=
125
+ def assign_attributes(attributes)
126
+ unless attributes.is_a?(Hash)
127
+ raise ArgumentError, 'attributes must be a Hash'
128
+ end
129
+
130
+ set_attributes(attributes, force: false)
131
+ end
132
+
133
+ # Collects the entity attributes.
134
+ #
135
+ # @param attributes [Hash<String, Object>] the entity attributes.
136
+ def attributes
137
+ @attributes.dup
138
+ end
139
+
140
+ # Replaces the entity's attributes with the given values.
141
+ #
142
+ # This method is used to update all of the attributes of the entity. For
143
+ # each attribute, the writer method is called with the value from the hash,
144
+ # or nil if the corresponding key is not present in the hash. Any nil or
145
+ # missing values set the attribute value to that attribute's default value,
146
+ # if any. Non-attribute properties are unchanged.
147
+ #
148
+ # If the attributes hash includes any keys that do not correspond to a valid
149
+ # attribute, the entity will raise an error.
150
+ #
151
+ # @param attributes [Hash] the attributes to assign to the entity.
152
+ #
153
+ # @raise ArgumentError if any key is not a valid attribute.
154
+ #
155
+ # @see #assign_attributes
156
+ def attributes=(attributes)
157
+ unless attributes.is_a?(Hash)
158
+ raise ArgumentError, 'attributes must be a Hash'
159
+ end
160
+
161
+ set_attributes(attributes, force: true)
162
+ end
163
+
164
+ # (see Stannum::Entities::Properties#properties)
165
+ def properties
166
+ super.merge(attributes)
167
+ end
168
+
169
+ private
170
+
171
+ def get_property(key)
172
+ return @attributes[key.to_s] if attributes.key?(key.to_s)
173
+
174
+ super
175
+ end
176
+
177
+ def inspectable_properties
178
+ super().merge(attributes)
179
+ end
180
+
181
+ def set_attributes(attributes, force:)
182
+ attributes, non_matching =
183
+ bisect_properties(attributes, self.class.attributes)
184
+
185
+ unless non_matching.empty?
186
+ handle_invalid_properties(non_matching, as: 'attribute')
187
+ end
188
+
189
+ write_attributes(attributes, force: force)
190
+ end
191
+
192
+ def set_properties(properties, force:)
193
+ attributes, non_matching =
194
+ bisect_properties(properties, self.class.attributes)
195
+
196
+ super(non_matching, force: force)
197
+
198
+ write_attributes(attributes, force: force)
199
+ end
200
+
201
+ def set_property(key, value)
202
+ return super unless attributes.key?(key.to_s)
203
+
204
+ send(self.class.attributes[key.to_s].writer_name, value)
205
+ end
206
+
207
+ def write_attributes(attributes, force:)
208
+ self.class.attributes.each do |attr_name, attribute|
209
+ next unless attributes.key?(attr_name) || force
210
+
211
+ send(
212
+ attribute.writer_name,
213
+ attributes[attr_name].nil? ? attribute.default : attributes[attr_name]
214
+ )
215
+ end
216
+ end
217
+ end
218
+ end