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é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: ActiveRecord, Capcode, Ruby

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