prick 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,444 @@
1
+
2
+ require "prick/constants.rb"
3
+
4
+ require "prick/archive.rb"
5
+ require "prick/build.rb"
6
+ require "prick/migration.rb"
7
+ require "prick/database.rb"
8
+ require "prick/schema.rb"
9
+ require "prick/git.rb"
10
+ require "prick/migra.rb"
11
+
12
+ require 'fileutils'
13
+
14
+ module Prick
15
+ class Project
16
+ # Name of project. Used in database and release names. Defaults to the name
17
+ # of the current directory
18
+ attr_reader :name
19
+
20
+ # Name of Postgresql user that own the database(s). Defaults to #name
21
+ attr_reader :user
22
+
23
+ # Schema. Represents the schema definition under the schemas/ directory
24
+ attr_reader :schema
25
+
26
+ # The current release, prerelease or feature
27
+ def release() @builds_by_name[Git.current_branch] end
28
+
29
+ # The project database. The project database has the same name as the project
30
+ # and doesn't include a version. It does not have to exist
31
+ attr_reader :database
32
+
33
+ # List of all builds
34
+ def builds() @builds_by_name.values end
35
+
36
+ # List of releases ordered from oldest to newest. Custom releases are sorted
37
+ # after regular releases and alphabetical between themselves
38
+ attr_reader :releases
39
+
40
+ # List of pre-releases
41
+ attr_reader :prereleases
42
+
43
+ # Hash from base-release name to feature. Note that this doesn't follow release history
44
+ attr_reader :features
45
+
46
+ # Ignored and orphaned objects
47
+ attr_reader :ignored_feature_nodes
48
+ attr_reader :ignored_release_nodes
49
+ attr_reader :orphan_feature_nodes
50
+ attr_reader :orphan_release_nodes
51
+ attr_reader :orphan_git_branches
52
+
53
+ # True if we're on a release branch
54
+ def release?() release && release.version.pre.nil? && !feature? end
55
+
56
+ # True if we're on a pre-release branch
57
+ def prerelease?() release && !release.version.pre.nil? && !feature? end
58
+
59
+ # True if we're on a feature branch
60
+ def feature?() release && !release.version.feature.nil? end
61
+
62
+ # Sorted list of databases associated with a release. Also include the
63
+ # project database if project. is true. If all: is true, also list
64
+ # databases that match the project but is without an associated release
65
+ def databases(all: false, project: true)
66
+ r = (project ? [database] : [])
67
+ if all
68
+ r + Rdbms.list_databases(Prick.database_re(name)).map { |name| Database.new(name, user) }
69
+ else
70
+ r + @releases.map(&:database)
71
+ end
72
+ end
73
+
74
+ # `name` is the project name and `user` is the name of the Postgresql user.
75
+ # `name` defaults to the name of the current directory and `user` defaults
76
+ # to `name`
77
+ def initialize(name = nil, user = nil)
78
+ @name = name || File.basename(Dir.getwd)
79
+ @name =~ PROJECT_NAME_RE or raise Error, "Illegal project name: #@name"
80
+ @user = user || @name
81
+ @user =~ USER_NAME_RE or raise Error, "Illegal postgres user name: #@user"
82
+ # Rdbms.ensure_state(:user_exist, @user)
83
+ Rdbms.create_user(@user) if !Rdbms.exist_user?(@user) # FIXME
84
+ @schema = Schema.new(self)
85
+ @database = Database.new(@name, @user)
86
+
87
+ @builds_by_name = {}
88
+ @builds_by_version = {}
89
+
90
+ @releases = []
91
+ @prereleases = []
92
+ @features = {}
93
+
94
+ @ignored_feature_nodes = []
95
+ @ignored_release_nodes = []
96
+
97
+ @orphan_feature_nodes = []
98
+ @orphan_release_nodes = [] # Includes prereleases
99
+
100
+ @orphan_git_branches = []
101
+
102
+ load_project
103
+ end
104
+
105
+ def [](name_or_version)
106
+ name = version = name_or_version
107
+ case name_or_version
108
+ when String; @builds_by_name[name]
109
+ when Version; @builds_by_version[version]
110
+ when NilClass; nil
111
+ else
112
+ raise Internal, "Expected String or Version index, got #{name_or_version.class}"
113
+ end
114
+ end
115
+
116
+ def []=(name_or_version, branch)
117
+ case name_or_version
118
+ when String;
119
+ name = name_or_version
120
+ version = Version.new(name)
121
+ when Version
122
+ version = name_or_version
123
+ name = version.to_s
124
+ else
125
+ raise Internal, "Expected String or Version index, got #{name_or_version.class}"
126
+ end
127
+ @builds_by_name[name] = @builds_by_version[version] = branch
128
+ end
129
+
130
+ def dirty?() !Git.clean? end
131
+
132
+ def build?(name_or_version)
133
+ name = version = name_or_version
134
+ case name_or_version
135
+ when String; @builds_by_name.key?(name)
136
+ when Version; @builds_by_version.key?(version)
137
+ when NilClass; nil
138
+ else
139
+ raise Internal, "Expected String or Version index, got #{name_or_version.class}"
140
+ end
141
+ end
142
+
143
+ # Initialize an on-disk prick instance
144
+ def self.initialize_directory(directory)
145
+ FileUtils.mkdir_p(directory)
146
+ Dir.chdir(directory) {
147
+ DIRS.each { |dir|
148
+ !File.exist?(dir) or raise Fail, "Already initialized: Directory '#{dir}' exists"
149
+ }
150
+
151
+ FileUtils.mkdir_p(DIRS)
152
+ DIRS.each { |dir| FileUtils.touch("#{dir}/.keep") }
153
+ }
154
+ end
155
+
156
+ # Create an initial release 0.0.0. ::initialize_project expects to be executed
157
+ # in the project directory
158
+ def self.initialize_project(name = File.basename(Dir.getwd), user = name || ENV['USER'])
159
+ FileUtils.cp("#{SHARE_PATH}/gitignore", ".gitignore")
160
+ FileUtils.cp("#{SHARE_PATH}/schemas/prick/schema.sql", "schemas/prick")
161
+ FileUtils.cp("#{SHARE_PATH}/schemas/prick/data.sql", "schemas/prick")
162
+ Git.init
163
+ Git.add(".")
164
+ Git.commit("Initial import")
165
+ project = Project.new(name, user)
166
+ release = Release.new(project, nil, Version.new("0.0.0"))
167
+ release.create
168
+
169
+ Git.delete_branch("master")
170
+ end
171
+
172
+ # Returns the full path to the project directory (TODO: Test)
173
+ def path() File.expand_path(".") end
174
+
175
+ ### COMMON METHODS
176
+
177
+ def checkout(name)
178
+ check_clean
179
+ Git.checkout_branch(name)
180
+ end
181
+
182
+ # Migrate the database from its current version to the current release on disk
183
+ def migrate
184
+ check_clean
185
+ database.exist? or raise "Project database not found"
186
+ history = release.history.keys.reverse.drop(1)
187
+ history.select! { |release| release.version >= database.version }
188
+ history.each { |release| release.migration.migrate(database.name) }
189
+ database.version = release.version
190
+ end
191
+
192
+ ### DEVELOPER-LEVEL METHODS
193
+
194
+ # Create and switch to feature branch
195
+ def feature(name)
196
+ check_clean
197
+ release? or raise Error, "Need to be on a release branch to create a feature"
198
+ feature = Feature.new(self, release, name).ensure(:active)
199
+ Git.commit "Created feature #{name}"
200
+ end
201
+
202
+ def rebase(version)
203
+ check_clean
204
+ feature? or raise Error, "Need to be on a feature branch to rebase"
205
+ release.rebase(version)
206
+ Git.commit "Rebased to #{version}"
207
+ end
208
+
209
+ ### RELEASE MANAGER LEVEL METHODS
210
+
211
+ # Create the first prerelease
212
+ #
213
+ # TODO Make it just copy files and commit them. Supports a single-user
214
+ # workflow. Requires a :prepared state
215
+ def prepare_release
216
+ check_clean
217
+ release? or raise Error, "Need to be on a release branch to prepare a new release"
218
+ release.prepare
219
+ end
220
+
221
+ def prepare_migration
222
+ check_clean
223
+ prerelease? || feature? or raise Error, "Need to be on a prerelease or feature branch to include features"
224
+
225
+ base_database = release.base_release.database
226
+ build(base_release.version) if !base_database.loaded?
227
+ build # if !database.loaded?
228
+
229
+ diff_sql = release.migration.diff_sql
230
+ Migra.migrate(base_database.name, database.name, diff_sql)
231
+ end
232
+
233
+ def create_release(version = nil)
234
+ check_clean
235
+ if release?
236
+ !version.nil? or raise Error, "Need version argument when on a release branch"
237
+ new_release = Release.new(self, release, version)
238
+ elsif prerelease?
239
+ version.nil? or raise Error, "Can't use version argument when on a pre-release branch"
240
+ new_release = release.target_release
241
+ else
242
+ raise Error, "Need to be on a release or pre-prelease branch to create a new release"
243
+ end
244
+ new_release.create
245
+ new_release
246
+ end
247
+
248
+ def create_prerelease(target_version)
249
+ check_clean
250
+ release? or raise Error, "Need to be on a release branch to create a pre-release"
251
+ prerelease = Prerelease.new(self, release, target_version.increment(:pre, 0), target_version)
252
+ prerelease.create
253
+ end
254
+
255
+ def increment_prerelease
256
+ check_clean
257
+ prerelease? or raise Error, "Need to be on a pre-release branch to make an incremental pre-release"
258
+ prerelease = Prerelease.new(self, release.base_release, release.version.increment(:pre))
259
+ prerelease.create
260
+ end
261
+
262
+ def create_feature(name)
263
+ check_clean
264
+ release? or raise Error, "Need to be on a release branch to create a feature"
265
+ feature = Feature.new(self, release, name)
266
+ feature.create
267
+ end
268
+
269
+ # Note: Does not commit
270
+ def include_feature(feature_version)
271
+ check_clean
272
+ prerelease? || feature? or raise Error, "Need to be on a prerelease or feature branch to include features"
273
+ Git.branch?(feature_version) or raise Error, "Can't find branch #{feature_version}"
274
+ release.include_feature(self[feature_version])
275
+ end
276
+
277
+ def commit_feature
278
+ msg = File.readlines(".git/MERGE_MSG").first.chomp
279
+ Git.commit msg
280
+ # Command.command "git commit -F .git/MERGE_MSG"
281
+ end
282
+
283
+ def create_migration
284
+ end
285
+
286
+ # Build the current database from the content of schema
287
+ def build(version = nil)
288
+ if version.nil?
289
+ database.recreate
290
+ schema.build
291
+ else
292
+ release = self[version] or raise Error, "Can't find release #{version}"
293
+ release.database.recreate
294
+ FileUtils.mkdir_p(CACHE_DIR)
295
+ if !File.directory?(File.join(CLONE_DIR, ".git"))
296
+ FileUtils.rm_rf(CLONE_DIR)
297
+ Command.command "git clone . #{CLONE_DIR}"
298
+ end
299
+ Dir.chdir(CLONE_DIR) {
300
+ Git.checkout_tag(version.to_s)
301
+ project = Project.new(name, user)
302
+ release.database.recreate
303
+ project.schema.build(release.database)
304
+ }
305
+ release.cache
306
+ end
307
+ end
308
+
309
+ # Load file into current database
310
+ def use_database(file = nil)
311
+ check_clean
312
+ File.file?(file) or raise Error, "Can't find #{file}"
313
+ database.recreate
314
+ database.load(file)
315
+ end
316
+
317
+ # Load file into the given database version. If version is nil the current
318
+ # database is used. If file is nil, the database associated with the given
319
+ # version is used. Not both version and file can be nil
320
+ def load_database(version, file = nil)
321
+ check_clean
322
+ !version.nil? || !file.nil? or raise Fail, "Not both version and file can be nil"
323
+ if version
324
+ release = self[version]
325
+ file ||= release.archive.path
326
+ File.file?(file) or raise Error, "Can't find #{file}"
327
+ release.database.recreate
328
+ release.database.load(file)
329
+ else
330
+ database.recreate
331
+ database.load(file)
332
+ end
333
+ end
334
+
335
+ # def reload_release(version, *file)
336
+ # lookup(version).reload(*file)
337
+ # end
338
+ #
339
+ # def unload(version = nil)
340
+ # if version
341
+ # lookup(version).database.drop
342
+ # else
343
+ # databases.each(&:drop)
344
+ # end
345
+ # end
346
+ #
347
+ # Remove temporary files and databases
348
+ def cleanup(version = nil, project: false)
349
+ if version
350
+ release = self[version]
351
+ release.archive.delete
352
+ release.database.drop
353
+ else
354
+ FileUtils::rm_rf CLONE_DIR
355
+ FileUtils::rm_f Dir.glob(File.join(CACHE_DIR, Prick.dump_glob(name)))
356
+ databases(all: true, project: project).each(&:drop)
357
+ end
358
+ end
359
+
360
+ private
361
+ def check_clean()
362
+ !dirty? or raise "Repository is dirty. Commit your changes and try again"
363
+ end
364
+
365
+ # For debug
366
+ def dump_deps(deps)
367
+ deps.each { |k,v|
368
+ puts " #{k} -> #{v || 'nil'}"
369
+ }
370
+ end
371
+
372
+ def load_project
373
+ # Maps from version to base release version
374
+ releases = { Version.zero => nil }
375
+ prereleases = {}
376
+ features = {}
377
+
378
+ # Collect branches from git
379
+ Git.list_branches.each { |branch|
380
+ if Version.version?(branch)
381
+ version = Version.new(branch)
382
+ if version.feature?
383
+ features[version] = version.truncate(:feature)
384
+ elsif version.pre?
385
+ prereleases[version] = nil
386
+ elsif version.release?
387
+ releases[version] = nil
388
+ else
389
+ raise Internal, "Unexpected state for #{version}"
390
+ end
391
+ else
392
+ @orphan_git_branches << branch
393
+ end
394
+ }
395
+
396
+ # Find base release for present releases and prereleases
397
+ Dir.each_child(RELEASE_DIR) { |node|
398
+ next if node.start_with?(".")
399
+ if Version.version?(node)
400
+ version = Version.new(node)
401
+ if releases.key?(version)
402
+ base_release = Build.deref_node_file(File.join(RELEASE_DIR, node))
403
+ releases[version] = base_release
404
+ elsif prereleases.key?(version)
405
+ base_release = Build.deref_node_file(File.join(RELEASE_DIR, node))
406
+ prereleases[version] = base_release
407
+ else
408
+ @orphan_release_nodes << node
409
+ end
410
+ else !prerelease_deps.key?(node)
411
+ @ignored_release_nodes << node
412
+ end
413
+ }
414
+
415
+ releases.sort_by { |k,v| k }.each { |release, base_release|
416
+ @releases << Release.new(self, self[base_release], release)
417
+ }
418
+ @releases.sort!
419
+
420
+ prereleases.each { |prerelease, base_release|
421
+ if base_release
422
+ @prereleases << Prerelease.new(self, self[base_release], prerelease)
423
+ else
424
+ @ignored_release_nodes << prerelease
425
+ end
426
+ }
427
+ @prereleases.sort!
428
+
429
+ if !Git.detached?
430
+ features.each { |feature, base_release|
431
+ # if File.exist?(base_release_file)
432
+ # recursive include of dependent releases
433
+
434
+ if base_release
435
+ (@features[base_release] ||= []) << Feature.new(self, self[base_release], feature.feature)
436
+ else
437
+ @ignored_release_nodes << release
438
+ end
439
+ }
440
+ end
441
+ end
442
+ end
443
+ end
444
+
@@ -0,0 +1,147 @@
1
+ require 'prick/command.rb'
2
+ require 'prick/ensure.rb'
3
+
4
+ require 'csv'
5
+
6
+ module Prick
7
+ module Rdbms
8
+ extend Ensure
9
+
10
+ ### EXECUTE SQL
11
+
12
+ # Execute the SQL statement and return stdout as an array of tuples
13
+ def self.exec_sql(db, sql, user: ENV['USER'])
14
+ stdout = Command.command %(
15
+ {
16
+ echo "set role #{user};"
17
+ echo "set search_path to public;"
18
+ echo "#{sql}"
19
+ } | psql --csv --tuples-only --quiet -v ON_ERROR_STOP=1 -d #{db}
20
+ )
21
+ CSV.new(stdout.join("\n")).read
22
+ end
23
+
24
+ # Execute the given file and return stdout as an array of tuples
25
+ def self.exec_file(db, file, user: ENV['USER'])
26
+ self.exec_sql(db, File.read(file), user: user)
27
+ end
28
+
29
+ # Execute the SQL statement and return the result as an array of record tuples
30
+ # Just an alias for ::exec_sql
31
+ def self.select(db, sql, user: ENV['USER']) exec_sql(db, sql, user: user) end
32
+
33
+ # Execute the SQL statement and return an array of values, one for each
34
+ # single-valued record in the result. Raises an exception if the SQL
35
+ # statement doesn't return records with exactly one field
36
+ def self.select_values(db, sql, user: ENV['USER'])
37
+ records = exec_sql(db, sql, user: user)
38
+ return [] if records.empty?
39
+ records.first.size == 1 or raise Prick::Fail, "Expected records with one field"
40
+ records.flatten
41
+ end
42
+
43
+ # Execute the SQL statement and return a single record as an array of values.
44
+ # Raises an exception if the SQL statement doesn't return exactly one
45
+ # record
46
+ def self.select_record(db, sql, user: ENV['USER'])
47
+ records = exec_sql(db, sql, user: user)
48
+ records.size == 1 or raise Prick::Fail, "Expected one row only"
49
+ records.first
50
+ end
51
+
52
+ # Execute the SQL statement and return a value. The value is the first
53
+ # field of the first record in the result. Raises an exception if the SQL
54
+ # statement doesn't return exactly one record with one field
55
+ def self.select_value(db, sql, user: ENV['USER'])
56
+ row = select_record(db, sql, user: user)
57
+ row.size == 1 or raise Prick::Fail, "Expected one field only"
58
+ row.first
59
+ end
60
+
61
+ ### DETECT SCHEMAS, TABLES, AND RECORDS
62
+
63
+ def self.exist_schema?(db, schema)
64
+ exist_record?(db, %(
65
+ select 1
66
+ from information_schema.schemata
67
+ where catalog_name = '#{db}'
68
+ and schema_name = '#{schema}'
69
+ ))
70
+ end
71
+
72
+ def self.exist_table?(db, schema, table)
73
+ exist_record?(db, %(
74
+ select 1
75
+ from information_schema.tables
76
+ where table_schema = '#{schema}'
77
+ and table_name = '#{table}'
78
+ ))
79
+ end
80
+
81
+ def self.exist_record?(db, sql)
82
+ !select(db, sql).empty?
83
+ end
84
+
85
+ ### MAINTAIN USERS AND DATABASES
86
+
87
+ def self.exist_user?(user)
88
+ exist_record?("template1", "select 1 from pg_roles where rolname = '#{user}'")
89
+ end
90
+
91
+ def self.create_user(user)
92
+ Command.command("createuser #{user}")
93
+ end
94
+
95
+ def self.drop_user(user, fail: true)
96
+ Command.command "dropuser #{user}", fail: fail
97
+ end
98
+
99
+ def self.exist_database?(db)
100
+ exist_record?("template1", "select 1 from pg_database where datname = '#{db}'")
101
+ end
102
+
103
+ # TODO: make `owner` an option
104
+ def self.create_database(db, owner: ENV['USER'], template: "template1")
105
+ owner_option = (owner ? "-O #{owner}" : "")
106
+ Command.command "createdb -T #{template} #{owner_option} #{db}"
107
+ end
108
+
109
+ def self.copy_database(from, to, owner: ENV['USER'])
110
+ create_database(to, owner: owner, template: from)
111
+ end
112
+
113
+ def self.drop_database(db, fail: true)
114
+ Command.command "dropdb #{db}", fail: fail
115
+ end
116
+
117
+ def self.list_databases(re = /.*/)
118
+ select_values("template1", "select datname from pg_database").select { |db| db =~ re }
119
+ end
120
+
121
+ def self.load(db, file, user: ENV['USER'])
122
+ Command.command %(
123
+ {
124
+ echo "set role #{user};"
125
+ gunzip --to-stdout #{file}
126
+ } | psql -v ON_ERROR_STOP=1 -d #{db}
127
+ )
128
+ end
129
+
130
+ def self.save(db, file, data: true)
131
+ data_opt = (data ? "" : "--schema-only")
132
+ Command.command "pg_dump --no-owner #{data_opt} #{db} | gzip --to-stdout >#{file}"
133
+ end
134
+
135
+ private
136
+ @ensure_states = {
137
+ exist_user: [:create_user, :drop_user],
138
+ exist_database: [
139
+ lambda { |this, db, user| this.ensure_state(:exist_user, user) },
140
+ lambda { |this, db, user| this.create_database(db, owner: user) },
141
+ :drop_database
142
+ ]
143
+ }
144
+ end
145
+ end
146
+
147
+