[Why not]

ActiveRecord sans Rails

By Greg

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
begin
  require 'active_record'
rescue LoadError => e
  raise LoadError, "ActiveRecord ne doit pas être installé : #{e.message}"
end
 
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
 
module AR
  Model = ActiveRecord::Base
 
  class SchemaInfo < Model
  end
 
  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
 
  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

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…

Tags: , ,

Une réponse à “ActiveRecord sans Rails”

  1. [...] y a quelques jours, je vous proposais une solution pour utiliser ActiveRecord sans Rails. Comme je vous l’avais indiqué, cette idée n’avait pour seul but que de permettre une [...]

Copyright © 2010 algo::rithmique. All Rights Reserved.
Powered by WordPress.