schema-evolution-manager 0.9.58 → 0.9.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 ""
@@ -2,8 +2,15 @@ module SchemaEvolutionManager
2
2
  class Library
3
3
 
4
4
  unless defined?(TMPFILE_DIR)
5
- TMPFILE_DIR = "/tmp"
5
+ # A directory private to this process. Temp file names below are
6
+ # "<prefix>.<counter>", and sem-apply uses a migration's basename as the
7
+ # prefix, so two processes applying the same scripts would otherwise
8
+ # compute the same path and delete each other's copy mid-run. mktmpdir
9
+ # honours TMPDIR and creates the directory mode 0700 with a random name.
10
+ TMPFILE_DIR = Dir.mktmpdir("schema-evolution-manager-")
6
11
  TMPFILE_PREFIX = "schema-evolution-manager-#{Process.pid}.tmp"
12
+ tmpfile_dir_owner = Process.pid
13
+ at_exit { FileUtils.rm_rf(TMPFILE_DIR) if Process.pid == tmpfile_dir_owner }
7
14
  end
8
15
  @@tmpfile_count = 0
9
16
  @@verbose = false
@@ -13,7 +20,7 @@ module SchemaEvolutionManager
13
20
  Preconditions.assert_class(dir, String)
14
21
 
15
22
  if !File.directory?(dir)
16
- Library.system_or_error("mkdir -p #{dir}")
23
+ Library.system_or_error(["mkdir", "-p", "--", dir])
17
24
  end
18
25
  Library.assert_dir_exists(dir)
19
26
  end
@@ -31,14 +38,14 @@ module SchemaEvolutionManager
31
38
  end
32
39
 
33
40
  def Library.git_assert_tag_exists(tag)
34
- command = "git tag -l"
41
+ command = ["git", "tag", "-l"]
35
42
  results = Library.system_or_error(command)
36
43
  if results.nil?
37
44
  raise "No git tags found"
38
45
  end
39
46
 
40
47
  if !Library.tag_exists?(tag)
41
- raise "Tag[#{tag}] not found. Check #{command}"
48
+ raise "Tag[#{tag}] not found. Check #{Shellwords.join(command)}"
42
49
  end
43
50
  end
44
51
 
@@ -54,11 +61,17 @@ module SchemaEvolutionManager
54
61
  # no tags, otherwise returns an instance of Version. Only searches for
55
62
  # tags matching x.x.x (e.g. 1.0.2)
56
63
  def Library.latest_tag
57
- `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
58
65
  end
59
66
 
67
+ # True only when a tag with exactly this name exists - 1.0.10 does
68
+ # not make 1.0.1 exist.
60
69
  def Library.tag_exists?(tag)
61
- `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
62
75
  end
63
76
 
64
77
  # Ex: Library.git_create_tag("0.0.1")
@@ -66,11 +79,11 @@ module SchemaEvolutionManager
66
79
  Library.assert_valid_tag(tag)
67
80
  has_remote = Library.git_has_remote?
68
81
  if has_remote
69
- Library.system_or_error("git fetch --tags origin")
82
+ Library.system_or_error(["git", "fetch", "--tags", "origin"])
70
83
  end
71
- Library.system_or_error("git tag -a -m #{tag} #{tag}")
84
+ Library.system_or_error(["git", "tag", "-a", "-m", tag, tag])
72
85
  if has_remote
73
- Library.system_or_error("git push --tags origin")
86
+ Library.system_or_error(["git", "push", "--tags", "origin"])
74
87
  end
75
88
  end
76
89
 
@@ -118,24 +131,65 @@ module SchemaEvolutionManager
118
131
  @@base_dir = Library.normalize_path(value)
119
132
  end
120
133
 
121
- # Runs the specified command, raising an error if there is a problem
122
- # (based on status code of the process executed). Otherwise returns
123
- # all the output from the script invoked.
124
- 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)
125
175
  if Library.is_verbose?
126
- puts cmd_to_log || command
176
+ puts display
127
177
  end
128
178
 
129
179
  begin
130
- result = `#{command}`.strip
131
- status = $?
132
- if status.to_i > 0
133
- raise "Non zero exit code[%s] running command[%s]" % [status, command]
134
- end
135
- rescue Exception => e
136
- raise "Error running command[%s]: %s" % [command, e.to_s]
180
+ result, status = Open3.capture2(env, *command)
181
+ rescue SystemCallError => e
182
+ raise "Error running command[%s]: %s" % [display, e.to_s]
183
+ end
184
+ if !status.success?
185
+ raise "Non zero exit code[%s] running command[%s]" % [status, display]
137
186
  end
138
- result
187
+ result.strip
188
+ end
189
+
190
+ # A shell-quoted rendering of an argv, for logs and error messages
191
+ def Library.command_to_s(command, env={})
192
+ (env.map { |k, v| "%s=%s" % [k, Shellwords.escape(v)] } + [Shellwords.join(command)]).join(" ")
139
193
  end
140
194
 
141
195
  def Library.normalize_path(path)
@@ -157,7 +211,7 @@ module SchemaEvolutionManager
157
211
  Preconditions.check_state(number_changes > 0)
158
212
  Preconditions.assert_empty_opts(opts)
159
213
 
160
- git_log_command = "git log --pretty=format:\"%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}"]
161
215
  git_log = Library.system_or_error(git_log_command)
162
216
  out = ""
163
217
  out << "Created: %s\n" % Library.format_time
@@ -165,7 +219,7 @@ module SchemaEvolutionManager
165
219
  out << "Git Tag: %s\n" % tag
166
220
  end
167
221
  out << "\n"
168
- out << "%s:\n" % git_log_command
222
+ out << "%s:\n" % Library.command_to_s(git_log_command)
169
223
  out << " " << git_log.split("\n").join("\n ") << "\n"
170
224
  out
171
225
  end
@@ -42,6 +42,7 @@ module SchemaEvolutionManager
42
42
 
43
43
  unless defined?(DEFAULTS)
44
44
  DEFAULTS = [AttributeValue.new("transaction", "single")]
45
+ PROPERTY_PREFIX = /^\-\-\s+sem\.attribute\./
45
46
  end
46
47
 
47
48
  attr_reader :path, :attribute_values
@@ -86,11 +87,8 @@ module SchemaEvolutionManager
86
87
  File.foreach(path, mode: "rb") do |raw|
87
88
  line = raw.force_encoding("UTF-8")
88
89
  line = line.scrub unless line.valid_encoding?
89
- 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
@@ -12,23 +12,49 @@ module SchemaEvolutionManager
12
12
 
13
13
  if !valid.include?(subcommand)
14
14
  if subcommand.empty?
15
- puts "ERROR: Missing db subcommand. Must be one of: %s" % valid.join(", ")
15
+ $stderr.puts "ERROR: Missing db subcommand. Must be one of: %s" % valid.join(", ")
16
16
  else
17
- puts "ERROR: Invalid db subcommand[%s]. Must be one of: %s" % [subcommand, valid.join(", ")]
17
+ $stderr.puts "ERROR: Invalid db subcommand[%s]. Must be one of: %s" % [subcommand, valid.join(", ")]
18
18
  end
19
19
  exit(4)
20
20
  end
21
21
 
22
- db_args = SchemaEvolutionManager::Args.new(args.join(" "), :optional => ['url', 'host', 'user', 'name', 'port', 'set'])
23
- db = SchemaEvolutionManager::Db.from_args(db_args)
22
+ db_args = SchemaEvolutionManager::Args.new(args, :optional => ['url', 'host', 'user', 'name', 'port', 'set'])
24
23
 
25
- if subcommand == "version"
26
- version = SchemaEvolutionManager::Versions.new(db).latest
27
- puts version unless version.nil?
28
- elsif subcommand == "scripts"
29
- SchemaEvolutionManager::Scripts.new(db, SchemaEvolutionManager::Scripts::SCRIPTS).applied.each do |filename|
30
- puts filename
24
+ if db_args.url.to_s.strip.empty? && db_args.name.to_s.strip.empty?
25
+ $stderr.puts "ERROR: Missing database connection. Provide --url <postgres url> or --host/--name."
26
+ exit(3)
27
+ end
28
+
29
+ # A common mistake is passing a bare database name to --url. Catch it with
30
+ # an actionable hint instead of letting parse_url raise a raw backtrace.
31
+ if db_args.url && !db_args.url.include?("://")
32
+ $stderr.puts "ERROR: --url must be a full connection string (e.g. postgres://localhost:5432/%s). To connect by database name, use --name %s." % [db_args.url, db_args.url]
33
+ exit(3)
34
+ end
35
+
36
+ begin
37
+ db = SchemaEvolutionManager::Db.from_args(db_args)
38
+ if subcommand == "version"
39
+ version = SchemaEvolutionManager::Versions.new(db).latest
40
+ if version.nil?
41
+ $stderr.puts "No deployed version recorded"
42
+ else
43
+ puts version
44
+ end
45
+ elsif subcommand == "scripts"
46
+ applied = SchemaEvolutionManager::Scripts.new(db, SchemaEvolutionManager::Scripts::SCRIPTS).applied
47
+ if applied.empty?
48
+ $stderr.puts "No scripts recorded"
49
+ else
50
+ applied.each { |filename| puts filename }
51
+ end
31
52
  end
53
+ rescue => e
54
+ # Surface a single clean line (e.g. bad url, unreachable host, missing
55
+ # database) rather than a Ruby stack trace.
56
+ $stderr.puts "ERROR: %s" % e.message.to_s.split("\n").first
57
+ exit(3)
32
58
  end
33
59
  end
34
60
 
@@ -3,7 +3,7 @@ module SchemaEvolutionManager
3
3
 
4
4
  module SemVersion
5
5
 
6
- VERSION ||= '0.9.43'
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,7 +1,9 @@
1
1
  require 'fileutils'
2
2
  require 'pathname'
3
- require 'tempfile'
3
+ require 'tmpdir'
4
+ require 'securerandom'
4
5
  require 'shellwords'
6
+ require 'open3'
5
7
 
6
8
  dir = File.dirname(__FILE__)
7
9
  lib_dir = File.join(dir, "schema-evolution-manager")
@@ -28,3 +30,4 @@ load File.join(lib_dir, 'install_template.rb')
28
30
  load File.join(lib_dir, 'script_error.rb')
29
31
  load File.join(lib_dir, 'sem_info.rb')
30
32
  load File.join(lib_dir, 'migration_file.rb')
33
+ load File.join(lib_dir, 'config.rb')
data/template/dev.rb CHANGED
@@ -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.58
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-07-17 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
@@ -72,7 +73,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
72
73
  - !ruby/object:Gem::Version
73
74
  version: '0'
74
75
  requirements: []
75
- rubygems_version: 3.7.1
76
+ rubygems_version: 4.0.17
76
77
  specification_version: 4
77
78
  summary: Schema evolution manager is a simple schema migration tool for postgresql
78
79
  databases.