L'héritage des tables uniques imbriquées ne fonctionne pas bien. Voici ce que vous devez savoir pour la faire fonctionner ou la contourner.
Quelques éléments de contexte pour l'illustration
Je suis récemment tombé sur le scénario suivant.
Spécifications initiales : un propriétaire de projet crée un projet et les donateurs peuvent contribuer à ce projet pour n'importe quelle somme d'argent.
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
class User < ApplicationRecord
# ...
end
class User::ProjectOwner < User
# ...
end
class User::Donor < User
# ...
end
class Project < ApplicationRecord
# ...
end
class Contribution < ApplicationRecord
# ...
end
Plus tard, une petite modification a été apportée aux spécifications : un donateur peut être soit une personne physique (un individu humain), soit une personne morale (une société ou tout autre type d'entité juridique).
Puisque les deux sont des donateurs et qu'ils partageront une quantité significative de logique, il semble évident qu'ils sont tous deux une spécialisation de User::Donor
, hence:
class User::Donor::Natural < User::Donor
# ...
end
class User::Donor::Legal < User::Donor
# ...
end
Jusqu'à présent, c'est de la POO classique et nous comptons sur le mécanisme STI d'ActiveRecord pour faire sa magie. (.find
type inference and so forth).
Alerte spoiler : ça ne marche pas.
STI ne joue pas bien avec le chargement paresseux du code
Cette partie n'est pas spécifique à STI (imbriqué) ou à ActiveRecord mais il est utile de la connaître.
Étant donné une base de données sans enregistrement (je travaille sur un nouveau projet) :
User.count
# => 0
User.descendants
# => []
C'est inattendu. Je pensais User.descendants
me donnerait un tableau de toutes les sous-classes de User (%i[User::ProjectOwner User::Donor User::Donor::Natural User::Donor::Legal]
) mais je n'ai rien de tout ça. Pourquoi ?
Vous ne vous attendez pas à ce qu'une constante existe si elle n'a pas été définie, n'est-ce pas ? Eh bien, à moins que vous ne chargiez le fichier qui la définit, elle n'existera pas.
Voici en gros comment ça se passe :
Me: …start a rails console…
Me: User.descendants
Me: #=> []
Me: puts "Did you know: you can clap for this article up to 50 times ;)" if User::Donor.is_a?(User)
Code loader: Oh, this `User::Donor` const does not exist yet, let me infer which file is supposed to define it and try to load it for you.
Code loader: Ok I found it and loaded it, you can proceed
Me: #=> "Did you know: you can clap for this article up to 50 times ;)"
Me: User.descendants
Me: #=> [User::Donor]
Me: puts "Another Brick In The Wall" if User::Pink.is_a?(User)
Code loader: Oh, this `User::Pink` const does not exist yet, let me infer which file is supposed to define it and try to load it for you.
Code loader: Sorry, this `User::Pink` is nowhere to be found, I hope you know how to rescue from NameError.
Me: #=> NameError (uninitialized constant #<Class:0x00007fb42cb92ef8>::Pink)
Vous comprenez maintenant pourquoi le chargement paresseux n'est pas compatible avec Single Table Inheritance : à moins que vous n'ayez déjà accédé à chacun des noms constants de vos sous-classes STI pour les précharger, votre application ne les connaîtra pas.
Ce n'est pas que STI ne fonctionne pas du tout, c'est juste un peu frustrant parce que nous avons souvent besoin d'énumérer la hiérarchie STI et il n'y a pas de moyen facile et prêt à l'emploi pour le faire.
Le guide de Ruby on Rails mentionne ce problème et propose une solution (incomplète): https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#single-table-inheritance
TL;DR: utiliser une préoccupation qui recueille tous les types de inheritance_column
et les pré-charger de force.
Pourquoi c'est incomplet : parce qu'un sous-type qui n'a pas encore d'enregistrement ne sera pas pré-chargé, ce qui signifie qu'il y a des choses que vous ne pourrez pas faire. Par exemple, vous ne pouvez pas compter sur l'inflexion pour générer des options de sélection, car les types sans enregistrement ne seront pas répertoriés dans vos options.
Une autre solution (vraiment pas recommandée) serait de pré-charger toutes les classes de votre application. C'est comme tuer une mouche avec un marteau.
Ma solution est basée sur le souci suggéré par le guide de Rails mais au lieu de collecter les types à partir de inheritance_column
, j'utilise un tableau qui contient toutes les sous-classes de l'ITS. De cette façon, je peux utiliser l'inflexion à volonté. Je suis d'accord pour dire que ce n'est pas un client SOLID à 100 %, mais c'est un compromis que je suis prêt à faire.
Ceci étant dit, parlons du sujet principal de cet article.
STI + chargement paresseux + modèles imbriqués = comportement imprévisible
L'héritage à tableau unique est conçu pour une classe de base et autant de sous-classes que vous voulez, tant qu'elles héritent toutes directement de la classe de base.
Jetez un coup d'œil aux deux exemples suivants. Le premier fonctionne parfaitement bien, tandis que le second vous donnera des maux de tête.
# Working example
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
class User < ApplicationRecord
end
class User::ProjectOwner < User
has_many :projects
end
class User::Donor < User
has_many :contributions
end
class Project < ApplicationRecord
belongs_to :project_owner, class_name: 'User::ProjectOwner', foreign_key: 'user_id'
end
class Contribution < ApplicationRecord
belongs_to :project
belongs_to :donor, class_name: 'User::Donor', foreign_key: 'user_id'
end
# Not working example
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
class User < ApplicationRecord
end
class User::ProjectOwner < User
has_many :projects
end
class User::Donor < User
has_many :contributions
end
class User::Donor::Natural < User::Donor
end
class User::Donor::Legal < User::Donor
end
class Project < ApplicationRecord
belongs_to :project_owner, class_name: 'User::ProjectOwner', foreign_key: 'user_id'
end
class Contribution < ApplicationRecord
belongs_to :project
belongs_to :donor, class_name: 'User::Donor', foreign_key: 'user_id'
end
Pourquoi la première fonctionne-t-elle de manière prévisible et pas la seconde ? Découvrez-le vous-même en prêtant attention aux requêtes SQL :
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
class User < ApplicationRecord
end
class User::ProjectOwner < User
has_many :projects
end
class User::Donor < User
has_many :contributions
end
class Project < ApplicationRecord
belongs_to :project_owner, class_name: 'User::ProjectOwner', foreign_key: 'user_id'
end
class Contribution < ApplicationRecord
belongs_to :project
belongs_to :donor, class_name: 'User::Donor', foreign_key: 'user_id'
end
# ...open a rails console...
project_owner = User::ProjectOwner.create
# => User::ProjectOwner(id: 1)
project = Project.create(project_owner: project_owner)
# => Project(id: 1, project_owner_id: 1)
donor = User::Donor.create
# => User::Donor(id: 1)
contribution = Contribution.create(donor: donor, project: project, amount: 100)
# => Contribution(id: 1, user_id: 1, project_id: 1, amount: 100)
# ...CLOSE the current rails console...
# ...OPEN a NEW rails console...
Contribution.last.donor
Contribution Load (0.5ms) SELECT "contributions".* FROM "contributions" ORDER BY "contributions"."id" DESC LIMIT $1 [["LIMIT", 1]]
User::Donor Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."type" = $1 AND "users"."id" = $2 LIMIT $3 [["type", "User::Donor"], ["id", 1], ["LIMIT", 1]]
# => User::Donor(id: 1)
Maintenant avec une STI imbriquée (classe de base, sous-classe de niveau intermédiaire et sous-classes de niveau feuille) :
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
class User < ApplicationRecord
end
class User::ProjectOwner < User
has_many :projects
end
class User::Donor < User
has_many :contributions
end
class User::Donor::Natural < User::Donor
end
class User::Donor::Legal < User::Donor
end
class Project < ApplicationRecord
belongs_to :project_owner, class_name: 'User::ProjectOwner', foreign_key: 'user_id'
end
class Contribution < ApplicationRecord
belongs_to :project
belongs_to :donor, class_name: 'User::Donor', foreign_key: 'user_id'
end
# ...open a rails console...
project_owner = User::ProjectOwner.create
# => User::ProjectOwner(id: 1)
project = Project.create(project_owner: project_owner)
# => Project(id: 1, project_owner_id: 1)
donor = User::Donor::Natural.create
# => User::Donor::Natural(id: 1)
contribution = Contribution.create(donor: donor, project: project, amount: 100)
# => Contribution(id: 1, user_id: 1, project_id: 1, amount: 100)
# ...CLOSE the current rails console...
# ...OPEN a NEW rails console...
Contribution.last.donor
Contribution Load (0.5ms) SELECT "contributions".* FROM "contributions" ORDER BY "contributions"."id" DESC LIMIT $1 [["LIMIT", 1]]
User::Donor Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."type" = $1 AND "users"."id" = $2 LIMIT $3 [["type", "User::Donor"], ["id", 1], ["LIMIT", 1]]
# => nil
Vous voyez ? La requête SQL pour trouver le donateur associé à la contribution recherche le type User::Donor
. Comme mon donateur est un User::Donor::Natural
, l'enregistrement n'est pas trouvé. ActiveRecord ne sait pas que User::Donor::Natural
is a subclass of User::Donor
dans le contexte d'une STI, à moins que je ne la charge au préalable.
irb(main):001:0> User.all.pluck :id
(0.9ms) SELECT "users"."id" FROM "users"
=> [2, 1]
irb(main):002:0> User.exists?(1)
User Exists? (0.3ms) SELECT 1 AS one FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
=> true
irb(main):003:0> User::Donor.exists?(1)
User::Donor Exists? (0.7ms) SELECT 1 AS one FROM "users" WHERE "users"."type" = $1 AND "users"."id" = $2 LIMIT $3 [["type", "User::Donor"], ["id", 1], ["LIMIT", 1]]
=> false
irb(main):004:0> User::Donor::Natural.exists?(1)
User::Donor::Natural Exists? (1.3ms) SELECT 1 AS one FROM "users" WHERE "users"."type" = $1 AND "users"."id" = $2 LIMIT $3 [["type", "User::Donor::Natural"], ["id", 1], ["LIMIT", 1]]
=> true
irb(main):005:0> User::Donor.exists?(1)
User::Donor Exists? (2.1ms) SELECT 1 AS one FROM "users" WHERE "users"."type" IN ($1, $2) AND "users"."id" = $3 LIMIT $4 [["type", "User::Donor"], ["type", "User::Donor::Natural"], ["id", 1], ["LIMIT", 1]]
=> true
Cela ne me convient pas. Je préfère ne pas prendre le risque de choisir une architecture dont le comportement est incertain car soumis au pré-chargement du code.
ActiveRecord aurait pu être conçu pour produire l'instruction SQL suivante :
SELECT * FROM users WHERE "users"."type" = "User::Donor" OR "users"."type" LIKE "User::Donor::%" AND "users"."id" = 1
Ce qui me permettrait de :
Demander
User.all
et récupérer les enregistrements de type :User, User::ProjectOwner, User::Donor, User::Donor::Natural, User::Donor::Legal
Demander
User::Donor.all
et récupérer les enregistrements de type:User::Donor, User::Donor::Natural, User::Donor::Legal
without code preloading
Demander
User::Donor::Natural.all
et récupérer les enregistrements de type:User::Donor::Natural
Demande
User::Donor::Legal.all
et récupérer les enregistrements de type:User::Donor::Legal
Mais il se comporte autrement :
SELECT * FROM users WHERE "users"."type" = "User::Donor" AND "users"."id" = 1
Ce n'est que lorsque j'ai pré-chargé les sous-classes de User::Donor
’s qu'il commence à me permettre de demander User::Donor.all
et récupérer les enregistrements de type: User::Donor, User::Donor::Natural, User::Donor::Legal
.
SELECT * FROM users WHERE "users"."type" IN ($1, $2, $3) AND "users"."id" = 1 [["type", "User::Donor"], ["type", "User::Donor::Natural"], ["type", "User::Donor::Legal"]]
On peut rejeter la faute sur le chargement de code paresseux, mais je ne le fais pas. Si je suis d'accord sur le fait que l'inflexion et le chargement de code paresseux ne peuvent pas fonctionner main dans la main en l'état, et puisque nous ne pouvons pas avoir un comportement prévisible/stable à partir d'un modèle de niveau intermédiaire, il serait préférable que la documentation d'AR décourage explicitement les ITS imbriquées.
Je préfère ne pas avoir de fonctionnalité plutôt qu'une sur laquelle je ne peux pas compter.
Pourquoi cela fonctionne-t-il bien à partir de la classe de base d'un STI ordinaire et pas à partir d'un STI de niveau moyen ?
La réponse se trouve dans le code source d'ActiveRecord.
Lors de l'accès à la relation, ActiveRecord ajoute une condition de type si nécessaire:
# https://github.com/rails/rails/blob/6bc7c478ba469ad4b033125d6798d48f36d6be3e/activerecord/lib/active_record/core.rb#L306
def relation
relation = Relation.create(self)
if finder_needs_type_condition? && !ignore_default_scope?
relation.where!(type_condition)
relation.create_with!(inheritance_column.to_s => sti_name)
else
relation
end
end
Pour déterminer si la condition de type est nécessaire, il effectue quelques vérifications concernant la distance entre la classe actuelle et ActiveRecord::Base ainsi que la présence d'une colonne d'héritage.
# https://github.com/rails/rails/blob/6bc7c478ba469ad4b033125d6798d48f36d6be3e/activerecord/lib/active_record/inheritance.rb#L74
# Returns +true+ if this does not need STI type condition. Returns
# +false+ if STI type condition needs to be applied.
def descends_from_active_record?
if self == Base
false
elsif superclass.abstract_class?
superclass.descends_from_active_record?
else
superclass == Base || !columns_hash.include?(inheritance_column)
end
end
def finder_needs_type_condition? #:nodoc:
# This is like this because benchmarking justifies the strange :false stuff
:true == (@finder_needs_type_condition ||= descends_from_active_record? ? :false : :true)
end
La condition de type est construite comme suit :
# https://github.com/rails/rails/blob/6bc7c478ba469ad4b033125d6798d48f36d6be3e/activerecord/lib/active_record/inheritance.rb#L262
def type_condition(table = arel_table)
sti_column = arel_attribute(inheritance_column, table)
sti_names = ([self] + descendants).map(&:sti_name)
predicate_builder.build(sti_column, sti_names)
end
Pour résumer :
Lors d'une requête à partir de la classe de base (dans mon exemple :
User
), aucune condition de type n'est ajoutée.
Puisqu'il liste tous les enregistrements de la table, il donne accès à tous les enregistrements dont la classe est ou hérite de User
. Parfait.
Lors d'une requête à partir d'une sous-classe de feuille, le type exact doit correspondre pour que l'enregistrement soit trouvé. Logique.
Lors d'une demande à partir d'une sous-classe de niveau intermédiaire telle que
User::Donor
(ni la classe de baseUser
ni une feuilleUser::Donor::Natural
), cela dépend. Comme prévu, les enregistrements de typeUser::Donor
sont chargés. D'autre part, les enregistrements dont la classe hérite deUser::Donor
ne seront sélectionnés que si leur classe est pré-chargée.
Existe-t-il une solution de contournement ?
Il y en a toujours une.
Nous pourrions envisager de modifier ActiveRecord pour qu'il utilise LIKE dans la requête SQL comme une condition alternative à la comparaison stricte des chaînes de caractères. Problème : Je n'ai pas fait de benchmark mais cela va certainement ralentir la lecture de la base de données. Bien que cette solution fonctionne, elle est inefficace, nécessite beaucoup de travail pour patcher ActiveRecord et, franchement, nous ne sommes même pas sûrs que l'équipe centrale de Rails accepterait un tel patch.
Une autre solution consisterait à remplacer la portée par défaut de l'option User::Donor
pour qu'il utilise une instruction LIKE comme décrit ci-dessus. Je ne suis pas un grand fan des scopes par défaut parce qu'il arrive toujours un jour où l'on doit utiliser .unscope
et voilà, ça ne marche plus. Ce n'est pas une solution durable.
Une autre solution encore pourrait être de pré-charger les sous-classes, par exemple avec la solution discutée précédemment. Je suppose que c'est une solution acceptable.
Une autre solution consiste à revenir à une architecture plus simple qui ne laisse aucune place aux changements de comportement : pas de sous-classes de niveau intermédiaire, pas de pré-chargement nécessaire. Comment ne pas me répéter pour le code commun partagé par User::Donor::Natural
and User::Donor::Legal
, vous demandez ?
Utilisation des préoccupations.
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
class User < ApplicationRecord
scope :donors, -> { where(type: ['User::DonorNatural', 'User::DonorLegal']) }
scope :project_owners, -> { where(type: 'User::ProjectOwner') }
end
class User::ProjectOwner < User
end
class User::DonorNatural < User
include User::DonorConcern
end
class User::DonorLegal < User
include User::DonorConcern
end
module User::DonorConcern
extend ActiveSupport::Concern
included do
has_many :contributions, foreign_key: 'user_id', inverse_of: :donor
end
end
class Project < ApplicationRecord
belongs_to :project_owner, class_name: 'User::ProjectOwner', foreign_key: 'user_id'
end
class Contribution < ApplicationRecord
belongs_to :project
belongs_to :donor, class_name: 'User', foreign_key: 'user_id', inverse_of: :contributions
end
There is still room for improvement (this code is intentionally oversimplified, no validations whatsoever) to make this article easier to read, my goal being to give you the essential information so that you can choose your own favorite solution in an informed way.
Mes solutions préférées
Lorsque cela est possible, je préfère avoir une architecture plus simple (pas de couches intermédiaires). Moins elle est complexe, moins j'ai de maux de tête.
Lorsque je dois avoir cette couche intermédiaire, je pré-charge toutes les sous-classes de mon STI pour éviter tout comportement aléatoire. Et je veux dire toutes les sous-classes de mon STI, pas seulement celles qui ont des enregistrements dans la base de données.
module UserStiPreloadConcern
unless Rails.application.config.eager_load
extend ActiveSupport::Concern
included do
cattr_accessor :preloaded, instance_accessor: false
end
class_methods do
def descendants
preload_sti unless preloaded
super
end
def preload_sti
user_subclasses = [
"User::ProjectOwner",
"User::Donor",
"User::Donor::Natural",
"User::Donor::Legal"
]
user_subclasses.each do |type|
type.constantize
end
self.preloaded = true
end
end
end
end
Merci d'avoir lu!