pgbundle 0.0.1

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,44 @@
1
+ module PgBundle
2
+ # The Dsl class defines the Domain Specific Language for the PgFile
3
+ # it's mainly user to parse a PgFile and return a Definition Object
4
+ class Dsl
5
+ def initialize
6
+ @definition = Definition.new
7
+ @extensions = []
8
+ end
9
+
10
+ def eval_pgfile(pgfile, contents=nil)
11
+ contents ||= File.read(pgfile.to_s)
12
+ instance_eval(contents)
13
+ @definition
14
+ rescue SyntaxError => e
15
+ syntax_msg = e.message.gsub("#{pgfile}:", 'on line ')
16
+ raise PgfileError, "Pgfile syntax error #{syntax_msg}"
17
+ rescue ScriptError, RegexpError, NameError, ArgumentError => e
18
+ e.backtrace[0] = "#{e.backtrace[0]}: #{e.message} (#{e.class})"
19
+ puts e.backtrace.join("\n ")
20
+ raise PgfileError, "There was an error in your Pgfile," \
21
+ " and pgbundle cannot continue. " \
22
+ + e.message
23
+ end
24
+
25
+ def database(*args)
26
+ opts = extract_options!(args)
27
+ @definition.database = Database.new(args.first, opts)
28
+ end
29
+
30
+ def pgx(*args)
31
+ opts = extract_options!(args)
32
+ ext = Extension.new(*args, opts)
33
+ @definition.extensions[ext.name] = ext
34
+ end
35
+
36
+ def extract_options!(arr)
37
+ if arr.last.is_a? Hash
38
+ arr.pop
39
+ else
40
+ {}
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,270 @@
1
+ require 'net/ssh'
2
+ module PgBundle
3
+ # The Extension class provides the api for defining an Extension
4
+ # it installation source, and dependencies
5
+ # example:
6
+ # define an extension named 'foo' at version '0.1.1', with source on github depending on hstore
7
+ # Extension.new('foo', '0.1.1', github: 'me/foo', requires: 'hstore')
8
+ # you can then check if the Extension is available on a given database
9
+ # extension.available?(database)
10
+ # or install it along with it's dependencies
11
+ # extension.install(database)
12
+ class Extension
13
+ attr_accessor :name, :version, :source, :resolving_dependencies
14
+ def initialize(*args)
15
+ opts = args.last.is_a?(Hash) ? args.pop : {}
16
+ @name, @version = args
17
+ self.dependencies = opts[:requires]
18
+ set_source(opts)
19
+ end
20
+
21
+ def dependencies
22
+ @dependencies
23
+ end
24
+
25
+ # set dependency hash with different options
26
+ # dependencies= {foo: Extension.new('foo'), bar: Extension.new('bar')}
27
+ # => {'foo' => Extension.new('foo'), 'bar' Extension.new('bar')}
28
+ # dependencies= 'foo'
29
+ # => {foo: Extension.new('foo')}
30
+ # dependencies= Extension.new('foo')
31
+ # => {foo: Extension.new('foo')}
32
+ # dependencies= ['foo', 'bar']
33
+ # => {'foo' => Extension.new('foo'), 'bar' Extension.new('bar')}
34
+ def dependencies=(obj = nil)
35
+ @dependencies = case obj
36
+ when nil
37
+ {}
38
+ when Hash
39
+ Hash[obj.map { |k, v| [k.to_s, v] }]
40
+ when String, Symbol
41
+ { obj.to_s => Extension.new(obj.to_s) }
42
+ when Extension
43
+ { obj.name => obj }
44
+ when Array
45
+ hashable = obj.map do |o|
46
+ case o
47
+ when String, Symbol
48
+ [o.to_s, Extension.new(obj.to_s)]
49
+ when Extension
50
+ [o.name, o]
51
+ end
52
+ end
53
+ Hash[hashable]
54
+ end
55
+ end
56
+
57
+ # returns true if extension is available for installation on a given database
58
+ def available?(database)
59
+ return false unless installed?(database)
60
+ return true if created?(database)
61
+
62
+ created_any_version?(database) ? updatable?(database) : creatable?(database)
63
+ rescue ExtensionCreateError
64
+ false
65
+ end
66
+
67
+ # returns true if extension is already created with the correct version in the given database
68
+ def created?(database)
69
+ if version
70
+ result = database.execute("SELECT * FROM pg_available_extension_versions WHERE name ='#{name}' AND version = '#{version}' AND installed").to_a
71
+ else
72
+ result = database.execute("SELECT * FROM pg_available_extension_versions WHERE name ='#{name}' AND installed").to_a
73
+ end
74
+
75
+ if result.empty?
76
+ false
77
+ else
78
+ true
79
+ end
80
+ end
81
+
82
+ # returns if the extension is already installed on the database system
83
+ # if it is also already created it returns the installed version
84
+ def installed?(database)
85
+ if version
86
+ result = database.execute("SELECT * FROM pg_available_extension_versions WHERE name ='#{name}' AND version = '#{version}'").to_a
87
+ else
88
+ result = database.execute("SELECT * FROM pg_available_extension_versions WHERE name ='#{name}'").to_a
89
+ end
90
+
91
+ if result.empty?
92
+ false
93
+ else
94
+ true
95
+ end
96
+ end
97
+
98
+ # checks that all dependencies are installed on a given database
99
+ def dependencies_installed?(database)
100
+ dependencies.all?{|_, d| d.installed?(database)}
101
+ end
102
+
103
+ # installs extension and all dependencies using make install
104
+ # returns true if Extension can successfully be created using CREATE EXTENSION
105
+ def install(database)
106
+ unless dependencies.empty?
107
+ install_dependencies(database)
108
+ end
109
+
110
+ make_install(database)
111
+ raise ExtensionNotFound.new(name, version) unless installed?(database)
112
+
113
+ add_missing_required_dependencies(database)
114
+
115
+ creatable?(database)
116
+ end
117
+
118
+ # completely removes extension be running make uninstall and DROP EXTENSION
119
+ def uninstall!(database)
120
+ drop_extension(database)
121
+ make_uninstall(database)
122
+ end
123
+
124
+ private
125
+
126
+ # create extension on a given database connection
127
+ def create!(con)
128
+ con.exec create_stmt
129
+ end
130
+
131
+ # create the dependency graph on the given connection
132
+ def create_dependencies(con)
133
+ @resolving_dependencies = true
134
+ dependencies.each do |_, d|
135
+ fail CircularDependencyError.new(name, d.name) if d.resolving_dependencies
136
+ d.create_dependencies(con)
137
+ d.create!(con)
138
+ end
139
+ @resolving_dependencies = false
140
+ end
141
+
142
+ # checks if Extension is already installed at any version thus need ALTER EXTENSION to install
143
+ def created_any_version?(database)
144
+ result = database.execute("SELECT * FROM pg_available_extension_versions WHERE name ='#{name}' AND installed").to_a
145
+ if result.empty?
146
+ false
147
+ else
148
+ true
149
+ end
150
+ end
151
+
152
+ # adds dependencies that are required but not defined yet
153
+ def add_missing_required_dependencies(database)
154
+ requires = requires(database)
155
+ requires.each do |name|
156
+ unless dependencies[name]
157
+ dependencies[name] = Extension.new(name)
158
+ end
159
+ end
160
+ end
161
+
162
+ # returns an array of required Extension specified in the extensions control file
163
+ def requires(database)
164
+ fail "Extension #{name} not (yet) installed" unless installed?(database)
165
+
166
+ stmt = if version
167
+ <<-SQL
168
+ SELECT unnest(requires) as name FROM
169
+ ( SELECT requires FROM pg_available_extension_versions where name='#{name}' AND version ='#{version}') t
170
+ SQL
171
+ else
172
+ <<-SQL
173
+ SELECT unnest(requires) as name FROM
174
+ (SELECT requires FROM
175
+ pg_available_extensions a
176
+ JOIN pg_available_extension_versions v ON v.name = a.name AND a.default_version = v.version
177
+ WHERE v.name = '#{name}')t
178
+ SQL
179
+ end
180
+
181
+ result = database.execute(stmt).to_a
182
+
183
+ requires = result.map{|r| r['name']}
184
+ end
185
+
186
+ # loads the source and runs make uninstall
187
+ # returns: self
188
+ def make_uninstall(database)
189
+ database.make_uninstall(source, name)
190
+ self
191
+ end
192
+
193
+ def drop_extension(database)
194
+ database.drop_extension(name)
195
+ end
196
+
197
+ # loads the source and runs make install
198
+ # returns: self
199
+ def make_install(database)
200
+ return self if installed?(database)
201
+
202
+ fail SourceNotFound, name if source.nil?
203
+
204
+ database.make_install(source, name)
205
+ self
206
+ end
207
+
208
+ def create_stmt
209
+ stmt = "CREATE EXTENSION #{name}"
210
+ stmt += " VERSION '#{version}'" unless version.nil? || version.empty?
211
+
212
+ stmt
213
+ end
214
+
215
+ def install_dependencies(database)
216
+ begin
217
+ dependencies.each do |_, d|
218
+ d.install(database)
219
+ end
220
+ rescue InstallError, ExtensionCreateError => e
221
+ raise DependencyNotFound.new(name, e.message)
222
+ end
223
+
224
+ true
225
+ end
226
+
227
+ def creatable?(database)
228
+ dependencies_installed?(database) && installed?(database)
229
+ end
230
+
231
+ # hard checks that the dependency can be created running CREATE command in a transaction
232
+ def creatable!(database)
233
+ database.transaction_rollback do |con|
234
+ begin
235
+ create_dependencies(con)
236
+ create!(con)
237
+ rescue PG::UndefinedFile => err
238
+ raise ExtensionNotFound.new(name, version)
239
+ rescue PG::UndefinedObject => err
240
+ raise MissingDependency.new(name, err.message)
241
+ end
242
+ end
243
+
244
+ true
245
+ end
246
+
247
+ # checks that the extension can be updated running ALTER EXTENSION command in a transaction
248
+ def updatable?(database)
249
+ result = true
250
+ database.execute 'BEGIN'
251
+ begin
252
+ database.execute "ALTER EXTENSION #{name} UPDATE TO '#{version}'"
253
+ rescue PG::UndefinedFile, PG::UndefinedObject => err
254
+ @error = err.message
255
+ result = false
256
+ end
257
+ database.execute 'ROLLBACK'
258
+
259
+ result
260
+ end
261
+
262
+ def set_source(opts)
263
+ if opts[:path]
264
+ @source = PathSource.new(opts[:path])
265
+ elsif opts[:github]
266
+ @source = GithubSource.new(opts[:github])
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,32 @@
1
+ require 'tmpdir'
2
+ module PgBundle
3
+ # The GithubSource class defines a Github Source
4
+ class GithubSource < BaseSource
5
+ def load(host, user, dest)
6
+ clone(dest)
7
+ if host == 'localhost'
8
+ copy_local(clone_dir, dest)
9
+ else
10
+ copy_to_remote(host, user, clone_dir, dest)
11
+ end
12
+ end
13
+
14
+ def branch_name
15
+ @branch || 'master'
16
+ end
17
+
18
+ private
19
+
20
+ def clone(dest)
21
+ # git clone user@git-server:project_name.git -b branch_name /some/folder
22
+ %x((git clone git@github.com:#{path}.git -b #{branch_name} --quiet --depth=1 #{clone_dir} && rm -rf #{clone_dir}/.git}) 2>&1)
23
+ unless $?.success?
24
+ fail GitCommandError
25
+ end
26
+ end
27
+
28
+ def clone_dir
29
+ @clone_dir ||= Dir.mktmpdir
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,15 @@
1
+ require 'tmpdir'
2
+ require 'net/scp'
3
+ module PgBundle
4
+ # The PathSource class defines a local Path Source
5
+ # eg. PathSource.new('/my/local/path')
6
+ class PathSource < BaseSource
7
+ def load(host, user, dest)
8
+ if host == 'localhost'
9
+ copy_local(path, dest)
10
+ else
11
+ copy_to_remote(host, user, path, dest)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module Pgbundle
2
+ VERSION = '0.0.1'
3
+ end
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'pgbundle/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "pgbundle"
8
+ spec.version = Pgbundle::VERSION
9
+ spec.authors = ["Manuel Kniep"]
10
+ spec.email = ["manuel@adjust.com"]
11
+ spec.summary = %q{bundling postgres extension}
12
+ spec.description = %q{bundler like postgres extension manager}
13
+ spec.homepage = "http://github.com/adjust/pgbundle"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency 'thor'
22
+ spec.add_dependency 'net-ssh'
23
+ spec.add_dependency 'net-scp'
24
+ #https://bitbucket.org/ged/ruby-pg/wiki/Home
25
+ spec.add_dependency 'pg', '> 0.17'
26
+ spec.add_development_dependency 'rspec', '~> 2.14.0'
27
+ spec.add_development_dependency "bundler", "~> 1.5"
28
+ spec.add_development_dependency "rake"
29
+ spec.add_development_dependency "pry"
30
+ end
@@ -0,0 +1,6 @@
1
+ database 'pgbundle_test', host: 'localhost'
2
+
3
+ pgx 'hstore'
4
+ pgx 'bar', path: './spec/sample_extensions/bar', requires: 'ltree'
5
+ pgx 'baz', '0.0.2', path: './spec/sample_extensions/baz', requires: 'foo'
6
+ pgx 'foo', '0.0.1', path: './spec/sample_extensions/foo'
@@ -0,0 +1,19 @@
1
+ require 'spec_helper'
2
+ describe PgBundle::Definition do
3
+ subject do
4
+ d = PgBundle::Definition.new
5
+ d.database = database
6
+ d.extensions['bar'] = PgBundle::Extension.new('bar', path: './spec/sample_extensions//bar', requires: 'ltree')
7
+ d.extensions['baz'] = PgBundle::Extension.new('baz', path: './spec/sample_extensions/baz', requires: 'foo')
8
+ d.extensions['foo'] = PgBundle::Extension.new('foo', '0.0.2', path: './spec/sample_extensions/foo')
9
+ d
10
+ end
11
+
12
+ it 'missing_extensions' do
13
+ subject.missing_extensions.map(&:name).should eq %w(bar baz foo)
14
+ end
15
+
16
+ it 'should install missing extension' do
17
+ subject.install.map(&:name).should eq %w(bar baz foo)
18
+ end
19
+ end
@@ -0,0 +1,8 @@
1
+ require 'spec_helper'
2
+
3
+ describe PgBundle::Dsl do
4
+
5
+ subject { PgBundle::Dsl.new.eval_pgfile(File.expand_path('../Pgfile', __FILE__)) }
6
+ its(:database) { should be_a PgBundle::Database }
7
+ its(:extensions) { should be_a Hash }
8
+ end
@@ -0,0 +1,76 @@
1
+ require 'spec_helper'
2
+ describe PgBundle::Extension do
3
+ describe 'Basiscs' do
4
+ subject { PgBundle::Extension.new('foo', '1.2.3', github: 'bar/foo', requires: 'baz') }
5
+
6
+ its(:name) { should eq 'foo' }
7
+ its(:version) { should eq '1.2.3' }
8
+ its('dependencies.first.last') { should be_a PgBundle::Extension }
9
+ its(:source) { should be_a PgBundle::GithubSource }
10
+ end
11
+
12
+ describe 'Installation' do
13
+
14
+ context 'version available' do
15
+ subject { PgBundle::Extension.new('foo', '0.0.2', path: './spec/sample_extensions/foo') }
16
+
17
+ it 'should not be available before install' do
18
+ subject.available?(database).should be false
19
+ end
20
+
21
+ it 'should be available after install' do
22
+ subject.install(database)
23
+ subject.available?(database).should be true
24
+ end
25
+
26
+ context 'wrong version installed' do
27
+ subject { PgBundle::Extension.new('foo', '0.0.2', path: './spec/sample_extensions/foo') }
28
+ let(:wrong_version) { PgBundle::Extension.new('foo', '0.0.1', path: './spec/sample_extensions/foo') }
29
+ before do
30
+ wrong_version.install(database)
31
+ database.connection.exec "CREATE EXTENSION foo VERSION '0.0.2'"
32
+ end
33
+
34
+ it 'should be already installed' do
35
+ subject.should be_created_any_version(database)
36
+ end
37
+
38
+ it 'should be available' do
39
+ subject.should be_available(database)
40
+ end
41
+ end
42
+
43
+ context 'with require' do
44
+ let(:dependend) { PgBundle::Extension.new('bar', path: './spec/sample_extensions/bar', requires: 'ltree') }
45
+ subject { PgBundle::Extension.new('foo', '0.0.2', path: './spec/sample_extensions/foo', requires: dependend) }
46
+
47
+ it 'requires should be installable' do
48
+ dependend.should_not be_available(database)
49
+ subject.install(database).should be true
50
+ dependend.should be_available(database)
51
+ end
52
+
53
+ end
54
+ end
55
+
56
+ context 'version not available' do
57
+ subject { PgBundle::Extension.new('foo', '0.0.3', path: './spec/sample_extensions/foo') }
58
+
59
+ it 'should raise ExtensionNotFound' do
60
+ expect { subject.install(database) }.to raise_error PgBundle::ExtensionNotFound, 'specified Version 0.0.3 for Extension foo not available'
61
+ end
62
+
63
+ it 'should not be available although it is installed' do
64
+ subject.available?(database).should be false
65
+ end
66
+ end
67
+
68
+ context 'require not found' do
69
+ subject { PgBundle::Extension.new('foo', '0.0.2', path: './spec/sample_extensions/foo', requires: PgBundle::Extension.new('noope')) }
70
+
71
+ it 'should raise DependencyNotFound' do
72
+ expect { subject.install(database) }.to raise_error PgBundle::DependencyNotFound, "Can't install Dependency for Extension foo: Source for Extension noope not found"
73
+ end
74
+ end
75
+ end
76
+ end