Skip to content
This repository
Browse code

Added new Migrations framework for describing schema transformations …

…in a way that can be easily applied across multiple databases #1604 [Tobias Luetke]

git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@1672 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
  • Loading branch information...
commit 4160b518a82bcaa84e0e3125b4947b2dc3837fa3 1 parent 452442d
David Heinemeier Hansson authored July 04, 2005
2  activerecord/CHANGELOG
... ...
@@ -1,5 +1,7 @@
1 1
 *SVN*
2 2
 
  3
+* Added new Migrations framework for describing schema transformations in a way that can be easily applied across multiple databases #1604 [Tobias Luetke] See documentation under ActiveRecord::Migration and the additional support in the Rails rakefile/generator.
  4
+
3 5
 * Added callback hooks to association collections #1549 [Florian Weber]. Example:
4 6
 
5 7
     class Project
19  activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
@@ -357,9 +357,10 @@ def add_limit_offset!(sql, options)
357 357
         sql << " OFFSET #{options[:offset]}" if options.has_key?(:offset) and !options[:offset].nil?
358 358
       end
359 359
 
  360
+
360 361
       def initialize_schema_information
361 362
         begin
362  
-          execute "CREATE TABLE schema_info (version #{native_database_types[:integer][:name]}(#{native_database_types[:integer][:limit]}))"
  363
+          execute "CREATE TABLE schema_info (version #{type_to_sql(:integer)})"
363 364
           insert "INSERT INTO schema_info (version) VALUES(0)"
364 365
         rescue ActiveRecord::StatementInvalid
365 366
           # Schema has been intialized
@@ -378,8 +379,7 @@ def drop_table(name)
378 379
 
379 380
       def add_column(table_name, column_name, type, options = {})
380 381
         native_type = native_database_types[type]
381  
-        add_column_sql = "ALTER TABLE #{table_name} ADD #{column_name} #{native_type[:name]}"
382  
-        add_column_sql << "(#{options[:limit] || native_type[:limit]})" if options[:limit] || native_type[:limit]
  382
+        add_column_sql = "ALTER TABLE #{table_name} ADD #{column_name} #{type_to_sql(type)}"
383 383
         add_column_sql << " DEFAULT '#{options[:default]}'" if options[:default]
384 384
         execute(add_column_sql)
385 385
       end
@@ -387,9 +387,20 @@ def add_column(table_name, column_name, type, options = {})
387 387
       def remove_column(table_name, column_name)
388 388
         execute "ALTER TABLE #{table_name} DROP #{column_name}"
389 389
       end
  390
+      
  391
+      def supports_migrations?
  392
+        false
  393
+      end      
390 394
 
391 395
 
392 396
       protected
  397
+        def type_to_sql(type)
  398
+          native = native_database_types[type]
  399
+          column_type_sql = native[:name]
  400
+          column_type_sql << "(#{native[:limit]})" if native[:limit]
  401
+          column_type_sql
  402
+        end            
  403
+              
393 404
         def log(sql, name)
394 405
           begin
395 406
             if block_given?
@@ -439,7 +450,7 @@ def format_log_entry(message, dump = nil)
439 450
             "%s  %s" % [message, dump]
440 451
           end
441 452
         end
442  
-    end
  453
+      end
443 454
 
444 455
     class TableDefinition
445 456
       attr_accessor :columns
5  activerecord/lib/active_record/connection_adapters/mysql_adapter.rb
@@ -63,6 +63,10 @@ class MysqlAdapter < AbstractAdapter
63 63
         "Lost connection to MySQL server during query",
64 64
         "MySQL server has gone away"
65 65
       ]
  66
+      
  67
+      def supports_migrations?
  68
+        true
  69
+      end
66 70
 
67 71
       def native_database_types
68 72
         {
@@ -89,7 +93,6 @@ def adapter_name
89 93
         'MySQL'
90 94
       end
91 95
 
92  
-
93 96
       def select_all(sql, name = nil)
94 97
         select(sql, name)
95 98
       end
21  activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
@@ -60,6 +60,27 @@ module ConnectionAdapters
60 60
     # * <tt>:encoding</tt> -- An optional client encoding that is using in a SET client_encoding TO <encoding> call on connection.
61 61
     # * <tt>:min_messages</tt> -- An optional client min messages that is using in a SET client_min_messages TO <min_messages> call on connection.
62 62
     class PostgreSQLAdapter < AbstractAdapter
  63
+      
  64
+      def native_database_types
  65
+        {
  66
+          :primary_key => "serial primary key",
  67
+          :string      => { :name => "character varying", :limit => 255 },
  68
+          :text        => { :name => "text" },
  69
+          :integer     => { :name => "integer" },
  70
+          :float       => { :name => "float" },
  71
+          :datetime    => { :name => "timestamp" },
  72
+          :timestamp   => { :name => "timestamp" },
  73
+          :time        => { :name => "timestamp" },
  74
+          :date        => { :name => "date" },
  75
+          :binary      => { :name => "bytea" },
  76
+          :boolean     => { :name => "boolean"}
  77
+        }
  78
+      end
  79
+      
  80
+      def supports_migrations?
  81
+        true
  82
+      end      
  83
+      
63 84
       def select_all(sql, name = nil)
64 85
         select(sql, name)
65 86
       end
102  activerecord/lib/active_record/migration.rb
@@ -2,7 +2,102 @@ module ActiveRecord
2 2
   class IrreversibleMigration < ActiveRecordError#:nodoc:
3 3
   end
4 4
   
5  
-  class Migration #:nodoc:
  5
+  # Migrations can manage the evolution of a schema used by several physical databases. It's a solution
  6
+  # to the common problem of adding a field to make a new feature work in your local database, but being unsure of how to
  7
+  # push that change to other developers and to the production server. With migrations, you can describe the transformations
  8
+  # in self-contained classes that can be checked into version control systems and executed against another database that
  9
+  # might be one, two, or five versions behind.
  10
+  #
  11
+  # Example of a simple migration:
  12
+  #
  13
+  #   class AddSsl < ActiveRecord::Migration
  14
+  #     def self.up
  15
+  #       add_column :accounts, :ssl_enabled, :boolean, :default => 1
  16
+  #     end
  17
+  #   
  18
+  #     def self.down
  19
+  #       remove_column :accounts, :ssl_enabled
  20
+  #     end
  21
+  #   end
  22
+  #
  23
+  # This migration will add a boolean flag to the accounts table and remove it again, if you're backing out of the migration.
  24
+  # It shows how all migrations have two class methods +up+ and +down+ that describes the transformations required to implement
  25
+  # or remove the migration. These methods can consist of both the migration specific methods, like add_column and remove_column, 
  26
+  # but may also contain regular Ruby code for generating data needed for the transformations.
  27
+  #
  28
+  # Example of a more complex migration that also needs to initialize data:
  29
+  #
  30
+  #   class AddSystemSettings < ActiveRecord::Migration
  31
+  #     def self.up
  32
+  #       create_table :system_settings do |t|
  33
+  #         t.column :name,     :string
  34
+  #         t.column :label,    :string
  35
+  #         t.column :value,    :text
  36
+  #         t.column :type,     :string
  37
+  #         t.column :position, :integer
  38
+  #       end
  39
+  #   
  40
+  #       SystemSetting.create :name => "notice", :label => "Use notice?", :value => 1
  41
+  #     end
  42
+  #   
  43
+  #     def self.down
  44
+  #       drop_table :system_settings
  45
+  #     end
  46
+  #   end
  47
+  #
  48
+  # This migration first adds the system_settings table, then creates the very first row in it using the Active Record model
  49
+  # that relies on the table. It also uses the more advanced create_table syntax where you can specify a complete table schema
  50
+  # in one block call.
  51
+  #
  52
+  # == Available transformations
  53
+  #
  54
+  # * <tt>create_table(name, options = "")</tt> Creates a table called +name+ and makes the table object available to a block
  55
+  #   that can then add columns to it, following the same format as add_column. See example above. The options string is for
  56
+  #   fragments like "DEFAULT CHARSET=UTF-8" that are appended to the create table definition.
  57
+  # * <tt>drop_table(name)</tt>: Drops the table called +name+.
  58
+  # * <tt>add_column(table_name, column_name, type, options = {})</tt>: Adds a new column to the table called +table_name+
  59
+  #   named +column_name+ specified to be one of the following types:
  60
+  #   :string, :text, :integer, :float, :datetime, :timestamp, :time, :date, :binary, :boolean. A default value can be specified
  61
+  #   by passing an +options+ hash like { :default => 11 }.
  62
+  # * <tt>remove_column(table_name, column_name)</tt>: Removes the column named +column_name+ from the table called +table_name+.
  63
+  #
  64
+  # == Irreversible transformations
  65
+  #
  66
+  # Some transformations are destructive in a manner that cannot be reversed. Migrations of that kind should raise
  67
+  # an <tt>IrreversibleMigration</tt> exception in their +down+ method.
  68
+  #
  69
+  # == Database support
  70
+  #
  71
+  # Migrations are currently only supported in MySQL and PostgreSQL.
  72
+  #
  73
+  # == More examples
  74
+  #
  75
+  # Not all migrations change the schema. Some just fix the data:
  76
+  #
  77
+  #   class RemoveEmptyTags < ActiveRecord::Migration
  78
+  #     def self.up
  79
+  #       Tag.find(:all).each { |tag| tag.destroy if tag.pages.empty? }
  80
+  #     end
  81
+  #   
  82
+  #     def self.down
  83
+  #       # not much we can do to restore deleted data
  84
+  #     end
  85
+  #   end
  86
+  #
  87
+  # Others remove columns when they migrate up instead of down:
  88
+  #
  89
+  #   class RemoveUnnecessaryItemAttributes < ActiveRecord::Migration
  90
+  #     def self.up
  91
+  #       remove_column :items, :incomplete_items_count
  92
+  #       remove_column :items, :completed_items_count
  93
+  #     end
  94
+  #
  95
+  #     def self.down
  96
+  #       add_column :items, :incomplete_items_count
  97
+  #       add_column :items, :completed_items_count
  98
+  #     end
  99
+  #   end
  100
+  class Migration
6 101
     class << self
7 102
       def up() end
8 103
       def down() end
@@ -17,11 +112,11 @@ def method_missing(method, *arguments, &block)
17 112
   class Migrator#:nodoc:
18 113
     class << self
19 114
       def up(migrations_path, target_version = nil)
20  
-        new(:up, migrations_path, target_version).migrate
  115
+        self.new(:up, migrations_path, target_version).migrate
21 116
       end
22 117
       
23 118
       def down(migrations_path, target_version = nil)
24  
-        new(:down, migrations_path, target_version).migrate
  119
+        self.new(:down, migrations_path, target_version).migrate
25 120
       end
26 121
       
27 122
       def current_version
@@ -30,6 +125,7 @@ def current_version
30 125
     end
31 126
     
32 127
     def initialize(direction, migrations_path, target_version = nil)
  128
+      raise StandardError.new("This database does not yet support migrations") unless Base.connection.supports_migrations?
33 129
       @direction, @migrations_path, @target_version = direction, migrations_path, target_version
34 130
       Base.connection.initialize_schema_information
35 131
     end
104  activerecord/test/migration_mysql.rb
... ...
@@ -1,104 +0,0 @@
1  
-require 'abstract_unit'
2  
-require 'fixtures/person'
3  
-require File.dirname(__FILE__) + '/fixtures/migrations/1_people_have_last_names'
4  
-require File.dirname(__FILE__) + '/fixtures/migrations/2_we_need_reminders'
5  
-
6  
-class Reminder < ActiveRecord::Base; end
7  
-
8  
-class MigrationTest < Test::Unit::TestCase
9  
-  def setup
10  
-  end
11  
-
12  
-  def teardown
13  
-    ActiveRecord::Base.connection.initialize_schema_information
14  
-    ActiveRecord::Base.connection.update "UPDATE schema_info SET version = 0"
15  
-
16  
-    Reminder.connection.drop_table("reminders") rescue nil
17  
-    Reminder.reset_column_information
18  
-
19  
-    Person.connection.remove_column("people", "last_name") rescue nil
20  
-    Person.reset_column_information
21  
-  end
22  
-
23  
-  def test_add_remove_single_field
24  
-    assert !Person.column_methods_hash.include?(:last_name)
25  
-
26  
-    PeopleHaveLastNames.up
27  
-
28  
-    Person.reset_column_information
29  
-    assert Person.column_methods_hash.include?(:last_name)
30  
-    
31  
-    PeopleHaveLastNames.down
32  
-
33  
-    Person.reset_column_information
34  
-    assert !Person.column_methods_hash.include?(:last_name)
35  
-  end
36  
-
37  
-  def test_add_table
38  
-    assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
39  
-    
40  
-    WeNeedReminders.up
41  
-    
42  
-    assert Reminder.create("content" => "hello world", "remind_at" => Time.now)
43  
-    assert "hello world", Reminder.find(:first)
44  
-    
45  
-    WeNeedReminders.down
46  
-    assert_raises(ActiveRecord::StatementInvalid) { Reminder.find(:first) }
47  
-  end
48  
-
49  
-  def test_migrator
50  
-    assert !Person.column_methods_hash.include?(:last_name)
51  
-    assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
52  
-
53  
-    ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/')
54  
-
55  
-    assert_equal 2, ActiveRecord::Migrator.current_version
56  
-    Person.reset_column_information
57  
-    assert Person.column_methods_hash.include?(:last_name)
58  
-    assert Reminder.create("content" => "hello world", "remind_at" => Time.now)
59  
-    assert "hello world", Reminder.find(:first)
60  
-
61  
-
62  
-    ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/')
63  
-
64  
-    assert_equal 0, ActiveRecord::Migrator.current_version
65  
-    Person.reset_column_information
66  
-    assert !Person.column_methods_hash.include?(:last_name)
67  
-    assert_raises(ActiveRecord::StatementInvalid) { Reminder.find(:first) }
68  
-  end
69  
-
70  
-  def test_migrator_one_up
71  
-    assert !Person.column_methods_hash.include?(:last_name)
72  
-    assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
73  
-
74  
-    ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 1)
75  
-
76  
-    Person.reset_column_information
77  
-    assert Person.column_methods_hash.include?(:last_name)
78  
-    assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
79  
-
80  
-
81  
-    ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 2)
82  
-
83  
-    assert Reminder.create("content" => "hello world", "remind_at" => Time.now)
84  
-    assert "hello world", Reminder.find(:first)
85  
-  end
86  
-  
87  
-  def test_migrator_one_down
88  
-    ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/')
89  
-    
90  
-    ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/', 1)
91  
-
92  
-    Person.reset_column_information
93  
-    assert Person.column_methods_hash.include?(:last_name)
94  
-    assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
95  
-  end
96  
-  
97  
-  def test_migrator_one_up_one_down
98  
-    ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 1)
99  
-    ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/', 0)
100  
-
101  
-    assert !Person.column_methods_hash.include?(:last_name)
102  
-    assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
103  
-  end
104  
-end
139  activerecord/test/migration_test.rb
... ...
@@ -0,0 +1,139 @@
  1
+require 'abstract_unit'
  2
+require 'fixtures/person'
  3
+require File.dirname(__FILE__) + '/fixtures/migrations/1_people_have_last_names'
  4
+require File.dirname(__FILE__) + '/fixtures/migrations/2_we_need_reminders'
  5
+
  6
+if ActiveRecord::Base.connection.supports_migrations? 
  7
+  class Reminder < ActiveRecord::Base; end
  8
+
  9
+  class MigrationTest < Test::Unit::TestCase
  10
+    def setup
  11
+    end
  12
+
  13
+    def teardown
  14
+      ActiveRecord::Base.connection.initialize_schema_information
  15
+      ActiveRecord::Base.connection.update "UPDATE schema_info SET version = 0"
  16
+
  17
+      Reminder.connection.drop_table("reminders") rescue nil
  18
+      Reminder.reset_column_information
  19
+
  20
+      Person.connection.remove_column("people", "last_name") rescue nil
  21
+      Person.connection.remove_column("people", "bio") rescue nil
  22
+      Person.connection.remove_column("people", "age") rescue nil
  23
+      Person.connection.remove_column("people", "height") rescue nil
  24
+      Person.connection.remove_column("people", "birthday") rescue nil
  25
+      Person.connection.remove_column("people", "favorite_day") rescue nil
  26
+      Person.connection.remove_column("people", "male") rescue nil
  27
+      Person.reset_column_information
  28
+    end
  29
+  
  30
+    def test_native_types
  31
+    
  32
+      Person.delete_all
  33
+      Person.connection.add_column "people", "last_name", :string
  34
+      Person.connection.add_column "people", "bio", :text
  35
+      Person.connection.add_column "people", "age", :integer
  36
+      Person.connection.add_column "people", "height", :float
  37
+      Person.connection.add_column "people", "birthday", :datetime
  38
+      Person.connection.add_column "people", "favorite_day", :date
  39
+      Person.connection.add_column "people", "male", :boolean
  40
+      assert_nothing_raised { Person.create :first_name => 'bob', :last_name => 'bobsen', :bio => "I was born ....", :age => 18, :height => 1.78, :birthday => 18.years.ago, :favorite_day => 10.days.ago, :male => true }
  41
+      bob = Person.find(:first)
  42
+        
  43
+      assert_equal bob.first_name, 'bob'
  44
+      assert_equal bob.last_name, 'bobsen'
  45
+      assert_equal bob.bio, "I was born ...."
  46
+      assert_equal bob.age, 18
  47
+      assert_equal bob.male?, true
  48
+    
  49
+      assert_equal String, bob.first_name.class
  50
+      assert_equal String, bob.last_name.class
  51
+      assert_equal String, bob.bio.class
  52
+      assert_equal Fixnum, bob.age.class
  53
+      assert_equal Time, bob.birthday.class
  54
+      assert_equal Date, bob.favorite_day.class
  55
+      assert_equal TrueClass, bob.male?.class
  56
+    end
  57
+
  58
+    def test_add_remove_single_field
  59
+      assert !Person.column_methods_hash.include?(:last_name)
  60
+
  61
+      PeopleHaveLastNames.up
  62
+
  63
+      Person.reset_column_information
  64
+      assert Person.column_methods_hash.include?(:last_name)
  65
+
  66
+      PeopleHaveLastNames.down
  67
+
  68
+      Person.reset_column_information
  69
+      assert !Person.column_methods_hash.include?(:last_name)
  70
+    end
  71
+
  72
+    def test_add_table
  73
+      assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
  74
+
  75
+      WeNeedReminders.up
  76
+
  77
+      assert Reminder.create("content" => "hello world", "remind_at" => Time.now)
  78
+      assert_equal "hello world", Reminder.find(:first).content
  79
+
  80
+      WeNeedReminders.down
  81
+      assert_raises(ActiveRecord::StatementInvalid) { Reminder.find(:first) }
  82
+    end
  83
+
  84
+    def test_migrator
  85
+      assert !Person.column_methods_hash.include?(:last_name)
  86
+      assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
  87
+
  88
+      ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/')
  89
+
  90
+      assert_equal 2, ActiveRecord::Migrator.current_version
  91
+      Person.reset_column_information
  92
+      assert Person.column_methods_hash.include?(:last_name)
  93
+      assert Reminder.create("content" => "hello world", "remind_at" => Time.now)
  94
+      assert_equal "hello world", Reminder.find(:first).content
  95
+
  96
+      ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/')
  97
+
  98
+      assert_equal 0, ActiveRecord::Migrator.current_version
  99
+      Person.reset_column_information
  100
+      assert !Person.column_methods_hash.include?(:last_name)
  101
+      assert_raises(ActiveRecord::StatementInvalid) { Reminder.find(:first) }
  102
+    end
  103
+
  104
+    def test_migrator_one_up
  105
+      assert !Person.column_methods_hash.include?(:last_name)
  106
+      assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
  107
+
  108
+      ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 1)
  109
+
  110
+      Person.reset_column_information
  111
+      assert Person.column_methods_hash.include?(:last_name)
  112
+      assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
  113
+
  114
+
  115
+      ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 2)
  116
+
  117
+      assert Reminder.create("content" => "hello world", "remind_at" => Time.now)
  118
+      assert_equal "hello world", Reminder.find(:first).content
  119
+    end
  120
+
  121
+    def test_migrator_one_down
  122
+      ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/')
  123
+
  124
+      ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/', 1)
  125
+
  126
+      Person.reset_column_information
  127
+      assert Person.column_methods_hash.include?(:last_name)
  128
+      assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
  129
+    end
  130
+
  131
+    def test_migrator_one_up_one_down
  132
+      ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 1)
  133
+      ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/', 0)
  134
+
  135
+      assert !Person.column_methods_hash.include?(:last_name)
  136
+      assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash }
  137
+    end
  138
+  end
  139
+end

0 notes on commit 4160b51

Please sign in to comment.
Something went wrong with that request. Please try again.