stannum 0.2.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +85 -21
- data/lib/stannum/constraints/hashes/extra_keys.rb +7 -2
- data/lib/stannum/constraints/hashes/indifferent_extra_keys.rb +47 -0
- data/lib/stannum/constraints/hashes.rb +6 -2
- data/lib/stannum/constraints/properties/base.rb +124 -0
- data/lib/stannum/constraints/properties/do_not_match_property.rb +117 -0
- data/lib/stannum/constraints/properties/match_property.rb +117 -0
- data/lib/stannum/constraints/properties/matching.rb +112 -0
- data/lib/stannum/constraints/properties.rb +17 -0
- data/lib/stannum/constraints/tuples/extra_items.rb +1 -1
- data/lib/stannum/constraints/type.rb +1 -1
- data/lib/stannum/constraints.rb +1 -0
- data/lib/stannum/contracts/builder.rb +13 -2
- data/lib/stannum/contracts/indifferent_hash_contract.rb +13 -0
- data/lib/stannum/contracts/tuple_contract.rb +1 -1
- data/lib/stannum/entities/attributes.rb +218 -0
- data/lib/stannum/entities/constraints.rb +177 -0
- data/lib/stannum/entities/properties.rb +186 -0
- data/lib/stannum/entities.rb +13 -0
- data/lib/stannum/entity.rb +83 -0
- data/lib/stannum/errors.rb +3 -3
- data/lib/stannum/rspec/match_errors_matcher.rb +6 -6
- data/lib/stannum/rspec/validate_parameter_matcher.rb +7 -7
- data/lib/stannum/schema.rb +78 -37
- data/lib/stannum/struct.rb +12 -346
- data/lib/stannum/version.rb +1 -1
- data/lib/stannum.rb +3 -0
- metadata +25 -19
@@ -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
|
81
|
+
actual[expected_count..].each.with_index(expected_count, &block)
|
82
82
|
end
|
83
83
|
end
|
84
84
|
end
|
data/lib/stannum/constraints.rb
CHANGED
@@ -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
|
-
"
|
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
|
@@ -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
|