Skip to content

Les associations en Rails: Partie 02

ubugnu edited this page Feb 4, 2018 · 2 revisions

has_one

Qu'est ce que c'est?

Après avoir déclaré que chaque instance du modèle Book devait appartenir à une instance du modèle Author, on aimera bien pouvoir avoir accès aux instances de Book associés à une instance de Author directement à partir de cette dernière, pour répondre à des questions du genre: quels sont les livres qui appartiennent à ahmed?

D'un autre coté, dans la leçon précédente, on a attribué l'auteur ahmed au livre quantum_intro, on aimerait aussi pouvoir faire la même opération dans le sens inverse, c'est à dire: attribuer le livre quantum_intro à l'auteur ahmed.

Pour ce faire, il faudra d'abord dire si chaque instance du modèle Ahmed peut avoir un seul et unique livre, ou si elle peut en avoir plusieurs.

Comment l'utiliser?

Il suffit de modifier la classe décrivant le modèle Author comme suit:

class Author < ApplicationRecord
  has_one :book
end

Dès lors, on sera capable d'attribuer un livre à un auteur, tout comme on a pu attribuer un auteur à un livre dans la leçon précédente.

Il faut noter qu'on n'a rien changé à la structure de la base de données, on a juste modifié la structure de la classe associée pour l'enrichir en méthodes capables de rendre l'association symétrique.

POC

On crée d'abord les deux modèle en leur ajoutant quelques attributs utiles.

rails generate model author name:string
rails generate model book title:string author:belongs_to

Puis on instancie quelques objets dans le but de les associer:

2.4.0 :001 > ahmed = Author.new name: 'Ahmed'
 => #<Author id: nil, name: "Ahmed", created_at: nil, updated_at: nil> 
2.4.0 :002 > quant_book = Book.new title: 'Quantum Mechanics'
 => #<Book id: nil, title: "Quantum Mechanics", author_id: nil, created_at: nil, updated_at: nil> 
2.4.0 :003 > ahmed.book 
 => nil 
2.4.0 :004 > ahmed.valid?
 => true 
2.4.0 :006 > quant_book.author
 => nil 
2.4.0 :007 > quant_book.valid?
 => false 
2.4.0 :008 > quant_book.errors.messages
 => {:author=>["must exist"]} 
2.4.0 :009 > ahmed.book = quant_book
 => #<Book id: nil, title: "Quantum Mechanics", author_id: nil, created_at: nil, updated_at: nil> 
2.4.0 :010 > ahmed.book.title
 => "Quantum Mechanics" 
2.4.0 :011 > quant_book.author
 => #<Author id: nil, name: "Ahmed", created_at: nil, updated_at: nil> 
2.4.0 :012 > quant_book.author.name
 => "Ahmed" 
2.4.0 :013 > quant_book.valid?     
 => true 
2.4.0 :014 > ahmed.save
   (0.3ms)  begin transaction
  SQL (0.4ms)  INSERT INTO "authors" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "Ahmed"], ["created_at", "2017-07-12 18:06:10.313472"], ["updated_at", "2017-07-12 18:06:10.313472"]]
  SQL (0.2ms)  INSERT INTO "books" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["title", "Quantum Mechanics"], ["author_id", 1], ["created_at", "2017-07-12 18:06:10.328036"], ["updated_at", "2017-07-12 18:06:10.328036"]]
   (89.7ms)  commit transaction
 => true 

Quelques remarques:

  • has_one n'a pas le même effet sur une classe que belongs_to: premièrement elle n'impose pas la présence d'un objet associé (ici un livre associé), et deuxièmement elle ne change rien dans la structure de la base de données.
  • De ce fait, un objet Author est valid même s'il n'a aucun livre associé. Ce n'est pas le cas d'un objet Book n'ayant pas d'auteur associé.
  • On est capable d'accéder aux attributs des objets Book à partir des objets Author et vice-versa
  • La sauvegarde d'un objet enclenche la sauvegarde des objets qui lui sont associés (s'ils ne sont pas déjà présents dans la base de données).

has_one ne force pas forcément l'unicité!

Bien que la classe Author comporte la ligne has_one :book, il faut bien comprendre que cette contrainte existe seulement au niveau d'ActiveRecord et du point de vue du modèle Author seulement (c'est à dire que le modèle Book ne sait pas qu'un objet Author doit avoir un seul et unique livre). D'un autre coté, cette contrainte n'existe pas au niveau de la base de données, rien n'interdit que plusieurs lignes de la table Books aient la même valeur pour la colonne author_id. Voyons un exemple concret:

2.4.0 :001 > nacer = Author.new name: "Nacer"
 => #<Author id: nil, name: "Nacer", created_at: nil, updated_at: nil> 
2.4.0 :002 > book1 = Book.new title: "My first book"
 => #<Book id: nil, title: "My first book", author_id: nil, created_at: nil, updated_at: nil> 
2.4.0 :003 > book2 = Book.new title: "My second book"
 => #<Book id: nil, title: "My second book", author_id: nil, created_at: nil, updated_at: nil> 
2.4.0 :004 > book1.author = nacer
 => #<Author id: nil, name: "Nacer", created_at: nil, updated_at: nil> 
2.4.0 :005 > book1.save
   (0.2ms)  begin transaction
  SQL (0.5ms)  INSERT INTO "authors" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "Nacer"], ["created_at", "2017-07-12 20:20:55.843616"], ["updated_at", "2017-07-12 20:20:55.843616"]]
  Author Load (0.1ms)  SELECT  "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  SQL (0.2ms)  INSERT INTO "books" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["title", "My first book"], ["author_id", 2], ["created_at", "2017-07-12 20:20:55.864639"], ["updated_at", "2017-07-12 20:20:55.864639"]]
   (112.6ms)  commit transaction
 => true 
2.4.0 :006 > book2.author = nacer
 => #<Author id: 2, name: "Nacer", created_at: "2017-07-12 20:20:55", updated_at: "2017-07-12 20:20:55"> 
2.4.0 :007 > book2.save
   (0.2ms)  begin transaction
  SQL (1.3ms)  INSERT INTO "books" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["title", "My second book"], ["author_id", 2], ["created_at", "2017-07-12 20:21:15.239644"], ["updated_at", "2017-07-12 20:21:15.239644"]]
   (121.2ms)  commit transaction
 => true 
2.4.0 :008 > book1.reload.author == book2.reload.author
  Book Load (0.6ms)  SELECT  "books".* FROM "books" WHERE "books"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  Author Load (0.2ms)  SELECT  "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  Book Load (0.3ms)  SELECT  "books".* FROM "books" WHERE "books"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
  Author Load (0.3ms)  SELECT  "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
 => true
2.4.0 :009 > nacer.reload.book.title
  Author Load (0.5ms)  SELECT  "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  Book Load (0.5ms)  SELECT  "books".* FROM "books" WHERE "books"."author_id" = ? LIMIT ?  [["author_id", 2], ["LIMIT", 1]]
 => "My first book" 
2.4.0 :010 >

On voit alors que du point de vue de la base de données, l'auteur nacer possède deux livres (book1.reload.author == book2.reload.author donne true). Le fait que nacer.reload.book.title donne le titre du premier livre n'est qu'artificiel, car on remarque que la requête SQL qui tire le livre de la table Books ne retourne que la première occurrence même s'il en existe plusieurs:

Book Load (0.5ms)  SELECT  "books".* FROM "books" WHERE "books"."author_id" = ? LIMIT ?  [["author_id", 2], ["LIMIT", 1]]

Une simple requête directe en enlevant la limite nous le confirme:

2.4.0 :011 > ActiveRecord::Base.connection.execute('SELECT  "books".* FROM "books" WHERE "books"."author_id" = 2').size
   (0.6ms)  SELECT  "books".* FROM "books" WHERE "books"."author_id" = 2
 => 2

Pour le moment donc, le data model est plutôt du genre:

La solution consiste à contraindre la clé étrangère author_id à être unique, pour avoir le data model suivant:

Il y a deux façons de faire:

  • La façon classique en contraignant cette clé à être unique au niveau de la base de données, cela est possible au moment de la génération du modèle Book:
rails generate model book title:string author:belongs_to:uniq
  • La deuxième consiste à poser cette contrainte dans la classe Book directement comme suit:
class Book < ApplicationRecord
  belongs_to :author
  validates_uniqueness_of :author_id
end

validates_uniqueness_of

Voyons ce que donne la deuxième façon:

2.4.0 :001 > Author.all.count                                                                                          
   (0.1ms)  SELECT COUNT(*) FROM "authors"
 => 2 
2.4.0 :002 > Book.all.count
   (0.1ms)  SELECT COUNT(*) FROM "books"
 => 4 
2.4.0 :005 > ActiveRecord::Base.connection.execute('DELETE FROM `Books`')  
   (109.9ms)  DELETE FROM `Books`
 => [] 
2.4.0 :006 > ActiveRecord::Base.connection.execute('DELETE FROM `Authors`')
   (120.0ms)  DELETE FROM `Authors`
 => [] 
2.4.0 :007 > Author.all.count                                                 
   (0.6ms)  SELECT COUNT(*) FROM "authors"
 => 0 
2.4.0 :008 > Book.all.count                                                   
   (0.4ms)  SELECT COUNT(*) FROM "books"
 => 0 
2.4.0 :009 > nacer = Author.new name: "Nacer"                                                                          
 => #<Author id: nil, name: "Nacer", created_at: nil, updated_at: nil> 
2.4.0 :010 > book1 = Book.new title: "Book 1"                                 
 => #<Book id: nil, title: "Book 1", author_id: nil, created_at: nil, updated_at: nil> 
2.4.0 :011 > book2 = Book.new title: "Book 2"                              
 => #<Book id: nil, title: "Book 2", author_id: nil, created_at: nil, updated_at: nil> 
2.4.0 :012 > book1.author = nacer
 => #<Author id: nil, name: "Nacer", created_at: nil, updated_at: nil> 
2.4.0 :013 > book1.save
   (0.2ms)  begin transaction
  Book Exists (0.5ms)  SELECT  1 AS one FROM "books" WHERE "books"."author_id" IS NULL LIMIT ?  [["LIMIT", 1]]
  SQL (1.1ms)  INSERT INTO "authors" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "Nacer"], ["created_at", "2017-07-12 20:35:10.834404"], ["updated_at", "2017-07-12 20:35:10.834404"]]
  Author Load (0.2ms)  SELECT  "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  Book Exists (0.1ms)  SELECT  1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ?  [["author_id", 3], ["LIMIT", 1]]
  SQL (0.2ms)  INSERT INTO "books" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["title", "Book 1"], ["author_id", 3], ["created_at", "2017-07-12 20:35:10.846861"], ["updated_at", "2017-07-12 20:35:10.846861"]]
   (102.3ms)  commit transaction
 => true 
2.4.0 :014 > book2.author = nacer            
 => #<Author id: 3, name: "Nacer", created_at: "2017-07-12 20:35:10", updated_at: "2017-07-12 20:35:10"> 
2.4.0 :015 > book2.valid?
  Book Exists (0.2ms)  SELECT  1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ?  [["author_id", 3], ["LIMIT", 1]]
 => false 
2.4.0 :016 > book2.errors.messages
 => {:author_id=>["has already been taken"]}

On voit que la tentative d'assignation de l'auteur nacer à un autre livre book2 rend ce livre non valide, le message d'erreur étant has already been taken.

:belongs_to:uniq

Enlevons maintenant validates_uniqueness_of de la classe Book et voyons ce que ça donne si la contrainte est posée directement dans la structure de la base de données:

rails generate model author name:string
rails generate model book title:string author:belongs_to:uniq

Ceci donne la transaction SQL suivante:

BEGIN TRANSACTION;
CREATE TABLE "books" (
        "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 
        "title" varchar, 
        "author_id" integer, 
        "created_at" datetime NOT NULL, 
        "updated_at" datetime NOT NULL, 
        CONSTRAINT "fk_rails_53d51ce16a" FOREIGN KEY ("author_id")  REFERENCES "authors" ("id")
);
CREATE TABLE "authors" (
        "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 
        "name" varchar, 
        "created_at" datetime NOT NULL, 
        "updated_at" datetime NOT NULL
);
CREATE UNIQUE INDEX "index_books_on_author_id" ON "books" ("author_id");
COMMIT;

La seule différence avec la transaction de la leçon précédente est que author_id est déclaré maintenant comme UNIQUE INDEX.

Voyons maintenant ce qui se passe si nous essayons d'assigner le même auteur à deux livres:

2.4.0 :001 > ahmed = Author.new name: "Ahmed"
 => #<Author id: nil, name: "Ahmed", created_at: nil, updated_at: nil> 
2.4.0 :002 > book1 = Book.new title: "Quantum Mechanics"
 => #<Book id: nil, title: "Quantum Mechanics", author_id: nil, created_at: nil, updated_at: nil> 
2.4.0 :003 > book2 = Book.new title: "Algebra"
 => #<Book id: nil, title: "Algebra", author_id: nil, created_at: nil, updated_at: nil> 
2.4.0 :004 > book1.author = ahmed
 => #<Author id: nil, name: "Ahmed", created_at: nil, updated_at: nil> 
2.4.0 :006 > book1.save
   (0.2ms)  begin transaction
  SQL (0.4ms)  INSERT INTO "authors" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "Ahmed"], ["created_at", "2017-07-13 10:50:41.164016"], ["updated_at", "2017-07-13 10:50:41.164016"]]
  Author Load (0.3ms)  SELECT  "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  SQL (0.2ms)  INSERT INTO "books" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["title", "Quantum Mechanics"], ["author_id", 1], ["created_at", "2017-07-13 10:50:41.240916"], ["updated_at", "2017-07-13 10:50:41.240916"]]
   (121.8ms)  commit transaction
 => true 
2.4.0 :007 > book2.author = ahmed
 => #<Author id: 1, name: "Ahmed", created_at: "2017-07-13 10:50:41", updated_at: "2017-07-13 10:50:41"> 
2.4.0 :008 > book2.valid?
 => true 
2.4.0 :009 > book2.save
   (0.2ms)  begin transaction
  SQL (1.2ms)  INSERT INTO "books" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["title", "Algebra"], ["author_id", 1], ["created_at", "2017-07-13 10:51:06.160283"], ["updated_at", "2017-07-13 10:51:06.160283"]]
   (0.2ms)  rollback transaction
ActiveRecord::RecordNotUnique: SQLite3::ConstraintException: UNIQUE constraint failed: books.author_id: INSERT INTO "books" ("title", "author_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)
	from (irb):9

La remarque la plus importante est que book2.valid? à donné true malgré que l'objet book2 ne soit pas valide d'un point de vue structure de la base de données. Cela est dû au fait que nous avons enlevé de la classe Book la vérification validates_uniqueness_of, et que ActiveRecord n'a pas connaissance de la contrainte posée sur author_id au niveau de la base de données.

Cette façon de faire est plus délicate dans un environnement de développement, vue qu'elle fait lever une exception qu'un faudra comprendre et secourir, et qu'il n'y a aucun moyen de vérifier la validité de l'objet avant de le sauvegarder (.valid? a donné true!). Alors que la première façon de faire est plus naturelle, vu que le message d'erreur est stockée dans l'objet lui-même, et que .valid? donne une information sûre. Néanmoins, la première façon de faire (avec validates_uniqueness_of) est gourmande en transactions SQL!.

En conclusion, la façon la plus robuste d'implémenter une (vraie!) association 1:1 est de combiner validates_uniqueness_of avec une clé étrangère author_id unique.