couch_tomato 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 (52) hide show
  1. data/MIT-LICENSE.txt +19 -0
  2. data/README.md +96 -0
  3. data/init.rb +3 -0
  4. data/lib/core_ext/date.rb +10 -0
  5. data/lib/core_ext/duplicable.rb +43 -0
  6. data/lib/core_ext/extract_options.rb +14 -0
  7. data/lib/core_ext/inheritable_attributes.rb +222 -0
  8. data/lib/core_ext/object.rb +5 -0
  9. data/lib/core_ext/string.rb +19 -0
  10. data/lib/core_ext/symbol.rb +15 -0
  11. data/lib/core_ext/time.rb +12 -0
  12. data/lib/couch_tomato/database.rb +279 -0
  13. data/lib/couch_tomato/js_view_source.rb +182 -0
  14. data/lib/couch_tomato/migration.rb +52 -0
  15. data/lib/couch_tomato/migrator.rb +235 -0
  16. data/lib/couch_tomato/persistence/base.rb +62 -0
  17. data/lib/couch_tomato/persistence/belongs_to_property.rb +58 -0
  18. data/lib/couch_tomato/persistence/callbacks.rb +60 -0
  19. data/lib/couch_tomato/persistence/dirty_attributes.rb +27 -0
  20. data/lib/couch_tomato/persistence/json.rb +48 -0
  21. data/lib/couch_tomato/persistence/magic_timestamps.rb +15 -0
  22. data/lib/couch_tomato/persistence/properties.rb +58 -0
  23. data/lib/couch_tomato/persistence/simple_property.rb +97 -0
  24. data/lib/couch_tomato/persistence/validation.rb +18 -0
  25. data/lib/couch_tomato/persistence.rb +85 -0
  26. data/lib/couch_tomato/replicator.rb +50 -0
  27. data/lib/couch_tomato.rb +46 -0
  28. data/lib/tasks/couch_tomato.rake +128 -0
  29. data/rails/init.rb +7 -0
  30. data/spec/callbacks_spec.rb +271 -0
  31. data/spec/comment.rb +8 -0
  32. data/spec/create_spec.rb +22 -0
  33. data/spec/custom_view_spec.rb +134 -0
  34. data/spec/destroy_spec.rb +29 -0
  35. data/spec/fixtures/address.rb +9 -0
  36. data/spec/fixtures/person.rb +6 -0
  37. data/spec/property_spec.rb +103 -0
  38. data/spec/spec_helper.rb +40 -0
  39. data/spec/unit/attributes_spec.rb +26 -0
  40. data/spec/unit/callbacks_spec.rb +33 -0
  41. data/spec/unit/create_spec.rb +58 -0
  42. data/spec/unit/customs_views_spec.rb +15 -0
  43. data/spec/unit/database_spec.rb +38 -0
  44. data/spec/unit/dirty_attributes_spec.rb +113 -0
  45. data/spec/unit/string_spec.rb +13 -0
  46. data/spec/unit/view_query_spec.rb +9 -0
  47. data/spec/update_spec.rb +40 -0
  48. data/test/test_helper.rb +63 -0
  49. data/test/unit/database_test.rb +285 -0
  50. data/test/unit/js_view_test.rb +362 -0
  51. data/test/unit/property_test.rb +193 -0
  52. metadata +133 -0
@@ -0,0 +1,235 @@
1
+ module CouchTomato
2
+ class IrreversibleMigration < StandardError#:nodoc:
3
+ end
4
+
5
+ class DuplicateMigrationVersionError < StandardError#:nodoc:
6
+ def initialize(version)
7
+ super("Multiple migrations have the version number #{version}")
8
+ end
9
+ end
10
+
11
+ class DuplicateMigrationNameError < StandardError#:nodoc:
12
+ def initialize(name)
13
+ super("Multiple migrations have the name #{name}")
14
+ end
15
+ end
16
+
17
+ class UnknownMigrationVersionError < StandardError #:nodoc:
18
+ def initialize(version)
19
+ super("No migration with version number #{version}")
20
+ end
21
+ end
22
+
23
+ class IllegalMigrationNameError < StandardError#:nodoc:
24
+ def initialize(name)
25
+ super("Illegal name for migration file: #{name}\n\t(only lower case letters, numbers, and '_' allowed)")
26
+ end
27
+ end
28
+
29
+ # MigrationProxy is used to defer loading of the actual migration classes
30
+ # until they are needed
31
+ class MigrationProxy
32
+ attr_accessor :name, :version, :filename
33
+
34
+ delegate :migrate, :announce, :write, :to=>:migration
35
+
36
+ private
37
+ def migration
38
+ @migration ||= load_migration
39
+ end
40
+
41
+ def load_migration
42
+ load(filename)
43
+ name.constantize
44
+ end
45
+ end
46
+
47
+ class Migrator#:nodoc:
48
+ class << self
49
+ def migrate(db, migrations_path, target_version = nil)
50
+ case
51
+ when target_version.nil? then up(db, migrations_path, target_version)
52
+ when current_version(db) > target_version then down(db, migrations_path, target_version)
53
+ else up(db, migrations_path, target_version)
54
+ end
55
+ end
56
+
57
+ def rollback(db, migrations_path, steps=1)
58
+ move(:down, db, migrations_path, steps)
59
+ end
60
+
61
+ def forward(db, migrations_path, steps=1)
62
+ move(:up, db, migrations_path, steps)
63
+ end
64
+
65
+ def up(db, migrations_path, target_version = nil)
66
+ self.new(:up, db, migrations_path, target_version).migrate
67
+ end
68
+
69
+ def down(db, migrations_path, target_version = nil)
70
+ self.new(:down, db, migrations_path, target_version).migrate
71
+ end
72
+
73
+ def run(direction, db, migrations_path, target_version)
74
+ self.new(direction, db, migrations_path, target_version).run
75
+ end
76
+
77
+ def migrations_doc(db)
78
+ begin
79
+ doc = db.get('_design/migrations')
80
+ rescue RestClient::ResourceNotFound
81
+ db.save_doc('_id' => '_design/migrations', 'versions' => nil)
82
+ doc = db.get('_design/migrations')
83
+ end
84
+
85
+ return doc
86
+ end
87
+
88
+ def get_all_versions(db)
89
+ doc = migrations_doc(db)
90
+
91
+ if doc['versions']
92
+ doc['versions'].sort
93
+ else
94
+ []
95
+ end
96
+ end
97
+
98
+ def current_version(db)
99
+ get_all_versions(db).max || 0
100
+ end
101
+
102
+ private
103
+
104
+ def move(direction, db, migrations_path, steps)
105
+ migrator = self.new(direction, db, migrations_path)
106
+ start_index = migrator.migrations.index(migrator.current_migration) || 0
107
+
108
+ finish = migrator.migrations[start_index + steps]
109
+ version = finish ? finish.version : 0
110
+ send(direction, db, migrations_path, version)
111
+ end
112
+ end
113
+
114
+ def initialize(direction, db, migrations_path, target_version = nil)
115
+ @db = db
116
+ @migrations_doc = self.class.migrations_doc(@db)
117
+ @direction, @migrations_path, @target_version = direction, migrations_path, target_version
118
+ end
119
+
120
+ def current_version
121
+ migrated.last || 0
122
+ end
123
+
124
+ def current_migration
125
+ migrations.detect { |m| m.version == current_version }
126
+ end
127
+
128
+ def run
129
+ target = migrations.detect { |m| m.version == @target_version }
130
+ raise UnknownMigrationVersionError.new(@target_version) if target.nil?
131
+ unless (up? && migrated.include?(target.version.to_i)) || (down? && !migrated.include?(target.version.to_i))
132
+ target.migrate(@direction, @db)
133
+ record_version_state_after_migrating(target.version)
134
+ end
135
+ end
136
+
137
+ def migrate
138
+ current = migrations.detect { |m| m.version == current_version }
139
+ target = migrations.detect { |m| m.version == @target_version }
140
+
141
+ if target.nil? && !@target_version.nil? && @target_version > 0
142
+ raise UnknownMigrationVersionError.new(@target_version)
143
+ end
144
+
145
+ start = up? ? 0 : (migrations.index(current) || 0)
146
+ finish = migrations.index(target) || migrations.size - 1
147
+ runnable = migrations[start..finish]
148
+
149
+ # skip the last migration if we're headed down, but not ALL the way down
150
+ runnable.pop if down? && !target.nil?
151
+
152
+ runnable.each do |migration|
153
+ puts "Migrating to #{migration.name} (#{migration.version})"
154
+
155
+ # On our way up, we skip migrating the ones we've already migrated
156
+ next if up? && migrated.include?(migration.version.to_i)
157
+
158
+ # On our way down, we skip reverting the ones we've never migrated
159
+ if down? && !migrated.include?(migration.version.to_i)
160
+ migration.announce 'never migrated, skipping'; migration.write
161
+ next
162
+ end
163
+
164
+ begin
165
+ migration.migrate(@direction, @db)
166
+ record_version_state_after_migrating(migration.version)
167
+ rescue => e
168
+ raise StandardError, "An error has occurred, all later migrations canceled:\n\n#{e}", e.backtrace
169
+ end
170
+ end
171
+ end
172
+
173
+ def migrations
174
+ @migrations ||= begin
175
+ files = Dir["#{@migrations_path}/[0-9]*_*.rb"]
176
+
177
+ migrations = files.inject([]) do |klasses, file|
178
+ version, name = file.scan(/([0-9]+)_([_a-z0-9]*).rb/).first
179
+
180
+ raise IllegalMigrationNameError.new(file) unless version
181
+ version = version.to_i
182
+
183
+ if klasses.detect { |m| m.version == version }
184
+ raise DuplicateMigrationVersionError.new(version)
185
+ end
186
+
187
+ if klasses.detect { |m| m.name == name.camelize }
188
+ raise DuplicateMigrationNameError.new(name.camelize)
189
+ end
190
+
191
+ migration = MigrationProxy.new
192
+ migration.name = name.camelize
193
+ migration.version = version
194
+ migration.filename = file
195
+ klasses << migration
196
+ end
197
+
198
+ migrations = migrations.sort_by(&:version)
199
+ down? ? migrations.reverse : migrations
200
+ end
201
+ end
202
+
203
+ def pending_migrations
204
+ already_migrated = migrated
205
+ migrations.reject { |m| already_migrated.include?(m.version.to_i) }
206
+ end
207
+
208
+ def migrated
209
+ @migrated_versions ||= self.class.get_all_versions(@db)
210
+ end
211
+
212
+ private
213
+ def record_version_state_after_migrating(version)
214
+ @migrated_versions ||= []
215
+ if down?
216
+ @migrated_versions.delete(version.to_i)
217
+ @migrations_doc['versions'].delete(version)
218
+ @db.save_doc(@migrations_doc)
219
+ else
220
+ @migrated_versions.push(version.to_i).sort!
221
+ @migrations_doc['versions'] ||= []
222
+ @migrations_doc['versions'].push(version)
223
+ @db.save_doc(@migrations_doc)
224
+ end
225
+ end
226
+
227
+ def up?
228
+ @direction == :up
229
+ end
230
+
231
+ def down?
232
+ @direction == :down
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,62 @@
1
+ module CouchTomato
2
+ module Persistence
3
+ module Base
4
+ # initialize a new instance of the model optionally passing it a hash of attributes.
5
+ # the attributes have to be declared using the #property method
6
+ #
7
+ # example:
8
+ # class Book
9
+ # include CouchTomato::Persistence
10
+ # property :title
11
+ # end
12
+ # book = Book.new :title => 'Time to Relax'
13
+ # book.title # => 'Time to Relax'
14
+ def initialize(attributes = {})
15
+ attributes.each do |name, value|
16
+ self.send("#{name}=", value)
17
+ end if attributes
18
+ end
19
+
20
+ # assign multiple attributes at once.
21
+ # the attributes have to be declared using the #property method
22
+ #
23
+ # example:
24
+ # class Book
25
+ # include CouchTomato::Persistence
26
+ # property :title
27
+ # property :year
28
+ # end
29
+ # book = Book.new
30
+ # book.attributes = {:title => 'Time to Relax', :year => 2009}
31
+ # book.title # => 'Time to Relax'
32
+ # book.year # => 2009
33
+ def attributes=(hash)
34
+ hash.each do |attribute, value|
35
+ self.send "#{attribute}=", value
36
+ end
37
+ end
38
+
39
+ # returns all of a model's attributes that have been defined using the #property method as a Hash
40
+ #
41
+ # example:
42
+ # class Book
43
+ # include CouchTomato::Persistence
44
+ # property :title
45
+ # property :year
46
+ # end
47
+ # book = Book.new :year => 2009
48
+ # book.attributes # => {:title => nil, :year => 2009}
49
+ def attributes
50
+ self.class.properties.inject({}) do |res, property|
51
+ property.serialize(res, self)
52
+ res
53
+ end
54
+ end
55
+
56
+ def ==(other) #:nodoc:
57
+ other.class == self.class && self.to_json == other.to_json
58
+ end
59
+
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,58 @@
1
+ module CouchTomato
2
+ module Persistence
3
+ class BelongsToProperty #:nodoc:
4
+ attr_accessor :name
5
+
6
+ def initialize(owner_clazz, name, options = {})
7
+ self.name = name
8
+ accessors = <<-ACCESSORS
9
+ def #{name}
10
+ return @#{name} if instance_variable_defined?(:@#{name})
11
+ @#{name} = @#{name}_id ? #{item_class_name}.find(@#{name}_id) : nil
12
+ end
13
+
14
+ def #{name}=(value)
15
+ @#{name} = value
16
+ if value.nil?
17
+ @#{name}_id = nil
18
+ else
19
+ @#{name}_id = value.id
20
+ end
21
+ end
22
+
23
+ def #{name}_id=(id)
24
+ remove_instance_variable(:@#{name}) if instance_variable_defined?(:@#{name})
25
+ @#{name}_id = id
26
+ end
27
+ ACCESSORS
28
+ owner_clazz.class_eval accessors
29
+ owner_clazz.send :attr_reader, "#{name}_id"
30
+ end
31
+
32
+ # def save(object)
33
+ #
34
+ # end
35
+
36
+ def dirty?(object)
37
+ false
38
+ end
39
+
40
+ # def destroy(object)
41
+ #
42
+ # end
43
+
44
+ def build(object, json)
45
+ object.send "#{name}_id=", json["#{name}_id"]
46
+ end
47
+
48
+ def serialize(json, object)
49
+ json["#{name}_id"] = object.send("#{name}_id") if object.send("#{name}_id")
50
+ end
51
+
52
+ def item_class_name
53
+ @name.to_s.camelize
54
+ end
55
+
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,60 @@
1
+ module CouchTomato
2
+ module Persistence
3
+ module Callbacks
4
+ def self.included(base)
5
+ base.extend ClassMethods
6
+
7
+ base.class_eval do
8
+ attr_accessor :skip_callbacks
9
+ def self.callbacks
10
+ @callbacks ||= {}
11
+ @callbacks[self.name] ||= {:before_validation => [], :before_validation_on_create => [],
12
+ :before_validation_on_update => [], :before_validation_on_save => [], :before_create => [],
13
+ :after_create => [], :before_update => [], :after_update => [],
14
+ :before_save => [], :after_save => [],
15
+ :before_destroy => [], :after_destroy => []}
16
+ end
17
+ end
18
+ end
19
+
20
+ # Runs all callbacks on a model with the given name, i.g. :after_create.
21
+ #
22
+ # This method is called by the CouchTomato::Database object when saving/destroying an object
23
+ def run_callbacks(name)
24
+ return if skip_callbacks
25
+ self.class.callbacks[name].uniq.each do |callback|
26
+ if callback.is_a?(Symbol)
27
+ send callback
28
+ elsif callback.is_a?(Proc)
29
+ callback.call self
30
+ else
31
+ raise "Don't know how to handle callback of type #{name.class.name}"
32
+ end
33
+ end
34
+ end
35
+
36
+ module ClassMethods
37
+ [
38
+ :before_validation,
39
+ :before_validation_on_create,
40
+ :before_validation_on_update,
41
+ :before_validation_on_save,
42
+ :before_create,
43
+ :before_save,
44
+ :before_update,
45
+ :before_destroy,
46
+ :after_update,
47
+ :after_save,
48
+ :after_create,
49
+ :after_destroy
50
+ ].each do |callback|
51
+ define_method callback do |*names|
52
+ names.each do |name|
53
+ callbacks[callback] << name
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,27 @@
1
+ module CouchTomato
2
+ module Persistence
3
+ module DirtyAttributes
4
+
5
+ def self.included(base)
6
+ base.class_eval do
7
+ after_save :reset_dirty_attributes
8
+ end
9
+ end
10
+
11
+ # returns true if a model has dirty attributes, i.e. their value has changed since the last save
12
+ def dirty?
13
+ new? || self.class.properties.inject(false) do |res, property|
14
+ res || property.dirty?(self)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def reset_dirty_attributes
21
+ self.class.properties.each do |property|
22
+ instance_variable_set("@#{property.name}_was", send(property.name))
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,48 @@
1
+ module CouchTomato
2
+ module Persistence
3
+ module Json
4
+ def self.included(base)
5
+ base.extend ClassMethods
6
+ end
7
+
8
+ # returns a JSON representation of a model in order to store it in CouchDB
9
+ def to_json(*args)
10
+ to_hash.to_json(*args)
11
+ # to_json(*args)
12
+ end
13
+
14
+ # returns all the attributes, the ruby class and the _id and _rev of a model as a Hash
15
+ def to_hash
16
+ (self.class.properties).inject({}) do |props, property|
17
+ property.serialize(props, self)
18
+ props
19
+ end.merge('ruby_class' => self.class.name).merge(id_and_rev_json)
20
+ end
21
+
22
+ private
23
+
24
+ def id_and_rev_json
25
+ ['_id', '_rev', '_deleted'].inject({}) do |hash, key|
26
+ hash[key] = self.send(key) unless self.send(key).nil?
27
+ hash
28
+ end
29
+ end
30
+
31
+ module ClassMethods
32
+
33
+ # creates a model instance from JSON
34
+ def json_create(json, meta={})
35
+ return if json.nil?
36
+ instance = self.new
37
+ instance._id = json[:_id] || json['_id']
38
+ instance._rev = json[:_rev] || json['_rev']
39
+ properties.each do |property|
40
+ property.build(instance, json)
41
+ end
42
+ instance.metadata = meta
43
+ instance
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,15 @@
1
+ module CouchTomato
2
+ module Persistence
3
+ module MagicTimestamps #:nodoc:
4
+ def self.included(base)
5
+ base.instance_eval do
6
+ property :created_at, :type => Time
7
+ property :updated_at, :type => Time
8
+
9
+ before_create lambda {|model| model.created_at = Time.now; model.created_at_not_changed}
10
+ before_save lambda {|model| model.updated_at = Time.now; model.updated_at_not_changed}
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,58 @@
1
+ require File.dirname(__FILE__) + '/simple_property'
2
+ require File.dirname(__FILE__) + '/belongs_to_property'
3
+
4
+ module CouchTomato
5
+ module Persistence
6
+ module Properties
7
+ def self.included(base)
8
+ base.extend ClassMethods
9
+ base.class_eval do
10
+ def self.properties
11
+ @properties ||= {}
12
+ @properties[self.name] ||= []
13
+ end
14
+ end
15
+ end
16
+
17
+ module ClassMethods
18
+ # returns all the property names of a model class that have been defined using the #property method
19
+ #
20
+ # example:
21
+ # class Book
22
+ # property :title
23
+ # property :year
24
+ # end
25
+ # Book.property_names # => [:title, :year]
26
+ def property_names
27
+ properties.map(&:name)
28
+ end
29
+
30
+ def json_create(json, meta={}) #:nodoc:
31
+ return if json.nil?
32
+ instance = super
33
+ # instance.send(:assign_attribute_copies_for_dirty_tracking)
34
+ instance.metadata = meta
35
+ instance
36
+ end
37
+
38
+ # Declare a proprty on a model class. properties are not typed by default. You can use any of the basic types by JSON (String, Integer, Fixnum, Array, Hash). If you want a property to be of a custom class you have to define it using the :class option.
39
+ #
40
+ # example:
41
+ # class Book
42
+ # property :title
43
+ # property :year
44
+ # property :publisher, :class => Publisher
45
+ # end
46
+ def property(name, options = {})
47
+ clazz = options.delete(:class)
48
+ properties << (clazz || SimpleProperty).new(self, name, options)
49
+ end
50
+
51
+ def belongs_to(name) #:nodoc:
52
+ property name, :class => BelongsToProperty
53
+ end
54
+
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,97 @@
1
+ module CouchTomato
2
+ module Persistence
3
+ class SimpleProperty #:nodoc:
4
+ attr_accessor :name, :type
5
+ JSON_TYPES = [String, Integer, Hash, Array, Fixnum, Float]
6
+
7
+ def initialize(owner_clazz, name, options = {})
8
+ if JSON_TYPES.include?(options[:type])
9
+ raise "#{options[:type]} is a native JSON type, only custom types should be specified"
10
+ end
11
+
12
+ if options[:type].kind_of?(Array) && options[:type].empty?
13
+ raise "property defined with `:type => []` but expected `:type => [SomePersistableType]`"
14
+ end
15
+
16
+ self.name = name
17
+ self.type = options[:type]
18
+ owner_clazz.class_eval do
19
+ attr_reader name, "#{name}_was"
20
+
21
+ def initialize(attributes = {})
22
+ super attributes
23
+ # assign_attribute_copies_for_dirty_tracking
24
+ end
25
+
26
+ # def assign_attribute_copies_for_dirty_tracking
27
+ # attributes.each do |name, value|
28
+ # self.instance_variable_set("@#{name}_was", clone_attribute(value))
29
+ # end if attributes
30
+ # end
31
+ # private :assign_attribute_copies_for_dirty_tracking
32
+
33
+ def clone_attribute(value)
34
+ if [Bignum, Fixnum, Symbol, TrueClass, FalseClass, NilClass, Float].include?(value.class)
35
+ value
36
+ else
37
+ value.clone
38
+ end
39
+ end
40
+
41
+ define_method "#{name}=" do |value|
42
+ self.instance_variable_set("@#{name}", value)
43
+ end
44
+
45
+ define_method "#{name}?" do
46
+ !self.send(name).nil? && !self.send(name).try(:blank?)
47
+ end
48
+
49
+ define_method "#{name}_changed?" do
50
+ !self.instance_variable_get("@#{name}_not_changed") && self.send(name) != self.send("#{name}_was")
51
+ end
52
+
53
+ define_method "#{name}_not_changed" do
54
+ self.instance_variable_set("@#{name}_not_changed", true)
55
+ end
56
+ end
57
+ end
58
+
59
+ def build(object, json)
60
+ value = json[name.to_s]
61
+ value = json[name.to_sym] if value.nil?
62
+
63
+ if type.kind_of? Array
64
+ typecasted_value = []
65
+ value.each do |val|
66
+ el = type[0].json_create val
67
+ typecasted_value << el
68
+ end
69
+ else
70
+ typecasted_value = if type
71
+ type.json_create value
72
+ else
73
+ value
74
+ end
75
+ end
76
+
77
+ object.send "#{name}=", typecasted_value
78
+ end
79
+
80
+ def dirty?(object)
81
+ object.send("#{name}_changed?")
82
+ end
83
+
84
+ # def save(object)
85
+ #
86
+ # end
87
+ #
88
+ # def destroy(object)
89
+ #
90
+ # end
91
+
92
+ def serialize(json, object)
93
+ json[name] = object.send name
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,18 @@
1
+ require 'validatable'
2
+
3
+ module CouchTomato
4
+ module Persistence
5
+ module Validation
6
+ def self.included(base)
7
+ base.send :include, Validatable
8
+ base.class_eval do
9
+ # Override the validate method to first run before_validation callback
10
+ def valid?
11
+ self.run_callbacks :before_validation
12
+ super
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end