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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ac86130a475dfedad8e6d56d0bf250b717e44698205fb612e7561096f45c796
4
- data.tar.gz: 79af5be19d627253552a44e73e753865e3b84baf8d91076859fcd269dda1b0e2
3
+ metadata.gz: 60215e02faf6492f2a4e0b263008b6038f95d3ff824417fb6ce615296342ea5f
4
+ data.tar.gz: e9450f3c24a2f54b3f25566c1152a6454de7e7b2eb4750dd0a8021b273b014c6
5
5
  SHA512:
6
- metadata.gz: 4283a52241a30e38cefac92040ea14a4e15f2b9af51912c9ae5017e8599f0e84912b5db8d2198515875c2ffcca84c261a63cde849fa58ee1369499546c4f4504
7
- data.tar.gz: 39152c48787380c306725fd15dabb0ab0574ce0ed3620d405bf0707f3d24252c1ecc91bd41127117e9a005a9bee648ee278e8cad458aea5ae998a6c1693a4fbf
6
+ metadata.gz: 6673d52ee37258fee586cd320799eeff9e6477d963a98c79f737f1fc8f44009dcbc076ed26dc8e900ef970e79516d1d115d125b6ec4d23da45d1b5903508ca8b
7
+ data.tar.gz: 3081446f6663a18bf00523332c0d9363e9b3322c9a900561205e1d90509698f4a6899824087a202ba9fdc5f6487fd9ab475574fbca246c7c0e156f691ceb700a
data/README.md CHANGED
@@ -1,5 +1,3 @@
1
- [![Build Status](https://travis-ci.org/mbryzek/schema-evolution-manager.svg?branch=main)](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.60
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 = File.join(`pwd`.strip, "scripts")
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 #{file} #{target}")
37
- SchemaEvolutionManager::Library.system_or_error("git add #{target}")
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!("./scripts")
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 = "./VERSION"
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.url}"
28
+ puts "Baselining schema for #{db.sanitized_url}"
28
29
 
29
30
  begin
30
- count = util.apply!("./scripts")
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 2>/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
- repo_path = SchemaEvolutionManager::Library.normalize_path(`pwd`.strip)
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
- SchemaEvolutionManager::Library.system_or_error("cp -R %s %s" % [File.join(repo_path, "scripts"), File.join(tmpdir, "scripts")])
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
- SchemaEvolutionManager::Library.system_or_error("xattr -cr #{tmpdir} 2>/dev/null || true")
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("COPYFILE_DISABLE=1 #{tar_cmd} #{tar_opts} -cf #{filename}.tar #{filename}")
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
- command = "gzip #{tarball}"
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
- "url" => db.url,
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 #{wrapper}")
61
- SchemaEvolutionManager::Library.system_or_error("git add #{wrapper}")
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 'Add schema-evolution-manager wrapper scripts' #{wrappers.join(" ")}")
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 #{readme}")
74
- SchemaEvolutionManager::Library.system_or_error("git commit -m 'Adding README.md' README.md")
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 !File.exist?("scripts")
78
- SchemaEvolutionManager::Library.system_or_error("mkdir scripts")
79
- SchemaEvolutionManager::Library.system_or_error("touch scripts/.exists")
80
- SchemaEvolutionManager::Library.system_or_error("git add scripts/.exists")
81
- SchemaEvolutionManager::Library.system_or_error("git commit -m 'Adding scripts directory' scripts/.exists")
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 #{branch}")
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: Actual string arguments
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.assert_class_or_nil(args, String)
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
- values = ARGV.join(" ")
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
@@ -54,7 +54,7 @@ module SchemaEvolutionManager
54
54
  input = STDIN.gets
55
55
  puts ""
56
56
  ensure
57
- `stty #{settings}`
57
+ system("stty", settings)
58
58
  end
59
59
  input
60
60
  end
@@ -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
- [@host, @port, @name, @user, password.to_s].join(":")
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://" % url
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" % url
69
+ raise "Invalid url[%s]. Missing database name" % display
45
70
  end
46
71
 
47
- parts = lead.split("@", 2)
48
- if parts.size == 2
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" % [url, port]
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, :port => port)
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
- attr_reader :url, :psql_executable_with_options
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
- @url = Preconditions.check_not_blank(url, "url cannot be blank")
20
+ Preconditions.check_not_blank(url, "url cannot be blank")
9
21
  password = opts.delete(:password)
10
22
 
11
- @psql_executable_with_options = "psql"
23
+ @psql_args = ["psql"]
12
24
  (opts.delete(:set) || []).each do |arg|
13
- @psql_executable_with_options << " --set #{arg}"
25
+ @psql_args += ["--set", arg]
14
26
  end
15
27
 
16
28
  Preconditions.assert_empty_opts(opts)
17
- connection_data = ConnectionData.parse_url(@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
- template = "#{@psql_executable_with_options} --no-align --tuples-only --no-psqlrc --command \"%s\" %s"
38
- command = template % [sql_command, Shellwords.escape(@url)]
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
- options = Db.attribute_values(path).join(" ")
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
- command = "#{@psql_executable_with_options} --file \"%s\" #{options} %s" % [tmp, Shellwords.escape(@url)]
83
-
84
- Library.with_temp_file do |output|
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
- file = Tempfile.new("sem-db")
132
- file.write(contents)
133
- file.rewind
134
- file.path
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
- # Returns a sanitized version of the URL with the password removed
138
- # to prevent passwords from being logged or displayed in error messages
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
- # Parse the URL to extract components
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
- template.add('lib_dir', @lib_dir)
16
- template.add('bin_dir', @bin_dir)
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 %s" % path)
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 = '%%lib_dir%%'
39
- bin_dir = '%%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 %s %s" % [version_name, 'schema-evolution-manager'])
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 %s %s" % [version_name, 'schema-evolution-manager'])
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 %s %s" % [filename, version_dir])
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 %s %s" % [path, this_dir])
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 %s/%s" % [this_dir, filename])
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 %s && ln -s %s" % [filename, path])
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 File.join('%s')\n" % File.join(version_dir, 'lib/schema-evolution-manager.rb')
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 #{dir}")
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
- `git tag -l`.strip.split.select { |tag| Version.is_valid?(tag) }.map { |tag| Version.parse(tag) }.sort.last
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
- `git tag -l`.strip.include?(tag)
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 #{tag} #{tag}")
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
- # Runs the specified command, raising an error if there is a problem
129
- # (based on status code of the process executed). Otherwise returns
130
- # all the output from the script invoked.
131
- def Library.system_or_error(command, cmd_to_log=nil)
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 cmd_to_log || command
176
+ puts display
134
177
  end
135
178
 
136
179
  begin
137
- result = `#{command}`.strip
138
- status = $?
139
- if status.to_i > 0
140
- raise "Non zero exit code[%s] running command[%s]" % [status, command]
141
- end
142
- rescue Exception => e
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:\"%h %ad | %s%d [%an]\" --date=short -#{number_changes}"
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
- stripped = line.strip
90
- if stripped.match(/^\-\-\s+sem\.attribute\./)
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 = '%s'" % [Db.schema_name, @table_name, 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
- 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]
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
- sql = "select filename from %s.%s where filename in (%s)" % [Db.schema_name, @table_name, "'" + scripts.join("', '") + "'"]
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.join(" "), :optional => ['url', 'host', 'user', 'name', 'port', 'set'])
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."
@@ -3,7 +3,7 @@ module SchemaEvolutionManager
3
3
 
4
4
  module SemVersion
5
5
 
6
- VERSION ||= '0.9.60'
6
+ VERSION ||= '0.9.61'
7
7
 
8
8
  end
9
9
 
@@ -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
- string = string.gsub(/%%#{sub.pattern}%%/, sub.value)
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
@@ -6,7 +6,7 @@
6
6
  #
7
7
 
8
8
  Dir.chdir(File.dirname($0)) {
9
- command = "sem-apply --url %%url%%"
10
- puts command
11
- exec(command)
9
+ command = ["sem-apply", "--url", %%url_literal%%]
10
+ puts command.join(" ")
11
+ exec(*command)
12
12
  }
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.60
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-27 00:00:00.000000000 Z
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