Les associations sont une part importante d’ActiveRecord. Il s’agit d’éléments
que l’on utilise dans les premières phases d’apprentissage de Rails. Bien que
son utilisation soit simple, l’implémentation est assez compliquée. Je vous
propose d’explorer le fonctionnement en observant le code exécuté à l’appel de
l’association belongs_to afin de comprendre le mécanisme complexe.
Dans cet article, je vais utiliser Pry pour explorer.
Je vous conseille de suivre l’exécution du code tout en lisant l’article. Cela
vous permettra d’avoir accès aux détails à votre guise. J’utilise la version la
plus récente lors de l’écriture de cette article, le 15 février 2015. La
version de Rails à la date de votre lecture peut être différente. Vous devez
spécifier le commit “6a7ac40dab” si vous faite un clone de Rails et
ce lien
pour explorer sur Github.
unlessFile.exist?('Gemfile')File.write('Gemfile',<<-GEMFILE) source 'https://rubygems.org' gem 'rails', github: 'rails/rails', ref: '6a7ac40dab' gem 'arel', github: 'rails/arel' gem 'sqlite3' gem 'pry-byebug' GEMFILEsystem'bundle'endrequire'bundler'Bundler.setup(:default)require'active_record'require'minitest/autorun'require'logger'# This connection will do for database-independent bug reports.ActiveRecord::Base.establish_connection(adapter:'sqlite3',database:':memory:')ActiveRecord::Base.logger=Logger.new(STDOUT)ActiveRecord::Schema.definedocreate_table:posts,force:truedo|t|endcreate_table:comments,force:truedo|t|t.integer:post_idendendclassPost<ActiveRecord::Baserequire'pry';binding.pryhas_many:commentsendclassComment<ActiveRecord::Basebelongs_to:postendclassBugTest<Minitest::Testdeftest_association_stuffpost=Post.create!post.comments<<Comment.create!post.commentsendend
Au lancement du programme, Pry stop l’exécution. Nous pouvons ainsi entrer dans
le code d’ActiveRecord par le module ActiveRecord::Associations. Plus de 1000 lignes de
commentaires sont disponibles pour expliquer les utilisations possibles de la
méthode.
Cette méthode de seulement deux lignes peut paraître simple mais la complexité
est cachée dans la méthode build, de classe
ActiveRecord::Associations::Builder::HasMany. Tout les arguments lui sont
transmis avec, en plus, self, correspondant à la classe du modèle, Post dans
notre cas.
La classe ActiveRecord::Associations::Builder::HasMany fournit des
informations basiques sur l’association, c’est-à-dire son nom et les options
disponibles.
Les informations fournies sont utiles pour les classes parentes. En effet, la
classe hérite de
ActiveRecord::Associations::Builder::CollectionAssociation::CollectionAssociation,
qui hérite de ActiveRecord::Associations::Builder::Association.
Le coeur du traitement se trouve dans la méthode build de la classe
ActiveRecord::Associations::Builder::Association.
12345678910111213141516
From:/home/dougui/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/bundler/gems/rails-6a7ac40dabf4/activerecord/lib/active_record/associations/builder/association.rb@line22ActiveRecord::Associations::Builder::Association.build:21:defself.build(model,name,scope,options,&block)=>22:ifmodel.dangerous_attribute_method?(name)23:raiseArgumentError,"You tried to define an association named #{name} on the model #{model.name}, but "\24:"this will conflict with a method #{name} already defined by Active Record. "\25:"Please choose a different association name."26:end27:28:extension=define_extensionsmodel,name,&block29:reflection=create_reflectionmodel,name,scope,options,extension30:define_accessorsmodel,reflection31:define_callbacksmodel,reflection32:define_validationsmodel,reflection33:reflection34:end
Pour la suite des explications, je vais passer en revue la méthode build ligne
par ligne. Commençons par la première.
La première fonction appelée, model.dangerous_attribute_method? vérifie si le
nom de l’association n’est pas une fonction existante dans ActiveRecord, par
exemple, il n’est possible de créer une association comme belongs_to :save.
Le nom de l’association ne peut également pas être similaire à une méthode
relative aux IDs, belongs_to :id par exemple.
12345678910111213141516
From:/home/dougui/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/bundler/gems/rails-6a7ac40dabf4/activerecord/lib/active_record/associations/builder/association.rb@line22ActiveRecord::Associations::Builder::Association.build:21:defself.build(model,name,scope,options,&block)22:ifmodel.dangerous_attribute_method?(name)23:raiseArgumentError,"You tried to define an association named #{name} on the model #{model.name}, but "\24:"this will conflict with a method #{name} already defined by Active Record. "\25:"Please choose a different association name."26:end27:=>28:extension=define_extensionsmodel,name,&block29:reflection=create_reflectionmodel,name,scope,options,extension30:define_accessorsmodel,reflection31:define_callbacksmodel,reflection32:define_validationsmodel,reflection33:reflection34:end
Comme on peut le voir, build accepte un block qui est transmis à
define_extensions. Le block est passé depuis la définition de l’association
comme celui-ci :
La fonction créer un module avec le block qui est fourni. Comme
son nom l’indique, extension_module_name contient le nom du module. Celui-ci
est composé du nom du modèle et de celui de l’association. Dans l’exemple, nous
obtenons “PostCommentsAssociationExtension”. Le nouveau module créé grâce à
Module.new(&Proc.new). Celui-ci est ensuite ajouté au code de l’application
afin d’être inclus par la suite. Ce module a pour but de recevoir les méthodes
qui vont être créées par la suite. Je trouve que c’est un moyen très astucieux
pour isoler ces méthodes.
Nous pouvons donc passer à la suite.
123456789101112131415
From:/home/dougui/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/bundler/gems/rails-6a7ac40dabf4/activerecord/lib/active_record/associations/builder/association.rb@line22ActiveRecord::Associations::Builder::Association.build:21:defself.build(model,name,scope,options,&block)22:ifmodel.dangerous_attribute_method?(name)23:raiseArgumentError,"You tried to define an association named #{name} on the model #{model.name}, but "\24:"this will conflict with a method #{name} already defined by Active Record. "\25:"Please choose a different association name."26:end27:28:extension=define_extensionsmodel,name,&block=>29:reflection=create_reflectionmodel,name,scope,options,extension30:define_accessorsmodel,reflection31:define_callbacksmodel,reflection32:define_validationsmodel,reflection33:reflection34:end
Je pense que la méthode create_reflection est la plus compliquée. Celle-ci
a pour but de créer un objet qui va contenir les informations relatives à l’association.
12345678910111213141516
From:/home/dougui/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/bundler/gems/rails-698afe1173c6/activerecord/lib/active_record/associations/builder/association.rb@line37ActiveRecord::Associations::Builder::Association.create_reflection:36:defself.create_reflection(model,name,scope,options,extension=nil)=>37:raiseArgumentError,"association names must be a Symbol"unlessname.kind_of?(Symbol)38:39:ifscope.is_a?(Hash)40:options=scope41:scope=nil42:end43:44:validate_options(options)45:46:scope=build_scope(scope,extension)47:48:ActiveRecord::Reflection.create(macro,name,scope,options,model)49:end
La première ligne renvoi une erreur si l’on essai de créer une collection avec
autre chose qu’un Symbol.
Par la suite, on remplace les options par le scope
si c’est un Hash. Cette partie semble être uniquement un hack. Si scope est
un Hash, c’est qu’il s’agit des options. Lors de la création de
l’association, il n’est pas spécifié quel argument représente le scope ou les
options. Il est donc nécessaire de faire ce petit tour de passe-passe pour
les distinguer.
validate_options permet de s’assurer que les options passées à l’association
sont valides. On ne peut donc pas faire une association comme has_many
:comments, something_invalid: :test. Une erreur sera retournée.
Comme son nom l’indique, build_scope construit le scope.
Par défaut, le scope est gradé tel quel. S’il y a un scope et que celui-ci ne
possède par d’argument, il est transformé pour être exécuté par l’instance
plutôt que la classe.
S’il y a une extension, celle-ci est “wrapper” comme ceci :
Grâce à cette méthode, le module créé précédemment est inclus lors de l’appel de
la méthode. Cette partie est plutôt complexe et demanderait un article au
complet. Je vais donc laisser cette partie pour le moment.
Après cet interlude, revenons à l’exécution de create_reflection.
12345678910111213141516
From:/home/dougui/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/bundler/gems/rails-698afe1173c6/activerecord/lib/active_record/associations/builder/association.rb@line48ActiveRecord::Associations::Builder::Association.create_reflection:36:defself.create_reflection(model,name,scope,options,extension=nil)37:raiseArgumentError,"association names must be a Symbol"unlessname.kind_of?(Symbol)38:39:ifscope.is_a?(Hash)40:options=scope41:scope=nil42:end43:44:validate_options(options)45:46:scope=build_scope(scope,extension)47:=>48:ActiveRecord::Reflection.create(macro,name,scope,options,model)49:end
La macro (:has_many), celui de l’association (:comments), le scope, les
options et le modèle sont passés à ActiveRecord::Reflection#create.
Premièrement, on trouve la classe correspondant à la macro, HasManyReflection
dans notre cas. Par la suite, cette classe est instanciée, avec le nom de
l’association, le scope, les options et le modèle. Les informations seront
passées à l’objet créé. HasManyReflection est un objet représentant
l’association. Les informations y sont stockées afin d’y être utilisées dans votre
code ou dans des gems. Pour accéder à la liste des reflections du modèle, il
est possible de faire Post.reflect_on_all_associations. Vous obtiendrez ce
résultat :
On peut comprendre aisément l’utilité d’une telle liste.
À la fin de l’exécution, nous nous retrouvons dans la méthode build.
12345678910111213141516
From:/home/dougui/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/bundler/gems/rails-698afe1173c6/activerecord/lib/active_record/associations/builder/association.rb@line30ActiveRecord::Associations::Builder::Association.build:21:defself.build(model,name,scope,options,&block)22:ifmodel.dangerous_attribute_method?(name)23:raiseArgumentError,"You tried to define an association named #{name} on the model #{model.name}, but "\24:"this will conflict with a method #{name} already defined by Active Record. "\25:"Please choose a different association name."26:end27:28:extension=define_extensionsmodel,name,&block29:reflection=create_reflectionmodel,name,scope,options,extension=>30:define_accessorsmodel,reflection31:define_callbacksmodel,reflection32:define_validationsmodel,reflection33:reflection34:end
Voici une fonction des plus intéressante. Grâce à la magie de la
métaprogrammation avec Ruby, cette fonction va définir les accesseurs.
Comme on peut le voir, un nouveau module GeneratedAssociationMethods est créé
puis inclus. Ce nouveau module est ensuite retourné pour une utilisation
ultérieure.
Si l’on continue l’exécution de define_accessors, on trouve l’appel de
define_readers et define_writers. Ces deux méthodes reçoivent, comme
argument, le nouveau module ainsi que le nom de l’association. Voici la
première :
Comme le module a été inclus à la classe du modèle, la fonction est disponible
pour celui-ci. Il est donc possible de faire un appel comme post.comments.
Cela revient à écrire post.association(:comments).reader.
Vous avez probablement remaqué ceci <<-CODE, __FILE__, __LINE__ + 1. Ce code
permet de spécifier une ligne et un fichier au code généré. Il est donc
possible def faire ceci :
La méthode define_writers suit exactement le même principe.
Nous revoici donc à la méthode build.
12345678910111213141516
From:/home/dougui/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/bundler/gems/rails-6a7ac40dabf4/activerecord/lib/active_record/associations/builder/association.rb@line31ActiveRecord::Associations::Builder::Association.build:21:defself.build(model,name,scope,options,&block)22:ifmodel.dangerous_attribute_method?(name)23:raiseArgumentError,"You tried to define an association named #{name} on the model #{model.name}, but "\24:"this will conflict with a method #{name} already defined by Active Record. "\25:"Please choose a different association name."26:end27:28:extension=define_extensionsmodel,name,&block29:reflection=create_reflectionmodel,name,scope,options,extension30:define_accessorsmodel,reflection=>31:define_callbacksmodel,reflection32:define_validationsmodel,reflection33:reflection34:end
Les méthodes define_callbacks et define_validations ont des noms qui parlent
d’eux même. Chacune de ces méthodes mérite un article à elle même. Je ne
m’éterniserais donc pas sur le sujet.
Nous pouvons donc revenir à la méthode supérieure.
La prochaine méthode est simplement un ajout de reflection à la liste connu des reflections.
Nous avons maintenant compris comment ActiveRecord construisait l’association,
mais pas la méthode utilisée pour rechercher les enregistrements. Pour ce
faire, je vais explorer post.comments.
Comme prévu, la première méthode est la suivante :
Il s’agit de celle qui a été créée ci-haut. L’association est ensuite cherchée
dans le cache et la méthode reader lui est appliquée.
1234567891011121314151617
From:/home/dougui/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/bundler/gems/rails-6a7ac40dabf4/activerecord/lib/active_record/associations/collection_association.rb@line30ActiveRecord::Associations::CollectionAssociation#reader:29:defreader(force_reload=false)=>30:ifforce_reload31:klass.uncached{reload}32:elsifstale_target?33:reload34:end35:36:ifowner.new_record?37:# Cache the proxy separately before the owner has an id38:# or else a post-save proxy will still lack the id39:@new_record_proxy||=CollectionProxy.create(klass,self)40:else41:@proxy||=CollectionProxy.create(klass,self)42:end43:end
Si le rechargement n’est pas forcé ou si le modèle n’est pas “vicié”, le modèle
n’est pas rechargé. Je me suis cassé les dents sur la méthode stale_target?.
Je vous invite à lire cette issue
pour plus de détails. Le proxy est également mémorisé. La classe
CollectionProxy hérite de ActiveRecord::Relation qui est chargée de la
relation avec la base de données. L’interface se fait grâce à Arel, mais je
pense réserver les explications pour un prochain article.
Si l’on fait post.comments.class.name on peut voir que c’est bien la classe
ActiveRecord::Associations::CollectionProxy qui est utilisée.
Conclusion
Explorer le code de Rails est un exercice assez difficile, mais toujours très
intéressant. Cette partie est probablement l’une des plus complexes de Rails.
L’exploration permet de comprendre Rails, de mieux l’utiliser de s’en inspirer
si l’on souhaite recréer un code similaire.