nando 1.0.6
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.
- checksums.yaml +7 -0
- data/.env +2 -0
- data/.gitignore +19 -0
- data/.rspec +3 -0
- data/.travis.yml +7 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +47 -0
- data/LICENSE +201 -0
- data/README.md +49 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/nando +83 -0
- data/lib/nando/baseline_templates/migration.rb +9 -0
- data/lib/nando/errors.rb +13 -0
- data/lib/nando/generator.rb +86 -0
- data/lib/nando/interface.rb +87 -0
- data/lib/nando/logger.rb +30 -0
- data/lib/nando/migration.rb +347 -0
- data/lib/nando/migrator.rb +369 -0
- data/lib/nando/parser.rb +68 -0
- data/lib/nando/parser_templates/migration.rb +13 -0
- data/lib/nando/schema_diff.rb +805 -0
- data/lib/nando/templates/migration.rb +9 -0
- data/lib/nando/templates/migration_without_transaction.rb +9 -0
- data/lib/nando/updater.rb +372 -0
- data/lib/nando/utils.rb +22 -0
- data/lib/nando/version.rb +3 -0
- data/lib/nando.rb +12 -0
- data/nando.gemspec +44 -0
- data/notes.txt +128 -0
- metadata +200 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
require 'pg'
|
|
2
|
+
require 'dotenv'
|
|
3
|
+
require 'awesome_print'
|
|
4
|
+
require 'singleton'
|
|
5
|
+
|
|
6
|
+
begin
|
|
7
|
+
require 'byebug'
|
|
8
|
+
rescue LoadError
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
Dotenv.load('.env')
|
|
12
|
+
|
|
13
|
+
class NandoMigrator
|
|
14
|
+
include Singleton
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@migration_table = ENV['MIGRATION_TABLE_NAME'] || 'schema_migrations'
|
|
18
|
+
@migration_field = ENV['MIGRATION_TABLE_FIELD'] || 'version'
|
|
19
|
+
@migration_dir = ENV['MIGRATION_DIR'] || 'db/migrate'
|
|
20
|
+
|
|
21
|
+
# accepts urls in the same format as dbmate => protocol://username:password@host:port/database_name
|
|
22
|
+
match = /([a-zA-Z]+)\:\/\/(\w+)\:(\w+)\@([\w\.]+)\:(\d+)\/(\w+)/.match(ENV['DATABASE_URL'])
|
|
23
|
+
|
|
24
|
+
raise Nando::GenericError.new('No .env file was found, or no valid DATABASE_URL variable was found in it') if match.nil?
|
|
25
|
+
|
|
26
|
+
@db_protocol = match[1]
|
|
27
|
+
@db_username = match[2]
|
|
28
|
+
@db_password = match[3]
|
|
29
|
+
@db_host = match[4]
|
|
30
|
+
@db_port = match[5]
|
|
31
|
+
@db_name = match[6]
|
|
32
|
+
|
|
33
|
+
@working_dir = ENV['WORKING_DIR'] || '.'
|
|
34
|
+
@schema_variable = ENV['SCHEMA_VARIABLE'] || '#{schema_name}'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_accessor :migration_table, :migration_field, :migration_dir, :working_dir, :schema_variable
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
# creates a new migration for the tool
|
|
42
|
+
def new_migration (options = {}, args = [])
|
|
43
|
+
migration_name = args[0].underscore
|
|
44
|
+
migration_type = options[:type] || Nando::Migration.name.demodulize # default type is migration with transaction
|
|
45
|
+
migration_timestamp = Time.now.strftime("%Y%m%d%H%M%S") # same format as ActiveRecord: year-month-day-hour-minute-second
|
|
46
|
+
|
|
47
|
+
final_migration_type = camelize_migration_type(migration_type)
|
|
48
|
+
|
|
49
|
+
migration_file_name = "#{migration_timestamp}_#{migration_name}"
|
|
50
|
+
migration_file_path = "#{@migration_dir}/#{migration_file_name}.rb"
|
|
51
|
+
|
|
52
|
+
MigrationGenerator::create_migration_file(migration_file_path, migration_name, final_migration_type)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# migrates all missing migrations
|
|
56
|
+
def migrate (options = {})
|
|
57
|
+
_debug 'Migrating!'
|
|
58
|
+
|
|
59
|
+
migration_files = get_migration_files(@migration_dir)
|
|
60
|
+
|
|
61
|
+
if migration_files.length == 0
|
|
62
|
+
raise Nando::GenericError.new("No migration files were found in '#{@migration_dir}'")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@db_connection = get_database_connection()
|
|
66
|
+
create_schema_migrations_table_if_not_exists()
|
|
67
|
+
applied_migrations = get_applied_migrations()
|
|
68
|
+
|
|
69
|
+
for filename in migration_files do
|
|
70
|
+
migration_version, migration_name = NandoUtils.get_migration_version_and_name_from_file_path(filename)
|
|
71
|
+
|
|
72
|
+
if applied_migrations[migration_version]
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
execute_migration_method(:up, filename, migration_name, migration_version)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# applies specific migration
|
|
82
|
+
def apply (options = {}, args = [])
|
|
83
|
+
_debug 'Applying!'
|
|
84
|
+
|
|
85
|
+
migration_version_to_apply = args[0].to_s
|
|
86
|
+
migration_files = get_migration_files(@migration_dir)
|
|
87
|
+
|
|
88
|
+
if migration_files.length == 0
|
|
89
|
+
raise Nando::GenericError.new("No migration files were found in '#{@migration_dir}'")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@db_connection = get_database_connection()
|
|
93
|
+
create_schema_migrations_table_if_not_exists()
|
|
94
|
+
applied_migrations = get_applied_migrations()
|
|
95
|
+
|
|
96
|
+
migration_has_run = applied_migrations.include?(migration_version_to_apply)
|
|
97
|
+
found_migration = false
|
|
98
|
+
|
|
99
|
+
for filename in migration_files do
|
|
100
|
+
migration_version, migration_name = NandoUtils.get_migration_version_and_name_from_file_path(filename)
|
|
101
|
+
|
|
102
|
+
if migration_version.to_s != migration_version_to_apply.to_s
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
found_migration = true
|
|
107
|
+
execute_migration_method(:up, filename, migration_name, migration_version, migration_has_run)
|
|
108
|
+
_debug 'There should only be 1 migration with each version, so we can break'
|
|
109
|
+
break
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if !found_migration
|
|
113
|
+
_error "No migration file with version '#{migration_version_to_apply}' was found!"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# rollbacks 1 migration (or more depending on argument)
|
|
118
|
+
def rollback (options = {})
|
|
119
|
+
_debug 'Rollback!'
|
|
120
|
+
|
|
121
|
+
rollback_count = 1 # TODO: temporary constant, add option in command interface
|
|
122
|
+
|
|
123
|
+
@db_connection = get_database_connection()
|
|
124
|
+
create_schema_migrations_table_if_not_exists()
|
|
125
|
+
migrations_to_revert = get_migrations_to_revert(rollback_count)
|
|
126
|
+
|
|
127
|
+
if migrations_to_revert.length == 0
|
|
128
|
+
raise Nando::GenericError.new("There are no migrations to revert")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
migration_files = get_migration_files_to_rollback(@migration_dir, migrations_to_revert)
|
|
132
|
+
if migration_files.length == 0
|
|
133
|
+
# TODO: this won't work as expected if we start accepting rollbacks of multiple files, since as long as 1 file is valid it will be rollbacked
|
|
134
|
+
raise Nando::GenericError.new("Could not find any valid files in '#{@migration_dir}' that match the migrations to revert #{migrations_to_revert}")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
for migration_index in 0...migration_files.length do
|
|
138
|
+
filename = migration_files[migration_index]
|
|
139
|
+
migration_version, migration_name = NandoUtils.get_migration_version_and_name_from_file_path(filename)
|
|
140
|
+
|
|
141
|
+
execute_migration_method(:down, filename, migration_name, migration_version)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# TODO: might add a migrate:down to distinguish from rollback, similarly to ActiveRecord
|
|
146
|
+
|
|
147
|
+
# parses migrations from dbmate to nando
|
|
148
|
+
def parse (options = {}, args = [])
|
|
149
|
+
_debug 'Parsing!'
|
|
150
|
+
|
|
151
|
+
NandoParser.parse_from_dbmate(args[0], args[1])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def baseline ()
|
|
155
|
+
_debug 'Creating Baseline!'
|
|
156
|
+
|
|
157
|
+
migration_name = "baseline".underscore
|
|
158
|
+
migration_timestamp = Time.now.strftime("%Y%m%d%H%M%S") # same format as ActiveRecord: year-month-day-hour-minute-second
|
|
159
|
+
|
|
160
|
+
migration_file_name = "#{migration_timestamp}_#{migration_name}"
|
|
161
|
+
migration_file_path = "#{@migration_dir}/#{migration_file_name}.rb"
|
|
162
|
+
|
|
163
|
+
MigrationGenerator::create_baseline_file(migration_file_path, migration_name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def update_migration (options = {}, args = [])
|
|
167
|
+
_debug 'Updating!'
|
|
168
|
+
functions_to_add = options[:functions_to_add]
|
|
169
|
+
|
|
170
|
+
MigrationUpdater.update_migration(args[0], @working_dir, functions_to_add)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def diff_schemas (options = {}, args = [])
|
|
174
|
+
_debug 'Schema Diff'
|
|
175
|
+
|
|
176
|
+
NandoSchemaDiff.diff_schemas(args[0], args[1])
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def get_migration_files (directory)
|
|
182
|
+
if !File.directory?(directory)
|
|
183
|
+
raise Nando::GenericError.new("No directory '#{directory}' was found")
|
|
184
|
+
end
|
|
185
|
+
files = Dir.children(directory)
|
|
186
|
+
|
|
187
|
+
migration_files = []
|
|
188
|
+
for filename in files do
|
|
189
|
+
if !/^(\d+)\_(.*)\.rb$/.match(filename)
|
|
190
|
+
_warn "#{filename} does not have a valid migration name. Skipping!"
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
migration_files.push(filename)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
migration_files.sort! # sort to ensure the migrations are executed chronologically
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def get_migration_files_to_rollback (directory, versions_to_rollback)
|
|
201
|
+
if !File.directory?(directory)
|
|
202
|
+
raise Nando::GenericError.new("No directory '#{directory}' was found")
|
|
203
|
+
end
|
|
204
|
+
files = Dir.children(directory)
|
|
205
|
+
|
|
206
|
+
migration_files = []
|
|
207
|
+
for filename in files do
|
|
208
|
+
match = /^(\d+)\_(.*)\.rb$/.match(filename)
|
|
209
|
+
if match.nil?
|
|
210
|
+
_warn "#{filename} does not have a valid migration name. Skipping!"
|
|
211
|
+
next
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
if versions_to_rollback.include?(match[1])
|
|
215
|
+
migration_files.push(filename)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
migration_files.sort.reverse # sort and reverse to ensure the migrations are executed chronologically (backwards)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def get_applied_migrations ()
|
|
223
|
+
# run the query
|
|
224
|
+
results = @db_connection.exec("SELECT * FROM #{@migration_table} ORDER BY #{@migration_field} asc")
|
|
225
|
+
|
|
226
|
+
applied_migrations = {}
|
|
227
|
+
# puts "---------------------------------"
|
|
228
|
+
# puts "Applied migrations:"
|
|
229
|
+
results.each{ |row|
|
|
230
|
+
# puts "#{row[@migration_field]}"
|
|
231
|
+
applied_migrations[row[@migration_field]] = true
|
|
232
|
+
}
|
|
233
|
+
# puts "---------------------------------"
|
|
234
|
+
return applied_migrations
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def get_migrations_to_revert (count)
|
|
238
|
+
# run the query
|
|
239
|
+
results = @db_connection.exec("SELECT * FROM #{@migration_table} ORDER BY #{@migration_field} desc LIMIT #{count}")
|
|
240
|
+
|
|
241
|
+
migrations_to_rollback = []
|
|
242
|
+
# puts "---------------------------------"
|
|
243
|
+
# puts "Rollbacked migrations:"
|
|
244
|
+
results.each{ |row|
|
|
245
|
+
# puts "#{row[@migration_field]}"
|
|
246
|
+
migrations_to_rollback.push(row[@migration_field])
|
|
247
|
+
}
|
|
248
|
+
# puts "---------------------------------"
|
|
249
|
+
return migrations_to_rollback
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def execute_migration_method (method, filename, migration_name, migration_version, skip_insert_version = false)
|
|
253
|
+
if method == :up
|
|
254
|
+
migrating = true
|
|
255
|
+
else
|
|
256
|
+
migrating = false
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
puts migrating ? "Applying: #{filename}" : "Reverting: #{filename}"
|
|
260
|
+
|
|
261
|
+
require "./#{@migration_dir}/#{filename}"
|
|
262
|
+
|
|
263
|
+
class_const = get_migration_class(migration_name)
|
|
264
|
+
|
|
265
|
+
migration_class = class_const.new(@db_connection, migration_version)
|
|
266
|
+
begin
|
|
267
|
+
migration_class.execute_migration(method)
|
|
268
|
+
rescue => exception
|
|
269
|
+
raise Nando::GenericError.new(exception)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
if !skip_insert_version
|
|
273
|
+
update_migration_table(migration_version, migrating)
|
|
274
|
+
else
|
|
275
|
+
puts "Migration '#{migration_version}' was already in '#{@migration_table}', applying but not re-inserting it into the table"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def update_migration_table (version, to_apply = true)
|
|
280
|
+
if to_apply
|
|
281
|
+
@db_connection.exec("INSERT INTO #{@migration_table} (#{@migration_field}) VALUES (#{version})")
|
|
282
|
+
else
|
|
283
|
+
@db_connection.exec("DELETE FROM #{@migration_table} WHERE #{@migration_field} = '#{version}'")
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def camelize_migration_type (migration_type)
|
|
288
|
+
camelize_migration_type = migration_type.camelize
|
|
289
|
+
if !['Migration', 'MigrationWithoutTransaction'].include?(camelize_migration_type)
|
|
290
|
+
raise Nando::GenericError.new("Invalid migration type '#{migration_type}'")
|
|
291
|
+
end
|
|
292
|
+
return camelize_migration_type
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def get_migration_class (filename)
|
|
296
|
+
name = filename.camelize
|
|
297
|
+
Object.const_defined?(name) ? Object.const_get(name) : Object.const_missing(name) # if the constant does not exist, raise error
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def create_schema_migrations_table_if_not_exists
|
|
301
|
+
results = @db_connection.exec("SELECT EXISTS (
|
|
302
|
+
SELECT FROM information_schema.tables
|
|
303
|
+
WHERE table_schema = 'public'
|
|
304
|
+
AND table_name = '#{@migration_table}')")
|
|
305
|
+
|
|
306
|
+
if results[0]["exists"] == 'f'
|
|
307
|
+
_warn "Table '#{@migration_table}' does not exist, creating one"
|
|
308
|
+
@db_connection.exec("CREATE TABLE public.#{@migration_table} (
|
|
309
|
+
#{@migration_field} VARCHAR(255) PRIMARY KEY,
|
|
310
|
+
executed_at timestamp DEFAULT NOW()
|
|
311
|
+
)")
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def get_database_connection
|
|
316
|
+
begin
|
|
317
|
+
conn = PG::Connection.open(:host => @db_host,
|
|
318
|
+
:port => @db_port,
|
|
319
|
+
:dbname => @db_name,
|
|
320
|
+
:user => @db_username,
|
|
321
|
+
:password => @db_password)
|
|
322
|
+
rescue => exception
|
|
323
|
+
raise Nando::GenericError.new(exception)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
return conn
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class String
|
|
333
|
+
# used to convert to snake case (Rails)
|
|
334
|
+
def underscore
|
|
335
|
+
self.gsub(/::/, '/')
|
|
336
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
|
|
337
|
+
.gsub(/([a-z\d])([A-Z])/,'\1_\2')
|
|
338
|
+
.tr("-", "_")
|
|
339
|
+
.downcase
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# used to convert to camel or Pascal case (Rails)
|
|
343
|
+
def camelize (uppercase_first_letter = true)
|
|
344
|
+
string = self
|
|
345
|
+
if uppercase_first_letter
|
|
346
|
+
string = string.sub(/^[a-z\d]*/) { |match| match.capitalize }
|
|
347
|
+
else
|
|
348
|
+
string = string.sub(/^(?:(?=\b|[A-Z_])|\w)/) { |match| match.downcase }
|
|
349
|
+
end
|
|
350
|
+
string.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{$1}#{$2.capitalize}" }.gsub("/", "::")
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# gets the class/module name with the previous class/module names
|
|
354
|
+
def demodulize
|
|
355
|
+
self.split('::').last
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# converts a string to boolean
|
|
359
|
+
def to_b
|
|
360
|
+
case self.downcase.strip
|
|
361
|
+
when 'true', 'yes', 'on', 't', '1', 'y', '=='
|
|
362
|
+
return true
|
|
363
|
+
when 'nil', 'null'
|
|
364
|
+
return nil
|
|
365
|
+
else
|
|
366
|
+
return false
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
data/lib/nando/parser.rb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
|
|
3
|
+
module NandoParser
|
|
4
|
+
|
|
5
|
+
def self.parse_from_dbmate (source_path, destination_path)
|
|
6
|
+
source_files = Dir.children(source_path)
|
|
7
|
+
source_files.sort! # this sort is not really necessary, but ensures the files are created in the same order of their name
|
|
8
|
+
|
|
9
|
+
puts "Found #{source_files.length} source files"
|
|
10
|
+
|
|
11
|
+
FileUtils.mkdir_p(destination_path)
|
|
12
|
+
clear_directory(destination_path)
|
|
13
|
+
|
|
14
|
+
for filename in source_files do
|
|
15
|
+
match = /^(\d+)\_([\w\_]+)\./.match(filename)
|
|
16
|
+
migration_version = match[1]
|
|
17
|
+
migration_name = match[2]
|
|
18
|
+
new_filename = "#{migration_version}_#{migration_name}.rb"
|
|
19
|
+
new_file = File.new(File.join(destination_path, new_filename), 'w')
|
|
20
|
+
|
|
21
|
+
source_file_lines = File.readlines(File.join(source_path, filename))
|
|
22
|
+
current_section = nil
|
|
23
|
+
up_method, down_method = '', ''
|
|
24
|
+
with_transaction = true
|
|
25
|
+
for line in source_file_lines do
|
|
26
|
+
next if /^--[\s|\\\/_]*$/.match(line) # if it's just a up/down comment, ignore
|
|
27
|
+
|
|
28
|
+
case current_section
|
|
29
|
+
when 'up'
|
|
30
|
+
if match = /--\smigrate:down(.*)/.match(line)
|
|
31
|
+
with_transaction = false if match[1].include?('transaction:false')
|
|
32
|
+
current_section = 'down'
|
|
33
|
+
else
|
|
34
|
+
up_method += " #{line}".rstrip + "\n"
|
|
35
|
+
end
|
|
36
|
+
when 'down'
|
|
37
|
+
down_method += " #{line}".rstrip + "\n"
|
|
38
|
+
else
|
|
39
|
+
if match = /--\smigrate:up(.*)/.match(line)
|
|
40
|
+
with_transaction = false if match[1].include?('transaction:false')
|
|
41
|
+
current_section = 'up'
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# binding
|
|
47
|
+
migration_class_name = migration_name.camelize
|
|
48
|
+
migration_type = with_transaction ? Nando::Migration.name.demodulize : Nando::MigrationWithoutTransaction.name.demodulize
|
|
49
|
+
migration_up_code = up_method
|
|
50
|
+
migration_down_code = down_method
|
|
51
|
+
# TODO: check if binding logic is correct, and if pathing changes when it's a gem
|
|
52
|
+
MigrationGenerator.render_to_file(File.join(File.dirname(File.expand_path(__FILE__)), 'parser_templates/migration.rb'), binding, new_file)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
dest_files = Dir.children(destination_path)
|
|
56
|
+
puts "Created #{dest_files.length} migrations in the destination folder"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.clear_directory (path)
|
|
60
|
+
Dir.foreach(path) do |f|
|
|
61
|
+
fn = File.join(path, f)
|
|
62
|
+
if f != '.' && f != '..'
|
|
63
|
+
File.delete(fn)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
end
|