better-ripple 1.0.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 (77) hide show
  1. data/LICENSE +17 -0
  2. data/README.md +182 -0
  3. data/RELEASE_NOTES.md +284 -0
  4. data/better-ripple.gemspec +55 -0
  5. data/lib/rails/generators/ripple/configuration/configuration_generator.rb +13 -0
  6. data/lib/rails/generators/ripple/configuration/templates/ripple.yml +25 -0
  7. data/lib/rails/generators/ripple/js/js_generator.rb +13 -0
  8. data/lib/rails/generators/ripple/js/templates/js/contrib.js +63 -0
  9. data/lib/rails/generators/ripple/js/templates/js/iso8601.js +76 -0
  10. data/lib/rails/generators/ripple/js/templates/js/ripple.js +132 -0
  11. data/lib/rails/generators/ripple/model/model_generator.rb +20 -0
  12. data/lib/rails/generators/ripple/model/templates/model.rb.erb +10 -0
  13. data/lib/rails/generators/ripple/observer/observer_generator.rb +16 -0
  14. data/lib/rails/generators/ripple/observer/templates/observer.rb.erb +2 -0
  15. data/lib/rails/generators/ripple/test/templates/cucumber.rb.erb +7 -0
  16. data/lib/rails/generators/ripple/test/test_generator.rb +44 -0
  17. data/lib/rails/generators/ripple_generator.rb +79 -0
  18. data/lib/ripple.rb +86 -0
  19. data/lib/ripple/associations.rb +380 -0
  20. data/lib/ripple/associations/embedded.rb +35 -0
  21. data/lib/ripple/associations/instantiators.rb +26 -0
  22. data/lib/ripple/associations/linked.rb +65 -0
  23. data/lib/ripple/associations/many.rb +38 -0
  24. data/lib/ripple/associations/many_embedded_proxy.rb +39 -0
  25. data/lib/ripple/associations/many_linked_proxy.rb +66 -0
  26. data/lib/ripple/associations/many_reference_proxy.rb +95 -0
  27. data/lib/ripple/associations/many_stored_key_proxy.rb +76 -0
  28. data/lib/ripple/associations/one.rb +20 -0
  29. data/lib/ripple/associations/one_embedded_proxy.rb +35 -0
  30. data/lib/ripple/associations/one_key_proxy.rb +58 -0
  31. data/lib/ripple/associations/one_linked_proxy.rb +26 -0
  32. data/lib/ripple/associations/one_stored_key_proxy.rb +43 -0
  33. data/lib/ripple/associations/proxy.rb +118 -0
  34. data/lib/ripple/attribute_methods.rb +132 -0
  35. data/lib/ripple/attribute_methods/dirty.rb +59 -0
  36. data/lib/ripple/attribute_methods/query.rb +34 -0
  37. data/lib/ripple/attribute_methods/read.rb +28 -0
  38. data/lib/ripple/attribute_methods/write.rb +25 -0
  39. data/lib/ripple/callbacks.rb +71 -0
  40. data/lib/ripple/conflict/basic_resolver.rb +86 -0
  41. data/lib/ripple/conflict/document_hooks.rb +46 -0
  42. data/lib/ripple/conflict/resolver.rb +79 -0
  43. data/lib/ripple/conflict/test_helper.rb +34 -0
  44. data/lib/ripple/conversion.rb +29 -0
  45. data/lib/ripple/core_ext.rb +3 -0
  46. data/lib/ripple/core_ext/casting.rb +151 -0
  47. data/lib/ripple/core_ext/indexes.rb +89 -0
  48. data/lib/ripple/core_ext/object.rb +8 -0
  49. data/lib/ripple/document.rb +105 -0
  50. data/lib/ripple/document/bucket_access.rb +25 -0
  51. data/lib/ripple/document/finders.rb +131 -0
  52. data/lib/ripple/document/key.rb +35 -0
  53. data/lib/ripple/document/link.rb +30 -0
  54. data/lib/ripple/document/persistence.rb +130 -0
  55. data/lib/ripple/embedded_document.rb +63 -0
  56. data/lib/ripple/embedded_document/around_callbacks.rb +18 -0
  57. data/lib/ripple/embedded_document/finders.rb +26 -0
  58. data/lib/ripple/embedded_document/persistence.rb +75 -0
  59. data/lib/ripple/i18n.rb +5 -0
  60. data/lib/ripple/indexes.rb +151 -0
  61. data/lib/ripple/inspection.rb +32 -0
  62. data/lib/ripple/locale/en.yml +26 -0
  63. data/lib/ripple/locale/fr.yml +24 -0
  64. data/lib/ripple/nested_attributes.rb +275 -0
  65. data/lib/ripple/observable.rb +28 -0
  66. data/lib/ripple/properties.rb +74 -0
  67. data/lib/ripple/property_type_mismatch.rb +12 -0
  68. data/lib/ripple/railtie.rb +26 -0
  69. data/lib/ripple/railties/ripple.rake +103 -0
  70. data/lib/ripple/serialization.rb +82 -0
  71. data/lib/ripple/test_server.rb +35 -0
  72. data/lib/ripple/timestamps.rb +25 -0
  73. data/lib/ripple/translation.rb +18 -0
  74. data/lib/ripple/validations.rb +65 -0
  75. data/lib/ripple/validations/associated_validator.rb +43 -0
  76. data/lib/ripple/version.rb +3 -0
  77. metadata +310 -0
@@ -0,0 +1,32 @@
1
+ require 'ripple'
2
+
3
+ module Ripple
4
+ # Makes IRB and other inspect output a bit friendlier
5
+ module Inspection
6
+
7
+ # A human-readable version of the {Ripple::Document} or {Ripple::EmbeddedDocument}
8
+ # plus attributes
9
+ def inspect
10
+ attribute_list = attributes_for_persistence.except("_type").map {|k,v| "#{k}=#{v.inspect}" }.join(' ')
11
+ inspection_string(attribute_list)
12
+ end
13
+
14
+ # A string representation of the {Ripple::Document} or {Ripple::EmbeddedDocument}
15
+ def to_s
16
+ inspection_string
17
+ end
18
+
19
+ private
20
+
21
+ def inspection_string(extra = nil)
22
+ body = self.class.name + persistance_identifier
23
+ body += " #{extra}" if extra
24
+ "<#{body}>"
25
+ end
26
+
27
+ def persistance_identifier
28
+ self.class.embeddable? ? "" : ":#{key || '[new]'}"
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,26 @@
1
+ en:
2
+ ripple:
3
+ around_callbacks_not_supported: "around_%{type} callbacks are not supported on embedded documents. See https://github.com/seancribbs/ripple/issues/188 for more detail."
4
+ associated_document_error_summary: "for %{doc_type} %{doc_id}: %{errors}"
5
+ attribute_hash: "value of attributes must be a Hash"
6
+ cluster_not_created: "The cluster for the current environment is not created yet. Run `rake riak:create` first."
7
+ conflict_handler_not_implemented: "No on_conflict handler has been implemented for %{document}"
8
+ document_invalid: "Validation failed: %{errors}"
9
+ document_not_found:
10
+ no_key: "Couldn't find document without a key"
11
+ one_key: "Couldn't find document with key: %{key}"
12
+ many_keys: "Couldn't find documents with keys: %{keys}"
13
+ index_type_unknown: "Cannot determine index type for property '%{property}' of type '%{type}'."
14
+ index_undefined: "No index has been defined for property '%{property}' of type '%{type}'."
15
+ invalid_association_value: "Invalid value %{value} for association %{name} of type %{klass} on %{owner}"
16
+ many_association_validation_error: "are invalid (%{association_errors})"
17
+ many_key_association: "You cannot use a many association using :key"
18
+ mass_assignment_roles_unsupported: "Roles for mass assignment are not supported for Rails 3.0"
19
+ missing_configuration: "You are missing your ripple configuration file that should be at %{file}"
20
+ no_root_document: "You cannot call %{method} on %{doc} without a root document"
21
+ one_association_validation_error: "is invalid (%{association_errors})"
22
+ property_type_mismatch: "Cannot cast %{value} into a %{class}"
23
+ stored_key_requires_property: "Missing property: %{name}. Stored-key associations require a property on the owner document."
24
+ undefined_property: "Undefined property :%{prop} for class '%{class}'"
25
+ unexpected_conflicts: "Conflict cannot be resolved for %{conflicts} (for %{document})"
26
+
@@ -0,0 +1,24 @@
1
+ fr:
2
+ ripple:
3
+ around_callbacks_not_supported: "Les callbacks around_%{type} ne sont pas utilisables sur les documents embarqués. Voir https://github.com/seancribbs/ripple/issues/188 pour plus d'information."
4
+ associated_document_error_summary: "Pour %{doc_type} %{doc_id}: %{errors}"
5
+ attribute_hash: "La valeur des attributs doit être un Hash"
6
+ cluster_not_created: "Le cluster pour l'environnement actuel n'a pas été créé. Executez d'abord `rake riak:create`"
7
+ conflict_handler_not_implemented: "Aucun handler on_conflict n'a été implémenté pour %{document}"
8
+ document_invalid: "La validation a échoué : %{errors}"
9
+ document_not_found:
10
+ no_key: "Document introuvable sans clé."
11
+ one_key: "Document introuvable avec la clé %{key}"
12
+ many_keys: "Documents introuvables avec les clés : %{keys}"
13
+ index_type_unknown: "Impossible de déterminer le type d'index pour la propriété '%{property}' of type '%{type}'."
14
+ invalid_association_value: "Valeur invalide %{value} pour l'association %{name} de type %{klass} sur %{owner}"
15
+ many_association_validation_error: "sont invalides (%{association_errors})"
16
+ many_key_association: "Vous ne pouvez pas utiliser une association `many` avec :key"
17
+ mass_assignment_roles_unsupported: "Les rôles pour les assignations de masse ne sont pas supportés avec Rails 3.0"
18
+ missing_configuration: "Votre fichier de configuration est introuvable. Il devrait être ici : %{file}"
19
+ no_root_document: "Vous ne pouvez appeler %{method} sur %{doc} sans document racine."
20
+ one_association_validation_error: "est invalide (%{association_errors})"
21
+ property_type_mismatch: "Ne peut caster %{value} en %{class}"
22
+ undefined_property: "Propriété indéfinie :%{prop} pour la classe '%{class}'"
23
+ unexpected_conflicts: "Le conflit ne peut être résolu : %{conflicts} (pour %{document})"
24
+
@@ -0,0 +1,275 @@
1
+ require 'active_support/concern'
2
+ if ActiveSupport::VERSION::STRING < '3.2'
3
+ require 'active_support/core_ext/class/inheritable_attributes'
4
+ else
5
+ require 'active_support/core_ext/class/attribute'
6
+ end
7
+
8
+ module Ripple
9
+ module NestedAttributes #:nodoc:
10
+ extend ActiveSupport::Concern
11
+
12
+ UNASSIGNABLE_KEYS = %w{ _destroy }
13
+ TRUE_VALUES = [ true, "true", 1, "1", "yes", "ok", "y" ]
14
+
15
+ included do
16
+ if respond_to?(:class_attribute) # ActiveSupport 3.1
17
+ class_attribute :nested_attributes_options, :instance_writer => false
18
+ else
19
+ class_inheritable_accessor :nested_attributes_options, :instance_writer => false
20
+ end
21
+ self.nested_attributes_options = {}
22
+ end
23
+
24
+ # = Nested Attributes
25
+ #
26
+ # This is similar to the `accepts_nested_attributes` functionality
27
+ # as found in AR. This allows the use update attributes and create
28
+ # new child records through the parent. It also allows the use of
29
+ # the `fields_for` form view helper, using a presenter pattern.
30
+ #
31
+ # To enable in the model, call the class method, using the same
32
+ # relationship as defined in the `one` or `many`.
33
+ #
34
+ # class Shipment
35
+ # include Ripple::Document
36
+ # one :box
37
+ # many :addresses
38
+ # accepts_nested_attributes_for :box, :addresses
39
+ # end
40
+ #
41
+ # == One
42
+ #
43
+ # Given this model:
44
+ #
45
+ # class Shipment
46
+ # include Ripple::Document
47
+ # one :box
48
+ # accepts_nested_attributes_for :box
49
+ # end
50
+ #
51
+ # This allows creating a box child during creation:
52
+ #
53
+ # shipment = Shipment.create(:box_attributes => { :shape => 'square' })
54
+ # shipment.box.shape # => 'square'
55
+ #
56
+ # This also allows updating box attributes:
57
+ #
58
+ # shipment.update_attributes(:box_attributes => { :key => 'xxx', :shape => 'triangle' })
59
+ # shipment.box.shape # => 'triangle'
60
+ #
61
+ # == Many
62
+ #
63
+ # Given this model
64
+ #
65
+ # class Manifest
66
+ # include Ripple::Document
67
+ # many :shipments
68
+ # accepts_nested_attributes_for :shipments
69
+ # end
70
+ #
71
+ # This allows creating several shipments during manifest creation:
72
+ #
73
+ # manifest = Manifest.create(:shipments_attributes => [ { :reference => "foo1" }, { :reference => "foo2" } ])
74
+ # manifest.shipments.size # => 2
75
+ # manifest.shipments.first.reference # => foo1
76
+ # manifest.shipments.second.reference # => foo2
77
+ #
78
+ # And updating shipment attributes:
79
+ #
80
+ # manifest.update_attributes(:shipment_attributes => [ { :key => 'xxx', :reference => 'updated foo1' },
81
+ # { :key => 'yyy', :reference => 'updated foo2' } ])
82
+ # manifest.shipments.first.reference # => updated foo1
83
+ # manifest.shipments.second.reference # => updated foo2
84
+ #
85
+ # NOTE: On many embedded, then entire collection of embedded documents is replaced, as there
86
+ # is no key to specifically update.
87
+ #
88
+ # Given
89
+ #
90
+ # class Manifest
91
+ # include Ripple::Documnet
92
+ # many :signatures
93
+ # accepts_nested_attributes_for :signatures
94
+ # end
95
+ #
96
+ # class Signature
97
+ # include Ripple::EmbeddedDocument
98
+ # property :esignature, String
99
+ # end
100
+ #
101
+ # The assigning of attributes replaces existing:
102
+ #
103
+ # manifest = Manifest.create(:signature_attributes => [ { :esig => 'a00001' }, { :esig => 'b00001' } ]
104
+ # manifest.signatures # => [<Signature esig="a00001">, <Signature esig="b00001">]
105
+ #
106
+ # manifest.signature_attributes = [ { :esig => 'c00001' } ]
107
+ # manifest.signatures # => [<Signature esig="c00001">]
108
+ #
109
+ module ClassMethods
110
+
111
+ def accepts_nested_attributes_for(*attr_names)
112
+ options = { :allow_destroy => false }
113
+ options.update(attr_names.extract_options!)
114
+
115
+ attr_names.each do |association_name|
116
+ if association = self.associations[association_name]
117
+ nested_attributes_options[association_name.to_sym] = options
118
+
119
+ define_method "#{association_name}_attributes=" do |attributes|
120
+ send("assign_nested_attributes_for_#{association.type}_association",
121
+ association_name, attributes
122
+ )
123
+ end
124
+
125
+ before_save :"autosave_nested_attributes_for_#{association_name}"
126
+ before_save :destroy_marked_for_destruction
127
+
128
+ define_method "autosave_nested_attributes_for_#{association_name}" do
129
+ if self.autosave[association_name]
130
+ send("save_nested_attributes_for_#{association.type}_association",
131
+ association_name
132
+ )
133
+ end
134
+ end
135
+ private :"autosave_nested_attributes_for_#{association_name}"
136
+
137
+ else
138
+ raise ArgumentError, "Association #{association_name} not found!"
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+
145
+ protected
146
+
147
+ def autosave
148
+ @autosave_nested_attributes_for ||= {}
149
+ end
150
+
151
+ def marked_for_destruction
152
+ @marked_for_destruction ||= {}
153
+ end
154
+
155
+ private
156
+
157
+ def save_nested_attributes_for_one_association(association_name)
158
+ send(association_name).save
159
+ end
160
+
161
+ def save_nested_attributes_for_many_association(association_name)
162
+ send(association_name).map(&:save)
163
+ end
164
+
165
+ def destroy_marked_for_destruction
166
+ self.marked_for_destruction.each_pair do |association_name, resources|
167
+ resources.map(&:destroy)
168
+ send(association_name).reload
169
+ end
170
+ end
171
+
172
+ def destroy_nested_many_association(association_name)
173
+ send(association_name).map(&:destroy)
174
+ end
175
+
176
+ def assign_nested_attributes_for_one_association(association_name, attributes)
177
+ association = self.class.associations[association_name]
178
+ if association.embedded?
179
+ assign_nested_attributes_for_one_embedded_association(association_name, attributes)
180
+ else
181
+ self.autosave[association_name] = true
182
+ assign_nested_attributes_for_one_linked_association(association_name, attributes)
183
+ end
184
+ end
185
+
186
+ def assign_nested_attributes_for_one_embedded_association(association_name, attributes)
187
+ send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
188
+ end
189
+
190
+ def assign_nested_attributes_for_one_linked_association(association_name, attributes)
191
+ attributes = attributes.stringify_keys
192
+ options = nested_attributes_options[association_name]
193
+
194
+ if attributes[key_attr.to_s].blank? && !reject_new_record?(association_name, attributes)
195
+ send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
196
+ else
197
+ if ((existing_record = send(association_name)).key.to_s == attributes[key_attr.to_s].to_s)
198
+ assign_to_or_mark_for_destruction(existing_record, attributes, association_name, options[:allow_destroy])
199
+ else
200
+ raise ArgumentError, "Attempting to update a child that isn't already associated to the parent."
201
+ end
202
+ end
203
+ end
204
+
205
+ def assign_nested_attributes_for_many_association(association_name, attributes_collection)
206
+ unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
207
+ raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
208
+ end
209
+
210
+ if attributes_collection.is_a? Hash
211
+ attributes_collection = attributes_collection.sort_by { |index, _| index.to_i }.map { |_, attributes| attributes }
212
+ end
213
+
214
+ association = self.class.associations[association_name]
215
+ if association.embedded?
216
+ assign_nested_attributes_for_many_embedded_association(association_name, attributes_collection)
217
+ else
218
+ self.autosave[association_name] = true
219
+ assign_nested_attributes_for_many_linked_association(association_name, attributes_collection)
220
+ end
221
+ end
222
+
223
+ def assign_nested_attributes_for_many_embedded_association(association_name, attributes_collection)
224
+ options = nested_attributes_options[association_name]
225
+ send(:"#{association_name}=", []) # Clobber existing
226
+ attributes_collection.each do |attributes|
227
+ attributes = attributes.stringify_keys
228
+ if !reject_new_record?(association_name, attributes)
229
+ send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
230
+ end
231
+ end
232
+ end
233
+
234
+ def assign_nested_attributes_for_many_linked_association(association_name, attributes_collection)
235
+ options = nested_attributes_options[association_name]
236
+ attributes_collection.each do |attributes|
237
+ attributes = attributes.stringify_keys
238
+
239
+ if attributes[key_attr.to_s].blank? && !reject_new_record?(association_name, attributes)
240
+ send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
241
+ elsif existing_record = send(association_name).detect { |record| record.key.to_s == attributes[key_attr.to_s].to_s }
242
+ assign_to_or_mark_for_destruction(existing_record, attributes, association_name, options[:allow_destroy])
243
+ end
244
+ end
245
+ end
246
+
247
+ def assign_to_or_mark_for_destruction(record, attributes, association_name, allow_destroy)
248
+ if has_destroy_flag?(attributes) && allow_destroy
249
+ (self.marked_for_destruction[association_name] ||= []) << record
250
+ else
251
+ record.attributes = attributes.except(*UNASSIGNABLE_KEYS)
252
+ end
253
+ end
254
+
255
+ def has_destroy_flag?(hash)
256
+ TRUE_VALUES.include?(hash.stringify_keys['_destroy'])
257
+ end
258
+
259
+ def reject_new_record?(association_name, attributes)
260
+ has_destroy_flag?(attributes) || call_reject_if(association_name, attributes)
261
+ end
262
+
263
+ def call_reject_if(association_name, attributes)
264
+ attributes = attributes.stringify_keys
265
+ case callback = nested_attributes_options[association_name][:reject_if]
266
+ when Symbol
267
+ method(callback).arity == 0 ? send(callback) : send(callback, attributes)
268
+ when Proc
269
+ callback.call(attributes)
270
+ end
271
+ end
272
+
273
+ end
274
+
275
+ end
@@ -0,0 +1,28 @@
1
+
2
+ require 'active_support/concern'
3
+ require 'active_model/observing'
4
+
5
+ module Ripple
6
+ module Observable
7
+ extend ActiveSupport::Concern
8
+ include ActiveModel::Observing
9
+
10
+ included do
11
+ set_callback(:create, :before) { notify_observers :before_create }
12
+ set_callback(:create, :after) { notify_observers :after_create }
13
+
14
+ set_callback(:update, :before) { notify_observers :before_update }
15
+ set_callback(:update, :after) { notify_observers :after_update }
16
+
17
+ set_callback(:save, :before) { notify_observers :before_save }
18
+ set_callback(:save, :after) { notify_observers :after_save }
19
+
20
+ set_callback(:destroy, :before) { notify_observers :before_destroy }
21
+ set_callback(:destroy, :after) { notify_observers :after_destroy }
22
+
23
+ set_callback(:validation, :before) { notify_observers :before_validation }
24
+ set_callback(:validation, :after) { notify_observers :after_validation }
25
+ end
26
+
27
+ end
28
+ end
@@ -0,0 +1,74 @@
1
+ require 'ripple/core_ext/casting'
2
+ require 'active_support/core_ext/hash/except'
3
+ require 'active_support/core_ext/hash/indifferent_access'
4
+
5
+ module Ripple
6
+ # Adds the ability to declare properties on your Ripple::Document class.
7
+ # Properties will automatically generate accessor (get/set/query) methods and
8
+ # handle type-casting between your Ruby type and JSON-compatible types.
9
+ module Properties
10
+ # @private
11
+ def inherited(subclass)
12
+ super
13
+ subclass.properties.merge!(properties)
14
+ end
15
+
16
+ # @return [Hash] the properties defined on the document
17
+ def properties
18
+ @properties ||= {}.with_indifferent_access
19
+ end
20
+
21
+ def property(key, type, options={})
22
+ prop = Property.new(key, type, options)
23
+ properties[prop.key] = prop
24
+ end
25
+ end
26
+
27
+ # Encapsulates a single property on your Ripple::Document class.
28
+ class Property
29
+ # @return [Symbol] the key of this property in the Document
30
+ attr_reader :key
31
+ # @return [Class] the Ruby type of property.
32
+ attr_reader :type
33
+ # @return [Hash] configuration options
34
+ attr_reader :options
35
+
36
+ # Create a new document property.
37
+ # @param [String, Symbol] key the key of the property
38
+ # @param [Class] type the Ruby type of the property. Use {Boolean} for true or false types.
39
+ # @param [Hash] options configuration options
40
+ # @option options [Object, Proc] :default (nil) a default value
41
+ # for the property, or a lambda to evaluate when providing the default.
42
+ def initialize(key, type, options={})
43
+ @options = options.to_options
44
+ @key = key.to_sym
45
+ @type = type
46
+ end
47
+
48
+ # @return [Object] The default value for this property if defined, or nil.
49
+ def default
50
+ default = options[:default]
51
+ default = default.dup if default.duplicable?
52
+
53
+ return nil if default.nil?
54
+ type_cast(default.respond_to?(:call) ? default.call : default)
55
+ end
56
+
57
+ # @return [Hash] options appropriate for the validates class method
58
+ def validation_options
59
+ @options.dup.except(:default)
60
+ end
61
+
62
+ # Attempt to coerce the passed value into this property's type
63
+ # @param [Object] value the value to coerce
64
+ # @return [Object] the value coerced into this property's type
65
+ # @raise [PropertyTypeMismatch] if the value cannot be coerced into the property's type
66
+ def type_cast(value)
67
+ if @type.respond_to?(:ripple_cast)
68
+ @type.ripple_cast(value)
69
+ else
70
+ value
71
+ end
72
+ end
73
+ end
74
+ end