simple_master 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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/lib/simple_master/active_record/belongs_to_master_polymorphic_reflection.rb +11 -0
  3. data/lib/simple_master/active_record/belongs_to_polymorphic_association.rb +17 -0
  4. data/lib/simple_master/active_record/belongs_to_polymorphic_builder.rb +21 -0
  5. data/lib/simple_master/active_record/extension.rb +183 -0
  6. data/lib/simple_master/active_record/preloader_association_extension.rb +11 -0
  7. data/lib/simple_master/active_record.rb +12 -0
  8. data/lib/simple_master/loader/dataset_loader.rb +20 -0
  9. data/lib/simple_master/loader/marshal_loader.rb +15 -0
  10. data/lib/simple_master/loader/query_loader.rb +63 -0
  11. data/lib/simple_master/loader.rb +55 -0
  12. data/lib/simple_master/master/association/belongs_to_association.rb +79 -0
  13. data/lib/simple_master/master/association/belongs_to_polymorphic_association.rb +79 -0
  14. data/lib/simple_master/master/association/has_many_association.rb +53 -0
  15. data/lib/simple_master/master/association/has_many_through_association.rb +64 -0
  16. data/lib/simple_master/master/association/has_one_association.rb +57 -0
  17. data/lib/simple_master/master/association.rb +50 -0
  18. data/lib/simple_master/master/column/bitmask_column.rb +74 -0
  19. data/lib/simple_master/master/column/boolean_column.rb +51 -0
  20. data/lib/simple_master/master/column/enum_column.rb +96 -0
  21. data/lib/simple_master/master/column/float_column.rb +21 -0
  22. data/lib/simple_master/master/column/id_column.rb +31 -0
  23. data/lib/simple_master/master/column/integer_column.rb +21 -0
  24. data/lib/simple_master/master/column/json_column.rb +27 -0
  25. data/lib/simple_master/master/column/polymorphic_type_column.rb +44 -0
  26. data/lib/simple_master/master/column/sti_type_column.rb +21 -0
  27. data/lib/simple_master/master/column/string_column.rb +17 -0
  28. data/lib/simple_master/master/column/symbol_column.rb +23 -0
  29. data/lib/simple_master/master/column/time_column.rb +38 -0
  30. data/lib/simple_master/master/column.rb +138 -0
  31. data/lib/simple_master/master/dsl.rb +239 -0
  32. data/lib/simple_master/master/editable.rb +155 -0
  33. data/lib/simple_master/master/filterable.rb +47 -0
  34. data/lib/simple_master/master/queryable.rb +75 -0
  35. data/lib/simple_master/master/storable.rb +20 -0
  36. data/lib/simple_master/master/validatable.rb +216 -0
  37. data/lib/simple_master/master.rb +417 -0
  38. data/lib/simple_master/schema.rb +49 -0
  39. data/lib/simple_master/storage/dataset.rb +172 -0
  40. data/lib/simple_master/storage/ondemand_table.rb +68 -0
  41. data/lib/simple_master/storage/table.rb +197 -0
  42. data/lib/simple_master/storage/test_table.rb +69 -0
  43. data/lib/simple_master/storage.rb +11 -0
  44. data/lib/simple_master/version.rb +5 -0
  45. data/lib/simple_master.rb +62 -0
  46. metadata +128 -0
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMaster
4
+ class Master
5
+ class Column
6
+ attr_reader :name
7
+ attr_reader :options
8
+ attr_accessor :group_key
9
+
10
+ def self.column_type
11
+ klass_name = name.split("::").last
12
+ type_name = klass_name.delete_suffix('Column')
13
+ return :column if type_name.empty?
14
+
15
+ type_name.underscore.to_sym
16
+ end
17
+
18
+ def self.inherited(subclass)
19
+ type = subclass.column_type
20
+ Column.register(type, subclass)
21
+ end
22
+
23
+ def self.column_types
24
+ @column_types ||= {}
25
+ end
26
+
27
+ def self.register(type, klass)
28
+ if column_types.key?(type)
29
+ fail "#{klass}: Column type #{type} is defined at #{column_types[type]}."
30
+ end
31
+
32
+ column_types[type] = klass
33
+ end
34
+
35
+ register(:column, self)
36
+
37
+ def initialize(name, options)
38
+ @name = name
39
+ @group_key = !!options[:group_key]
40
+ @options = options
41
+ end
42
+
43
+ def init(master_class, for_test = false)
44
+ master_class.simple_master_module.attr_reader name
45
+
46
+ master_class.simple_master_module.class_eval <<-RUBY, __FILE__, __LINE__ + 1
47
+ def #{name}=(value)
48
+ #{code_for_conversion}
49
+ #{code_for_dirty_check if for_test}
50
+ @#{name} = value
51
+ end
52
+
53
+ def #{name}_value_for_sql
54
+ value = #{code_for_sql_value}
55
+ return "NULL" if value.nil?
56
+ return "'" + value.gsub(/'/, "''").gsub("\\\\", '\\&\\&') + "'" if value.is_a?(String)
57
+ value
58
+ end
59
+
60
+ # For inspecting raw DB/CSV values when checking CSV diffs
61
+ def #{name}_value_for_csv
62
+ #{code_for_sql_value}
63
+ end
64
+ RUBY
65
+
66
+ globalize(master_class) if options[:globalize]
67
+
68
+ if options[:db_column_name]
69
+ master_class.simple_master_module.alias_method :"#{options[:db_column_name]}=", :"#{name}="
70
+ end
71
+ end
72
+
73
+ def db_column_name
74
+ options[:db_column_name] || name
75
+ end
76
+
77
+ private
78
+
79
+ def code_for_conversion
80
+ end
81
+
82
+ def globalize(master_class)
83
+ fail "#{master_class}.#{name}: group key can not be globalized." if options[:group_key]
84
+
85
+ mod = Module.new
86
+ mod.class_eval <<-RUBY, __FILE__, __LINE__ + 1
87
+ def #{name}
88
+ return super if @_globalized_#{name}.nil?
89
+ @_globalized_#{name}.fetch(I18n.locale) { super }
90
+ end
91
+
92
+ attr_reader :_globalized_#{name}
93
+
94
+ def _globalized_#{name}=(hash_or_json)
95
+ hash = hash_or_json.is_a?(String) ? JSON.parse(hash_or_json, symbolize_names: true) : hash_or_json
96
+ @_globalized_#{name} = hash&.transform_values { |value|
97
+ #{code_for_conversion}
98
+ value
99
+ }
100
+ end
101
+
102
+ def _globalized_#{name}_set(locale, value)
103
+ @_globalized_#{name} ||= {}
104
+ #{code_for_conversion}
105
+ @_globalized_#{name}[locale] = value
106
+ end
107
+ RUBY
108
+
109
+ master_class.simple_master_module.prepend(mod)
110
+ end
111
+
112
+ def code_for_dirty_check
113
+ <<-RUBY
114
+ dirty! unless @#{name} == value
115
+ RUBY
116
+ end
117
+
118
+ def code_for_sql_value
119
+ <<-RUBY
120
+ self.#{name}
121
+ RUBY
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ require "simple_master/master/column/id_column"
128
+ require "simple_master/master/column/integer_column"
129
+ require "simple_master/master/column/float_column"
130
+ require "simple_master/master/column/string_column"
131
+ require "simple_master/master/column/symbol_column"
132
+ require "simple_master/master/column/boolean_column"
133
+ require "simple_master/master/column/json_column"
134
+ require "simple_master/master/column/time_column"
135
+ require "simple_master/master/column/enum_column"
136
+ require "simple_master/master/column/bitmask_column"
137
+ require "simple_master/master/column/sti_type_column"
138
+ require "simple_master/master/column/polymorphic_type_column"
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMaster
4
+ class Master
5
+ # Items declared at class-definition time are grouped inside this module
6
+ module Dsl
7
+ TYPES_BY_OPTIONS = {
8
+ polymorphic_type: Column::PolymorphicTypeColumn,
9
+ sti: Column::StiTypeColumn,
10
+ enum: Column::EnumColumn,
11
+ bitmask: Column::BitmaskColumn,
12
+ }.freeze
13
+
14
+ def def_column(column_name, options = {})
15
+ column = column_type(column_name, options).new(column_name, options)
16
+ columns << column
17
+
18
+ if options[:sti]
19
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
20
+ def self.sti_base_class
21
+ #{name}
22
+ end
23
+
24
+ def self.sti_column
25
+ :#{column_name}
26
+ end
27
+ RUBY
28
+ end
29
+ end
30
+
31
+ def has_one(name, options = EMPTY_HASH)
32
+ ass = Association::HasOneAssociation.new(self, name, options)
33
+ has_one_associations << ass
34
+ end
35
+
36
+ def has_many(name, options = {})
37
+ ass =
38
+ if options[:through]
39
+ Association::HasManyThroughAssociation.new(self, name, options)
40
+ else
41
+ Association::HasManyAssociation.new(self, name, options)
42
+ end
43
+
44
+ has_many_associations << ass
45
+ end
46
+
47
+ def belongs_to(name, options = EMPTY_HASH)
48
+ ass =
49
+ if options[:polymorphic]
50
+ Association::BelongsToPolymorphicAssociation.new(self, name, options)
51
+ else
52
+ Association::BelongsToAssociation.new(self, name, options)
53
+ end
54
+
55
+ belongs_to_associations << ass
56
+ end
57
+
58
+ def group_key(column_name)
59
+ proc {
60
+ update_column_info(column_name) do |column|
61
+ column.group_key = true
62
+
63
+ column
64
+ end
65
+ }.tap { dsl_initializers << _1 }
66
+ end
67
+
68
+ def enum(*args)
69
+ if args.length == 1
70
+ options = args.first
71
+ proc {
72
+ options.each do |name, enums|
73
+ update_column_info(name) do |column|
74
+ Column::EnumColumn.new(name, column.options.merge(enum: enums))
75
+ end
76
+ end
77
+ }.tap { dsl_initializers << _1 }
78
+ elsif args.length >= 2
79
+ name = args.shift
80
+ enums = args.shift
81
+ options = args.shift || {}
82
+
83
+ proc {
84
+ update_column_info(name) do |column|
85
+ Column::EnumColumn.new(name, column.options.merge(options.merge(enum: enums)))
86
+ end
87
+ }.tap { dsl_initializers << _1 }
88
+ else
89
+ fail
90
+ end
91
+ end
92
+
93
+ def bitmask(name, options = Association)
94
+ proc {
95
+ update_column_info(name) do |column|
96
+ Column::BitmaskColumn.new(name, column.options.merge(bitmask: options[:as]))
97
+ end
98
+ }.tap { dsl_initializers << _1 }
99
+ end
100
+
101
+ def globalize(column_name)
102
+ proc {
103
+ update_column_info(column_name) do |column|
104
+ column.options[:globalize] = true
105
+ column
106
+ end
107
+ }.tap { dsl_initializers << _1 }
108
+ end
109
+
110
+ # * Cache generated at load time and stored in the dataset.
111
+ #
112
+ # cache_class_method method_name_symbol
113
+ #
114
+ # * Can also be declared using the return value of a def:
115
+ #
116
+ # cache_class_method def self.method_name
117
+ # ...
118
+ # end
119
+ #
120
+ # * Pass a block to generate the cache from the block instead of an existing method.
121
+ #
122
+ # cache_class_method method_name_symbol { ... }
123
+ #
124
+ # * Defines class methods; multiple caches can be generated by passing several args.
125
+ #
126
+ # cache_class_method(:cache1, :cache2) {
127
+ # ...
128
+ # [cache1, cache2]
129
+ # }
130
+ def cache_class_method(*args, &block)
131
+ @class_method_cache_info ||= []
132
+ if block_given?
133
+ @class_method_cache_info << [args, block]
134
+ else
135
+ args.each do |arg|
136
+ method_name = :"_class_cache_#{arg}"
137
+ @class_method_cache_info << [[arg], method_name]
138
+ singleton_class.alias_method method_name, arg
139
+ end
140
+ end
141
+
142
+ args.each do |arg|
143
+ define_singleton_method(arg) do
144
+ class_method_cache[arg]
145
+ end
146
+ end
147
+ end
148
+
149
+ alias def_custom_cache cache_class_method
150
+
151
+ # Access the method once after the record is loaded
152
+ def tap_instance_method(method_name)
153
+ @instance_methods_need_tap ||= []
154
+ @instance_methods_need_tap << method_name
155
+ end
156
+
157
+ # * Cache generated at load time and stored in an instance variable.
158
+ # Lightweight, but must not reference the dataset, so use with care.
159
+ # Intended for caches derived from record attributes.
160
+ #
161
+ # cache_attribute method_name_symbol
162
+ #
163
+ # * Can also be declared using the return value of a def:
164
+ #
165
+ # cache_attribute def method_name
166
+ # ...
167
+ # end
168
+ #
169
+ # * Passing a block generates the cache from the block.
170
+ # cache_attribute method_name { ... }
171
+ def cache_attribute(method_name, &)
172
+ calc_method_name = :"_attribute_cache_#{method_name}"
173
+
174
+ if block_given?
175
+ define_method(calc_method_name, &)
176
+ else
177
+ alias_method calc_method_name, method_name
178
+ end
179
+
180
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
181
+ def #{method_name}
182
+ return @#{method_name} if defined? @#{method_name}
183
+
184
+ @#{method_name} = #{calc_method_name}
185
+ end
186
+ RUBY
187
+
188
+ tap_instance_method method_name
189
+ end
190
+
191
+ # * Cache generated at load time and stored in the dataset.
192
+ # Heavier than cache_attribute because it uses the dataset.
193
+ #
194
+ # cache_method method_name_symbol
195
+ #
196
+ # * Can also be declared using the return value of a def:
197
+ #
198
+ # cache_method def method_name
199
+ # ...
200
+ # end
201
+ #
202
+ # * Passing a block generates the cache from the block.
203
+ #
204
+ # cache_method method_name_symbol { ... }
205
+ def cache_method(method_name, &)
206
+ calc_method_name = :"_method_cache_#{method_name}"
207
+ if block_given?
208
+ define_method(calc_method_name, &)
209
+ else
210
+ alias_method calc_method_name, method_name
211
+ end
212
+
213
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
214
+ def #{method_name}
215
+ self.class.method_cache[:#{method_name}].fetch(self) {
216
+ self.class.method_cache[:#{method_name}][self] = #{calc_method_name}
217
+ }
218
+ end
219
+ RUBY
220
+
221
+ tap_instance_method method_name
222
+ end
223
+
224
+ private
225
+
226
+ def column_type(column_name, options)
227
+ return Column::IdColumn if column_name == :id
228
+
229
+ TYPES_BY_OPTIONS.each do |option_key, type|
230
+ return type if options[option_key]
231
+ end
232
+
233
+ return Column unless options[:type]
234
+
235
+ Column.column_types.fetch(options[:type]) { fail "Undefined column type: #{options[:type]}" }
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMaster
4
+ class Master
5
+ # Provide save! helpers for test data
6
+ module Editable
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def create(...)
11
+ new(...).save
12
+ end
13
+
14
+ def create!(...)
15
+ new(...).save!
16
+ end
17
+ end
18
+ def dirty!
19
+ @dirty = true
20
+ end
21
+
22
+ def new_record?
23
+ id.nil? || self.class.id_hash[id] != self
24
+ end
25
+
26
+ def has_changes_to_save?
27
+ !@dirty.nil?
28
+ end
29
+
30
+ def record_to_save
31
+ # Assumes type_not_match? has already been checked
32
+ return nil if type.nil?
33
+ return @record_to_save if @record_to_save && @record_to_save.type == type
34
+
35
+ @record_to_save = type.constantize.new
36
+ end
37
+
38
+ def type_not_match?
39
+ self.class.sti_class? && type != self.class.to_s
40
+ end
41
+
42
+ # Use a generator that avoids ID collisions across classes
43
+ @@generated_id = 0 # rubocop:disable Style/ClassVars
44
+ def generate_id
45
+ loop do
46
+ @@generated_id += 1 # rubocop:disable Style/ClassVars
47
+ break unless self.class.base_class.id_hash.key?(@@generated_id)
48
+ end
49
+ @@generated_id
50
+ end
51
+
52
+ # FOR TEST
53
+ def save(**_options)
54
+ save!
55
+ end
56
+
57
+ def save!(**_options)
58
+ return if @saving
59
+ @saving = true
60
+ if id.nil?
61
+ self.id = generate_id
62
+ end
63
+
64
+ # Save belongs_to.
65
+ association_records = belongs_to_store.dup
66
+ association_records.each do |association_name, record|
67
+ next unless record
68
+
69
+ unless send(:"_#{association_name}_target_save?")
70
+ belongs_to_store.delete(association_name)
71
+ next
72
+ end
73
+
74
+ record.save
75
+ send(:"#{association_name}=", record)
76
+ belongs_to_store.delete(association_name) if record.is_a?(SimpleMaster::Master)
77
+ end
78
+
79
+ if @dirty
80
+ # Update this table
81
+ if type_not_match?
82
+ # If data would be created on the parent class, discard that instance and save separately
83
+ record_to_save&.update!(attributes)
84
+ else
85
+ if new_record?
86
+ id_updated = true
87
+ current_and_super_classes_each { |klass| klass.master_storage.update(id, self) }
88
+ SimpleMaster.logger.debug { "[SimpleMaster] Created: #{self.class}##{id}" }
89
+ else
90
+ current_and_super_classes_each { |klass| klass.master_storage.record_updated }
91
+ SimpleMaster.logger.debug { "[SimpleMaster] Updated: #{self.class}##{id}" }
92
+ end
93
+ end
94
+ @dirty = false
95
+ end
96
+
97
+ # save has_many
98
+ association_records = has_many_store.dup
99
+
100
+ association_records.each do |association_name, records|
101
+ association = (self.class.all_has_many_associations | self.class.all_has_one_associations).find { |ass| ass.name == association_name }
102
+ records.each do |record|
103
+ if id_updated
104
+ record.send(:"#{association.foreign_key}=", send(association.primary_key))
105
+ end
106
+ record.save
107
+ end
108
+ has_many_store.delete(association_name) if association.target_class < Master
109
+ end
110
+
111
+ @saving = false
112
+
113
+ self
114
+ end
115
+
116
+ def current_and_super_classes_each
117
+ klass = self.class
118
+ yield klass
119
+
120
+ loop do
121
+ klass = klass.superclass
122
+ break unless klass < Master
123
+ next if klass.abstract_class?
124
+
125
+ yield klass
126
+ end
127
+ end
128
+
129
+ def update(attributes)
130
+ update!(attributes)
131
+ true
132
+ end
133
+
134
+ def update!(attributes)
135
+ attributes.each do |key, value|
136
+ send :"#{key}=", value
137
+ end
138
+ save!
139
+ end
140
+
141
+ def destroy
142
+ destroy!
143
+ true
144
+ end
145
+
146
+ def destroy!
147
+ fail "Destroy is not allowed"
148
+ end
149
+
150
+ # NOTE: no freezing
151
+ def freeze
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMaster
4
+ class Master
5
+ # Lookup helper methods
6
+ module Filterable
7
+ def find(id)
8
+ id_hash.fetch(id)
9
+ end
10
+
11
+ def find_by_id(id)
12
+ id_hash[id]
13
+ end
14
+
15
+ def find_by_ids(ids)
16
+ id_hash.values_at(*ids).compact
17
+ end
18
+
19
+ def find_by_ids!(ids)
20
+ id_hash.fetch_values(*ids)
21
+ end
22
+
23
+ def find_by(key, value)
24
+ all_by(key, value).first
25
+ end
26
+
27
+ def all_by(key, value)
28
+ grouped_hash.fetch(key).fetch(value) { EMPTY_ARRAY }
29
+ end
30
+
31
+ def all_by!(key, value)
32
+ grouped_hash.fetch(key).fetch(value)
33
+ end
34
+
35
+ def all_in(key, values)
36
+ # NOTE: Avoid Array#flatten because it is slow here
37
+ grouped_hash.fetch(key).fetch_values(*values) { EMPTY_ARRAY }.flat_map(&:itself).freeze
38
+ end
39
+
40
+ def exists?(key)
41
+ id_hash.key?(key)
42
+ end
43
+
44
+ delegate :pluck, :map, :first, :last, :each, :select, to: :all
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMaster
4
+ class Master
5
+ # Query in database
6
+ module Queryable
7
+ def query_select_all
8
+ connection.select_all("SELECT * from #{table_name}")
9
+ end
10
+
11
+ def query_upsert_records(records, batch_size: 10000)
12
+ insert_queries(records, true, batch_size: batch_size).each do |sql|
13
+ connection.execute(sql)
14
+ end
15
+ end
16
+
17
+ def insert_queries(records, on_duplicate_key_update = false, batch_size: 10000)
18
+ return [] if records.empty?
19
+
20
+ column_names = all_columns.map(&:name)
21
+ db_column_names = all_columns.map(&:db_column_name)
22
+ sql_columns = db_column_names.map { "`#{_1}`" }.join(", ").then { "(#{_1})" }
23
+
24
+ sql_column_methods = column_names.map { |column_name| :"#{column_name}_value_for_sql" }
25
+ current_time = "'#{Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')}'"
26
+
27
+ if on_duplicate_key_update
28
+ sql_update = db_column_names.filter_map { |column_name| "`#{column_name}` = new.#{column_name}" if column_name != :created_at }.join(", ")
29
+ on_duplicate_key_update_sql = " AS new ON DUPLICATE KEY UPDATE #{sql_update}"
30
+ else
31
+ on_duplicate_key_update_sql = ""
32
+ end
33
+
34
+ records.each_slice(batch_size).map { |sliced_records|
35
+ values_sql =
36
+ sliced_records.map { |record|
37
+ sql_column_methods
38
+ .zip(column_names)
39
+ .map { |method_name, column_name|
40
+ if [:updated_at, :created_at].include?(column_name)
41
+ current_time
42
+ else
43
+ record.send(method_name)
44
+ end
45
+ }.join(", ").then { "(#{_1})" }
46
+ }.join(", \n")
47
+
48
+ "INSERT INTO `#{table_name}` \n#{sql_columns} VALUES \n#{values_sql}#{on_duplicate_key_update_sql};\n"
49
+ }
50
+ end
51
+
52
+ def sqlite_insert_query(records)
53
+ insert_queries(records, false).join("\n").gsub("\\\\", "\\")
54
+ end
55
+
56
+ def query_delete_all
57
+ connection.execute(delete_all_query)
58
+ end
59
+
60
+ def delete_all_query
61
+ "DELETE FROM #{table_name};"
62
+ end
63
+
64
+ def table_available?
65
+ connection.table_exists? table_name
66
+ rescue
67
+ false
68
+ end
69
+
70
+ def connection
71
+ ::ActiveRecord::Base.connection
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleMaster
4
+ class Master
5
+ module Storable
6
+ def master_storage(dataset = $current_dataset)
7
+ dataset.table(self)
8
+ end
9
+
10
+ delegate :all, :all=, :id_hash, :id_hash=, :grouped_hash, :grouped_hash=, :class_method_cache, :class_method_cache=, :method_cache, to: :master_storage
11
+ delegate :update_id_hash, :update_grouped_hash, :update_class_method_cache, :tap_instance_methods, :freeze_all, to: :master_storage
12
+ end
13
+
14
+ module SubClassStorable
15
+ def master_storage(dataset = $current_dataset)
16
+ dataset.table(sti_base_class).sub_table(self)
17
+ end
18
+ end
19
+ end
20
+ end