Partager via


Filtres de requête globaux

Les filtres de requête globaux permettent d’attacher un filtre à un type d’entité et d’appliquer ce filtre chaque fois qu’une requête sur ce type d’entité est exécutée ; considérez-les comme un opérateur LINQ Where supplémentaire qui est ajouté chaque fois que le type d’entité est interrogé. Ces filtres sont utiles dans divers cas.

Conseil / Astuce

Vous pouvez afficher l’exemple de cet article sur GitHub.

Exemple de base : suppression réversible

Dans certains scénarios, au lieu de supprimer une ligne de la base de données, il est préférable de définir un IsDeleted indicateur pour marquer la ligne comme supprimée ; ce modèle est appelé suppression réversible. La suppression temporaire permet de restaurer des lignes si nécessaire ou de conserver une piste d’audit où les lignes supprimées restent accessibles. Les filtres de requête globaux peuvent être utilisés pour filtrer les lignes marquées comme supprimées par défaut, tout en vous permettant de les accéder dans des contextes spécifiques en désactivant le filtre pour une requête donnée.

Pour activer la suppression réversible, nous allons ajouter une IsDeleted propriété à notre type de blog :

public class Blog
{
    public int Id { get; set; }
    public bool IsDeleted { get; set; }

    public string Name { get; set; }
}

Nous avons maintenant configuré un filtre de requête global à l’aide de l’API HasQueryFilter dans OnModelCreating:

modelBuilder.Entity<Blog>().HasQueryFilter(b => !b.IsDeleted);

Nous pouvons désormais interroger nos Blog entités comme d’habitude ; le filtre configuré garantit que toutes les requêtes seront, par défaut, filtrées pour exclure toutes les instances où IsDeleted est vrai.

Notez qu’à ce stade, vous devez définir IsDeleted manuellement pour supprimer une entité de manière réversible. Pour une solution de bout en bout, vous pouvez remplacer la méthode SaveChangesAsync de votre type de contexte pour ajouter une logique qui parcourt toutes les entités que l'utilisateur a supprimées et les marque comme modifiées, en définissant la propriété IsDeleted à vrai :

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    ChangeTracker.DetectChanges();

    foreach (var item in ChangeTracker.Entries<Blog>().Where(e => e.State == EntityState.Deleted))
    {
        item.State = EntityState.Modified;
        item.CurrentValues["IsDeleted"] = true;
    }

    return await base.SaveChangesAsync(cancellationToken);
}

Cela vous permet d’utiliser les API EF qui suppriment une instance d’entité comme d’habitude et de les faire supprimer temporairement à la place.

Utilisation des données contextuelles - multi-location

Un autre scénario courant pour les filtres de requêtes globales est la multi-location, où votre application stocke les données appartenant à différents utilisateurs dans la même table. Dans ce cas, il existe généralement une colonne d’ID de locataire qui associe la ligne à un locataire spécifique, et les filtres de requête globaux peuvent être utilisés pour filtrer automatiquement les lignes du locataire actuel. Cela fournit une isolation de locataire forte pour vos requêtes par défaut, en supprimant la nécessité de penser au filtrage pour le locataire dans chaque requête.

Contrairement à la suppression logicielle, la multi-location nécessite de connaître l’ID du locataire actuel ; cette valeur est généralement déterminée, par exemple, lorsque l’utilisateur s’authentifie sur le Web. Pour les besoins d’EF, l’ID de locataire doit être disponible sur l’instance de contexte, afin que le filtre de requête global puisse y faire référence et l’utiliser lors de l’interrogation. Acceptons un tenantId paramètre dans le constructeur de notre type de contexte et référencez-le à partir de notre filtre :

public class MultitenancyContext(string tenantId) : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>().HasQueryFilter(b => b.TenantId == tenantId);
    }
}

Cela force toute personne à construire un contexte pour spécifier son ID de locataire associé et garantit que seules Blog les entités avec cet ID sont retournées par défaut à partir de requêtes.

Remarque

Cet exemple n’a montré que les concepts de multitenancité de base nécessaires pour illustrer les filtres de requête globaux. Pour en savoir plus sur la multi-location et EF, consultez la section Multi-location dans les applications EF Core.

Utilisation de plusieurs filtres de requête

L’appel HasQueryFilter avec un filtre simple remplace tout filtre précédent, de sorte que plusieurs filtres ne peuvent pas être définis sur le même type d’entité de cette façon :

modelBuilder.Entity<Blog>().HasQueryFilter(b => !b.IsDeleted);
// The following overwrites the previous query filter:
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.TenantId == tenantId);

Remarque

Cette fonctionnalité est introduite dans EF Core 10.0 (en préversion).

Pour définir plusieurs filtres de requête sur le même type d’entité, ils doivent être nommés :

modelBuilder.Entity<Blog>()
    .HasQueryFilter("SoftDeletionFilter", b => !b.IsDeleted)
    .HasQueryFilter("TenantFilter", b => b.TenantId == tenantId);

Cela vous permet de gérer chaque filtre séparément, y compris la désactivation sélective d’un filtre, mais pas l’autre.

Désactivation des filtres

Les filtres peuvent être désactivés pour les requêtes LINQ individuelles à l’aide de l’opérateur IgnoreQueryFilters :

var allBlogs = await context.Blogs.IgnoreQueryFilters().ToListAsync();

Si plusieurs filtres nommés sont configurés, cela les désactive tous. Pour désactiver de manière sélective des filtres spécifiques (à partir d’EF 10), passez la liste des noms de filtres à désactiver :

var allBlogs = await context.Blogs.IgnoreQueryFilters(["SoftDeletionFilter"]).ToListAsync();

Filtres de requête et navigations requises

Avertissement

L’utilisation de la navigation requise pour accéder à l’entité qui a un filtre de requête global défini peut entraîner des résultats inattendus.

Les navigations requises dans EF impliquent que l’entité associée est toujours présente. Étant donné que les jointures internes peuvent être utilisées pour extraire des entités associées, si une entité associée requise est filtrée par le filtre de requête, l’entité parente peut également être filtrée. Cela peut entraîner une récupération inattendue de moins d’éléments que prévu.

Pour illustrer le problème, nous pouvons utiliser les entités Blog et Post et les configurer comme suit :

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired();
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));

Le modèle peut être alimenté avec les données suivantes :

db.Blogs.Add(
    new Blog
    {
        Url = "http://sample.com/blogs/fish",
        Posts =
        [
            new() { Title = "Fish care 101" },
            new() { Title = "Caring for tropical fish" },
            new() { Title = "Types of ornamental fish" }
        ]
    });

db.Blogs.Add(
    new Blog
    {
        Url = "http://sample.com/blogs/cats",
        Posts =
        [
            new() { Title = "Cat care 101" },
            new() { Title = "Caring for tropical cats" },
            new() { Title = "Types of ornamental cats" }
        ]
    });

Le problème peut être observé lors de l’exécution des deux requêtes suivantes :

var allPosts = await db.Posts.ToListAsync();
var allPostsWithBlogsIncluded = await db.Posts.Include(p => p.Blog).ToListAsync();

Avec la configuration ci-dessus, la première requête retourne toutes les 6 Post instances, mais la deuxième requête ne retourne que 3. Cette incompatibilité se produit car la Include méthode de la deuxième requête charge les entités associées Blog . Étant donné que la navigation entre Blog et Post est requise, EF Core utilise INNER JOIN lors de la construction de la requête :

SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[IsDeleted], [p].[Title], [t].[BlogId], [t].[Name], [t].[Url]
FROM [Posts] AS [p]
INNER JOIN (
    SELECT [b].[BlogId], [b].[Name], [b].[Url]
    FROM [Blogs] AS [b]
    WHERE [b].[Url] LIKE N'%fish%'
) AS [t] ON [p].[BlogId] = [t].[BlogId]

L'utilisation du filtre INNER JOIN supprime toutes les Post lignes dont les lignes associées Blog ont été supprimées par un filtre de requête. Ce problème peut être résolu en configurant la navigation en tant que navigation facultative au lieu de navigation obligatoire, ce qui entraîne EF à générer un LEFT JOIN au lieu d’un INNER JOIN:

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(false);
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));

Une autre approche consiste à spécifier des filtres cohérents sur les types d’entité Blog et Post ; une fois que les filtres correspondants sont appliqués aux deux types d’entités Blog et Post, les lignes Post qui pourraient se retrouver dans un état inattendu sont supprimées, et les deux requêtes retournent 3 résultats.

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired();
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));
modelBuilder.Entity<Post>().HasQueryFilter(p => p.Blog.Url.Contains("fish"));

Filtres de requête et IEntityTypeConfiguration

Si votre filtre de requête doit accéder à un ID de locataire ou à des informations contextuelles similaires, IEntityTypeConfiguration<TEntity> peut poser une complication supplémentaire car, contrairement à OnModelCreating, il n’existe aucune instance de votre type de contexte facilement disponible pour référencer depuis le filtre de requête. Pour contourner ce problème, ajoutez un contexte factice à votre type de configuration et référencez-le comme suit :

private sealed class CustomerEntityConfiguration : IEntityTypeConfiguration<Customer>
{
    private readonly SomeDbContext _context = null!;

    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.HasQueryFilter(d => d.TenantId == _context.TenantId);
    }
}

Limites

Les filtres de requête globaux présentent les limitations suivantes :

  • Les filtres ne peuvent être définis que pour le type d’entité racine d’une hiérarchie d’héritage.
  • Actuellement, EF Core ne détecte pas les cycles dans les définitions de filtre de requêtes globales. Vous devez donc être prudent lors de leur définition. S’il est spécifié incorrectement, les cycles peuvent entraîner des boucles infinies pendant la traduction de requête.