-
Notifications
You must be signed in to change notification settings - Fork 0
Les associations en Rails: Partie 02
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.
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.
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 quebelongs_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 objetBook
n'ayant pas d'auteur associé. - On est capable d'accéder aux attributs des objets
Book
à partir des objetsAuthor
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).
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
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
.
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.