module Enginery class Migrator include Helpers TIME_FORMAT = '%Y-%m-%d_%H-%M-%S'.freeze NAME_REGEXP = /\A(\d+)\.(\d+\-\d+\-\d+_\d+\-\d+\-\d+)\.(.*)#{Regexp.escape MIGRATION_SUFFIX}\Z/.freeze def initialize dst_root, setups = {} @dst_root, @setups = dst_root, setups @migrations = Dir[dst_path(:migrations, '**/*%s' % MIGRATION_SUFFIX)].inject([]) do |map,f| step, time, name = File.basename(f).scan(NAME_REGEXP).flatten step && time && name && map << [step.to_i, time, name, f.sub(dst_path.migrations, '')] map end.sort {|a,b| a.first <=> b.first}.freeze end # generate new migration. # it will create a [n].[timestamp].[name].rb migration file in base/migrations/ # and column_transitions.yml file in base/migrations/track/ # migration file will contain "up" and "down" sections. # column_transitions file will keep track of column type changes. # def new name (name.nil? || name.empty?) && fail("Please provide migration name via second argument") (name =~ /[^\w|\d|\-|\.|\:]/) && fail("Migration name can contain only alphanumerics, dashes, semicolons and dots") @migrations.any? {|m| m[2] == name} && fail('"%s" migration already exists' % name) max = (@migrations.max {|m| m.first}||[0]).first model = @setups[:create_table] || @setups[:update_table] context = {model: model, name: name, step: max + 1} [:create_table, :update_table].each do |o| context[o] = (m = constant_defined?(@setups[o])) ? model_to_table(m) : nil end table = context[:create_table] || context[:update_table] || fail('No model provided or provided one does not exists!') [:create_columns, :update_columns].each do |o| context[o] = transitions(table, (@setups[o]||[]).map {|(n,t)| [n, opted_column_type(t)]}) end context[:rename_columns] = @setups[:rename_columns]||[] engine = Tenjin::Engine.new(path: [src_path.migrations], cache: false) source_code = engine.render('%s.erb' % guess_orm, context.merge(context: context)) o o '--- %s model - generating "%s" migration ---' % [model, name] o o ' Serial Number: %s' % context[:step] o time = Time.now.strftime(TIME_FORMAT) path = dst_path(:migrations, class_to_route(model)) FileUtils.mkdir_p(path) file = File.join(path, [context[:step], time, name, 'rb']*'.') write_file file, source_code output_source_code source_code.split("\n") name end # convert given range or a single migration into files to be run # ex: 1-5 will run migrations from one to 5 inclusive # 1 2 4 will run 1st, 2nd, and 4th migrations # 2 will run only 2nd migration def serials_to_files vector, *serials vector = validate_vector(vector) serials.map do |serial| if serial =~ /\-/ a, z = serial.split('-') (a..z).to_a else serial end end.flatten.map do |e| @migrations.find {|m| m.first == e.to_i} || fail('Wrong range provided. "%s" is not a recognized migration step' % e) end.sort do |a,b| vector == :up ? a.first <=> b.first : b.first <=> a.first end.map(&:last) end # - validate migration file name # - apply migration in given direction if migration was not previously performed # in given direction or :force option given # - create a track in TRACKING_TABLE # so on consequent requests we may know whether migration was already performed def run vector, file, force_run = nil vector = validate_vector(vector) (migration = @migrations.find {|m| m.last == file}) || fail('"%s" is not a valid migration file' % file) create_tracking_table_if_needed track = track_exists?(file, vector) if track && !force_run o o '*** Skipping "%s: %s" migration ***' % [migration[0], migration[2]] o ' It was already performed %s on %s' % [track.vector.upcase, track.performed_at] o ' Use :force option to run it anyway - enginery m:%s:force ...' % vector o return end apply!(migration, vector) && persist_track(file, vector) end # list available migrations with date of last run, if any def list create_tracking_table_if_needed o indent('--'), '-=---' @migrations.each do |(step,time,name,file)| track = track_exists?(file) last_perform = track ? '%s on %s' % [track.vector, track.performed_at] : 'none' o indent(step), ' : ', name o indent('created at'), ' : ', DateTime.strptime(time, TIME_FORMAT).rfc2822 o indent('last performed'), ' : ', last_perform o indent('--'), '-=---' end end def outstanding_migrations vector create_tracking_table_if_needed serials = @migrations.inject([]) do |l,(step,time,name,file)| track_exists?(File.basename(file), vector) ? l : l.push(step) end serials_to_files(vector, *serials) end def last_run file create_tracking_table_if_needed return unless track = track_exists?(file) [track.vector, track.performed_at] end def update_model_file context, vector model = context[:model] file = dst_path(:models, class_to_route(model) + MODEL_SUFFIX) return unless File.file?(file) lines, properties = File.readlines(file), [] lines.each_with_index do |l,i| property = l.scan(/(\s+)?property\s+[\W]?(\w+)\W+(\w+)(.*)/).flatten properties << (property << i) if property[1] && property[2] end return if properties.empty? new_lines = case vector.to_s.downcase.to_sym when :up add_properties(lines, properties, context) when :down remove_properties(lines, properties, context) end return unless new_lines File.open(file, 'w') {|f| f << new_lines.join} end def guess_orm orm = (@setups[:orm] || Cfg[:orm] || fail('No project-wide ORM detected. Please update config/config.yml by adding orm: [DataMapper|ActiveRecord|Sequel]')).to_s.strip (ORM_MATCHERS.find {|o,m| orm =~ m} || fail('"%s" ORM not supported')).first end private # load migration file and call corresponding methods that will run migration up/down def apply! migration, vector, orm = guess_orm o o '*** Performing %s step #%s ***' % [vector, migration.first] o ' Label: %s' % migration[2] o ' ORM: %s' % orm begin load dst_path(:migrations, migration.last) case orm when :DataMapper update_model_file(MigratorContext, vector) mj, mn, pt = DataMapper::VERSION.scan(/\d+/).map(&:to_i) if MigratorContext[:rename_columns].any? && [1,2,0] == [mj,mn,pt] o ' status: Skipped as renaming columns is broken on DataMapper 1.2.0' return false end MigratorInstance.instance_exec do # when using perform_up/down DataMapper will create a tracking table # and decide whether migration should be run, based on needs_up? and needs_down? # Enginery keeps own tracks and does not need DataMapper's tracking table # nor decisions on running migrations, # so using instance_exec to apply migrations directly. if action = instance_variable_get('@%s_action' % vector) action.call end end when :ActiveRecord MigratorInstance.new.send vector when :Sequel model = constant_defined?(MigratorContext[:model]) MigratorInstance.apply model.db, vector end o ' status: OK' true rescue => e fail e.message, *e.backtrace end end def add_properties lines, properties, context property_setup, new_properties = nil, [] context[:create_columns].each do |(n,t)| next if properties.find {|p| p[1].to_s == n.to_s} property_setup = [properties.last.first, n, t.to_s.split('::').last] new_properties << '%sproperty :%s, %s' % property_setup end if new_properties.any? lines[properties.last.last] += (new_properties.join("\n") + "\n") end context[:rename_columns].each do |(cn,nn)| next unless property = properties.find {|p| p[1].to_s == cn.to_s} property_setup = [property[0], nn, *property[2..3]] lines[property.last] = "%sproperty :%s, %s%s\n" % property_setup end context[:update_columns].each do |(n,t)| next unless property = properties.find {|p| p[1].to_s == n.to_s} property_setup = [*property[0..1], t.to_s.split('::').last, property[3]] lines[property.last] = "%sproperty :%s, %s%s\n" % property_setup end property_setup ? lines : nil end def remove_properties lines, properties, context property = nil context[:create_columns].each do |(n)| next unless property = properties.find {|p| p[1].to_s == n.to_s} lines[property.last] = nil end context[:rename_columns].each do |(cn,nn)| next unless property = properties.find {|p| p[1].to_s == nn.to_s} property[1] = cn lines[property.last] = "%sproperty :%s, %s%s\n" % property end context[:update_columns].each do |(n,nt,ot)| next unless property = properties.find {|p| p[1].to_s == n.to_s} property[2] = ot.to_s.split('::').last lines[property.last] = "%sproperty :%s, %s%s\n" % property end property ? lines : nil end def create_tracking_table_if_needed require src_path(:migrations, 'tracking_table/%s.rb' % guess_orm) case guess_orm when :DataMapper TracksMigrator.instance_exec { @up_action.call } when :ActiveRecord TracksMigrator.new.up when :Sequel TracksMigrator.apply Sequel::Model.db, :up end end def track_exists? migration, vector = nil conditions = {migration: migration} conditions[:vector] = vector.to_s if vector # #to_s required on Sequel case guess_orm when :ActiveRecord, :DataMapper TracksModel.first(conditions: conditions) when :Sequel TracksModel.first(conditions) end end def persist_track migration, vector key = {migration: migration} row = key.merge(performed_at: DateTime.now.rfc2822, vector: vector.to_s) case guess_orm when :DataMapper TracksModel.all(key).destroy! TracksModel.create(row) when :ActiveRecord TracksModel.delete_all(key) TracksModel.create(row) when :Sequel TracksModel.where(key).delete TracksModel.insert(row) end end # get the actual db table of a given model def model_to_table model case guess_orm when :DataMapper model.repository.adapter.resource_naming_convention.call(model) when :ActiveRecord, :Sequel model.table_name end end def default_column_type orm = guess_orm case orm when :ActiveRecord 'string' when :DataMapper, :Sequel 'String' end end # convert given string into column type suitable for migration file def opted_column_type type, orm = nil orm ||= guess_orm type ||= default_column_type(orm) case orm when :DataMapper 'DataMapper::Property::%s' % capitalize(type) when :Sequel type.to_s =~ /text/i ? "String, text: true" : capitalize(type) else type end end # someString.capitalize will return Somestring. # we need SomeString instead, which is returned by this method def capitalize smth smth.to_s.match(/(\w)(.*)/) {|m| m[1].upcase << m[2]} end def validate_vector vector invalid_vector!(vector) unless vector.is_a?(String) (vector =~ /\Au/i) && (vector = :up) (vector =~ /\Ad/i) && (vector = :down) invalid_vector!(vector) unless vector.is_a?(Symbol) vector end def invalid_vector! vector fail('%s is a unrecognized vector. Use either "up" or "down"' % vector.inspect) end def indent smth string = smth.to_s ident_size = 20 - string.size ident_size = 0 if ident_size < 0 INDENT + ' '*ident_size + string end def transitions table, columns transitions_file = dst_path(:migrations, 'transitions.yml') transitions = File.file?(transitions_file) ? (YAML.load(File.read(transitions_file)) rescue {}) : {} transitions[table] ||= {} columns.each do |column| column[2] = transitions[table][column.first] transitions[table][column.first] = column[1] end File.open(transitions_file, 'w') {|f| f << YAML.dump(transitions)} columns end end end