schema-evolution-manager 0.9.60 → 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 +10 -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 +70 -23
- 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 +1 -1
- 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 +3 -1
- data/template/dev.rb +3 -3
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60215e02faf6492f2a4e0b263008b6038f95d3ff824417fb6ce615296342ea5f
|
|
4
|
+
data.tar.gz: e9450f3c24a2f54b3f25566c1152a6454de7e7b2eb4750dd0a8021b273b014c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6673d52ee37258fee586cd320799eeff9e6477d963a98c79f737f1fc8f44009dcbc076ed26dc8e900ef970e79516d1d115d125b6ec4d23da45d1b5903508ca8b
|
|
7
|
+
data.tar.gz: 3081446f6663a18bf00523332c0d9363e9b3322c9a900561205e1d90509698f4a6899824087a202ba9fdc5f6487fd9ab475574fbca246c7c0e156f691ceb700a
|
data/README.md
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
[](https://travis-ci.org/mbryzek/schema-evolution-manager)
|
|
2
|
-
|
|
3
1
|
# Schema Evolution Manager (sem)
|
|
4
2
|
|
|
5
3
|
## Intended Audience
|
|
@@ -43,6 +41,24 @@ dozens of independent postgresql databases.
|
|
|
43
41
|
See INSTALLATION and GETTING STARTED for details.
|
|
44
42
|
|
|
45
43
|
|
|
44
|
+
## Why sem
|
|
45
|
+
|
|
46
|
+
sem is deliberately small. A schema change is a plain SQL file, named
|
|
47
|
+
by the timestamp it was added, committed to git next to every other
|
|
48
|
+
change to that database. There is no migration DSL, no down
|
|
49
|
+
migrations, and no runtime dependency beyond ruby, git and psql.
|
|
50
|
+
|
|
51
|
+
Applying changes is psql running each file in order inside a
|
|
52
|
+
transaction, and recording the filename in one table. Because psql
|
|
53
|
+
does the work, anything that runs in psql runs in sem: plpgsql,
|
|
54
|
+
extensions, data fixes, large backfills.
|
|
55
|
+
|
|
56
|
+
If you want a tool that generates SQL from a model, or that rolls
|
|
57
|
+
schema changes back for you, sem is the wrong choice. If you want the
|
|
58
|
+
SQL you wrote to be the SQL that runs, and a plain record of what ran
|
|
59
|
+
where, it is a good fit.
|
|
60
|
+
|
|
61
|
+
|
|
46
62
|
## Project Goals
|
|
47
63
|
|
|
48
64
|
- Absolutely minimal set of dependencies. We found that anything
|
|
@@ -133,7 +149,7 @@ There are three ways to install schema evolution manager:
|
|
|
133
149
|
|
|
134
150
|
git clone git@github.com:mbryzek/schema-evolution-manager.git
|
|
135
151
|
cd schema-evolution-manager
|
|
136
|
-
git checkout 0.9.
|
|
152
|
+
git checkout 0.9.61
|
|
137
153
|
ruby ./configure.rb
|
|
138
154
|
sudo ./install.rb
|
|
139
155
|
|
|
@@ -157,6 +173,23 @@ version.
|
|
|
157
173
|
git init /tmp/sample
|
|
158
174
|
sem-init --dir /tmp/sample --url postgresql://postgres@localhost/sample
|
|
159
175
|
|
|
176
|
+
### Configuring the scripts directory (.sem)
|
|
177
|
+
|
|
178
|
+
By default sem keeps scripts in ./scripts. To keep them elsewhere, commit
|
|
179
|
+
a file named .sem at the root of the repository:
|
|
180
|
+
|
|
181
|
+
sem.config.scripts_dir = schema/scripts
|
|
182
|
+
|
|
183
|
+
Every command walks up from the current directory to the first .sem and
|
|
184
|
+
resolves paths relative to the directory that holds it. Blank lines and
|
|
185
|
+
lines starting with # are ignored. sem-init writes the file for you when
|
|
186
|
+
given --scripts_dir:
|
|
187
|
+
|
|
188
|
+
sem-init --dir /tmp/sample --url postgresql://postgres@localhost/sample --scripts_dir schema/scripts
|
|
189
|
+
|
|
190
|
+
sem-dist always packs the scripts under the name scripts/ and never packs
|
|
191
|
+
.sem, so a tarball applies on a server with no .sem.
|
|
192
|
+
|
|
160
193
|
### Writing your first sql script
|
|
161
194
|
|
|
162
195
|
cd /tmp/sample
|
|
@@ -236,6 +269,18 @@ There are two recommended ways in which to pass user passwords to psql:
|
|
|
236
269
|
|
|
237
270
|
sem-apply --url postgresql://postgres@localhost/sample --password
|
|
238
271
|
|
|
272
|
+
### Running inside the postgres Docker image
|
|
273
|
+
|
|
274
|
+
The official postgres image runs the scripts in
|
|
275
|
+
docker-entrypoint-initdb.d before the server listens on TCP. During
|
|
276
|
+
that phase postgres accepts connections only on its unix socket, so a
|
|
277
|
+
URL naming localhost is refused. Leave the host out of the URL and
|
|
278
|
+
libpq connects over the socket:
|
|
279
|
+
|
|
280
|
+
sem-apply --url postgresql://$POSTGRES_USER@/$POSTGRES_DB
|
|
281
|
+
|
|
282
|
+
No password is needed on the socket during initialization.
|
|
283
|
+
|
|
239
284
|
### Apply the changes
|
|
240
285
|
|
|
241
286
|
sem-apply --url postgresql://postgres@localhost/sample
|
data/bin/sem-add
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
|
-
# == Adds a database upgrade script to this repository.
|
|
2
|
+
# == Adds a database upgrade script to this repository. The script is
|
|
3
|
+
# written to the scripts directory named by .sem (default ./scripts)
|
|
3
4
|
#
|
|
4
5
|
# == Usage
|
|
5
6
|
# sem-add <path>
|
|
@@ -19,7 +20,7 @@ end
|
|
|
19
20
|
SchemaEvolutionManager::Preconditions.check_state(File.exist?(file), "File[#{file}] could not be found")
|
|
20
21
|
SchemaEvolutionManager::Preconditions.check_state(file.match(/\.sql/i), "File[#{file}] must end with .sql")
|
|
21
22
|
|
|
22
|
-
scripts_dir =
|
|
23
|
+
scripts_dir = SchemaEvolutionManager::Config.load.scripts_dir
|
|
23
24
|
SchemaEvolutionManager::Library.ensure_dir!(scripts_dir)
|
|
24
25
|
|
|
25
26
|
contents = IO.read(file)
|
|
@@ -33,7 +34,7 @@ while File.exist?(target)
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
puts "Adding #{target}"
|
|
36
|
-
SchemaEvolutionManager::Library.system_or_error("mv
|
|
37
|
-
SchemaEvolutionManager::Library.system_or_error("git add
|
|
37
|
+
SchemaEvolutionManager::Library.system_or_error(["mv", "--", file, target])
|
|
38
|
+
SchemaEvolutionManager::Library.system_or_error(["git", "add", "--", target])
|
|
38
39
|
|
|
39
40
|
puts "File staged in git. You need to commit and push"
|
data/bin/sem-apply
CHANGED
|
@@ -23,6 +23,7 @@ password = if args.password
|
|
|
23
23
|
nil
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
config = SchemaEvolutionManager::Config.load
|
|
26
27
|
db = SchemaEvolutionManager::Db.from_args(args, :password => password)
|
|
27
28
|
util = SchemaEvolutionManager::ApplyUtil.new(db, :dry_run => args.dry_run || false)
|
|
28
29
|
|
|
@@ -30,12 +31,12 @@ begin
|
|
|
30
31
|
db.bootstrap!
|
|
31
32
|
|
|
32
33
|
puts "Upgrading schema for #{db.sanitized_url}"
|
|
33
|
-
count = util.apply!(
|
|
34
|
+
count = util.apply!(config.scripts_dir)
|
|
34
35
|
if count == 0
|
|
35
36
|
puts " All scripts have been previously applied"
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
version_file = "
|
|
39
|
+
version_file = File.join(config.base_dir, "VERSION")
|
|
39
40
|
if !util.dry_run? && File.exist?(version_file)
|
|
40
41
|
version = IO.read(version_file).strip
|
|
41
42
|
if !version.empty?
|
data/bin/sem-baseline
CHANGED
|
@@ -17,6 +17,7 @@ load File.join(File.dirname(__FILE__), 'sem-config')
|
|
|
17
17
|
|
|
18
18
|
args = SchemaEvolutionManager::Args.from_stdin(:optional => %w(url host port name user dry_run))
|
|
19
19
|
|
|
20
|
+
config = SchemaEvolutionManager::Config.load
|
|
20
21
|
db = SchemaEvolutionManager::Db.from_args(args)
|
|
21
22
|
db.bootstrap!
|
|
22
23
|
|
|
@@ -24,10 +25,10 @@ dry_run = args.dry_run.nil? ? false : args.dry_run
|
|
|
24
25
|
|
|
25
26
|
util = SchemaEvolutionManager::BaselineUtil.new(db, :dry_run => dry_run)
|
|
26
27
|
|
|
27
|
-
puts "Baselining schema for #{db.
|
|
28
|
+
puts "Baselining schema for #{db.sanitized_url}"
|
|
28
29
|
|
|
29
30
|
begin
|
|
30
|
-
count = util.apply!(
|
|
31
|
+
count = util.apply!(config.scripts_dir)
|
|
31
32
|
if count == 0
|
|
32
33
|
puts " All scripts have been previously applied"
|
|
33
34
|
end
|
data/bin/sem-dist
CHANGED
|
@@ -30,13 +30,13 @@ args = SchemaEvolutionManager::Args.from_stdin(:optional => ['artifact_name', 't
|
|
|
30
30
|
|
|
31
31
|
# On MAC OS X, use gnutar if available, otherwise use tar with --no-xattrs
|
|
32
32
|
tar_cmd = `which gnutar 2> /dev/null`.strip
|
|
33
|
-
tar_opts =
|
|
33
|
+
tar_opts = []
|
|
34
34
|
if tar_cmd == ""
|
|
35
35
|
tar_cmd = "tar"
|
|
36
36
|
# Use --no-xattrs to prevent extended attributes from being archived (works in create mode)
|
|
37
37
|
# Also use --no-mac-metadata to prevent AppleDouble files
|
|
38
|
-
if system("tar --no-xattrs -cf /dev/null /dev/null
|
|
39
|
-
tar_opts = "--no-xattrs --no-mac-metadata"
|
|
38
|
+
if system("tar", "--no-xattrs", "-cf", "/dev/null", "/dev/null", :err => File::NULL)
|
|
39
|
+
tar_opts = ["--no-xattrs", "--no-mac-metadata"]
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -58,7 +58,8 @@ SchemaEvolutionManager::Library.git_assert_tag_exists(tag)
|
|
|
58
58
|
SchemaEvolutionManager::Versions.validate_version!(tag)
|
|
59
59
|
|
|
60
60
|
changes = SchemaEvolutionManager::Library.git_changes(:tag => tag)
|
|
61
|
-
|
|
61
|
+
config = SchemaEvolutionManager::Config.load
|
|
62
|
+
repo_path = config.base_dir
|
|
62
63
|
|
|
63
64
|
if args.artifact_name
|
|
64
65
|
artifact_name = args.artifact_name
|
|
@@ -76,16 +77,19 @@ SchemaEvolutionManager::Library.with_temp_file do |tmp|
|
|
|
76
77
|
tmpdir = File.join(tmp, filename)
|
|
77
78
|
SchemaEvolutionManager::Library.ensure_dir!(tmpdir)
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
# The tarball always holds the scripts under the fixed name scripts/
|
|
81
|
+
# and never .sem, so a server applies it without any config.
|
|
82
|
+
SchemaEvolutionManager::Library.system_or_error(["cp", "-R", "--", config.scripts_dir, File.join(tmpdir, "scripts")])
|
|
80
83
|
File.open(File.join(tmpdir, "CHANGES"), "w") { |out| out << changes }
|
|
81
84
|
File.open(File.join(tmpdir, "VERSION"), "w") { |out| out << tag }
|
|
82
85
|
|
|
83
|
-
# Clear extended attributes to avoid tar warnings on Linux
|
|
84
|
-
|
|
86
|
+
# Clear extended attributes to avoid tar warnings on Linux. Best
|
|
87
|
+
# effort: xattr is absent on most Linux hosts.
|
|
88
|
+
system("xattr", "-cr", "--", tmpdir, [:out, :err] => File::NULL)
|
|
85
89
|
|
|
86
90
|
Dir.chdir(tmp) do
|
|
87
91
|
# COPYFILE_DISABLE prevents AppleDouble ._ files from being created
|
|
88
|
-
SchemaEvolutionManager::Library.system_or_error(
|
|
92
|
+
SchemaEvolutionManager::Library.system_or_error([tar_cmd] + tar_opts + ["-cf", "#{filename}.tar", "--", filename], :env => { "COPYFILE_DISABLE" => "1" })
|
|
89
93
|
FileUtils.cp("#{filename}.tar", tarball)
|
|
90
94
|
end
|
|
91
95
|
end
|
|
@@ -96,8 +100,7 @@ if File.exist?(gzip_file)
|
|
|
96
100
|
FileUtils.rm(gzip_file)
|
|
97
101
|
end
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
SchemaEvolutionManager::Library.system_or_error(command)
|
|
103
|
+
SchemaEvolutionManager::Library.system_or_error(["gzip", "--", tarball])
|
|
101
104
|
|
|
102
105
|
puts ""
|
|
103
106
|
puts "Created distribution file at #{gzip_file}"
|
data/bin/sem-init
CHANGED
|
@@ -9,16 +9,19 @@
|
|
|
9
9
|
#
|
|
10
10
|
# dir: The directory containing a git repository that will contain the schema evolution files
|
|
11
11
|
# url: The connection string for the psql database
|
|
12
|
+
# scripts_dir: Optional path to the scripts directory, relative to dir. When given,
|
|
13
|
+
# it is recorded in dir/.sem so every sem command finds it. Defaults to scripts
|
|
12
14
|
#
|
|
13
15
|
# == Example
|
|
14
16
|
# git init /tmp/example_repo
|
|
15
17
|
# bin/sem-init --dir /tmp/example_repo --url postgresql://username@localhost/sample
|
|
18
|
+
# bin/sem-init --dir /tmp/example_repo --url postgresql://username@localhost/sample --scripts_dir schema/scripts
|
|
16
19
|
#
|
|
17
20
|
|
|
18
21
|
load File.join(File.dirname(__FILE__), 'sem-config')
|
|
19
22
|
SchemaEvolutionManager::Library.set_verbose(true)
|
|
20
23
|
|
|
21
|
-
args = SchemaEvolutionManager::Args.from_stdin(:required => %w(dir), :optional => %w(url host port name user))
|
|
24
|
+
args = SchemaEvolutionManager::Args.from_stdin(:required => %w(dir), :optional => %w(url host port name user scripts_dir))
|
|
22
25
|
SchemaEvolutionManager::Preconditions.check_state(File.directory?(args.dir), "Dir[%s] does not exist" % args.dir)
|
|
23
26
|
|
|
24
27
|
if args.url.nil? && args.name.nil?
|
|
@@ -43,8 +46,11 @@ template_dir = File.join(SchemaEvolutionManager::Library.base_dir, "template")
|
|
|
43
46
|
SchemaEvolutionManager::Preconditions.check_state(File.directory?(template_dir),
|
|
44
47
|
"Could not find schema-evolution-manager/template subdir. Expected it at[%s]" % [template_dir])
|
|
45
48
|
|
|
49
|
+
# The wrapper scripts are committed to the repository, so they carry the url
|
|
50
|
+
# with any password removed; a password is supplied through a pgpass file.
|
|
51
|
+
# dev.rb takes the url as a ruby string literal.
|
|
46
52
|
subs = {
|
|
47
|
-
"
|
|
53
|
+
"url_literal" => SchemaEvolutionManager::ConnectionData.strip_password(db.url).inspect,
|
|
48
54
|
"add_script_path" => File.join(SchemaEvolutionManager::Library.base_dir, "bin/sem-add")
|
|
49
55
|
}
|
|
50
56
|
|
|
@@ -57,40 +63,56 @@ Dir.chdir(args.dir) do
|
|
|
57
63
|
if !File.exist?(wrapper)
|
|
58
64
|
puts "Creating wrapper script #{wrapper}"
|
|
59
65
|
copy_file(path, wrapper, subs)
|
|
60
|
-
SchemaEvolutionManager::Library.system_or_error("chmod +x
|
|
61
|
-
SchemaEvolutionManager::Library.system_or_error("git add
|
|
66
|
+
SchemaEvolutionManager::Library.system_or_error(["chmod", "--", "+x", wrapper])
|
|
67
|
+
SchemaEvolutionManager::Library.system_or_error(["git", "add", "--", wrapper])
|
|
62
68
|
wrappers << wrapper
|
|
63
69
|
end
|
|
64
70
|
end
|
|
65
71
|
if !wrappers.empty?
|
|
66
|
-
SchemaEvolutionManager::Library.system_or_error("git commit -m
|
|
72
|
+
SchemaEvolutionManager::Library.system_or_error(["git", "commit", "-m", "Add schema-evolution-manager wrapper scripts", "--"] + wrappers)
|
|
67
73
|
end
|
|
68
74
|
|
|
69
75
|
readme = "README.md"
|
|
70
76
|
if !File.exist?(readme)
|
|
71
77
|
puts "Creating #{readme}"
|
|
72
78
|
copy_file("schema-evolution-manager/template/README.md", "README.md", subs)
|
|
73
|
-
SchemaEvolutionManager::Library.system_or_error("git add
|
|
74
|
-
SchemaEvolutionManager::Library.system_or_error("git commit -m
|
|
79
|
+
SchemaEvolutionManager::Library.system_or_error(["git", "add", "--", readme])
|
|
80
|
+
SchemaEvolutionManager::Library.system_or_error(["git", "commit", "-m", "Adding README.md", "--", readme])
|
|
75
81
|
end
|
|
76
82
|
|
|
77
|
-
if
|
|
78
|
-
SchemaEvolutionManager::
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
if args.scripts_dir
|
|
84
|
+
config_file = SchemaEvolutionManager::Config::FILENAME
|
|
85
|
+
if File.exist?(config_file)
|
|
86
|
+
existing = SchemaEvolutionManager::Config.parse(File.expand_path(config_file))
|
|
87
|
+
SchemaEvolutionManager::Preconditions.check_state(existing.scripts_dir == File.expand_path(args.scripts_dir),
|
|
88
|
+
"%s already sets scripts_dir[%s] - not overwriting with[%s]" % [config_file, existing.scripts_dir, args.scripts_dir])
|
|
89
|
+
else
|
|
90
|
+
puts "Creating #{config_file}"
|
|
91
|
+
File.open(config_file, "w") { |out| out << "sem.config.scripts_dir = #{args.scripts_dir}\n" }
|
|
92
|
+
SchemaEvolutionManager::Library.system_or_error(["git", "add", "--", config_file])
|
|
93
|
+
SchemaEvolutionManager::Library.system_or_error(["git", "commit", "-m", "Adding #{config_file}", "--", config_file])
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
scripts_dir = args.scripts_dir || "scripts"
|
|
98
|
+
if !File.exist?(scripts_dir)
|
|
99
|
+
exists_marker = File.join(scripts_dir, ".exists")
|
|
100
|
+
SchemaEvolutionManager::Library.ensure_dir!(scripts_dir)
|
|
101
|
+
SchemaEvolutionManager::Library.system_or_error(["touch", exists_marker])
|
|
102
|
+
SchemaEvolutionManager::Library.system_or_error(["git", "add", "--", exists_marker])
|
|
103
|
+
SchemaEvolutionManager::Library.system_or_error(["git", "commit", "-m", "Adding scripts directory", "--", exists_marker])
|
|
82
104
|
end
|
|
83
105
|
|
|
84
106
|
# Create the first git tag if necessary
|
|
85
|
-
tags = SchemaEvolutionManager::Library.system_or_error("git tag -l")
|
|
107
|
+
tags = SchemaEvolutionManager::Library.system_or_error(["git", "tag", "-l"])
|
|
86
108
|
if tags.nil? || tags == ""
|
|
87
109
|
puts "Creating initial git tag (0.0.1)"
|
|
88
110
|
SchemaEvolutionManager::Library.git_create_tag("0.0.1")
|
|
89
111
|
end
|
|
90
112
|
|
|
91
113
|
if SchemaEvolutionManager::Library.git_has_remote?
|
|
92
|
-
branch = SchemaEvolutionManager::Library.system_or_error("git symbolic-ref --short HEAD")
|
|
93
|
-
SchemaEvolutionManager::Library.system_or_error("git push origin
|
|
114
|
+
branch = SchemaEvolutionManager::Library.system_or_error(["git", "symbolic-ref", "--short", "HEAD"])
|
|
115
|
+
SchemaEvolutionManager::Library.system_or_error(["git", "push", "origin", branch])
|
|
94
116
|
end
|
|
95
117
|
|
|
96
118
|
end
|
|
@@ -16,6 +16,7 @@ module SchemaEvolutionManager
|
|
|
16
16
|
:name => "Specifies the name of the database to which to connect",
|
|
17
17
|
:url => "The connection string for the psql database",
|
|
18
18
|
:dir => "Path to a directory",
|
|
19
|
+
:scripts_dir => "Path to the scripts directory, relative to --dir. Recorded in .sem",
|
|
19
20
|
:tag => "A git tag (e.g. 0.0.1)",
|
|
20
21
|
:prefix => "Configure installer to use this prefix",
|
|
21
22
|
:set => "Passthrough for postgresql --set argument. Returns an array of the options set"
|
|
@@ -31,13 +32,16 @@ module SchemaEvolutionManager
|
|
|
31
32
|
}
|
|
32
33
|
end
|
|
33
34
|
|
|
34
|
-
attr_reader :artifact_name, :host, :port, :name, :prefix, :url, :user, :dir, :dry_run, :non_interactive, :tag, :password, :set
|
|
35
|
+
attr_reader :artifact_name, :host, :port, :name, :prefix, :url, :user, :dir, :dry_run, :non_interactive, :tag, :password, :set, :scripts_dir
|
|
35
36
|
|
|
36
|
-
# args:
|
|
37
|
+
# args: The arguments, either an argv array (each element one
|
|
38
|
+
# argument, so a value may contain whitespace) or a string
|
|
39
|
+
# split on whitespace
|
|
37
40
|
# :required => list of parameters that are required
|
|
38
41
|
# :optional => list of parameters that are optional
|
|
39
42
|
def initialize(args, opts={})
|
|
40
|
-
Preconditions.
|
|
43
|
+
Preconditions.check_state(args.nil? || args.is_a?(String) || args.is_a?(Array),
|
|
44
|
+
"args must be a String or an Array")
|
|
41
45
|
required = (opts.delete(:required) || []).map { |flag| format_flag(flag) }
|
|
42
46
|
optional = (opts.delete(:optional) || []).map { |flag| format_flag(flag) }
|
|
43
47
|
Preconditions.assert_class(required, Array)
|
|
@@ -67,6 +71,7 @@ module SchemaEvolutionManager
|
|
|
67
71
|
@url = found_arguments.delete(:url)
|
|
68
72
|
@user = found_arguments.delete(:user)
|
|
69
73
|
@dir = found_arguments.delete(:dir)
|
|
74
|
+
@scripts_dir = found_arguments.delete(:scripts_dir)
|
|
70
75
|
@tag = found_arguments.delete(:tag)
|
|
71
76
|
@set = found_arguments.delete(:set) || []
|
|
72
77
|
|
|
@@ -100,8 +105,7 @@ module SchemaEvolutionManager
|
|
|
100
105
|
|
|
101
106
|
# Hack to minimize bleeding from STDIN. Returns an instance of Args class
|
|
102
107
|
def Args.from_stdin(opts)
|
|
103
|
-
|
|
104
|
-
Args.new(values, opts)
|
|
108
|
+
Args.new(ARGV.dup, opts)
|
|
105
109
|
end
|
|
106
110
|
|
|
107
111
|
private
|
|
@@ -143,10 +147,9 @@ module SchemaEvolutionManager
|
|
|
143
147
|
|
|
144
148
|
|
|
145
149
|
def parse_string_arguments(args)
|
|
146
|
-
Preconditions.assert_class_or_nil(args, String)
|
|
147
150
|
found = {}
|
|
148
151
|
index = 0
|
|
149
|
-
values = args.to_s.strip.split(/\s+/)
|
|
152
|
+
values = args.is_a?(Array) ? args : args.to_s.strip.split(/\s+/)
|
|
150
153
|
while index < values.length do
|
|
151
154
|
flag = format_flag(values[index])
|
|
152
155
|
index += 1
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module SchemaEvolutionManager
|
|
2
|
+
|
|
3
|
+
# Repository level configuration, read from a file named .sem. Every
|
|
4
|
+
# command walks up from the current directory to the first .sem; the
|
|
5
|
+
# directory holding it is the base directory. Lines follow the
|
|
6
|
+
# migration file attribute grammar:
|
|
7
|
+
#
|
|
8
|
+
# sem.config.scripts_dir = schema/scripts
|
|
9
|
+
#
|
|
10
|
+
# Blank lines and lines starting with # are ignored. Paths are
|
|
11
|
+
# relative to the base directory. With no .sem anywhere, the base
|
|
12
|
+
# directory is the start directory and scripts_dir is ./scripts.
|
|
13
|
+
class Config
|
|
14
|
+
|
|
15
|
+
unless defined?(FILENAME)
|
|
16
|
+
FILENAME = ".sem"
|
|
17
|
+
PROPERTY_PREFIX = /^sem\.config\./
|
|
18
|
+
DEFAULT_SCRIPTS_DIR = "scripts"
|
|
19
|
+
NAMES = ["scripts_dir"]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :path, :base_dir, :scripts_dir
|
|
23
|
+
|
|
24
|
+
# path: the .sem file this config was read from, or nil
|
|
25
|
+
# base_dir: absolute path against which relative paths resolve
|
|
26
|
+
# scripts_dir: scripts directory, relative to base_dir or absolute
|
|
27
|
+
def initialize(path, base_dir, scripts_dir)
|
|
28
|
+
@path = path
|
|
29
|
+
@base_dir = Library.normalize_path(File.expand_path(base_dir))
|
|
30
|
+
@scripts_dir = Library.normalize_path(File.expand_path(scripts_dir, @base_dir))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def Config.load(start_dir=Dir.pwd)
|
|
34
|
+
Preconditions.assert_class(start_dir, String)
|
|
35
|
+
start = File.expand_path(start_dir)
|
|
36
|
+
Preconditions.check_state(File.directory?(start), "Dir[%s] does not exist" % start)
|
|
37
|
+
|
|
38
|
+
if path = Config.find(start)
|
|
39
|
+
Config.parse(path)
|
|
40
|
+
else
|
|
41
|
+
Config.new(nil, start, DEFAULT_SCRIPTS_DIR)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the path to the first .sem file found walking up from
|
|
46
|
+
# dir to the filesystem root, or nil if there is none.
|
|
47
|
+
def Config.find(dir)
|
|
48
|
+
current = File.expand_path(dir)
|
|
49
|
+
loop do
|
|
50
|
+
candidate = File.join(current, FILENAME)
|
|
51
|
+
if File.file?(candidate)
|
|
52
|
+
return candidate
|
|
53
|
+
end
|
|
54
|
+
parent = File.dirname(current)
|
|
55
|
+
if parent == current
|
|
56
|
+
return nil
|
|
57
|
+
end
|
|
58
|
+
current = parent
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def Config.parse(path)
|
|
63
|
+
Preconditions.check_state(File.file?(path), "File[%s] does not exist" % path)
|
|
64
|
+
|
|
65
|
+
values = {}
|
|
66
|
+
File.foreach(path).each_with_index do |line, index|
|
|
67
|
+
stripped = line.strip
|
|
68
|
+
if stripped.empty? || stripped.start_with?("#")
|
|
69
|
+
next
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
location = "File[%s] line %s" % [path, index + 1]
|
|
73
|
+
name, value = Library.parse_property(stripped, PROPERTY_PREFIX)
|
|
74
|
+
Preconditions.check_state(!name.nil? && !value.nil?,
|
|
75
|
+
"%s: Invalid line[%s]. Expected sem.config.<name> = <value>" % [location, stripped])
|
|
76
|
+
Preconditions.check_state(NAMES.include?(name),
|
|
77
|
+
"%s: Config with name[%s] not found. Must be one of: %s" % [location, name, NAMES.join(" ")])
|
|
78
|
+
Preconditions.check_state(!value.empty?,
|
|
79
|
+
"%s: Config[%s] - value cannot be blank" % [location, name])
|
|
80
|
+
Preconditions.check_state(!values.has_key?(name),
|
|
81
|
+
"%s: Config[%s] is specified more than once" % [location, name])
|
|
82
|
+
values[name] = value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Config.new(path, File.dirname(path), values["scripts_dir"] || DEFAULT_SCRIPTS_DIR)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
end
|
|
@@ -3,8 +3,8 @@ module SchemaEvolutionManager
|
|
|
3
3
|
class ConnectionData
|
|
4
4
|
|
|
5
5
|
DEFAULT_PORT = 5432 unless defined?(DEFAULT_PORT)
|
|
6
|
-
|
|
7
|
-
attr_reader :host, :name, :port, :user
|
|
6
|
+
|
|
7
|
+
attr_reader :host, :name, :port, :user, :password
|
|
8
8
|
|
|
9
9
|
def initialize(host, name, opts={})
|
|
10
10
|
@host = host
|
|
@@ -19,50 +19,103 @@ module SchemaEvolutionManager
|
|
|
19
19
|
Preconditions.check_argument(@port > 0, "Port must be > 0")
|
|
20
20
|
|
|
21
21
|
@user = opts.delete(:user)
|
|
22
|
+
@password = opts.delete(:password)
|
|
22
23
|
Preconditions.assert_empty_opts(opts)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
# Returns a valid pgpass line entry representing this connection.
|
|
27
|
+
# The name parsed from a url can carry query parameters
|
|
28
|
+
# (?sslmode=require), which libpq does not match on, so they are dropped.
|
|
26
29
|
#
|
|
27
30
|
# @param password: Optional password to include in the connection string
|
|
28
31
|
def pgpass(password=nil)
|
|
29
|
-
|
|
32
|
+
database = @name.to_s.split("?", 2).first
|
|
33
|
+
[@host, @port, database, @user, password].map { |v| ConnectionData.pgpass_escape(v) }.join(":")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# libpq reads ':' as the pgpass field separator and '\' as its escape,
|
|
37
|
+
# so both must be backslash-escaped within a field.
|
|
38
|
+
def ConnectionData.pgpass_escape(value)
|
|
39
|
+
value.to_s.gsub(/[\\:]/) { |c| "\\#{c}" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the url with any password removed from it. The username is
|
|
43
|
+
# kept so that libpq still selects the matching pgpass entry.
|
|
44
|
+
#
|
|
45
|
+
# @param url e.g. postgres://user:pass@db.com:5553/test_db
|
|
46
|
+
def ConnectionData.strip_password(url)
|
|
47
|
+
ConnectionData.rebuild_lead(url) { |user, _password| user }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the url with any password replaced by [REDACTED], for display.
|
|
51
|
+
def ConnectionData.redact(url)
|
|
52
|
+
ConnectionData.rebuild_lead(url) { |user, password| password.nil? ? user : "#{user}:[REDACTED]" }
|
|
30
53
|
end
|
|
31
54
|
|
|
32
55
|
# Parses a connection string into a ConnectionData instance. You
|
|
33
|
-
# will get an error if the URL could not be parsed.
|
|
56
|
+
# will get an error if the URL could not be parsed. A password in the
|
|
57
|
+
# url is percent-decoded, as libpq does, and never appears in an error.
|
|
34
58
|
#
|
|
35
59
|
# @param url e.g. postgres://user1@db.com:5553/test_db
|
|
36
60
|
def ConnectionData.parse_url(url)
|
|
61
|
+
display = ConnectionData.redact(url)
|
|
37
62
|
protocol, rest = url.split("//", 2)
|
|
38
63
|
if rest.nil?
|
|
39
|
-
raise "Invalid url[%s]. Expected to start with postgres://" %
|
|
64
|
+
raise "Invalid url[%s]. Expected to start with postgres://" % display
|
|
40
65
|
end
|
|
41
|
-
|
|
66
|
+
|
|
42
67
|
lead, name = rest.split("/", 2)
|
|
43
68
|
if name.nil?
|
|
44
|
-
raise "Invalid url[%s]. Missing database name" %
|
|
69
|
+
raise "Invalid url[%s]. Missing database name" % display
|
|
45
70
|
end
|
|
46
71
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
user = parts[0]
|
|
50
|
-
db_host = parts[1]
|
|
51
|
-
else
|
|
52
|
-
user = nil
|
|
53
|
-
db_host = lead
|
|
54
|
-
end
|
|
72
|
+
userinfo, db_host = ConnectionData.split_lead(lead)
|
|
73
|
+
user, password = ConnectionData.split_userinfo(userinfo)
|
|
55
74
|
|
|
56
75
|
host, port = db_host.split(":", 2)
|
|
57
76
|
if port
|
|
58
77
|
if port.to_i.to_s != port
|
|
59
|
-
raise "Invalid url[%s]. Expected database port[%s] to be an integer" % [
|
|
78
|
+
raise "Invalid url[%s]. Expected database port[%s] to be an integer" % [display, port]
|
|
60
79
|
end
|
|
61
80
|
end
|
|
62
81
|
|
|
63
|
-
ConnectionData.new(host, name, :user => user
|
|
82
|
+
ConnectionData.new(host, name, :user => user && ConnectionData.percent_decode(user),
|
|
83
|
+
:password => password && ConnectionData.percent_decode(password), :port => port)
|
|
64
84
|
end
|
|
65
|
-
|
|
85
|
+
|
|
86
|
+
# Splits "userinfo@host:port" on the LAST '@', since an unencoded
|
|
87
|
+
# password may itself contain one. Returns [userinfo or nil, host:port]
|
|
88
|
+
def ConnectionData.split_lead(lead)
|
|
89
|
+
index = lead.rindex("@")
|
|
90
|
+
index.nil? ? [nil, lead] : [lead[0...index], lead[(index + 1)..-1]]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Splits "user:password" on the FIRST ':'. Returns [user, password or nil]
|
|
94
|
+
def ConnectionData.split_userinfo(userinfo)
|
|
95
|
+
return [nil, nil] if userinfo.nil?
|
|
96
|
+
user, password = userinfo.split(":", 2)
|
|
97
|
+
[user, password]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def ConnectionData.percent_decode(value)
|
|
101
|
+
value.gsub(/%([0-9A-Fa-f]{2})/) { $1.hex.chr }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Yields [user, password] from the url's userinfo and returns the url
|
|
105
|
+
# with the userinfo replaced by the block's result. A url with no
|
|
106
|
+
# userinfo is returned unchanged.
|
|
107
|
+
def ConnectionData.rebuild_lead(url)
|
|
108
|
+
protocol, rest = url.split("//", 2)
|
|
109
|
+
return url if rest.nil?
|
|
110
|
+
lead, name = rest.split("/", 2)
|
|
111
|
+
userinfo, db_host = ConnectionData.split_lead(lead)
|
|
112
|
+
return url if userinfo.nil?
|
|
113
|
+
user, password = ConnectionData.split_userinfo(userinfo)
|
|
114
|
+
new_userinfo = yield(user, password)
|
|
115
|
+
new_lead = new_userinfo.to_s.empty? ? db_host : "#{new_userinfo}@#{db_host}"
|
|
116
|
+
name.nil? ? "#{protocol}//#{new_lead}" : "#{protocol}//#{new_lead}/#{name}"
|
|
117
|
+
end
|
|
118
|
+
|
|
66
119
|
end
|
|
67
120
|
|
|
68
121
|
end
|
|
@@ -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 ""
|
|
@@ -20,7 +20,7 @@ module SchemaEvolutionManager
|
|
|
20
20
|
Preconditions.assert_class(dir, String)
|
|
21
21
|
|
|
22
22
|
if !File.directory?(dir)
|
|
23
|
-
Library.system_or_error("mkdir -p
|
|
23
|
+
Library.system_or_error(["mkdir", "-p", "--", dir])
|
|
24
24
|
end
|
|
25
25
|
Library.assert_dir_exists(dir)
|
|
26
26
|
end
|
|
@@ -38,14 +38,14 @@ module SchemaEvolutionManager
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def Library.git_assert_tag_exists(tag)
|
|
41
|
-
command = "git tag -l"
|
|
41
|
+
command = ["git", "tag", "-l"]
|
|
42
42
|
results = Library.system_or_error(command)
|
|
43
43
|
if results.nil?
|
|
44
44
|
raise "No git tags found"
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
if !Library.tag_exists?(tag)
|
|
48
|
-
raise "Tag[#{tag}] not found. Check #{command}"
|
|
48
|
+
raise "Tag[#{tag}] not found. Check #{Shellwords.join(command)}"
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
@@ -61,11 +61,17 @@ module SchemaEvolutionManager
|
|
|
61
61
|
# no tags, otherwise returns an instance of Version. Only searches for
|
|
62
62
|
# tags matching x.x.x (e.g. 1.0.2)
|
|
63
63
|
def Library.latest_tag
|
|
64
|
-
|
|
64
|
+
Library.git_tags.select { |tag| Version.is_valid?(tag) }.map { |tag| Version.parse(tag) }.sort.last
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# True only when a tag with exactly this name exists - 1.0.10 does
|
|
68
|
+
# not make 1.0.1 exist.
|
|
67
69
|
def Library.tag_exists?(tag)
|
|
68
|
-
|
|
70
|
+
Library.git_tags.include?(tag)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def Library.git_tags
|
|
74
|
+
`git tag -l`.strip.split
|
|
69
75
|
end
|
|
70
76
|
|
|
71
77
|
# Ex: Library.git_create_tag("0.0.1")
|
|
@@ -73,11 +79,11 @@ module SchemaEvolutionManager
|
|
|
73
79
|
Library.assert_valid_tag(tag)
|
|
74
80
|
has_remote = Library.git_has_remote?
|
|
75
81
|
if has_remote
|
|
76
|
-
Library.system_or_error("git fetch --tags origin")
|
|
82
|
+
Library.system_or_error(["git", "fetch", "--tags", "origin"])
|
|
77
83
|
end
|
|
78
|
-
Library.system_or_error("git tag -a -m
|
|
84
|
+
Library.system_or_error(["git", "tag", "-a", "-m", tag, tag])
|
|
79
85
|
if has_remote
|
|
80
|
-
Library.system_or_error("git push --tags origin")
|
|
86
|
+
Library.system_or_error(["git", "push", "--tags", "origin"])
|
|
81
87
|
end
|
|
82
88
|
end
|
|
83
89
|
|
|
@@ -125,24 +131,65 @@ module SchemaEvolutionManager
|
|
|
125
131
|
@@base_dir = Library.normalize_path(value)
|
|
126
132
|
end
|
|
127
133
|
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
|
|
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)
|
|
132
175
|
if Library.is_verbose?
|
|
133
|
-
puts
|
|
176
|
+
puts display
|
|
134
177
|
end
|
|
135
178
|
|
|
136
179
|
begin
|
|
137
|
-
result =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
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]
|
|
144
186
|
end
|
|
145
|
-
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(" ")
|
|
146
193
|
end
|
|
147
194
|
|
|
148
195
|
def Library.normalize_path(path)
|
|
@@ -164,7 +211,7 @@ module SchemaEvolutionManager
|
|
|
164
211
|
Preconditions.check_state(number_changes > 0)
|
|
165
212
|
Preconditions.assert_empty_opts(opts)
|
|
166
213
|
|
|
167
|
-
git_log_command = "git log --pretty=format
|
|
214
|
+
git_log_command = ["git", "log", "--pretty=format:%h %ad | %s%d [%an]", "--date=short", "-#{number_changes}"]
|
|
168
215
|
git_log = Library.system_or_error(git_log_command)
|
|
169
216
|
out = ""
|
|
170
217
|
out << "Created: %s\n" % Library.format_time
|
|
@@ -172,7 +219,7 @@ module SchemaEvolutionManager
|
|
|
172
219
|
out << "Git Tag: %s\n" % tag
|
|
173
220
|
end
|
|
174
221
|
out << "\n"
|
|
175
|
-
out << "%s:\n" % git_log_command
|
|
222
|
+
out << "%s:\n" % Library.command_to_s(git_log_command)
|
|
176
223
|
out << " " << git_log.split("\n").join("\n ") << "\n"
|
|
177
224
|
out
|
|
178
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
|
|
@@ -19,7 +19,7 @@ module SchemaEvolutionManager
|
|
|
19
19
|
exit(4)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
db_args = SchemaEvolutionManager::Args.new(args
|
|
22
|
+
db_args = SchemaEvolutionManager::Args.new(args, :optional => ['url', 'host', 'user', 'name', 'port', 'set'])
|
|
23
23
|
|
|
24
24
|
if db_args.url.to_s.strip.empty? && db_args.name.to_s.strip.empty?
|
|
25
25
|
$stderr.puts "ERROR: Missing database connection. Provide --url <postgres url> or --host/--name."
|
|
@@ -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,8 +1,9 @@
|
|
|
1
1
|
require 'fileutils'
|
|
2
2
|
require 'pathname'
|
|
3
|
-
require 'tempfile'
|
|
4
3
|
require 'tmpdir'
|
|
4
|
+
require 'securerandom'
|
|
5
5
|
require 'shellwords'
|
|
6
|
+
require 'open3'
|
|
6
7
|
|
|
7
8
|
dir = File.dirname(__FILE__)
|
|
8
9
|
lib_dir = File.join(dir, "schema-evolution-manager")
|
|
@@ -29,3 +30,4 @@ load File.join(lib_dir, 'install_template.rb')
|
|
|
29
30
|
load File.join(lib_dir, 'script_error.rb')
|
|
30
31
|
load File.join(lib_dir, 'sem_info.rb')
|
|
31
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-09
|
|
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
|