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 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...