From 61f91d00048ec102da80165211ad646825b6a16e Mon Sep 17 00:00:00 2001 From: maumar Date: Tue, 19 May 2020 00:09:45 -0700 Subject: [PATCH] Improving query filters docs: - using navigations inside query filters, - using required navigations to acceess entity with query filter defined, - samples. --- entity-framework/core/querying/filters.md | 71 +++- samples/core/QueryFilters/Program.cs | 2 +- .../core/QueryFiltersNavigations/Program.cs | 310 ++++++++++++++++++ .../QueryFiltersNavigations.csproj | 11 + samples/core/Samples.sln | 30 +- 5 files changed, 416 insertions(+), 8 deletions(-) create mode 100644 samples/core/QueryFiltersNavigations/Program.cs create mode 100644 samples/core/QueryFiltersNavigations/QueryFiltersNavigations.csproj diff --git a/entity-framework/core/querying/filters.md b/entity-framework/core/querying/filters.md index 770075c15b..3ee13192a5 100644 --- a/entity-framework/core/querying/filters.md +++ b/entity-framework/core/querying/filters.md @@ -19,7 +19,7 @@ Global query filters are LINQ query predicates (a boolean expression typically p The following example shows how to use Global Query Filters to implement soft-delete and multi-tenancy query behaviors in a simple blogging model. > [!TIP] -> You can view this article's [sample](https://github.com/dotnet/EntityFramework.Docs/tree/master/samples/core/QueryFilters) on GitHub. +> You can view [multi-tenancy sample](https://github.com/dotnet/EntityFramework.Docs/tree/master/samples/core/QueryFilters) and [samples using navigations](https://github.com/dotnet/EntityFramework.Docs/tree/master/samples/core/QueryFiltersNavigations) on GitHub. First, define the entities: @@ -39,6 +39,75 @@ The predicate expressions passed to the _HasQueryFilter_ calls will now automati > [!NOTE] > It is currently not possible to define multiple query filters on the same entity - only the last one will be applied. However, you can define a single filter with multiple conditions using the logical _AND_ operator ([`&&` in C#](https://docs.microsoft.com/dotnet/csharp/language-reference/operators/boolean-logical-operators#conditional-logical-and-operator-)). +## Use of navigations + +Navigations can be used when defining global query filters. They are applied recursively - when navigations used in query filters are translated, query filters defined on referenced entities are also applied, potentially adding more navigations. + +> [!NOTE] +> Currently EF Core does not detect cycles in global query filter definitions, so you should be careful when defining them. If specified incorrectly, this could lead to infinite loops during query translation. + +## Accessing entity with query filter using reqiured navigation + +> [!CAUTION] +> Using required navigation to access entity which has global query filter defined may lead to unexpected results. + +Required navigation expects the related entity to always be present. If required related entity is filtered out by the query filter, the parent entity could end up in unexpected state. This may result in returning fewer elements than expected. + +To illustrate the problem, we can use the `Blog` and `Post` entities specified above and the following _OnModelCreating_ method: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(); + modelBuilder.Entity().HasQueryFilter(b => b.Url.Contains("fish")); +} +``` + +The model can be seeded with the following data: + +[!code-csharp[Main](../../../samples/core/QueryFiltersNavigations/Program.cs#SeedData)] + +The problem can be observed when executing two queries: + +[!code-csharp[Main](../../../samples/core/QueryFiltersNavigations/Program.cs#Queries)] + +With this setup, the first query returns all 6 `Post`s, however the second query only returns 3. This happens because _Include_ method in the second query loads the related `Blog` entities. Since the navigation between `Blog` and `Post` is required, EF Core uses `INNER JOIN` when constructing the query: + +```SQL +SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[IsDeleted], [p].[Title], [t].[BlogId], [t].[Name], [t].[Url] +FROM [Post] AS [p] +INNER JOIN ( + SELECT [b].[BlogId], [b].[Name], [b].[Url] + FROM [Blogs] AS [b] + WHERE CHARINDEX(N'fish', [b].[Url]) > 0 +) AS [t] ON [p].[BlogId] = [t].[BlogId] +``` + +Use of the `INNER JOIN` filters out all `Post`s whose related `Blog`s have been removed by a global query filter. + +It can be addressed by using optional navigation instead of required. +This way the first query stays the same as before, however the second query will now generate `LEFT JOIN` and return 6 results. + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(false); + modelBuilder.Entity().HasQueryFilter(b => b.Url.Contains("fish")); +} +``` + +Alternative approach is to specify consistent filters on both `Blog` and `Post` entities. +This way matching filters are applied to both `Blog` and `Post`. `Post`s that could end up in unexpected state are removed and both queries return 3 results. + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(); + modelBuilder.Entity().HasQueryFilter(b => b.Url.Contains("fish")); + modelBuilder.Entity().HasQueryFilter(p => p.Blog.Url.Contains("fish")); +} +``` + ## Disabling Filters Filters may be disabled for individual LINQ queries by using the `IgnoreQueryFilters()` operator. diff --git a/samples/core/QueryFilters/Program.cs b/samples/core/QueryFilters/Program.cs index ff925e9906..22412470e9 100644 --- a/samples/core/QueryFilters/Program.cs +++ b/samples/core/QueryFilters/Program.cs @@ -59,6 +59,7 @@ private static void SetupDatabase() { using (var db = new BloggingContext("diego")) { + db.Database.EnsureDeleted(); if (db.Database.EnsureCreated()) { db.Blogs.Add( @@ -198,7 +199,6 @@ public class Post public string Content { get; set; } public bool IsDeleted { get; set; } - public int BlogId { get; set; } public Blog Blog { get; set; } } #endregion diff --git a/samples/core/QueryFiltersNavigations/Program.cs b/samples/core/QueryFiltersNavigations/Program.cs new file mode 100644 index 0000000000..7d5c531637 --- /dev/null +++ b/samples/core/QueryFiltersNavigations/Program.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Samples +{ + public class Program + { + private static void Main() + { + QueryFiltersWithNavigationsExample(); + + QueryFiltersWithRequiredNavigationExample(); + } + + private static void QueryFiltersWithNavigationsExample() + { + SetupDatabase(); + + using (var animalContext = new AnimalContext()) + { + Console.WriteLine("*****************"); + Console.WriteLine("* Animal lovers *"); + Console.WriteLine("*****************"); + + // Jamie and Paul are filtered out. + // Paul doesn't own any pets. Jamie owns Puffy, but her pet has been filtered out. + var animalLovers = animalContext.People.ToList(); + DisplayResults(animalLovers); + + Console.WriteLine("**************************************************"); + Console.WriteLine("* Animal lovers and their pets - filters enabled *"); + Console.WriteLine("**************************************************"); + + // Jamie and Paul are filtered out. + // Paul doesn't own any pets. Jamie owns Puffy, but her pet has been filtered out. + // Simba's favorite toy has also been filtered out. + // Puffy is filtered out so he doesn't show up as Hati's friend. + var ownersAndTheirPets = animalContext.People + .Include(p => p.Pets) + .ThenInclude(p => ((Dog)p).FavoriteToy) + .ToList(); + + DisplayResults(ownersAndTheirPets); + + Console.WriteLine("*********************************************************"); + Console.WriteLine("* Animal lovers and their pets - query filters disabled *"); + Console.WriteLine("*********************************************************"); + + var ownersAndTheirPetsUnfiltered = animalContext.People + .IgnoreQueryFilters() + .Include(p => p.Pets) + .ThenInclude(p => ((Dog)p).FavoriteToy) + .ToList(); + + DisplayResults(ownersAndTheirPetsUnfiltered); + } + } + + private static void SetupDatabase() + { + using (var animalContext = new AnimalContext()) + { + if (animalContext.Database.EnsureCreated()) + { + var janice = new Person { Name = "Janice" }; + var jamie = new Person { Name = "Jamie" }; + var cesar = new Person { Name = "Cesar" }; + var paul = new Person { Name = "Paul" }; + var dominic = new Person { Name = "Dominic" }; + + var kibbles = new Cat { Name = "Kibbles", PrefersCardboardBoxes = false, Owner = janice }; + var sammy = new Cat { Name = "Sammy", PrefersCardboardBoxes = true, Owner = janice }; + var puffy = new Cat { Name = "Puffy", PrefersCardboardBoxes = true, Owner = jamie }; + var hati = new Dog { Name = "Hati", FavoriteToy = new Toy { Name = "Squeeky duck" }, Owner = dominic, FriendsWith = puffy }; + var simba = new Dog { Name = "Simba", FavoriteToy = new Toy { Name = "Bone" }, Owner = cesar, FriendsWith = sammy }; + puffy.Tolerates = hati; + sammy.Tolerates = simba; + + animalContext.People.AddRange(janice, jamie, cesar, paul, dominic); + animalContext.Animals.AddRange(kibbles, sammy, puffy, hati, simba); + animalContext.SaveChanges(); + } + } + } + + private static void DisplayResults(List people) + { + foreach (var person in people) + { + Console.WriteLine($"{person.Name}"); + if (person.Pets != null) + { + foreach (var pet in person.Pets) + { + Console.Write($" - {pet.Name} [{pet.GetType().Name}] "); + if (pet is Cat cat) + { + Console.Write($"| Prefers cardboard boxes: {(cat.PrefersCardboardBoxes ? "Yes" : "No")} "); + Console.WriteLine($"| Tolerates: {(cat.Tolerates != null ? cat.Tolerates.Name : "No one")}"); + } + else if (pet is Dog dog) + { + Console.Write($"| Favorite toy: {(dog.FavoriteToy != null ? dog.FavoriteToy.Name : "None")} "); + Console.WriteLine($"| Friend: {(dog.FriendsWith != null ? dog.FriendsWith.Name : "The Owner")}"); + } + } + } + + Console.WriteLine(); + } + } + + private static void QueryFiltersWithRequiredNavigationExample() + { + using (var db = new FilteredBloggingContextRequired()) + { + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + + #region SeedData + db.Blogs.Add( + new Blog + { + Url = "http://sample.com/blogs/fish", + Posts = new List + { + new Post { Title = "Fish care 101" }, + new Post { Title = "Caring for tropical fish" }, + new Post { Title = "Types of ornamental fish" } + } + }); + + db.Blogs.Add( + new Blog + { + Url = "http://sample.com/blogs/cats", + Posts = new List + { + new Post { Title = "Cat care 101" }, + new Post { Title = "Caring for tropical cats" }, + new Post { Title = "Types of ornamental cats" } + } + }); + #endregion + + db.SaveChanges(); + } + + Console.WriteLine("Use of required navigations to access entity with query filter demo"); + using (var db = new FilteredBloggingContextRequired()) + { + + #region Queries + var allPosts = db.Posts.ToList(); + var allPostsWithBlogsIncluded = db.Posts.Include(p => p.Blog).ToList(); + #endregion + + if (allPosts.Count == allPostsWithBlogsIncluded.Count) + { + Console.WriteLine($"Query filters set up correctly. Result count for both queries: {allPosts.Count}."); + } + else + { + Console.WriteLine("Unexpected discrepancy due to query filters and required navigations interaction."); + Console.WriteLine($"All posts count: {allPosts.Count}."); + Console.WriteLine($"All posts with blogs included count: {allPostsWithBlogsIncluded.Count}."); + } + } + } + } + + #region QueryFiltersWithNavigations model + public class AnimalContext : DbContext + { + private static readonly ILoggerFactory _loggerFactory + = LoggerFactory.Create( + builder => + { + builder + .AddFilter((category, level) => + level == LogLevel.Information + && category.EndsWith("Connection", StringComparison.Ordinal)) + .AddConsole(); + }); + + public DbSet People { get; set; } + public DbSet Animals { get; set; } + public DbSet Toys { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseSqlServer( + @"Server=(localdb)\mssqllocaldb;Database=Demo.QueryFiltersNavigations;Trusted_Connection=True;ConnectRetryCount=0;") + .UseLoggerFactory(_loggerFactory); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasOne(c => c.Tolerates).WithOne(d => d.FriendsWith).HasForeignKey(c => c.ToleratesId); + modelBuilder.Entity().HasOne(d => d.FavoriteToy).WithOne(t => t.BelongsTo).HasForeignKey(d => d.BelongsToId); + + modelBuilder.Entity().HasQueryFilter(p => p.Pets.Count > 0); + modelBuilder.Entity().HasQueryFilter(a => !a.Name.StartsWith("P")); + modelBuilder.Entity().HasQueryFilter(a => a.Name.Length > 5); + + // invalid - cycle in query filter definitions + //modelBuilder.Entity().HasQueryFilter(a => a.Owner.Name != "John"); + } + } + + public class Person + { + public int Id { get; set; } + public string Name { get; set; } + public List Pets { get; set; } + } + + public abstract class Animal + { + public int Id { get; set; } + public string Name { get; set; } + public Person Owner { get; set; } + } + + public class Cat : Animal + { + public bool PrefersCardboardBoxes { get; set; } + + public int? ToleratesId { get; set; } + + public Dog Tolerates { get; set; } + } + + public class Dog : Animal + { + public Toy FavoriteToy { get; set; } + public Cat FriendsWith { get; set; } + } + + public class Toy + { + public int Id { get; set; } + public string Name { get; set; } + public int? BelongsToId { get; set; } + public Dog BelongsTo { get; set; } + } + + #endregion + + #region QueryFiltersWithRequiredNavigation model + + public class Blog + { + public int BlogId { get; set; } + public string Name { get; set; } + public string Url { get; set; } + + public List Posts { get; set; } + } + + public class Post + { + public int PostId { get; set; } + public string Title { get; set; } + public string Content { get; set; } + public bool IsDeleted { get; set; } + + public Blog Blog { get; set; } + } + + public class FilteredBloggingContextRequired : DbContext + { + public DbSet Blogs { get; set; } + public DbSet Posts { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseSqlServer( + @"Server=(localdb)\mssqllocaldb;Database=Demo.QueryFiltersRequiredNavigations;Trusted_Connection=True;ConnectRetryCount=0;"); + } + + //incorrect setup - required navigation used to reference entity that has query filter defined, but no query filter for the entity on the other side of the navigation + //protected override void OnModelCreating(ModelBuilder modelBuilder) + //{ + // modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(); + // modelBuilder.Entity().HasQueryFilter(b => b.Url.Contains("fish")); + //} + + // correct setup #1 - optional navigation used to reference entity that has query filter defined + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(false); + modelBuilder.Entity().HasQueryFilter(b => b.Url.Contains("fish")); + } + + // correct setup #2 - required navigation used and query filters are applied for entities on both sides of the navigation, making sure results are consistent + //protected override void OnModelCreating(ModelBuilder modelBuilder) + //{ + // modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(); + // modelBuilder.Entity().HasQueryFilter(b => b.Url.Contains("fish")); + // modelBuilder.Entity().HasQueryFilter(p => p.Blog.Url.Contains("fish")); + //} + } + #endregion +} diff --git a/samples/core/QueryFiltersNavigations/QueryFiltersNavigations.csproj b/samples/core/QueryFiltersNavigations/QueryFiltersNavigations.csproj new file mode 100644 index 0000000000..7b61c0829b --- /dev/null +++ b/samples/core/QueryFiltersNavigations/QueryFiltersNavigations.csproj @@ -0,0 +1,11 @@ + + + Exe + netcoreapp3.1 + Samples + + + + + + diff --git a/samples/core/Samples.sln b/samples/core/Samples.sln index 3bdf0759c8..45730af7f9 100644 --- a/samples/core/Samples.sln +++ b/samples/core/Samples.sln @@ -47,17 +47,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cosmos", "Cosmos\Cosmos.csp EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EFGetStarted", "GetStarted\EFGetStarted.csproj", "{14A4E71C-57EB-4CB8-BBC3-C35CE4707E52}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlServer", "SqlServer\SqlServer.csproj", "{80756004-3664-481A-879F-E1A261328758}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SqlServer", "SqlServer\SqlServer.csproj", "{80756004-3664-481A-879F-E1A261328758}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ValueConversions", "Modeling\ValueConversions\ValueConversions.csproj", "{FE71504E-C32B-4E2F-9830-21ED448DABC4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ValueConversions", "Modeling\ValueConversions\ValueConversions.csproj", "{FE71504E-C32B-4E2F-9830-21ED448DABC4}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ItemsWebApi", "Miscellaneous\Testing\ItemsWebApi\ItemsWebApi\ItemsWebApi.csproj", "{ECF03060-646F-4B62-9446-1953F228CB09}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ItemsWebApi", "Miscellaneous\Testing\ItemsWebApi\ItemsWebApi\ItemsWebApi.csproj", "{ECF03060-646F-4B62-9446-1953F228CB09}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ItemsWebApi", "ItemsWebApi", "{8695C7BE-F9B2-477A-AD7B-C15DC5418F66}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Miscellaneous\Testing\ItemsWebApi\Tests\Tests.csproj", "{E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Miscellaneous\Testing\ItemsWebApi\Tests\Tests.csproj", "{E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedDatabaseTests", "Miscellaneous\Testing\ItemsWebApi\SharedDatabaseTests\SharedDatabaseTests.csproj", "{34C237C8-DD12-4C14-9B15-B7F85C218CDB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharedDatabaseTests", "Miscellaneous\Testing\ItemsWebApi\SharedDatabaseTests\SharedDatabaseTests.csproj", "{34C237C8-DD12-4C14-9B15-B7F85C218CDB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QueryFiltersNavigations", "QueryFiltersNavigations\QueryFiltersNavigations.csproj", "{CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -431,6 +433,22 @@ Global {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Release|x64.Build.0 = Release|Any CPU {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Release|x86.ActiveCfg = Release|Any CPU {34C237C8-DD12-4C14-9B15-B7F85C218CDB}.Release|x86.Build.0 = Release|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Debug|ARM.ActiveCfg = Debug|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Debug|ARM.Build.0 = Debug|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Debug|x64.ActiveCfg = Debug|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Debug|x64.Build.0 = Debug|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Debug|x86.ActiveCfg = Debug|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Debug|x86.Build.0 = Debug|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Release|Any CPU.Build.0 = Release|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Release|ARM.ActiveCfg = Release|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Release|ARM.Build.0 = Release|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Release|x64.ActiveCfg = Release|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Release|x64.Build.0 = Release|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Release|x86.ActiveCfg = Release|Any CPU + {CDB06F3E-A3D8-4DF8-AB32-71BF9B2EEC29}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -449,8 +467,8 @@ Global {802E31AD-2F1E-41A1-A662-5929E2626601} = {CA5046EC-C894-4535-8190-A31F75FDEB96} {63685B9A-1233-4B44-AAC1-8DDD4B16B65D} = {CA5046EC-C894-4535-8190-A31F75FDEB96} {FE71504E-C32B-4E2F-9830-21ED448DABC4} = {CA5046EC-C894-4535-8190-A31F75FDEB96} - {8695C7BE-F9B2-477A-AD7B-C15DC5418F66} = {4E2B02EE-0C76-42D6-BA0A-337D7680A5D6} {ECF03060-646F-4B62-9446-1953F228CB09} = {8695C7BE-F9B2-477A-AD7B-C15DC5418F66} + {8695C7BE-F9B2-477A-AD7B-C15DC5418F66} = {4E2B02EE-0C76-42D6-BA0A-337D7680A5D6} {E8AD02D7-8AFB-4233-BDAF-F0AEF986F1F3} = {8695C7BE-F9B2-477A-AD7B-C15DC5418F66} {34C237C8-DD12-4C14-9B15-B7F85C218CDB} = {8695C7BE-F9B2-477A-AD7B-C15DC5418F66} EndGlobalSection