dyna 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 468dd97763a9c340c77d46800af940b47bc6ac22
4
+ data.tar.gz: 6f446670b9ece4b36d7056dd0c2e3d61f380ebdd
5
+ SHA512:
6
+ metadata.gz: 011fd23c5040bb08d12e090669231e1db2de7ec899993456ad603e26c44080f345bad620ef86ad38a63569d9575b0301eacd5ac955a759760cb2787a5fd850ed
7
+ data.tar.gz: e336f511705841d336aabd8c3599a447bc3323aba06f300caa78f41d853eeaa93e83849d0d80968cc43f61ef30ba4f3e6a8e7dd39b2e4596047e4352af48e6f4
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in dyna.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 shinya-watanabe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # Dyna
2
+
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/dyna`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+
5
+ TODO: Delete this and the text above, and describe your gem
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'dyna'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install dyna
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/dyna.
36
+
37
+
38
+ ## License
39
+
40
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
41
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+ task :default => :spec
6
+
data/bin/dyna ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env ruby
2
+ $: << File.expand_path("#{File.dirname __FILE__}/../lib")
3
+ require 'rubygems'
4
+ require 'dyna'
5
+ require 'optparse'
6
+ require 'pry-byebug'
7
+
8
+ Version = Dyna::VERSION
9
+
10
+ mode = nil
11
+ file = 'Dynafile'
12
+ output_file = '-'
13
+ split = false
14
+ MAGIC_COMMENT = <<-EOS
15
+ # -*- mode: ruby -*-
16
+ # vi: set ft=ruby :
17
+ EOS
18
+
19
+ options = {
20
+ :dry_run => false,
21
+ :format => :ruby,
22
+ :color => true,
23
+ :debug => false,
24
+ }
25
+
26
+ ARGV.options do |opt|
27
+ begin
28
+ access_key = nil
29
+ secret_key = nil
30
+ region = nil
31
+ profile_name = nil
32
+ credentials_path = nil
33
+ format_passed = false
34
+ role_arn = nil
35
+ serial_number = nil
36
+ token_code = nil
37
+ role_session_name = nil
38
+
39
+ opt.on('-p', '--profile PROFILE_NAME') {|v| profile_name = v }
40
+ opt.on('', '--role_arn ROLE_ARN') {|v| role_arn = v }
41
+ opt.on('', '--serial_number SERIAL_NUMBER') {|v| serial_number = v }
42
+ opt.on('', '--token_code TOKEN_CODE') {|v| token_code = v }
43
+ opt.on('', '--role_session_name ROLE_SESSION_NAME') {|v| role_session_name = v }
44
+ opt.on('' , '--credentials-path PATH') {|v| credentials_path = v }
45
+ opt.on('-k', '--access-key ACCESS_KEY') {|v| access_key = v }
46
+ opt.on('-s', '--secret-key SECRET_KEY') {|v| secret_key = v }
47
+ opt.on('-r', '--region REGION') {|v| region = v }
48
+ opt.on('-a', '--apply') {|v| mode = :apply }
49
+ opt.on('-f', '--file FILE') {|v| file = v }
50
+ opt.on('', '--table_names TABLE_NAMES', Array) {|v| options[:table_names] = v }
51
+ opt.on('', '--exclude_table_names TABLE_NAMES', Array) {|v| options[:exclude_table_names] = v }
52
+ opt.on('', '--dry-run') {|v| options[:dry_run] = true }
53
+ opt.on('-e', '--export') {|v| mode = :export}
54
+ opt.on('-o', '--output FILE') {|v| output_file = v }
55
+ opt.on('', '--split') {|v| split = true }
56
+ opt.on('', '--split-more') {|v| split = :more }
57
+ opt.on('' , '--no-color') { options[:color] = false }
58
+ opt.on('' , '--debug') { options[:debug] = true }
59
+ opt.parse!
60
+
61
+ aws_opts = {}
62
+ if access_key and secret_key
63
+ aws_opts = {
64
+ :access_key_id => access_key,
65
+ :secret_access_key => secret_key,
66
+ }
67
+ elsif profile_name or credentials_path
68
+ credentials_opts = {}
69
+ credentials_opts[:profile_name] = profile_name if profile_name
70
+ credentials_opts[:path] = credentials_path if credentials_path
71
+ provider = AWS::Core::CredentialProviders::SharedCredentialFileProvider.new(credentials_opts)
72
+ aws_opts[:credential_provider] = provider
73
+ elsif role_arn and serial_number
74
+ credentials = Aws::AssumeRoleCredentials.new(
75
+ client: Aws::STS::Client.new,
76
+ role_arn: role_arn,
77
+ role_session_name: role_session_name,
78
+ serial_number: serial_number,
79
+ token_code: token_code,
80
+ )
81
+ aws_opts[:credentials] = credentials
82
+ elsif (access_key and !secret_key) or (!access_key and secret_key) or mode.nil?
83
+ puts opt.help
84
+ exit 1
85
+ end
86
+
87
+ aws_opts[:region] = region if region
88
+ Aws.config.update(aws_opts)
89
+
90
+ # Remap groups to exclude to regular expressions (if they're surrounded by '/')
91
+ if options[:exclude_table_names]
92
+ options[:exclude_table_names].map! do |name|
93
+ name =~ /\A\/(.*)\/\z/ ? Regexp.new($1) : Regexp.new("\A#{Regexp.escape(name)}\z")
94
+ end
95
+ end
96
+ rescue => e
97
+ $stderr.puts("[ERROR] #{e.message}")
98
+ exit 1
99
+ end
100
+ end
101
+
102
+ String.colorize = options[:color]
103
+
104
+ if options[:debug]
105
+ Aws.config.update({
106
+ :http_wire_trace => true,
107
+ :logger => Dyna::Logger.instance,
108
+ })
109
+ end
110
+
111
+ begin
112
+ logger = Dyna::Logger.instance
113
+ logger.set_debug(options[:debug])
114
+ client = Dyna::Client.new(options)
115
+
116
+ case mode
117
+ when :export
118
+ if split
119
+ logger.info('Export Table')
120
+
121
+ output_file = 'Dynafile' if output_file == '-'
122
+ requires = []
123
+ base_dir = File.dirname(output_file)
124
+
125
+ client.export(options) do |exported, converter|
126
+ write_table_file = proc do |table_file, tables|
127
+ requires << table_file
128
+
129
+ logger.info(" write `#{table_file}`")
130
+ FileUtils.mkdir_p(File.dirname(table_file))
131
+
132
+ open(table_file, 'wb') do |f|
133
+ f.puts MAGIC_COMMENT
134
+ f.puts converter.call(tables)
135
+ end
136
+ end
137
+
138
+ exported.each do |table_name, tables|
139
+ table_file = File.join(base_dir, "#{Aws::DynamoDB::Client.new.config.region}/#{table_name}.dyna")
140
+ write_table_file.call(table_file, table_name => tables)
141
+ end
142
+ end
143
+
144
+ logger.info(" write `#{output_file}`")
145
+ open(output_file, 'wb') do |f|
146
+ f.puts MAGIC_COMMENT
147
+
148
+ requires.each do |table_file|
149
+ table_file.sub!(%r|\A#{Regexp.escape base_dir}/?|, '')
150
+ f.puts "require '#{table_file}'"
151
+ end
152
+ end
153
+ else
154
+ exported = client.export(options)
155
+
156
+ if output_file == '-'
157
+ logger.info('# Export Table') if options[:format] == :ruby
158
+ puts exported
159
+ else
160
+ logger.info("Export Table to `#{output_file}`")
161
+
162
+ open(output_file, 'wb') do |f|
163
+ f.puts MAGIC_COMMENT if options[:format] == :ruby
164
+ f.puts exported
165
+ end
166
+ end
167
+ end
168
+ when :apply
169
+ unless File.exist?(file)
170
+ raise "No Dynafile found (looking for: #{file})"
171
+ end
172
+
173
+ msg = "Apply `#{file}` to DynamoDB"
174
+ msg << ' (dry-run)' if options[:dry_run]
175
+ logger.info(msg)
176
+
177
+ updated = client.apply(file)
178
+
179
+ logger.info('No change'.intense_blue) unless updated
180
+ else
181
+ raise 'must not happen'
182
+ end
183
+ rescue => e
184
+ if options[:debug]
185
+ raise e
186
+ else
187
+ $stderr.puts("[ERROR] #{e.message}".red)
188
+ $stderr.puts("#{e.backtrace.join("\n")}".red)
189
+ exit 1
190
+ end
191
+ end
data/dyna.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'dyna/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "dyna"
8
+ spec.version = Dyna::VERSION
9
+ spec.authors = ["wata"]
10
+ spec.email = ["wata.gm@gmail.com"]
11
+
12
+ spec.summary = %q{Codenize DynamoDB table}
13
+ spec.description = %q{Manager DynamoDB table by DSL}
14
+ spec.homepage = 'https://github.com/wata-gh/dyna'
15
+ spec.license = "MIT"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+ spec.bindir = "bin"
21
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
22
+ spec.require_paths = ["lib"]
23
+ spec.add_dependency 'aws-sdk', '~> 2'
24
+ spec.add_dependency 'term-ansicolor', '~> 1.4'
25
+ spec.add_dependency 'diffy', '~> 3.1'
26
+ spec.add_dependency 'hashie', '~> 3.4'
27
+
28
+ spec.add_development_dependency 'bundler', '~> 1.13'
29
+ spec.add_development_dependency 'rake', '~> 10.0'
30
+ spec.add_development_dependency 'rspec', '~> 3.0'
31
+ spec.add_development_dependency 'pry-byebug', '~> 3.4'
32
+ end
@@ -0,0 +1,89 @@
1
+ module Dyna
2
+ class Client
3
+ include Logger::ClientHelper
4
+ include Filterable
5
+
6
+ def initialize(options = {})
7
+ @options = OpenStruct.new(options)
8
+ @options_hash = options
9
+ @options.ddb = Aws::DynamoDB::Client.new
10
+ end
11
+
12
+ def apply(file)
13
+ walk(file)
14
+ end
15
+
16
+ def export(options = {})
17
+ exported = Exporter.export(@options.ddb, @options)
18
+
19
+ converter = proc do |src|
20
+ DSL.convert(@options.ddb.config.region, src)
21
+ end
22
+
23
+ if block_given?
24
+ yield(exported, converter)
25
+ else
26
+ converter.call(exported)
27
+ end
28
+ end
29
+
30
+ private
31
+ def load_file(file)
32
+ if file.kind_of?(String)
33
+ open(file) do |f|
34
+ parse(f.read, file)
35
+ end
36
+ elsif file.respond_to?(:read)
37
+ parse(file.read, file.path)
38
+ else
39
+ raise TypeError, "can't load #{file}"
40
+ end
41
+ end
42
+
43
+ def parse(src, path)
44
+ DSL.define(src, path).result
45
+ end
46
+
47
+ def walk(file)
48
+ dsl = load_file(file)
49
+ dsl_ddbs = dsl.ddbs
50
+ ddb_wrapper = DynamoDBWrapper.new(@options.ddb, @options)
51
+
52
+ dsl_ddbs.each do |region, ddb_dsl|
53
+ walk_ddb(ddb_dsl, ddb_wrapper) if @options.ddb.config.region == region
54
+ end
55
+
56
+ ddb_wrapper.updated?
57
+ end
58
+
59
+ def walk_ddb(ddb_dsl, ddb_wrapper)
60
+ table_list_dsl = ddb_dsl.tables.group_by(&:table_name).each_with_object({}) do |(k, v), h|
61
+ h[k] = v.first unless should_skip(k)
62
+ end
63
+ table_list_aws = ddb_wrapper.tables.group_by(&:table_name).each_with_object({}) do |(k, v), h|
64
+ h[k] = v.first unless should_skip(k)
65
+ end
66
+
67
+ table_list_dsl.each do |name, table_dsl|
68
+ unless table_list_aws[name]
69
+ result = ddb_wrapper.create(table_dsl)
70
+ if result
71
+ table_list_aws[name] = DynamoDBWrapper::Table.new(
72
+ @options.ddb,
73
+ result.table_description,
74
+ @options,
75
+ )
76
+ end
77
+ end
78
+ end
79
+
80
+ table_list_dsl.each do |name, table_dsl|
81
+ table_aws = table_list_aws.delete(name)
82
+ next unless table_aws # only dry-run and should be created
83
+ table_aws.update(table_dsl) unless table_aws.eql?(table_dsl)
84
+ end
85
+
86
+ table_list_aws.values.each(&:delete)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,87 @@
1
+ module Dyna
2
+ class DSL
3
+ class Converter
4
+ class << self
5
+ def convert(region, exported)
6
+ self.new(region, exported).convert
7
+ end
8
+ end
9
+
10
+ def initialize(region, exported)
11
+ @region = region
12
+ @exported = exported
13
+ end
14
+
15
+ def convert
16
+ output_dynamo_db
17
+ end
18
+
19
+ private
20
+ def output_dynamo_db
21
+ tables = @exported.map {|name, table|
22
+ output_table(name, table)
23
+ }.join("\n").strip
24
+
25
+ <<-EOS
26
+ dynamo_db "#{@region}" do
27
+ #{tables}
28
+ end
29
+ EOS
30
+ end
31
+
32
+ def output_table(name, table)
33
+ local_secondary_indexes = ''
34
+ global_secondary_indexes = ''
35
+ if table[:local_secondary_indexes]
36
+ local_secondary_indexes_tmpl = <<-EOS.chomp
37
+ <% table[:local_secondary_indexes].each do |index| %>
38
+ local_secondary_index <%= index[:index_name].inspect %> do
39
+ key_schema hash: <%= index[:key_schema][0][:attribute_name].inspect %>, range: <%= index[:key_schema][1][:attribute_name].inspect %><% if index[:projection] %>
40
+ projection projection_type: <%= index[:projection][:projection_type].inspect %><% end %>
41
+ end
42
+ <% end %>
43
+ EOS
44
+ local_secondary_indexes = ERB.new(local_secondary_indexes_tmpl).result(binding)
45
+ end
46
+
47
+ if table[:global_secondary_indexes]
48
+ p table[:global_secondary_indexes]
49
+ global_secondary_indexes_tmpl = <<-EOS.chomp
50
+ <% table[:global_secondary_indexes].each do |index| %>
51
+ global_secondary_index <%= index[:index_name].inspect %> do
52
+ key_schema hash: <%= index[:key_schema][0][:attribute_name].inspect %><% if index[:key_schema].size == 2 %>, range: <%= index[:key_schema][1][:attribute_name].inspect %><% end %><% if index[:projection] %>
53
+ projection projection_type: <%= index[:projection][:projection_type].inspect %><% end %>
54
+ provisioned_throughput read_capacity_units: <%= index[:provisioned_throughput][:read_capacity_units] %>, write_capacity_units: <%= index[:provisioned_throughput][:read_capacity_units] %>
55
+ end
56
+ <% end %>
57
+ EOS
58
+ global_secondary_indexes = ERB.new(global_secondary_indexes_tmpl).result(binding)
59
+ end
60
+
61
+ attribute_definitions_tmpl = <<-EOS.chomp
62
+ <% table[:attribute_definitions].each do |attr| %>
63
+ attribute_definition(
64
+ attribute_name: <%= attr[:attribute_name].inspect %>,
65
+ attribute_type: <%= attr[:attribute_type].inspect %>,
66
+ )
67
+ <% end %>
68
+ EOS
69
+ attribute_definitions = ERB.new(attribute_definitions_tmpl).result(binding)
70
+ <<-EOS
71
+ table "#{name}" do
72
+ key_schema(
73
+ hash: #{table[:key_schema][0][:attribute_name].inspect},
74
+ range: #{table[:key_schema][1][:attribute_name].inspect},
75
+ )
76
+ #{attribute_definitions}
77
+ provisioned_throughput(
78
+ read_capacity_units: #{table[:provisioned_throughput][:read_capacity_units]},
79
+ write_capacity_units: #{table[:provisioned_throughput][:write_capacity_units]},
80
+ )
81
+ #{local_secondary_indexes}#{global_secondary_indexes}
82
+ end
83
+ EOS
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,31 @@
1
+ module Dyna
2
+ class DSL
3
+ class DynamoDB
4
+ include Dyna::TemplateHelper
5
+
6
+ attr_reader :result
7
+
8
+ def initialize(context, tables, &block)
9
+ @context = context
10
+ @result = OpenStruct.new({
11
+ :tables => tables,
12
+ })
13
+
14
+ instance_eval(&block)
15
+ end
16
+
17
+ private
18
+ def table(name, &block)
19
+ if table_names.include?(name)
20
+ raise "Table `#{name}` is already defined"
21
+ end
22
+
23
+ @result.tables << Table.new(@context, name, &block).result
24
+ end
25
+
26
+ def table_names
27
+ @result.tables.map(&:table_name)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,112 @@
1
+ module Dyna
2
+ class DSL
3
+ class DynamoDB
4
+ class Table
5
+ include Dyna::TemplateHelper
6
+ attr_reader :result
7
+
8
+ def initialize(context, table_name, &block)
9
+ @table_name = table_name
10
+ @context = context
11
+
12
+ @result = Hashie::Mash.new({
13
+ :table_name => table_name,
14
+ })
15
+ instance_eval(&block)
16
+ end
17
+
18
+ def key_schema(hash:, range: nil)
19
+ @result.key_schema = [{
20
+ attribute_name: hash,
21
+ key_type: 'HASH',
22
+ }]
23
+
24
+ if range
25
+ @result.key_schema << {
26
+ attribute_name: range,
27
+ key_type: 'RANGE',
28
+ }
29
+ end
30
+ end
31
+
32
+ def attribute_definition(attribute_name:, attribute_type:)
33
+ @result.attribute_definitions ||= []
34
+ @result.attribute_definitions << {
35
+ attribute_name: attribute_name,
36
+ attribute_type: attribute_type,
37
+ }
38
+ end
39
+
40
+ def provisioned_throughput(read_capacity_units:, write_capacity_units:)
41
+ @result.provisioned_throughput = {
42
+ read_capacity_units: read_capacity_units,
43
+ write_capacity_units: write_capacity_units,
44
+ }
45
+ end
46
+
47
+ def stream_specification(stream_enabled:, stream_view_type: nil)
48
+ @result.stream_specification = {
49
+ stream_enabled: stream_enabled,
50
+ stream_view_type: stream_view_type,
51
+ }
52
+ end
53
+
54
+ def local_secondary_index(index_name, &block)
55
+ @result.local_secondary_indexes ||= []
56
+ index = LocalSecondaryIndex.new
57
+ index.instance_eval(&block)
58
+ @result.local_secondary_indexes << {
59
+ index_name: index_name,
60
+ }.merge(index.result.symbolize_keys)
61
+ end
62
+
63
+ def global_secondary_index(index_name, &block)
64
+ @result.global_secondary_indexes ||= []
65
+ index = GlobalSecondaryIndex.new
66
+ index.instance_eval(&block)
67
+ @result.global_secondary_indexes << {
68
+ index_name: index_name,
69
+ }.merge(index.result.symbolize_keys)
70
+ end
71
+
72
+ class LocalSecondaryIndex
73
+ attr_accessor :result
74
+
75
+ def initialize
76
+ @result = Hashie::Mash.new
77
+ end
78
+
79
+ def key_schema(hash:, range: nil)
80
+ @result.key_schema = [{
81
+ attribute_name: hash,
82
+ key_type: 'HASH',
83
+ }]
84
+
85
+ if range
86
+ @result.key_schema << {
87
+ attribute_name: range,
88
+ key_type: 'RANGE',
89
+ }
90
+ end
91
+ end
92
+
93
+ def projection(projection_type:, non_key_attributes: nil)
94
+ @result.projection = {
95
+ projection_type: projection_type,
96
+ non_key_attributes: non_key_attributes,
97
+ }
98
+ end
99
+ end
100
+
101
+ class GlobalSecondaryIndex < LocalSecondaryIndex
102
+ def provisioned_throughput(read_capacity_units:, write_capacity_units:)
103
+ @result.provisioned_throughput = {
104
+ read_capacity_units: read_capacity_units,
105
+ write_capacity_units: write_capacity_units,
106
+ }
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end