stannum 0.2.0 → 0.3.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,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