Add metadata to schema_migrations table #8399

Merged
merged 3 commits into from Dec 5, 2012
View
18 activerecord/CHANGELOG.md
@@ -1,5 +1,23 @@
## Rails 4.0.0 (unreleased) ##
+* Add migration history to schema.rb dump.
+ Loading schema.rb with full migration history
+ restores the exact list of migrations that created
+ that schema (including names and fingerprints). This
+ avoids possible mistakes caused by assuming all
+ migrations with a lower version have been run when
+ loading schema.rb. Old schema.rb files without migration
+ history but with the :version setting still work as before.
+
+ *Josh Susser*
+
+* Add metadata columns to schema_migrations table.
+ New columns are: migrated_at (timestamp),
+ fingerprint (md5 hash of migration source), and
+ name (filename minus version and extension)
+
+ *Josh Susser*
+
* Add STI support to init and building associations.
Allows you to do BaseClass.new(:type => "SubClass") as well as
parent.children.build(:type => "SubClass") or parent.build_child
View
8 activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
@@ -490,8 +490,8 @@ def dump_schema_information #:nodoc:
sm_table = ActiveRecord::Migrator.schema_migrations_table_name
ActiveRecord::SchemaMigration.order('version').map { |sm|
- "INSERT INTO #{sm_table} (version) VALUES ('#{sm.version}');"
- }.join "\n\n"
+ "INSERT INTO #{sm_table} (version, migrated_at, fingerprint, name) VALUES ('#{sm.version}',LOCALTIMESTAMP,'#{sm.fingerprint}','#{sm.name}');"
+ }.join("\n\n")
end
# Should not be called normally, but this operation is non-destructive.
@@ -512,15 +512,15 @@ def assume_migrated_upto_version(version, migrations_paths = ActiveRecord::Migra
end
unless migrated.include?(version)
- execute "INSERT INTO #{sm_table} (version) VALUES ('#{version}')"
+ ActiveRecord::SchemaMigration.create!(:version => version, :migrated_at => Time.now)
end
inserted = Set.new
(versions - migrated).each do |v|
if inserted.include?(v)
raise "Duplicate migration #{v}. Please renumber your migrations to resolve the conflict."
elsif v < version
- execute "INSERT INTO #{sm_table} (version) VALUES ('#{v}')"
+ ActiveRecord::SchemaMigration.create!(:version => v, :migrated_at => Time.now)
inserted << v
end
end
View
24 activerecord/lib/active_record/migration.rb
@@ -1,5 +1,6 @@
require "active_support/core_ext/class/attribute_accessors"
require 'set'
+require 'digest/md5'
module ActiveRecord
# Exception that can be raised to stop migrations from going backwards.
@@ -554,6 +555,10 @@ def basename
delegate :migrate, :announce, :write, :to => :migration
+ def fingerprint
@rafaelfranca
rafaelfranca Dec 2, 2012

We will need to document this method.

@joshsusser
joshsusser Dec 2, 2012

No other methods in MigrationProxy are documented. I'd suggest making the class nodoc, as it's an internal implementation detail. Or, since the HTML docs include the source of the implementation, the 1-line implementation is pretty much self-documenting.

@rafaelfranca
rafaelfranca Dec 2, 2012
@p
p Dec 3, 2012

People read "internal" methods to understand how things work, and "there is other code that is equally bad" is not a very good reason to continue writing poor code.

+ @fingerprint ||= Digest::MD5.hexdigest(File.read(filename))
+ end
+
private
def migration
@@ -724,7 +729,7 @@ def run
raise UnknownMigrationVersionError.new(@target_version) if target.nil?
unless (up? && migrated.include?(target.version.to_i)) || (down? && !migrated.include?(target.version.to_i))
target.migrate(@direction)
- record_version_state_after_migrating(target.version)
+ record_version_state_after_migrating(target)
end
end
@@ -747,7 +752,7 @@ def migrate
begin
ddl_transaction do
migration.migrate(@direction)
- record_version_state_after_migrating(migration.version)
+ record_version_state_after_migrating(migration)
end
rescue => e
canceled_msg = Base.connection.supports_ddl_transactions? ? "this and " : ""
@@ -805,13 +810,18 @@ def validate(migrations)
raise DuplicateMigrationVersionError.new(version) if version
end
- def record_version_state_after_migrating(version)
+ def record_version_state_after_migrating(target)
if down?
- migrated.delete(version)
- ActiveRecord::SchemaMigration.where(:version => version.to_s).delete_all
+ migrated.delete(target.version)
+ ActiveRecord::SchemaMigration.where(:version => target.version.to_s).delete_all
else
- migrated << version
- ActiveRecord::SchemaMigration.create!(:version => version.to_s)
+ migrated << target.version
+ ActiveRecord::SchemaMigration.create!(
+ :version => target.version.to_s,
+ :migrated_at => Time.now,
+ :fingerprint => target.fingerprint,
+ :name => File.basename(target.filename,'.rb').gsub(/^\d+_/,'')
+ )
end
end
View
40 activerecord/lib/active_record/schema.rb
@@ -34,27 +34,45 @@ def migrations_paths
end
def define(info, &block)
+ @using_deprecated_version_setting = info[:version].present?
+ SchemaMigration.drop_table
+ initialize_schema_migrations_table
+
instance_eval(&block)
- unless info[:version].blank?
- initialize_schema_migrations_table
- assume_migrated_upto_version(info[:version], migrations_paths)
- end
+ # handle files from pre-4.0 that used :version option instead of dumping migration table
+ assume_migrated_upto_version(info[:version], migrations_paths) if @using_deprecated_version_setting
end
# Eval the given block. All methods available to the current connection
# adapter are available within the block, so you can easily use the
# database definition DSL to build up your schema (+create_table+,
# +add_index+, etc.).
- #
- # The +info+ hash is optional, and if given is used to define metadata
- # about the current schema (currently, only the schema's version):
- #
- # ActiveRecord::Schema.define(version: 20380119000001) do
- # ...
- # end
def self.define(info={}, &block)
new.define(info, &block)
end
+
+ # Create schema migration history. Include migration statements in a block to this method.
+ #
+ # migrations do
+ # migration 20121128235959, "44f1397e3b92442ca7488a029068a5ad", "add_horn_color_to_unicorns"
+ # migration 20121129235959, "4a1eb3965d94406b00002b370854eae8", "add_magic_power_to_unicorns"
+ # end
+ def migrations
+ raise(ArgumentError, "Can't set migrations while using :version option") if @using_deprecated_version_setting
+ yield
+ end
+
+ # Add a migration to the ActiveRecord::SchemaMigration table.
+ #
+ # The +version+ argument is an integer.
+ # The +fingerprint+ and +name+ arguments are required but may be empty strings.
+ # The migration's +migrated_at+ attribute is set to the current time,
+ # instead of being set explicitly as an argument to the method.
+ #
+ # migration 20121129235959, "4a1eb3965d94406b00002b370854eae8", "add_magic_power_to_unicorns"
+ def migration(version, fingerprint, name)
+ SchemaMigration.create!(version: version, migrated_at: Time.now, fingerprint: fingerprint, name: name)
+ end
end
end
View
17 activerecord/lib/active_record/schema_dumper.rb
@@ -24,6 +24,7 @@ def self.dump(connection=ActiveRecord::Base.connection, stream=STDOUT)
def dump(stream)
header(stream)
+ migrations(stream)
tables(stream)
trailer(stream)
stream
@@ -44,7 +45,7 @@ def header(stream)
stream.puts "# encoding: #{stream.external_encoding.name}"
end
- stream.puts <<HEADER
+ header_text = <<HEADER_RUBY
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
@@ -59,13 +60,25 @@ def header(stream)
ActiveRecord::Schema.define(#{define_params}) do
-HEADER
+HEADER_RUBY
+ stream.puts header_text
end
def trailer(stream)
stream.puts "end"
end
+ def migrations(stream)
+ all_migrations = ActiveRecord::SchemaMigration.all.to_a
+ if all_migrations.any?
+ stream.puts(" migrations do")
+ all_migrations.each do |migration|
+ stream.puts(migration.schema_line(" "))
+ end
+ stream.puts(" end")
+ end
+ end
+
def tables(stream)
@connection.tables.sort.each do |tbl|
next if ['schema_migrations', ignore_tables].flatten.any? do |ignored|
View
39 activerecord/lib/active_record/schema_migration.rb
@@ -14,23 +14,56 @@ def self.index_name
end
def self.create_table
- unless connection.table_exists?(table_name)
+ if connection.table_exists?(table_name)
@rafaelfranca
rafaelfranca Dec 2, 2012

Since this is for backward compatibility I would extract it to a method if a meaningful name.

+ cols = connection.columns(table_name).collect { |col| col.name }
+ unless cols.include?("migrated_at")
+ connection.add_column(table_name, "migrated_at", :datetime)
+ q_table_name = connection.quote_table_name(table_name)
+ q_timestamp = connection.quoted_date(Time.now)
+ connection.update("UPDATE #{q_table_name} SET migrated_at = '#{q_timestamp}' WHERE migrated_at IS NULL")
+ connection.change_column(table_name, "migrated_at", :datetime, :null => false)
+ end
+ unless cols.include?("fingerprint")
+ connection.add_column(table_name, "fingerprint", :string, :limit => 32)
+ end
+ unless cols.include?("name")
+ connection.add_column(table_name, "name", :string)
+ end
+ else
connection.create_table(table_name, :id => false) do |t|
t.column :version, :string, :null => false
+ t.column :migrated_at, :datetime, :null => false
+ t.column :fingerprint, :string, :limit => 32
+ t.column :name, :string
end
- connection.add_index table_name, :version, :unique => true, :name => index_name
+ connection.add_index(table_name, "version", :unique => true, :name => index_name)
end
+ reset_column_information
end
def self.drop_table
+ if connection.index_exists?(table_name, "version", :unique => true, :name => index_name)
+ connection.remove_index(table_name, :name => index_name)
+ end
if connection.table_exists?(table_name)
- connection.remove_index table_name, :name => index_name
connection.drop_table(table_name)
end
end
def version
super.to_i
end
+
+ # Construct ruby source to include in schema.rb dump for this migration.
+ # Pass a string of spaces as +indent+ to allow calling code to control how deeply indented the line is.
+ # The generated line includes the migration version, fingerprint, and name. Either fingerprint or name
+ # can be an empty string.
+ #
+ # Example output:
+ #
+ # migration 20121129235959, "ee4be703f9e6e2fc0f4baddebe6eb8f7", "add_magic_power_to_unicorns"
+ def schema_line(indent)
+ %Q(#{indent}migration %s, "%s", "%s") % [version, fingerprint, name]
+ end
end
end
View
51 activerecord/test/cases/ar_schema_test.rb
@@ -46,4 +46,55 @@ def test_schema_subclass
end
end
+ class ActiveRecordSchemaMigrationsTest < ActiveRecordSchemaTest
+ def setup
+ super
+ ActiveRecord::SchemaMigration.delete_all
+ end
+
+ def test_migration_adds_row_to_migrations_table
+ schema = ActiveRecord::Schema.new
+ schema.migration(1001, "", "")
+ schema.migration(1002, "123456789012345678901234567890ab", "add_magic_power_to_unicorns")
+
+ migrations = ActiveRecord::SchemaMigration.all.to_a
+ assert_equal 2, migrations.length
+
+ assert_equal 1001, migrations[0].version
+ assert_match %r{^2\d\d\d-}, migrations[0].migrated_at.to_s(:db)
+ assert_equal "", migrations[0].fingerprint
+ assert_equal "", migrations[0].name
+
+ assert_equal 1002, migrations[1].version
+ assert_match %r{^2\d\d\d-}, migrations[1].migrated_at.to_s(:db)
+ assert_equal "123456789012345678901234567890ab", migrations[1].fingerprint
+ assert_equal "add_magic_power_to_unicorns", migrations[1].name
+ end
+
+ def test_define_clears_schema_migrations
+ assert_nothing_raised do
+ ActiveRecord::Schema.define do
+ migrations do
+ migration(123001, "", "")
+ end
+ end
+ ActiveRecord::Schema.define do
+ migrations do
+ migration(123001, "", "")
+ end
+ end
+ end
+ end
+
+ def test_define_raises_if_both_version_and_explicit_migrations
+ assert_raise(ArgumentError) do
+ ActiveRecord::Schema.define(version: 123001) do
+ migrations do
+ migration(123001, "", "")
+ end
+ end
+ end
+ end
+ end
+
end
View
4 activerecord/test/cases/migration/logger_test.rb
@@ -6,10 +6,12 @@ class LoggerTest < ActiveRecord::TestCase
# mysql can't roll back ddl changes
self.use_transactional_fixtures = false
- Migration = Struct.new(:name, :version) do
+ Migration = Struct.new(:name, :version, :filename, :fingerprint) do
def migrate direction
# do nothing
end
+ def filename; "anon.rb"; end
+ def fingerprint; "123456789012345678901234567890ab"; end
end
def setup
View
24 activerecord/test/cases/migration/table_and_index_test.rb
@@ -1,24 +0,0 @@
-require "cases/helper"
-
-module ActiveRecord
- class Migration
- class TableAndIndexTest < ActiveRecord::TestCase
- def test_add_schema_info_respects_prefix_and_suffix
- conn = ActiveRecord::Base.connection
-
- conn.drop_table(ActiveRecord::Migrator.schema_migrations_table_name) if conn.table_exists?(ActiveRecord::Migrator.schema_migrations_table_name)
- # Use shorter prefix and suffix as in Oracle database identifier cannot be larger than 30 characters
- ActiveRecord::Base.table_name_prefix = 'p_'
- ActiveRecord::Base.table_name_suffix = '_s'
- conn.drop_table(ActiveRecord::Migrator.schema_migrations_table_name) if conn.table_exists?(ActiveRecord::Migrator.schema_migrations_table_name)
-
- conn.initialize_schema_migrations_table
-
- assert_equal "p_unique_schema_migrations_s", conn.indexes(ActiveRecord::Migrator.schema_migrations_table_name)[0][:name]
- ensure
- ActiveRecord::Base.table_name_prefix = ""
- ActiveRecord::Base.table_name_suffix = ""
- end
- end
- end
-end
View
11 activerecord/test/cases/migration_test.rb
@@ -59,12 +59,21 @@ def teardown
def test_migrator_versions
migrations_path = MIGRATIONS_ROOT + "/valid"
ActiveRecord::Migrator.migrations_paths = migrations_path
+ m0_path = File.join(migrations_path, "1_valid_people_have_last_names.rb")
+ m0_fingerprint = Digest::MD5.hexdigest(File.read(m0_path))
ActiveRecord::Migrator.up(migrations_path)
assert_equal 3, ActiveRecord::Migrator.current_version
assert_equal 3, ActiveRecord::Migrator.last_version
assert_equal false, ActiveRecord::Migrator.needs_migration?
+ rows = connection.select_all("SELECT * FROM #{connection.quote_table_name(ActiveRecord::Migrator.schema_migrations_table_name)}")
+ assert_equal m0_fingerprint, rows[0]["fingerprint"]
+ assert_equal "valid_people_have_last_names", rows[0]["name"]
+ rows.each do |row|
+ assert_match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/, row["migrated_at"], "missing migrated_at")
+ end
+
ActiveRecord::Migrator.down(MIGRATIONS_ROOT + "/valid")
assert_equal 0, ActiveRecord::Migrator.current_version
assert_equal 3, ActiveRecord::Migrator.last_version
@@ -337,7 +346,7 @@ def test_create_table_with_binary_column
assert_nothing_raised {
Person.connection.create_table :binary_testings do |t|
- t.column "data", :binary, :null => false
+ t.column :data, :binary, :null => false
end
}
View
9 activerecord/test/cases/migrator_test.rb
@@ -18,6 +18,9 @@ def initialize name = self.class.name, version = nil
def up; @went_up = true; end
def down; @went_down = true; end
+ # also used in place of a MigrationProxy
+ def filename; "anon.rb"; end
+ def fingerprint; "123456789012345678901234567890ab"; end
end
def setup
@@ -102,7 +105,7 @@ def test_relative_migrations
end
def test_finds_pending_migrations
- ActiveRecord::SchemaMigration.create!(:version => '1')
+ ActiveRecord::SchemaMigration.create!(:version => '1', :name => "anon", :migrated_at => Time.now)
migration_list = [ Migration.new('foo', 1), Migration.new('bar', 3) ]
migrations = ActiveRecord::Migrator.new(:up, migration_list).pending_migrations
@@ -152,7 +155,7 @@ def test_down_calls_down
end
def test_current_version
- ActiveRecord::SchemaMigration.create!(:version => '1000')
+ ActiveRecord::SchemaMigration.create!(:version => '1000', :name => "anon", :migrated_at => Time.now)
assert_equal 1000, ActiveRecord::Migrator.current_version
end
@@ -320,7 +323,7 @@ def test_migrator_forward
def test_only_loads_pending_migrations
# migrate up to 1
- ActiveRecord::SchemaMigration.create!(:version => '1')
+ ActiveRecord::SchemaMigration.create!(:version => '1', :name => "anon", :migrated_at => Time.now)
calls, migrator = migrator_class(3)
migrator.migrate("valid", nil)
View
17 activerecord/test/cases/schema_dumper_test.rb
@@ -1,6 +1,5 @@
require "cases/helper"
-
class SchemaDumperTest < ActiveRecord::TestCase
def setup
super
@@ -18,11 +17,15 @@ def standard_dump
def test_dump_schema_information_outputs_lexically_ordered_versions
versions = %w{ 20100101010101 20100201010101 20100301010101 }
versions.reverse.each do |v|
- ActiveRecord::SchemaMigration.create!(:version => v)
+ ActiveRecord::SchemaMigration.create!(
+ :version => v, :migrated_at => Time.now,
+ :fingerprint => "123456789012345678901234567890ab", :name => "anon")
end
schema_info = ActiveRecord::Base.connection.dump_schema_information
assert_match(/20100201010101.*20100301010101/m, schema_info)
+ target_line = %q{INSERT INTO schema_migrations (version, migrated_at, fingerprint, name) VALUES ('20100101010101',LOCALTIMESTAMP,'123456789012345678901234567890ab','anon');}
+ assert_match target_line, schema_info
end
def test_magic_comment
@@ -36,6 +39,16 @@ def test_schema_dump
assert_no_match %r{create_table "schema_migrations"}, output
end
+ def test_schema_dump_includes_migrations
+ ActiveRecord::SchemaMigration.delete_all
+ ActiveRecord::Migrator.migrate(MIGRATIONS_ROOT + "/always_safe")
+
+ output = standard_dump
+ assert_match %r{migrations do}, output, "Missing migrations block"
+ assert_match %r{migration 1001, "[0-9a-f]{32}", "always_safe"}, output, "Missing migration line"
+ assert_match %r{migration 1002, "[0-9a-f]{32}", "still_safe"}, output, "Missing migration line"
+ end
+
def test_schema_dump_excludes_sqlite_sequence
output = standard_dump
assert_no_match %r{create_table "sqlite_sequence"}, output
View
54 activerecord/test/cases/schema_migration_test.rb
@@ -0,0 +1,54 @@
+require "cases/helper"
+
+class SchemaMigrationTest < ActiveRecord::TestCase
+ def sm_table_name
+ ActiveRecord::SchemaMigration.table_name
+ end
+
+ def connection
+ ActiveRecord::Base.connection
+ end
+
+ def test_add_schema_info_respects_prefix_and_suffix
+ connection.drop_table(sm_table_name) if connection.table_exists?(sm_table_name)
+ # Use shorter prefix and suffix as in Oracle database identifier cannot be larger than 30 characters
+ ActiveRecord::Base.table_name_prefix = 'p_'
+ ActiveRecord::Base.table_name_suffix = '_s'
+ connection.drop_table(sm_table_name) if connection.table_exists?(sm_table_name)
+
+ ActiveRecord::SchemaMigration.create_table
+
+ assert_equal "p_unique_schema_migrations_s", connection.indexes(sm_table_name)[0][:name]
+ ensure
+ ActiveRecord::Base.table_name_prefix = ""
+ ActiveRecord::Base.table_name_suffix = ""
+ end
+
+ def test_add_metadata_columns_to_exisiting_schema_migrations
+ # creates the old table schema from pre-Rails4.0, so we can test adding to it below
+ if connection.table_exists?(sm_table_name)
+ connection.drop_table(sm_table_name)
+ end
+ connection.create_table(sm_table_name, :id => false) do |schema_migrations_table|
+ schema_migrations_table.column("version", :string, :null => false)
+ end
+
+ connection.insert "INSERT INTO #{connection.quote_table_name(sm_table_name)} (version) VALUES (100)"
+ connection.insert "INSERT INTO #{connection.quote_table_name(sm_table_name)} (version) VALUES (200)"
+
+ ActiveRecord::SchemaMigration.create_table
+
+ rows = connection.select_all("SELECT * FROM #{connection.quote_table_name(sm_table_name)}")
+ assert rows[0].has_key?("migrated_at"), "missing column `migrated_at`"
+ assert_match(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/, rows[0]["migrated_at"])
+ assert rows[0].has_key?("fingerprint"), "missing column `fingerprint`"
+ assert rows[0].has_key?("name"), "missing column `name`"
+ end
+
+ def test_schema_migrations_columns
+ ActiveRecord::SchemaMigration.create_table
+
+ columns = connection.columns(sm_table_name).collect(&:name)
+ %w[version migrated_at fingerprint name].each { |col| assert columns.include?(col), "missing column `#{col}`" }
+ end
+end
View
5 activerecord/test/migrations/always_safe/1001_always_safe.rb
@@ -0,0 +1,5 @@
+class AlwaysSafe < ActiveRecord::Migration
+ def change
+ # do nothing to avoid side-effect conflicts from running multiple times
+ end
+end
View
5 activerecord/test/migrations/always_safe/1002_still_safe.rb
@@ -0,0 +1,5 @@
+class StillSafe < ActiveRecord::Migration
+ def change
+ # do nothing to avoid side-effect conflicts from running multiple times
+ end
+end