ActiveRecord sans Rails
Si 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émaEn 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 migrationPour 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
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...