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.
- 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
@@ -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,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,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
|