diff --git a/MIT-LICENSE b/MIT-LICENSE new file mode 100644 index 0000000..31c142a --- /dev/null +++ b/MIT-LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2007 PJ Hyett + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README b/README new file mode 100644 index 0000000..1e37daa --- /dev/null +++ b/README @@ -0,0 +1,46 @@ +== AutoMigrations + +Forget migrations, auto-migrate! + + +== Usage + +Write out your schema (or use an existing one) + + $ cat db/schema.rb + + ActiveRecord::Schema.define do + + create_table :posts do |t| + t.string :title + t.text :body + t.timestamps + end + + end + + $ rake db:auto:migrate + + Created posts table + +...a few days later + + $ cat db/schema.rb + + ActiveRecord::Schema.define do + + create_table :posts do |t| + t.string :title + t.text :content + t.timestamps + end + + end + + $ rake db:auto:migrate + -- add_column("posts", :content, :text) + -> 0.0307s + -- remove_column("posts", "body") + -> 0.0311s + +* PJ Hyett [ pjhyett@gmail.com ] diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..f3bf7ce --- /dev/null +++ b/Rakefile @@ -0,0 +1,24 @@ +require 'rake' +require 'rake/testtask' +require 'rake/rdoctask' + +desc 'Default: run unit tests.' +task :default => :test + +desc 'Test the auto_migrations plugin.' +Rake::TestTask.new(:test) do |t| + t.libs << 'lib' + t.pattern = 'test/**/*_test.rb' + t.verbose = true +end + +desc 'Generate documentation for the auto_migrations plugin.' +Rake::RDocTask.new(:rdoc) do |rdoc| + files = ['README', 'LICENSE', 'lib/**/*.rb'] + rdoc.rdoc_files.add(files) + rdoc.main = "README" # page to start on + rdoc.title = "auto_migrations" + rdoc.template = File.exists?(t="/Users/chris/ruby/projects/err/rock/template.rb") ? t : "/var/www/rock/template.rb" + rdoc.rdoc_dir = 'doc' # rdoc output folder + rdoc.options << '--inline-source' +end diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..de79db5 --- /dev/null +++ b/init.rb @@ -0,0 +1,2 @@ +require 'auto_migrations' +ActiveRecord::Migration.send :include, AutoMigrations diff --git a/lib/auto_migrations.rb b/lib/auto_migrations.rb new file mode 100644 index 0000000..6060a8b --- /dev/null +++ b/lib/auto_migrations.rb @@ -0,0 +1,191 @@ +module AutoMigrations + + def self.run + # Turn off schema_info code for auto-migration + class << ActiveRecord::Schema + alias :old_define :define + attr_accessor :version + def define(info={}, &block) + @version = Time.now.utc.strftime("%Y%m%d%H%M%S") + if part = info.delete(:part) + announce "Loading schema component: #{part.to_s.humanize}" + end + instance_eval(&block) + end + end + + load(File.join(RAILS_ROOT, 'db', 'schema.rb')) + Dir.glob(File.join(RAILS_ROOT + '/db/schema_parts/*.rb')).each do |file| + if !(`/usr/bin/env ruby -c #{file}` =~ /Syntax OK/m) + warn "Invalid syntax in: #{file}" + next + end + load(file) + end + ActiveRecord::Migration.drop_unused_tables + ActiveRecord::Migration.drop_unused_indexes + ActiveRecord::Migration.update_schema_version(ActiveRecord::Schema.version) if ActiveRecord::Schema.version + + class << ActiveRecord::Schema + alias :define :old_define + end + end + + def self.schema_to_migration(with_reset = false) + schema_in = File.read(File.join(RAILS_ROOT, "db", "schema.rb")) + schema_in.gsub!(/#(.)+\n/, '') + schema_in.sub!(/ActiveRecord::Schema.define(.+)do[ ]?\n/, '') + schema_in.gsub!(/^/, ' ') + schema = "class InitialSchema < ActiveRecord::Migration\n def self.up\n" + schema += " # We're resetting the migrations database...\n" + + " drop_table :schema_migrations\n" + + " initialize_schema_migrations_table\n\n" if with_reset + schema += schema_in + schema << "\n def self.down\n" + schema << (ActiveRecord::Base.connection.tables - %w(schema_info schema_migrations)).map do |table| + " drop_table :#{table}\n" + end.join + schema << " end\nend\n" + migration_file = File.join(RAILS_ROOT, "db", "migrate", "001_initial_schema.rb") + File.open(migration_file, "w") { |f| f << schema } + puts "Migration created at db/migrate/001_initial_schema.rb" + end + + def self.included(base) + base.extend ClassMethods + class << base + cattr_accessor :tables_in_schema, :indexes_in_schema + self.tables_in_schema, self.indexes_in_schema = [], [] + alias_method_chain :method_missing, :auto_migration + end + end + + module ClassMethods + + def method_missing_with_auto_migration(method, *args, &block) + case method + when :create_table + auto_create_table(method, *args, &block) + when :add_index + auto_add_index(method, *args, &block) + else + method_missing_without_auto_migration(method, *args, &block) + end + end + + def auto_create_table(method, *args, &block) + table_name = args.shift.to_s + options = args.pop || {} + + (self.tables_in_schema ||= []) << table_name + + # Table doesn't exist, create it + unless ActiveRecord::Base.connection.tables.include?(table_name) + return method_missing_without_auto_migration(method, *[table_name, options], &block) + end + + # Grab database columns + fields_in_db = ActiveRecord::Base.connection.columns(table_name).inject({}) do |hash, column| + hash[column.name] = column + hash + end + + # Grab schema columns (lifted from active_record/connection_adapters/abstract/schema_statements.rb) + table_definition = ActiveRecord::ConnectionAdapters::TableDefinition.new(ActiveRecord::Base.connection) + primary_key = options[:primary_key] || "id" + table_definition.primary_key(primary_key) unless options[:id] == false + yield table_definition + fields_in_schema = table_definition.columns.inject({}) do |hash, column| + hash[column.name.to_s] = column + hash + end + + # Add fields to db new to schema + (fields_in_schema.keys - fields_in_db.keys).each do |field| + column = fields_in_schema[field] + options = {:limit => column.limit, :precision => column.precision, :scale => column.scale} + options[:default] = column.default if !column.default.nil? + options[:null] = column.null if !column.null.nil? + add_column table_name, column.name, column.type.to_sym, options + end + + # Remove fields from db no longer in schema + (fields_in_db.keys - fields_in_schema.keys & fields_in_db.keys).each do |field| + column = fields_in_db[field] + remove_column table_name, column.name + end + + (fields_in_schema.keys & fields_in_db.keys).each do |field| + if field != primary_key #ActiveRecord::Base.get_primary_key(table_name) + changed = false # flag + new_type = fields_in_schema[field].type.to_sym + new_attr = {} + + # First, check if the field type changed + if fields_in_schema[field].type.to_sym != fields_in_db[field].type.to_sym + changed = true + end + + # Special catch for precision/scale, since *both* must be specified together + # Always include them in the attr struct, but they'll only get applied if changed = true + new_attr[:precision] = fields_in_schema[field][:precision] + new_attr[:scale] = fields_in_schema[field][:scale] + + # Next, iterate through our extended attributes, looking for any differences + # This catches stuff like :null, :precision, etc + fields_in_schema[field].each_pair do |att,value| + next if att == :type or att == :base or att == :name # special cases + if !value.nil? && value != fields_in_db[field].send(att) + new_attr[att] = value + changed = true + end + end + + # Change the column if applicable + change_column table_name, field, new_type, new_attr if changed + end + end + end + + def auto_add_index(method, *args, &block) + table_name = args.shift.to_s + fields = Array(args.shift).map(&:to_s) + options = args.shift + + index_name = options[:name] if options + index_name ||= ActiveRecord::Base.connection.index_name(table_name, :column => fields) + + (self.indexes_in_schema ||= []) << index_name + + unless ActiveRecord::Base.connection.indexes(table_name).detect { |i| i.name == index_name } + method_missing_without_auto_migration(method, *[table_name, fields, options], &block) + end + end + + def drop_unused_tables + (ActiveRecord::Base.connection.tables - tables_in_schema - %w(schema_info schema_migrations)).each do |table| + drop_table table + end + end + + def drop_unused_indexes + tables_in_schema.each do |table_name| + indexes_in_db = ActiveRecord::Base.connection.indexes(table_name).map(&:name) + (indexes_in_db - indexes_in_schema & indexes_in_db).each do |index_name| + remove_index table_name, :name => index_name + end + end + end + + def update_schema_version(version) + ActiveRecord::Base.connection.update("INSERT INTO schema_migrations VALUES ('#{version}')") + + schema_file = File.join(RAILS_ROOT, "db", "schema.rb") + schema = File.read(schema_file) + schema.sub!(/:version => \d+/, ":version => #{version}") + File.open(schema_file, "w") { |f| f << schema } + end + + end + +end diff --git a/tasks/auto_migrations_tasks.rake b/tasks/auto_migrations_tasks.rake new file mode 100644 index 0000000..7dcdcbe --- /dev/null +++ b/tasks/auto_migrations_tasks.rake @@ -0,0 +1,20 @@ +namespace :db do + namespace :auto do + desc "Use schema.rb to auto-migrate" + task :migrate => :environment do + AutoMigrations.run + end + end + + namespace :schema do + desc "Create migration from schema.rb" + task :to_migration => :environment do + AutoMigrations.schema_to_migration + end + + desc "Create migration from schema.rb and reset migrations log" + task :to_migration_with_reset => :environment do + AutoMigrations.schema_to_migration(true) + end + end +end diff --git a/test/auto_migrations_test.rb b/test/auto_migrations_test.rb new file mode 100644 index 0000000..8a7bd88 --- /dev/null +++ b/test/auto_migrations_test.rb @@ -0,0 +1,8 @@ +require 'test/unit' + +class AutoMigrationsTest < Test::Unit::TestCase + # Replace this with your real tests. + def test_this_plugin + flunk + end +end