J'essaie de créer une application de thésaurus simple dans Rails, dans laquelle un mot dans une table de mots serait dans une relation auto-jointe à plusieurs mots dans la table, via une table de synonyme de jointure.
Ma classe SynonymPair est construite comme suit:
[17] pry(main)> w2 = Word.create(word: "w2") => #<Word:0x00007ffd522190b0 id: 7, word: "w2"> [18] pry(main)> sp1 = SynonymPair.create(word1:w1, word2:w2) => #<SynonymPair:0x00007ffd4fea2230 id: 6, word1_id: 6, word2_id: 7> [19] pry(main)> w1.synonym_pairs => #<SynonymPair::ActiveRecord_Associations_CollectionProxy:0x3ffea7f783e4> [20] pry(main)> w1.synonyms ActiveRecord::HasManyThroughSourceAssociationNotFoundError: Could not find the source association(s) "synonym" or :synonyms in model SynonymPair. Try 'has_many :synonyms, :through => :synonym_pairs, :source => <name>'. Is it one of word1 or word2?
Un aspect crucial de ce programme de thésaurus est que peu importe si un mot est dans la colonne mot1 ou mot2; word1 est un synonyme de word2, et vice versa.
Pour que ma classe Words renvoie les SynonymPairs et les Synonymes d'un mot donné, j'ai écrit une requête SQL:
p>
class Word < ActiveRecord::Base has_many :synonym_pairs, ->(word) { where("word1_id = ? OR word2_id = ?", word.id, word.id) } has_many :synonyms, through: :synonym_pairs end
Ce code fonctionne comme prévu. Cependant, il ne tire pas parti des modèles d'association dans ActiveRecord. Donc, je me demandais qu'il serait possible d'écrire une requête de relation personnalisée has_many: synonymes_pairs / has_many: synonym via:: synonym-pairs dans la classe Words, plutôt que d'écrire une requête SQL entière, comme je l'ai fait ci-dessus. En d'autres termes, je suis curieux de savoir s'il est possible de convertir ma requête SQL en une requête de relations personnalisées Rails.
Remarque, j'ai essayé la requête de relations personnalisées suivante:
class Word < ActiveRecord::Base def synonym_pairs #joins :synonym_pairs and :words where either word1_id OR word2_id matches word.id. sql = <<-SQL SELECT synonym_pairs.id, synonym_pairs.word1_id, synonym_pairs.word2_id, words.word FROM synonym_pairs JOIN words ON synonym_pairs.word1_id = words.id WHERE words.word = ? UNION SELECT synonym_pairs.id, synonym_pairs.word1_id, synonym_pairs.word2_id, words.word FROM synonym_pairs JOIN words ON synonym_pairs.word2_id = words.id WHERE words.word = ? SQL #returns synonym_pair objects from the result of sql query DB[:conn].execute(sql,self.word,self.word).map do |element| SynonymPair.find(element[0]) end end def synonyms self.synonym_pairs.map do |element| if element.word1 == self element.word2 else element.word1 end end end end
Mais, après avoir passé quelques graines Word / SynonymPair, il a renvoyé un 'ActiveRecord: Associations: CollectionProxy' lorsque j'ai essayé d'obtenir que j'appelle mot # synonym_pairs et l'erreur suivante lorsque j'ai appelé mot # synonymes: p>
class SynonymPair < ActiveRecord::Base belongs_to :word1, class_name: :Word belongs_to :word2, class_name: :Word end
Avez-vous d'autres idées pour obtenir une requête de relation personnalisée, ou toute sorte de modèle d'auto-jointure fonctionnant ici?
3 Réponses :
Vous recherchez probablement la scope Méthode de classe ActiveRecord:
class SynonymPair < ActiveRecord::Base belongs_to :word1, class_name: :Word belongs_to :word2, class_name: :Word scope :with_word, -> (word) { where(word1: word).or(where(word2: word)) } end class Word < ActiveRecord::Base scope :synonyms_for, -> (word) do pairs = SynonymPair.with_word(word) where(id: pairs.select(:word1_id)).where.not(id: word.id).or( where(id: pairs.select(:word2_id)).where.not(id: word.id)) end def synonyms Word.synonyms_for(self) end end
Le problème avec les étendues est qu'elles ne sont pas des relations et qu'elles ne peuvent pas être chargées rapidement. Cela peut entraîner des problèmes de performances assez graves sur toute la ligne.
Au lieu d'une table de paires de synonymes, vous pouvez simplement créer une table de jointure M2M standard:
irb(main):019:0> happy.synonyms Word Load (0.3ms) SELECT "words".* FROM "words" INNER JOIN "synonymities" ON "words"."id" = "synonymities"."synomym_id" WHERE "synonymities"."word_id" = $1 LIMIT $2 [["word_id", 1], ["LIMIT", 11]] => #<ActiveRecord::Associations::CollectionProxy [#<Word id: 2, text: "Jolly", created_at: "2020-07-06 09:00:43", updated_at: "2020-07-06 09:00:43">]> irb(main):020:0> jolly.synonyms Word Load (0.3ms) SELECT "words".* FROM "words" INNER JOIN "synonymities" ON "words"."id" = "synonymities"."synomym_id" WHERE "synonymities"."word_id" = $1 LIMIT $2 [["word_id", 2], ["LIMIT", 11]] => #<ActiveRecord::Associations::CollectionProxy [#<Word id: 1, text: "Happy", created_at: "2020-07-06 09:00:32", updated_at: "2020-07-06 09:00:32">]>
happy = Word.create!(text: 'Happy') jolly = Word.create!(text: 'Jolly') # wrapping this in a single transaction is slightly faster then two transactions Synonymity.transaction do happy.synonyms << jolly jolly.synonyms << happy end
class CreateSynonymities < ActiveRecord::Migration[6.0] def change create_table :synonymities do |t| t.belongs_to :word, null: false, foreign_key: true t.belongs_to :synonym, null: false, foreign_key: { to_table: :words } end end end
Pendant cette solution nécessiterait deux fois plus de lignes dans la table de jointure, cela pourrait valoir la peine de faire un compromis car traiter des relations où les clés étrangères ne sont pas fixées est un cauchemar dans ActiveRecord. Cela fonctionne.
AR ne vous permet pas vraiment de fournir la jointure sql lorsque vous utilisez .eager_load
et .includes
et que vous chargez des enregistrements avec une requête personnalisée et faire en sorte que l'AR ait un sens si les résultats et traiter les associations comme chargées pour éviter les problèmes de requête n + 1 peut être extrêmement piraté et prendre du temps. Parfois, vous devez simplement construire votre schéma autour de la RA plutôt que d'essayer de le battre en soumission.
Vous définiriez une relation synonyme entre deux mots avec:
class Synonymity belongs_to :word belongs_to :synonym, class_name: 'Word' end
XXX
La table SynonymPair est déjà la table de jointure M2M, pourquoi en créer une autre?
@eikes J'ai changé la dénomination et les choses pour rendre plus clair ce qui se passe ici. La table n'est pas vraiment interrogée comme une table de paires. Il a plutôt deux lignes pour chaque paire (une pour chaque direction). C'est donc une solution très différente.
Vous devrez alors ajouter chaque paire deux fois. Pas optimal. Cela peut entraîner des problèmes de performances assez graves sur toute la ligne. L'auteur a spécifiquement déclaré: "Un aspect crucial de ce programme de thésaurus est qu'il ne devrait pas être important qu'un mot soit dans la colonne mot1 ou mot2; mot1 est un synonyme de mot2, et vice versa."
@eikes comme écrit dans la réponse, c'est un compromis. Mais le manque de chargement avec les alternatives sera un problème de performance beaucoup plus immédiat.
Si vous voulez vraiment configurer des associations où l'enregistrement peut être dans l'une ou l'autre des colonnes de la table de jointure, vous avez besoin d'une association has_many
et d'une association indirecte pour chaque clé étrangère potentielle.
Continuez avec moi ici car cela devient vraiment fou:
class Word < ActiveRecord::Base has_many :synonym_pairs_as_word_1, class_name: 'SynonymPair', foreign_key: 'word_1' has_many :synonym_pairs_as_word_2, class_name: 'SynonymPair', foreign_key: 'word_2' has_many :word_1_synonyms, through: :synonym_pairs_as_word_1, class_name: 'Word', source: :word_2 has_many :word_2_synonyms, through: :synonym_pairs_as_word_2, class_name: 'Word', source: :word_1 def synonyms self.class.where(id: word_1_synonyms).or(id: word_2_synonyms) end end
Puisque les synonymes ici ne sont toujours pas vraiment une association, vous avez toujours un problème potentiel de requête n + 1 si vous chargez une liste de mots et leurs synonymes.
Bien que vous puissiez charger avec impatience word_1_synonymes et word_2_synonymes et les combiner (en les transformant en tableaux), cela pose un problème si vous devez commander les enregistrements.
Vous devez indiquer que les
synonymes
appartiennent à la classe Word. Donchas_many: synonymes, class_name: Word, through:: synonym_pairs
@LesNightingill qui ne fonctionnerait que si l'association pointe vers une seule association sur la table vers laquelle elle pointe.