Pourquoi éviter les STI imbriqués | ActiveRecord, Rails 6

Younes Serraj
Younes Serraj2 juin 2020

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.

Pourquoi il faut éviter les STI imbriqués


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 base User ni une feuille User::Donor::Natural), cela dépend. Comme prévu, les enregistrements de type User::Donor sont chargés. D'autre part, les enregistrements dont la classe hérite de User::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!

Partager
Younes Serraj
Younes Serraj2 juin 2020

Blog de Capsens

Capsens est une agence spécialisée dans le développement de solutions fintech. Nous aimons les startups, la méthodologie scrum, le Ruby et le React.