Follow me
RSS feed
My sources
My Viadeo

Sequel sans... séquelle ;)

Greg | 12 Nov 2009

rubyIl 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 utilisation d'Active Record dans Capcode. C'est maintenant au tour de Sequel...

Contrairement à ActiveRecord, Sequel n'a pas été développé pour un framework. Ce n'est pas non plus (initialement) un ORM, mais une librairie d'accès pour des bases de données, un peu comme DBI. Cependant, contrairement à ce dernier, il se rapproche de DataMapper ou ActiveRecord par sont côté SQL dissimulé. Bref, Sequel se situe entre DBI et ActiveRecord/DataMapper.

Voici un petit exemple :

require "rubygems"
require "sequel"

# Connection à une base SQLite 
DB = Sequel.connect( "sqlite://test.db" )

# Création d'une table 
DB.create_table :users do
  primary_key :id
  String :login
  String :password
end

# Ajout de données dans la table
DB[:users].insert( :login => 'Muriel', :password => 'leiruM' )
DB[:users].insert( :login => 'Greg', :password => 'gerG' )

# Affichage du contenu de la table 
DB[:users].all.each do |record|
  puts "#{record[:login]} : #{record[:password]}"
end

J'ai dit un peu plus haut que Sequel n'était pas un ORM. Ce n'est pas tout à fait vrai. En effet, il offre la possibilité d'être utilisé comme tel :

require "rubygems"
require "sequel"

# Connection à une base SQLite 
DB = Sequel.connect( "sqlite://test.db" )

# Création d'une table 
DB.create_table :users do
  primary_key :id
  String :login
  String :password
end

# Mapping
class User < Sequel::Model
end

# Ajout de données dans la table
User.insert( :login => 'Muriel', :password => 'leiruM' )
User.insert( :login => 'Greg', :password => 'gerG' )

# Affichage du contenu de la table 
User.all.each do |record|
  puts "#{record.login} : #{record.password}"
end

Bon, mais quel est l'objectif recherché me demanderez-vous ? Et bien simplement de palier à certains petits défauts de conception qui me dérange dans Sequel.

Déclaration des modèles

Il est impossible dans Sequel de déclarer un modèle si la table correspondante n'existe pas, ou, tout au moins, si la connexion à la base n'a pas été faite avant cette déclaration. Ainsi, faire ceci provoque une erreur :

# Mapping
class User < Sequel::Model
end

# Connection à une base SQLite 
DB = Sequel.connect( "sqlite://test.db" )

DB.create_table :users do
  primary_key :id
  String :login
  String :password
end

# => Sequel::Error: No database associated with Sequel::Model

Si par contre nous faisons la connexion avant, cela semble fonctionner :

# Connection à une base SQLite 
DB = Sequel.connect( "sqlite://test.db" )

# Mapping
class User < Sequel::Model #:users
end

DB.create_table :users do
  primary_key :id
  String :login
  String :password
end

Je dis bien semble, car en fait le comportement de la classe User n'est pas le même si la table existe avant la déclaration du modèle. Ainsi, si vous faites ceci :

# Connection à une base SQLite 
DB = Sequel.connect( "sqlite://test.db" )

DB.create_table :users do
  primary_key :id
  String :login
  String :password
end

# Mapping
class User < Sequel::Model
end

Vous pourrez utiliser la classe User en considérant les champs de la table users comme des accesseurs de cette classe :

User.all.each do |user|
  puts user.login
  puts user.password
end

Si par contre vous créez la table après avoir déclaré le modèle, votre classe User n'a plus le même comportement et vous devez passer par la méthode values de la classe User qui renvoie un hashage des données :

# Connection à une base SQLite 
DB = Sequel.connect( "sqlite://test.db" )

# Mapping
class User < Sequel::Model #:users
end

DB.create_table :users do
  primary_key :id
  String :login
  String :password
end

# ...

# Affichage du contenu de la table 
User.all.each do |record|
  puts record.values[:login]
  puts record.values[:password]
end

Tout cela n'est pas très rigoureux et pas réellement exploitable.

Création de table et migration

Pour la création des tables, là encore j'ai un problème. En effet, comme vous avez pu le constater, la méthode create_table est une méthode d'instance de Sequel::Database récupérée via une connexion (Sequel.connect). Ceci implique que nous ne pouvons proposer un schéma de table qu'après avoir initialisé la connexion à la base. Si cela semble tout à fait logique, cela peut entrainer pas mal de contraintes dans certains cas. Oui, mais pas avec Capcode ! Effectivement, les méthodes Capcode.run et Capcode.application prenant en paramètre un bloc, il est possible de créer les tables dans ce bloc. Cependant, cela rompt avec la philosophie souhaitée pour l'utilisation de ce bloc1 qui veut que l'on réserve ce bloc pour y placer des éléments de configuration de l'application (remplissage de table, ...) et non pas du code pour l'application.

Si vous avez l'habitude d'utiliser ActiveRecord ou DataMapper ce type de problème ne vous aura jamais dérangé parce que dans le premier cas vous utilisez les migrations, et donc vous générez le schéma à priori, et dans le second, le schéma fait partie intégrante du modèle et il est donc créé automatiquement2.

Heureusement3 Sequel offre la possibilité d'utiliser des migrations. Pour cela, vous avez plusieurs solutions. Soit définir vos migrations dans des fichiers et utiliser l'option -m de l'outil sequel, soit utiliser la méthode apply de Sequel::Migration. Grâce à cette dernière possibilité, nous pouvons résoudre le problème évoqué en décrivant le schéma dans une migration et en faisant la migration juste après la connexion :

require "rubygems"
require "sequel"

# Chargement de l'extension permettant les migrations
Sequel.extension :migration

# Migration
class CreateUser < Sequel::Migration
  def up
    create_table :users do
      primary_key :id
      String :login
      String :password
    end
  end
  
  def down
    drop_table :users
  end
end

# Connection à une base SQLite 
DB = Sequel.connect( "sqlite://test.db" )

# Mise en place du schéma
CreateUser.apply( DB, :up )

So what ?

Avec tout ce que nous venons de voir, nous sommes en mesure de mettre en place une classe mimant ce que nous avons fait avec ActiveRecord mais pour Sequel. Donc nous pouvons repartir d'un exemple similaire4 :

require 'rubygems'
require 'sq'

# Mise en place des migrations (versionnées)
class CreateUsers < SQ::Schema 1.0
  def up
    create_table :users do
      String :login
      String :password
    end
  end
 
  def down 
    drop_table :users
  end
end

class CreatePosts < SQ::Schema 1.1
  def up
    create_table :posts do
      String :title
      String :body
    end
  end
 
  def down 
    drop_table :posts
  end
end

# Création des modèles
class User < SQ::Model
end

# Connexion
SQ.db_connect( "test.yml", "test.log" )

# Utilisation
User.insert(:login => 'Muriel', :password => 'leiruM')
User.insert(:login => 'Greg', :password => 'gerG')

User.each do |d|
  puts u[:login]
end

La mise en place des schémas est quasiment identique à ce que nous avons fait avec ActiveRecord et nous allons encore une fois déclarer une méthode de classe qui renverra une classe qui sera, dans le cas présent, de type Sequel::Migration. Par la suite, dans la méthode de connexion (SQ.db_connect), nous suivrons exactement le même procédé que celui imaginé pour ActiveRecord, en s'adaptant juste au comportement de Sequel :

module SQ
  class << self
    def Schema( n )
      @final = [n, @final.to_f].max
      m = (@migrations ||= [])
      Class.new(Sequel::Migration) do
        meta_def(:version) { n }
        meta_def(:inherited) { |k| m << k }
      end
    end
  
    def db_connect( dbfile, logfile )
      dbconfig = YAML::load(File.open(dbfile)).keys_to_sym
      dbconfig[:adapter] = "sqlite" if dbconfig[:adapter] == "sqlite3"
      version = dbconfig.delete(:schema_version) { |_| @final }
      
      db = Sequel.connect(@cnx)
      
      if @migrations
        db.create_table? :schema_table do
          Float :version
        end
        si = db[:schema_table].first || (db[:schema_table].insert(:version => 0)
 {:version => 0})
        @migrations.each do |k|
          k.apply(db, :up) if si[:version] < k.version and k.version <= version
          k.apply(db, :down) if si[:version] >= k.version and k.version > version
        end
        db[:schema_table].where(:version => si[:version]).update(:version => version)
      end
    end
  end
end

Nous allons cependant devoir faire quelques adaptations mineures dans ce code pour régler le cas des modèles. Il n'est en effet pas possible d'utiliser les modèles proposés par Sequel, mais ceci ne nous empêche pas de les mimer. Pour cela nous allons créer de toutes pièces la classe SQ::Model et nous ferons en sorte qu'elle réponde, via ses méthodes de classe, aux mêmes méthodes que Sequel::Dataset. Par example la classe User répondra aux même méthodes que DB[:users]. Or :users == User.to_s.tableize.to_sym, ce qui rende la mise en place de la classe SQ::Model extrêmement simple :

module SQ
  class Model
    def self.method_missing( name, *args, &block )
      if block_given?
        SQ::db[self.to_s.tableize.to_sym].__send__(name.to_sym, *args, &block)
      else
        SQ::db[self.to_s.tableize.to_sym].__send__(name.to_sym, *args)
      end
    end
  end
end

Le seul élément vraiment remarquable dans ce code est l'utilisation de SQ::db sensé représenter la connexion. Et c'est là que nous devons modifier le code de la partie schéma pour permettre de partager la connexion au sein du module SQ. Pour cela nous mettons en place la méthode suivante :

module SQ
  class << self
    def db
      @db ||= Sequel.connect(@dbconfig)
    end
  end
end

Puis nous supprimons la ligne db = Sequel.connect(@cnx) dans SQ::db_connect, nous remplaçons partout l'utilisation de la variable db par SQ::db et nous modifions la variable dbconfig en la passant en variable d'instance. Finalement, le code complet est le suivant :

 1 begin
 2   require 'sequel'
 3   Sequel.extension :migration
 4   Sequel.extension :inflector
 5 rescue LoadError => e
 6   raise LoadError, "Sequel ne doit pas être installé : #{e.message}"
 7 end
 8 
 9 class Object
10   def meta_def(m,&b)
11     (class<<self
12 self end).send(:define_method,m,&b)
13   end
14 end
15 
16 class Hash
17   def keys_to_sym
18     self.each do |k, v|
19       self.delete(k)
20       self[k.to_s.to_sym] = v
21     end
22   end
23 end
24 
25 module SQ
26   class Model
27     def self.method_missing( name, *args, &block )
28       if block_given?
29         SQ::db[self.to_s.tableize.to_sym].__send__(name.to_sym, *args, &block)
30       else
31         SQ::db[self.to_s.tableize.to_sym].__send__(name.to_sym, *args)
32       end
33     end
34   end
35   
36   class << self
37     def db
38       @db ||= Sequel.connect(@dbconfig)
39     end
40 
41     def Schema( n )
42       @final = [n, @final.to_f].max
43       m = (@migrations ||= [])
44       Class.new(Sequel::Migration) do
45         meta_def(:version) { n }
46         meta_def(:inherited) { |k| m << k }
47       end
48     end
49 
50     def db_connect( dbfile, logfile )
51       @dbconfig = YAML::load(File.open(dbfile)).keys_to_sym
52       @dbconfig[:adapter] = "sqlite" if @dbconfig[:adapter] == "sqlite3"
53       version = dbconfig.delete(:schema_version) { |_| @final }
54       
55       if @migrations
56         SQ::db.create_table? :schema_table do
57           Float :version
58         end
59         si = SQ::db[:schema_table].first || (SQ::db[:schema_table].insert(:version => 0)
60  {:version => 0})
61         @migrations.each do |k|
62           k.apply(SQ::db, :up) if si[:version] < k.version and k.version <= version
63           k.apply(SQ::db, :down) if si[:version] >= k.version and k.version > version
64         end
65         SQ::db[:schema_table].where(:version => si[:version]).update(:version => version)
66       end
67     end
68   end
69 end

Notez que nous utilisons l'extension inflector afin de pouvoir utiliser la méthode String.tableize et que nous prenons en compte (ligne 51) le fait que Sequel permet d'utiliser SQLite en nommant sont adaptateur sqlite là ou les autres utilisent sqlite3.

Voilà, tout ceci a été mis en place dans Capcode et un petit example illustre comment l'utiliser.

1 Laquelle ?
2 Tout ceci est extrêmement schématique, je sais, mais je ne m'étendrais pas plus sur le sujet.
3 Faudrait savoir ! Un coup on aime, un coup on aime pas les migrations !
4 Pour des questions de bonne compréhension, nous placerons notre code dans le module SQ là ou nous utilisions AR...

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.