prick 0.2.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.
@@ -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
+