heroku-schemas 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +20 -0
- data/README.md +94 -0
- data/Rakefile +2 -0
- data/features/drop.feature +9 -0
- data/features/migration.feature +18 -0
- data/features/show.feature +9 -0
- data/features/step_definitions/common_steps.rb +8 -0
- data/features/step_definitions/drop_steps.rb +12 -0
- data/features/step_definitions/migration_steps.rb +57 -0
- data/features/step_definitions/show_steps.rb +12 -0
- data/features/support/config.example.yml +2 -0
- data/features/support/env.rb +42 -0
- data/features/support/lib/configuration.rb +23 -0
- data/features/support/lib/heroku-schemas/test/app.rb +109 -0
- data/features/support/lib/output_buffer.rb +26 -0
- data/heroku-schemas.gemspec +29 -0
- data/init.rb +1 -0
- data/lib/heroku-schemas.rb +19 -0
- data/lib/heroku-schemas/current_database.rb +9 -0
- data/lib/heroku-schemas/database.rb +63 -0
- data/lib/heroku-schemas/drop.rb +22 -0
- data/lib/heroku-schemas/migration.rb +109 -0
- data/lib/heroku-schemas/pgbackups.rb +18 -0
- data/lib/heroku-schemas/schema_command.rb +12 -0
- data/lib/heroku-schemas/schema_reference.rb +88 -0
- data/lib/heroku-schemas/schema_utilities.rb +45 -0
- data/lib/heroku-schemas/show.rb +15 -0
- data/lib/heroku-schemas/target_database.rb +9 -0
- data/lib/heroku-schemas/version.rb +3 -0
- data/lib/heroku/command/schemas.rb +28 -0
- data/spec/dummy-app/.gitignore +17 -0
- data/spec/dummy-app/Gemfile +37 -0
- data/spec/dummy-app/README.rdoc +261 -0
- data/spec/dummy-app/Rakefile +7 -0
- data/spec/dummy-app/app/assets/javascripts/application.js +15 -0
- data/spec/dummy-app/app/assets/stylesheets/application.css +13 -0
- data/spec/dummy-app/app/controllers/application_controller.rb +3 -0
- data/spec/dummy-app/app/helpers/application_helper.rb +2 -0
- data/spec/dummy-app/app/mailers/.gitkeep +0 -0
- data/spec/dummy-app/app/models/.gitkeep +0 -0
- data/spec/dummy-app/app/models/dummy_record.rb +3 -0
- data/spec/dummy-app/app/views/layouts/application.html.erb +14 -0
- data/spec/dummy-app/config.ru +4 -0
- data/spec/dummy-app/config/application.rb +65 -0
- data/spec/dummy-app/config/boot.rb +6 -0
- data/spec/dummy-app/config/environment.rb +5 -0
- data/spec/dummy-app/config/environments/development.rb +37 -0
- data/spec/dummy-app/config/environments/production.rb +67 -0
- data/spec/dummy-app/config/environments/test.rb +37 -0
- data/spec/dummy-app/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/dummy-app/config/initializers/inflections.rb +15 -0
- data/spec/dummy-app/config/initializers/mime_types.rb +5 -0
- data/spec/dummy-app/config/initializers/secret_token.rb +7 -0
- data/spec/dummy-app/config/initializers/session_store.rb +8 -0
- data/spec/dummy-app/config/initializers/wrap_parameters.rb +14 -0
- data/spec/dummy-app/config/locales/en.yml +5 -0
- data/spec/dummy-app/config/routes.rb +58 -0
- data/spec/dummy-app/db/migrate/20131106080427_create_dummy_records.rb +9 -0
- data/spec/dummy-app/db/schema.rb +22 -0
- data/spec/dummy-app/db/seeds.rb +7 -0
- data/spec/dummy-app/doc/README_FOR_APP +2 -0
- data/spec/dummy-app/lib/assets/.gitkeep +0 -0
- data/spec/dummy-app/lib/tasks/.gitkeep +0 -0
- data/spec/dummy-app/log/.gitkeep +0 -0
- data/spec/dummy-app/script/rails +6 -0
- data/spec/dummy-app/test/fixtures/.gitkeep +0 -0
- data/spec/dummy-app/test/fixtures/dummy_records.yml +7 -0
- data/spec/dummy-app/test/functional/.gitkeep +0 -0
- data/spec/dummy-app/test/integration/.gitkeep +0 -0
- data/spec/dummy-app/test/performance/browsing_test.rb +12 -0
- data/spec/dummy-app/test/test_helper.rb +13 -0
- data/spec/dummy-app/test/unit/.gitkeep +0 -0
- data/spec/dummy-app/test/unit/dummy_record_test.rb +7 -0
- data/spec/dummy-app/vendor/assets/javascripts/.gitkeep +0 -0
- data/spec/dummy-app/vendor/assets/stylesheets/.gitkeep +0 -0
- data/spec/dummy-app/vendor/plugins/.gitkeep +0 -0
- data/spec/heroku-schemas/schema_utilities_spec.rb +46 -0
- data/spec/spec_helper.rb +8 -0
- metadata +313 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2013 Tom Benner
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
Heroku Schemas
|
2
|
+
==============
|
3
|
+
Run many apps on a single database
|
4
|
+
|
5
|
+
Description
|
6
|
+
-----------
|
7
|
+
|
8
|
+
Heroku Schemas lets you run multiple Heroku apps on top of a single Heroku Postgres database. A Postgres database can have multiple "schemas" (basically Postgres's word for database "namespaces"), and Heroku Schemas simply makes each app use its own schema within a single, shared database.
|
9
|
+
|
10
|
+
For example, if you have five apps with small levels of traffic, instead of using five databases, you can now just use one database with five schemas to serve all of them.
|
11
|
+
|
12
|
+
Installation
|
13
|
+
------------
|
14
|
+
|
15
|
+
Install the plugin:
|
16
|
+
|
17
|
+
```sh
|
18
|
+
heroku plugins:install git://github.com/tombenner/heroku-schemas.git
|
19
|
+
```
|
20
|
+
|
21
|
+
Usage
|
22
|
+
-----
|
23
|
+
|
24
|
+
To make an app use a schema named `my_schema` in the database of an app called `my-other-app`:
|
25
|
+
|
26
|
+
```sh
|
27
|
+
cd path/to/my-app
|
28
|
+
heroku schemas:use my-other-app:my_schema
|
29
|
+
```
|
30
|
+
|
31
|
+
This copies the app's database into the new schema and makes the app use it. You can then remove the original database from your plan.
|
32
|
+
|
33
|
+
Heroku Schemas also lets you see what database/schema the current app is using (`show`) and drop schemas (`drop`).
|
34
|
+
|
35
|
+
Commands
|
36
|
+
--------
|
37
|
+
|
38
|
+
### Use
|
39
|
+
|
40
|
+
Make the app in the current directory use a new database/schema. If the app has an existing database, it is copied to the target database/schema.
|
41
|
+
|
42
|
+
The following command makes my-app use the schema `my_schema` in the default database of my-other-app:
|
43
|
+
|
44
|
+
```sh
|
45
|
+
heroku schemas:use my-other-app:my_schema
|
46
|
+
```
|
47
|
+
|
48
|
+
If my-other-app has more than one database, you can specify which database the schema should be in:
|
49
|
+
```sh
|
50
|
+
heroku schemas:use my-other-app:HEROKU_POSTGRESQL_BLUE_URL:my_schema
|
51
|
+
```
|
52
|
+
|
53
|
+
("`BLUE`"" in `HEROKU_POSTGRESQL_BLUE_URL` should be replaced with the color name in the database's name.)
|
54
|
+
|
55
|
+
### Show
|
56
|
+
|
57
|
+
Show which database/schema is currently being used by the app.
|
58
|
+
|
59
|
+
```sh
|
60
|
+
heroku schemas:show
|
61
|
+
=> my-other-app:HEROKU_POSTGRESQL_BLUE_URL:my_schema
|
62
|
+
```
|
63
|
+
|
64
|
+
### Drop
|
65
|
+
|
66
|
+
Drop (delete) the schema that is currently being used by the app. This is irreversible, so please be sure that you're dropping the intended schema.
|
67
|
+
|
68
|
+
```sh
|
69
|
+
heroku schemas:drop
|
70
|
+
=> Dropped schema my-other-app:HEROKU_POSTGRESQL_BLUE_URL:my_schema
|
71
|
+
```
|
72
|
+
|
73
|
+
Tests
|
74
|
+
-----
|
75
|
+
|
76
|
+
The feature tests create and manipulate two Heroku apps; to run them, you'll need to:
|
77
|
+
|
78
|
+
```sh
|
79
|
+
cp features/support/config.example.yml features/support/config.yml
|
80
|
+
```
|
81
|
+
|
82
|
+
And then edit config.yml to include your Heroku API key and a prefix for the app names (choose something unique to avoid naming conflicts with other people who are running these tests).
|
83
|
+
|
84
|
+
Notes
|
85
|
+
-----
|
86
|
+
|
87
|
+
A shared database may not be wise for significant, production apps, but it may be worthwhile if you have multiple small apps or apps that are in development.
|
88
|
+
|
89
|
+
Heroku Schemas is for educational purposes. The author assumes no liability for anything that happens to your data or your Heroku account while using Heroku Schemas.
|
90
|
+
|
91
|
+
License
|
92
|
+
-------
|
93
|
+
|
94
|
+
Heroku Schemas is released under the MIT License. Please see the MIT-LICENSE file for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
Feature: Migration
|
2
|
+
In order to move an app's database
|
3
|
+
A developer
|
4
|
+
Should be able to run a migration
|
5
|
+
|
6
|
+
Scenario: App has not been migrated
|
7
|
+
Given two apps named 'current-app' and 'target-app'
|
8
|
+
And I create a backup with the target schema
|
9
|
+
And I create the target schema
|
10
|
+
And I update the database URL
|
11
|
+
And I import the backup into the target schema
|
12
|
+
Then the first app should be using the database of the second app
|
13
|
+
|
14
|
+
Scenario: App has been migrated
|
15
|
+
Given two apps named 'current-app' and 'target-app'
|
16
|
+
And I add the current app's schema to the target app's database
|
17
|
+
And I run the migration
|
18
|
+
Then an error containing "already contains data" is raised
|
@@ -0,0 +1,9 @@
|
|
1
|
+
Feature: Show
|
2
|
+
In order to see what schema an app is using
|
3
|
+
A developer
|
4
|
+
Should be able to run a command
|
5
|
+
|
6
|
+
Scenario: App has been initialized
|
7
|
+
Given an app named 'current-app'
|
8
|
+
And I run the show command
|
9
|
+
Then the output should match 'current-app:[A-Z_]+:public'
|
@@ -0,0 +1,8 @@
|
|
1
|
+
Given /^an app named '([\w_-]+)'$/ do |app|
|
2
|
+
@configuration = Configuration
|
3
|
+
@heroku = Heroku::API.new(:api_key => @configuration['heroku_api_key'])
|
4
|
+
|
5
|
+
prefix = @configuration['heroku_app_prefix']
|
6
|
+
app_name = "#{prefix}#{app}"
|
7
|
+
@app = HerokuSchemas::Test::App.new(app_name, HerokuSchemas::CurrentDatabase)
|
8
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
And /^I drop the schema named '(.+)'$/ do |schema|
|
2
|
+
@schema = schema
|
3
|
+
@drop = HerokuSchemas::Drop.new(
|
4
|
+
context_app: @app.name,
|
5
|
+
string_reference: "#{@app.name}:#{@schema}"
|
6
|
+
)
|
7
|
+
@drop.perform
|
8
|
+
end
|
9
|
+
|
10
|
+
Then /^no schemas should exist$/ do
|
11
|
+
@drop.database.existing_schemas.should be_empty
|
12
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
Given /^two apps named '([\w_-]+)' and '([\w_-]+)'$/ do |current_app, target_app|
|
2
|
+
@configuration = Configuration
|
3
|
+
@heroku = Heroku::API.new(:api_key => @configuration['heroku_api_key'])
|
4
|
+
|
5
|
+
prefix = @configuration['heroku_app_prefix']
|
6
|
+
current_app_name = "#{prefix}#{current_app}"
|
7
|
+
target_app_name = "#{prefix}#{target_app}"
|
8
|
+
|
9
|
+
@current_app = HerokuSchemas::Test::App.new(current_app_name, HerokuSchemas::CurrentDatabase)
|
10
|
+
@target_app = HerokuSchemas::Test::App.new(target_app_name, HerokuSchemas::TargetDatabase)
|
11
|
+
|
12
|
+
@current_schema = 'public'
|
13
|
+
@target_schema = HerokuSchemas::SchemaUtilities.app_to_schema(current_app_name)
|
14
|
+
@migration = HerokuSchemas::Migration.new(
|
15
|
+
context_app: current_app_name,
|
16
|
+
string_reference: "#{target_app_name}:#{@target_schema}"
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
And /^I add the current app's schema to the target app's database$/ do
|
21
|
+
@target_app.add_data_to_schema(@target_schema)
|
22
|
+
end
|
23
|
+
|
24
|
+
And /^I create a backup with the target schema$/ do
|
25
|
+
@migration.create_backup_with_target_schema
|
26
|
+
@migration.backup_url.should =~ %r|^https://.+\.dump.+$|
|
27
|
+
end
|
28
|
+
|
29
|
+
And /^I update the database URL$/ do
|
30
|
+
@migration.update_database_url
|
31
|
+
end
|
32
|
+
|
33
|
+
And /^I create the target schema$/ do
|
34
|
+
@migration.create_target_schema_in_target_database
|
35
|
+
@migration.target_database.existing_schemas.should =~ ['public', @target_schema]
|
36
|
+
end
|
37
|
+
|
38
|
+
And /^I import the backup into the target schema$/ do
|
39
|
+
@migration.import_backup_into_target_database
|
40
|
+
end
|
41
|
+
|
42
|
+
And /^I run the migration$/ do
|
43
|
+
begin
|
44
|
+
@migration.perform
|
45
|
+
rescue Exception => @error
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
Then /an error containing "(.+)" is raised/ do |message_excerpt|
|
50
|
+
@error.should_not be_nil
|
51
|
+
@error.message.should include(message_excerpt)
|
52
|
+
end
|
53
|
+
|
54
|
+
Then /^the first app should be using the database of the second app$/ do
|
55
|
+
@migration.target_database.schema_tables(@target_schema).should =~ ['dummy_records']
|
56
|
+
@migration.target_database.select_values("SELECT dummy_records.name FROM #{@target_schema}.dummy_records").should == [@current_app.name]
|
57
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
And /^I run the show command$/ do
|
2
|
+
@show = HerokuSchemas::Show.new(
|
3
|
+
context_app: @app.name
|
4
|
+
)
|
5
|
+
@buffer = OutputBuffer.new.activate
|
6
|
+
@show.perform
|
7
|
+
@buffer.stop
|
8
|
+
end
|
9
|
+
|
10
|
+
Then /^the output should match '(.+)'$/ do |regex|
|
11
|
+
@buffer.to_s.should match(/#{regex}/)
|
12
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
$:.unshift(File.dirname(__FILE__) + '/../../lib')
|
2
|
+
require 'rubygems'
|
3
|
+
require 'bundler/setup'
|
4
|
+
require 'rspec/expectations'
|
5
|
+
require 'aruba/cucumber'
|
6
|
+
|
7
|
+
require 'heroku-schemas'
|
8
|
+
support_path = File.expand_path('../', __FILE__)
|
9
|
+
require support_path + '/lib/configuration'
|
10
|
+
require support_path + '/lib/heroku-schemas/test/app'
|
11
|
+
|
12
|
+
Before do
|
13
|
+
@aruba_timeout_seconds = 120 # A long time needed some times
|
14
|
+
unset_bundler_env_vars
|
15
|
+
end
|
16
|
+
|
17
|
+
if(ENV['ARUBA_REPORT_DIR'])
|
18
|
+
# Override reporting behaviour so we don't document all files, only the ones
|
19
|
+
# that have been created after @aruba_report_start (a Time object). This is
|
20
|
+
# given a value after the Rails app is generated (see cucumber_rails_steps.rb)
|
21
|
+
module Aruba
|
22
|
+
module Reporting
|
23
|
+
def children(dir)
|
24
|
+
children = Dir["#{dir}/*"].sort
|
25
|
+
|
26
|
+
# include
|
27
|
+
children = children.select do |child|
|
28
|
+
File.directory?(child) ||
|
29
|
+
(@aruba_report_start && File.stat(child).mtime > @aruba_report_start)
|
30
|
+
end
|
31
|
+
|
32
|
+
# exclude
|
33
|
+
children = children.reject do |child|
|
34
|
+
child =~ /Gemfile/ ||
|
35
|
+
child =~ /\.log$/
|
36
|
+
end
|
37
|
+
|
38
|
+
children
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
class Configuration
|
4
|
+
class << self
|
5
|
+
def load
|
6
|
+
@@config = nil
|
7
|
+
io = File.open(File.dirname(__FILE__) + '/../config.yml')
|
8
|
+
YAML::load_documents(io) { |doc| @@config = doc }
|
9
|
+
raise 'Could not locate a configuration named "config.yml"' unless @@config
|
10
|
+
end
|
11
|
+
|
12
|
+
def [] key
|
13
|
+
@@config[key]
|
14
|
+
end
|
15
|
+
|
16
|
+
def []= key, value
|
17
|
+
@@config[key] = value
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
Configuration.load
|
23
|
+
@configuration = Configuration
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# Allows for manipulations of a Heroku app and its database in a testing environment
|
2
|
+
module HerokuSchemas
|
3
|
+
module Test
|
4
|
+
class App
|
5
|
+
attr_reader :database, :name
|
6
|
+
|
7
|
+
def initialize(name, database)
|
8
|
+
@name = name
|
9
|
+
@database = database
|
10
|
+
@heroku = Heroku::API.new(:api_key => Configuration['heroku_api_key'])
|
11
|
+
@dummy_app_path = File.expand_path('../../../../../spec/dummy-app', __FILE__)
|
12
|
+
initialize_app
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize_app
|
16
|
+
if app_exists?
|
17
|
+
puts "Resetting app #{name}..."
|
18
|
+
reset_app
|
19
|
+
else
|
20
|
+
puts "Creating app #{name}..."
|
21
|
+
create_app
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def create_app
|
26
|
+
begin
|
27
|
+
@heroku.post_app('name' => name)
|
28
|
+
rescue Heroku::API::Errors::RequestFailed
|
29
|
+
end
|
30
|
+
|
31
|
+
begin
|
32
|
+
@heroku.post_addon(name, 'pgbackups:plus')
|
33
|
+
rescue Heroku::API::Errors::RequestFailed
|
34
|
+
end
|
35
|
+
|
36
|
+
set_git_remote
|
37
|
+
Dir.chdir @dummy_app_path do
|
38
|
+
system "git push heroku master"
|
39
|
+
end
|
40
|
+
add_data_to_schema
|
41
|
+
end
|
42
|
+
|
43
|
+
def set_git_remote
|
44
|
+
Dir.chdir @dummy_app_path do
|
45
|
+
system "git remote set-url heroku git@heroku.com:#{name}.git"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def add_data_to_schema(schema=nil)
|
50
|
+
if schema
|
51
|
+
original_schema_search_path = database.connection.schema_search_path
|
52
|
+
database.connection.schema_search_path = schema
|
53
|
+
database.execute("CREATE SCHEMA #{schema}")
|
54
|
+
end
|
55
|
+
app_database = database
|
56
|
+
ActiveRecord::Schema.define do
|
57
|
+
@connection = app_database.connection
|
58
|
+
create_table "dummy_records", :force => true do |t|
|
59
|
+
t.string "name"
|
60
|
+
t.datetime "created_at", :null => false
|
61
|
+
t.datetime "updated_at", :null => false
|
62
|
+
end
|
63
|
+
end
|
64
|
+
database.execute("INSERT INTO dummy_records (name, created_at, updated_at) VALUES ('#{name}', NOW(), NOW())")
|
65
|
+
if schema
|
66
|
+
database.connection.schema_search_path = original_schema_search_path
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def app_exists?
|
71
|
+
begin
|
72
|
+
app = @heroku.get_app(name)
|
73
|
+
rescue Heroku::API::Errors::NotFound
|
74
|
+
return false
|
75
|
+
end
|
76
|
+
app
|
77
|
+
end
|
78
|
+
|
79
|
+
def reset_app
|
80
|
+
database_url = reset_app_database_url
|
81
|
+
database.connect_to_url(database_url)
|
82
|
+
database.existing_schemas.each do |schema|
|
83
|
+
database.execute("DROP SCHEMA #{schema} CASCADE")
|
84
|
+
end
|
85
|
+
database.execute('CREATE SCHEMA public')
|
86
|
+
add_data_to_schema
|
87
|
+
end
|
88
|
+
|
89
|
+
# Reset DATABASE_URL to the value in the first HEROKU_POSTGRESQL_$COLOR_URL-style config variable
|
90
|
+
def reset_app_database_url
|
91
|
+
@heroku.get_config_vars(name).body.each do |key, value|
|
92
|
+
if key != 'DATABASE_URL' && key.end_with?('_URL') && value.start_with?('postgres://')
|
93
|
+
@heroku.put_config_vars(name, 'DATABASE_URL' => value)
|
94
|
+
return value
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def delete_app
|
100
|
+
begin
|
101
|
+
app = @heroku.delete_app(name)
|
102
|
+
rescue Heroku::API::Errors::NotFound
|
103
|
+
return false
|
104
|
+
end
|
105
|
+
app
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|