Skip to content
Browse files

Initial commit to immigrant

  • Loading branch information...
0 parents commit f9a76871a6fd0155a28c4d43eceeae598757b574 @jenseng committed Apr 1, 2012
7 Gemfile
@@ -0,0 +1,7 @@
+source "http://rubygems.org"
+
+gemspec
+
+group :test do
+ gem 'rake'
+end
20 LICENSE.txt
@@ -0,0 +1,20 @@
+Copyright (c) 2012 Jon Jensen
+
+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.
40 README.rdoc
@@ -0,0 +1,40 @@
+= Immigrant
+
+Immigrant gives {Foreigner}[https://github.com/matthuhiggins/foreigner] a
+migration generator so you can effortlessly add missing foreign keys. This is
+particularly helpful when you decide to add keys to an established Rails app.
+
+Like Foreigner, Immigrant requires Rails 3.0 or greater.
+
+== Installation
+
+Add the following to your Gemfile:
+
+ gem 'immigrant'
+
+== Usage
+
+ rails generate immigration AddKeys
+
+This will create a migration named AddKeys which will have add_foreign_key
+statements for any missing foreign keys. Immigrant infers missing ones by
+evaluating the associations in your models (e.g. belongs_to, has_many, etc.).
+Only missing keys will be added; existing ones will never be altered or
+removed.
+
+== Considerations
+
+If the data in your tables is bad, then the migration will fail to run
+(obviously). IOW, ensure you don't have orphaned records *before* you try to
+add foreign keys.
+
+== Known Issues
+
+Immigrant currently only looks for foreign keys in ActiveRecord::Base's
+database. So if a model is using a different database connection and it has
+foreign keys, Immigrant will incorrectly include them again in the generated
+migration.
+
+== License
+
+Copyright (c) 2012 Jon Jensen, released under the MIT license
13 Rakefile
@@ -0,0 +1,13 @@
+require 'rake'
+
+desc 'Default: run unit tests.'
+task :default => :test
+
+require 'rake/testtask'
+desc 'Test the immigrant plugin.'
+Rake::TestTask.new(:test) do |t|
+ t.libs << 'lib'
+ t.libs << 'test'
+ t.pattern = 'test/**/*_test.rb'
+ t.verbose = true
+end
20 immigrant.gemspec
@@ -0,0 +1,20 @@
+# -*- encoding: utf-8 -*-
+
+Gem::Specification.new do |s|
+ s.name = 'immigrant'
+ s.version = '0.1.0'
+ s.summary = 'Migration generator for Foreigner'
+ s.description = 'Adds a generator for creating a foreign key migration based on your current model associations'
+
+ s.required_ruby_version = '>= 1.8.7'
+ s.required_rubygems_version = '>= 1.3.5'
+
+ s.author = 'Jon Jensen'
+ s.email = 'jenseng@gmail.com'
+ s.homepage = 'http://github.com/jenseng/immigrant'
+
+ s.extra_rdoc_files = %w(README.rdoc)
+ s.files = %w(LICENSE.txt Rakefile README.rdoc lib/generators/USAGE) + Dir['lib/**/*.rb'] + Dir['test/**/*.rb']
+ s.add_dependency('activerecord', '>= 3.0')
+ s.add_dependency('foreigner', '>= 1.1.3')
+end
19 lib/generators/USAGE
@@ -0,0 +1,19 @@
+Description:
+ Creates a new foreign key migration based on your current associations.
+ Pass the migration name, either CamelCased or under_scored.
+
+ A migration class is generated in db/migrate prefixed by a timestamp of the
+ current date and time. It will contain add_foreign_key calls to create any
+ foreign keys that do not already exist (inferred from your model
+ associations and current foreign keys in the database). If there are no
+ missing foreign keys, no migration will be created.
+
+Example:
+ `rails generate immigration AddMissingForeignKeys`
+
+ If the current date is Apr 1, 2012 and the current time 02:03:04, this
+ creates the AddMissingForeignKeys migration
+ db/migrate/20120401020304_add_missing_foreign_keys.rb with appropriate
+ add_foreign_key calls in the Change migration. If on Rails < 3.1, they
+ will be in the Up migration, and corresponding remove_foreign_key calls
+ will be in the Down migration.
20 lib/generators/immigration_generator.rb
@@ -0,0 +1,20 @@
+require 'rails/generators/active_record'
+
+class ImmigrationGenerator < ActiveRecord::Generators::Base
+ def create_immigration_file
+ @keys, warnings = Immigrant.infer_keys
+ warnings.values.each{ |warning| $stderr.puts "WARNING: #{warning}" }
+ @keys.each do |key|
+ next unless key.options[:dependent] == :delete
+ $stderr.puts "NOTICE: #{key.options[:name]} has ON DELETE CASCADE. You should remove the :dependent option from the association to take advantage of this."
+ end
+ if @keys.present?
+ template = ActiveRecord::VERSION::STRING < "3.1." ? "immigration-pre-3.1.rb" : "immigration.rb"
+ migration_template template, "db/migrate/#{file_name}.rb"
+ else
+ puts "Nothing to do"
+ end
+ end
+
+ source_root File.expand_path(File.join(File.dirname(__FILE__), 'templates'))
+end
13 lib/generators/templates/immigration-pre-3.1.rb
@@ -0,0 +1,13 @@
+class <%= migration_class_name %> < ActiveRecord::Migration
+ def self.up
+<% @keys.each do |key| -%>
+ <%= key.to_ruby(:add) %>
+<%- end -%>
+ end
+
+ def self.down
+<% @keys.each do |key| -%>
+ <%= key.to_ruby(:remove) %>
+<%- end -%>
+ end
+end
7 lib/generators/templates/immigration.rb
@@ -0,0 +1,7 @@
+class <%= migration_class_name %> < ActiveRecord::Migration
+ def change
+<% @keys.each do |key| -%>
+ <%= key.to_ruby(:add) %>
+<%- end -%>
+ end
+end
141 lib/immigrant.rb
@@ -0,0 +1,141 @@
+require 'active_support/all'
+require 'foreigner'
+
+module Immigrant
+ extend ActiveSupport::Autoload
+ autoload :Loader
+ autoload :ForeignKeyDefinition
+
+ class << self
+ def infer_keys(db_keys = current_foreign_keys, classes = model_classes)
+ database_keys = db_keys.inject({}) { |hash, foreign_key|
+ hash[foreign_key.hash_key] = foreign_key
+ hash
+ }
+ model_keys, warnings = model_keys(classes)
+ new_keys = []
+ model_keys.keys.each do |hash_key|
+ foreign_key = model_keys[hash_key]
+ # if the foreign key exists in the db, we call it good (even if
+ # the name is different or :dependent doesn't match), though
+ # we do warn on clearly broken stuff
+ if current_key = database_keys[hash_key]
+ if current_key.to_table != foreign_key.to_table || current_key.options[:primary_key] != foreign_key.options[:primary_key]
+ warnings[hash_key] = "Skipping #{foreign_key.from_table}.#{foreign_key.options[:column]}: its association references a different key/table than its current foreign key"
+ end
+ else
+ new_keys << foreign_key
+ end
+ end
+ [new_keys.sort_by{ |key| key.options[:name] }, warnings]
+ end
+
+ private
+
+ def current_foreign_keys
+ ActiveRecord::Base.connection.tables.map{ |table|
+ ActiveRecord::Base.connection.foreign_keys(table)
+ }.flatten
+ end
+
+ def model_classes
+ classes = []
+ pattern = /^\s*(has_one|has_many|has_and_belongs_to_many|belongs_to)\s/
+ Dir['app/models/*.rb'].each do |model|
+ class_name = model.sub(/\A.*\/(.*?)\.rb\z/, '\1').camelize
+ begin
+ klass = class_name.constantize
+ rescue SyntaxError, LoadError
+ if File.read(model) =~ pattern
+ raise "unable to load #{class_name} and its associations"
+ end
+ next
+ end
+ classes << klass if klass < ActiveRecord::Base
+ end
+ classes
+ end
+
+ def model_keys(classes)
+ # see what the models say there should be
+ foreign_keys = {}
+ warnings = {}
+ classes.map{ |klass|
+ foreign_keys_for(klass)
+ }.flatten.uniq.each do |foreign_key|
+ # we may have inferred it from several different places, e.g.
+ # Bar.belongs_to :foo
+ # Foo.has_many :bars
+ # Foo.has_many :bazzes, :class_name => Bar
+ # we need to make sure everything is legit and see if any of them
+ # specify :dependent => :delete
+ if current_key = foreign_keys[foreign_key.hash_key]
+ if current_key.to_table != foreign_key.to_table || current_key.options[:primary_key] != foreign_key.options[:primary_key]
+ warnings[foreign_key.hash_key] ||= "Skipping #{foreign_key.from_table}.#{foreign_key.options[:column]}: it has multiple associations referencing different keys/tables."
+ next
+ else
+ next unless foreign_key.options[:dependent]
+ end
+ end
+ foreign_keys[foreign_key.hash_key] = foreign_key
+ end
+ warnings.keys.each { |hash_key| foreign_keys.delete(hash_key) }
+ [foreign_keys, warnings]
+ end
+
+ def foreign_keys_for(klass)
+ fk_method = ActiveRecord::VERSION::STRING < '3.1.' ? :primary_key_name : :foreign_key
+
+ klass.reflections.values.reject{ |reflection|
+ # some associations can just be ignored, since:
+ # 1. we aren't going to parse SQL
+ # 2. foreign keys for :through associations will be handled by their
+ # component has_one/has_many/belongs_to associations
+ # 3. :polymorphic(/:as) associations can't have foreign keys
+ (reflection.options.keys & [:finder_sql, :through, :polymorphic, :as]).present?
+ }.map { |reflection|
+ begin
+ case reflection.macro
+ when :belongs_to
+ Foreigner::ConnectionAdapters::ForeignKeyDefinition.new(
+ klass.table_name, reflection.klass.table_name,
+ :column => reflection.send(fk_method).to_s,
+ :primary_key => reflection.klass.primary_key.to_s,
+ # although belongs_to can specify :dependent, it doesn't make
+ # sense from a foreign key perspective
+ :dependent => nil
+ )
+ when :has_one, :has_many
+ Foreigner::ConnectionAdapters::ForeignKeyDefinition.new(
+ reflection.klass.table_name, klass.table_name,
+ :column => reflection.send(fk_method).to_s,
+ :primary_key => klass.primary_key.to_s,
+ :dependent => [:delete, :delete_all].include?(reflection.options[:dependent]) && reflection.options[:conditions].nil? ? :delete : nil
+ )
+ when :has_and_belongs_to_many
+ [
+ Foreigner::ConnectionAdapters::ForeignKeyDefinition.new(
+ reflection.options[:join_table], klass.table_name,
+ :column => reflection.send(fk_method).to_s,
+ :primary_key => klass.primary_key.to_s,
+ :dependent => nil
+ ),
+ Foreigner::ConnectionAdapters::ForeignKeyDefinition.new(
+ reflection.options[:join_table], reflection.klass.table_name,
+ :column => reflection.association_foreign_key.to_s,
+ :primary_key => reflection.klass.primary_key.to_s,
+ :dependent => nil
+ )
+ ]
+ end
+ rescue NameError # e.g. belongs_to :oops_this_is_not_a_table
+ []
+ end
+ }.flatten
+ end
+
+ end
+end
+
+require 'immigrant/loader'
+require 'immigrant/railtie' if defined?(Rails)
38 lib/immigrant/foreign_key_definition.rb
@@ -0,0 +1,38 @@
+module Immigrant
+ # add some useful stuff to foreigner's ForeignKeyDefinition
+ # TODO: get it in foreigner so we don't need to monkey patch
+ module ForeignKeyDefinition
+ def initialize(from_table, to_table, options, *args)
+ options ||= {}
+ options[:name] ||= "#{from_table}_#{options[:column]}_fk"
+ super(from_table, to_table, options, *args)
+ end
+
+ def hash_key
+ [from_table, options[:column]]
+ end
+
+ def to_ruby(action = :add)
+ if action == :add
+ # not DRY ... guts of this are copied from Foreigner :(
+ parts = [ ('add_foreign_key ' + from_table.inspect) ]
+ parts << to_table.inspect
+ parts << (':name => ' + options[:name].inspect)
+
+ if options[:column] != "#{to_table.singularize}_id"
+ parts << (':column => ' + options[:column].inspect)
+ end
+ if options[:primary_key] != 'id'
+ parts << (':primary_key => ' + options[:primary_key].inspect)
+ end
+ if options[:dependent].present?
+ parts << (':dependent => ' + options[:dependent].inspect)
+ end
+ parts.join(', ')
+ else
+ "remove_foreign_key #{from_table.inspect}, " \
+ ":name => #{options[:name].inspect}"
+ end
+ end
+ end
+end
7 lib/immigrant/loader.rb
@@ -0,0 +1,7 @@
+module Immigrant
+ def self.load
+ Foreigner::ConnectionAdapters::ForeignKeyDefinition.instance_eval do
+ include Immigrant::ForeignKeyDefinition
+ end
+ end
+end
13 lib/immigrant/railtie.rb
@@ -0,0 +1,13 @@
+module Immigrant
+ class Railtie < Rails::Railtie
+ initializer 'immigrant.load' do
+ # TODO: implement hook in Foreigner and use that instead
+ ActiveSupport.on_load :active_record do
+ Immigrant.load
+ end
+ end
+ generators do
+ require "generators/immigration_generator"
+ end
+ end
+end
20 test/helper.rb
@@ -0,0 +1,20 @@
+require 'bundler/setup'
+Bundler.require(:default)
+
+require 'test/unit'
+require 'active_record'
+
+require 'foreigner'
+class Foreigner::Adapter
+ def self.configured_name; "dummy_adapter"; end
+end
+Foreigner.load
+
+require 'immigrant'
+Immigrant.load
+
+module TestMethods
+ def foreign_key_definition(*args)
+ Foreigner::ConnectionAdapters::ForeignKeyDefinition.new(*args)
+ end
+end
314 test/immigrant_test.rb
@@ -0,0 +1,314 @@
+require 'helper'
+
+class ImmigrantTest < ActiveSupport::TestCase
+ include TestMethods
+
+ class MockModel < ActiveRecord::Base
+ self.abstract_class = true
+ class << self
+ def primary_key
+ connection.primary_key(table_name)
+ end
+ def connection
+ @connection ||= MockConnection.new
+ end
+ end
+ end
+
+ class MockConnection
+ def supports_primary_key? # AR <3.2
+ true
+ end
+ def primary_key(table)
+ table !~ /s_.*s\z/ ? 'id' : nil
+ end
+ end
+
+ def teardown
+ subclasses = ActiveSupport::DescendantsTracker.direct_descendants(MockModel)
+ subclasses.each do |subclass|
+ ImmigrantTest.send(:remove_const, subclass.to_s.sub(/.*::/, ''))
+ end
+ subclasses.replace([])
+ end
+
+
+ # basic scenarios
+
+ test 'belongs_to should generate a foreign key' do
+ class Author < MockModel; end
+ class Book < MockModel
+ belongs_to :guy, :class_name => 'Author', :foreign_key => 'author_id'
+ end
+
+ assert_equal(
+ [foreign_key_definition(
+ 'books', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => nil
+ )],
+ Immigrant.infer_keys([], [Author, Book]).first
+ )
+ end
+
+ test 'has_one should generate a foreign key' do
+ class Author < MockModel
+ has_one :piece_de_resistance, :class_name => 'Book', :order => "id DESC"
+ end
+ class Book < MockModel; end
+
+ assert_equal(
+ [foreign_key_definition(
+ 'books', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => nil
+ )],
+ Immigrant.infer_keys([], [Author, Book]).first
+ )
+ end
+
+ test 'has_one :dependent => :delete should generate a foreign key with :dependent => :delete' do
+ class Author < MockModel
+ has_one :book, :order => "id DESC", :dependent => :delete
+ end
+ class Book < MockModel; end
+
+ assert_equal(
+ [foreign_key_definition(
+ 'books', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => :delete
+ )],
+ Immigrant.infer_keys([], [Author, Book]).first
+ )
+ end
+
+ test 'has_many should generate a foreign key' do
+ class Author < MockModel
+ has_many :babies, :class_name => 'Book'
+ end
+ class Book < MockModel; end
+
+ assert_equal(
+ [foreign_key_definition(
+ 'books', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => nil
+ )],
+ Immigrant.infer_keys([], [Author, Book]).first
+ )
+ end
+
+ test 'has_many :dependent => :delete_all should generate a foreign key with :dependent => :delete' do
+ class Author < MockModel
+ has_many :books, :dependent => :delete_all
+ end
+ class Book < MockModel; end
+
+ assert_equal(
+ [foreign_key_definition(
+ 'books', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => :delete
+ )],
+ Immigrant.infer_keys([], [Author, Book]).first
+ )
+ end
+
+ test 'has_and_belongs_to_many should generate two foreign keys' do
+ class Author < MockModel
+ has_and_belongs_to_many :fans
+ end
+ class Fan < MockModel; end
+
+ assert_equal(
+ [foreign_key_definition(
+ 'authors_fans', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => nil
+ ),
+ foreign_key_definition(
+ 'authors_fans', 'fans',
+ :column => 'fan_id', :primary_key => 'id', :dependent => nil
+ )],
+ Immigrant.infer_keys([], [Author, Fan]).first
+ )
+ end
+
+ test 'conditional has_one/has_many associations should ignore :dependent' do
+ class Author < MockModel
+ has_many :articles, :conditions => "published", :dependent => :delete_all
+ has_one :favorite_book, :class_name => 'Book',
+ :conditions => "most_awesome", :dependent => :delete
+ end
+ class Book < MockModel; end
+ class Article < MockModel; end
+
+ assert_equal(
+ [foreign_key_definition(
+ 'articles', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => nil
+ ),
+ foreign_key_definition(
+ 'books', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => nil
+ )],
+ Immigrant.infer_keys([], [Article, Author, Book]).first
+ )
+ end
+
+
+ # (no) duplication
+
+ test 'STI should not generate duplicate foreign keys' do
+ class Company < MockModel; end
+ class Employee < MockModel
+ belongs_to :company
+ end
+ class Manager < Employee; end
+
+ assert(Manager.reflections.present?)
+ assert_equal(
+ [foreign_key_definition(
+ 'employees', 'companies',
+ :column => 'company_id', :primary_key => 'id', :dependent => nil
+ )],
+ Immigrant.infer_keys([], [Company, Employee, Manager]).first
+ )
+ end
+
+ test 'complementary associations should not generate duplicate foreign keys' do
+ class Author < MockModel
+ has_many :books
+ end
+ class Book < MockModel
+ belongs_to :author
+ end
+
+ assert_equal(
+ [foreign_key_definition(
+ 'books', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => nil
+ )],
+ Immigrant.infer_keys([], [Author, Book]).first
+ )
+ end
+
+ test 'redundant associations should not generate duplicate foreign keys' do
+ class Author < MockModel
+ has_many :books
+ has_many :favorite_books, :class_name => 'Book', :conditions => "awesome"
+ has_many :bad_books, :class_name => 'Book', :conditions => "amateur_hour"
+ end
+ class Book < MockModel; end
+
+ assert_equal(
+ [foreign_key_definition(
+ 'books', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => nil
+ )],
+ Immigrant.infer_keys([], [Author, Book]).first
+ )
+ end
+
+
+ # skipped associations
+
+ test 'associations should not generate foreign keys if they already exist, even if :dependent/name are different' do
+ database_keys = [
+ foreign_key_definition(
+ 'articles', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => nil,
+ :name => "doesn't_matter"
+ ),
+ foreign_key_definition(
+ 'books', 'authors', :column => 'author_id', :primary_key => 'id',
+ :dependent => :delete
+ )
+ ]
+
+ class Author < MockModel
+ has_many :articles
+ has_one :favorite_book, :class_name => 'Book',
+ :conditions => "most_awesome"
+ end
+ class Book < MockModel; end
+ class Article < MockModel; end
+
+ assert_equal(
+ [],
+ Immigrant.infer_keys(database_keys, [Article, Author, Book]).first
+ )
+ end
+
+ test 'finder_sql associations should not generate foreign keys' do
+ class Author < MockModel
+ has_many :books, :finder_sql => <<-SQL
+ SELECT *
+ FROM books
+ WHERE author_id = \#{id}
+ ORDER BY RANDOM() LIMIT 5'
+ SQL
+ end
+ class Book < MockModel; end
+
+ assert_equal(
+ [],
+ Immigrant.infer_keys([], [Author, Book]).first
+ )
+ end
+
+ test 'polymorphic associations should not generate foreign keys' do
+ class Property < MockModel
+ belongs_to :owner, :polymorphic => true
+ end
+ class Person < MockModel
+ has_many :properties, :as => :owner
+ end
+ class Corporation < MockModel
+ has_many :properties, :as => :owner
+ end
+
+ assert_equal(
+ [],
+ Immigrant.infer_keys([], [Corporation, Person, Property]).first
+ )
+ end
+
+ test 'has_many :through should not generate foreign keys' do
+ class Author < MockModel
+ has_many :authors_fans
+ has_many :fans, :through => :authors_fans
+ end
+ class AuthorsFan < MockModel
+ belongs_to :author
+ belongs_to :fan
+ end
+ class Fan < MockModel
+ has_many :authors_fans
+ has_many :authors, :through => :authors_fans
+ end
+
+ assert_equal(
+ [foreign_key_definition(
+ 'authors_fans', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => nil
+ ),
+ foreign_key_definition(
+ 'authors_fans', 'fans',
+ :column => 'fan_id', :primary_key => 'id', :dependent => nil
+ )],
+ Immigrant.infer_keys([], [Author, AuthorsFan, Fan]).first
+ )
+ end
+
+ test 'broken associations should not cause errors' do
+ class Author < MockModel; end
+ class Book < MockModel
+ belongs_to :author
+ belongs_to :invalid
+ end
+
+ assert_equal(
+ [foreign_key_definition(
+ 'books', 'authors',
+ :column => 'author_id', :primary_key => 'id', :dependent => nil
+ )],
+ Immigrant.infer_keys([], [Author, Book]).first
+ )
+ end
+end

0 comments on commit f9a7687

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