schema-evolution-manager 0.9.24
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +334 -0
- data/bin/sem-add +46 -0
- data/bin/sem-apply +41 -0
- data/bin/sem-baseline +34 -0
- data/bin/sem-config +1 -0
- data/bin/sem-dist +92 -0
- data/bin/sem-info +43 -0
- data/bin/sem-init +95 -0
- data/lib/schema-evolution-manager.rb +26 -0
- data/lib/schema-evolution-manager/apply_util.rb +60 -0
- data/lib/schema-evolution-manager/args.rb +159 -0
- data/lib/schema-evolution-manager/ask.rb +44 -0
- data/lib/schema-evolution-manager/baseline_util.rb +50 -0
- data/lib/schema-evolution-manager/db.rb +104 -0
- data/lib/schema-evolution-manager/install_template.rb +118 -0
- data/lib/schema-evolution-manager/library.rb +176 -0
- data/lib/schema-evolution-manager/migration_file.rb +93 -0
- data/lib/schema-evolution-manager/preconditions.rb +61 -0
- data/lib/schema-evolution-manager/rdoc_usage.rb +37 -0
- data/lib/schema-evolution-manager/script_error.rb +19 -0
- data/lib/schema-evolution-manager/scripts.rb +95 -0
- data/lib/schema-evolution-manager/sem_info.rb +72 -0
- data/lib/schema-evolution-manager/sem_version.rb +9 -0
- data/lib/schema-evolution-manager/template.rb +39 -0
- data/lib/schema-evolution-manager/version.rb +71 -0
- data/scripts/20130318-105434.sql +64 -0
- data/scripts/20130318-105456.sql +135 -0
- data/template/README.md +28 -0
- data/template/dev.rb +12 -0
- metadata +80 -0
@@ -0,0 +1,93 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
|
3
|
+
# Represents a single file to make a schema migration, typically
|
4
|
+
# stored in the scripts subdirectory.
|
5
|
+
class MigrationFile
|
6
|
+
|
7
|
+
class Attribute
|
8
|
+
|
9
|
+
attr_reader :name, :valid_values
|
10
|
+
|
11
|
+
def initialize(name, valid_values)
|
12
|
+
@name = Preconditions.check_not_blank(name, "name cannot be blank")
|
13
|
+
@valid_values = Preconditions.assert_class(valid_values, Array)
|
14
|
+
end
|
15
|
+
|
16
|
+
unless defined?(ATTRIBUTES)
|
17
|
+
ATTRIBUTES = [Attribute.new("transaction", ["single", "none"])]
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
class AttributeValue
|
23
|
+
|
24
|
+
attr_reader :attribute, :value
|
25
|
+
|
26
|
+
def initialize(attribute_name, value)
|
27
|
+
Preconditions.assert_class(attribute_name, String)
|
28
|
+
@value = Preconditions.assert_class(value, String)
|
29
|
+
|
30
|
+
@attribute = Attribute::ATTRIBUTES.find { |a| a.name == attribute_name }
|
31
|
+
Preconditions.check_not_null(@attribute,
|
32
|
+
"Attribute with name[%s] not found. Must be one of: %s" %
|
33
|
+
[attribute_name,
|
34
|
+
Attribute::ATTRIBUTES.map { |a| a.name }.join(" ")])
|
35
|
+
|
36
|
+
Preconditions.check_state(@attribute.valid_values.include?(@value),
|
37
|
+
"Attribute[%s] - Invalid value[%s]. Must be one of: %s" %
|
38
|
+
[@attribute.name, @value, @attribute.valid_values.join(" ")])
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
|
43
|
+
unless defined?(DEFAULTS)
|
44
|
+
DEFAULTS = [AttributeValue.new("transaction", "single")]
|
45
|
+
end
|
46
|
+
|
47
|
+
attr_reader :path, :attribute_values
|
48
|
+
|
49
|
+
def initialize(path)
|
50
|
+
@path = path
|
51
|
+
Preconditions.check_state(File.exists?(@path), "File[#{@path}] does not exist")
|
52
|
+
@attribute_values = parse_attribute_values
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
# Returns a list of AttributeValues from the file itself,
|
58
|
+
# including all defaults set by SEM. AttributeValues are defined
|
59
|
+
# in comments in the file.
|
60
|
+
def parse_attribute_values
|
61
|
+
values = []
|
62
|
+
each_property do |name, value|
|
63
|
+
values << AttributeValue.new(name, value)
|
64
|
+
end
|
65
|
+
|
66
|
+
DEFAULTS.each do |default|
|
67
|
+
if values.find { |v| v.attribute.name == default.attribute.name }.nil?
|
68
|
+
values << default
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
values
|
73
|
+
end
|
74
|
+
|
75
|
+
# Parse properties from the comments. Looks for this pattern:
|
76
|
+
#
|
77
|
+
# -- sem.attribute.name = value
|
78
|
+
#
|
79
|
+
# and yields each matching row with |name, value|
|
80
|
+
def each_property
|
81
|
+
IO.readlines(path).each do |l|
|
82
|
+
stripped = l.strip
|
83
|
+
if stripped.match(/^\-\-\s+sem\.attribute\./)
|
84
|
+
stripped.sub!(/^\-\-\s+sem\.attribute\./, '')
|
85
|
+
name, value = stripped.split(/\=/, 2).map(&:strip)
|
86
|
+
yield name, value
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
|
93
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
# Modeled after http://docs.guava-libraries.googlecode.com/git/javadoc/com/google/common/base/Preconditions.html
|
3
|
+
class Preconditions
|
4
|
+
|
5
|
+
def Preconditions.check_argument(expression, error_message=nil)
|
6
|
+
if !expression
|
7
|
+
raise error_message || "check_argument failed"
|
8
|
+
end
|
9
|
+
nil
|
10
|
+
end
|
11
|
+
|
12
|
+
def Preconditions.check_state(expression, error_message=nil)
|
13
|
+
if !expression
|
14
|
+
raise error_message || "check_state failed"
|
15
|
+
end
|
16
|
+
nil
|
17
|
+
end
|
18
|
+
|
19
|
+
def Preconditions.check_not_null(reference, error_message=nil)
|
20
|
+
if reference.nil?
|
21
|
+
raise error_message || "argument cannot be nil"
|
22
|
+
end
|
23
|
+
reference
|
24
|
+
end
|
25
|
+
|
26
|
+
def Preconditions.check_not_blank(reference, error_message=nil)
|
27
|
+
if reference.to_s.strip == ""
|
28
|
+
raise error_message || "argument cannot be blank"
|
29
|
+
end
|
30
|
+
reference
|
31
|
+
end
|
32
|
+
|
33
|
+
# Throws an error if opts is not empty. Useful when parsing
|
34
|
+
# arguments to a function
|
35
|
+
def Preconditions.assert_empty_opts(opts)
|
36
|
+
if !opts.empty?
|
37
|
+
raise "Invalid opts: #{opts.keys.inspect}\n#{opts.inspect}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Asserts that value is not nill and is_?(klass). Returns
|
42
|
+
# value. Common use is
|
43
|
+
#
|
44
|
+
# amount = Preconditions.assert_class(amount, BigDecimal)
|
45
|
+
def Preconditions.assert_class(value, klass)
|
46
|
+
Preconditions.check_not_null(value, "Value cannot be nil")
|
47
|
+
Preconditions.check_not_null(klass, "Klass cannot be nil")
|
48
|
+
Preconditions.check_state(value.is_a?(klass),
|
49
|
+
"Value is of type[#{value.class}] - class[#{klass}] is required. value[#{value.inspect.to_s}]")
|
50
|
+
value
|
51
|
+
end
|
52
|
+
|
53
|
+
def Preconditions.assert_class_or_nil(value, klass)
|
54
|
+
if !value.nil?
|
55
|
+
Preconditions.assert_class(value, klass)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
# Adapter for what we need to remove dependency on RDoc which was removed in ruby 1.9
|
3
|
+
class RdocUsage
|
4
|
+
|
5
|
+
def RdocUsage.message
|
6
|
+
path = Library.normalize_path(RdocUsage.program_name)
|
7
|
+
|
8
|
+
lines = []
|
9
|
+
|
10
|
+
IO.readlines(path).each do |line|
|
11
|
+
if !line.match(/^\#/)
|
12
|
+
break
|
13
|
+
end
|
14
|
+
if line.match(/^\#\!/)
|
15
|
+
next
|
16
|
+
end
|
17
|
+
lines << line.sub(/^\#\s?/, '').sub(/\n$/, '')
|
18
|
+
end
|
19
|
+
lines << ""
|
20
|
+
|
21
|
+
lines.join("\n")
|
22
|
+
end
|
23
|
+
|
24
|
+
def RdocUsage.printAndExit(exit_code=0)
|
25
|
+
puts RdocUsage.message
|
26
|
+
exit(exit_code)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
def RdocUsage.program_name
|
31
|
+
$0
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
|
3
|
+
class ScriptError < Exception
|
4
|
+
|
5
|
+
attr_reader :filename
|
6
|
+
|
7
|
+
def initialize(db, filename)
|
8
|
+
@db = Preconditions.assert_class(db, Db)
|
9
|
+
@filename = Preconditions.assert_class(filename, String)
|
10
|
+
end
|
11
|
+
|
12
|
+
def dml
|
13
|
+
sql_command = "insert into %s.%s (filename) values ('%s')" % [Db.schema_name, Scripts::SCRIPTS, filename]
|
14
|
+
"psql --command \"%s\" %s" % [sql_command, @db.url]
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
|
3
|
+
class Scripts
|
4
|
+
|
5
|
+
if !defined?(SCRIPTS)
|
6
|
+
SCRIPTS = "scripts"
|
7
|
+
BOOTSTRAP_SCRIPTS = "bootstrap_scripts"
|
8
|
+
VALID_TABLE_NAMES = [BOOTSTRAP_SCRIPTS, SCRIPTS]
|
9
|
+
end
|
10
|
+
|
11
|
+
# @param db Instance of Db class
|
12
|
+
# @param table_name Name of the table used to record which scripts
|
13
|
+
# we have processed. Will be one of 'scripts' or 'bootstrap_scripts'
|
14
|
+
def initialize(db, table_name)
|
15
|
+
@db = Preconditions.assert_class(db, Db)
|
16
|
+
@table_name = Preconditions.assert_class(table_name, String)
|
17
|
+
Preconditions.check_state(VALID_TABLE_NAMES.include?(@table_name),
|
18
|
+
"Invalid table name[%s]. Must be one of: %s" % [@table_name, VALID_TABLE_NAMES.join(", ")])
|
19
|
+
end
|
20
|
+
|
21
|
+
# Returns a sorted list of the full file paths to any sql scripts in
|
22
|
+
# the specified directory
|
23
|
+
def Scripts.all(dir)
|
24
|
+
Preconditions.assert_class(dir, String)
|
25
|
+
|
26
|
+
if File.directory?(dir)
|
27
|
+
Dir.glob("#{dir}/*.sql").sort
|
28
|
+
else
|
29
|
+
[]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# For each sql script that needs to be applied to this database,
|
34
|
+
# yields a pair of |filename, fullpath| in proper order
|
35
|
+
#
|
36
|
+
# db = Db.new(host, user, name)
|
37
|
+
# scripts = Scripts.new(db)
|
38
|
+
# scripts.each_pending do |filename, path|
|
39
|
+
# puts filename
|
40
|
+
# end
|
41
|
+
def each_pending(dir)
|
42
|
+
files = {}
|
43
|
+
Scripts.all(dir).each do |path|
|
44
|
+
name = File.basename(path)
|
45
|
+
files[name] = path
|
46
|
+
end
|
47
|
+
|
48
|
+
scripts_previously_run(files.keys).each do |filename|
|
49
|
+
files.delete(filename)
|
50
|
+
end
|
51
|
+
|
52
|
+
files.keys.sort.each do |filename|
|
53
|
+
## We have to recheck if this script is still pending. Some
|
54
|
+
## upgrade scripts may modify the scripts table themselves. This
|
55
|
+
## is actually useful in cases like when we migrated gilt from
|
56
|
+
## util_schema => schema_evolution_manager schema
|
57
|
+
if !has_run?(filename)
|
58
|
+
yield filename, files[filename]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# True if this script has already been applied to the db. False
|
64
|
+
# otherwise.
|
65
|
+
def has_run?(filename)
|
66
|
+
if @db.schema_schema_evolution_manager_exists?
|
67
|
+
query = "select count(*) from %s.%s where filename = '%s'" % [Db.schema_name, @table_name, filename]
|
68
|
+
@db.psql_command(query).to_i > 0
|
69
|
+
else
|
70
|
+
false
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Inserts a record to indiciate that we have loaded the specified file.
|
75
|
+
def record_as_run!(filename)
|
76
|
+
Preconditions.check_state(filename.match(/^\d\d\d\d\d\d+\-\d\d\d\d\d\d\.sql$/),
|
77
|
+
"Invalid filename[#{filename}]. Must be like: 20120503-173242.sql")
|
78
|
+
command = "insert into %s.%s (filename) select '%s' where not exists (select 1 from %s.%s where filename = '%s')" % [Db.schema_name, @table_name, filename, Db.schema_name, @table_name, filename]
|
79
|
+
@db.psql_command(command)
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
# Fetch the list of scripts that have already been applied to this
|
84
|
+
# database.
|
85
|
+
def scripts_previously_run(scripts)
|
86
|
+
if scripts.empty? || !@db.schema_schema_evolution_manager_exists?
|
87
|
+
[]
|
88
|
+
else
|
89
|
+
sql = "select filename from %s.%s where filename in (%s)" % [Db.schema_name, @table_name, "'" + scripts.join("', '") + "'"]
|
90
|
+
@db.psql_command(sql).strip.split
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
|
3
|
+
module SemInfo
|
4
|
+
|
5
|
+
def SemInfo.version(args=nil)
|
6
|
+
SchemaEvolutionManager::SemVersion::VERSION.dup
|
7
|
+
end
|
8
|
+
|
9
|
+
def SemInfo.tag(args)
|
10
|
+
valid = ['exists', 'latest', 'next']
|
11
|
+
|
12
|
+
subcommand = args.shift.to_s.strip
|
13
|
+
|
14
|
+
if subcommand == "exists"
|
15
|
+
tag = args.shift.to_s.strip
|
16
|
+
if tag.empty?
|
17
|
+
puts "ERROR: Missing tag."
|
18
|
+
exit(3)
|
19
|
+
elsif ::SchemaEvolutionManager::Library.tag_exists?(tag)
|
20
|
+
puts "true"
|
21
|
+
else
|
22
|
+
puts "false"
|
23
|
+
end
|
24
|
+
|
25
|
+
elsif subcommand == "latest"
|
26
|
+
if latest = ::SchemaEvolutionManager::SemInfo::Tag.latest
|
27
|
+
latest.to_version_string
|
28
|
+
else
|
29
|
+
nil
|
30
|
+
end
|
31
|
+
|
32
|
+
elsif subcommand == "next"
|
33
|
+
::SchemaEvolutionManager::SemInfo::Tag.next(args).to_version_string
|
34
|
+
|
35
|
+
elsif subcommand.empty?
|
36
|
+
puts "ERROR: Missing tag subcommand. Must be one of: %s" % valid.join(", ")
|
37
|
+
exit(3)
|
38
|
+
|
39
|
+
else
|
40
|
+
puts "ERROR: Invalid tag subcommand[%s]. Must be one of: %s" % [subcommand, valid.join(", ")]
|
41
|
+
exit(4)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
module Tag
|
46
|
+
|
47
|
+
def Tag.latest
|
48
|
+
Library.latest_tag || Version.new("0.0.0")
|
49
|
+
end
|
50
|
+
|
51
|
+
# @param component: One of major|minor|micro. Defaults to micro. Currently passed in as an array
|
52
|
+
def Tag.next(args=nil)
|
53
|
+
component = (args || []).first
|
54
|
+
valid = ['micro', 'minor', 'major']
|
55
|
+
|
56
|
+
if component.to_s.empty?
|
57
|
+
component = "micro"
|
58
|
+
end
|
59
|
+
|
60
|
+
if valid.include?(component)
|
61
|
+
latest.send("next_%s" % component)
|
62
|
+
else
|
63
|
+
puts "ERROR: Invalid component[%s]. Must be one of: %s" % [component, valid.join(", ")]
|
64
|
+
exit(4)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
|
3
|
+
# Simple template parsing using regular expressions to substitute values.
|
4
|
+
# See unit test for example usage at test/specs/lib/template_spec.rb
|
5
|
+
class Template
|
6
|
+
|
7
|
+
class Substitution
|
8
|
+
|
9
|
+
attr_reader :pattern, :value
|
10
|
+
|
11
|
+
def initialize(pattern, value)
|
12
|
+
@pattern = Preconditions.check_not_blank(pattern)
|
13
|
+
@value = Preconditions.check_not_blank(value)
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
@subs = []
|
20
|
+
end
|
21
|
+
|
22
|
+
# add('first', 'Mike')
|
23
|
+
# Will replace all instances of '%%first%%' with value when you call parse
|
24
|
+
def add(pattern, value)
|
25
|
+
@subs << Substitution.new(pattern, value)
|
26
|
+
end
|
27
|
+
|
28
|
+
def parse(contents)
|
29
|
+
Preconditions.check_not_blank(contents)
|
30
|
+
string = contents.dup
|
31
|
+
@subs.each do |sub|
|
32
|
+
string = string.gsub(/%%#{sub.pattern}%%/, sub.value)
|
33
|
+
end
|
34
|
+
string
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module SchemaEvolutionManager
|
2
|
+
|
3
|
+
class Version
|
4
|
+
|
5
|
+
VERSION_FILE = File.join(Library.base_dir, "VERSION") unless defined?(VERSION_FILE)
|
6
|
+
|
7
|
+
attr_reader :major, :minor, :micro
|
8
|
+
|
9
|
+
def initialize(version_string)
|
10
|
+
Preconditions.check_not_blank(version_string, "version_string cannot be blank")
|
11
|
+
Library.assert_valid_tag(version_string)
|
12
|
+
pieces = version_string.split(".", 3)
|
13
|
+
@major = pieces[0].to_i
|
14
|
+
@minor = pieces[1].to_i
|
15
|
+
@micro = pieces[2].to_i
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_version_string
|
19
|
+
"%s.%s.%s" % [major, minor, micro]
|
20
|
+
end
|
21
|
+
|
22
|
+
# Returns the next major version
|
23
|
+
def next_major
|
24
|
+
Version.new("%s.%s.%s" % [major+1, 0, 0])
|
25
|
+
end
|
26
|
+
|
27
|
+
# Returns the next minor version
|
28
|
+
def next_minor
|
29
|
+
Version.new("%s.%s.%s" % [major, minor+1, 0])
|
30
|
+
end
|
31
|
+
|
32
|
+
# Returns the next micro version
|
33
|
+
def next_micro
|
34
|
+
Version.new("%s.%s.%s" % [major, minor, micro+1])
|
35
|
+
end
|
36
|
+
|
37
|
+
def <=>(other)
|
38
|
+
Preconditions.assert_class(other, Version)
|
39
|
+
value = major <=> other.major
|
40
|
+
if value == 0
|
41
|
+
value = minor <=> other.minor
|
42
|
+
if value == 0
|
43
|
+
value = micro <=> other.micro
|
44
|
+
end
|
45
|
+
end
|
46
|
+
value
|
47
|
+
end
|
48
|
+
|
49
|
+
# Reads the current version (from the VERSION FILE), returning an
|
50
|
+
# instance of the Version class
|
51
|
+
def Version.read
|
52
|
+
Preconditions.check_state(File.exists?(VERSION_FILE), "Version file at path[%s] not found" % VERSION_FILE)
|
53
|
+
version = IO.read(VERSION_FILE).strip
|
54
|
+
Version.new(version)
|
55
|
+
end
|
56
|
+
|
57
|
+
def Version.write(version)
|
58
|
+
Preconditions.assert_class(version, Version)
|
59
|
+
File.open(VERSION_FILE, "w") do |out|
|
60
|
+
out << "%s\n" % version.to_version_string
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def Version.is_valid?(version_string)
|
65
|
+
Preconditions.check_not_blank(version_string, "version_string cannot be blank")
|
66
|
+
version_string.match(/^\d+\.\d+\.\d+$/)
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
|
71
|
+
end
|