Migrationspfad von HABTM zu has_many :through

In Ruby on Rails gibt es zwei fundamentale Ansätze eine N:M Beziehung umzusetzen: has_and_belongs_to_many und has_many :through. Beide haben Vor-und Nachteile. Das heißt aber auch, beide Assoziationen haben ihre Berechtigung.
Es hält sich aber hartnäckig das Vorurteil, dass has_many :through grundsätzlich vorzuziehen wäre, weil sie flexibler ist. Es stimmt, sie ist flexibler. Das has_many :through Modell kann bei Bedarf mit weiteren Attributen versehen werden, ohne die Assoziation ändern zu müssen. Allerdings ist das nicht immer notwendig. Zumal has_and_belongs_to_many leichtgewichtiger ist und einen wesentlich geringeren footprint hat.
Die Angst vor einer eventuell notwendigen Strukturänderung sollte keinesfalls der Grund sein, sich für has_many :through zu entscheiden. Angst ist immer ein schlechter Berater.
Anhand eines Beispiels wird gezeigt, wie einfach und natürlich die Migration von einer bestehenden has_and_belongs_to_many Assoziation zu einer has_many :through Assoziation ist.
Ausgehend von Usern, die mehrere Adressen haben können und in jeder Adresse mehrere User wohnen können:

class User < ActiveRecord::Base
  has_and_belongs_to_many :addresses
end

class Address < ActiveRecord::Base
  has_and_belongs_to_many :users
end

Diese Beziehung ist schnell aufgesetzt, da sie standardgemäß lediglich die beiden Attribute user_id und address_id in einer Zwischentabelle mit dem Namen addresses_users erfordert:

$ rails g migration CreateAddressesUsersJoinTable users addresses && rake db:migrate

Fertig.
Diese bestehende Beziehung im Bedarfsfall in eine has_many :through zu migrieren ist sehr einfach.

1. Das Zwischenmodell

Zunächst das Modell für die Zwischentabelle user_addresses mit zusätzlichen Zeitstempeln:

$ rails g migration UserAddress user:belongs_to address:belongs_to && rake db:migrate

erzeugt und führt diese Migration aus:

class CreateUserAddresses < ActiveRecord::Migration                             
  def change                                                                    
    create_table :user_addresses do |t|                                         
      t.belongs_to :user, index: true, foreign_key: true                        
      t.belongs_to :address, index: true, foreign_key: true                     
                                                                                
      t.timestamps null: false                                                  
    end                                                                         
  end                                                                           
end  

2. Setzen der has_many :through Assoziation

Damit die neue has_many :through Assoziation eindeutig gesetzt werden kann, muss allerdings die alte HABTM Beziehung temporär umbenannt werden (eine Seite der Beziehung reicht). Das bereitet das Kopieren der Daten (Assoziationslinks) vor:

class User < ActiveRecord::Base
  has_and_belongs_to_many :deprecated_addresses, 
    join_table: :addresses_users, class_name: Address, 
    association_foreign_key: :address_id
  has_many :user_addresses
  has_many :addresses, through: :user_addresses
end

3. Migrieren der bestehenden Daten

Dieser Schritt ist notwendig, um die Assoziationslinks zu kopieren. Die Migrationsdatei:

$ rails g migration MigrateAddressesUsers

erstellt UserAddress Objekte:

class MigrateAdressesUsersAssociations < ActiveRecord::Migration                
  def change                                                                    
    User.transaction do                                                         
      User.find_each do |user|
        user.addresses = user.deprecated_addresses                            
      end                                                                       
    end                                                                         
  end                                                                           
end

Es macht absolut Sinn, die Daten der Zwischentabelle in Batches zu migrieren. Standardmäßig greift sich find_each 1000 Datensätze. Wie umfangreich die Batches sein sollten, hängt natürlich von dem jeweiligen Fall ab.
Die erfolgte Migration der Beziehungen kann nun deployed werden. Das Abräumen der deprecated gewordenen HABTM Beziehung sollte in einem weiteren deploy erfolgen.

5. Löschen der HABTM Tabelle und Beziehung

Im 2. deploy muss die Migrationsdatei zum Löschen der überflüssig gewordenen HABTM Tabelle erstellt werden:

$ rails g migration DropTableAddressesUsers

Sie sollte den Inhalt haben:

class DropTableAddressesUsers < ActiveRecord::Migration
  def change
    drop_table :addresses_users
  end
end

Schließlich die Migration ausführen:

$ rake db:migrate

Und die beiden Modelle ohne die HABTM Beziehungen:

class User < ActiveRecord::Base
  has_many :user_addresses
  has_many :addresses, through: :user_addresses
end

class Address < ActiveRecord::Base
  has_many :user_addresses
  has_many :users, through: :user_addresses
end

Zusammenfassung

Der Prozess der Migration einer has_and_belongs_to_many Beziehung zu einer has_many :through Beziehung sieht erstmal umfangreicher aus, als er ist. Einige Schritte sind ohnehin erforderlich, wenn sich von Vornherein für die has_many :through Assoziation entschieden wird. Eigentlich entsteht nur der zusätzliche Aufwand für das Migrieren der Links (Assoziationsdaten). Auch das Aufteilen der Migration in 2 deploys ist nur notwendig, weil die bereits entstandenen Assoziationen gesichert kopiert werden müssen.
Grundsätzlich sollte sich immer für HABTM entschieden werden, es sei denn, es ist wahrscheinlich, dass die Beziehung weitere Attribute haben soll. Denn eine dedizierte HABTM Beziehung ist performanter und eine Migration zu einer has_many :through Beziehung ist unproblematisch, wenn sie denn überhaupt notwendig wird.