heroku-schemas 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (81) hide show
  1. data/.gitignore +4 -0
  2. data/Gemfile +3 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +94 -0
  5. data/Rakefile +2 -0
  6. data/features/drop.feature +9 -0
  7. data/features/migration.feature +18 -0
  8. data/features/show.feature +9 -0
  9. data/features/step_definitions/common_steps.rb +8 -0
  10. data/features/step_definitions/drop_steps.rb +12 -0
  11. data/features/step_definitions/migration_steps.rb +57 -0
  12. data/features/step_definitions/show_steps.rb +12 -0
  13. data/features/support/config.example.yml +2 -0
  14. data/features/support/env.rb +42 -0
  15. data/features/support/lib/configuration.rb +23 -0
  16. data/features/support/lib/heroku-schemas/test/app.rb +109 -0
  17. data/features/support/lib/output_buffer.rb +26 -0
  18. data/heroku-schemas.gemspec +29 -0
  19. data/init.rb +1 -0
  20. data/lib/heroku-schemas.rb +19 -0
  21. data/lib/heroku-schemas/current_database.rb +9 -0
  22. data/lib/heroku-schemas/database.rb +63 -0
  23. data/lib/heroku-schemas/drop.rb +22 -0
  24. data/lib/heroku-schemas/migration.rb +109 -0
  25. data/lib/heroku-schemas/pgbackups.rb +18 -0
  26. data/lib/heroku-schemas/schema_command.rb +12 -0
  27. data/lib/heroku-schemas/schema_reference.rb +88 -0
  28. data/lib/heroku-schemas/schema_utilities.rb +45 -0
  29. data/lib/heroku-schemas/show.rb +15 -0
  30. data/lib/heroku-schemas/target_database.rb +9 -0
  31. data/lib/heroku-schemas/version.rb +3 -0
  32. data/lib/heroku/command/schemas.rb +28 -0
  33. data/spec/dummy-app/.gitignore +17 -0
  34. data/spec/dummy-app/Gemfile +37 -0
  35. data/spec/dummy-app/README.rdoc +261 -0
  36. data/spec/dummy-app/Rakefile +7 -0
  37. data/spec/dummy-app/app/assets/javascripts/application.js +15 -0
  38. data/spec/dummy-app/app/assets/stylesheets/application.css +13 -0
  39. data/spec/dummy-app/app/controllers/application_controller.rb +3 -0
  40. data/spec/dummy-app/app/helpers/application_helper.rb +2 -0
  41. data/spec/dummy-app/app/mailers/.gitkeep +0 -0
  42. data/spec/dummy-app/app/models/.gitkeep +0 -0
  43. data/spec/dummy-app/app/models/dummy_record.rb +3 -0
  44. data/spec/dummy-app/app/views/layouts/application.html.erb +14 -0
  45. data/spec/dummy-app/config.ru +4 -0
  46. data/spec/dummy-app/config/application.rb +65 -0
  47. data/spec/dummy-app/config/boot.rb +6 -0
  48. data/spec/dummy-app/config/environment.rb +5 -0
  49. data/spec/dummy-app/config/environments/development.rb +37 -0
  50. data/spec/dummy-app/config/environments/production.rb +67 -0
  51. data/spec/dummy-app/config/environments/test.rb +37 -0
  52. data/spec/dummy-app/config/initializers/backtrace_silencers.rb +7 -0
  53. data/spec/dummy-app/config/initializers/inflections.rb +15 -0
  54. data/spec/dummy-app/config/initializers/mime_types.rb +5 -0
  55. data/spec/dummy-app/config/initializers/secret_token.rb +7 -0
  56. data/spec/dummy-app/config/initializers/session_store.rb +8 -0
  57. data/spec/dummy-app/config/initializers/wrap_parameters.rb +14 -0
  58. data/spec/dummy-app/config/locales/en.yml +5 -0
  59. data/spec/dummy-app/config/routes.rb +58 -0
  60. data/spec/dummy-app/db/migrate/20131106080427_create_dummy_records.rb +9 -0
  61. data/spec/dummy-app/db/schema.rb +22 -0
  62. data/spec/dummy-app/db/seeds.rb +7 -0
  63. data/spec/dummy-app/doc/README_FOR_APP +2 -0
  64. data/spec/dummy-app/lib/assets/.gitkeep +0 -0
  65. data/spec/dummy-app/lib/tasks/.gitkeep +0 -0
  66. data/spec/dummy-app/log/.gitkeep +0 -0
  67. data/spec/dummy-app/script/rails +6 -0
  68. data/spec/dummy-app/test/fixtures/.gitkeep +0 -0
  69. data/spec/dummy-app/test/fixtures/dummy_records.yml +7 -0
  70. data/spec/dummy-app/test/functional/.gitkeep +0 -0
  71. data/spec/dummy-app/test/integration/.gitkeep +0 -0
  72. data/spec/dummy-app/test/performance/browsing_test.rb +12 -0
  73. data/spec/dummy-app/test/test_helper.rb +13 -0
  74. data/spec/dummy-app/test/unit/.gitkeep +0 -0
  75. data/spec/dummy-app/test/unit/dummy_record_test.rb +7 -0
  76. data/spec/dummy-app/vendor/assets/javascripts/.gitkeep +0 -0
  77. data/spec/dummy-app/vendor/assets/stylesheets/.gitkeep +0 -0
  78. data/spec/dummy-app/vendor/plugins/.gitkeep +0 -0
  79. data/spec/heroku-schemas/schema_utilities_spec.rb +46 -0
  80. data/spec/spec_helper.rb +8 -0
  81. metadata +313 -0
@@ -0,0 +1,26 @@
1
+ require 'stringio'
2
+
3
+ class OutputBuffer
4
+ def initialize
5
+ @buffer = StringIO.new
6
+ activate
7
+ end
8
+
9
+ def activate
10
+ $stdout = @buffer
11
+ self
12
+ end
13
+
14
+ def to_s
15
+ @buffer.rewind
16
+ @buffer.read
17
+ end
18
+
19
+ def stop
20
+ OutputBuffer::restore_default
21
+ end
22
+
23
+ def self.restore_default
24
+ $stdout = STDOUT
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path('../lib', __FILE__)
3
+ require 'heroku-schemas/version'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'heroku-schemas'
7
+ s.version = HerokuSchemas::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ['Tom Benner']
10
+ s.email = ['tombenner@gmail.com']
11
+ s.homepage = 'https://github.com/tombenner/heroku-schemas'
12
+ s.summary = s.description = %q{Run many apps on a single database.}
13
+
14
+ s.rubyforge_project = 'heroku-schemas'
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
+ s.require_paths = ['lib']
19
+
20
+ s.add_dependency 'heroku', '~> 3.0.0'
21
+ s.add_dependency 'heroku-api'
22
+ s.add_dependency 'pg'
23
+ s.add_dependency 'activerecord', '3.2.0'
24
+
25
+ s.add_development_dependency 'rake'
26
+ s.add_development_dependency 'rspec'
27
+ s.add_development_dependency 'cucumber'
28
+ s.add_development_dependency 'aruba'
29
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'heroku-schemas'
@@ -0,0 +1,19 @@
1
+ require 'active_record'
2
+ require 'active_support/core_ext'
3
+ require 'heroku-api'
4
+
5
+ require 'heroku-schemas/database'
6
+ require 'heroku-schemas/current_database'
7
+ require 'heroku-schemas/target_database'
8
+
9
+ require 'heroku-schemas/schema_command'
10
+ require 'heroku-schemas/drop'
11
+ require 'heroku-schemas/migration'
12
+ require 'heroku-schemas/show'
13
+
14
+ require 'heroku-schemas/pgbackups'
15
+ require 'heroku-schemas/schema_reference'
16
+ require 'heroku-schemas/schema_utilities'
17
+ require 'heroku-schemas/version'
18
+ require 'heroku/command/base'
19
+ require 'heroku/command/schemas'
@@ -0,0 +1,9 @@
1
+ module HerokuSchemas
2
+ class CurrentDatabase < HerokuSchemas::Database
3
+ class << self
4
+ def connection_name
5
+ 'current'
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,63 @@
1
+ module HerokuSchemas
2
+ class Database < ActiveRecord::Base
3
+ class << self
4
+ def connect_to_url(url)
5
+ ActiveRecord::Base.configurations[connection_name] = url_to_config(url)
6
+ establish_connection(connection_name)
7
+ self
8
+ end
9
+
10
+ def url_to_config(url)
11
+ db = URI.parse(url)
12
+ {
13
+ :adapter => 'postgresql',
14
+ :username => db.user,
15
+ :password => db.password,
16
+ :port => db.port,
17
+ :database => db.path.sub(%r(^/), ''),
18
+ :host => db.host
19
+ }
20
+ end
21
+
22
+ def config_to_url(config)
23
+ "postgres://#{config[:username]}:#{config[:password]}@#{config[:host]}:#{config[:port]}/#{config[:database]}"
24
+ end
25
+
26
+ def url
27
+ config_to_url(connection.instance_variable_get(:@config))
28
+ end
29
+
30
+ def execute(sql)
31
+ connection.execute(sql)
32
+ end
33
+
34
+ def select_values(sql)
35
+ connection.select_values(sql)
36
+ end
37
+
38
+ def schema_exists?(schema)
39
+ existing_schemas.include?(schema)
40
+ end
41
+
42
+ def tables_exist?(schema)
43
+ schema_tables(schema).present?
44
+ end
45
+
46
+ def schema_tables(schema)
47
+ select_values("SELECT table_name FROM information_schema.tables WHERE table_schema = '#{schema}'")
48
+ end
49
+
50
+ def existing_schemas
51
+ select_values('SELECT schema_name FROM information_schema.schemata')
52
+ end
53
+
54
+ def create_schema(schema)
55
+ execute("CREATE SCHEMA #{schema}")
56
+ end
57
+
58
+ def rename_schema(from_schema, to_schema)
59
+ execute("ALTER SCHEMA #{from_schema} RENAME TO #{to_schema}")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,22 @@
1
+ module HerokuSchemas
2
+ class Drop < SchemaCommand
3
+ attr_reader :database
4
+
5
+ def initialize(options)
6
+ super(options)
7
+
8
+ @string_reference = options[:string_reference]
9
+ raise 'Context app not provided' if @context_app.blank?
10
+ raise 'Schema reference not provided' if @string_reference.blank?
11
+
12
+ related_apps = [@context_app, @string_reference.split(':').first].uniq
13
+ @schema = HerokuSchemas::SchemaReference.new(heroku: @heroku, string_reference: @context_app, related_apps: related_apps)
14
+ @database = HerokuSchemas::CurrentDatabase.connect_to_url(@schema.database_url)
15
+ end
16
+
17
+ def perform
18
+ @database.execute("DROP SCHEMA #{@schema.schema} CASCADE")
19
+ write "Dropped schema #{@schema.database_app}:#{@schema.database_variable}:#{@schema.schema}"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,109 @@
1
+ module HerokuSchemas
2
+ class Migration < SchemaCommand
3
+ attr_reader :current_schema, :current_database
4
+ attr_reader :target_schema, :target_database
5
+ attr_reader :backup_url, :context_app
6
+
7
+ def initialize(options)
8
+ super(options)
9
+ @pgbackups = HerokuSchemas::Pgbackups.new
10
+
11
+ @string_reference = options[:string_reference]
12
+ raise 'Context app not provided' if @context_app.blank?
13
+ raise 'Schema reference not provided' if @string_reference.blank?
14
+
15
+ related_apps = [@context_app, @string_reference.split(':').first].uniq
16
+ @current_schema = HerokuSchemas::SchemaReference.new(heroku: @heroku, string_reference: @context_app, related_apps: related_apps)
17
+ @target_schema = HerokuSchemas::SchemaReference.new(heroku: @heroku, string_reference: @string_reference, related_apps: related_apps)
18
+
19
+ @current_database = HerokuSchemas::CurrentDatabase.connect_to_url(@current_schema.database_url)
20
+ @target_database = HerokuSchemas::TargetDatabase.connect_to_url(@target_schema.database_url)
21
+ end
22
+
23
+ def perform
24
+ validate_migration
25
+ if current_schema_has_data?
26
+ migrate_database
27
+ else
28
+ update_database_url
29
+ end
30
+ end
31
+
32
+ def validate_migration
33
+ if current_schema == target_schema
34
+ raise "App (#{context_app}) is already using the target schema (#{target_schema.base_database_url}, #{target_schema.schema})"
35
+ end
36
+ if target_schema_has_data?
37
+ command = "heroku schemas:drop #{target_schema.database_app}:#{target_schema.database_variable}:#{target_schema.schema}"
38
+ raise "Target schema (#{target_schema.base_database_url}, #{target_schema.schema}) already contains data. Please drop it before proceding by running:\n`#{command}`"
39
+ end
40
+ end
41
+
42
+ def current_schema_has_data?
43
+ current_database.existing_schemas.include?(current_schema.schema) && current_database.tables_exist?(current_schema.schema)
44
+ end
45
+
46
+ def target_schema_has_data?
47
+ target_database.existing_schemas.include?(target_schema.schema) && target_database.tables_exist?(target_schema.schema)
48
+ end
49
+
50
+ def is_migrated?
51
+ target_database.existing_schemas.include?(target_schema.schema)
52
+ end
53
+
54
+ def migrate_database
55
+ create_backup_with_target_schema
56
+ update_database_url
57
+ create_target_schema_in_target_database
58
+ import_backup_into_target_database
59
+ end
60
+
61
+ def create_backup_with_target_schema
62
+ write 'Creating database backup...'
63
+ current_database.rename_schema(current_schema.schema, target_schema.schema)
64
+ @backup_url = create_backup(current_schema.database_app, current_schema.database_variable)
65
+ current_database.rename_schema(target_schema.schema, current_schema.schema)
66
+ end
67
+
68
+ def create_target_schema_in_target_database
69
+ target_database.create_schema(target_schema.schema)
70
+ end
71
+
72
+ def import_backup_into_target_database
73
+ write 'Restoring database backup...'
74
+ restore_from_backup_url
75
+ end
76
+
77
+ # Point DATABASE_URL to the target app's DATABASE_URL, but with the new schema_search_path
78
+ def update_database_url
79
+ target_database_url = SchemaUtilities.add_schema_search_path_to_url(target_schema.database_url, target_schema.schema)
80
+ set_app_database_url(context_app, target_database_url)
81
+ end
82
+
83
+ def create_backup(app, database_variable)
84
+ args = [database_variable, '--app', app, '--expire'].compact
85
+ # args = ['HEROKU_POSTGRESQL_CYAN_URL', '--app', 'tom-target-app', '--expire'].compact
86
+
87
+ # @pgbackups.app = app
88
+ # @pgbackups.capture(database_variable, expire: true)
89
+ Heroku::Command.run('pgbackups:capture', args)
90
+
91
+ @pgbackups.app = app
92
+ @pgbackups.latest_backup_url
93
+ end
94
+
95
+ def restore_from_backup_url
96
+ args = [target_schema.database_variable, backup_url, '--app', target_schema.database_app, '--confirm', target_schema.database_app]
97
+ Heroku::Command.run('pgbackups:restore', args)
98
+ end
99
+
100
+ def app_database_url(app)
101
+ vars = @heroku.get_config_vars(app).body
102
+ vars['DATABASE_URL']
103
+ end
104
+
105
+ def set_app_database_url(app, url)
106
+ @heroku.put_config_vars(app, 'DATABASE_URL' => url)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,18 @@
1
+ require 'heroku/command/pgbackups'
2
+
3
+ module HerokuSchemas
4
+ class Pgbackups < Heroku::Command::Pgbackups
5
+ attr_accessor :app, :args
6
+
7
+ def validate_arguments!
8
+ end
9
+
10
+ def latest_backup
11
+ pgbackup_client.get_latest_backup
12
+ end
13
+
14
+ def latest_backup_url
15
+ latest_backup['public_url']
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ module HerokuSchemas
2
+ class SchemaCommand
3
+ def initialize(options)
4
+ @heroku = Heroku::API.new(:api_key => ENV['HEROKU_API_KEY'])
5
+ @context_app = options[:context_app]
6
+ end
7
+
8
+ def write(string)
9
+ puts string
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,88 @@
1
+ module HerokuSchemas
2
+ class SchemaReference
3
+ attr_reader :app, :database_app, :database_variable, :base_database_url, :database_url, :schema
4
+
5
+ # String references to schemas can take the following forms:
6
+ # my_app
7
+ # my_app:my_schema
8
+ # my_app:MY_DATABASE_URL:my_schema
9
+
10
+ def initialize(options)
11
+ defaults = {
12
+ heroku: nil,
13
+ string_reference: nil,
14
+ related_apps: []
15
+ }
16
+ options.reverse_merge!(defaults)
17
+ @heroku = options[:heroku]
18
+ @string_reference = options[:string_reference]
19
+ @related_apps = options[:related_apps]
20
+ @database_variable = nil
21
+ @schema = nil
22
+
23
+ raise 'String reference not provided' if @string_reference.blank?
24
+ configure
25
+ end
26
+
27
+ def configure
28
+ refs = @string_reference.split(':')
29
+ raise "Invalid schema reference: #{@string_reference}" unless [1, 2, 3].include?(refs.length)
30
+ if refs.length == 1
31
+ @app = refs.first
32
+ elsif refs.length == 2
33
+ @app, @schema = refs
34
+ else
35
+ @app, @database_variable, @schema = refs
36
+ end
37
+
38
+ app_variables = app_config_variables(@app)
39
+ @database_variable ||= 'DATABASE_URL'
40
+ @database_url = app_variables[@database_variable]
41
+ @schema ||= SchemaUtilities.url_to_schema(@database_url)
42
+ raise "Database URL not found for database variable #{@database_variable}" if @database_url.blank?
43
+
44
+ validation_message = SchemaUtilities.validate_schema(@schema)
45
+ raise validation_message if validation_message != true
46
+
47
+ @database_url = @base_database_url = get_base_database_url(@database_url)
48
+ @database_url = "#{@database_url}?schema_search_path=#{@schema}" unless @schema == 'public'
49
+
50
+ if @database_variable == 'DATABASE_URL'
51
+ @related_apps.each do |app|
52
+ config_variables = app_config_variables(app)
53
+ database_variable = find_database_variable(config_variables, @base_database_url)
54
+ if database_variable
55
+ @database_variable = database_variable
56
+ @database_app = app
57
+ break
58
+ end
59
+ end
60
+ end
61
+ raise "Database variable not found" if @database_variable.blank?
62
+ end
63
+
64
+ def find_database_variable(config_variables, database_url)
65
+ standardized_database_url = get_base_database_url(database_url)
66
+ config_variables.each do |name, value|
67
+ next if name == 'DATABASE_URL'
68
+ return name if get_base_database_url(value) == standardized_database_url
69
+ end
70
+ nil
71
+ end
72
+
73
+ def app_config_variables(app)
74
+ @heroku.get_config_vars(app).body
75
+ end
76
+
77
+ def get_base_database_url(url)
78
+ return nil if url.nil?
79
+ url.split('?').first
80
+ end
81
+
82
+ def ==(schema_reference)
83
+ return true if self.base_database_url == schema_reference.base_database_url &&
84
+ self.schema == schema_reference.schema
85
+ false
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,45 @@
1
+ module HerokuSchemas
2
+ class SchemaUtilities
3
+ class << self
4
+ def add_schema_search_path_to_url(url, schema)
5
+ uri = URI(url)
6
+ params = uri_to_params(uri)
7
+
8
+ # Handle the case of poorly-formed query strings where CGI::parse turns 'pool=20?pool=20'
9
+ # into {'pool' => ["20?pool=20"]}
10
+ params.each do |key, value|
11
+ if value.is_a?(Array) && value.length == 1
12
+ params[key] = value.first.split('?').first
13
+ end
14
+ end
15
+
16
+ params['schema_search_path'] = schema
17
+ uri.query = params.to_query
18
+ uri.to_s
19
+ end
20
+
21
+ def app_to_schema(app)
22
+ app.downcase.gsub(/[^\w_]/, '_')
23
+ end
24
+
25
+ def url_to_schema(url)
26
+ uri = URI(url)
27
+ params = uri_to_params(uri)
28
+ schema = params['schema_search_path'] || 'public'
29
+ schema = schema.first if schema.is_a?(Array)
30
+ schema
31
+ end
32
+
33
+ def uri_to_params(uri)
34
+ uri.query ? CGI::parse(uri.query) : {}
35
+ end
36
+
37
+ def validate_schema(schema)
38
+ if schema.blank? || schema =~ /[^a-z0-9_]/
39
+ return "Schema must only contain lowercase letters, numbers and underscores. Provided value: #{schema}"
40
+ end
41
+ true
42
+ end
43
+ end
44
+ end
45
+ end