Follow me
RSS feed
My sources
My Viadeo

ActiveRecord sans Rails

Greg | 07 Nov 2009

rubySi vous recherchez sur Google comment utiliser ActiveRecord sans Rails, vous verrez qu'il existe pas mal de pages sur le sujet. Le problème est que l'on vous y raconte toujours la même histoire, à savoir comment établir une connexion vers une base existante. Quant à savoir comment créer le schéma de la base, c'est une autre histoire. Là encore, Google nous permet de trouver quelques pistes, mais rien de bien satisfaisant. A part peut-être le premier résultat qui propose d'utiliser les migrations dans des fichiers séparés, un Rakefile, bref une artillerie un peu lourde1. Voyons comment faire plus simple.

ActiveRecord

Pour utiliser ActiveRecord nous avons besoin d'au moins2 deux choses : un schéma et un modèle. En général les schémas sont mis en place via les migrations. Pour cela, Rails créés dans le répertoire db/migrate des fichiers définissant des classes héritant de ActiveRecord::Migration. Les modèles sont, eux, placés dans le répertoire app/models de l'application Rails. Ils définissent des classes héritant d'ActiveRecord::Base. En général, modèles et migrations sont créés en même temps. Quand vous créez un modèle, Rails génère en même temps une migration :

$ ruby script/generate model user
      exists  app/models/
      exists  test/unit/
      exists  test/fixtures/
      create  app/models/user.rb
      create  test/unit/user_test.rb
      create  test/fixtures/users.yml
      create  db/migrate
      create  db/migrate/20091106191317_create_users.rb
$

Le fichier de migration ainsi généré contiendra le code suivant :

class CreateUsers < ActiveRecord::Migration
  def self.up
    create_table :users do |t|

      t.timestamps
    end
  end

  def self.down
    drop_table :users
  end
end

Le fichier du modèle sera lui beaucoup plus court :

class User < ActiveRecord::Base
end

Si nous voulons utiliser notre modèle, il faut maintenant faire deux petites choses.

1. Compléter le schéma

En fait de schéma, c'est bien au fichier de migration que je pense. En effet, pour le moment notre table users ne contient que les champs de timestamps, et il faut donc y ajouter les champs permettant de définir un utilisateur.

2. Faire la migration

Pour cela il faut lancer un rake db:migrate

Une fois cela fait, nous pouvons utiliser le modèle User dans nos contrôleurs.

Comme vous avez dû le comprendre au début de ce post, je trouve tout cela un peu lourd. Non seulement il faut, exploser les choses en une multitude de fichiers, mais il faut en plus un Rackfile. En ce qui me concerne j'aimerai pouvoir lancer une application Ruby utilisant ActiveRecord, que la base soit créée (ou mise à jour) s'il y a besoin, et tout cela, en ayant la possibilité de tout mettre dans un seul fichier... Bref, idéalement je voudrais pouvoir faire cela :

require 'rubygems'
require 'active_record'

class DBSchema < ActiveRecord::Migration
  def self.up
    create_table :users do |t|
      t.string :login
      t.string :password
    end
  end
  
  def self.down 
    drop_table :users
  end
end

class User < ActiveRecord::Base
end

ActiveRecord::Base.establish_connection( "test.yml" )

User.new( :login => "Muriel", :password => "leiruM" ).save
User.new( :login => "Greg", :password => "gerG" ).save

User.all.each do |u|
  puts u.login
end
# => Muriel
# => Greg

Malheureusement, ce n'est pas possible3. J'ai donc développé une petite surcouche permettant de faire ceci :

require 'rubygems'
require 'ar'

class DBSchema < AR::Schema 1.0
  def self.up
    create_table :users do |t|
      t.string :login
      t.string :password
    end
  end
  
  def self.down 
    drop_table :users
  end
end

class User < AR::Model
end

AR.db_connect( "test.yml", "test.log" )

User.new( :login => "Muriel", :password => "leiruM" ).save
User.new( :login => "Greg", :password => "gerG" ).save

User.all.each do |u|
  puts u.login
end
# => Muriel
# => Greg

Création du schéma

Comme vous pouvez le voir, je n'utilise pas ActiveRecord::Migration mais AR::Schema, ce qui semble plus logique puisqu'il s'agit bien de définir un schéma de base. Notez que ce schéma est défini avec un numéro de version4. Pour faire cela, nous allons faire un peu de métaprogrammation. Dans les faits, AR::Schema n'est pas un classe, mais une méthode de classe qui renvoie une classe de type ActiveRecord::Migration :

module AR
  # ...

  def self.Schema( n )
    @final = [n, @final.to_f].max
    m = (@migrations ||= [])
    Class.new(ActiveRecord::Migration) do
      meta_def(:version) { n }
      meta_def(:inherited) { |k| m << k }
    end
  end

  # ...
end

Dans la classe de type ActiveRecord::Migration que nous renvoyons, nous définissons deux méthodes de classes : version et inherited. La première sert à stocker la version du schéma. La seconde est utilisée pour mettre à jour la liste des classes de migration définies. Ainsi si nous reprenons l'exemple ci-dessus :

class DBSchema < AR::Schema 1.0
  def self.up
    create_table :users do |t|
      t.string :login
      t.string :password
    end
  end
  
  def self.down 
    drop_table :users
  end
end

Dans AR, la variable @migrations sera égale à [DBSchema], @final sera égale à 1.0 et DBSchema.version sera égal à 1.0. Nous reviendrons plus tard sur l'intérêt de la variable @final.

Création du modèle

Là où nous avons l'habitude d'utiliser ActiveRecord::Base, je propose AR::Model. Là encore, ce n'est qu'une question de logique puisque nous définissons bien un modèle.

La mise en place d'AR::Model est on ne peut plus simple :

module AR
  Model = ActiveRecord::Base
  # ...
end

Connexion et Migration

Il ne reste plus qu'à définir la méthode AR.db_connect. Dans cette méthode, nous devons faire deux choses : créer la connexion puis appliquer les migrations.

La première partie est relativement simple et passe par l'utilisation d'ActiveRecord::Base.establish_connection.

Pour la gestion des migrations, nous avons un peu plus de travail. En effet, pour savoir si une migration s'applique (et dans quel sens (up ou down), nous devons savoir quelle version du schéma existe (s'il existe). Si vous regardez comment Rails gère cela, vous verrez que dans la base de vos applications se trouve une table schema_migrations contenant le préfixe (format AAAAMMJJHHMMSS) du fichier de migration le plus proche dans le temps. Nous allons utiliser la même méthode, mais comme nous avons pris le parti de passer un numéro de version à nos schémas, ce sont ces derniers que nous allons utiliser.

Nous devons donc créer un modèle (SchemaInfo) qui nous servira à gérer cette version, avec la table correspondante dans la base. ActiveRecord ne supportant pas de devoir créer une table si elle existe déjà, nous commencerons par vérifier sa présence :

module AR
  # ...

  class SchemaInfo < Model
  end

  class << self
    def db_connect( dbfile, logfile )
      dbconfig = YAML::load(File.open(dbfile)).keys_to_sym

      # ...
      
      ActiveRecord::Base.establish_connection(dbconfig)
      ActiveRecord::Base.logger = Logger.new(logfile)
      
      if @migrations
        unless SchemaInfo.table_exists?
          ActiveRecord::Schema.define do
            create_table SchemaInfo.table_name do |t|
              t.column :version, :float
            end
          end
        end

        # ...
      end
    end
  end
end
Ceci étant, nous pouvons maintenant jouer avec les numéros de version. Cependant, pour pouvoir gérer pleinement

les migrations il peut être utile de préciser quelle version de schéma nous voulons utiliser. Par défaut, nous partirons du principe que c'est le schéma de plus haute version qui sera pris en compte, mais nous devons permettre de préciser la version du schéma à utiliser. Pour cela je vous propose de rajouter cette information dans le fichier de configuration de la base en y ajoutant l'item schema_version :

adapter: sqlite3
database: test.db
schema_version: 1.0

Nous pouvons maintenant récupérer ce numéro de version dans AR.db_connect :

module AR

  # ...

  class << self
    def db_connect( dbfile, logfile )
      dbconfig = YAML::load(File.open(dbfile)).keys_to_sym
      version = dbconfig.delete(:schema_version) { |_| @final }

      # ...

    end
  end
end

Il ne reste plus qu'à lancer les migrations en fonction de la version. Ainsi, pour chaque schéma, si la version du schéma existant est inférieure à la version du schéma courant et que la version du schéma courant et inférieure ou égale à la version souhaitée, alors nous migrons vers le haut (up). Si la version du schéma existant est supérieure ou égale à la version du schéma courant et que la version du schéma courant est supérieure à la version du schéma souhaité, alors nous migrons vers le bas (down). Sinon, on ne fait rien.

Bien entendu, il ne faudra pas oublier de modifier la version du schéma dans la table d'information sur le schéma.

module AR

  # ...

  class << self
    def db_connect( dbfile, logfile )
      dbconfig = YAML::load(File.open(dbfile)).keys_to_sym
      version = dbconfig.delete(:schema_version) { |_| @final }
      
      ActiveRecord::Base.establish_connection(dbconfig)
      ActiveRecord::Base.logger = Logger.new(logfile)
      
      if @migrations
        unless SchemaInfo.table_exists?
          ActiveRecord::Schema.define do
            create_table SchemaInfo.table_name do |t|
              t.column :version, :float
            end
          end
        end
        si = SchemaInfo.find(:first) || SchemaInfo.new(:version => 0)
        @migrations.each do |k|
          k.migrate(:up) if si.version < k.version and k.version <= version
          k.migrate(:down) if si.version >= k.version and k.version > version
        end
        si.update_attributes(:version => version)
      end
    end
  end
end

Pour terminer

Nous avons utilisé les méthodes meta_def et keys_to_sym lors de la création du module AR. Voici comment elles sont définies :

class Object
  def meta_def(m,&b)
    (class<<self
self end).send(:define_method,m,&b)
  end
end

class Hash
  def keys_to_sym
    self.each do |k, v|
      self.delete(k)
      self[k.to_s.to_sym] = v
    end
  end
end

Voici donc le code complet de ar.rb :

 1 begin
 2   require 'active_record'
 3 rescue LoadError => e
 4   raise LoadError, "ActiveRecord ne doit pas être installé : #{e.message}"
 5 end
 6 
 7 class Object
 8   def meta_def(m,&b)
 9     (class<<self
10 self end).send(:define_method,m,&b)
11   end
12 end
13 
14 class Hash
15   def keys_to_sym
16     self.each do |k, v|
17       self.delete(k)
18       self[k.to_s.to_sym] = v
19     end
20   end
21 end
22 
23 module AR
24   Model = ActiveRecord::Base
25   
26   class SchemaInfo < Model
27   end
28   
29   def self.Schema( n )
30     @final = [n, @final.to_f].max
31     m = (@migrations ||= [])
32     Class.new(ActiveRecord::Migration) do
33       meta_def(:version) { n }
34       meta_def(:inherited) { |k| m << k }
35     end
36   end
37   
38   class << self
39     def db_connect( dbfile, logfile )
40       dbconfig = YAML::load(File.open(dbfile)).keys_to_sym
41       version = dbconfig.delete(:schema_version) { |_| @final }
42       
43       ActiveRecord::Base.establish_connection(dbconfig)
44       ActiveRecord::Base.logger = Logger.new(logfile)
45       
46       if @migrations
47         unless SchemaInfo.table_exists?
48           ActiveRecord::Schema.define do
49             create_table SchemaInfo.table_name do |t|
50               t.column :version, :float
51             end
52           end
53         end
54         si = SchemaInfo.find(:first) || SchemaInfo.new(:version => 0)
55         @migrations.each do |k|
56           k.migrate(:up) if si.version < k.version and k.version <= version
57           k.migrate(:down) if si.version >= k.version and k.version > version
58         end
59         si.update_attributes(:version => version)
60       end
61     end
62   end
63 end

Pourquoi vous avoir parlé de tout cela. Et bien simplement parce que c'est ce que j'ai mis en place dans Capcode pour pouvoir utiliser ActiveRecord5.

Ce code s'inspire librement de ce qui est mis en place dans Camping.

1 Ok, par forcement pour un gros projet...
2 Je dis au moins, car nous pouvons avoir plusieurs modèles
3 Notez que je conserve l'utilisation d'un fichier Yaml pour la configuration de la base. 4 J'avais déjà expliqué comment créer ce type de classe.
5 Et pourtant je déteste ActiveRecord et son principe de migration...

Copyright © 2009 - 2011 Grégoire Lejeune.
All documents licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 2.5 License, except ones with specified licence.
Powered by Jekyll.