Migraties, data en model classes
Geplaatst door Remco van 't Veer vr, 22 jun 2007 14:01:00 GMT
Migraties is m’n op een na favoriete Rails ingrediënt. Het is ook het onderdeel wat me al te veel lastige problemen heeft bezorgd. Natuurlijk kan je tests schrijven voor je migraties met behulp van MigrationTestHelper. Maar daar tackle je de hoofd brekers niet mee; data migraties en constant evoluerende model classes.
Een voorbeeld!
Stel je voor, je zit met een collega plezierig te hacken aan een waanzinnige web app. Jullie hebben een hele serie models geïntroduceerd. Uiteraard met migraties.
Het is mooi weer, de vakantie dagen worden weer her en der opgenomen en ook je collega gaat er lekker op uit voor een paar weken. Jij blijft enthousiast werken aan die killer app en brouwt de volgende migratie:
class CreateSpecifications < ActiveRecord::Migration
def self.up
create_table :specifications do |t|
t.column :product_id, :integer
t.column :description, :text
t.column :color, :string
end
Product.find(:all).each do |p|
Specification.create!(
:product_id => p.id, :description => p.specifications
)
end
remove_column :products, :specifications
end
Niet veel bijzonders; de specifications
column wordt in een eigen tabel ondergebracht met een nieuwe column voor de kleur.
Een paar dagen later belt je klant om te melden dat de product specificaties van een ander site gaan komen en dat er een URL in plaats van een omschrijving en kleur aan een product gekoppeld moet worden.
Geen probleem, denk je. Wij zijn Agile en al die dingen. Dus je gaat aan de slag; een nieuwe migratie.
class DropSpecifications < ActiveRecord::Migration
def self.up
add_column :products, :specifications_link, :string
drop_table :specifications
end
Daarna verwijder je de Specification
class en past je controllers en views aan en knutselt weer lekker verder.
Op maandag komt je collega, gebruind en uitgerust, weer aanschuiven. Na koffie en een babbeltje, svn up
, “aha migraties!”, rake db:migrate
, BOEM! Migratie CreateSpecifications
faalt omdat de Specification
class niet bestaat..
Met je collega kom je er wel uit. Je belooft kroketten voor de lunch te halen en na het tijdelijk verwijderen van de falende code is hij weer op de rit. Het probleem had zich echter ook voor kunnen doen bij deployment naar de productie omgeving, cap deploy_with_migrations
. Capistrano mag dan rollbacks doen maar niet voor migraties! Productie site down, klant ongelukkig, stress..
Er zijn nog veel meer scenario’s waarbij data migraties de mist in kunnen gaan; de introductie van validaties (met migratie om de gaatjes te vullen) waardoor de migratie van een paar dagen geleden geen create!
meer kan doen, velden die virtuele velden worden en daarmee geen setter meer hebben, het gebruik van velden welke nog niet bekent zijn in een eerder geïnstanceerde model class enzovoort.
Natuurlijk kan je bij het aanpassen van je models ook je migraties nalopen maar hoe Agile zijn we dan nog eigenlijk? Is het niet veel beter om dit soort afhankelijkheden gewoon te voorkomen?
Er is een veilige manier om migraties te doen, met de meer low-level execute
methode en varianten. Laten we de CreateSpecifications
migratie eens herschrijven;
class CreateSpecifications < ActiveRecord::Migration
def self.up
create_table :specifications do |t|
t.column :product_id, :integer
t.column :description, :text
t.column :color, :string
end
select_all('SELECT * FROM products').each do |row|
execute %Q{
INSERT INTO specifications (product_id, description)
VALUES (#{row['id']}, #{quote(row['specifications'])})
}
end
remove_column :products, :specifications
end
Op deze manier zijn migraties alleen maar afhankelijk van het huidige schema in de database. De select_all
methode geeft de mogelijkheid queries te draaien en geeft daarbij een Array
van Hash
’s terug, met execute
kan je je INSERT
’s en UPDATES
’s uitvoeren en quote
zorgt ervoor dat je op een veilige manier je statements op kunt bouwen.
Er zit helaas een groot nadeel aan deze aanpak: je migraties zijn database type specifiek geworden, in dit voorbeeld MySQL. PostgreSQL en andere databases gebruiken bijvoorbeeld een sequence om ID’s te maken voor je records, dat zul je dus bij de INSERT
zelf moeten doen. Daarbij komt dat je allemaal dingen over SQL moet weten die je dankzij ActiveRecord helemaal niet hoeft te weten.
Een alternatief is in je migratie class zelf ActiveRecord classes te definiëren die je nodig hebt. Dat kan er als volgt uitzien:
class CreateSpecifications < ActiveRecord::Migration
class Product13 < ActiveRecord::Base
set_table_name 'products'
end
class Specification13 < ActiveRecord::Base
set_table_name 'specifications'
end
def self.up
create_table :specifications do |t|
t.column :product_id, :integer
t.column :description, :text
t.column :color, :string
end
Product13.find(:all).each do |p|
Specification13.create!(:product_id => p.id, :description => p.specification)
end
remove_column :products, :specifications
end
Merk op dat ik in plaats van Product
, Product13
definieer en set_table_name
gebruik om naar de juiste tabel te verwijzen. Om te voorkomen dat in een eerder gedraaide migratie, tijdens het draaien van rake db:migrate
, bijvoorbeeld Product
al gedefinieerd is en nog met het oude schema opgezadeld zit, plak ik het migratie nummer (in dit geval 13) aan de class naam om hem uniek te maken. Ik weet nu zeker dat ik een ActiveRecord class heb welke het huidige schema gebruikt.
Het resultaat is een stuk leesbaarder dan met de “bak je eigen SQL” aanpak, en je database onafhankelijkheid is weer terug. Jammer genoeg werken polymorphische relaties en single-table-inheritance niet, maar daar valt wel om heen te werken. Daarnaast zijn, door de afwijkende class namen, de declaraties van associaties, als je die nodig hebt in je migratie, wat hariger omdat class namen, foreign keys e.d. niet meer aan de conventies voldoen en dus niet automatisch afgeleid kunnen worden.
Wat mij betreft moet hoofdstuk 16.4 “Data Migrations” uit “Agile Web Development with Rails” nodig herschreven worden. Er liggen zoveel fouten op de loer als je de models uit je applicatie gebruikt in je migraties dat het simpelweg afgeraden zou moeten worden.
Ofwel “gebruik GEEN applicatie models in je migraties!“
Eens.
Een ander probleem waar ik wel eens tegen aan loop: Je voegt eerst een column toe en dan ge je data gaat toevoegen. Je krijgt dan een error dat Rails de nieuwe kolom niet kent.
Een tussentijdse Location.reset_column_information kan meestal wel uitkomst bieden.
Als je postgresql gebruikt is dit ook wel een handige plugin: http://www.redhillonrails.org/#transactional_migrations