prick 0.4.0 → 0.5.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/TODO +7 -0
  4. data/exe/prick +95 -33
  5. data/lib/ext/fileutils.rb +7 -0
  6. data/lib/prick.rb +5 -3
  7. data/lib/prick/builder.rb +31 -8
  8. data/lib/prick/cache.rb +34 -0
  9. data/lib/prick/constants.rb +106 -54
  10. data/lib/prick/database.rb +26 -18
  11. data/lib/prick/diff.rb +103 -25
  12. data/lib/prick/git.rb +31 -9
  13. data/lib/prick/head.rb +183 -0
  14. data/lib/prick/migration.rb +41 -181
  15. data/lib/prick/program.rb +199 -0
  16. data/lib/prick/project.rb +277 -0
  17. data/lib/prick/rdbms.rb +2 -1
  18. data/lib/prick/schema.rb +5 -10
  19. data/lib/prick/state.rb +129 -74
  20. data/lib/prick/version.rb +41 -28
  21. data/share/diff/diff.after-tables.sql +4 -0
  22. data/share/diff/diff.before-tables.sql +4 -0
  23. data/share/diff/diff.tables.sql +8 -0
  24. data/share/migration/diff.tables.sql +8 -0
  25. data/share/{release_migration → migration}/features.yml +0 -0
  26. data/share/migration/migrate.sql +3 -0
  27. data/share/{release_migration → migration}/migrate.yml +3 -0
  28. data/share/migration/tables.sql +3 -0
  29. data/share/{schemas → schema/schema}/build.yml +0 -0
  30. data/share/{schemas → schema/schema}/prick/build.yml +0 -0
  31. data/share/schema/schema/prick/data.sql +7 -0
  32. data/share/{schemas → schema/schema}/prick/schema.sql +0 -0
  33. data/share/{schemas → schema/schema}/prick/tables.sql +2 -2
  34. data/share/{schemas → schema/schema}/public/.keep +0 -0
  35. data/share/{schemas → schema/schema}/public/build.yml +0 -0
  36. data/share/{schemas → schema/schema}/public/schema.sql +0 -0
  37. data/test_refactor +34 -0
  38. metadata +22 -20
  39. data/file +0 -0
  40. data/lib/prick/build.rb +0 -376
  41. data/lib/prick/migra.rb +0 -22
  42. data/share/feature_migration/diff.sql +0 -2
  43. data/share/feature_migration/migrate.sql +0 -2
  44. data/share/release_migration/diff.sql +0 -3
  45. data/share/release_migration/migrate.sql +0 -5
  46. data/share/schemas/prick/data.sql +0 -7
@@ -2,6 +2,283 @@ require "prick/state.rb"
2
2
 
3
3
  require "tmpdir"
4
4
 
5
+ module Prick
6
+ class Project
7
+ attr_reader :name
8
+ attr_reader :user # Database user
9
+ attr_reader :head # Current branch/tag
10
+ attr_reader :schema
11
+
12
+ # Return the versioned database or the project database if `version` is nil
13
+ def database(version = nil)
14
+ version ? Database.new("#{name}-#{version}", user) : @database
15
+ end
16
+
17
+ attr_reader :cache
18
+
19
+ def initialize(name, user, head)
20
+ @name = name
21
+ @user = user || ENV['USER']
22
+ @head = head
23
+ @schema = Schema.new
24
+ @database = Database.new(name, user)
25
+ @cache = Cache.new
26
+ end
27
+
28
+ forward_methods :version, :base_version, :@head
29
+ forward_methods :clean?, :@head
30
+ forward_methods :tag?, :release_tag?, :migration_tag?, :@head
31
+ forward_methods :branch?, :release_branch?, :prerelease_branch?, :feature_branch?, :migration_branch?, :@head
32
+
33
+ def self.exist?(directory)
34
+ File.directory?(directory) && Dir.chdir(directory) { ProjectState.new.exist? }
35
+ end
36
+
37
+ def self.create(name, user, directory)
38
+ user ||= ENV['USER']
39
+
40
+ FileUtils.mkdir(directory) if directory != "."
41
+
42
+ Dir.chdir(directory) {
43
+ # Initialize git instance
44
+ Git.init
45
+
46
+ # Create prick version file
47
+ PrickVersion.new.write(VERSION)
48
+
49
+ # Create project state file
50
+ ProjectState.new(name: name, user: user).write
51
+
52
+ # Directories
53
+ FileUtils.mkdir_p(DIRS)
54
+ DIRS.each { |dir| FileUtils.touch("#{dir}/.keep") }
55
+
56
+ # Copy default gitignore and schema files
57
+ Share.cp("gitignore", ".gitignore")
58
+ Share.cp("schema/schema", ".")
59
+
60
+ # Add .prick-migration file
61
+ MigrationState.new(version: Version.zero).create
62
+
63
+ # Add everything so far
64
+ Git.add(".")
65
+ Git.commit("Initial import")
66
+
67
+ # Rename branch "master"/"main" to "0.0.0_initial"
68
+ from_branch = Git.branch?("master") ? "master" : "main"
69
+ Git.rename_branch(from_branch, "0.0.0_initial")
70
+
71
+ # Create schema
72
+ schema = Schema.new
73
+ schema.version = Version.zero
74
+ Git.add schema.version_file
75
+
76
+ # Create release
77
+ Git.commit "Created release v0.0.0"
78
+ Git.create_tag(Version.zero)
79
+ Git.checkout_tag(Version.zero)
80
+ }
81
+ end
82
+
83
+ # Initialize from disk
84
+ #
85
+ # TODO Handle migrations
86
+ def self.load
87
+ if Git.detached?
88
+ name = "v#{Git.current_tag}"
89
+ else
90
+ name = Git.current_branch
91
+ end
92
+ begin
93
+ branch = Head.load(name)
94
+ rescue Version::FormatError
95
+ raise Fail, "Illegal branch name: #{name}"
96
+ end
97
+ state = ProjectState.new.read
98
+ Project.new(state.name, state.user, branch)
99
+ end
100
+
101
+ # def checkout(branch_or_tag) end
102
+
103
+ def build(database = self.database, version: nil)
104
+ database.clean
105
+ if version
106
+ FileUtils.mkdir_p(TMP_DIR)
107
+ Dir.mktmpdir("clone-", TMP_DIR) { |dir|
108
+ Command.command "git clone . #{dir}"
109
+ Dir.chdir(dir) {
110
+ Git.checkout_tag(version)
111
+ project = Project.load
112
+ project.head.build(database)
113
+ }
114
+ }
115
+ else
116
+ head.build(database)
117
+ end
118
+ self
119
+ end
120
+
121
+ # def make(database = self.database, version: nil)
122
+ # database.clean
123
+ # if cache.exist?(version)
124
+ # load(database, version: version)
125
+ # else
126
+ # build(database, version: version)
127
+ # cache.save(database, version) if version
128
+ # end
129
+ # self
130
+ # end
131
+
132
+ def load(database = self.database, version: nil, file: nil)
133
+ version.nil? ^ file.nil? or raise Internal, "Need exactly one of :file and :version to be defined"
134
+ database.clean
135
+ if version
136
+ cache.exist?(version) or raise Internal, "Can't find cache file for database #{database}"
137
+ cache.load(database, version)
138
+ else
139
+ database.load(file)
140
+ end
141
+ self
142
+ end
143
+
144
+ def save(database = nil, file: nil)
145
+ !database.nil? || file or raise Internal, "Need a database when saving to file"
146
+ database ||= self.database
147
+ if file
148
+ database.save(file)
149
+ else
150
+ cache.save(database)
151
+ end
152
+ self
153
+ end
154
+
155
+ # Create a schema diff between two database versions. to_version defaults to the
156
+ # current version. Returns the Diff object
157
+ def diff(from_version, to_version)
158
+ begin
159
+ from_db = Database.new("#{name}-base", user)
160
+ to_db = Database.new("#{name}-next", user)
161
+ from_version ||= version
162
+ build(from_db, version: from_version)
163
+ build(to_db, version: to_version)
164
+ Diff.new(from_db, to_db)
165
+ ensure
166
+ from_db&.drop
167
+ to_db&.drop
168
+ end
169
+ end
170
+
171
+ def prepare_release(fork = nil)
172
+ check_clean(:release_tag)
173
+ @head = ReleaseBranch.new(fork, version).create
174
+ submit "Prepared new release based on v#{version}", true
175
+ self
176
+ end
177
+
178
+ def prepare_diff(from_version = version)
179
+ begin
180
+ from_name = "#{name}-base"
181
+ from_db = Database.new(from_name, user)
182
+ build(from_db, version: from_version)
183
+
184
+ to_name = "#{name}-next"
185
+ to_db = Database.new(to_name, user)
186
+ build(to_db)
187
+
188
+ if prerelease_branch?
189
+ head.migrate_features(from_db)
190
+ end
191
+
192
+ diff = Diff.new(from_db, to_db)
193
+ for path, lines, tables in [
194
+ [BEFORE_TABLES_DIFF_PATH, diff.before_table_changes, false],
195
+ [TABLES_DIFF_PATH, diff.table_changes, true],
196
+ [AFTER_TABLES_DIFF_PATH, diff.after_table_changes, false]]
197
+ if lines.empty?
198
+ if File.exist?(path)
199
+ if tables
200
+ Share.cp(File.join("diff", File.basename(path)), path)
201
+ Git.add(path)
202
+ else
203
+ Git.rm(path)
204
+ end
205
+ end
206
+ else
207
+ Share.cp(File.join("diff", File.basename(path)), path)
208
+ File.open(path, "a") { |f| f.puts lines }
209
+ Git.add(path) if !tables
210
+ end
211
+ end
212
+ self
213
+ ensure
214
+ from_db&.drop
215
+ to_db&.drop
216
+ end
217
+ end
218
+
219
+ def create_release(new_version)
220
+ check_clean(:release_branch)
221
+ head = ReleaseTag.new(new_version, version)
222
+ check_migration(head.migration)
223
+ (@head = head).create
224
+ self
225
+ end
226
+
227
+ def generate_schema
228
+ build = SchemaBuilder.new(database, SCHEMA_DIR).build(execute: false)
229
+ puts build.lines
230
+ end
231
+
232
+ def generate_migration
233
+ build = MigrationBuilder.new(database, MIGRATION_DIR).build(execute: false)
234
+ puts build.lines
235
+ end
236
+
237
+ private
238
+ def check(kind) self.send(:"#{kind}?") end
239
+ def check_clean(kind = nil)
240
+ clean? or raise Internal, "Dirty repository"
241
+ kind.nil? || check(kind) or raise Internal, "Not on a #{kind} tag/branh"
242
+ end
243
+
244
+ def check_branch() branch? or raise Internal, "Not on a branch" end
245
+ def check_tag() tag? or raise Internal, "Not on a tag" end
246
+
247
+ # FIXME: Use Cache::file
248
+ def cache_file(version) File.join(CACHE_DIR, "#{database(version)}.sql.gz") end
249
+
250
+ def submit(msg, commit = true)
251
+ Git.commit msg if commit
252
+ @message = msg
253
+ end
254
+
255
+ def clean(database) # FIXME: Use Database#clean
256
+ if database.exist?
257
+ database.recreate if database.loaded?
258
+ else
259
+ database.create
260
+ end
261
+ end
262
+
263
+ def check_migration(migration)
264
+ begin
265
+ from_version = migration.base_version
266
+ from_db = Database.new("#{name}-base", user)
267
+ to_db = Database.new("#{name}-next", user)
268
+ build(from_db, version: from_version)
269
+ build(to_db)
270
+ migration.migrate(from_db)
271
+ Diff.new(from_db, to_db).same? or raise Error, "Schema/migration mismatch"
272
+ ensure
273
+ from_db&.drop
274
+ to_db&.drop
275
+ end
276
+ end
277
+ end
278
+ end
279
+
280
+ __END__
281
+
5
282
  module Prick
6
283
  class Project
7
284
  # Name of project. Persisted in the project state file
@@ -9,7 +9,8 @@ module Prick
9
9
 
10
10
  ### EXECUTE SQL
11
11
 
12
- # Execute the SQL statement and return stdout as an array of tuples
12
+ # Execute the SQL statement and return stdout as an array of tuples. FIXME:
13
+ # SQL can't contain '"'
13
14
  def self.exec_sql(db, sql, user: ENV['USER'])
14
15
  stdout = Command.command %(
15
16
  {
@@ -9,22 +9,17 @@ module Prick
9
9
 
10
10
  # Note this models the SCHEMAS_DIR directory, not a database schema
11
11
  class Schema
12
- attr_reader :directory
13
- def yml_file() SchemaBuilder.yml_file(directory) end
12
+ def yml_file() SchemaBuilder.yml_file(SCHEMA_DIR) end
14
13
 
15
- def version() SchemaVersion.new(directory).read end
16
- def version=(version) SchemaVersion.new(directory).write(version) end
17
- def version_file() SchemaVersion.new(directory).path end
18
-
19
- def initialize(directory = SCHEMAS_DIR)
20
- @directory = directory
21
- end
14
+ def version() SchemaVersion.load end
15
+ def version=(version) SchemaVersion.new(version).write end
16
+ def version_file() SchemaVersion.new.path end
22
17
 
23
18
  def built?(database) database.exist? && database.version == version end
24
19
 
25
20
  # `subject` can be a subpath of schema/ (ie. 'public/tables')
26
21
  def build(database, subject = nil)
27
- SchemaBuilder.new(database, directory).build(subject)
22
+ SchemaBuilder.new(database, SCHEMA_DIR).build(subject)
28
23
  end
29
24
  end
30
25
  end
@@ -2,25 +2,119 @@
2
2
  require 'yaml'
3
3
 
4
4
  module Prick
5
+ # General interface for prick state files. Comments are automatically removed
5
6
  class PrickFile
6
7
  attr_reader :path
7
8
  def initialize(path) @path = path end
8
9
  def exist?() File.exist?(path) end
10
+ def self.exist?() self.new.exist? end # Require an #initializer with no arguments
11
+
12
+ def self.load(*args) self.new(*args).read end
13
+
14
+ def self.read() raise NotThis end
9
15
 
10
16
  protected
17
+ # Read file from disk or from the given branch or tag
11
18
  def general_read(method, branch: nil, tag: nil)
19
+ !(branch && tag) or raise Internal, "Not both of `branch` and `tag` can be defined"
12
20
  branch || tag ? Git.send(method, path, branch: branch, tag: tag) : File.open(path, "r").send(method)
13
21
  end
22
+
14
23
  def do_read(**opts) general_read(:read, **opts) end
15
- def do_readlines(**opts) general_read(:readlines, **opts) end
24
+ def do_readline(**opts) do_readlines(**opts).first end
25
+ def do_readlines(**opts) general_read(:readlines, **opts).reject { |l| l =~ /^\s*#/ } end
26
+ end
27
+
28
+ # Models the .prick-version file. It contains just one line with the version
29
+ # of prick itself
30
+ class PrickVersion < PrickFile
31
+ def initialize() super PRICK_VERSION_FILE end
32
+
33
+ # Return the version
34
+ def read(branch: nil, tag: nil)
35
+ Version.new(do_readline(branch: branch, tag: tag).chomp.sub(/^prick-/, ""))
36
+ end
37
+
38
+ # Write prick version
39
+ def write(version)
40
+ File.open(path, "w") { |file| file.puts "prick-#{version}" }
41
+ end
42
+ end
43
+
44
+ # Models the schema version that is stored in the data.sql file in the prick
45
+ # schema directory. Note that SchemaVersion caches the version. Use #clear to
46
+ # reset the cache
47
+ class SchemaVersion < PrickFile
48
+ def initialize(version = nil)
49
+ @version = version
50
+ super SCHEMA_VERSION_PATH
51
+ end
52
+
53
+ def clear() @version = nil end
54
+
55
+ def create() raise Internal, "This should not happen" end
56
+
57
+ def read(**opts)
58
+ return @version if @version
59
+ lines = do_readlines
60
+ while !lines.empty?
61
+ line = lines.shift.chomp
62
+ next if line != COPY_STMT
63
+ data = lines.shift.chomp
64
+ a = data.split("\t").map { |val| parse_sql_literal(val) }
65
+ a.size == FIELDS.size + 1 or raise Fail, "Illegal data format in #{path}"
66
+ fork, major, minor, patch, pre, feature, version = *a[1..-1]
67
+ @version = Version.new("0.0.0", fork: fork, feature: feature)
68
+ @version.major = major
69
+ @version.minor = minor
70
+ @version.patch = patch
71
+ @version.pre = pre
72
+ return @version
73
+ end
74
+ raise Fail, "No COPY statement in #{path}"
75
+ end
76
+
77
+ def write(version = @version)
78
+ # puts "Writing #{path}"
79
+ version_string = version.truncate(:pre).to_s
80
+ File.open(path, "w") { |f|
81
+ f.puts "--"
82
+ f.puts "-- This file is auto-generated by prick(1). Please don't touch"
83
+ f.puts COPY_STMT
84
+ f.print \
85
+ "1\t",
86
+ FIELDS[..-2].map { |f| version.send(f.to_sym) || '\N' }.join("\t"),
87
+ "\t#{version_string}\n"
88
+ f.puts "\\."
89
+ }
90
+ Git.add(path)
91
+ version
92
+ end
93
+
94
+ private
95
+ FIELDS = %w(fork major minor patch pre feature version)
96
+ COPY_STMT = "COPY prick.versions (id, #{FIELDS.join(', ')}) FROM stdin;"
97
+
98
+ def parse_sql_literal(s)
99
+ case s
100
+ when '\N', 'null'; nil
101
+ when/^\d+$/; s.to_i
102
+ else
103
+ s
104
+ end
105
+ end
16
106
  end
17
107
 
108
+ # General interface for prick state files in YAML format
18
109
  class State < PrickFile
19
- # `fields` is a Hash from field name (Symbol) to field type (class). Eg. { f: Integer }
110
+ # `fields` is a Hash from field name (Symbol) to field type (class) where a
111
+ # class can be a class known to YAML (Integer, String, etc.) or Version.
112
+ # The #initialize method generates an accessor methods for each field
20
113
  def initialize(path, **fields)
21
114
  super(path)
22
115
  @fields = fields
23
116
  @fields.each_key { |k| self.class.attr_accessor k }
117
+ @loaded = false
24
118
  end
25
119
 
26
120
  def create() write end
@@ -29,23 +123,23 @@ module Prick
29
123
  for field, value in fields
30
124
  self.send(:"#{field}=", value)
31
125
  end
126
+ self
32
127
  end
33
128
 
34
129
  def read(branch: nil, tag: nil)
35
- if branch.nil?
36
- hash = YAML.load(do_read(branch: branch, tag: tag))
37
- for field, klass in @fields
38
- value = hash[field.to_s]
39
- value = Version.new(value) if klass == Version && !value.nil?
40
- self.instance_variable_set("@#{field}", value)
41
- end
42
- else
43
- raise NotYet
130
+ return self if @loaded
131
+ hash = YAML.load(do_read(branch: branch, tag: tag))
132
+ for field, klass in @fields
133
+ value = hash[field.to_s]
134
+ value = Version.new(value) if klass == Version && !value.nil?
135
+ self.instance_variable_set("@#{field}", value)
44
136
  end
137
+ @loaded = true
45
138
  self
46
139
  end
47
140
 
48
- def write
141
+ def write(**fields)
142
+ set(**fields)
49
143
  hash = @fields.map { |field, klass|
50
144
  value = self.send(field)
51
145
  value = value.to_s if klass == Version && !value.nil?
@@ -58,80 +152,41 @@ module Prick
58
152
  end
59
153
 
60
154
  class MigrationState < State
61
- def initialize(directory, version: nil, base_version: nil)
62
- super(File.join(directory, MIGRATION_STATE_FILE), version: Version, base_version: Version)
155
+ def initialize(version: nil, base_version: nil)
156
+ super(PRICK_MIGRATION_PATH, version: Version, base_version: Version)
63
157
  set(version: version, base_version: base_version)
64
158
  end
65
159
  end
66
160
 
67
- class FeaturesState < State
68
- def initialize(directory, features: [])
69
- super(File.join(directory, FEATURES_STATE_FILE), features: Array)
70
- set(features: features)
161
+ class HeadState < State
162
+ def initialize(name: nil)
163
+ super(PRICK_HEAD_PATH, name: String)
164
+ set(name: name)
71
165
  end
72
166
  end
73
167
 
74
- class ProjectState < State
75
- def initialize(name: nil, user: nil)
76
- super(PROJECT_STATE_FILE, name: String, user: String)
77
- set(name: name, user: user)
168
+ class FeatureState < State
169
+ def initialize(feature: nil)
170
+ super(PRICK_FEATURE_PATH, feature: String)
171
+ set(feature: feature)
78
172
  end
79
173
  end
80
174
 
81
- class PrickVersion < PrickFile
82
- def initialize() super PRICK_VERSION_FILE end
83
-
84
- def read(branch: nil, tag: nil)
85
- Version.new(do_readlines(branch: branch, tag: tag).first.chomp.sub(/^prick-/, ""))
86
- end
175
+ # class FeaturesState < State
176
+ # def initialize(directory, features: [])
177
+ # super(File.join(directory, FEATURES_STATE_FILE), features: Array)
178
+ # set(features: features)
179
+ # end
180
+ # end
181
+ #
87
182
 
88
- def write(version)
89
- File.open(path, "w") { |file| file.puts "prick-#{version}" }
183
+ # Models the .prick-project file that contains the name of the project and
184
+ # the database user
185
+ class ProjectState < State
186
+ def initialize(name: nil, user: nil)
187
+ super(PROJECT_STATE_FILE, name: String, user: String)
188
+ set(name: name, user: user)
90
189
  end
91
190
  end
92
191
 
93
- class SchemaVersion < PrickFile
94
- def initialize(schema = SCHEMAS_DIR) super(File.join(schema, "prick", "data.sql")) end
95
-
96
- def read(**opts)
97
- lines = do_readlines
98
- while !lines.empty?
99
- line = lines.shift.chomp
100
- next if line != COPY_STMT
101
- l = lines.shift.chomp
102
- a = l.split("\t")[1..].map { |val| val == '\N' ? nil : val }
103
- a.size == FIELDS.size or raise Fail, "Illegal data format in #{path}"
104
- custom, major, minor, patch, pre = a[0], *a[1..-2].map { |val| val && val.to_i }
105
- v = Version.new("0.0.0", custom: (custom == '\N' ? nil : custom))
106
- v.major = major
107
- v.minor = minor
108
- v.patch = patch
109
- v.pre = (pre == "null" ? nil : pre)
110
- return v
111
- end
112
- raise Fail, "No COPY statement in #{path}"
113
- end
114
-
115
- def write(version)
116
- version_string = version.truncate(:pre).to_s
117
- File.open(path, "w") { |f|
118
- f.puts "--"
119
- f.puts "-- This file is auto-generated by prick(1). Please don't touch"
120
- f.puts COPY_STMT
121
- f.print \
122
- "1\t",
123
- FIELDS[..-2].map { |f| version.send(f.to_sym) || '\N' }.join("\t"),
124
- "\t#{version_string}\n"
125
- f.puts "\\."
126
- }
127
- Git.add(path)
128
- end
129
-
130
- def create() raise Internal, "This should not happen" end
131
-
132
- private
133
- FIELDS = %w(custom major minor patch pre feature version)
134
- COPY_STMT = "COPY prick.versions (id, #{FIELDS.join(', ')}) FROM stdin;"
135
- end
136
192
  end
137
-