prick 0.2.0 → 0.3.0

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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -5
  3. data/Gemfile +4 -1
  4. data/TODO +3 -0
  5. data/doc/prick.txt +114 -0
  6. data/exe/prick +224 -370
  7. data/lib/ext/fileutils.rb +11 -0
  8. data/lib/ext/forward_method.rb +18 -0
  9. data/lib/ext/shortest_path.rb +44 -0
  10. data/lib/prick.rb +17 -9
  11. data/lib/prick/branch.rb +254 -0
  12. data/lib/prick/builder.rb +141 -0
  13. data/lib/prick/command.rb +19 -11
  14. data/lib/prick/constants.rb +42 -20
  15. data/lib/prick/database.rb +5 -3
  16. data/lib/prick/diff.rb +47 -0
  17. data/lib/prick/exceptions.rb +15 -3
  18. data/lib/prick/git.rb +46 -21
  19. data/lib/prick/migration.rb +165 -185
  20. data/lib/prick/program.rb +238 -0
  21. data/lib/prick/project.rb +266 -358
  22. data/lib/prick/rdbms.rb +2 -2
  23. data/lib/prick/schema.rb +19 -88
  24. data/lib/prick/share.rb +64 -0
  25. data/lib/prick/state.rb +137 -0
  26. data/lib/prick/version.rb +34 -14
  27. data/libexec/strip-comments +33 -0
  28. data/make_releases +48 -345
  29. data/make_schema +10 -0
  30. data/prick.gemspec +11 -22
  31. data/share/feature_migration/diff.sql +2 -0
  32. data/share/feature_migration/migrate.sql +2 -0
  33. data/share/release_migration/diff.sql +3 -0
  34. data/share/release_migration/features.yml +6 -0
  35. data/share/release_migration/migrate.sql +5 -0
  36. data/share/release_migration/migrate.yml +5 -0
  37. data/share/schema/build.yml +14 -0
  38. data/share/schema/schema.sql +5 -0
  39. data/share/schemas/build.yml +3 -0
  40. data/share/schemas/prick/build.yml +14 -0
  41. data/share/schemas/prick/data.sql +1 -2
  42. data/share/schemas/prick/schema.sql +0 -15
  43. data/share/schemas/prick/tables.sql +17 -0
  44. data/share/schemas/public/.keep +0 -0
  45. data/share/schemas/public/build.yml +14 -0
  46. data/share/schemas/public/schema.sql +3 -0
  47. data/test_assorted +192 -0
  48. data/test_feature +112 -0
  49. data/test_single_dev +83 -0
  50. metadata +34 -61
@@ -14,14 +14,13 @@ module Command
14
14
  end
15
15
  end
16
16
 
17
- # Execute the shell command 'cmd' and return the output as an array of
18
- # strings
19
- #
20
- # If :stderr is true, it returns return a tuple of [stdout, stderr] instead
21
- #
22
- # The shell command is executed with the `errexit` and `pipefail` bash
23
- # options. It raises a Command::Error exception if the command fails unless
24
- # :fail is true. The exit status of the last command is stored in ::status
17
+ # Execute the shell command 'cmd' and return standard output as an array of
18
+ # strings while stderr is passed through unless stderr: is false. If stderr:
19
+ # is true, it returns a tuple of [stdout, stderr] instead. The shell command
20
+ # is executed with the `errexit` and `pipefail` bash options
21
+ #
22
+ # It raises a Command::Error exception if the command fails unless :fail is
23
+ # true. The exit status of the last command is stored in ::status
25
24
  #
26
25
  def command(cmd, stderr: false, fail: true)
27
26
  cmd = "set -o errexit\nset -o pipefail\n#{cmd}"
@@ -62,10 +61,19 @@ module Command
62
61
  pr[0].close
63
62
  pe[0].close
64
63
 
64
+ result =
65
+ case stderr
66
+ when true; [out, err]
67
+ when false; out
68
+ when NilClass; $stderr.puts err
69
+ else
70
+ raise Internal, "Unexpected value for :stderr - #{stderr.inspect}"
71
+ end
72
+
65
73
  if @status == 0 || fail == false
66
- stderr ? [out, err] : out
67
- else
68
- raise Command::Error.new((out + err).join("\n") + "\n", status, out, err)
74
+ result
75
+ elsif fail
76
+ raise Command::Error.new("\n" + cmd + "\n" + (out + err).join("\n") + "\n", status, out, err)
69
77
  end
70
78
  end
71
79
 
@@ -1,28 +1,50 @@
1
1
 
2
2
  module Prick
3
+ ### DIRECTORIES AND FILE NAMES
4
+
5
+ # Path to the .prick-version file
6
+ PRICK_VERSION_FILE = ".prick-version"
7
+
8
+ # State files
9
+ PROJECT_STATE_FILE = ".prick-project"
10
+ FEATURES_STATE_FILE = ".prick-features"
11
+ MIGRATION_STATE_FILE = ".prick-migration"
12
+ MIGRATIONS_FILE = "migrations.sql"
13
+
14
+ # Diff file
15
+ DIFF_FILE = "diff.sql"
16
+
3
17
  # Shared files (part of the installation)
4
18
  SHARE_PATH = "#{File.dirname(File.dirname(__dir__))}/share"
19
+ LIBEXEC_PATH = "#{File.dirname(File.dirname(__dir__))}/libexec"
20
+
21
+ STRIP_COMMENTS_COMMAND = File.join(LIBEXEC_PATH, "strip-comments")
5
22
 
6
23
  # Project directories
7
24
  DIRS = [
8
- RELEASE_DIR = "releases",
9
- MIGRATION_DIR = "migrations",
10
- FEATURE_DIR = "features",
11
- SCHEMA_DIR = "schemas",
12
- PRICK_DIR = "#{SCHEMA_DIR}/prick",
13
- PUBLIC_DIR = "#{SCHEMA_DIR}/public",
25
+ MIGRATIONS_DIR = "migrations",
26
+ RELEASES_DIR = "releases",
27
+ SCHEMAS_DIR = "schemas",
28
+ PRICK_DIR = "#{SCHEMAS_DIR}/prick",
29
+ PUBLIC_DIR = "#{SCHEMAS_DIR}/public",
14
30
  VAR_DIR = "var",
15
31
  CACHE_DIR = "#{VAR_DIR}/cache",
32
+ SPOOL_DIR = "#{VAR_DIR}/spool",
16
33
  TMP_DIR = "tmp",
17
34
  CLONE_DIR = "tmp/clone",
18
35
  SPEC_DIR = "spec"
19
36
  ]
20
37
 
38
+ # Path to prick data file
39
+ # PRICK_DATA_PATH = File.join(SCHEMAS_DIR, "prick", "data.sql")
40
+ SCHEMA_VERSION_PATH = File.join(SCHEMAS_DIR, "prick", "data.sql")
41
+
21
42
  # Dump files
22
43
  DUMP_EXT = "dump.gz"
23
44
  DUMP_GLOB = "*-[0-9]*.#{DUMP_EXT}"
24
45
  def self.dump_glob(project_name) "#{project_name}-*.#{DUMP_EXT}" end
25
46
 
47
+ ### REGULAR EXPRESSIONS
26
48
 
27
49
  # Matches an identifier. Identifiers consist of lower case letters, digits
28
50
  # and underscores but not dashes because they're used as separators
@@ -56,7 +78,7 @@ module Prick
56
78
  USER_NAME_SUB_RE = NAME_SUB_RE
57
79
  USER_NAME_RE = NAME_RE
58
80
 
59
- # Matches a major.minor.patch version
81
+ # Matches a major.minor.patch ('MMP') version
60
82
  #
61
83
  # The *_SEMVER REs are derived from the canonical RE
62
84
  #
@@ -119,19 +141,6 @@ module Prick
119
141
  /x
120
142
  RELEASE_RE = /^#{RELEASE_SUB_RE}$/
121
143
 
122
- # Migration RE. The syntax is <version>_<version>
123
- #
124
- # The RE defines the following captures
125
- # $1 - from version
126
- # $2 - from custom name, can be nil
127
- # $3 - from semantic version
128
- # $4 - to version
129
- # $5 - to custom name, can be nil
130
- # $6 - to semantic version
131
- #
132
- MIGRATION_SUB_RE = /(#{RELEASE_SUB_RE})_(#{RELEASE_SUB_RE})/
133
- MIGRATION_RE = /^#{MIGRATION_SUB_RE}$/
134
-
135
144
  # Matches a prerelease branch
136
145
  #
137
146
  # The RE defines the following captures:
@@ -154,6 +163,19 @@ module Prick
154
163
  FEATURE_SUB_RE = /#{ABSTRACT_RELEASE_SUB_RE}_(#{FEATURE_NAME_SUB_RE})/
155
164
  FEATURE_RE = /^#{FEATURE_SUB_RE}$/
156
165
 
166
+ # Migration RE. The syntax is <version>_<version>
167
+ #
168
+ # The RE defines the following captures
169
+ # $1 - from version
170
+ # $2 - from custom name, can be nil
171
+ # $3 - from semantic version
172
+ # $4 - to version
173
+ # $5 - to custom name, can be nil
174
+ # $6 - to semantic version
175
+ #
176
+ MIGRATION_SUB_RE = /(#{RELEASE_SUB_RE})_(#{RELEASE_SUB_RE})/
177
+ MIGRATION_RE = /^#{MIGRATION_SUB_RE}$/
178
+
157
179
  # Project release RE. The general syntax is '<project>-<custom>-<version>'
158
180
  #
159
181
  # The RE defines the following captures:
@@ -1,5 +1,5 @@
1
1
 
2
- require "prick/ensure.rb"
2
+ #require "prick/ensure.rb"
3
3
 
4
4
  module Prick
5
5
  class Database
@@ -42,12 +42,14 @@ module Prick
42
42
  def recreate() drop if exist?; create; @version = nil end
43
43
  def drop() Rdbms.drop_database(name, fail: false); @version = nil end
44
44
 
45
- def loaded?() exist? && !version.nil? end
45
+ def loaded?() !version.nil? end
46
46
  def load(file) Rdbms.load(name, file, user: user); @version = nil end
47
47
 
48
48
  def save(file) Rdbms.save(name, file); @version = nil end
49
49
 
50
- include Ensure
50
+ def to_s() name end
51
+
52
+ # include Ensure
51
53
 
52
54
  private
53
55
  @states = {
@@ -0,0 +1,47 @@
1
+ module Prick
2
+ class Diff
3
+ def initialize(db1, db2)
4
+ @db1, @db2 = db1, db2
5
+ @diffed = false
6
+ @diff = nil
7
+ @result = nil
8
+ end
9
+
10
+ def self.same?(db1, db2) Diff.new(db1, db2).same? end
11
+
12
+ # Return true if the two databases are equal. Named #same? to avoid name
13
+ # collision with the built in #equal?
14
+ def same?
15
+ do_diff if @result.nil?
16
+ @result
17
+ end
18
+
19
+ # Return the diff between the two databases or nil if they're equal
20
+ def read
21
+ @diffed ? @diff : do_diff
22
+ end
23
+
24
+ # Write the diff between the databases to the given file. Return true if
25
+ # the databases are equal
26
+ def write(file)
27
+ if @diffed
28
+ IO.write(file, @diff)
29
+ else
30
+ do_diff(file)
31
+ end
32
+ @result
33
+ end
34
+
35
+ private
36
+ def do_diff(file = nil)
37
+ file_arg = file ? ">#{file}" : ""
38
+ command = "migra --unsafe postgres:///#{@db1} postgres:///#{@db2} #{file_arg}"
39
+ stdout = Command.command command, fail: false
40
+ [0,2].include? Command.status or
41
+ raise Fail, "migra command failed with status #{Command.status}: #{command}"
42
+ @result = Command.status == 0
43
+ @diffed = !file
44
+ @diff = @diffed && !@result ? stdout : nil
45
+ end
46
+ end
47
+ end
@@ -2,12 +2,24 @@
2
2
  module Prick
3
3
  class Error < RuntimeError; end
4
4
  class Fail < Error; end
5
+
5
6
  class NotYet < NotImplementedError
6
- def initialize() super("Program error - Not yet implemented") end
7
+ def initialize() super("Internal error: Not yet implemented") end
8
+ end
9
+
10
+ class NotThis < ScriptError
11
+ def initialize() super("Internal error: Abstract method called") end
7
12
  end
8
- class AbstractMethod < ScriptError
9
- def initialize() super("Program error - Abstract method called") end
13
+
14
+ class Abstract < ScriptError
15
+ def initialize() super("Internal error: Abstract method called") end
10
16
  end
17
+
18
+ AbstractMethod = Abstract
19
+
11
20
  class Internal < ScriptError; end
21
+ class Oops < Internal
22
+ def initialize() super("Oops, this shouldn't happen") end
23
+ end
12
24
  end
13
25
 
@@ -9,34 +9,41 @@ module Prick
9
9
  Command.command "git init"
10
10
  end
11
11
 
12
- # Returns true if the repository has no modified files. Requires the
13
- # repository to have at least one commit. Creating the repository using
14
- # Project::initialize_directory guarantees that
12
+ # Returns true if the repository has no modified files or unresolved
13
+ # conflicts. Requires the repository to have at least one commit. Creating
14
+ # the repository using Project::initialize_directory guarantees that
15
15
  def self.clean?()
16
16
  Command.command("git status").find { |l|
17
- l =~ /Changes to be committed:/ || l =~ /^\s*modified:/
17
+ (l =~ /^Changes to be committed:/) ||
18
+ (l =~ /^Unmerged paths:/) ||
19
+ (l =~ /^Changes not staged for commit:/)
18
20
  }.nil?
19
21
  end
20
22
 
21
23
  # Returns true if the repository is on a detached branch
22
24
  def self.detached?
23
- line = File.readlines(".git/HEAD").first.chomp
24
- line =~ /^ref:/ ? false : true
25
+ Command.command? "git symbolic-ref -q HEAD", expect: 1
25
26
  end
26
27
 
27
28
  # The current tag. This is only defined if the repository is in "detached
28
29
  # head" mode
29
- def self.current_tag() raise "Not yet implemented" end
30
-
30
+ def self.current_tag()
31
+ self.detached? ? Command.command("git describe --tags").first.sub(/^v/, "") : nil
32
+ end
33
+
31
34
  # Return true if `version` has an associated tag
32
35
  def self.tag?(version)
33
- !list_tags.grep(version).empty?
36
+ !list_tags.grep(version.to_s).empty?
34
37
  end
35
38
 
36
- # List tag versions
37
39
  # Create version tag
38
- def self.create_tag(version)
39
- Command.command "git tag -a 'v#{version}' -m 'Release #{version}'"
40
+ def self.create_tag(version, message: "Release #{version}", commit_id: nil)
41
+ Command.command "git tag -a 'v#{version}' -m '#{message}' #{commit_id}"
42
+ end
43
+
44
+ # Create a cancel-version tag
45
+ def self.cancel_tag(version)
46
+ create_tag("#{version}_cancelled", message: "Cancel #{version}", commit_id: tag_id(version))
40
47
  end
41
48
 
42
49
  def self.delete_tag(version, remote: false)
@@ -44,18 +51,30 @@ module Prick
44
51
  Command.command("git push --delete origin 'v#{version}'", fail: false) if remote
45
52
  end
46
53
 
54
+ def self.tag_id(version)
55
+ Command.command("git rev-parse 'v#{version}^{}'").first
56
+ end
57
+
47
58
  # Checkout a version tag as a detached head
48
59
  def self.checkout_tag(version)
49
60
  Command.command "git checkout 'v#{version}'"
50
61
  end
51
62
 
52
- def self.list_tags
53
- Command.command("git tag").map { |tag| tag.sub(/^v(#{VERSION_SUB_RE})/, '\1') }
63
+ def self.list_tags(include_cancelled: false)
64
+ tags = Command.command("git tag")
65
+ if !include_cancelled
66
+ cancelled = tags.select { |tag| tag =~ /_cancelled$/ }
67
+ for cancel_tag in cancelled
68
+ tags.delete(cancel_tag)
69
+ tags.delete(cancel_tag.sub(/_cancelled$/, ""))
70
+ end
71
+ end
72
+ tags.map { |tag| tag = tag[1..-1] }
54
73
  end
55
74
 
56
- # Name of the current branch
75
+ # Name of the current branch. This is nil if on a tag ("detached HEAD")
57
76
  def self.current_branch()
58
- Command.command("git rev-parse --abbrev-ref HEAD").first
77
+ self.detached? ? nil : Command.command("git rev-parse --abbrev-ref HEAD").first
59
78
  end
60
79
 
61
80
  # Check if branch exist
@@ -94,12 +113,18 @@ module Prick
94
113
 
95
114
  Command.command "git merge --no-commit #{name}", fail: false
96
115
 
97
- # Reinstate included files
98
- files.each { |path, content|
116
+ # Restore excluded files
117
+ files.each { |path, content|
99
118
  File.open(path, "w") { |file| file.puts(content) }
100
119
  # Resolve git unmerged status
101
120
  Git.add(path)
102
121
  }
122
+
123
+ # TODO Detect outstanding merges
124
+ end
125
+
126
+ def self.merge_tag(name, exclude_files: [], fail: false)
127
+ merge_branch(name, exclude_files: exclude_files, fail: fail)
103
128
  end
104
129
 
105
130
  # List branches
@@ -109,7 +134,7 @@ module Prick
109
134
 
110
135
  # Add a file to the index of the current branch
111
136
  def self.add(*files)
112
- Array(files).each { |file|
137
+ Array(files).flatten.each { |file|
113
138
  Dir.chdir(File.dirname(file)) {
114
139
  Command.command "git add '#{File.basename(file)}'"
115
140
  }
@@ -127,7 +152,7 @@ module Prick
127
152
  end.map { |l| "#{l}\n" }
128
153
  end
129
154
 
130
- # Return content of file
155
+ # Return content of file as a String
131
156
  def self.read(file, tag: nil, branch: nil)
132
157
  !(tag && branch) or raise Internal, "Can't use both tag: and branch: options"
133
158
  if tag
@@ -135,7 +160,7 @@ module Prick
135
160
  else
136
161
  branch ||= "HEAD"
137
162
  Command.command "git show #{branch}:#{file}"
138
- end.join("\n")
163
+ end.join("\n") + "\n"
139
164
  end
140
165
 
141
166
  def self.rm(file)
@@ -1,230 +1,210 @@
1
1
 
2
- require 'yaml'
3
-
4
2
  module Prick
3
+ # A Migration consists of a .prick-features file, a .prick-migration file, and a
4
+ # set of SQL migration files. Features can be include as subdirectories
5
+ #
5
6
  class Migration
6
- KEEP_FILE = ".keep"
7
+ # Directory of the .prick_migration file. It is nil in CurrentRelease
8
+ # objects that supports the single-developer workflow
9
+ def migration_dir() @migration_state && File.dirname(@migration_state.path) end
7
10
 
8
- attr_reader :path
11
+ # Directory of the .prick_features file. nil for the initial 0.0.0
12
+ # migration. Note that the feature and the migration directories can be the
13
+ # same. The features directory also contains the auto-generated files
14
+ # diff.sql, features.sql, features.yml, and the user-defined migrations.sql file
15
+ def features_dir() @features_state && File.dirname(@features_state.path) end
9
16
 
10
- def files() raise AbstractMethod end
11
- def release_files() files end
17
+ # Directory of the migration. Alias for #features_dir. It should be used in
18
+ # derived classes when the features and migration directory are the same
19
+ def dir() features_dir end
12
20
 
13
- # Return versions of the features in this migration
14
- def feature_versions() raise AbstractMethod end
21
+ # Migration file
22
+ def migrations_file() File.join(dir, MIGRATIONS_FILE) end
15
23
 
16
- def initialize(path, template_dir)
17
- @path = path
18
- @template_dir = template_dir
19
- end
24
+ # Version of the result of running this migration. `version` is nil for the
25
+ # current release and features that are not part of a pre-release. Needs to
26
+ # be redefined in subclasses where migration_dir is nil
27
+ forward_method :version, :version=, :@migration_state
20
28
 
21
- def exist?() File.exist?(keep_file) end
22
- def create() FileUtils.touch_p(keep_file); Git.add(keep_file) end
23
- def destroy() Git.rm_rf(path) end
29
+ # Base version of the migration. `base_version` is nil for the initial
30
+ # 0.0.0 migration. Needs to be redefined in subclasses where migration_dir
31
+ # is nil
32
+ forward_method :base_version, :base_version=, :@migration_state
24
33
 
25
- def present?() exist? && files.all? { |f| File.exist?(f) } end
26
- def prepare() files.each { |f| FileUtils.cp(template_file(f), path) }; Git.add(path) end
34
+ # List of features in this migration. The features ordered in the same
35
+ # order as they're included
36
+ forward_method :features, :@features_state
27
37
 
28
- def include_feature(feature) raise AbstractMethod end
38
+ # Note that migration_dir can be nil
39
+ def initialize(migration_dir, features_dir)
40
+ @migration_state = migration_dir && MigrationState.new(migration_dir)
41
+ @features_state = features_dir && FeaturesState.new(features_dir)
42
+ end
29
43
 
30
- def migrate() raise AbstractMethod end
44
+ def dump
45
+ puts "#{self.class}"
46
+ indent {
47
+ puts "migration_dir: #{migration_dir}"
48
+ puts "features_dir: #{features_dir}"
49
+ puts "version: #{version}"
50
+ puts "base_version: #{base_version}"
51
+ }
52
+ end
31
53
 
32
- def to_s() path end
33
- def <=>(other) path <=> other.path end
54
+ # Needs to be redefined in ReleaseMigration. This default implementation
55
+ # assumes #migration_dir is equal to #features_dir
56
+ def self.load(directory) self.new(directory, directory).load end
34
57
 
35
- def self.path(version)
36
- if version.feature?
37
- File.join(FEATURE_DIR, version.truncate(:feature).to_s, version.feature)
38
- else
39
- File.join(FEATURE_DIR, version)
40
- end
58
+ def load
59
+ @migration_state&.read
60
+ @features_state.read
61
+ self
41
62
  end
42
63
 
43
- def self.version(path)
44
- Version.new(path.delete_prefix("#{FEATURE_DIR}/").sub("/", "_"))
64
+ def save
65
+ @migration_state&.write
66
+ @features_state.write
67
+ Git.add(@migration_state.path) if @migration_state
68
+ Git.add(@features_state.path)
69
+ self
45
70
  end
46
71
 
47
- def self.feature?(path)
48
- Version.new(path.delete_prefix("#{FEATURE_DIR}/").sub("/", "_")).feature?
72
+ # Return true if this is the initial 0.0.0 migration
73
+ def initial?() version&.zero? end
74
+
75
+ def exist?()
76
+ (@migration_state.nil? || @migration_state.exist?) && (initial? || @features_state.exist?)
49
77
  end
50
78
 
51
- def self.files(path) raise AbstractMethod end
52
- def self.release_files(path) files(path) end
79
+ def create(templates_pattern = nil)
80
+ if @migration_state && !@migration_state.exist?
81
+ FileUtils.mkdir_p(migration_dir) if migration_dir
82
+ @migration_state.create
83
+ Git.add(@migration_state.path)
84
+ end
85
+ if !initial? && !@features_state.exist?
86
+ FileUtils.mkdir_p(features_dir)
87
+ @features_state.create
88
+ Git.add(@features_state.path)
89
+ end
90
+ if !initial? && templates_pattern
91
+ files = Share.cp(templates_pattern, features_dir, clobber: false)
92
+ Git.add files
93
+ end
94
+ self
95
+ end
96
+
97
+ def insert_feature(version)
98
+ features.unshift(version.to_s)
99
+ @features_state.write
100
+ Git.add(@features_state.path)
101
+ generate_features_yml
102
+ self
103
+ end
104
+
105
+ def append_feature(version)
106
+ features.push(version.to_s)
107
+ @features_state.write
108
+ Git.add(@features_state.path)
109
+ generate_features_yml
110
+ self
111
+ end
112
+
113
+ def generate_features_yml
114
+ features_yml_file = File.join(features_dir, "features.yml")
115
+ Share.cp("release_migration/features.yml", features_yml_file)
116
+ file = File.open(features_yml_file, "a")
117
+ features.each { |feature|
118
+ version = Version.new(feature)
119
+ if base_version == version.truncate(:pre)
120
+ file.puts "- #{version.feature}"
121
+ else
122
+ file.puts "- ../#{version.truncate(:pre)}/#{version.feature}"
123
+ end
124
+ }
125
+ end
53
126
 
54
- def dump(&block)
55
- # puts self.class
56
- # indent {
57
- # puts "path : #{path}"
58
- # puts "files: #{files.inspect}"
59
- # puts "release_files: #{release_files.inspect}"
60
- # puts "feature_versions: #{feature_versions.map(&:to_s).inspect}"
61
- # puts "keep_file: #{keep_file}"
62
- # yield if block_given?
63
- # }
127
+ def migrate(database)
128
+ MigrationBuilder.new(database, features_dir).build
129
+ Rdbms.exec_sql(database.name, "delete from prick.versions", user: database.user)
130
+ Rdbms.exec_file(database.name, File.join(PRICK_DIR, "data.sql"), user: database.user)
64
131
  end
65
132
 
66
- protected
67
- def template_file(path) File.join(SHARE_PATH, @template_dir, File.basename(path)) end
68
- def keep_file() File.join(path, KEEP_FILE) end
133
+ # This only migrates included features. It is used in pre-releases to migrate included features
134
+ # before creating a diff, so the diff only contains changes not defined in the features
135
+ def migrate_features(database)
136
+ MigrationBuilder.new(database, features_dir).build("features.yml")
137
+ end
69
138
  end
70
139
 
71
140
  class ReleaseMigration < Migration
72
- FEATURES_TMPL_DIR = "features"
73
- FILES = [
74
- FEATURES_SQL = "features.sql",
75
- FEATURES_YML = "features.yml",
76
- MIGRATIONS_SQL = "migrations.sql",
77
- DIFF_SQL = "diff.sql"
78
- ]
79
-
80
- attr_reader :features_yml
81
- attr_reader :features_sql
82
- attr_reader :migrations_sql
83
- attr_reader :diff_sql
84
-
85
- def files() [features_yml, features_sql, migrations_sql, diff_sql] end
86
-
87
- def initialize(path)
88
- super(path, FEATURES_TMPL_DIR)
89
- @features_yml = File.join(path, FEATURES_YML)
90
- @features_sql = File.join(path, FEATURES_SQL)
91
- @migrations_sql = File.join(path, MIGRATIONS_SQL)
92
- @diff_sql = File.join(path, DIFF_SQL)
93
- end
94
-
95
- def feature_versions() read_features_yml.map { |path| Migration.version(path) } end
96
- def feature_paths() read_features_yml end
97
-
98
- # `feature` is a Feature object
99
- def include_feature(migration, append: true)
100
- migration.is_a?(FeatureMigration) or raise "Expected FeatureMigration object, got #{migration.class}"
101
- !feature_paths.include?(migration.path) or raise Error, "Feature #{migration.version} is already included"
102
- exclude_files = [Schema.data_file] +
103
- migration.release_files +
104
- migration.feature_paths.map { |path| FeatureMigration.release_files(path) }.flatten
105
-
106
- Git.merge_branch(migration.version, exclude_files: exclude_files)
107
-
108
- feature_paths = YAML.load(Git.read(migration.features_yml, branch: migration.version))
109
- append_features_yml(feature_paths, append: append)
110
-
111
- feature_paths.each { |feature_path|
112
- if path != File.dirname(feature_path)
113
- FileUtils.ln_s(File.join("../..", feature_path), path)
114
- Git.add(File.join(path, File.basename(feature_path)))
115
- end
116
- }
141
+ def version() @migration_state&.version end
142
+ def version=(v)
143
+ @migration_state ||= MigrationState.new(ReleaseMigration.migration_dir(v))
144
+ @migration_state.version = v
145
+ end
146
+
147
+ def base_version() @migration_state&.base_version end # || @migration_state.version end
148
+ def base_version=(v)
149
+ @migration_state or raise Internal, "Can't set base version when version is undefined"
150
+ @migration_state.base_version = v
151
+ end
152
+
153
+ # Note that `version` can be nil
154
+ def initialize(version, base_version)
155
+ migration_dir = version && ReleaseMigration.migration_dir(version)
156
+ features_dir = ReleaseMigration.features_dir(base_version)
157
+ super(migration_dir, features_dir)
158
+ if version
159
+ self.version = version
160
+ self.base_version = base_version
161
+ else
162
+ @base_migration_state = MigrationState.new(features_dir)
163
+ end
117
164
  end
118
165
 
119
- def migrate(database_name)
120
- puts "ReleaseMigration#migrate"
121
- if File.exist?(migrations_sql)
122
- Dir.chdir(path) {
123
- puts " cd #{path}"
124
- puts " psql -d #{database_name} < #{MIGRATIONS_SQL}"
125
- Command.command "psql -d #{database_name} < #{MIGRATIONS_SQL}"
126
- }
166
+ def create() super("release_migration/*") end
167
+
168
+ def self.load(migration_dir, features_dir = nil)
169
+ !migration_dir.nil? or raise "Parameter migration_dir can't be nil"
170
+ migration_state = MigrationState.new(migration_dir).read
171
+ if features_dir.nil?
172
+ features_dir = ReleaseMigration.features_dir(migration_state.base_version)
127
173
  end
174
+ self.new(migration_state.version, migration_state.base_version)
128
175
  end
176
+
177
+ def self.migration_dir(version) version && File.join(RELEASES_DIR, version.to_s) end
178
+ def self.features_dir(base_version) migration_dir(base_version) end
129
179
 
130
180
 
131
- def self.files(path)
132
- FILES.map { |file| File.join(path, file) }
133
- end
181
+ end
134
182
 
135
- def read_features_yml(branch = nil) YAML.load(File.read(features_yml)) || [] end
183
+ class MigrationMigration < Migration
184
+ attr_reader :name
136
185
 
137
- def append_features_yml(paths, append: true)
138
- write_features_yml(read_features_yml.insert(append ? -1 : 0, *paths))
186
+ def initialize(version, base_version)
187
+ @name = "#{base_version}_#{version}"
188
+ dir = File.join(MIGRATIONS_DIR, name)
189
+ super(dir, dir)
190
+ self.version = version
191
+ self.base_version = base_version
139
192
  end
140
193
 
141
- def write_features_yml(paths)
142
- FileUtils.cp(template_file(features_yml), features_yml)
143
- File.open(features_yml, "a") { |f| f.write(paths.to_yaml) }
144
- FileUtils.cp(template_file(features_sql), features_sql)
145
- File.open(features_sql, "a") { |f|
146
- paths.map { |path|
147
- f.puts "\\cd #{File.basename(path)}"
148
- f.puts "\\i migrate.sql" }
149
- f.puts "\\cd .."
150
- f.puts
151
- }
152
- Git.add(features_yml)
153
- Git.add(features_sql)
154
- end
194
+ def create() super("release_migration/*") end
155
195
  end
156
196
 
157
197
  class FeatureMigration < Migration
158
- FEATURE_TMPL_DIR = "features/feature"
159
- FILES = [
160
- MIGRATE_SQL = "migrate.sql",
161
- DIFF_SQL = "diff.sql"
162
- ]
163
-
164
198
  attr_reader :name
165
- attr_reader :version
166
- attr_reader :release_migration # The enclosing release migration
167
-
168
- attr_reader :migrate_sql
169
- attr_reader :diff_sql
170
-
171
- def features_yml() release_migration.features_yml end
172
- def features_sql() release_migration.features_sql end
173
- def migrations_sql() release_migration.migrations_sql end
174
- def release_diff_sql() release_migration.diff_sql end
175
-
176
- def files() [migrate_sql, diff_sql] end
177
- def release_files() release_migration.files end
178
199
 
179
- def feature_versions() release_migration.feature_versions end
180
- def feature_paths() release_migration.feature_paths end
181
-
182
- def initialize(path, name: File.basename(path))
183
- Migration.feature?(path) or raise "Expected a feature path, got #{path}"
184
- super(path, FEATURE_TMPL_DIR)
200
+ def initialize(name, base_version)
185
201
  @name = name
186
- @version = Migration.version(path)
187
- @release_migration = ReleaseMigration.new(File.dirname(path))
188
- @migrate_sql = File.join(path, MIGRATE_SQL)
189
- @diff_sql = File.join(path, DIFF_SQL)
190
- end
191
-
192
- def exist?() release_migration.exist? && super end
193
- def create()
194
- release_migration.exist? || release_migration.create
195
- super
196
- end
197
-
198
- def present?() release_migration.present? && super end
199
- def prepare()
200
- release_migration.present? || release_migration.prepare
201
- super
202
- release_migration.append_features_yml([path])
203
- end
204
-
205
- def include_feature(migration)
206
- release_migration.include_feature(migration, append: false)
207
- end
208
-
209
- def self.files(path)
210
- release_files + FILES.map { |file| File.join(path, file) }
202
+ dir = File.join(RELEASES_DIR, base_version.to_s, name)
203
+ super(dir, dir)
204
+ self.version = Version.new(base_version, feature: name)
205
+ self.base_version = base_version
211
206
  end
212
207
 
213
- def self.release_files(path)
214
- ReleaseMigration.files(path)
215
- end
216
-
217
- def dump
218
- super {
219
- puts "name: #{name}"
220
- puts "release_migration: #{path}"
221
- }
222
- end
208
+ def create() super("feature_migration/*") end
223
209
  end
224
210
  end
225
-
226
-
227
-
228
-
229
-
230
-