From 4160b518a82bcaa84e0e3125b4947b2dc3837fa3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 4 Jul 2005 18:51:02 +0000 Subject: 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 --- activerecord/CHANGELOG | 2 + .../connection_adapters/abstract_adapter.rb | 19 ++- .../connection_adapters/mysql_adapter.rb | 5 +- .../connection_adapters/postgresql_adapter.rb | 21 ++++ activerecord/lib/active_record/migration.rb | 102 ++++++++++++++- activerecord/test/migration_mysql.rb | 104 --------------- activerecord/test/migration_test.rb | 139 +++++++++++++++++++++ 7 files changed, 280 insertions(+), 112 deletions(-) delete mode 100644 activerecord/test/migration_mysql.rb create mode 100644 activerecord/test/migration_test.rb diff --git a/activerecord/CHANGELOG b/activerecord/CHANGELOG index 1b631b2bd9..86464919de 100644 --- a/activerecord/CHANGELOG +++ b/activerecord/CHANGELOG @@ -1,5 +1,7 @@ *SVN* +* 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. + * Added callback hooks to association collections #1549 [Florian Weber]. Example: class Project diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 1137ea1011..1b5c8184ae 100755 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -357,9 +357,10 @@ module ActiveRecord sql << " OFFSET #{options[:offset]}" if options.has_key?(:offset) and !options[:offset].nil? end + def initialize_schema_information begin - execute "CREATE TABLE schema_info (version #{native_database_types[:integer][:name]}(#{native_database_types[:integer][:limit]}))" + execute "CREATE TABLE schema_info (version #{type_to_sql(:integer)})" insert "INSERT INTO schema_info (version) VALUES(0)" rescue ActiveRecord::StatementInvalid # Schema has been intialized @@ -378,8 +379,7 @@ module ActiveRecord def add_column(table_name, column_name, type, options = {}) native_type = native_database_types[type] - add_column_sql = "ALTER TABLE #{table_name} ADD #{column_name} #{native_type[:name]}" - add_column_sql << "(#{options[:limit] || native_type[:limit]})" if options[:limit] || native_type[:limit] + add_column_sql = "ALTER TABLE #{table_name} ADD #{column_name} #{type_to_sql(type)}" add_column_sql << " DEFAULT '#{options[:default]}'" if options[:default] execute(add_column_sql) end @@ -387,9 +387,20 @@ module ActiveRecord def remove_column(table_name, column_name) execute "ALTER TABLE #{table_name} DROP #{column_name}" end + + def supports_migrations? + false + end protected + def type_to_sql(type) + native = native_database_types[type] + column_type_sql = native[:name] + column_type_sql << "(#{native[:limit]})" if native[:limit] + column_type_sql + end + def log(sql, name) begin if block_given? @@ -439,7 +450,7 @@ module ActiveRecord "%s %s" % [message, dump] end end - end + end class TableDefinition attr_accessor :columns diff --git a/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb index ad0e490194..ec0558a4d5 100755 --- a/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb @@ -63,6 +63,10 @@ module ActiveRecord "Lost connection to MySQL server during query", "MySQL server has gone away" ] + + def supports_migrations? + true + end def native_database_types { @@ -89,7 +93,6 @@ module ActiveRecord 'MySQL' end - def select_all(sql, name = nil) select(sql, name) end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 66cbe3a58b..3d6550ea9a 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -60,6 +60,27 @@ module ActiveRecord # * :encoding -- An optional client encoding that is using in a SET client_encoding TO call on connection. # * :min_messages -- An optional client min messages that is using in a SET client_min_messages TO call on connection. class PostgreSQLAdapter < AbstractAdapter + + def native_database_types + { + :primary_key => "serial primary key", + :string => { :name => "character varying", :limit => 255 }, + :text => { :name => "text" }, + :integer => { :name => "integer" }, + :float => { :name => "float" }, + :datetime => { :name => "timestamp" }, + :timestamp => { :name => "timestamp" }, + :time => { :name => "timestamp" }, + :date => { :name => "date" }, + :binary => { :name => "bytea" }, + :boolean => { :name => "boolean"} + } + end + + def supports_migrations? + true + end + def select_all(sql, name = nil) select(sql, name) end diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 7a1c47038d..cb54d2b967 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -2,7 +2,102 @@ module ActiveRecord class IrreversibleMigration < ActiveRecordError#:nodoc: end - class Migration #:nodoc: + # Migrations can manage the evolution of a schema used by several physical databases. It's a solution + # to the common problem of adding a field to make a new feature work in your local database, but being unsure of how to + # push that change to other developers and to the production server. With migrations, you can describe the transformations + # in self-contained classes that can be checked into version control systems and executed against another database that + # might be one, two, or five versions behind. + # + # Example of a simple migration: + # + # class AddSsl < ActiveRecord::Migration + # def self.up + # add_column :accounts, :ssl_enabled, :boolean, :default => 1 + # end + # + # def self.down + # remove_column :accounts, :ssl_enabled + # end + # end + # + # This migration will add a boolean flag to the accounts table and remove it again, if you're backing out of the migration. + # It shows how all migrations have two class methods +up+ and +down+ that describes the transformations required to implement + # or remove the migration. These methods can consist of both the migration specific methods, like add_column and remove_column, + # but may also contain regular Ruby code for generating data needed for the transformations. + # + # Example of a more complex migration that also needs to initialize data: + # + # class AddSystemSettings < ActiveRecord::Migration + # def self.up + # create_table :system_settings do |t| + # t.column :name, :string + # t.column :label, :string + # t.column :value, :text + # t.column :type, :string + # t.column :position, :integer + # end + # + # SystemSetting.create :name => "notice", :label => "Use notice?", :value => 1 + # end + # + # def self.down + # drop_table :system_settings + # end + # end + # + # This migration first adds the system_settings table, then creates the very first row in it using the Active Record model + # that relies on the table. It also uses the more advanced create_table syntax where you can specify a complete table schema + # in one block call. + # + # == Available transformations + # + # * create_table(name, options = "") Creates a table called +name+ and makes the table object available to a block + # that can then add columns to it, following the same format as add_column. See example above. The options string is for + # fragments like "DEFAULT CHARSET=UTF-8" that are appended to the create table definition. + # * drop_table(name): Drops the table called +name+. + # * add_column(table_name, column_name, type, options = {}): Adds a new column to the table called +table_name+ + # named +column_name+ specified to be one of the following types: + # :string, :text, :integer, :float, :datetime, :timestamp, :time, :date, :binary, :boolean. A default value can be specified + # by passing an +options+ hash like { :default => 11 }. + # * remove_column(table_name, column_name): Removes the column named +column_name+ from the table called +table_name+. + # + # == Irreversible transformations + # + # Some transformations are destructive in a manner that cannot be reversed. Migrations of that kind should raise + # an IrreversibleMigration exception in their +down+ method. + # + # == Database support + # + # Migrations are currently only supported in MySQL and PostgreSQL. + # + # == More examples + # + # Not all migrations change the schema. Some just fix the data: + # + # class RemoveEmptyTags < ActiveRecord::Migration + # def self.up + # Tag.find(:all).each { |tag| tag.destroy if tag.pages.empty? } + # end + # + # def self.down + # # not much we can do to restore deleted data + # end + # end + # + # Others remove columns when they migrate up instead of down: + # + # class RemoveUnnecessaryItemAttributes < ActiveRecord::Migration + # def self.up + # remove_column :items, :incomplete_items_count + # remove_column :items, :completed_items_count + # end + # + # def self.down + # add_column :items, :incomplete_items_count + # add_column :items, :completed_items_count + # end + # end + class Migration class << self def up() end def down() end @@ -17,11 +112,11 @@ module ActiveRecord class Migrator#:nodoc: class << self def up(migrations_path, target_version = nil) - new(:up, migrations_path, target_version).migrate + self.new(:up, migrations_path, target_version).migrate end def down(migrations_path, target_version = nil) - new(:down, migrations_path, target_version).migrate + self.new(:down, migrations_path, target_version).migrate end def current_version @@ -30,6 +125,7 @@ module ActiveRecord end def initialize(direction, migrations_path, target_version = nil) + raise StandardError.new("This database does not yet support migrations") unless Base.connection.supports_migrations? @direction, @migrations_path, @target_version = direction, migrations_path, target_version Base.connection.initialize_schema_information end diff --git a/activerecord/test/migration_mysql.rb b/activerecord/test/migration_mysql.rb deleted file mode 100644 index 6761d76ca1..0000000000 --- a/activerecord/test/migration_mysql.rb +++ /dev/null @@ -1,104 +0,0 @@ -require 'abstract_unit' -require 'fixtures/person' -require File.dirname(__FILE__) + '/fixtures/migrations/1_people_have_last_names' -require File.dirname(__FILE__) + '/fixtures/migrations/2_we_need_reminders' - -class Reminder < ActiveRecord::Base; end - -class MigrationTest < Test::Unit::TestCase - def setup - end - - def teardown - ActiveRecord::Base.connection.initialize_schema_information - ActiveRecord::Base.connection.update "UPDATE schema_info SET version = 0" - - Reminder.connection.drop_table("reminders") rescue nil - Reminder.reset_column_information - - Person.connection.remove_column("people", "last_name") rescue nil - Person.reset_column_information - end - - def test_add_remove_single_field - assert !Person.column_methods_hash.include?(:last_name) - - PeopleHaveLastNames.up - - Person.reset_column_information - assert Person.column_methods_hash.include?(:last_name) - - PeopleHaveLastNames.down - - Person.reset_column_information - assert !Person.column_methods_hash.include?(:last_name) - end - - def test_add_table - assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } - - WeNeedReminders.up - - assert Reminder.create("content" => "hello world", "remind_at" => Time.now) - assert "hello world", Reminder.find(:first) - - WeNeedReminders.down - assert_raises(ActiveRecord::StatementInvalid) { Reminder.find(:first) } - end - - def test_migrator - assert !Person.column_methods_hash.include?(:last_name) - assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } - - ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/') - - assert_equal 2, ActiveRecord::Migrator.current_version - Person.reset_column_information - assert Person.column_methods_hash.include?(:last_name) - assert Reminder.create("content" => "hello world", "remind_at" => Time.now) - assert "hello world", Reminder.find(:first) - - - ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/') - - assert_equal 0, ActiveRecord::Migrator.current_version - Person.reset_column_information - assert !Person.column_methods_hash.include?(:last_name) - assert_raises(ActiveRecord::StatementInvalid) { Reminder.find(:first) } - end - - def test_migrator_one_up - assert !Person.column_methods_hash.include?(:last_name) - assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } - - ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 1) - - Person.reset_column_information - assert Person.column_methods_hash.include?(:last_name) - assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } - - - ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 2) - - assert Reminder.create("content" => "hello world", "remind_at" => Time.now) - assert "hello world", Reminder.find(:first) - end - - def test_migrator_one_down - ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/') - - ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/', 1) - - Person.reset_column_information - assert Person.column_methods_hash.include?(:last_name) - assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } - end - - def test_migrator_one_up_one_down - ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 1) - ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/', 0) - - assert !Person.column_methods_hash.include?(:last_name) - assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } - end -end diff --git a/activerecord/test/migration_test.rb b/activerecord/test/migration_test.rb new file mode 100644 index 0000000000..72d0fbf817 --- /dev/null +++ b/activerecord/test/migration_test.rb @@ -0,0 +1,139 @@ +require 'abstract_unit' +require 'fixtures/person' +require File.dirname(__FILE__) + '/fixtures/migrations/1_people_have_last_names' +require File.dirname(__FILE__) + '/fixtures/migrations/2_we_need_reminders' + +if ActiveRecord::Base.connection.supports_migrations? + class Reminder < ActiveRecord::Base; end + + class MigrationTest < Test::Unit::TestCase + def setup + end + + def teardown + ActiveRecord::Base.connection.initialize_schema_information + ActiveRecord::Base.connection.update "UPDATE schema_info SET version = 0" + + Reminder.connection.drop_table("reminders") rescue nil + Reminder.reset_column_information + + Person.connection.remove_column("people", "last_name") rescue nil + Person.connection.remove_column("people", "bio") rescue nil + Person.connection.remove_column("people", "age") rescue nil + Person.connection.remove_column("people", "height") rescue nil + Person.connection.remove_column("people", "birthday") rescue nil + Person.connection.remove_column("people", "favorite_day") rescue nil + Person.connection.remove_column("people", "male") rescue nil + Person.reset_column_information + end + + def test_native_types + + Person.delete_all + Person.connection.add_column "people", "last_name", :string + Person.connection.add_column "people", "bio", :text + Person.connection.add_column "people", "age", :integer + Person.connection.add_column "people", "height", :float + Person.connection.add_column "people", "birthday", :datetime + Person.connection.add_column "people", "favorite_day", :date + Person.connection.add_column "people", "male", :boolean + 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 } + bob = Person.find(:first) + + assert_equal bob.first_name, 'bob' + assert_equal bob.last_name, 'bobsen' + assert_equal bob.bio, "I was born ...." + assert_equal bob.age, 18 + assert_equal bob.male?, true + + assert_equal String, bob.first_name.class + assert_equal String, bob.last_name.class + assert_equal String, bob.bio.class + assert_equal Fixnum, bob.age.class + assert_equal Time, bob.birthday.class + assert_equal Date, bob.favorite_day.class + assert_equal TrueClass, bob.male?.class + end + + def test_add_remove_single_field + assert !Person.column_methods_hash.include?(:last_name) + + PeopleHaveLastNames.up + + Person.reset_column_information + assert Person.column_methods_hash.include?(:last_name) + + PeopleHaveLastNames.down + + Person.reset_column_information + assert !Person.column_methods_hash.include?(:last_name) + end + + def test_add_table + assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } + + WeNeedReminders.up + + assert Reminder.create("content" => "hello world", "remind_at" => Time.now) + assert_equal "hello world", Reminder.find(:first).content + + WeNeedReminders.down + assert_raises(ActiveRecord::StatementInvalid) { Reminder.find(:first) } + end + + def test_migrator + assert !Person.column_methods_hash.include?(:last_name) + assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } + + ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/') + + assert_equal 2, ActiveRecord::Migrator.current_version + Person.reset_column_information + assert Person.column_methods_hash.include?(:last_name) + assert Reminder.create("content" => "hello world", "remind_at" => Time.now) + assert_equal "hello world", Reminder.find(:first).content + + ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/') + + assert_equal 0, ActiveRecord::Migrator.current_version + Person.reset_column_information + assert !Person.column_methods_hash.include?(:last_name) + assert_raises(ActiveRecord::StatementInvalid) { Reminder.find(:first) } + end + + def test_migrator_one_up + assert !Person.column_methods_hash.include?(:last_name) + assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } + + ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 1) + + Person.reset_column_information + assert Person.column_methods_hash.include?(:last_name) + assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } + + + ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 2) + + assert Reminder.create("content" => "hello world", "remind_at" => Time.now) + assert_equal "hello world", Reminder.find(:first).content + end + + def test_migrator_one_down + ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/') + + ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/', 1) + + Person.reset_column_information + assert Person.column_methods_hash.include?(:last_name) + assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } + end + + def test_migrator_one_up_one_down + ActiveRecord::Migrator.up(File.dirname(__FILE__) + '/fixtures/migrations/', 1) + ActiveRecord::Migrator.down(File.dirname(__FILE__) + '/fixtures/migrations/', 0) + + assert !Person.column_methods_hash.include?(:last_name) + assert_raises(ActiveRecord::StatementInvalid) { Reminder.column_methods_hash } + end + end +end \ No newline at end of file -- cgit v1.2.3