schema-evolution-manager 0.9.58 → 0.9.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a59b34cee82db218b0008110f365a64567ec8dcbaa9f59a0fcd30acce9586f0
4
- data.tar.gz: 5a6066de334be67b4915debfc0670e27c56d40eeb008c60bbc2348da8be37603
3
+ metadata.gz: 60215e02faf6492f2a4e0b263008b6038f95d3ff824417fb6ce615296342ea5f
4
+ data.tar.gz: e9450f3c24a2f54b3f25566c1152a6454de7e7b2eb4750dd0a8021b273b014c6
5
5
  SHA512:
6
- metadata.gz: 21fe48093f5386013d3eed04dec8a9ebd512a670608a6b5a663b2c68c0b9af3fedb910577a58c3e3abe8098d3e66ac76cc25019cad0ebaa47e22014aa061c10c
7
- data.tar.gz: 447e2949614cb4f2c332f317596195b796ff811016d8d6259b42a351e59b8a04c324b8bbb8bae3e8248b9a521bb3ba719db4d35676ea2ad1ebbc011ce6984caf
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.58
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"
@@ -26,17 +27,21 @@ module SchemaEvolutionManager
26
27
  :dry_run => "Include flag to echo commands that will run without actually executing them",
27
28
  :non_interactive => "Avoid all prompts and use defaults for any option that requires user input",
28
29
  :help => "Display help",
30
+ :version => "Display the version of schema-evolution-manager",
29
31
  :verbose => "Enable verbose logging of all system calls",
30
32
  }
31
33
  end
32
34
 
33
- 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
34
36
 
35
- # 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
36
40
  # :required => list of parameters that are required
37
41
  # :optional => list of parameters that are optional
38
42
  def initialize(args, opts={})
39
- 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")
40
45
  required = (opts.delete(:required) || []).map { |flag| format_flag(flag) }
41
46
  optional = (opts.delete(:optional) || []).map { |flag| format_flag(flag) }
42
47
  Preconditions.assert_class(required, Array)
@@ -51,6 +56,9 @@ module SchemaEvolutionManager
51
56
  if !optional.include?(:verbose)
52
57
  optional << :verbose
53
58
  end
59
+ if !optional.include?(:version)
60
+ optional << :version
61
+ end
54
62
 
55
63
  found_arguments = parse_string_arguments(args)
56
64
  missing = required.select { |field| blank?(found_arguments[field]) }
@@ -63,6 +71,7 @@ module SchemaEvolutionManager
63
71
  @url = found_arguments.delete(:url)
64
72
  @user = found_arguments.delete(:user)
65
73
  @dir = found_arguments.delete(:dir)
74
+ @scripts_dir = found_arguments.delete(:scripts_dir)
66
75
  @tag = found_arguments.delete(:tag)
67
76
  @set = found_arguments.delete(:set) || []
68
77
 
@@ -71,6 +80,7 @@ module SchemaEvolutionManager
71
80
  @password = found_arguments.delete(:password)
72
81
  @help = found_arguments.delete(:help)
73
82
  @verbose = found_arguments.delete(:verbose)
83
+ @version = found_arguments.delete(:version)
74
84
 
75
85
  Preconditions.check_state(found_arguments.empty?,
76
86
  "Did not handle all flags: %s" % found_arguments.keys.join(" "))
@@ -79,6 +89,11 @@ module SchemaEvolutionManager
79
89
  RdocUsage.printAndExit(0)
80
90
  end
81
91
 
92
+ if @version
93
+ puts SemVersion::VERSION
94
+ exit(0)
95
+ end
96
+
82
97
  if @verbose
83
98
  Library.set_verbose(true)
84
99
  end
@@ -90,8 +105,7 @@ module SchemaEvolutionManager
90
105
 
91
106
  # Hack to minimize bleeding from STDIN. Returns an instance of Args class
92
107
  def Args.from_stdin(opts)
93
- values = ARGV.join(" ")
94
- Args.new(values, opts)
108
+ Args.new(ARGV.dup, opts)
95
109
  end
96
110
 
97
111
  private
@@ -133,10 +147,9 @@ module SchemaEvolutionManager
133
147
 
134
148
 
135
149
  def parse_string_arguments(args)
136
- Preconditions.assert_class_or_nil(args, String)
137
150
  found = {}
138
151
  index = 0
139
- values = args.to_s.strip.split(/\s+/)
152
+ values = args.is_a?(Array) ? args : args.to_s.strip.split(/\s+/)
140
153
  while index < values.length do
141
154
  flag = format_flag(values[index])
142
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