sessionm-cassandra_object 2.2.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (83) hide show
  1. data/.gitignore +2 -0
  2. data/CHANGELOG +3 -0
  3. data/Gemfile +2 -0
  4. data/LICENSE +13 -0
  5. data/MIT-LICENSE +20 -0
  6. data/README.markdown +12 -0
  7. data/Rakefile +15 -0
  8. data/lib/cassandra_object/associations/one_to_many.rb +146 -0
  9. data/lib/cassandra_object/associations/one_to_one.rb +85 -0
  10. data/lib/cassandra_object/associations.rb +50 -0
  11. data/lib/cassandra_object/attributes.rb +97 -0
  12. data/lib/cassandra_object/base.rb +97 -0
  13. data/lib/cassandra_object/batches.rb +31 -0
  14. data/lib/cassandra_object/callbacks.rb +27 -0
  15. data/lib/cassandra_object/collection.rb +8 -0
  16. data/lib/cassandra_object/connection.rb +29 -0
  17. data/lib/cassandra_object/consistency.rb +31 -0
  18. data/lib/cassandra_object/cursor.rb +90 -0
  19. data/lib/cassandra_object/dirty.rb +32 -0
  20. data/lib/cassandra_object/errors.rb +10 -0
  21. data/lib/cassandra_object/finder_methods.rb +72 -0
  22. data/lib/cassandra_object/generators/migration_generator.rb +31 -0
  23. data/lib/cassandra_object/generators/templates/migration.rb.erb +11 -0
  24. data/lib/cassandra_object/identity/abstract_key_factory.rb +36 -0
  25. data/lib/cassandra_object/identity/custom_key_factory.rb +50 -0
  26. data/lib/cassandra_object/identity/hashed_natural_key_factory.rb +10 -0
  27. data/lib/cassandra_object/identity/key.rb +20 -0
  28. data/lib/cassandra_object/identity/natural_key_factory.rb +51 -0
  29. data/lib/cassandra_object/identity/uuid_key_factory.rb +39 -0
  30. data/lib/cassandra_object/identity.rb +52 -0
  31. data/lib/cassandra_object/log_subscriber.rb +37 -0
  32. data/lib/cassandra_object/migrations/migration.rb +15 -0
  33. data/lib/cassandra_object/migrations.rb +66 -0
  34. data/lib/cassandra_object/mocking.rb +15 -0
  35. data/lib/cassandra_object/persistence.rb +138 -0
  36. data/lib/cassandra_object/railtie.rb +11 -0
  37. data/lib/cassandra_object/schema/migration.rb +106 -0
  38. data/lib/cassandra_object/schema/migration_proxy.rb +25 -0
  39. data/lib/cassandra_object/schema/migrator.rb +213 -0
  40. data/lib/cassandra_object/schema.rb +37 -0
  41. data/lib/cassandra_object/serialization.rb +6 -0
  42. data/lib/cassandra_object/tasks/column_family.rb +90 -0
  43. data/lib/cassandra_object/tasks/keyspace.rb +89 -0
  44. data/lib/cassandra_object/tasks/ks.rake +121 -0
  45. data/lib/cassandra_object/timestamps.rb +19 -0
  46. data/lib/cassandra_object/type.rb +19 -0
  47. data/lib/cassandra_object/types/array_type.rb +16 -0
  48. data/lib/cassandra_object/types/boolean_type.rb +23 -0
  49. data/lib/cassandra_object/types/date_type.rb +20 -0
  50. data/lib/cassandra_object/types/float_type.rb +19 -0
  51. data/lib/cassandra_object/types/hash_type.rb +16 -0
  52. data/lib/cassandra_object/types/integer_type.rb +19 -0
  53. data/lib/cassandra_object/types/set_type.rb +22 -0
  54. data/lib/cassandra_object/types/string_type.rb +16 -0
  55. data/lib/cassandra_object/types/time_type.rb +27 -0
  56. data/lib/cassandra_object/types/time_with_zone_type.rb +18 -0
  57. data/lib/cassandra_object/types/utf8_string_type.rb +18 -0
  58. data/lib/cassandra_object/types.rb +11 -0
  59. data/lib/cassandra_object/validations.rb +46 -0
  60. data/lib/cassandra_object.rb +49 -0
  61. data/sessionm-cassandra_object.gemspec +26 -0
  62. data/test/active_model_test.rb +9 -0
  63. data/test/base_test.rb +28 -0
  64. data/test/batches_test.rb +30 -0
  65. data/test/connection_test.rb +28 -0
  66. data/test/consistency_test.rb +20 -0
  67. data/test/finder_methods_test.rb +49 -0
  68. data/test/identity_test.rb +30 -0
  69. data/test/persistence_test.rb +84 -0
  70. data/test/test_helper.rb +31 -0
  71. data/test/timestamps_test.rb +27 -0
  72. data/test/types/array_type_test.rb +15 -0
  73. data/test/types/boolean_type_test.rb +23 -0
  74. data/test/types/date_type_test.rb +4 -0
  75. data/test/types/float_type_test.rb +4 -0
  76. data/test/types/hash_type_test.rb +4 -0
  77. data/test/types/integer_type_test.rb +18 -0
  78. data/test/types/set_type_test.rb +17 -0
  79. data/test/types/string_type_test.rb +4 -0
  80. data/test/types/time_type_test.rb +4 -0
  81. data/test/types/utf8_string_type_test.rb +4 -0
  82. data/test/validations_test.rb +15 -0
  83. metadata +183 -0
@@ -0,0 +1,90 @@
1
+ module CassandraObject
2
+ class Cursor
3
+ include Consistency
4
+
5
+ def initialize(target_class, column_family, key, super_column, options={})
6
+ @target_class = target_class
7
+ @column_family = column_family
8
+ @key = key.to_s
9
+ @super_column = super_column
10
+ @options = options
11
+ @validators = []
12
+ end
13
+
14
+ def find(number_to_find)
15
+ limit = number_to_find
16
+ objects = CassandraObject::Collection.new
17
+ out_of_keys = false
18
+
19
+ if start_with = @options[:start_after]
20
+ limit += 1
21
+ else
22
+ start_with = nil
23
+ end
24
+
25
+ while objects.size < number_to_find && !out_of_keys
26
+ index_results = connection.get(@column_family, @key, @super_column,
27
+ count: limit,
28
+ start: start_with,
29
+ reversed: @options[:reversed],
30
+ consistency: target_class.thrift_read_consistency)
31
+
32
+ out_of_keys = index_results.size < limit
33
+
34
+ if !start_with.blank?
35
+ index_results.delete(start_with)
36
+ end
37
+
38
+ keys = index_results.keys
39
+ values = index_results.values
40
+
41
+ missing_keys = []
42
+
43
+ results = values.empty? ? {} : @target_class.multi_get(values)
44
+ results.each do |(key, result)|
45
+ if result.nil?
46
+ missing_keys << key
47
+ end
48
+ end
49
+
50
+ unless missing_keys.empty?
51
+ @target_class.multi_get(missing_keys, :quorum=>true).each do |(key, result)|
52
+ index_key = index_results.index(key)
53
+ if result.nil?
54
+ remove(index_key)
55
+ results.delete(key)
56
+ else
57
+ results[key] = result
58
+ end
59
+ end
60
+ end
61
+
62
+ results.values.each do |o|
63
+ if @validators.all? {|v| v.call(o) }
64
+ objects << o
65
+ else
66
+ remove(index_results.index(o.key))
67
+ end
68
+ end
69
+
70
+ start_with = objects.last_column_name = keys.last
71
+ limit = (number_to_find - results.size) + 1
72
+
73
+ end
74
+
75
+ return objects
76
+ end
77
+
78
+ def connection
79
+ @target_class.connection
80
+ end
81
+
82
+ def remove(index_key)
83
+ connection.remove(@column_family, @key, @super_column, index_key, consistency: target_class.thrift_write_consistency)
84
+ end
85
+
86
+ def validator(&validator)
87
+ @validators << validator
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,32 @@
1
+ module CassandraObject
2
+ module Dirty
3
+ extend ActiveSupport::Concern
4
+ include ActiveModel::Dirty
5
+
6
+ # Attempts to +save+ the record and clears changed attributes if successful.
7
+ def save(*) #:nodoc:
8
+ if status = super
9
+ @previously_changed = changes
10
+ @changed_attributes.clear
11
+ end
12
+ status
13
+ end
14
+
15
+ # Attempts to <tt>save!</tt> the record and clears changed attributes if successful.
16
+ def save!(*) #:nodoc:
17
+ super.tap do
18
+ @previously_changed = changes
19
+ @changed_attributes.clear
20
+ end
21
+ end
22
+
23
+ def write_attribute(name, value)
24
+ name = name.to_s
25
+ unless attribute_changed?(name)
26
+ old = read_attribute(name)
27
+ changed_attributes[name] = old if old != value
28
+ end
29
+ super
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,10 @@
1
+ module CassandraObject
2
+ class CasssandraObjectError < StandardError
3
+ end
4
+
5
+ class RecordNotSaved < CasssandraObjectError
6
+ end
7
+
8
+ class RecordNotFound < CasssandraObjectError
9
+ end
10
+ end
@@ -0,0 +1,72 @@
1
+ module CassandraObject
2
+ module FinderMethods
3
+ extend ActiveSupport::Concern
4
+ module ClassMethods
5
+ def find(key)
6
+ if parse_key(key) && attributes = connection.get(column_family, key)
7
+ instantiate(key, attributes)
8
+ else
9
+ raise CassandraObject::RecordNotFound
10
+ end
11
+ end
12
+
13
+ def find_by_id(key)
14
+ find(key)
15
+ rescue CassandraObject::RecordNotFound
16
+ nil
17
+ end
18
+
19
+ def all(options = {})
20
+ limit = options[:limit] || 100
21
+ results = ActiveSupport::Notifications.instrument("get_range.cassandra_object", column_family: column_family, key_count: limit) do
22
+ connection.get_range(column_family, key_count: limit, consistency: thrift_read_consistency)
23
+ end
24
+
25
+ results.map do |k, v|
26
+ v.empty? ? nil : instantiate(k, v)
27
+ end.compact
28
+ end
29
+
30
+ def first(options = {})
31
+ all(options.merge(:limit => 1)).first
32
+ end
33
+
34
+ def find_with_ids(*ids)
35
+ expects_array = ids.first.kind_of?(Array)
36
+ return ids.first if expects_array && ids.first.empty?
37
+
38
+ ids = ids.dup
39
+ ids.flatten!
40
+ ids.compact!
41
+ ids.collect!(&:to_s)
42
+ ids.uniq!
43
+
44
+ #raise RecordNotFound, "Couldn't find #{record_klass.name} without an ID" if ids.empty?
45
+
46
+ results = multi_get(ids).values.compact
47
+
48
+ results.size <= 1 && !expects_array ? results.first : results
49
+ end
50
+
51
+ private
52
+ def multi_get(keys, options={})
53
+ attribute_results = ActiveSupport::Notifications.instrument("multi_get.cassandra_object", column_family: column_family, keys: keys) do
54
+ connection.multi_get(column_family, keys.map(&:to_s), consistency: thrift_read_consistency)
55
+ end
56
+
57
+ attribute_results.inject({}) do |memo, (key, attributes)|
58
+ if attributes.empty?
59
+ memo[key] = nil
60
+ else
61
+ memo[parse_key(key)] = instantiate(key, attributes)
62
+ end
63
+ memo
64
+ end
65
+ end
66
+
67
+ def get(key, options={})
68
+ multi_get([key], options).values.first
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,31 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/named_base'
3
+
4
+ module CassandraObject
5
+ module Generators
6
+ class MigrationGenerator < Rails::Generators::NamedBase
7
+
8
+ source_root File.expand_path("../templates", __FILE__)
9
+
10
+ def self.banner
11
+ "rails g cassandra_object:migration NAME"
12
+ end
13
+
14
+ def self.desc(description = nil)
15
+ <<EOF
16
+ Description:
17
+ Create an empty Cassandra migration file in 'ks/migrate'. Very similar to Rails database migrations.
18
+
19
+ Example:
20
+ `rails g cassandra_object:migration CreateFooColumnFamily`
21
+ EOF
22
+ end
23
+
24
+ def create
25
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
26
+ template 'migration.rb.erb', "ks/migrate/#{timestamp}_#{file_name.underscore}.rb"
27
+ end
28
+
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ class <%= name %> < CassandraObject::Schema::Migration
2
+
3
+ def self.up
4
+
5
+ end
6
+
7
+ def self.down
8
+
9
+ end
10
+
11
+ end
@@ -0,0 +1,36 @@
1
+ module CassandraObject
2
+ module Identity
3
+ # Key factories need to support 3 operations
4
+ class AbstractKeyFactory
5
+ # Next key takes an object and returns the key object it should use.
6
+ # object will be ignored with synthetic keys but could be useful with natural ones
7
+ #
8
+ # @param [CassandraObject::Base] the object that needs a new key
9
+ # @return [CassandraObject::Identity::Key] the key
10
+ #
11
+ def next_key(object)
12
+ raise NotImplementedError, "#{self.class.name}#next_key isn't implemented."
13
+ end
14
+
15
+ # Parse should create a new key object from the 'to_param' format
16
+ #
17
+ # @param [String] the result of calling key.to_param
18
+ # @return [CassandraObject::Identity::Key] the parsed key
19
+ #
20
+ def parse(string)
21
+ raise NotImplementedError, "#{self.class.name}#parse isn't implemented."
22
+ end
23
+
24
+
25
+ # create should create a new key object from the cassandra format.
26
+ #
27
+ # @param [String] the result of calling key.to_s
28
+ # @return [CassandraObject::Identity::Key] the key
29
+ #
30
+ def create(string)
31
+ raise NotImplementedError, "#{self.class.name}#create isn't implemented."
32
+ end
33
+ end
34
+ end
35
+ end
36
+
@@ -0,0 +1,50 @@
1
+ module CassandraObject
2
+ module Identity
3
+ class CustomKeyFactory < AbstractKeyFactory
4
+ class CustomKey
5
+ include Key
6
+
7
+ attr_reader :value
8
+
9
+ def initialize(value)
10
+ @value = value
11
+ end
12
+
13
+ def to_s
14
+ value
15
+ end
16
+
17
+ def to_param
18
+ value
19
+ end
20
+
21
+ def ==(other)
22
+ other.is_a?(CustomKey) && other.value == value
23
+ end
24
+
25
+ def eql?(other)
26
+ other == self
27
+ end
28
+ end
29
+
30
+ attr_reader :method
31
+
32
+ def initialize(options)
33
+ @method = options[:method]
34
+ end
35
+
36
+ def next_key(object)
37
+ CustomKey.new(object.send(@method))
38
+ end
39
+
40
+ def parse(paramized_key)
41
+ CustomKey.new(paramized_key)
42
+ end
43
+
44
+ def create(paramized_key)
45
+ CustomKey.new(paramized_key)
46
+ end
47
+ end
48
+ end
49
+ end
50
+
@@ -0,0 +1,10 @@
1
+ require 'digest/sha1'
2
+ module CassandraObject
3
+ module Identity
4
+ class HashedNaturalKeyFactory < NaturalKeyFactory
5
+ def next_key(object)
6
+ NaturalKey.new(Digest::SHA1.hexdigest(attributes.map { |a| object.attributes[a.to_s] }.join(separator)))
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ module CassandraObject
2
+ module Identity
3
+ # An "interface" that keys need to implement
4
+ #
5
+ # You don't have to include this. But, there's no reason I can think of not to.
6
+ #
7
+ module Key
8
+ # to_param should return a nice-readable representation of the key suitable to chuck into URLs
9
+ #
10
+ # @return [String] a nice readable representation of the key suitable for URLs
11
+ def to_param; end
12
+
13
+ # to_s should return the bytes which will be written to cassandra both as keys and values for associations.
14
+ #
15
+ # @return [String] the bytes which will be written to cassandra as keys
16
+ def to_s; end
17
+ end
18
+ end
19
+ end
20
+
@@ -0,0 +1,51 @@
1
+ module CassandraObject
2
+ module Identity
3
+ class NaturalKeyFactory < AbstractKeyFactory
4
+ class NaturalKey
5
+ include Key
6
+
7
+ attr_reader :value
8
+
9
+ def initialize(value)
10
+ @value = value
11
+ end
12
+
13
+ def to_s
14
+ value
15
+ end
16
+
17
+ def to_param
18
+ value
19
+ end
20
+
21
+ def ==(other)
22
+ other.is_a?(NaturalKey) && other.value == value
23
+ end
24
+
25
+ def eql?(other)
26
+ other == self
27
+ end
28
+ end
29
+
30
+ attr_reader :attributes, :separator
31
+
32
+ def initialize(options)
33
+ @attributes = [*options[:attributes]]
34
+ @separator = options[:separator] || "-"
35
+ end
36
+
37
+ def next_key(object)
38
+ NaturalKey.new(attributes.map { |a| object.attributes[a.to_s] }.join(separator))
39
+ end
40
+
41
+ def parse(paramized_key)
42
+ NaturalKey.new(paramized_key)
43
+ end
44
+
45
+ def create(paramized_key)
46
+ NaturalKey.new(paramized_key)
47
+ end
48
+ end
49
+ end
50
+ end
51
+
@@ -0,0 +1,39 @@
1
+ module CassandraObject
2
+ module Identity
3
+ # Key factories need to support 3 operations
4
+ class UUIDKeyFactory < AbstractKeyFactory
5
+ class UUID < SimpleUUID::UUID
6
+ include Key
7
+
8
+ def to_param
9
+ to_guid
10
+ end
11
+
12
+ def to_s
13
+ # FIXME - this should probably write the raw bytes
14
+ # but it's very hard to debug without this for now.
15
+ to_guid
16
+ end
17
+ end
18
+
19
+ # Next key takes an object and returns the key object it should use.
20
+ # object will be ignored with synthetic keys but could be useful with natural ones
21
+ def next_key(object)
22
+ UUID.new
23
+ end
24
+
25
+ # Parse should create a new key object from the 'to_param' format
26
+ def parse(string)
27
+ UUID.new(string)
28
+ rescue
29
+ nil
30
+ end
31
+
32
+ # create should create a new key object from the cassandra format.
33
+ def create(string)
34
+ UUID.new(string)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
@@ -0,0 +1,52 @@
1
+ module CassandraObject
2
+ module Identity
3
+ extend ActiveSupport::Concern
4
+ extend ActiveSupport::Autoload
5
+
6
+ autoload :Key
7
+ autoload :AbstractKeyFactory
8
+ autoload :UUIDKeyFactory
9
+ autoload :NaturalKeyFactory
10
+ autoload :HashedNaturalKeyFactory
11
+ autoload :CustomKeyFactory
12
+
13
+ module ClassMethods
14
+ # Indicate what kind of key the model will have: uuid or natural
15
+ #
16
+ # @param [:uuid, :natural] the type of key
17
+ # @param the options you want to pass along to the key factory (like :attributes => :name, for a natural key).
18
+ #
19
+ def key(name_or_factory = :uuid, *options)
20
+ @key_factory = case name_or_factory
21
+ when :uuid
22
+ UUIDKeyFactory.new
23
+ when :natural
24
+ NaturalKeyFactory.new(*options)
25
+ when :custom
26
+ CustomKeyFactory.new(*options)
27
+ else
28
+ name_or_factory
29
+ end
30
+ end
31
+
32
+ def next_key(object = nil)
33
+ @key_factory.next_key(object).tap do |key|
34
+ raise "Keys may not be nil" if key.nil?
35
+ end
36
+ end
37
+
38
+ def parse_key(string)
39
+ @key_factory.parse(string)
40
+ end
41
+ end
42
+
43
+ def id
44
+ key.to_s
45
+ end
46
+
47
+ def id=(key)
48
+ self.key = self.class.parse_key(key)
49
+ id
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,37 @@
1
+ module CassandraObject
2
+ class LogSubscriber < ActiveSupport::LogSubscriber
3
+ def multi_get(event)
4
+ name = '%s multi_get (%.1fms)' % [event.payload[:column_family], event.duration]
5
+
6
+ debug " #{name} (#{event.payload[:keys].size}) #{event.payload[:keys].join(" ")}"
7
+ end
8
+
9
+ def remove(event)
10
+ name = '%s remove (%.1fms)' % [event.payload[:column_family], event.duration]
11
+
12
+ message = " #{name} #{event.payload[:key]}"
13
+ message << " #{Array(event.payload[:attributes]).inspect}" if event.payload[:attributes]
14
+
15
+ debug message
16
+ end
17
+
18
+ def truncate(event)
19
+ name = '%s truncate (%.1fms)' % [event.payload[:column_family], event.duration]
20
+
21
+ debug " #{name} #{event.payload[:column_family]}"
22
+ end
23
+
24
+ def insert(event)
25
+ name = '%s insert (%.1fms)' % [event.payload[:column_family], event.duration]
26
+
27
+ debug " #{name} #{event.payload[:key]} #{event.payload[:attributes].inspect}"
28
+ end
29
+
30
+ def get_range(event)
31
+ name = '%s get_range (%.1fms)' % [event.payload[:column_family], event.duration]
32
+
33
+ debug " #{name} (#{event.payload[:count]}) '#{event.payload[:start]}' => '#{event.payload[:finish]}'"
34
+ end
35
+ end
36
+ end
37
+ CassandraObject::LogSubscriber.attach_to :cassandra_object
@@ -0,0 +1,15 @@
1
+ module CassandraObject
2
+ module Migrations
3
+ class Migration
4
+ attr_reader :version
5
+ def initialize(version, block)
6
+ @version = version
7
+ @block = block
8
+ end
9
+
10
+ def run(attrs)
11
+ @block.call(attrs)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,66 @@
1
+ module CassandraObject
2
+ module Migrations
3
+ extend ActiveSupport::Concern
4
+ extend ActiveSupport::Autoload
5
+
6
+ included do
7
+ class_inheritable_array :migrations
8
+ class_inheritable_accessor :current_schema_version
9
+ self.current_schema_version = 0
10
+ end
11
+
12
+ autoload :Migration
13
+
14
+ class MigrationNotFoundError < StandardError
15
+ def initialize(record_version, migrations)
16
+ super("Cannot migrate a record from #{record_version.inspect}. Migrations exist for #{migrations.map(&:version)}")
17
+ end
18
+ end
19
+
20
+ module InstanceMethods
21
+ def schema_version
22
+ Integer(@schema_version || self.class.current_schema_version)
23
+ end
24
+ end
25
+
26
+ module ClassMethods
27
+ def migrate(version, &blk)
28
+ write_inheritable_array(:migrations, [Migration.new(version, blk)])
29
+
30
+ if version > self.current_schema_version
31
+ self.current_schema_version = version
32
+ end
33
+ end
34
+
35
+ def instantiate(key, attributes)
36
+ version = attributes.delete('schema_version')
37
+ original_attributes = attributes.dup
38
+ if version == current_schema_version
39
+ return super(key, attributes)
40
+ end
41
+
42
+ versions_to_migrate = ((version.to_i + 1)..current_schema_version)
43
+
44
+ migrations_to_run = versions_to_migrate.map do |v|
45
+ migrations.find {|m| m.version == v}
46
+ end
47
+
48
+ if migrations_to_run.any?(&:nil?)
49
+ raise MigrationNotFoundError.new(version, migrations)
50
+ end
51
+
52
+ migrations_to_run.inject(attributes) do |attrs, migration|
53
+ migration.run(attrs)
54
+ @schema_version = migration.version.to_s
55
+ attrs
56
+ end
57
+
58
+ super(key, attributes).tap do |record|
59
+ original_attributes.diff(attributes).keys.each do |attribute|
60
+ record.attribute_will_change! attribute
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,15 @@
1
+ require 'cassandra/mock'
2
+ module CassandraObject
3
+ module Mocking
4
+ extend ActiveSupport::Concern
5
+ module ClassMethods
6
+ def use_mock!(really=true)
7
+ if really
8
+ self.connection_class = Cassandra::Mock
9
+ else
10
+ self.connection_class = Cassandra
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end