Rails generators dissection

From WhyNotWiki

Jump to: navigation, search

[edit] How it finds which migration to run

As our example, let's examine:

> ./script/generate migration create_mice

Now we'll dissect the Rails source and figure out how it finds the "migration" generator.

/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator

generators/
scripts/
base.rb
commands.rb
lookup.rb
manifest.rb
options.rb
scripts.rb
simple_logger.rb
spec.rb


/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/scripts.rb

      # Generator scripts handle command-line invocation.  Each script
      # responds to an invoke! class method which handles option parsing
      # and generator invocation.
      class Base
        include Options
        default_options :collision => :ask, :quiet => false

        # Run the generator script.  Takes an array of unparsed arguments
        # and a hash of parsed arguments, takes the generator as an option
        # or first remaining argument, and invokes the requested command.
        def run(args = [], runtime_options = {})
          parse!(args.dup, runtime_options)

          # Generator name is the only required option.
          unless options[:generator]
            usage if args.empty?
            options[:generator] ||= args.shift
          end
          p options   # 

          # Look up generator instance and invoke command on it.
          Rails::Generator::Base.instance(options[:generator], args, options).command(options[:command]).invoke!
> ./script/generate migration f
{:command=>:create, :generator=>"migration"}

/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/lookup.rb

    # Generator lookup is managed by a list of sources which return specs
    # describing where to find and how to create generators.  This module
    # provides class methods for manipulating the source list and looking up
    # generator specs, and an #instance wrapper for quickly instantiating
    # generators by name.
    #
    # A spec is not a generator:  it's a description of where to find
    # the generator and how to create it.  A source is anything that
    # yields generators from #each.  PathSource and GemSource are provided.
    module Lookup
      def self.append_features(base)
        super
        base.extend(ClassMethods)
        base.use_component_sources!
      end

      # Convenience method to instantiate another generator.
      def instance(generator_name, args, runtime_options = {})
        self.class.instance(generator_name, args, runtime_options)
      end

...

        # Convenience method to lookup and instantiate a generator.
        def instance(generator_name, args = [], runtime_options = {})
          p args
          p full_options(runtime_options)
          lookup(generator_name).klass.new(args, full_options(runtime_options))
        end

> ./script/generate migration create_mice
["create_mice"]
{:command=>:create, :generator=>"migration", :quiet=>false, :collision=>:ask}

/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/lookup.rb

        # Use component generators (model, controller, etc).
        # 1.  Rails application.  If RAILS_ROOT is defined we know we're
        #     generating in the context of a Rails application, so search
        #     RAILS_ROOT/generators.
        # 2.  User home directory.  Search ~/.rails/generators.
        # 3.  RubyGems.  Search for gems named *_generator.
        # 4.  Builtins.  Model, controller, mailer, scaffold.
        def use_component_sources!
          reset_sources
          if defined? ::RAILS_ROOT
            sources << PathSource.new(:lib, "#{::RAILS_ROOT}/lib/generators")
            sources << PathSource.new(:vendor, "#{::RAILS_ROOT}/vendor/generators")
            sources << PathSource.new(:plugins, "#{::RAILS_ROOT}/vendor/plugins/**/generators")
          end
          sources << PathSource.new(:user, "#{Dir.user_home}/.rails/generators")
          sources << GemSource.new if Object.const_defined?(:Gem)
          sources << PathSource.new(:builtin, "#{File.dirname(__FILE__)}/generators/components")
        end

...

          # Lookup and cache every generator from the source list.
          def cache
            @cache ||= sources.inject([]) { |cache, source| cache + source.map }
          end

...

        # Lookup knows how to find generators' Specs from a list of Sources.
        # Searches the sources, in order, for the first matching name.
        def lookup(generator_name)
          @found ||= {}
          generator_name = generator_name.to_s.downcase
          puts cache.class.name
          @found[generator_name] ||= cache.find { |spec| spec.name == generator_name }
          unless @found[generator_name]
            chars = generator_name.scan(/./).map{|c|"#{c}.*?"}
            rx = /^#{chars}$/
            gns = cache.select{|spec| spec.name =~ rx }
            @found[generator_name] ||= gns.first if gns.length == 1
            raise GeneratorError, "Pattern '#{generator_name}' matches more than one generator: #{gns.map{|sp|sp.name}.join(', ')}" if gns.length > 1
          end
          @found[generator_name] or raise GeneratorError, "Couldn't find '#{generator_name}' generator"
        end

/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/lookup.rb

    # PathSource looks for generators in a filesystem directory.
    class PathSource < Source
      attr_reader :path

      def initialize(label, path)
        super label
        @path = path
      end

      # Yield each eligible subdirectory.
      def each
        Dir["#{path}/[a-z]*"].each do |dir|
          if File.directory?(dir)
            puts dir
            yield Spec.new(File.basename(dir), dir, label)
          end
        end
      end
    end
> ./script/generate migration create_mice
script/../config/../vendor/plugins/surveys/generators/create_surveys_table_migration
...
script/../config/../vendor/plugins/helper_test/generators/helper_test
/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/generators/components/migration
/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/generators/components/plugin
...
/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/generators/components/model
/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/generators/components/mailer
/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/generators/components/controller

So the generator it will use is /usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/generators/components/migration .

[edit] How to override a built-in generator: Example: Override migration generator to get the latest from Subversion

Fortunately for us, Rails looks in a couple of local/overridable locations (such as PathSource.new(:lib, "#{::RAILS_ROOT}/lib/generators")) before it ever goes searching for its built-in generators (PathSource.new(:builtin, "#{File.dirname(__FILE__)}/generators/components")).

So we can just copy /usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/generators/components/migration/migration_generator.rb over to lib/generators/migration/migration_generator.rb and it will use lib/generators/migration/migration_generator.rb instead of the other.

Now let's suppose we want to add some code to make sure we have the latest migrations from Subversion before generating a migration. This is to ensure that if someone else has committed, say, migration 18, that we will be aware of that and create migration 19 rather than creating another 18!

This is what we start with:

class MigrationGenerator < Rails::Generator::NamedBase
  def manifest
    record do |m|
      m.migration_template 'migration.rb', 'db/migrate'
    end
  end
end

We can continue to use the migration_template command, which is defined here: /usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/commands.rb

        # When creating a migration, it knows to find the first available file in db/migrate and use the migration.rb template.
        def migration_template(relative_source, relative_destination, template_options = {})
          migration_directory relative_destination
          migration_file_name = template_options[:migration_file_name] || file_name
          raise "Another migration is already named #{migration_file_name}: #{existing_migrations(migration_file_name).first}" if migration_exists?(migration
          template(relative_source, "#{relative_destination}/#{next_migration_string}_#{migration_file_name}.rb", template_options)
        end

Where does file_name come from?

/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/base.rb

      attr_reader   :name, :class_name, :singular_name, :plural_name, :table_name
      alias_method  :file_name,  :singular_name

        def assign_names!(name)
          @name = name
          base_name, @class_path, @file_path, @class_nesting, @class_nesting_depth = extract_modules(@name)
          @class_name_without_nesting, @singular_name, @plural_name = inflect_names(base_name)
          puts name, base_name, self.file_name
          @table_name = ActiveRecord::Base.pluralize_table_names ? plural_name : singular_name
          if @class_nesting.empty?
            @class_name = @class_name_without_nesting
          else
            @class_name = "#{@class_nesting}::#{@class_name_without_nesting}"
          end
        end
> ./script/generate migration CreateMice
CreateMice
CreateMice
create_mice

> ./script/generate migration Snowscape::CreateIcicles
Snowscape::CreateIcicles
CreateIcicles
create_icicles

> ./script/generate migration barnyard/create_mice
barnyard/create_mice
create_mice
create_mice

A first attempt...

class MigrationGenerator < Rails::Generator::NamedBase
  def initialize(runtime_args, runtime_options = {})
    sh 'svn up db/migrations'     # Get everyone else's latest revisions so that we can correctly determine the next migration number
    super
  end

  def manifest
    record do |m|
      m.migration_template 'migration.rb', 'db/migrate'
      new_file = Dir["db/migrate/*#{self.file_name}*"].first
      puts "You just created '#{new_file}'"
      # We would svn add the file now, but it doesn't really exist yet...
    end
  end
end

But since we're only creating the manifest right now, not actually running these commands yet (they will be run by the Commands::Create class after the manifest is done being created), the file doesn't exist yet!

> ./script/generate migration create_mice
svn up db/migrations
At revision 1546.
You just created ''
      exists  db/migrate
      create  db/migrate/018_create_mice.rb

So... Looks like we'll have to create a custom command. Fortunately, that is easy enough.

This works:

lib/generators/migration/migration_generator.rb

module Rails
  module Generator
    module Commands
      class Create < Base
        def add_latest_migration_to_svn(relative_destination)
          new_file = Dir["db/migrate/*#{self.file_name}*"].first
          migration_number = new_file.match(/[0-9]+/)[0]
          puts "Your new migration will now be added to the repository immediately so that other people won't accidentally create a migration with the same n
          sh "svn add #{new_file}"
          sh "svn ci -m 'Added new migration \"#{new_file}\"' #{new_file}"
        end
      end
    end
  end
end

class MigrationGenerator < Rails::Generator::NamedBase
  def initialize(runtime_args, runtime_options = {})
    sh 'svn up db/migrations'     # Get everyone else's latest revisions so that we can correctly determine the next migration number
    super
  end

  def manifest
    record do |m|
      m.migration_template 'migration.rb', 'db/migrate'
      m.add_latest_migration_to_svn 'db/migrate'
    end
  end
end

[edit] How script/destroy works

No, they didn't have to make a separate destroy script for each generator. What they appear to do instead is to just run the generator's manifest in reverse!

/usr/lib/ruby/gems/1.8/gems/rails-1.1.6/lib/rails_generator/commands.rb

      # Undo the actions performed by a generator.  Rewind the action
      # manifest and attempt to completely erase the results of each action.
      class Destroy < RewindBase
        # Remove a file if it exists and is a file.
        def file(relative_source, relative_destination, file_options = {})
          destination = destination_path(relative_destination)
          if File.exists?(destination)
            logger.rm relative_destination
            unless options[:pretend]
              if options[:svn]
                # If the file has been marked to be added
                # but has not yet been checked in, revert and delete
                if options[:svn][relative_destination]
                  system("svn revert #{destination}")
                  FileUtils.rm(destination)
                else
                # If the directory is not in the status list, it
                # has no modifications so we can simply remove it
                  system("svn rm #{destination}")
                end
              else
                FileUtils.rm(destination)
              end
            end
          else
            logger.missing relative_destination
            return
          end
        end
Personal tools