tdp 1.0.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 (4) hide show
  1. checksums.yaml +7 -0
  2. data/bin/tdp +41 -0
  3. data/lib/tdp.rb +442 -0
  4. metadata +102 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 87527ee02b194f28bbf5a6a8c642884969b9e9ca
4
+ data.tar.gz: e19033289f7d1665f3be87e891fba2c14d30f00c
5
+ SHA512:
6
+ metadata.gz: ef74cbb9f6cffba8d574af1c297c63ca34ce6e890fcacf69f95266992930615a5835f0ec88c4a7c80fb3a42710c73e88d76ffad916d34a29b1458873e6c55402
7
+ data.tar.gz: 26c3a905c281cdd957138b7b16090b6867b00227048caf44cf6b2b161f54f6bd99af8a58299467045c1bbeb603bb8373d878d4ec2f228fbc4e68750fddba5606
data/bin/tdp ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/tdp'
4
+
5
+ HELP_BANNER = %(
6
+ Usage: tdp <command> <database url> [<patches #1> [<patches #2> ...]]
7
+
8
+ where
9
+ <command> is one of:
10
+ * bootstrap
11
+ * upgrade
12
+ * retrofit
13
+ * validate_upgradable
14
+ * validate_compatible
15
+
16
+ <database url> is database url, e.g.
17
+ sqlite://test.db
18
+ postgres://user:password@host:port/database_name
19
+
20
+ <patches #N> is a name of .sql file or a directory with .sql files
21
+
22
+ ).lstrip.freeze
23
+
24
+ SUPPORTED_COMMANDS = %w(
25
+ bootstrap upgrade retrofit validate_upgradable validate_compatible
26
+ ).freeze
27
+
28
+ def help
29
+ puts HELP_BANNER
30
+ exit(1)
31
+ end
32
+
33
+ help if ARGV.length < 2
34
+ help unless SUPPORTED_COMMANDS.include? ARGV[0]
35
+
36
+ begin
37
+ TDP.execute(ARGV[1], ARGV[2..-1], &ARGV[0].to_sym)
38
+ rescue TDP::NotConfiguredError => e
39
+ puts e.message
40
+ exit(1)
41
+ end
@@ -0,0 +1,442 @@
1
+
2
+ require 'digest'
3
+ require 'sequel'
4
+
5
+ ##
6
+ # Tiny Database Patcher.
7
+ #
8
+ module TDP
9
+ ##
10
+ # Raised when there is a record that patch was applied to
11
+ # the database but the patch itself doesn't exist in the
12
+ # schema definition.
13
+ #
14
+ class NotConfiguredError < RuntimeError
15
+ ##
16
+ # * *patch_name* is the name of the patch
17
+ #
18
+ def initialize(patch_name)
19
+ super "No definition file for patch in database: #{patch_name}"
20
+ end
21
+ end
22
+
23
+ ##
24
+ # Raised when patch exists in the schema definition but
25
+ # wasn't applied to the database.
26
+ #
27
+ class NotAppliedError < RuntimeError
28
+ # Problematic patch (a Patch object)
29
+ attr_reader :patch
30
+
31
+ ##
32
+ # patch :: a problematic patch (a Patch object)
33
+ #
34
+ def initialize(patch)
35
+ super "Patch is not applied: #{patch.name}"
36
+ @patch = patch
37
+ end
38
+ end
39
+
40
+ ##
41
+ # Raised when signature of the patch in database doesn't match
42
+ # the signature of the patch in schema definition.
43
+ #
44
+ class MismatchError < RuntimeError
45
+ # Problematic patch (a Patch object)
46
+ attr_reader :patch
47
+
48
+ ##
49
+ # patch :: a problematic patch (a Patch object)
50
+ #
51
+ def initialize(patch)
52
+ super "Applied patch doesn't match definition: #{patch.name}"
53
+ @patch = patch
54
+ end
55
+ end
56
+
57
+ ##
58
+ # Raised when schema definition contains multiple patches
59
+ # with same name and different content.
60
+ #
61
+ class ContradictionError < RuntimeError
62
+ # Contradicting patches (Patch objects)
63
+ attr_reader :patches
64
+
65
+ ##
66
+ # patches :: an array of problematic patches (Patch objects)
67
+ def initialize(patches)
68
+ super('Patches with same name and different content: ' +
69
+ patches.map(&:full_filename).join(' / ')
70
+ )
71
+ @patches = patches.clone.freeze
72
+ end
73
+ end
74
+
75
+ ##
76
+ # A single patch.
77
+ #
78
+ class Patch
79
+ # Content of the patch.
80
+ attr_reader :content
81
+
82
+ # Full name of the patch file.
83
+ attr_reader :full_filename
84
+
85
+ # SHA-256 hash of content.
86
+ attr_reader :signature
87
+
88
+ # Name of the patch.
89
+ attr_reader :name
90
+
91
+ ##
92
+ # full_filename :: full path to +.sql+ file
93
+ #
94
+ def initialize(full_filename)
95
+ @full_filename = full_filename
96
+ _, @name = File.split(full_filename)
97
+ @content = File.read(full_filename)
98
+ @signature = Digest::SHA256.hexdigest(@content)
99
+ end
100
+
101
+ ##
102
+ # Returns true if patch is volatile.
103
+ #
104
+ def volatile?
105
+ TDP.volatile_patch_file?(@name)
106
+ end
107
+
108
+ ##
109
+ # Returns true if patch is permanent.
110
+ #
111
+ def permanent?
112
+ TDP.permanent_patch_file?(@name)
113
+ end
114
+
115
+ ##
116
+ # Comparison function. Any permanent patch takes precedence
117
+ # over any volatile one, if both patches are permanent or both
118
+ # are volatile, ordering is based on name.
119
+ #
120
+ def <=>(other)
121
+ return -1 if permanent? && other.volatile?
122
+ return 1 if volatile? && other.permanent?
123
+ @name <=> other.name
124
+ end
125
+ end
126
+
127
+ ##
128
+ # A set of patches.
129
+ #
130
+ class PatchSet
131
+ def initialize
132
+ @patches = {}
133
+ end
134
+
135
+ ##
136
+ # Adds a patch to the set. Raises ContradictionError in case
137
+ # if patch set already contains a patch with the same name and
138
+ # different content.
139
+ #
140
+ # patch :: Patch object to add
141
+ #
142
+ def <<(patch)
143
+ known_patch = @patches[patch.name]
144
+ if known_patch.nil?
145
+ @patches[patch.name] = patch
146
+ elsif patch.content != known_patch.content
147
+ raise ContradictionError, [known_patch, patch]
148
+ end
149
+ end
150
+
151
+ ##
152
+ # Calls the given block once for each patch in collection,
153
+ # passing that element as a parameter.
154
+ #
155
+ # Ordering of the patches is: first, all permanent patches
156
+ # alphanumerically sorted by name, then all volatile patches
157
+ # sorted in the same way.
158
+ #
159
+ def each
160
+ @patches.values.sort.each { |patch| yield patch }
161
+ end
162
+
163
+ ##
164
+ # Returns an array of patches for which given block returns
165
+ # a true value.
166
+ #
167
+ # Ordering of patches is same as in #self.each method.
168
+ #
169
+ def select
170
+ @patches.values.sort.select { |patch| yield patch }
171
+ end
172
+
173
+ ##
174
+ # Retrieves Patch by name. If there's no patch with this
175
+ # name, returns nil.
176
+ #
177
+ def [](name)
178
+ @patches[name]
179
+ end
180
+ end
181
+
182
+ ##
183
+ # Data access object that encapsulates all operations with
184
+ # the database.
185
+ class DAO
186
+ # Sequel::Database object
187
+ attr_reader :db
188
+
189
+ ##
190
+ # Creates a new DAO object.
191
+ #
192
+ # db :: must be either of:
193
+ # * instance of Sequel::Database class
194
+ # * database URL that can be passed to Sequel.connect()
195
+ #
196
+ def initialize(db)
197
+ case db
198
+ when Sequel::Database
199
+ @db = db
200
+ when String
201
+ @db = Sequel.connect(db)
202
+ else
203
+ raise ArgumentError, "Invalid argument #{db} of class #{db.class}"
204
+ end
205
+ end
206
+
207
+ ##
208
+ # Initializes database tables for keeping track of applied
209
+ # patches.
210
+ #
211
+ def bootstrap
212
+ return if @db.table_exists?(:tdp_patch)
213
+ @db << %{
214
+ CREATE TABLE tdp_patch(
215
+ name VARCHAR PRIMARY KEY
216
+ , signature VARCHAR NOT NULL
217
+ )
218
+ }
219
+ end
220
+
221
+ ##
222
+ # Fetches the information about applied patches and
223
+ # returns it as { name => signature } hash.
224
+ #
225
+ def applied_patches
226
+ result = {}
227
+ @db[:tdp_patch].select(:name, :signature).each do |row|
228
+ result[row[:name]] = row[:signature]
229
+ end
230
+ result
231
+ end
232
+
233
+ ##
234
+ # Looks up a signature of a patch by its name.
235
+ #
236
+ def patch_signature(name)
237
+ row = @db[:tdp_patch].select(:signature).where(name: name).first
238
+ row.nil? ? nil : row[:signature]
239
+ end
240
+
241
+ ##
242
+ # Applies a patch (a Patch object).
243
+ #
244
+ def apply(patch)
245
+ @db << patch.content
246
+ register(patch)
247
+ rescue Sequel::Error => ex
248
+ raise Sequel::Error,
249
+ "Failed to apply patch #{patch.full_filename}: #{ex}"
250
+ end
251
+
252
+ ##
253
+ # Registers a patch (a Patch object) as applied.
254
+ #
255
+ def register(patch)
256
+ q = @db[:tdp_patch].where(name: patch.name)
257
+ if q.empty?
258
+ @db[:tdp_patch].insert(
259
+ name: patch.name,
260
+ signature: patch.signature
261
+ )
262
+ else
263
+ q.update(signature: patch.signature)
264
+ end
265
+ end
266
+
267
+ ##
268
+ # Erases all data about applied patches.
269
+ #
270
+ def erase
271
+ @db[:tdp_patch].delete
272
+ end
273
+ end
274
+
275
+ ##
276
+ # Main class of the package.
277
+ #
278
+ class Engine
279
+ ##
280
+ # Creates a new Engine object.
281
+ #
282
+ # db :: must be one of:
283
+ # * instance of Sequel::Database class
284
+ # * database URL that can be passed to Sequel.connect()
285
+ #
286
+ def initialize(db)
287
+ @dao = DAO.new(db)
288
+ @patches = PatchSet.new
289
+ end
290
+
291
+ ##
292
+ # Registers patch files in the engine.
293
+ #
294
+ # filename :: may be either a name of .sql file or a name
295
+ # of directory (which would be recursively scanned for .sql
296
+ # files)
297
+ #
298
+ def <<(filename)
299
+ if File.directory?(filename)
300
+ Dir.foreach(filename) do |x|
301
+ self << File.join(filename, x) unless x.start_with?('.')
302
+ end
303
+ elsif TDP.patch_file?(filename)
304
+ @patches << Patch.new(filename)
305
+ end
306
+ end
307
+
308
+ ##
309
+ # Initializes database tables for keeping track of applied
310
+ # patches.
311
+ #
312
+ def bootstrap
313
+ @dao.bootstrap
314
+ end
315
+
316
+ ##
317
+ # Produces an ordered list of patches that need to be applied.
318
+ #
319
+ # May raise MismatchError in case if signatures of any permanent
320
+ # patches that are present in the definition don't match
321
+ # ones of the patches applied to the database.
322
+ #
323
+ def plan
324
+ @patches.select do |patch|
325
+ signature = @dao.patch_signature(patch.name)
326
+ next false if signature == patch.signature
327
+ next true if signature.nil? || patch.volatile?
328
+ raise MismatchError, patch
329
+ end
330
+ end
331
+
332
+ ##
333
+ # Applies all changes that need to be applied.
334
+ #
335
+ def upgrade
336
+ validate_upgradable
337
+ plan.each { |patch| @dao.apply(patch) }
338
+ end
339
+
340
+ ##
341
+ # Validates that it is safe run upgrade the database.
342
+ # In particular, the following conditions must be met:
343
+ # * there is an .sql file for every patch that is marked
344
+ # as applied to database
345
+ # * every permanent patch has same signature as the corresponding
346
+ # .sql file.
347
+ #
348
+ # May raise MismatchError or NotConfiguredError in case if those
349
+ # conditions aren't met.
350
+ #
351
+ def validate_upgradable
352
+ @dao.applied_patches.each_key do |name|
353
+ raise NotConfiguredError, name unless @patches[name]
354
+ end
355
+ plan
356
+ end
357
+
358
+ ##
359
+ # Validates that all patches are applied to the database.
360
+ #
361
+ # May raise MismatchError, NotConfiguredError or NotAppliedError
362
+ # in case if there are any problems.
363
+ #
364
+ def validate_compatible
365
+ validate_upgradable
366
+
367
+ @patches.each do |patch|
368
+ signature = @dao.patch_signature(patch.name)
369
+ next if signature == patch.signature
370
+ raise NotAppliedError, patch if signature.nil?
371
+ raise MismatchError, patch
372
+ end
373
+ end
374
+
375
+ ##
376
+ # Erases existing data about applied patches and replaces
377
+ # it with configured schema.
378
+ #
379
+ def retrofit
380
+ @dao.erase
381
+ @patches.each do |patch|
382
+ @dao.register(patch)
383
+ end
384
+ end
385
+ end
386
+
387
+ ##
388
+ # Returns true if argument is a valid file name of a permanent
389
+ # patch.
390
+ #
391
+ # To qualify for a permanent patch, file name must start with
392
+ # a number and end with ".sql" extension.
393
+ # E.g. _001-initial-schema.sql_ or _201611001_add_accounts_table.sql_
394
+ #
395
+ def self.permanent_patch_file?(filename)
396
+ /^\d+.*\.sql$/ =~ filename
397
+ end
398
+
399
+ ##
400
+ # Returns true if argument is a valid file name of a volatile
401
+ # patch.
402
+ #
403
+ # To qualify for a volatile patch, file name must end with ".sql"
404
+ # exception and *NOT* start with a number (otherwise it'd qualify
405
+ # for permanent patch instead). E.g. _views.sql_
406
+ # or _stored-procedures.sql_
407
+ #
408
+ def self.volatile_patch_file?(filename)
409
+ /^[^\d]+.*\.sql$/ =~ filename
410
+ end
411
+
412
+ ##
413
+ # Returns true if argument is a valid file name of a patch.
414
+ #
415
+ # To qualify for a patch, file name must end with ".sql"
416
+ # extension.
417
+ #
418
+ def self.patch_file?(filename)
419
+ filename.end_with?('.sql')
420
+ end
421
+
422
+ ##
423
+ # Main entrypoint of TDP package.
424
+ #
425
+ # Initializes an Engine with given database details and
426
+ # schema files locations and then calls the given block
427
+ # passing engine as a parameter.
428
+ #
429
+ # db :: must be one of:
430
+ # * instance of Sequel::Database class
431
+ # * database URL that can be passed to Sequel.connect()
432
+ #
433
+ # *paths* must be an array of names of .sql files and directories
434
+ # containing those files
435
+ #
436
+ def self.execute(db, paths = [])
437
+ engine = Engine.new(db)
438
+ paths.each { |x| engine << x }
439
+ engine.bootstrap
440
+ yield engine
441
+ end
442
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tdp
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Ivan Appel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-11-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sequel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.40'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.40'
27
+ - !ruby/object:Gem::Dependency
28
+ name: simplecov
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.12'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.12'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sqlite3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: test-unit
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.2'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.2'
69
+ description: Tool for pure-SQL database migrations
70
+ email: ivan.appel@gmail.com
71
+ executables:
72
+ - tdp
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - bin/tdp
77
+ - lib/tdp.rb
78
+ homepage: http://github.com/geekyfox/tdp
79
+ licenses:
80
+ - MIT
81
+ metadata: {}
82
+ post_install_message:
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubyforge_project:
98
+ rubygems_version: 2.4.8
99
+ signing_key:
100
+ specification_version: 4
101
+ summary: Tiny Database Patcher
102
+ test_files: []