Sequel sans… séquelle ;)
Il 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 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 63 64 65 66 67 | begin require 'sequel' Sequel.extension :migration Sequel.extension :inflector rescue LoadError => e raise LoadError, "Sequel 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 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 class << self def db @db ||= Sequel.connect(@dbconfig) end 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 } if @migrations SQ::db.create_table? :schema_table do Float :version end si = SQ::db[:schema_table].first || (SQ::db[:schema_table].insert(:version => 0); {:version => 0}) @migrations.each do |k| k.apply(SQ::db, :up) if si[:version] < k.version and k.version <= version k.apply(SQ::db, :down) if si[:version] >= k.version and k.version > version end SQ::db[:schema_table].where(:version => si[:version]).update(:version => version) end end end 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…
