schema-evolution-manager 0.9.58 → 0.9.61
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.
- checksums.yaml +4 -4
- data/README.md +48 -3
- data/bin/sem-add +5 -4
- data/bin/sem-apply +3 -2
- data/bin/sem-baseline +3 -2
- data/bin/sem-dist +13 -10
- data/bin/sem-init +37 -15
- data/lib/schema-evolution-manager/args.rb +20 -7
- data/lib/schema-evolution-manager/ask.rb +1 -1
- data/lib/schema-evolution-manager/config.rb +90 -0
- data/lib/schema-evolution-manager/connection_data.rb +71 -18
- data/lib/schema-evolution-manager/db.rb +55 -60
- data/lib/schema-evolution-manager/install_template.rb +16 -13
- data/lib/schema-evolution-manager/library.rb +78 -24
- data/lib/schema-evolution-manager/migration_file.rb +3 -5
- data/lib/schema-evolution-manager/scripts.rb +19 -6
- data/lib/schema-evolution-manager/sem_info.rb +36 -10
- data/lib/schema-evolution-manager/sem_version.rb +1 -1
- data/lib/schema-evolution-manager/template.rb +3 -1
- data/lib/schema-evolution-manager.rb +4 -1
- data/template/dev.rb +3 -3
- metadata +4 -3
|
@@ -2,20 +2,34 @@ module SchemaEvolutionManager
|
|
|
2
2
|
|
|
3
3
|
class Db
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
# psql_args: the psql executable and its global options, as an argv
|
|
6
|
+
attr_reader :url, :psql_args
|
|
7
|
+
|
|
8
|
+
# Options every psql invocation carries. --no-psqlrc keeps the applying
|
|
9
|
+
# user's ~/.psqlrc (and the system psqlrc) out of every command and
|
|
10
|
+
# migration; --no-password makes a missing credential fail rather than
|
|
11
|
+
# prompt, so an unattended apply can never hang.
|
|
12
|
+
PSQL_ISOLATION_OPTIONS = ["--no-psqlrc", "--no-password"]
|
|
13
|
+
|
|
14
|
+
# A password embedded in the url (postgres://user:pass@host/db) is moved
|
|
15
|
+
# into a private pgpass file and @url keeps only the password-free form,
|
|
16
|
+
# so the password never reaches a psql argv, a log line or an error.
|
|
17
|
+
#
|
|
18
|
+
# @param password: Optional password; takes precedence over one in the url
|
|
7
19
|
def initialize(url, opts={})
|
|
8
|
-
|
|
20
|
+
Preconditions.check_not_blank(url, "url cannot be blank")
|
|
9
21
|
password = opts.delete(:password)
|
|
10
22
|
|
|
11
|
-
@
|
|
23
|
+
@psql_args = ["psql"]
|
|
12
24
|
(opts.delete(:set) || []).each do |arg|
|
|
13
|
-
@
|
|
25
|
+
@psql_args += ["--set", arg]
|
|
14
26
|
end
|
|
15
27
|
|
|
16
28
|
Preconditions.assert_empty_opts(opts)
|
|
17
|
-
connection_data = ConnectionData.parse_url(
|
|
29
|
+
connection_data = ConnectionData.parse_url(url)
|
|
30
|
+
@url = ConnectionData.strip_password(url)
|
|
18
31
|
|
|
32
|
+
password ||= connection_data.password
|
|
19
33
|
if password
|
|
20
34
|
ENV['PGPASSFILE'] = Db.password_to_tempfile(connection_data.pgpass(password))
|
|
21
35
|
end
|
|
@@ -34,10 +48,8 @@ module SchemaEvolutionManager
|
|
|
34
48
|
# executes a simple sql command.
|
|
35
49
|
def psql_command(sql_command)
|
|
36
50
|
Preconditions.assert_class(sql_command, String)
|
|
37
|
-
|
|
38
|
-
command
|
|
39
|
-
command_to_log = template % [sql_command, sanitized_url]
|
|
40
|
-
Library.system_or_error(command, command_to_log)
|
|
51
|
+
command = @psql_args + PSQL_ISOLATION_OPTIONS + ["--no-align", "--tuples-only", "--command", sql_command]
|
|
52
|
+
Library.system_or_error(command + [@url], :log => command + [sanitized_url])
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
def Db.attribute_values(path)
|
|
@@ -66,32 +78,28 @@ module SchemaEvolutionManager
|
|
|
66
78
|
options
|
|
67
79
|
end
|
|
68
80
|
|
|
69
|
-
# executes sql commands from a file in a single transaction
|
|
81
|
+
# executes sql commands from a file in a single transaction.
|
|
82
|
+
# psql reads the original file, so the line numbers and path in
|
|
83
|
+
# any psql error match the file in git.
|
|
70
84
|
def psql_file(filename, path)
|
|
71
85
|
Preconditions.assert_class(path, String)
|
|
72
86
|
Preconditions.check_state(File.exist?(path), "File[%s] not found" % path)
|
|
73
87
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
Library.with_temp_file(:prefix => File.basename(path)) do |tmp|
|
|
77
|
-
File.open(tmp, "w") do |out|
|
|
78
|
-
out << "\\set ON_ERROR_STOP true\n\n"
|
|
79
|
-
out << IO.read(path)
|
|
80
|
-
end
|
|
88
|
+
command = psql_file_command(path)
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
result = `#{command} > #{output} 2>&1`.strip
|
|
86
|
-
status = $?
|
|
87
|
-
if status.to_i > 0
|
|
88
|
-
errors = File.exist?(output) ? IO.read(output) : result
|
|
89
|
-
raise ScriptError.new(self, filename, path, errors)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
90
|
+
output, status = Open3.capture2e(*command)
|
|
91
|
+
if !status.success?
|
|
92
|
+
raise ScriptError.new(self, filename, path, output)
|
|
92
93
|
end
|
|
93
94
|
end
|
|
94
95
|
|
|
96
|
+
# The psql argv psql_file runs. ON_ERROR_STOP is set on the
|
|
97
|
+
# command line, after any --set the caller passed, so it is in
|
|
98
|
+
# effect before the first line of the file is read.
|
|
99
|
+
def psql_file_command(path)
|
|
100
|
+
@psql_args + PSQL_ISOLATION_OPTIONS + ["--set", "ON_ERROR_STOP=1", "--file", path] + Db.attribute_values(path) + [@url]
|
|
101
|
+
end
|
|
102
|
+
|
|
95
103
|
# True if the specific schema exists; false otherwise
|
|
96
104
|
def schema_schema_evolution_manager_exists?
|
|
97
105
|
sql = "select count(*) from pg_namespace where nspname='%s'" % Db.schema_name
|
|
@@ -122,47 +130,34 @@ module SchemaEvolutionManager
|
|
|
122
130
|
end
|
|
123
131
|
end
|
|
124
132
|
|
|
133
|
+
# Returns value as a SQL string literal, doubling any single quote.
|
|
134
|
+
def Db.quote_literal(value)
|
|
135
|
+
Preconditions.assert_class(value, String)
|
|
136
|
+
"'" + value.gsub("'", "''") + "'"
|
|
137
|
+
end
|
|
138
|
+
|
|
125
139
|
# Returns the name of the schema_evolution_manager schema
|
|
126
140
|
def Db.schema_name
|
|
127
141
|
"schema_evolution_manager"
|
|
128
142
|
end
|
|
129
143
|
|
|
144
|
+
# Writes the pgpass contents to a file in Library::TMPFILE_DIR,
|
|
145
|
+
# returning its path. The file is a plain file rather than a Tempfile so
|
|
146
|
+
# that no finalizer can unlink it while psql still needs it; the
|
|
147
|
+
# TMPFILE_DIR at_exit hook removes it. libpq ignores a pgpass file that
|
|
148
|
+
# is group or world readable, so it is created mode 0600.
|
|
130
149
|
def Db.password_to_tempfile(contents)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
150
|
+
path = File.join(Library::TMPFILE_DIR, "pgpass.%s" % SecureRandom.hex(8))
|
|
151
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0600) do |out|
|
|
152
|
+
out.write(contents)
|
|
153
|
+
end
|
|
154
|
+
path
|
|
135
155
|
end
|
|
136
156
|
|
|
137
|
-
#
|
|
138
|
-
#
|
|
157
|
+
# The url for display. @url never carries a password (see initialize),
|
|
158
|
+
# so this is @url itself; kept for callers that print the connection.
|
|
139
159
|
def sanitized_url
|
|
140
|
-
|
|
141
|
-
if @url.include?("://")
|
|
142
|
-
protocol, rest = @url.split("://", 2)
|
|
143
|
-
lead, name = rest.split("/", 2)
|
|
144
|
-
|
|
145
|
-
# Check if there's a username:password@ pattern
|
|
146
|
-
if lead.include?("@")
|
|
147
|
-
# Take the last element as host_part to handle passwords with @ symbols
|
|
148
|
-
host_part = lead.split("@").last
|
|
149
|
-
# Take everything before the last @ as user_part
|
|
150
|
-
user_part = lead.split("@")[0..-2].join("@")
|
|
151
|
-
|
|
152
|
-
if user_part.include?(":")
|
|
153
|
-
# Remove password, keep only username (everything before the first colon)
|
|
154
|
-
username = user_part.split(":", 2)[0]
|
|
155
|
-
sanitized_lead = "#{username}:[REDACTED]@#{host_part}"
|
|
156
|
-
else
|
|
157
|
-
sanitized_lead = lead
|
|
158
|
-
end
|
|
159
|
-
"#{protocol}://#{sanitized_lead}/#{name}"
|
|
160
|
-
else
|
|
161
|
-
@url
|
|
162
|
-
end
|
|
163
|
-
else
|
|
164
|
-
@url
|
|
165
|
-
end
|
|
160
|
+
@url
|
|
166
161
|
end
|
|
167
162
|
|
|
168
163
|
end
|
|
@@ -12,8 +12,10 @@ module SchemaEvolutionManager
|
|
|
12
12
|
def generate
|
|
13
13
|
template = Template.new
|
|
14
14
|
template.add('timestamp', Time.now.to_s)
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
# Inserted as Ruby string literals: the generated script runs under
|
|
16
|
+
# sudo, so a quote in the prefix must not end the literal
|
|
17
|
+
template.add('lib_dir', @lib_dir.inspect)
|
|
18
|
+
template.add('bin_dir', @bin_dir.inspect)
|
|
17
19
|
template.parse(TEMPLATE)
|
|
18
20
|
end
|
|
19
21
|
|
|
@@ -22,7 +24,7 @@ module SchemaEvolutionManager
|
|
|
22
24
|
File.open(path, "w") do |out|
|
|
23
25
|
out << generate
|
|
24
26
|
end
|
|
25
|
-
Library.system_or_error("chmod +x
|
|
27
|
+
Library.system_or_error(["chmod", "--", "+x", path])
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
if !defined?(TEMPLATE)
|
|
@@ -35,8 +37,8 @@ module SchemaEvolutionManager
|
|
|
35
37
|
load File.join(File.dirname(__FILE__), 'lib/schema-evolution-manager.rb')
|
|
36
38
|
SchemaEvolutionManager::Library.set_verbose(true)
|
|
37
39
|
|
|
38
|
-
lib_dir =
|
|
39
|
-
bin_dir =
|
|
40
|
+
lib_dir = %%lib_dir%%
|
|
41
|
+
bin_dir = %%bin_dir%%
|
|
40
42
|
version = SchemaEvolutionManager::Version.read
|
|
41
43
|
|
|
42
44
|
version_name = "schema-evolution-manager-%s" % version.to_version_string
|
|
@@ -49,18 +51,18 @@ SchemaEvolutionManager::Library.ensure_dir!(bin_dir)
|
|
|
49
51
|
Dir.chdir(lib_dir) do
|
|
50
52
|
if File.exist?("schema-evolution-manager")
|
|
51
53
|
if File.symlink?("schema-evolution-manager")
|
|
52
|
-
SchemaEvolutionManager::Library.system_or_error("rm schema-evolution-manager")
|
|
53
|
-
SchemaEvolutionManager::Library.system_or_error("ln -s
|
|
54
|
+
SchemaEvolutionManager::Library.system_or_error(["rm", "schema-evolution-manager"])
|
|
55
|
+
SchemaEvolutionManager::Library.system_or_error(["ln", "-s", version_name, "schema-evolution-manager"])
|
|
54
56
|
else
|
|
55
57
|
puts "*** WARNING: File[%s] already exists. Not creating symlink" % File.join(lib_dir, "schema-evolution-manager")
|
|
56
58
|
end
|
|
57
59
|
else
|
|
58
|
-
SchemaEvolutionManager::Library.system_or_error("ln -s
|
|
60
|
+
SchemaEvolutionManager::Library.system_or_error(["ln", "-s", version_name, "schema-evolution-manager"])
|
|
59
61
|
end
|
|
60
62
|
end
|
|
61
63
|
|
|
62
64
|
['CONVENTIONS.md', 'LICENSE', 'README.md', 'VERSION', 'install.sh'].each do |filename|
|
|
63
|
-
SchemaEvolutionManager::Library.system_or_error("cp
|
|
65
|
+
SchemaEvolutionManager::Library.system_or_error(["cp", "--", filename, version_dir])
|
|
64
66
|
end
|
|
65
67
|
|
|
66
68
|
['bin', 'lib', 'lib/schema-evolution-manager', 'template', 'scripts'].each do |dir|
|
|
@@ -69,9 +71,9 @@ end
|
|
|
69
71
|
Dir.foreach(dir) do |filename|
|
|
70
72
|
path = File.join(dir, filename)
|
|
71
73
|
if File.file?(path)
|
|
72
|
-
SchemaEvolutionManager::Library.system_or_error("cp
|
|
74
|
+
SchemaEvolutionManager::Library.system_or_error(["cp", "--", path, this_dir])
|
|
73
75
|
if dir == "bin" && filename != "sem-config"
|
|
74
|
-
SchemaEvolutionManager::Library.system_or_error("chmod +x
|
|
76
|
+
SchemaEvolutionManager::Library.system_or_error(["chmod", "--", "+x", File.join(this_dir, filename)])
|
|
75
77
|
end
|
|
76
78
|
end
|
|
77
79
|
end
|
|
@@ -82,7 +84,8 @@ Dir.chdir(bin_dir) do
|
|
|
82
84
|
Dir.foreach(aliased_bin_dir) do |filename|
|
|
83
85
|
path = File.join(aliased_bin_dir, filename)
|
|
84
86
|
if File.file?(path)
|
|
85
|
-
SchemaEvolutionManager::Library.system_or_error("rm -f
|
|
87
|
+
SchemaEvolutionManager::Library.system_or_error(["rm", "-f", "--", filename])
|
|
88
|
+
SchemaEvolutionManager::Library.system_or_error(["ln", "-s", "--", path])
|
|
86
89
|
end
|
|
87
90
|
end
|
|
88
91
|
end
|
|
@@ -91,7 +94,7 @@ end
|
|
|
91
94
|
init_file = File.join(version_dir, "bin/sem-config")
|
|
92
95
|
SchemaEvolutionManager::Preconditions.check_state(File.exist?(init_file), "Init file[%s] not found" % init_file)
|
|
93
96
|
File.open(init_file, "w") do |out|
|
|
94
|
-
out << "load
|
|
97
|
+
out << "load %s\n" % File.join(version_dir, 'lib/schema-evolution-manager.rb').inspect
|
|
95
98
|
end
|
|
96
99
|
|
|
97
100
|
puts ""
|
|
@@ -2,8 +2,15 @@ module SchemaEvolutionManager
|
|
|
2
2
|
class Library
|
|
3
3
|
|
|
4
4
|
unless defined?(TMPFILE_DIR)
|
|
5
|
-
|
|
5
|
+
# A directory private to this process. Temp file names below are
|
|
6
|
+
# "<prefix>.<counter>", and sem-apply uses a migration's basename as the
|
|
7
|
+
# prefix, so two processes applying the same scripts would otherwise
|
|
8
|
+
# compute the same path and delete each other's copy mid-run. mktmpdir
|
|
9
|
+
# honours TMPDIR and creates the directory mode 0700 with a random name.
|
|
10
|
+
TMPFILE_DIR = Dir.mktmpdir("schema-evolution-manager-")
|
|
6
11
|
TMPFILE_PREFIX = "schema-evolution-manager-#{Process.pid}.tmp"
|
|
12
|
+
tmpfile_dir_owner = Process.pid
|
|
13
|
+
at_exit { FileUtils.rm_rf(TMPFILE_DIR) if Process.pid == tmpfile_dir_owner }
|
|
7
14
|
end
|
|
8
15
|
@@tmpfile_count = 0
|
|
9
16
|
@@verbose = false
|
|
@@ -13,7 +20,7 @@ module SchemaEvolutionManager
|
|
|
13
20
|
Preconditions.assert_class(dir, String)
|
|
14
21
|
|
|
15
22
|
if !File.directory?(dir)
|
|
16
|
-
Library.system_or_error("mkdir -p
|
|
23
|
+
Library.system_or_error(["mkdir", "-p", "--", dir])
|
|
17
24
|
end
|
|
18
25
|
Library.assert_dir_exists(dir)
|
|
19
26
|
end
|
|
@@ -31,14 +38,14 @@ module SchemaEvolutionManager
|
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
def Library.git_assert_tag_exists(tag)
|
|
34
|
-
command = "git tag -l"
|
|
41
|
+
command = ["git", "tag", "-l"]
|
|
35
42
|
results = Library.system_or_error(command)
|
|
36
43
|
if results.nil?
|
|
37
44
|
raise "No git tags found"
|
|
38
45
|
end
|
|
39
46
|
|
|
40
47
|
if !Library.tag_exists?(tag)
|
|
41
|
-
raise "Tag[#{tag}] not found. Check #{command}"
|
|
48
|
+
raise "Tag[#{tag}] not found. Check #{Shellwords.join(command)}"
|
|
42
49
|
end
|
|
43
50
|
end
|
|
44
51
|
|
|
@@ -54,11 +61,17 @@ module SchemaEvolutionManager
|
|
|
54
61
|
# no tags, otherwise returns an instance of Version. Only searches for
|
|
55
62
|
# tags matching x.x.x (e.g. 1.0.2)
|
|
56
63
|
def Library.latest_tag
|
|
57
|
-
|
|
64
|
+
Library.git_tags.select { |tag| Version.is_valid?(tag) }.map { |tag| Version.parse(tag) }.sort.last
|
|
58
65
|
end
|
|
59
66
|
|
|
67
|
+
# True only when a tag with exactly this name exists - 1.0.10 does
|
|
68
|
+
# not make 1.0.1 exist.
|
|
60
69
|
def Library.tag_exists?(tag)
|
|
61
|
-
|
|
70
|
+
Library.git_tags.include?(tag)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def Library.git_tags
|
|
74
|
+
`git tag -l`.strip.split
|
|
62
75
|
end
|
|
63
76
|
|
|
64
77
|
# Ex: Library.git_create_tag("0.0.1")
|
|
@@ -66,11 +79,11 @@ module SchemaEvolutionManager
|
|
|
66
79
|
Library.assert_valid_tag(tag)
|
|
67
80
|
has_remote = Library.git_has_remote?
|
|
68
81
|
if has_remote
|
|
69
|
-
Library.system_or_error("git fetch --tags origin")
|
|
82
|
+
Library.system_or_error(["git", "fetch", "--tags", "origin"])
|
|
70
83
|
end
|
|
71
|
-
Library.system_or_error("git tag -a -m
|
|
84
|
+
Library.system_or_error(["git", "tag", "-a", "-m", tag, tag])
|
|
72
85
|
if has_remote
|
|
73
|
-
Library.system_or_error("git push --tags origin")
|
|
86
|
+
Library.system_or_error(["git", "push", "--tags", "origin"])
|
|
74
87
|
end
|
|
75
88
|
end
|
|
76
89
|
|
|
@@ -118,24 +131,65 @@ module SchemaEvolutionManager
|
|
|
118
131
|
@@base_dir = Library.normalize_path(value)
|
|
119
132
|
end
|
|
120
133
|
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
|
|
134
|
+
# Parses one property line of the form "<prefix><name> = <value>",
|
|
135
|
+
# stripping whitespace from both the name and the value. Returns
|
|
136
|
+
# [name, value] when the stripped line matches prefix (a Regexp
|
|
137
|
+
# anchored at the start of the line), otherwise nil. value is nil
|
|
138
|
+
# when the line has no equals sign. Shared by migration file
|
|
139
|
+
# attributes (-- sem.attribute.) and the .sem config file
|
|
140
|
+
# (sem.config.).
|
|
141
|
+
def Library.parse_property(line, prefix)
|
|
142
|
+
Preconditions.assert_class(line, String)
|
|
143
|
+
Preconditions.assert_class(prefix, Regexp)
|
|
144
|
+
|
|
145
|
+
stripped = line.strip
|
|
146
|
+
if stripped.match(prefix)
|
|
147
|
+
stripped.sub(prefix, '').split(/\=/, 2).map(&:strip)
|
|
148
|
+
else
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Runs the command, raising an error if it exits non-zero, and
|
|
154
|
+
# returns its standard output, stripped. Standard error passes
|
|
155
|
+
# through to ours.
|
|
156
|
+
#
|
|
157
|
+
# The command is an argv array, executed directly and never through a
|
|
158
|
+
# shell, so a path or value holding a space, a quote or a dollar sign
|
|
159
|
+
# reaches the program as exactly one argument:
|
|
160
|
+
#
|
|
161
|
+
# Library.system_or_error(["mv", "--", file, target])
|
|
162
|
+
#
|
|
163
|
+
# @param env: Optional hash of environment variables set for the command
|
|
164
|
+
# @param log: Optional array shown in place of the command when
|
|
165
|
+
# logging or raising, for a command carrying a secret
|
|
166
|
+
def Library.system_or_error(command, opts={})
|
|
167
|
+
env = opts.delete(:env) || {}
|
|
168
|
+
log = opts.delete(:log) || command
|
|
169
|
+
Preconditions.assert_empty_opts(opts)
|
|
170
|
+
Preconditions.assert_class(command, Array)
|
|
171
|
+
Preconditions.check_state(!command.empty?, "command cannot be empty")
|
|
172
|
+
Preconditions.check_state(command.all? { |arg| arg.is_a?(String) }, "every argument must be a String: %s" % command.inspect)
|
|
173
|
+
|
|
174
|
+
display = Library.command_to_s(log, env)
|
|
125
175
|
if Library.is_verbose?
|
|
126
|
-
puts
|
|
176
|
+
puts display
|
|
127
177
|
end
|
|
128
178
|
|
|
129
179
|
begin
|
|
130
|
-
result =
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
raise "Error running command[%s]: %s" % [command, e.to_s]
|
|
180
|
+
result, status = Open3.capture2(env, *command)
|
|
181
|
+
rescue SystemCallError => e
|
|
182
|
+
raise "Error running command[%s]: %s" % [display, e.to_s]
|
|
183
|
+
end
|
|
184
|
+
if !status.success?
|
|
185
|
+
raise "Non zero exit code[%s] running command[%s]" % [status, display]
|
|
137
186
|
end
|
|
138
|
-
result
|
|
187
|
+
result.strip
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A shell-quoted rendering of an argv, for logs and error messages
|
|
191
|
+
def Library.command_to_s(command, env={})
|
|
192
|
+
(env.map { |k, v| "%s=%s" % [k, Shellwords.escape(v)] } + [Shellwords.join(command)]).join(" ")
|
|
139
193
|
end
|
|
140
194
|
|
|
141
195
|
def Library.normalize_path(path)
|
|
@@ -157,7 +211,7 @@ module SchemaEvolutionManager
|
|
|
157
211
|
Preconditions.check_state(number_changes > 0)
|
|
158
212
|
Preconditions.assert_empty_opts(opts)
|
|
159
213
|
|
|
160
|
-
git_log_command = "git log --pretty=format
|
|
214
|
+
git_log_command = ["git", "log", "--pretty=format:%h %ad | %s%d [%an]", "--date=short", "-#{number_changes}"]
|
|
161
215
|
git_log = Library.system_or_error(git_log_command)
|
|
162
216
|
out = ""
|
|
163
217
|
out << "Created: %s\n" % Library.format_time
|
|
@@ -165,7 +219,7 @@ module SchemaEvolutionManager
|
|
|
165
219
|
out << "Git Tag: %s\n" % tag
|
|
166
220
|
end
|
|
167
221
|
out << "\n"
|
|
168
|
-
out << "%s:\n" % git_log_command
|
|
222
|
+
out << "%s:\n" % Library.command_to_s(git_log_command)
|
|
169
223
|
out << " " << git_log.split("\n").join("\n ") << "\n"
|
|
170
224
|
out
|
|
171
225
|
end
|
|
@@ -42,6 +42,7 @@ module SchemaEvolutionManager
|
|
|
42
42
|
|
|
43
43
|
unless defined?(DEFAULTS)
|
|
44
44
|
DEFAULTS = [AttributeValue.new("transaction", "single")]
|
|
45
|
+
PROPERTY_PREFIX = /^\-\-\s+sem\.attribute\./
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
attr_reader :path, :attribute_values
|
|
@@ -86,11 +87,8 @@ module SchemaEvolutionManager
|
|
|
86
87
|
File.foreach(path, mode: "rb") do |raw|
|
|
87
88
|
line = raw.force_encoding("UTF-8")
|
|
88
89
|
line = line.scrub unless line.valid_encoding?
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
stripped.sub!(/^\-\-\s+sem\.attribute\./, '')
|
|
92
|
-
name, value = stripped.split(/\=/, 2).map(&:strip)
|
|
93
|
-
yield name, value
|
|
90
|
+
if property = Library.parse_property(line, PROPERTY_PREFIX)
|
|
91
|
+
yield property[0], property[1]
|
|
94
92
|
end
|
|
95
93
|
end
|
|
96
94
|
end
|
|
@@ -6,6 +6,18 @@ module SchemaEvolutionManager
|
|
|
6
6
|
SCRIPTS = "scripts"
|
|
7
7
|
BOOTSTRAP_SCRIPTS = "bootstrap_scripts"
|
|
8
8
|
VALID_TABLE_NAMES = [BOOTSTRAP_SCRIPTS, SCRIPTS]
|
|
9
|
+
# A script basename is interpolated into SQL and passed to psql, so
|
|
10
|
+
# nothing outside this pattern may reach a query or be applied.
|
|
11
|
+
VALID_FILENAME = /\A\d{6,}-\d{6}\.sql\z/
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Raises unless filename follows the naming convention
|
|
15
|
+
# (e.g. 20120503-173242.sql). Returns the filename.
|
|
16
|
+
def Scripts.assert_valid_filename!(filename)
|
|
17
|
+
Preconditions.assert_class(filename, String)
|
|
18
|
+
Preconditions.check_state(VALID_FILENAME.match?(filename),
|
|
19
|
+
"Invalid filename[#{filename}]. Must be like: 20120503-173242.sql")
|
|
20
|
+
filename
|
|
9
21
|
end
|
|
10
22
|
|
|
11
23
|
# @param db Instance of Db class
|
|
@@ -41,7 +53,7 @@ module SchemaEvolutionManager
|
|
|
41
53
|
def each_pending(dir)
|
|
42
54
|
files = {}
|
|
43
55
|
Scripts.all(dir).each do |path|
|
|
44
|
-
name = File.basename(path)
|
|
56
|
+
name = Scripts.assert_valid_filename!(File.basename(path))
|
|
45
57
|
files[name] = path
|
|
46
58
|
end
|
|
47
59
|
|
|
@@ -63,8 +75,9 @@ module SchemaEvolutionManager
|
|
|
63
75
|
# True if this script has already been applied to the db. False
|
|
64
76
|
# otherwise.
|
|
65
77
|
def has_run?(filename)
|
|
78
|
+
Scripts.assert_valid_filename!(filename)
|
|
66
79
|
if @db.schema_schema_evolution_manager_exists?
|
|
67
|
-
query = "select count(*) from %s.%s where filename =
|
|
80
|
+
query = "select count(*) from %s.%s where filename = %s" % [Db.schema_name, @table_name, Db.quote_literal(filename)]
|
|
68
81
|
@db.psql_command(query).to_i > 0
|
|
69
82
|
else
|
|
70
83
|
false
|
|
@@ -73,9 +86,8 @@ module SchemaEvolutionManager
|
|
|
73
86
|
|
|
74
87
|
# Inserts a record to indiciate that we have loaded the specified file.
|
|
75
88
|
def record_as_run!(filename)
|
|
76
|
-
|
|
77
|
-
|
|
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]
|
|
89
|
+
literal = Db.quote_literal(Scripts.assert_valid_filename!(filename))
|
|
90
|
+
command = "insert into %s.%s (filename) select %s where not exists (select 1 from %s.%s where filename = %s)" % [Db.schema_name, @table_name, literal, Db.schema_name, @table_name, literal]
|
|
79
91
|
@db.psql_command(command)
|
|
80
92
|
end
|
|
81
93
|
|
|
@@ -96,7 +108,8 @@ module SchemaEvolutionManager
|
|
|
96
108
|
if scripts.empty? || !@db.schema_schema_evolution_manager_exists?
|
|
97
109
|
[]
|
|
98
110
|
else
|
|
99
|
-
|
|
111
|
+
literals = scripts.map { |name| Db.quote_literal(Scripts.assert_valid_filename!(name)) }
|
|
112
|
+
sql = "select filename from %s.%s where filename in (%s)" % [Db.schema_name, @table_name, literals.join(", ")]
|
|
100
113
|
@db.psql_command(sql).strip.split
|
|
101
114
|
end
|
|
102
115
|
end
|
|
@@ -12,23 +12,49 @@ module SchemaEvolutionManager
|
|
|
12
12
|
|
|
13
13
|
if !valid.include?(subcommand)
|
|
14
14
|
if subcommand.empty?
|
|
15
|
-
puts "ERROR: Missing db subcommand. Must be one of: %s" % valid.join(", ")
|
|
15
|
+
$stderr.puts "ERROR: Missing db subcommand. Must be one of: %s" % valid.join(", ")
|
|
16
16
|
else
|
|
17
|
-
puts "ERROR: Invalid db subcommand[%s]. Must be one of: %s" % [subcommand, valid.join(", ")]
|
|
17
|
+
$stderr.puts "ERROR: Invalid db subcommand[%s]. Must be one of: %s" % [subcommand, valid.join(", ")]
|
|
18
18
|
end
|
|
19
19
|
exit(4)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
db_args = SchemaEvolutionManager::Args.new(args
|
|
23
|
-
db = SchemaEvolutionManager::Db.from_args(db_args)
|
|
22
|
+
db_args = SchemaEvolutionManager::Args.new(args, :optional => ['url', 'host', 'user', 'name', 'port', 'set'])
|
|
24
23
|
|
|
25
|
-
if
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
if db_args.url.to_s.strip.empty? && db_args.name.to_s.strip.empty?
|
|
25
|
+
$stderr.puts "ERROR: Missing database connection. Provide --url <postgres url> or --host/--name."
|
|
26
|
+
exit(3)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A common mistake is passing a bare database name to --url. Catch it with
|
|
30
|
+
# an actionable hint instead of letting parse_url raise a raw backtrace.
|
|
31
|
+
if db_args.url && !db_args.url.include?("://")
|
|
32
|
+
$stderr.puts "ERROR: --url must be a full connection string (e.g. postgres://localhost:5432/%s). To connect by database name, use --name %s." % [db_args.url, db_args.url]
|
|
33
|
+
exit(3)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
begin
|
|
37
|
+
db = SchemaEvolutionManager::Db.from_args(db_args)
|
|
38
|
+
if subcommand == "version"
|
|
39
|
+
version = SchemaEvolutionManager::Versions.new(db).latest
|
|
40
|
+
if version.nil?
|
|
41
|
+
$stderr.puts "No deployed version recorded"
|
|
42
|
+
else
|
|
43
|
+
puts version
|
|
44
|
+
end
|
|
45
|
+
elsif subcommand == "scripts"
|
|
46
|
+
applied = SchemaEvolutionManager::Scripts.new(db, SchemaEvolutionManager::Scripts::SCRIPTS).applied
|
|
47
|
+
if applied.empty?
|
|
48
|
+
$stderr.puts "No scripts recorded"
|
|
49
|
+
else
|
|
50
|
+
applied.each { |filename| puts filename }
|
|
51
|
+
end
|
|
31
52
|
end
|
|
53
|
+
rescue => e
|
|
54
|
+
# Surface a single clean line (e.g. bad url, unreachable host, missing
|
|
55
|
+
# database) rather than a Ruby stack trace.
|
|
56
|
+
$stderr.puts "ERROR: %s" % e.message.to_s.split("\n").first
|
|
57
|
+
exit(3)
|
|
32
58
|
end
|
|
33
59
|
end
|
|
34
60
|
|
|
@@ -29,7 +29,9 @@ module SchemaEvolutionManager
|
|
|
29
29
|
Preconditions.check_not_blank(contents)
|
|
30
30
|
string = contents.dup
|
|
31
31
|
@subs.each do |sub|
|
|
32
|
-
|
|
32
|
+
# Block form: the value is inserted literally, so a backslash in
|
|
33
|
+
# it is never read as a backreference
|
|
34
|
+
string = string.gsub(/%%#{sub.pattern}%%/) { sub.value }
|
|
33
35
|
end
|
|
34
36
|
string
|
|
35
37
|
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
require 'fileutils'
|
|
2
2
|
require 'pathname'
|
|
3
|
-
require '
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'securerandom'
|
|
4
5
|
require 'shellwords'
|
|
6
|
+
require 'open3'
|
|
5
7
|
|
|
6
8
|
dir = File.dirname(__FILE__)
|
|
7
9
|
lib_dir = File.join(dir, "schema-evolution-manager")
|
|
@@ -28,3 +30,4 @@ load File.join(lib_dir, 'install_template.rb')
|
|
|
28
30
|
load File.join(lib_dir, 'script_error.rb')
|
|
29
31
|
load File.join(lib_dir, 'sem_info.rb')
|
|
30
32
|
load File.join(lib_dir, 'migration_file.rb')
|
|
33
|
+
load File.join(lib_dir, 'config.rb')
|
data/template/dev.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: schema-evolution-manager
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.9.
|
|
4
|
+
version: 0.9.61
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Bryzek
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-09 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
12
|
description: '["Michael Bryzek"]'
|
|
13
13
|
email: mbryzek@alum.mit.edu
|
|
@@ -35,6 +35,7 @@ files:
|
|
|
35
35
|
- lib/schema-evolution-manager/args.rb
|
|
36
36
|
- lib/schema-evolution-manager/ask.rb
|
|
37
37
|
- lib/schema-evolution-manager/baseline_util.rb
|
|
38
|
+
- lib/schema-evolution-manager/config.rb
|
|
38
39
|
- lib/schema-evolution-manager/connection_data.rb
|
|
39
40
|
- lib/schema-evolution-manager/db.rb
|
|
40
41
|
- lib/schema-evolution-manager/install_template.rb
|
|
@@ -72,7 +73,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
72
73
|
- !ruby/object:Gem::Version
|
|
73
74
|
version: '0'
|
|
74
75
|
requirements: []
|
|
75
|
-
rubygems_version:
|
|
76
|
+
rubygems_version: 4.0.17
|
|
76
77
|
specification_version: 4
|
|
77
78
|
summary: Schema evolution manager is a simple schema migration tool for postgresql
|
|
78
79
|
databases.
|