# HoboFields - Migration Generator Note that these doctests are good tests but not such good docs. The migration generator doesn't really fit well with the doctest concept of a single IRB session. As you'll see, there's a lot of jumping-through-hoops and doing stuff that no normal user of the migration generator would ever do. Firstly, in order to test the migration generator outside of a full Rails stack, there's a few things we need to do. First off we need to configure ActiveSupport for auto-loading >> require 'rubygems' >> require 'activesupport' >> Dependencies.load_paths << '.' >> Dependencies.mechanism = :require And we'll require: >> require 'activerecord' >> require 'action_controller' >> require 'hobofields' We also need to get ActiveRecord set up with a database connection >> mysql_database = "hobofields_doctest" >> system("mysqladmin create #{mysql_database}") or raise "could not create database" >> ActiveRecord::Base.establish_connection(:adapter => "mysql", :database => mysql_database, :host => "localhost") OK we're ready to get going. ## The migration generator -- introduction The migration generator works by: * Loading all of the models in your Rails app * Using the Rails schema-dumper to extract information about the current state of the database. * Calculating the changes that are required to bring the database into sync with your application. Normally you would run the migration generator as a regular Rails generator. You would type $ script/generator hobo_migration in your Rails app, and the migration file would be created in `db/migrate`. In order to demonstrate the generator in this doctest script however, we'll be using the Ruby API instead. The method `HoboFields::MigrationGenerator.run` returns a pair of strings -- the up migration and the down migration. At the moment the database is empty and no ActiveRecord models exist, so the generator is going to tell us there is nothing to do. >> HoboFields::MigrationGenerator.run => ["", ""] ### Models without `fields do` are ignored The migration generator only takes into account classes that use HoboFields, i.e. classes with a `fields do` declaration. Models without this are ignored: >> class Advert < ActiveRecord::Base; end >> HoboFields::MigrationGenerator.run => ["", ""] ### Create the table Here we see a simple `create_table` migration along with the `drop_table` down migration >> class Advert < ActiveRecord::Base fields do name :string end end >> up, down = HoboFields::MigrationGenerator.run >> up =>"" create_table :adverts do |t| t.string :name end >> down => "drop_table :adverts" Normally we would run the generated migration with `rake db:create`. We can achieve the same effect directly in Ruby like this: >> ActiveRecord::Migration.class_eval up >> Advert.columns.*.name => ["id", "name"] We'll define a method to make that easier next time >> def migrate(renames={}) up, down = HoboFields::MigrationGenerator.run(renames) puts up ActiveRecord::Migration.class_eval(up) ActiveRecord::Base.send(:subclasses).each { |model| model.reset_column_information } [up, down] end We'll have a look at the migration generator in more detail later, first we'll have a look at the extra features HoboFields has added to the model. ### Add fields If we add a new field to the model, the migration generator will add it to the database. >> class Advert fields do name :string body :text published_at :datetime end end >> up, down = migrate >> up =>"" add_column :adverts, :body, :text add_column :adverts, :published_at, :datetime >> down =>"" remove_column :adverts, :body remove_column :adverts, :published_at >> ### Remove fields If we remove a field from the model, the migration generator removes the database column. Note that we have to explicitly clear the known fields to achieve this in rdoctest -- in a Rails context you would simply edit the file >> Advert.field_specs.clear # not normally needed class Advert < ActiveRecord::Base fields do name :string body :text end end >> up, down = migrate >> up => "remove_column :adverts, :published_at" >> down => "add_column :adverts, :published_at, :datetime" ### Rename a field Here we rename the `name` field to `title`. By default the generator sees this as removing `name` and adding `title`. >> Advert.field_specs.clear # not normally needed class Advert < ActiveRecord::Base fields do title :string body :text end end >> # Just generate - don't run the migration: >> up, down = HoboFields::MigrationGenerator.run >> up =>"" add_column :adverts, :title, :string remove_column :adverts, :name >> down =>"" remove_column :adverts, :title add_column :adverts, :name, :string >> When run as a generator, the migration-generator won't make this assumption. Instead it will prompt for user input to resolve the ambiguity. When using the Ruby API, we can ask for a rename instead of an add + drop by passing in a hash: >> up, down = HoboFields::MigrationGenerator.run(:adverts => { :name => :title }) >> up => "rename_column :adverts, :name, :title" >> down => "rename_column :adverts, :title, :name" Let's apply that change to the database >> migrate ### Change a type >> Advert.attr_type :title => String >> class Advert fields do title :text body :text end end >> up, down = HoboFields::MigrationGenerator.run >> up => "change_column :adverts, :title, :text, :limit => nil" >> down => "change_column :adverts, :title, :string" ### Add a default >> class Advert fields do title :string, :default => "Untitled" body :text end end >> up, down = migrate >> up => 'change_column :adverts, :title, :string, :default => "Untitled", :limit => 255' >> down => "change_column :adverts, :title, :string" ### Limits >> class Advert fields do price :integer, :limit => 4 end end >> up, down = HoboFields::MigrationGenerator.run >> up "add_column :advert, :integer, :limit => 4" Note that limit on a decimal column is ignored (use :scale and :precision) >> class Advert fields do price :decimal, :limit => 4 end end >> up, down = HoboFields::MigrationGenerator.run >> up => "add_column :adverts, :price, :decimal" Cleanup >> Advert.field_specs.delete :price ### Foreign Keys HoboFields extends the `belongs_to` macro so that it also declares the foreign-key field. >> class Advert belongs_to :category end >> up, down = HoboFields::MigrationGenerator.run >> up => 'add_column :adverts, :category_id, :integer' >> down => 'remove_column :adverts, :category_id' Cleanup: >> Advert.field_specs.delete(:category_id) If you specify a custom foreign key, the migration generator observes that: >> class Advert belongs_to :category, :foreign_key => "c_id" end >> up, down = HoboFields::MigrationGenerator.run >> up => 'add_column :adverts, :c_id, :integer' >> down => 'remove_column :adverts, :c_id' Cleanup: >> Advert.field_specs.delete(:c_id) ### Timestamps `updated_at` and `created_at` can be declared with the shorthand `timestamps` >> class Advert fields do timestamps end end >> up, down = HoboFields::MigrationGenerator.run >> up =>"" add_column :adverts, :created_at, :datetime add_column :adverts, :updated_at, :datetime >> down =>"" remove_column :adverts, :created_at remove_column :adverts, :updated_at >> Cleanup: >> Advert.field_specs.delete(:updated_at) >> Advert.field_specs.delete(:created_at) ### Rename a table The migration generator respects the `set_table_name` declaration, although as before, we need to explicitly tell the generator that we want a rename rather than a create and a drop. >> class Advert set_table_name "ads" fields do title :string, :default => "Untitled" body :text end end >> up, down = HoboFields::MigrationGenerator.run(:adverts => :ads) >> up => "rename_table :adverts, :ads" >> down => "rename_table :ads, :adverts" Set the table name back to what it should be and confirm we're in sync: >> class Advert; set_table_name "adverts"; end >> HoboFields::MigrationGenerator.run => ["", ""] ### Drop a table If you delete a model, the migration generator will create a `drop_table` migration. Unfortunately there's no way to fully remove the Advert class we've defined from the doctest session, but we can tell the migration generator to ignore it. >> HoboFields::MigrationGenerator.ignore_models = [ :advert ] Dropping tables is where the automatic down-migration really comes in handy: >> up, down = HoboFields::MigrationGenerator.run >> up => "drop_table :adverts" >> down =>"" create_table "adverts", :force => true do |t| t.text "body" t.string "title", :default => "Untitled" end >> ### Rename a table As with renaming columns, we have to tell the migration generator about the rename. Here we create a new class 'Advertisement'. Remember 'Advert' is being ignored so it's as if we renamed the definition in our models directory. >> class Advertisement < ActiveRecord::Base fields do title :string, :default => "Untitled" body :text end end >> up, down = HoboFields::MigrationGenerator.run(:adverts => :advertisements) >> up => "rename_table :adverts, :advertisements" >> down => "rename_table :advertisements, :adverts" Now that we've seen the renaming we'll switch the 'ignore' setting to ignore that 'Advertisements' class. >> HoboFields::MigrationGenerator.ignore_models = [ :advertisement ] ## STI ### Adding an STI subclass Adding a subclass should introduce the 'type' column and no other changes >> class FancyAdvert < Advert end >> up, down = HoboFields::MigrationGenerator.run >> up => "add_column :adverts, :type, :string" >> down => "remove_column :adverts, :type" Cleanup >> Advert.field_specs.delete(:type) ## Coping with multiple changes The migration generator is designed to create complete migrations even if many changes to the models have taken place. First let's confirm we're in a known state. One model, 'Advert', with a string 'title' and text 'body': >> Advert.connection.tables => ["adverts"] >> Advert.columns.*.name => ["id", "body", "title"] >> HoboFields::MigrationGenerator.run => ["", ""] ### Rename a column and change the default >> Advert.field_specs.clear >> class Advert fields do name :string, :default => "No Name" body :text end end >> up, down = HoboFields::MigrationGenerator.run(:adverts => {:title => :name}) >> up =>"" rename_column :adverts, :title, :name change_column :adverts, :name, :string, :default => "No Name", :limit => 255 >> down =>"" rename_column :adverts, :name, :title change_column :adverts, :title, :string, :default => "Untitled" >> ### Rename a table and add a column >> HoboFields::MigrationGenerator.ignore_models << :advert class Ad < ActiveRecord::Base fields do title :string, :default => "Untitled" body :text created_at :datetime end end >> up, down = HoboFields::MigrationGenerator.run(:adverts => :ads) >> up =>"" rename_table :adverts, :ads add_column :ads, :created_at, :datetime >> ## Cleanup >> system "mysqladmin --force drop #{mysql_database}"