heroku-schemas 0.0.1

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 (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