prick 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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
-