schema-evolution-manager 0.9.74 → 0.9.75

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: 547c6eb4a33514444b83da617c79fecfff22ffe1afbb1133853525ac119a603f
4
- data.tar.gz: 5ea3f1f9e021af98a52fae4a02ddd26cd978b5eb2e08c8698f39e02f003610fc
3
+ metadata.gz: ab56a47b14bfc595e3cdad6ba15edef70ea02b50dbdb1c4b43a72e832937ca20
4
+ data.tar.gz: d035c43de9a7e0de1d12ff1f95fcc9acde7fdf0916b2827f5f1bbb604c317260
5
5
  SHA512:
6
- metadata.gz: 66660ad71948a70a7da979229af57e8a2da0c0934658adc4d24c0c4287039607257a37ff9ceacae82216611279e8667f09761fb213448deb3ee3297a8043d638
7
- data.tar.gz: 2aec0f7a2c10ffe7a3d7725de4f9825df921b173bd98f85f95d004569ae24fa102ae879031097462774d6fce05a06b733c4870bc01e7fa2917bd52ad7a8678f5
6
+ metadata.gz: 6f1eb21b2c631d38a38806274f2f7e87d6ad15e74f28b742842a3a746a3e0c2ecf4e4e517fd2494c61fbc15c9d2b3f7917fd13d201778d1b129147e909f27200
7
+ data.tar.gz: 3c24490ba5ee1e285c8e28dbb36cb79f0a441f9a9d9efa02ff3cca8f3027ed2d64b0356033826e4e0b99db0ef73a00c907afa2b4e812f560817d6a4945b0bc01
data/README.md CHANGED
@@ -149,7 +149,7 @@ There are three ways to install schema evolution manager:
149
149
 
150
150
  git clone git@github.com:mbryzek/schema-evolution-manager.git
151
151
  cd schema-evolution-manager
152
- git checkout 0.9.74
152
+ git checkout 0.9.75
153
153
  ruby ./configure.rb
154
154
  sudo ./install.rb
155
155
 
@@ -244,7 +244,13 @@ deploy process.
244
244
 
245
245
  sem-apply --url postgresql://postgres@localhost/sample --dry_run
246
246
 
247
- You will likely see a number of create table statements (see data model section below). You should also see:
247
+ A dry run writes nothing to the database: not your scripts, and not sem's own
248
+ schema either. On a database sem has not yet installed itself in, it lists the
249
+ bootstrap scripts that would create that schema (see data model section below):
250
+
251
+ [DRY RUN] Bootstrapping 20130318-105434.sql
252
+
253
+ You should also see:
248
254
 
249
255
  [DRY RUN] Applying 20130318-214407.sql
250
256
 
@@ -259,7 +265,7 @@ There are two recommended ways in which to pass user passwords to psql:
259
265
  file](http://www.postgresql.org/docs/9.4/static/libpq-pgpass.html)
260
266
  with the appropriate credentials</li>
261
267
 
262
- 2. Specify a [--password] flag when running sem-apply. You will
268
+ 2. Specify a [--password] flag when running sem-apply or sem-baseline. You will
263
269
  then be prompted to enter your password once. sem will create a
264
270
  temporary file to store your password, using that file during the
265
271
  duration of the command and ensuring the file is deleted after sem
data/bin/sem-add CHANGED
@@ -13,12 +13,11 @@ load File.join(File.dirname(__FILE__), 'sem-config')
13
13
 
14
14
  file = ARGV.shift
15
15
  if file.to_s.strip == ""
16
- puts "**** ERROR: Need file path"
17
- SchemaEvolutionManager::RdocUsage.printAndExit(1)
16
+ SchemaEvolutionManager::RdocUsage.error!("Missing file path", SchemaEvolutionManager::RdocUsage::MISSING_INPUT, :usage => true)
18
17
  end
19
18
 
20
19
  SchemaEvolutionManager::Preconditions.check_state(File.exist?(file), "File[#{file}] could not be found")
21
- SchemaEvolutionManager::Preconditions.check_state(file.match(/\.sql/i), "File[#{file}] must end with .sql")
20
+ SchemaEvolutionManager::Preconditions.check_state(file.match?(/\.sql\z/i), "File[#{file}] must end with .sql")
22
21
 
23
22
  scripts_dir = SchemaEvolutionManager::Config.load.scripts_dir
24
23
  SchemaEvolutionManager::Library.ensure_dir!(scripts_dir)
data/bin/sem-apply CHANGED
@@ -15,20 +15,14 @@ require 'tempfile'
15
15
 
16
16
  load File.join(File.dirname(__FILE__), 'sem-config')
17
17
 
18
- args = SchemaEvolutionManager::Args.from_stdin(:optional => %w(url host port name user dry_run password set))
19
-
20
- password = if args.password
21
- SchemaEvolutionManager::Ask.for_password("Please enter your password: ")
22
- else
23
- nil
24
- end
18
+ args = SchemaEvolutionManager::Args.from_stdin(:optional => SchemaEvolutionManager::Db::CONNECTION_FLAGS + %w(dry_run password))
19
+ db = SchemaEvolutionManager::Db.from_command_line(args)
25
20
 
26
21
  config = SchemaEvolutionManager::Config.load
27
- db = SchemaEvolutionManager::Db.from_args(args, :password => password)
28
22
  util = SchemaEvolutionManager::ApplyUtil.new(db, :dry_run => args.dry_run || false)
29
23
 
30
24
  begin
31
- db.bootstrap!
25
+ db.bootstrap!(:dry_run => util.dry_run?)
32
26
 
33
27
  puts "Upgrading schema for #{db.sanitized_url}"
34
28
  count = util.apply!(config.scripts_dir)
@@ -59,7 +53,11 @@ rescue SchemaEvolutionManager::ScriptError => e
59
53
  end
60
54
 
61
55
  puts ""
62
- puts "If this script has previously been applied to this database, you can record it as having been applied by:"
56
+ if e.applied?
57
+ puts "This script was applied, but recording it as applied failed. Record it before the next apply, which would otherwise run it again:"
58
+ else
59
+ puts "If this script has previously been applied to this database, you can record it as having been applied by:"
60
+ end
63
61
  puts " " + e.dml
64
62
  puts ""
65
63
  exit(1)
data/bin/sem-baseline CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
- # == Makes sure that sem is bootstapped, and then adds all existing scripts to the sem-database without applying
2
+ # == Makes sure that sem is bootstrapped, and then adds all existing scripts to the sem-database without applying
3
3
  # Useful for migration to sem
4
4
  #
5
5
  # == Usage
@@ -9,19 +9,19 @@
9
9
  #
10
10
  # == Examples
11
11
  # sem-baseline --url postgresql://postgres@localhost/sample
12
- # sem-apply --host localhost --user web --name test
12
+ # sem-baseline --host localhost --user web --name test
13
13
  #
14
14
 
15
15
 
16
16
  load File.join(File.dirname(__FILE__), 'sem-config')
17
17
 
18
- args = SchemaEvolutionManager::Args.from_stdin(:optional => %w(url host port name user dry_run))
18
+ args = SchemaEvolutionManager::Args.from_stdin(:optional => SchemaEvolutionManager::Db::CONNECTION_FLAGS + %w(dry_run password))
19
+ db = SchemaEvolutionManager::Db.from_command_line(args)
19
20
 
20
21
  config = SchemaEvolutionManager::Config.load
21
- db = SchemaEvolutionManager::Db.from_args(args)
22
- db.bootstrap!
23
22
 
24
23
  dry_run = args.dry_run.nil? ? false : args.dry_run
24
+ db.bootstrap!(:dry_run => dry_run)
25
25
 
26
26
  util = SchemaEvolutionManager::BaselineUtil.new(db, :dry_run => dry_run)
27
27
 
data/bin/sem-info CHANGED
@@ -29,6 +29,13 @@
29
29
  # libraries. Initially designed to help with wrapper scripts that
30
30
  # needed information (like the next minor tag)
31
31
  #
32
+ # Exit status
33
+ #
34
+ # Errors go to stderr only, so stdout holds nothing but the answer.
35
+ # 1: the command failed (e.g. the database could not be read)
36
+ # 3: missing input (a command, subcommand, tag or connection)
37
+ # 4: invalid input (an unrecognized value or a malformed --url)
38
+ #
32
39
 
33
40
  load File.join(File.dirname(__FILE__), 'sem-config')
34
41
 
@@ -43,10 +50,9 @@ elsif command == "db"
43
50
  elsif command == "version"
44
51
  puts SchemaEvolutionManager::SemInfo.version(ARGV)
45
52
 
53
+ elsif command == ""
54
+ SchemaEvolutionManager::RdocUsage.error!("Missing command", SchemaEvolutionManager::RdocUsage::MISSING_INPUT, :usage => true)
55
+
46
56
  else
47
- if command != ""
48
- puts "ERROR: Unrecognized command[%s]" % command
49
- puts ""
50
- end
51
- SchemaEvolutionManager::RdocUsage.printAndExit(1)
57
+ SchemaEvolutionManager::RdocUsage.error!("Unrecognized command[%s]" % command, SchemaEvolutionManager::RdocUsage::INVALID_INPUT, :usage => true)
52
58
  end
data/bin/sem-init CHANGED
@@ -21,15 +21,9 @@
21
21
  load File.join(File.dirname(__FILE__), 'sem-config')
22
22
  SchemaEvolutionManager::Library.set_verbose(true)
23
23
 
24
- args = SchemaEvolutionManager::Args.from_stdin(:required => %w(dir), :optional => %w(url host port name user scripts_dir))
24
+ args = SchemaEvolutionManager::Args.from_stdin(:required => %w(dir), :optional => SchemaEvolutionManager::Db::CONNECTION_FLAGS + %w(scripts_dir))
25
25
  SchemaEvolutionManager::Preconditions.check_state(File.directory?(args.dir), "Dir[%s] does not exist" % args.dir)
26
-
27
- if args.url.nil? && args.name.nil?
28
- puts "Must specify either url or name"
29
- exit(1)
30
- end
31
-
32
- db = SchemaEvolutionManager::Db.from_args(args)
26
+ db = SchemaEvolutionManager::Db.from_command_line(args)
33
27
 
34
28
  def copy_file(source, target, substitutions)
35
29
  template = SchemaEvolutionManager::Template.new
@@ -72,14 +66,6 @@ Dir.chdir(args.dir) do
72
66
  SchemaEvolutionManager::Library.system_or_error(["git", "commit", "-m", "Add schema-evolution-manager wrapper scripts", "--"] + wrappers)
73
67
  end
74
68
 
75
- readme = "README.md"
76
- if !File.exist?(readme)
77
- puts "Creating #{readme}"
78
- copy_file("schema-evolution-manager/template/README.md", "README.md", subs)
79
- SchemaEvolutionManager::Library.system_or_error(["git", "add", "--", readme])
80
- SchemaEvolutionManager::Library.system_or_error(["git", "commit", "-m", "Adding README.md", "--", readme])
81
- end
82
-
83
69
  if args.scripts_dir
84
70
  config_file = SchemaEvolutionManager::Config::FILENAME
85
71
  if File.exist?(config_file)
@@ -104,8 +90,7 @@ Dir.chdir(args.dir) do
104
90
  end
105
91
 
106
92
  # Create the first git tag if necessary
107
- tags = SchemaEvolutionManager::Library.system_or_error(["git", "tag", "-l"])
108
- if tags.nil? || tags == ""
93
+ if SchemaEvolutionManager::Library.git_tags.empty?
109
94
  puts "Creating initial git tag (0.0.1)"
110
95
  SchemaEvolutionManager::Library.git_create_tag("0.0.1")
111
96
  end
@@ -1,41 +1,15 @@
1
1
  module SchemaEvolutionManager
2
2
 
3
- class ApplyUtil
3
+ # Runs each pending script and records it as run.
4
+ class ApplyUtil < PendingScriptsUtil
4
5
 
5
- def initialize(db, opts={})
6
- @dry_run = opts.delete(:dry_run)
7
- if @dry_run.nil?
8
- @dry_run = true
9
- end
10
-
11
- @db = Preconditions.assert_class(db, Db)
12
- @scripts = Scripts.new(@db, Scripts::SCRIPTS)
13
- end
14
-
15
- def dry_run?
16
- @dry_run
6
+ private
7
+ def label
8
+ "Applying"
17
9
  end
18
10
 
19
- # Applies scripts in order, returning number of scripts applied
20
- def apply!(dir)
21
- Preconditions.check_state(File.directory?(dir),
22
- "Dir[%s] does not exist" % dir)
23
-
24
- count = 0
25
- @scripts.each_pending(dir) do |filename, path|
26
- count += 1
27
- if @dry_run
28
- puts "[DRY RUN] Applying #{filename}"
29
- puts path
30
- puts ""
31
- else
32
- print "Applying #{filename}"
33
- @db.psql_file(filename, path)
34
- @scripts.record_as_run!(filename)
35
- puts " Done"
36
- end
37
- end
38
- count
11
+ def execute?
12
+ true
39
13
  end
40
14
 
41
15
  end
@@ -61,6 +61,10 @@ module SchemaEvolutionManager
61
61
  end
62
62
 
63
63
  found_arguments = parse_string_arguments(args)
64
+ not_accepted = found_arguments.keys - required - optional
65
+ Preconditions.check_state(not_accepted.empty?,
66
+ "Flag[%s] is not accepted by this command. Accepted: %s" %
67
+ [not_accepted.map { |f| "--#{f}" }.join(" "), (required + optional).map { |f| "--#{f}" }.join(" ")])
64
68
  missing = required.select { |field| blank?(found_arguments[field]) }
65
69
 
66
70
  @artifact_name = found_arguments.delete(:artifact_name)
@@ -76,7 +80,7 @@ module SchemaEvolutionManager
76
80
  @set = found_arguments.delete(:set) || []
77
81
 
78
82
  @dry_run = found_arguments.delete(:dry_run)
79
- @non_interactice = found_arguments.delete(:non_interactive)
83
+ @non_interactive = found_arguments.delete(:non_interactive)
80
84
  @password = found_arguments.delete(:password)
81
85
  @help = found_arguments.delete(:help)
82
86
  @verbose = found_arguments.delete(:verbose)
@@ -120,12 +124,8 @@ module SchemaEvolutionManager
120
124
  title = fields.size == 1 ? "Missing parameter" : "Missing parameters"
121
125
  sorted = fields.sort_by { |f| f.to_s }
122
126
 
123
- puts "**************************************************"
124
- puts "ERROR: #{title}: #{sorted.join(", ")}"
125
- puts "**************************************************"
126
- puts help_parameters("Required parameters", required)
127
- puts help_parameters("Optional parameters", optional)
128
- exit(1)
127
+ details = [help_parameters("Required parameters", required), help_parameters("Optional parameters", optional)]
128
+ RdocUsage.error!("#{title}: #{sorted.join(", ")}", RdocUsage::MISSING_INPUT, :details => details)
129
129
  end
130
130
 
131
131
  def help_parameters(title, parameters)
@@ -155,6 +155,7 @@ module SchemaEvolutionManager
155
155
  index += 1
156
156
 
157
157
  if FLAGS_WITH_ARGUMENTS.has_key?(flag)
158
+ Preconditions.check_state(index < values.length, "Flag[--%s] needs a value" % flag)
158
159
  if ARRAY_FLAGS.include?(flag)
159
160
  found[flag] ||= []
160
161
  found[flag] << values[index]
@@ -13,7 +13,7 @@ module SchemaEvolutionManager
13
13
  # to the screen. If false, we do NOT echo.
14
14
  def Ask.for_string(message, opts={})
15
15
  default = opts.delete(:default)
16
- echo = opts[:echo].nil? ? true : opts.delete(:echo)
16
+ echo = opts.has_key?(:echo) ? opts.delete(:echo) : true
17
17
  Preconditions.assert_empty_opts(opts)
18
18
 
19
19
  final_message = message.dup
@@ -24,9 +24,15 @@ module SchemaEvolutionManager
24
24
  value = nil
25
25
  while value.to_s == ""
26
26
  print final_message
27
- value = get_input(echo).to_s.strip
27
+ input = get_input(echo)
28
+ value = input.to_s.strip
28
29
  if value.to_s == "" && default
29
30
  value = default.to_s.strip
31
+ elsif input.nil?
32
+ # Standard input is closed (a pipe, /dev/null, a CI job): asking
33
+ # again reads nothing again, forever.
34
+ puts ""
35
+ raise "No input for[%s]: standard input is closed" % message.strip
30
36
  end
31
37
  end
32
38
  value
@@ -1,48 +1,15 @@
1
1
  module SchemaEvolutionManager
2
2
 
3
- class BaselineUtil
4
-
5
- def initialize(db, opts={})
6
- @dry_run = opts.delete(:dry_run)
7
- if @dry_run.nil?
8
- @dry_run = true
9
- end
10
-
11
- @db = Preconditions.assert_class(db, Db)
12
- @scripts = Scripts.new(@db, Scripts::SCRIPTS)
13
- end
14
-
15
- def dry_run?
16
- @dry_run
17
- end
18
-
19
- # Applies scripts in order, returning number of scripts applied
20
- def apply!(dir)
21
- Preconditions.check_state(File.directory?(dir),
22
- "Dir[%s] does not exist" % dir)
23
-
24
- count = 0
25
- @scripts.each_pending(dir) do |filename, path|
26
- count += 1
27
- if @dry_run
28
- puts "[DRY RUN] Baselining #{filename}"
29
- apply_dry_run(filename, path)
30
- else
31
- puts "Baselining #{filename}"
32
- apply_real(filename, path)
33
- end
34
- end
35
- count
36
- end
3
+ # Records each pending script as run without running it.
4
+ class BaselineUtil < PendingScriptsUtil
37
5
 
38
6
  private
39
- def apply_dry_run(filename, path)
40
- puts path
41
- puts ""
7
+ def label
8
+ "Baselining"
42
9
  end
43
10
 
44
- def apply_real(filename, path)
45
- @scripts.record_as_run!(filename)
11
+ def execute?
12
+ false
46
13
  end
47
14
 
48
15
  end
@@ -2,6 +2,17 @@ module SchemaEvolutionManager
2
2
 
3
3
  class Db
4
4
 
5
+ # Every flag that names a database connection. A command that opens a
6
+ # Db accepts these plus its own flags (dry_run, password, scripts_dir)
7
+ CONNECTION_FLAGS = %w(url host port name user set) unless defined?(CONNECTION_FLAGS)
8
+
9
+ # The connection flags do not name a database: neither --url nor
10
+ # --name, or a --url that is not a connection string
11
+ class InvalidConnection < RuntimeError; end
12
+
13
+ # Neither --url nor --name was given
14
+ class MissingConnection < InvalidConnection; end
15
+
5
16
  # psql_args: the psql executable and its global options, as an argv
6
17
  attr_reader :url, :psql_args
7
18
 
@@ -36,22 +47,43 @@ module SchemaEvolutionManager
36
47
  end
37
48
 
38
49
  # Installs schema_evolution_manager. Automatically upgrades schema_evolution_manager.
39
- def bootstrap!
50
+ #
51
+ # @param dry_run: When true, prints each bootstrap script that would run
52
+ # and writes nothing: the schema is neither created nor upgraded. The
53
+ # pending-script reads answer for an absent schema, so a dry run of the
54
+ # scripts that follow needs no bootstrap. Returns the number of
55
+ # bootstrap scripts run (or, in a dry run, that would run).
56
+ def bootstrap!(opts={})
57
+ dry_run = opts.delete(:dry_run) || false
58
+ Preconditions.assert_empty_opts(opts)
59
+
40
60
  scripts = Scripts.new(self, Scripts::BOOTSTRAP_SCRIPTS)
41
61
  dir = File.join(Library.base_dir, "scripts")
62
+ count = 0
42
63
  scripts.each_pending(dir) do |filename, path|
43
- psql_file(filename, path)
44
- scripts.record_as_run!(filename)
64
+ count += 1
65
+ if dry_run
66
+ puts "[DRY RUN] Bootstrapping #{filename}"
67
+ else
68
+ psql_file(filename, path, scripts)
69
+ scripts.record_as_run!(filename)
70
+ end
45
71
  end
72
+ count
46
73
  end
47
74
 
48
75
  # executes a simple sql command.
49
76
  def psql_command(sql_command)
50
- Preconditions.assert_class(sql_command, String)
51
- command = @psql_args + PSQL_ISOLATION_OPTIONS + ["--no-align", "--tuples-only", "--command", sql_command]
77
+ command = psql_command_args(sql_command)
52
78
  Library.system_or_error(command + [@url], :log => command + [sanitized_url])
53
79
  end
54
80
 
81
+ # The psql argv psql_command runs for sql_command, without the url.
82
+ def psql_command_args(sql_command)
83
+ Preconditions.assert_class(sql_command, String)
84
+ @psql_args + PSQL_ISOLATION_OPTIONS + ["--no-align", "--tuples-only", "--command", sql_command]
85
+ end
86
+
55
87
  def Db.attribute_values(path)
56
88
  Preconditions.assert_class(path, String)
57
89
 
@@ -78,26 +110,55 @@ module SchemaEvolutionManager
78
110
  options
79
111
  end
80
112
 
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.
84
- def psql_file(filename, path)
113
+ # Applies the sql file at path and records filename as run in
114
+ # scripts. psql reads the original file, so the line numbers and path
115
+ # in any psql error match the file in git.
116
+ #
117
+ # A file applied in a single transaction (the default) is recorded by
118
+ # the same psql invocation, inside that transaction: the script and
119
+ # its record commit together or not at all, so no interruption can
120
+ # leave a script applied but unrecorded for the next run to repeat.
121
+ #
122
+ # A transaction = none file has no transaction to record it in. It is
123
+ # recorded afterwards on a connection of its own, and a failure of
124
+ # that record raises a ScriptError whose applied? is true: the script
125
+ # ran, and its dml is the one statement left to run.
126
+ #
127
+ # @param scripts The Scripts instance that records filename as run;
128
+ # a failure's ScriptError prints the command that records it there.
129
+ def psql_file(filename, path, scripts)
85
130
  Preconditions.assert_class(path, String)
131
+ Preconditions.assert_class(scripts, Scripts)
86
132
  Preconditions.check_state(File.exist?(path), "File[%s] not found" % path)
87
133
 
88
- command = psql_file_command(path)
134
+ single = MigrationFile.new(path).single_transaction?
135
+ record_sql = scripts.record_as_run_sql(filename)
136
+ command = psql_file_command(path, single ? record_sql : nil)
89
137
 
90
138
  output, status = Open3.capture2e(*command)
91
139
  if !status.success?
92
- raise ScriptError.new(self, filename, path, output)
140
+ raise ScriptError.new(scripts, filename, path, output)
141
+ end
142
+
143
+ if !single
144
+ begin
145
+ scripts.record_as_run!(filename)
146
+ rescue RuntimeError => e
147
+ raise ScriptError.new(scripts, filename, path, e.message, :applied => true)
148
+ end
93
149
  end
94
150
  end
95
151
 
96
152
  # The psql argv psql_file runs. ON_ERROR_STOP is set on the
97
153
  # command line, after any --set the caller passed, so it is in
98
154
  # 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]
155
+ #
156
+ # @param record_sql: When given, run by the same psql after the file
157
+ # (a --command after the --file), so --single-transaction wraps both
158
+ # in one BEGIN ... COMMIT.
159
+ def psql_file_command(path, record_sql=nil)
160
+ record = record_sql ? ["--command", record_sql] : []
161
+ @psql_args + PSQL_ISOLATION_OPTIONS + ["--set", "ON_ERROR_STOP=1", "--file", path] + record + Db.attribute_values(path) + [@url]
101
162
  end
102
163
 
103
164
  # True if the specific schema exists; false otherwise. Once seen the
@@ -111,26 +172,57 @@ module SchemaEvolutionManager
111
172
  end
112
173
 
113
174
  # Parses command line arguments returning an instance of
114
- # Db. Exists if invalid config.
175
+ # Db. Raises InvalidConnection if they name no database.
115
176
  def Db.parse_command_line_config(arg_string)
116
177
  Preconditions.assert_class(arg_string, String)
117
- args = Args.new(arg_string, :optional => ['url', 'host', 'user', 'name', 'port', 'set'])
178
+ args = Args.new(arg_string, :optional => CONNECTION_FLAGS)
118
179
  Db.from_args(args)
119
180
  end
120
181
 
182
+ # The Db a command's connection flags name. Prompts for a password
183
+ # when --password was given. A connection the flags do not name is
184
+ # one ERROR line on stderr: exit RdocUsage::MISSING_INPUT when no
185
+ # database is named, RdocUsage::INVALID_INPUT when --url is malformed.
186
+ def Db.from_command_line(args)
187
+ Preconditions.assert_class(args, Args)
188
+ password = args.password ? Ask.for_password("Please enter your password: ") : nil
189
+ begin
190
+ Db.from_args(args, :password => password)
191
+ rescue MissingConnection => e
192
+ RdocUsage.error!(e.message, RdocUsage::MISSING_INPUT)
193
+ rescue InvalidConnection => e
194
+ RdocUsage.error!(e.message, RdocUsage::INVALID_INPUT)
195
+ end
196
+ end
197
+
121
198
  # @param password: Optional password to use when connecting to the database.
199
+ # Raises InvalidConnection unless args name a database: a --url
200
+ # that is a connection string, or a --name.
122
201
  def Db.from_args(args, opts={})
123
202
  Preconditions.assert_class(args, Args)
124
203
  password = opts.delete(:password)
125
204
  Preconditions.assert_empty_opts(opts)
126
205
 
127
- options = { :password => password, :set => args.set }
128
- if args.url
129
- Db.new(args.url, options)
206
+ Db.new(Db.connection_url(args), :password => password, :set => args.set)
207
+ end
208
+
209
+ # The url args name: --url as given, else one built from --host,
210
+ # --port, --name and --user
211
+ def Db.connection_url(args)
212
+ url = args.url.to_s.strip
213
+ name = args.name.to_s.strip
214
+
215
+ if !url.empty?
216
+ # A bare database name passed to --url is the common mistake
217
+ if !url.include?("://")
218
+ raise InvalidConnection.new("--url must be a full connection string (e.g. postgres://localhost:5432/%s). To connect by database name, use --name %s." % [url, url])
219
+ end
220
+ url
221
+ elsif name.empty?
222
+ raise MissingConnection.new("Missing database connection. Provide --url <postgres url> or --host/--name.")
130
223
  else
131
- base = "%s:%s/%s" % [args.host || "localhost", args.port || ConnectionData::DEFAULT_PORT, args.name]
132
- url = args.user ? "%s@%s" % [args.user, base] : base
133
- Db.new("postgres://" + url, options)
224
+ base = "%s:%s/%s" % [args.host || "localhost", args.port || ConnectionData::DEFAULT_PORT, name]
225
+ "postgres://" + (args.user ? "%s@%s" % [args.user, base] : base)
134
226
  end
135
227
  end
136
228
 
@@ -38,14 +38,8 @@ module SchemaEvolutionManager
38
38
  end
39
39
 
40
40
  def Library.git_assert_tag_exists(tag)
41
- command = ["git", "tag", "-l"]
42
- results = Library.system_or_error(command)
43
- if results.nil?
44
- raise "No git tags found"
45
- end
46
-
47
41
  if !Library.tag_exists?(tag)
48
- raise "Tag[#{tag}] not found. Check #{Shellwords.join(command)}"
42
+ raise "Tag[#{tag}] not found. Check git tag -l"
49
43
  end
50
44
  end
51
45
 
@@ -61,7 +55,13 @@ module SchemaEvolutionManager
61
55
  # no tags, otherwise returns an instance of Version. Only searches for
62
56
  # tags matching x.x.x (e.g. 1.0.2)
63
57
  def Library.latest_tag
64
- Library.git_tags.select { |tag| Version.is_valid?(tag) }.map { |tag| Version.parse(tag) }.sort.last
58
+ Library.release_tags.last
59
+ end
60
+
61
+ # The release tags among +tags+ (x.y.z, optionally prefixed) as
62
+ # Versions, oldest first. Defaults to every tag in the repository.
63
+ def Library.release_tags(tags=Library.git_tags)
64
+ tags.select { |tag| Version.is_valid?(tag) }.map { |tag| Version.parse(tag) }.sort
65
65
  end
66
66
 
67
67
  # True only when a tag with exactly this name exists - 1.0.10 does
@@ -70,11 +70,14 @@ module SchemaEvolutionManager
70
70
  Library.git_tags.include?(tag)
71
71
  end
72
72
 
73
+ # Every tag in the repository. Raises when git fails (outside a
74
+ # checkout, git missing), so a failure never reads as "no tags".
73
75
  def Library.git_tags
74
- `git tag -l`.strip.split
76
+ Library.system_or_error(["git", "tag", "-l"]).split
75
77
  end
76
78
 
77
79
  # Ex: Library.git_create_tag("0.0.1")
80
+ # Pushes only the tag it creates, never the checkout's other local tags.
78
81
  def Library.git_create_tag(tag)
79
82
  Library.assert_valid_tag(tag)
80
83
  has_remote = Library.git_has_remote?
@@ -83,7 +86,7 @@ module SchemaEvolutionManager
83
86
  end
84
87
  Library.system_or_error(["git", "tag", "-a", "-m", tag, tag])
85
88
  if has_remote
86
- Library.system_or_error(["git", "push", "--tags", "origin"])
89
+ Library.system_or_error(["git", "push", "origin", "refs/tags/%s" % tag])
87
90
  end
88
91
  end
89
92
 
@@ -53,6 +53,12 @@ module SchemaEvolutionManager
53
53
  @attribute_values = parse_attribute_values
54
54
  end
55
55
 
56
+ # True when psql applies the whole file in one transaction
57
+ # (transaction = single, the default)
58
+ def single_transaction?
59
+ @attribute_values.any? { |v| v.attribute.name == "transaction" && v.value == "single" }
60
+ end
61
+
56
62
  private
57
63
 
58
64
  # Returns a list of AttributeValues from the file itself,
@@ -0,0 +1,30 @@
1
+ module SchemaEvolutionManager
2
+
3
+ # Applies the pending scripts of a directory to the scripts table, with
4
+ # a dry run by default. Subclasses name the action (label) and whether
5
+ # each script body is executed (execute?); Scripts#apply_pending! is the
6
+ # loop.
7
+ class PendingScriptsUtil
8
+
9
+ def initialize(db, opts={})
10
+ @dry_run = opts.delete(:dry_run)
11
+ if @dry_run.nil?
12
+ @dry_run = true
13
+ end
14
+
15
+ @db = Preconditions.assert_class(db, Db)
16
+ @scripts = Scripts.new(@db, Scripts::SCRIPTS)
17
+ end
18
+
19
+ def dry_run?
20
+ @dry_run
21
+ end
22
+
23
+ # Applies scripts in order, returning number of scripts applied
24
+ def apply!(dir)
25
+ @scripts.apply_pending!(dir, :execute => execute?, :dry_run => @dry_run, :label => label)
26
+ end
27
+
28
+ end
29
+
30
+ end
@@ -26,6 +26,35 @@ module SchemaEvolutionManager
26
26
  exit(exit_code)
27
27
  end
28
28
 
29
+ # Exit codes for a command-line fault, one per kind of fault
30
+ unless defined?(MISSING_INPUT)
31
+ # A required argument, subcommand or connection was not given
32
+ MISSING_INPUT = 3
33
+ # An argument was given that the command does not accept
34
+ INVALID_INPUT = 4
35
+ # The input was well formed and running it failed
36
+ FAILED = 1
37
+ end
38
+
39
+ # A command-line fault: one "ERROR: <message>" line on stderr, then
40
+ # any detail lines (and the command's usage, with :usage => true),
41
+ # then exit with exit_code. Never writes to stdout, so a wrapper that
42
+ # captures stdout (TAG=$(sem-info tag next minor)) captures no error.
43
+ def RdocUsage.error!(message, exit_code, opts={})
44
+ details = opts.delete(:details) || []
45
+ usage = opts.delete(:usage)
46
+ Preconditions.assert_empty_opts(opts)
47
+ Preconditions.check_state([MISSING_INPUT, INVALID_INPUT, FAILED].include?(exit_code), "Invalid exit_code[%s]" % exit_code)
48
+
49
+ $stderr.puts "ERROR: %s" % message
50
+ details.each { |line| $stderr.puts line }
51
+ if usage
52
+ $stderr.puts ""
53
+ $stderr.puts RdocUsage.message
54
+ end
55
+ exit(exit_code)
56
+ end
57
+
29
58
  private
30
59
  def RdocUsage.program_name
31
60
  $0
@@ -4,16 +4,31 @@ module SchemaEvolutionManager
4
4
 
5
5
  attr_reader :path, :output
6
6
 
7
- def initialize(db, filename, path, output)
8
- @db = Preconditions.assert_class(db, Db)
7
+ # @param scripts The Scripts instance that records filename as run
8
+ # @param applied: true when the script ran to completion and only
9
+ # recording it as run failed (a transaction = none script,
10
+ # whose record cannot share its transaction)
11
+ def initialize(scripts, filename, path, output, opts={})
12
+ @scripts = Preconditions.assert_class(scripts, Scripts)
9
13
  @filename = Preconditions.assert_class(filename, String)
10
14
  @path = Preconditions.assert_class(path, String)
11
15
  @output = Preconditions.assert_class(output, String)
16
+ @applied = opts.delete(:applied) || false
17
+ Preconditions.assert_empty_opts(opts)
12
18
  end
13
19
 
20
+ # True when the script was applied and is not recorded as run: its
21
+ # dml must be run before the next apply, or that apply runs it again
22
+ def applied?
23
+ @applied
24
+ end
25
+
26
+ # The psql command that records this script as applied: the statement
27
+ # Scripts#record_as_run! executes, in the argv Db#psql_command runs.
14
28
  def dml
15
- sql_command = "insert into %s.%s (filename) values ('%s')" % [Db.schema_name, Scripts::SCRIPTS, @filename]
16
- "psql --command \"%s\" %s" % [sql_command, @db.sanitized_url]
29
+ db = @scripts.db
30
+ command = db.psql_command_args(@scripts.record_as_run_sql(@filename)) + [db.sanitized_url]
31
+ Library.command_to_s(command)
17
32
  end
18
33
 
19
34
  end
@@ -2,6 +2,8 @@ module SchemaEvolutionManager
2
2
 
3
3
  class Scripts
4
4
 
5
+ attr_reader :db, :table_name
6
+
5
7
  if !defined?(SCRIPTS)
6
8
  SCRIPTS = "scripts"
7
9
  BOOTSTRAP_SCRIPTS = "bootstrap_scripts"
@@ -72,6 +74,45 @@ module SchemaEvolutionManager
72
74
  end
73
75
  end
74
76
 
77
+ # Walks the pending scripts of dir in order and records each as run,
78
+ # returning how many were pending.
79
+ #
80
+ # @param execute: true applies each script with Db#psql_file, which
81
+ # records it too (inside the script's own transaction unless it
82
+ # is transaction = none); false only records it (a baseline)
83
+ # @param dry_run: true prints each pending script and its path and
84
+ # changes nothing
85
+ # @param label: The action printed per script ("Applying",
86
+ # "Baselining", "Bootstrapping")
87
+ # @param quiet: true prints nothing outside a dry run
88
+ def apply_pending!(dir, opts={})
89
+ execute = opts.delete(:execute)
90
+ dry_run = opts.delete(:dry_run)
91
+ label = Preconditions.assert_class(opts.delete(:label), String)
92
+ quiet = opts.delete(:quiet) || false
93
+ Preconditions.assert_empty_opts(opts)
94
+ Preconditions.check_state(File.directory?(dir),
95
+ "Dir[%s] does not exist" % dir)
96
+
97
+ count = 0
98
+ each_pending(dir) do |filename, path|
99
+ count += 1
100
+ if dry_run
101
+ puts "[DRY RUN] #{label} #{filename}"
102
+ puts path
103
+ puts ""
104
+ elsif execute
105
+ print "#{label} #{filename}" unless quiet
106
+ @db.psql_file(filename, path, self)
107
+ puts " Done" unless quiet
108
+ else
109
+ puts "#{label} #{filename}" unless quiet
110
+ record_as_run!(filename)
111
+ end
112
+ end
113
+ count
114
+ end
115
+
75
116
  # True if this script has already been applied to the db. False
76
117
  # otherwise.
77
118
  def has_run?(filename)
@@ -86,9 +127,15 @@ module SchemaEvolutionManager
86
127
 
87
128
  # Inserts a record to indiciate that we have loaded the specified file.
88
129
  def record_as_run!(filename)
130
+ @db.psql_command(record_as_run_sql(filename))
131
+ end
132
+
133
+ # The statement record_as_run! executes: an idempotent insert of
134
+ # filename into this instance's table. ScriptError#dml prints it as
135
+ # the recovery command, so the hint and the write are one statement.
136
+ def record_as_run_sql(filename)
89
137
  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]
91
- @db.psql_command(command)
138
+ "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]
92
139
  end
93
140
 
94
141
  # Returns the sorted list of filenames already applied to this database.
@@ -10,31 +10,16 @@ module SchemaEvolutionManager
10
10
  valid = ['version', 'scripts']
11
11
  subcommand = args.shift.to_s.strip
12
12
 
13
- if !valid.include?(subcommand)
14
- if subcommand.empty?
15
- $stderr.puts "ERROR: Missing db subcommand. Must be one of: %s" % valid.join(", ")
16
- else
17
- $stderr.puts "ERROR: Invalid db subcommand[%s]. Must be one of: %s" % [subcommand, valid.join(", ")]
18
- end
19
- exit(4)
20
- end
21
-
22
- db_args = SchemaEvolutionManager::Args.new(args, :optional => ['url', 'host', 'user', 'name', 'port', 'set'])
23
-
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)
13
+ if subcommand.empty?
14
+ RdocUsage.error!("Missing db subcommand. Must be one of: %s" % valid.join(", "), RdocUsage::MISSING_INPUT)
15
+ elsif !valid.include?(subcommand)
16
+ RdocUsage.error!("Invalid db subcommand[%s]. Must be one of: %s" % [subcommand, valid.join(", ")], RdocUsage::INVALID_INPUT)
27
17
  end
28
18
 
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
19
+ db_args = SchemaEvolutionManager::Args.new(args, :optional => SchemaEvolutionManager::Db::CONNECTION_FLAGS)
35
20
 
36
21
  begin
37
- db = SchemaEvolutionManager::Db.from_args(db_args)
22
+ db = SchemaEvolutionManager::Db.from_command_line(db_args)
38
23
  if subcommand == "version"
39
24
  version = SchemaEvolutionManager::Versions.new(db).latest
40
25
  if version.nil?
@@ -53,8 +38,7 @@ module SchemaEvolutionManager
53
38
  rescue => e
54
39
  # Surface a single clean line (e.g. bad url, unreachable host, missing
55
40
  # database) rather than a Ruby stack trace.
56
- $stderr.puts "ERROR: %s" % e.message.to_s.split("\n").first
57
- exit(3)
41
+ RdocUsage.error!(e.message.to_s.split("\n").first, RdocUsage::FAILED)
58
42
  end
59
43
  end
60
44
 
@@ -66,8 +50,7 @@ module SchemaEvolutionManager
66
50
  if subcommand == "exists"
67
51
  tag = args.shift.to_s.strip
68
52
  if tag.empty?
69
- puts "ERROR: Missing tag."
70
- exit(3)
53
+ RdocUsage.error!("Missing tag.", RdocUsage::MISSING_INPUT)
71
54
  elsif ::SchemaEvolutionManager::Library.tag_exists?(tag)
72
55
  puts "true"
73
56
  else
@@ -85,12 +68,10 @@ module SchemaEvolutionManager
85
68
  ::SchemaEvolutionManager::SemInfo::Tag.next(args).to_version_string
86
69
 
87
70
  elsif subcommand.empty?
88
- puts "ERROR: Missing tag subcommand. Must be one of: %s" % valid.join(", ")
89
- exit(3)
71
+ RdocUsage.error!("Missing tag subcommand. Must be one of: %s" % valid.join(", "), RdocUsage::MISSING_INPUT)
90
72
 
91
73
  else
92
- puts "ERROR: Invalid tag subcommand[%s]. Must be one of: %s" % [subcommand, valid.join(", ")]
93
- exit(4)
74
+ RdocUsage.error!("Invalid tag subcommand[%s]. Must be one of: %s" % [subcommand, valid.join(", ")], RdocUsage::INVALID_INPUT)
94
75
  end
95
76
  end
96
77
 
@@ -112,8 +93,7 @@ module SchemaEvolutionManager
112
93
  if valid.include?(component)
113
94
  latest.send("next_%s" % component)
114
95
  else
115
- puts "ERROR: Invalid component[%s]. Must be one of: %s" % [component, valid.join(", ")]
116
- exit(4)
96
+ RdocUsage.error!("Invalid component[%s]. Must be one of: %s" % [component, valid.join(", ")], RdocUsage::INVALID_INPUT)
117
97
  end
118
98
  end
119
99
 
@@ -3,7 +3,7 @@ module SchemaEvolutionManager
3
3
 
4
4
  module SemVersion
5
5
 
6
- VERSION ||= '0.9.74'
6
+ VERSION ||= '0.9.75'
7
7
 
8
8
  end
9
9
 
@@ -2,7 +2,13 @@ module SchemaEvolutionManager
2
2
 
3
3
  class Version
4
4
 
5
- VERSION_FILE = File.join(Library.base_dir, "VERSION") unless defined?(VERSION_FILE)
5
+ unless defined?(VERSION_FILE)
6
+ VERSION_FILE = File.join(Library.base_dir, "VERSION")
7
+ # An optional prefix (v1.2.3, r1.2.3) then major.minor.micro. The
8
+ # prefix is lazy so it never takes a leading digit of the major
9
+ # version: 10.2.3 is major 10, not prefix "1" and major 0.
10
+ PATTERN = /\A(\w*?)(\d+)\.(\d+)\.(\d+)\z/
11
+ end
6
12
 
7
13
  attr_reader :major, :minor, :micro
8
14
 
@@ -11,15 +17,13 @@ module SchemaEvolutionManager
11
17
  @minor = minor.to_i
12
18
  @micro = micro.to_i
13
19
  @prefix = opts.delete(:prefix) || nil
14
- if !opts.empty?
15
- raise "Invalid keys: " + opts.keys
16
- end
20
+ Preconditions.assert_empty_opts(opts)
17
21
  end
18
22
 
19
23
  def Version.parse(version_string)
20
24
  Preconditions.check_not_blank(version_string, "version_string cannot be blank")
21
25
  Library.assert_valid_tag(version_string)
22
- if md = version_string.match(/^(\w*)(\d+)\.(\d+)\.(\d+)$/)
26
+ if md = version_string.match(PATTERN)
23
27
  Version.new(md[2], md[3], md[4], :prefix => md[1])
24
28
  else
25
29
  raise "ERROR: Bug in version string parser for version[%s]" % version_string
@@ -74,7 +78,7 @@ module SchemaEvolutionManager
74
78
 
75
79
  def Version.is_valid?(version_string)
76
80
  Preconditions.check_not_blank(version_string, "version_string cannot be blank")
77
- version_string.match(/^\w*\d+\.\d+\.\d+$/) ? true : false
81
+ PATTERN.match?(version_string)
78
82
  end
79
83
 
80
84
  end
@@ -23,6 +23,7 @@ load File.join(lib_dir, 'scripts.rb')
23
23
  load File.join(lib_dir, 'versions.rb')
24
24
  load File.join(lib_dir, 'connection_data.rb')
25
25
  load File.join(lib_dir, 'db.rb')
26
+ load File.join(lib_dir, 'pending_scripts_util.rb')
26
27
  load File.join(lib_dir, 'apply_util.rb')
27
28
  load File.join(lib_dir, 'baseline_util.rb')
28
29
  load File.join(lib_dir, 'template.rb')
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.74
4
+ version: 0.9.75
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Bryzek
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-11-07 00:00:00.000000000 Z
10
+ date: 2026-11-11 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: '["Michael Bryzek"]'
13
13
  email: mbryzek@alum.mit.edu
@@ -41,6 +41,7 @@ files:
41
41
  - lib/schema-evolution-manager/install_template.rb
42
42
  - lib/schema-evolution-manager/library.rb
43
43
  - lib/schema-evolution-manager/migration_file.rb
44
+ - lib/schema-evolution-manager/pending_scripts_util.rb
44
45
  - lib/schema-evolution-manager/preconditions.rb
45
46
  - lib/schema-evolution-manager/rdoc_usage.rb
46
47
  - lib/schema-evolution-manager/script_error.rb