hashcast 0.4.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,15 @@
1
+ class HashCast::Casters::DateTimeCaster
2
+
3
+ def self.cast(value, attr_name, options = {})
4
+ return value if value.is_a?(DateTime)
5
+ return value.to_datetime if value.is_a?(Time)
6
+ return cast_string(value) if value.is_a?(String)
7
+ raise HashCast::Errors::CastingError, "should be a datetime"
8
+ end
9
+
10
+ def self.cast_string(value)
11
+ DateTime.parse(value)
12
+ rescue ArgumentError => e
13
+ raise HashCast::Errors::CastingError, "is invalid datetime"
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ class HashCast::Casters::FloatCaster
2
+
3
+ def self.cast(value, attr_name, options = {})
4
+ return value if value.is_a?(Float)
5
+ return cast_string(value) if value.is_a?(String)
6
+ raise HashCast::Errors::CastingError, "#{value} should be a float"
7
+ end
8
+
9
+ def self.cast_string(value)
10
+ Float(value)
11
+ rescue ArgumentError => e
12
+ raise HashCast::Errors::CastingError, "#{value} is invalid float"
13
+ end
14
+ end
@@ -0,0 +1,8 @@
1
+ class HashCast::Casters::HashCast
2
+
3
+ def self.cast(value, attr_name, options = {})
4
+ return value if value.is_a?(Hash)
5
+ raise HashCast::Errors::CastingError, "should be a hash"
6
+ end
7
+
8
+ end
@@ -0,0 +1,15 @@
1
+ class HashCast::Casters::IntegerCaster
2
+
3
+ def self.cast(value, attr_name, options = {})
4
+ return value if value.is_a?(Integer)
5
+ return cast_string(value) if value.is_a?(String)
6
+ raise HashCast::Errors::CastingError, "should be a integer"
7
+ end
8
+
9
+ def self.cast_string(value)
10
+ Integer(value)
11
+ rescue ArgumentError => e
12
+ raise HashCast::Errors::CastingError, "is invalid integer"
13
+ end
14
+
15
+ end
@@ -0,0 +1,8 @@
1
+ class HashCast::Casters::StringCaster
2
+
3
+ def self.cast(value, attr_name, options = {})
4
+ return value if value.is_a?(String)
5
+ return value.to_s if value.is_a?(Symbol)
6
+ raise HashCast::Errors::CastingError, "should be a string, but was #{value.class}"
7
+ end
8
+ end
@@ -0,0 +1,15 @@
1
+ class HashCast::Casters::SymbolCaster
2
+ MAX_SYMBOL_LENGTH = 1000
3
+
4
+ def self.cast(value, attr_name, options = {})
5
+ return value if value.is_a?(Symbol)
6
+ return cast_string(value) if value.is_a?(String)
7
+ raise HashCast::Errors::CastingError, "should be a symbol"
8
+ end
9
+
10
+ def self.cast_string(value)
11
+ return value.to_sym if value.length <= MAX_SYMBOL_LENGTH
12
+ raise HashCast::Errors::CastingError, "is too long to be a symbol (#{MAX_SYMBOL_LENGTH} max.)"
13
+ end
14
+
15
+ end
@@ -0,0 +1,15 @@
1
+ class HashCast::Casters::TimeCaster
2
+
3
+ def self.cast(value, attr_name, options = {})
4
+ return value if value.is_a?(Time)
5
+ return cast_string(value) if value.is_a?(String)
6
+ raise HashCast::Errors::CastingError, "#{value} should be a time"
7
+ end
8
+
9
+ def self.cast_string(value)
10
+ Time.parse(value)
11
+ rescue ArgumentError => e
12
+ raise HashCast::Errors::CastingError, "#{value} is invalid time"
13
+ end
14
+
15
+ end
@@ -0,0 +1,136 @@
1
+ module HashCast
2
+ # Copied from here https://github.com/rails/rails/blob/master/activesupport/lib/active_support/concern.rb
3
+ #
4
+ # A typical module looks like this:
5
+ #
6
+ # module M
7
+ # def self.included(base)
8
+ # base.extend ClassMethods
9
+ # base.class_eval do
10
+ # scope :disabled, -> { where(disabled: true) }
11
+ # end
12
+ # end
13
+ #
14
+ # module ClassMethods
15
+ # ...
16
+ # end
17
+ # end
18
+ #
19
+ # By using <tt>ActiveSupport::Concern</tt> the above module could instead be
20
+ # written as:
21
+ #
22
+ # require 'active_support/concern'
23
+ #
24
+ # module M
25
+ # extend ActiveSupport::Concern
26
+ #
27
+ # included do
28
+ # scope :disabled, -> { where(disabled: true) }
29
+ # end
30
+ #
31
+ # module ClassMethods
32
+ # ...
33
+ # end
34
+ # end
35
+ #
36
+ # Moreover, it gracefully handles module dependencies. Given a +Foo+ module
37
+ # and a +Bar+ module which depends on the former, we would typically write the
38
+ # following:
39
+ #
40
+ # module Foo
41
+ # def self.included(base)
42
+ # base.class_eval do
43
+ # def self.method_injected_by_foo
44
+ # ...
45
+ # end
46
+ # end
47
+ # end
48
+ # end
49
+ #
50
+ # module Bar
51
+ # def self.included(base)
52
+ # base.method_injected_by_foo
53
+ # end
54
+ # end
55
+ #
56
+ # class Host
57
+ # include Foo # We need to include this dependency for Bar
58
+ # include Bar # Bar is the module that Host really needs
59
+ # end
60
+ #
61
+ # But why should +Host+ care about +Bar+'s dependencies, namely +Foo+? We
62
+ # could try to hide these from +Host+ directly including +Foo+ in +Bar+:
63
+ #
64
+ # module Bar
65
+ # include Foo
66
+ # def self.included(base)
67
+ # base.method_injected_by_foo
68
+ # end
69
+ # end
70
+ #
71
+ # class Host
72
+ # include Bar
73
+ # end
74
+ #
75
+ # Unfortunately this won't work, since when +Foo+ is included, its <tt>base</tt>
76
+ # is the +Bar+ module, not the +Host+ class. With <tt>ActiveSupport::Concern</tt>,
77
+ # module dependencies are properly resolved:
78
+ #
79
+ # require 'active_support/concern'
80
+ #
81
+ # module Foo
82
+ # extend ActiveSupport::Concern
83
+ # included do
84
+ # def self.method_injected_by_foo
85
+ # ...
86
+ # end
87
+ # end
88
+ # end
89
+ #
90
+ # module Bar
91
+ # extend ActiveSupport::Concern
92
+ # include Foo
93
+ #
94
+ # included do
95
+ # self.method_injected_by_foo
96
+ # end
97
+ # end
98
+ #
99
+ # class Host
100
+ # include Bar # works, Bar takes care now of its dependencies
101
+ # end
102
+ module Concern
103
+ class MultipleIncludedBlocks < StandardError #:nodoc:
104
+ def initialize
105
+ super "Cannot define multiple 'included' blocks for a Concern"
106
+ end
107
+ end
108
+
109
+ def self.extended(base) #:nodoc:
110
+ base.instance_variable_set(:@_dependencies, [])
111
+ end
112
+
113
+ def append_features(base)
114
+ if base.instance_variable_defined?(:@_dependencies)
115
+ base.instance_variable_get(:@_dependencies) << self
116
+ return false
117
+ else
118
+ return false if base < self
119
+ @_dependencies.each { |dep| base.send(:include, dep) }
120
+ super
121
+ base.extend const_get(:ClassMethods) if const_defined?(:ClassMethods)
122
+ base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
123
+ end
124
+ end
125
+
126
+ def included(base = nil, &block)
127
+ if base.nil?
128
+ raise MultipleIncludedBlocks if instance_variable_defined?(:@_included_block)
129
+
130
+ @_included_block = block
131
+ else
132
+ super
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,11 @@
1
+ class HashCast::Config
2
+ attr_accessor :input_keys, :output_keys
3
+
4
+ def input_keys
5
+ @input_keys || :symbol
6
+ end
7
+
8
+ def output_keys
9
+ @output_keys || :symbol
10
+ end
11
+ end
@@ -0,0 +1,49 @@
1
+ module HashCast::Errors
2
+
3
+ # Base error class for all HashCast errors
4
+ class HashCastError < StandardError; end
5
+
6
+ # Raised when caster with given name is not registered in HashCast
7
+ class CasterNotFoundError < HashCastError; end
8
+
9
+ # Raised when some of the given to HashCast argument is not valid
10
+ class ArgumentError < HashCastError; end
11
+
12
+ class AttributeError < HashCastError
13
+ attr_reader :namespaces
14
+
15
+ def initialize(message, namespace = nil)
16
+ super(message)
17
+ @namespaces = []
18
+ @namespaces << namespace if namespace
19
+ end
20
+
21
+ def add_namespace(namespace)
22
+ namespaces << namespace
23
+ end
24
+
25
+ def message
26
+ to_s
27
+ end
28
+
29
+ def to_s
30
+ if namespaces.empty?
31
+ super
32
+ else
33
+ reverted_namespaces = namespaces.reverse
34
+ msg = reverted_namespaces.first.to_s
35
+ msg += reverted_namespaces[1..-1].inject("") { |res, item| res += "[#{item}]"}
36
+ msg + " " + super
37
+ end
38
+ end
39
+
40
+ end
41
+ # Raised when hash attribute can't be casted
42
+ class CastingError < AttributeError; end
43
+
44
+ # Raised when required hash attribute wasn't given for casting
45
+ class MissingAttributeError < AttributeError; end
46
+
47
+ # Raised when unexpected hash attribute was given for casting
48
+ class UnexpectedAttributeError < AttributeError; end
49
+ end
@@ -0,0 +1,30 @@
1
+ module HashCast::Metadata
2
+ class Attribute
3
+ attr_reader :name, :caster, :options
4
+ attr_accessor :children
5
+
6
+ def initialize(name, caster, options)
7
+ @name = name
8
+ @caster = caster
9
+ @options = options
10
+ @children = []
11
+ end
12
+
13
+ def has_children?
14
+ !children.empty?
15
+ end
16
+
17
+ def required?
18
+ !optional?
19
+ end
20
+
21
+ def optional?
22
+ !!options[:optional]
23
+ end
24
+
25
+ def allow_nil?
26
+ !!options[:allow_nil]
27
+ end
28
+
29
+ end
30
+ end
@@ -0,0 +1,3 @@
1
+ module HashCast
2
+ VERSION = "0.4.0"
3
+ end
@@ -0,0 +1,471 @@
1
+ require 'spec_helper'
2
+
3
+ describe HashCast::Caster do
4
+ describe "#cast" do
5
+
6
+ class ContactCaster
7
+ include HashCast::Caster
8
+
9
+ attributes do
10
+ hash :contact do
11
+ string :name
12
+ integer :age, optional: true
13
+ float :weight
14
+ date :birthday
15
+ datetime :last_logged_in
16
+ time :last_visited_at
17
+ hash :company do
18
+ string :name
19
+ end
20
+ array :emails, each: :string
21
+ array :social_accounts, each: :hash do
22
+ string :name
23
+ symbol :type
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ it "should cast hash attributes" do
30
+ input_hash = {
31
+ contact: {
32
+ name: "John Smith",
33
+ age: "22",
34
+ weight: "65.5",
35
+ birthday: "2014-02-02",
36
+ last_logged_in: "2014-02-02 10:10:00",
37
+ last_visited_at: "2014-02-02 10:10:00",
38
+ company: {
39
+ name: "MyCo",
40
+ },
41
+ emails: [ "test@example.com", "test2@example.com" ],
42
+ social_accounts: [
43
+ {
44
+ name: "john_smith",
45
+ type: 'twitter',
46
+ },
47
+ {
48
+ name: "John",
49
+ type: :facebook,
50
+ },
51
+ ]
52
+ }
53
+ }
54
+
55
+ casted_hash = ContactCaster.cast(input_hash)
56
+
57
+ casted_hash.should == {
58
+ contact: {
59
+ name: "John Smith",
60
+ age: 22,
61
+ weight: 65.5,
62
+ birthday: Date.parse("2014-02-02"),
63
+ last_logged_in: DateTime.parse("2014-02-02 10:10:00"),
64
+ last_visited_at: Time.parse("2014-02-02 10:10:00"),
65
+ company: {
66
+ name: "MyCo",
67
+ },
68
+ emails: [ "test@example.com", "test2@example.com" ],
69
+ social_accounts: [
70
+ {
71
+ name: "john_smith",
72
+ type: :twitter,
73
+ },
74
+ {
75
+ name: "John",
76
+ type: :facebook,
77
+ },
78
+ ]
79
+ }
80
+ }
81
+ end
82
+
83
+ describe "Custom casters" do
84
+ class SettingsCaster
85
+ include HashCast::Caster
86
+
87
+ attributes do
88
+ string :account
89
+ end
90
+ end
91
+
92
+ class EmailCaster
93
+ include HashCast::Caster
94
+
95
+ attributes do
96
+ string :address
97
+ end
98
+ end
99
+
100
+ class CompanyCaster
101
+ include HashCast::Caster
102
+
103
+ attributes do
104
+ string :name
105
+ hash :settings, caster: SettingsCaster
106
+ array :emails, caster: EmailCaster
107
+ end
108
+ end
109
+
110
+ it "should allow specify caster for nested hash attribute" do
111
+ casted_hash = CompanyCaster.cast(
112
+ name: 'Might & Magic',
113
+ settings: {
114
+ account: :'migthy_lord'
115
+ },
116
+ emails: [
117
+ { address: :'test1@example.com' },
118
+ { address: :'test2@example.com' },
119
+ ]
120
+ )
121
+
122
+ casted_hash.should == {
123
+ name: "Might & Magic",
124
+ settings: { account: "migthy_lord" },
125
+ emails: [
126
+ { address: "test1@example.com" },
127
+ { address: "test2@example.com" }
128
+ ]
129
+ }
130
+ end
131
+ end
132
+
133
+ it "should raise error if some attribute can't be casted" do
134
+ input_hash = {
135
+ contact: {
136
+ name: {},
137
+ age: 22,
138
+ weight: 65.5,
139
+ birthday: Date.today,
140
+ last_logged_in: DateTime.now,
141
+ last_visited_at: Time.now,
142
+ company: {
143
+ name: "MyCo",
144
+ },
145
+ emails: [ "test@example.com", "test2@example.com" ],
146
+ social_accounts: [
147
+ {
148
+ name: "john_smith",
149
+ type: :twitter,
150
+ },
151
+ {
152
+ name: "John",
153
+ type: :facebook,
154
+ }
155
+ ]
156
+ }
157
+ }
158
+
159
+ expect do
160
+ ContactCaster.cast(input_hash)
161
+ end.to raise_error(HashCast::Errors::CastingError, "contact[name] should be a string, but was Hash")
162
+ end
163
+
164
+ it "should raise error if some attribute wasn't given" do
165
+ input_hash = {
166
+ contact: {
167
+ age: 22,
168
+ weight: 65.5,
169
+ birthday: Date.today,
170
+ last_logged_in: DateTime.now,
171
+ last_visited_at: Time.now,
172
+ company: {
173
+ name: "MyCo",
174
+ },
175
+ emails: [ "test@example.com", "test2@example.com" ],
176
+ social_accounts: [
177
+ {
178
+ name: "john_smith",
179
+ type: :twitter,
180
+ },
181
+ {
182
+ name: "John",
183
+ type: :facebook,
184
+ }
185
+ ]
186
+ }
187
+ }
188
+
189
+ expect do
190
+ ContactCaster.cast(input_hash)
191
+ end.to raise_error(HashCast::Errors::MissingAttributeError, "contact[name] should be given")
192
+ end
193
+
194
+ it "should not raise error if attribute is optional" do
195
+ input_hash = {
196
+ contact: {
197
+ name: "Jim",
198
+ weight: 65.5,
199
+ birthday: Date.today,
200
+ last_logged_in: DateTime.now,
201
+ last_visited_at: Time.now,
202
+ company: {
203
+ name: "MyCo",
204
+ },
205
+ emails: [ "test@example.com", "test2@example.com" ],
206
+ social_accounts: [
207
+ {
208
+ name: "john_smith",
209
+ type: :twitter,
210
+ },
211
+ {
212
+ name: "John",
213
+ type: :facebook,
214
+ },
215
+ ]
216
+ }
217
+ }
218
+
219
+ expect do
220
+ ContactCaster.cast(input_hash)
221
+ end.to_not raise_error
222
+ end
223
+
224
+ it "should raise error if unexpected attribute was given" do
225
+ input_hash = {
226
+ contact: {
227
+ wrong_attribute: 'foo',
228
+ name: "Jim",
229
+ weight: 65.5,
230
+ birthday: Date.today,
231
+ last_logged_in: DateTime.now,
232
+ last_visited_at: Time.now,
233
+ company: {
234
+ name: "MyCo",
235
+ },
236
+ emails: [ "test@example.com", "test2@example.com" ],
237
+ social_accounts: [
238
+ {
239
+ name: "john_smith",
240
+ type: :twitter,
241
+ },
242
+ {
243
+ name: "John",
244
+ type: :facebook,
245
+ },
246
+ ]
247
+ }
248
+ }
249
+
250
+ expect do
251
+ ContactCaster.cast(input_hash)
252
+ end.to raise_error(HashCast::Errors::UnexpectedAttributeError, "contact[wrong_attribute] is not valid attribute name")
253
+ end
254
+
255
+ it "shouldn't unexpected attributes error if skip_unexpected_attributes flag is set to true" do
256
+ input_hash = {
257
+ contact: {
258
+ wrong_attribute: 'foo',
259
+ name: "Jim",
260
+ weight: 65.5,
261
+ birthday: Date.today,
262
+ last_logged_in: DateTime.now,
263
+ last_visited_at: Time.now,
264
+ company: {
265
+ name: "MyCo",
266
+ },
267
+ emails: [ "test@example.com", "test2@example.com" ],
268
+ social_accounts: [
269
+ {
270
+ name: "john_smith",
271
+ type: :twitter,
272
+ },
273
+ {
274
+ name: "John",
275
+ type: :facebook,
276
+ },
277
+ ]
278
+ }
279
+ }
280
+
281
+ expect do
282
+ ContactCaster.cast(input_hash, skip_unexpected_attributes: true)
283
+ end.not_to raise_error(HashCast::Errors::UnexpectedAttributeError)
284
+
285
+ end
286
+
287
+ it "should convert accept hash with string keys and cast them to symbol keys" do
288
+ input_hash = {
289
+ 'contact' => {
290
+ 'name' => "John Smith",
291
+ 'age' => "22",
292
+ 'weight' => "65.5",
293
+ 'birthday' => "2014-02-02",
294
+ 'last_logged_in' => "2014-02-02 10:10:00",
295
+ 'last_visited_at' => "2014-02-02 10:10:00",
296
+ 'company' => {
297
+ 'name' => "MyCo",
298
+ },
299
+ 'emails' => [ "test@example.com", "test2@example.com" ],
300
+ 'social_accounts' => [
301
+ {
302
+ 'name' => "john_smith",
303
+ 'type' => 'twitter',
304
+ },
305
+ {
306
+ 'name' => "John",
307
+ 'type' => :facebook,
308
+ },
309
+ ]
310
+ }
311
+ }
312
+
313
+ casted_hash = ContactCaster.cast(input_hash, input_keys: :string, output_keys: :symbol)
314
+
315
+ casted_hash.should == {
316
+ contact: {
317
+ name: "John Smith",
318
+ age: 22,
319
+ weight: 65.5,
320
+ birthday: Date.parse("2014-02-02"),
321
+ last_logged_in: DateTime.parse("2014-02-02 10:10:00"),
322
+ last_visited_at: Time.parse("2014-02-02 10:10:00"),
323
+ company: {
324
+ name: "MyCo",
325
+ },
326
+ emails: [ "test@example.com", "test2@example.com" ],
327
+ social_accounts: [
328
+ {
329
+ name: "john_smith",
330
+ type: :twitter,
331
+ },
332
+ {
333
+ name: "John",
334
+ type: :facebook,
335
+ },
336
+ ]
337
+ }
338
+ }
339
+ end
340
+ end
341
+
342
+ context "checking invalid parameters" do
343
+ it "should raise CaterNotFound exception if caster name is invalid" do
344
+ expect do
345
+ class WrongCaster
346
+ include HashCast::Caster
347
+
348
+ attributes do
349
+ integr :name
350
+ end
351
+ end
352
+ end.to raise_error(HashCast::Errors::CasterNotFoundError)
353
+ end
354
+ end
355
+
356
+ context "allow nil values" do
357
+ before(:all) do
358
+ class HomeCaster
359
+ include HashCast::Caster
360
+
361
+ attributes do
362
+ string :city
363
+ integer :zip, allow_nil: true
364
+ end
365
+ end
366
+ end
367
+
368
+ it "should allow nil values if allow_nil is set to true" do
369
+ HomeCaster.cast(
370
+ city: 'Kazan',
371
+ zip: nil
372
+ )
373
+ end
374
+
375
+ it "should allow nil values unless allow_nil is set to true" do
376
+ expect do
377
+ HomeCaster.cast(
378
+ city: nil,
379
+ zip: nil
380
+ )
381
+ end.to raise_error(HashCast::Errors::CastingError, "city should be a string, but was NilClass")
382
+ end
383
+ end
384
+
385
+ context "input_keys" do
386
+ it "strings -> symbol works" do
387
+ expect(
388
+ SettingsCaster.cast({"account" => "value"}, {input_keys: :string, output_keys: :symbol})
389
+ ).to eq({account: "value"})
390
+ end
391
+
392
+ it "symbol -> string works" do
393
+ expect(
394
+ SettingsCaster.cast({account: "value"}, {input_keys: :symbol, output_keys: :string})
395
+ ).to eq({"account" => "value"})
396
+ end
397
+
398
+ it "symbol -> symbol works" do
399
+ expect(
400
+ SettingsCaster.cast({account: "value"}, {input_keys: :symbol, output_keys: :symbol})
401
+ ).to eq({account: "value"})
402
+ end
403
+
404
+ it "string -> string works" do
405
+ pending
406
+ expect(
407
+ SettingsCaster.cast({"account" => "value"}, {input_keys: :string, output_keys: :string})
408
+ ).to eq({"account" => "value"})
409
+ end
410
+ end
411
+
412
+ context "possible exeptions" do
413
+ it "raises when attributes were not defined" do
414
+ class NoAttrCaster
415
+ include HashCast::Caster
416
+ end
417
+
418
+ expect{
419
+ NoAttrCaster.cast({a: 1})
420
+ }.to raise_error(HashCast::Errors::ArgumentError, "Attributes block should be defined")
421
+ end
422
+
423
+ context "check_options!" do
424
+ it "raises when options are not a hash" do
425
+ expect {
426
+ SettingsCaster.cast({account: "some"}, 1)
427
+ }.to raise_error(HashCast::Errors::ArgumentError, "Options should be a hash")
428
+ end
429
+
430
+ it "raises on bad options" do
431
+ expect {
432
+ SettingsCaster.cast({account: "some"}, {input_keys: "string"})
433
+ }.to raise_error(HashCast::Errors::ArgumentError, "input_keys should be :string or :symbol")
434
+
435
+ expect {
436
+ SettingsCaster.cast({account: "some"}, {output_keys: "string"})
437
+ }.to raise_error(HashCast::Errors::ArgumentError, "output_keys should be :string or :symbol")
438
+ end
439
+ end
440
+
441
+ it "raises when input is not hash" do
442
+ expect {
443
+ SettingsCaster.cast(["some"])
444
+ }.to raise_error(HashCast::Errors::ArgumentError, "Hash should be given")
445
+ end
446
+
447
+ context "AttributesParser" do
448
+ it "raises when attribute name is not string/symbol" do
449
+ expect{
450
+ class BadAttrNameCaster
451
+ include HashCast::Caster
452
+ attributes do
453
+ string 4545
454
+ end
455
+ end
456
+ }.to raise_error(HashCast::Errors::ArgumentError, "attribute name should be a symbol or string")
457
+ end
458
+
459
+ it "raises when attribute options are not hash" do
460
+ expect{
461
+ class AttrOptionsNotHashCast
462
+ include HashCast::Caster
463
+ attributes do
464
+ string :some, [1,2,3]
465
+ end
466
+ end
467
+ }.to raise_error(HashCast::Errors::ArgumentError, "attribute options should be a Hash")
468
+ end
469
+ end
470
+ end
471
+ end