Class: Migrant::MigrationGenerator

Inherits:
Object
  • Object
show all
Includes:
Term::ANSIColor
Defined in:
lib/migrant/migration_generator.rb

Instance Method Summary collapse

Instance Method Details

#runObject



6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/migrant/migration_generator.rb', line 6

def run
  # Ensure db/migrate path exists before starting
  FileUtils.mkdir_p(Rails.root.join('db', 'migrate'))
  @possible_irreversible_migrations = false

  migrator = (ActiveRecord::Migrator.public_methods.include?(:open))? 
              ActiveRecord::Migrator.open(migrations_path) : 
              ActiveRecord::Migrator.new(:up, migrations_path)
              
  @class_suffix = defined?(ActiveRecord::Migration::Compatibility)? "[#{Rails.version[/^\d+\.\d+/]}]" : ''

  unless migrator.pending_migrations.blank?
    log "Aborting as this database has not yet run all the existing migrations.\n\nMost likely you just need to run rake db:migrate instead of rake db:upgrade in this environment.", :error
    return false
  end

  # Get all tables and compare to the desired schema
  # The next line is an evil hack to recursively load all model files in app/models
  # This needs to be done because Rails normally lazy-loads these files, resulting a blank descendants list of AR::Base
  model_root = "#{Rails.root.to_s}/app/models/"

  Dir["#{model_root}**/*.rb"].each do |file|
    if (model_name = file.sub(model_root, '').match(/(.*)?\.rb$/))
      model_name[1].camelize.safe_constantize
    end
  end

  # Rails 3.2+ caches table (non) existence so this needs to be cleared before we start
  ActiveRecord::Base.connection.schema_cache.clear! if ActiveRecord::Base.connection.respond_to?(:schema_cache)

  ActiveRecord::Base.descendants.select { |model| model.structure_defined? && model.schema.requires_migration? }.each do |model|
    model.reset_column_information # db:migrate doesn't do this
    @table_name = model.table_name
    @columns = Hash[[:changed, :added, :deleted, :renamed, :transferred].collect { |a| [a,[]] }]

    if model.table_exists?
      # Structure ActiveRecord::Base's column information so we can compare it directly to the schema
      db_schema = Hash[*model.columns.collect {|c| [c.name.to_sym, Hash[*[:type, :limit, :default].map { |type| [type, c.send(type)] }.flatten]  ] }.flatten]
      model.schema.columns.to_a.sort { |a,b| a.to_s <=> b.to_s }.each do |field_name, data_type|
        if data_type.dangerous_migration_from?(db_schema[field_name]) &&
           ask_user("#{model}: '#{field_name}': Converting from ActiveRecord type #{db_schema[field_name][:type]} to #{data_type.column[:type]} could cause data loss. Continue?", %W{Yes No}, true) == "No"
          log "Aborting dangerous action on #{field_name}."
        elsif (options = data_type.structure_changes_from(db_schema[field_name]))
          if db_schema[field_name]
            change_column(field_name, options, db_schema[field_name])
          else
            add_column(field_name, options)
          end
        end
      end

      # Removed rows
      unless model.schema.partial?
        db_schema.reject { |field_name, options| field_name.to_s == model.primary_key || model.schema.columns.keys.include?(field_name) }.each do |removed_field_name, options|
          case ask_user("#{model}: '#{removed_field_name}' is no longer in use.", (@columns[:added].blank?)? %W{Destroy Ignore} : %W{Destroy Move Ignore})
            when 'Destroy' then delete_column(removed_field_name, db_schema[removed_field_name])
            when 'Move' then
              target = ask_user("Move '#{removed_field_name}' to:", @columns[:added].collect(&:first))
              target_column = model.schema.columns[target]

              unless target_column.dangerous_migration_from?(db_schema[removed_field_name])
                target_column.structure_changes_from(db_schema[removed_field_name])
                move_column(removed_field_name, target, db_schema[removed_field_name], target_column)
              else
                case ask_user("Unable to safely move '#{removed_field_name}' to '#{target}'. Keep the original column for now?", %W{Yes No}, true)
                  when 'No' then delete_column(removed_field_name, db_schema[removed_field_name])
                end
              end
          end
        end
      end
      destroyed_columns = @columns[:deleted].reject { |field, options| @columns[:transferred].collect(&:first).include?(field) }
      unless destroyed_columns.blank?
        if ask_user("#{model}: '#{destroyed_columns.collect(&:first).join(', ')}' and associated data will be DESTROYED in all environments. Continue?", %W{Yes No}, true) == 'No'
          log "Okay, not removing anything for now."
          @columns[:deleted] = []
        end
      end

      # For adapters that can report indexes, add as necessary
      if ActiveRecord::Base.connection.respond_to?(:indexes)
        current_indexes = ActiveRecord::Base.connection.indexes(model.table_name).collect { |index| (index.columns.length == 1)? index.columns.first.to_sym : index.columns.collect(&:to_sym) }
        @indexes = model.schema.indexes.uniq.reject { |index| current_indexes.include?(index) }.collect do |field_name|
          description = (field_name.respond_to?(:join))? field_name.join('_') : field_name.to_s

          [field_name, description]
        end

        # Don't spam the user with indexes that columns are being created with
        @new_indexes = @indexes.reject { |index, options| @columns[:changed].detect { |c| c.first == index } || @columns[:added].detect { |c| c.first == index } }
      end

      next if @columns[:changed].empty? && @columns[:added].empty? && @columns[:renamed].empty? && @columns[:transferred].empty? && @columns[:deleted].empty? && @indexes.empty? # Nothing to do for this table

      # Example: changed_table_added_something_and_modified_something
      @activity = 'changed_'+model.table_name+[['added', @columns[:added]], ['modified', @columns[:changed]], ['deleted', destroyed_columns],
      ['moved', @columns[:transferred]], ['renamed', @columns[:renamed]], ['indexed', @new_indexes]].reject { |v| v[1].empty? }.collect { |v| "_#{v[0]}_"+v[1].collect(&:last).join('_') }.join('_and')
      @activity = @activity.split('_')[0..2].join('_')+'_with_multiple_changes' if @activity.length >= 240 # Most filesystems will raise Errno::ENAMETOOLONG otherwise

      render('change_migration')
    else
      @activity = "create_#{model.table_name}"
      @columns = model.schema.column_migrations
      @indexes = model.schema.indexes.uniq

      render("create_migration")
    end

    filename = "#{migrations_path}/#{next_migration_number}_#{@activity}.rb"
    File.open(filename, 'w') { |migration| migration.write(@output) }
    log "Wrote #{filename}..."
  end

  if @possible_irreversible_migrations
    log "*** One or more move operations were performed, which potentially could cause data loss on db:rollback. \n*** Please review your migrations before committing!", :warning
  end

  true
end