From d5e1d19f970f4213e693c17b3e8912f77f6fc754 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Wed, 11 Jun 2025 08:26:40 +0200 Subject: [PATCH 1/2] Document new DateOnly.DayNumber translations (#5042) See https://github.com/dotnet/efcore/pull/36189 --- entity-framework/core/providers/sql-server/functions.md | 1 + entity-framework/core/providers/sqlite/functions.md | 1 + entity-framework/core/what-is-new/ef-core-10.0/whatsnew.md | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/entity-framework/core/providers/sql-server/functions.md b/entity-framework/core/providers/sql-server/functions.md index 815cb95bb4..fb8c77c998 100644 --- a/entity-framework/core/providers/sql-server/functions.md +++ b/entity-framework/core/providers/sql-server/functions.md @@ -127,6 +127,7 @@ dateOnly.Day | DATEPART(day, @dat dateOnly.DayOfYear | DATEPART(dayofyear, @dateOnly) | EF Core 8.0 dateOnly.Month | DATEPART(month, @dateOnly) | EF Core 8.0 dateOnly.Year | DATEPART(year, @dateOnly) | EF Core 8.0 +dateOnly.DayNumber | DATEDIFF(day, '0001-01-01', @dateOnly) | EF Core 10.0 EF.Functions.AtTimeZone(dateTime, timeZone) | @dateTime AT TIME ZONE @timeZone | EF Core 7.0 EF.Functions.DateDiffDay(start, end) | DATEDIFF(day, @start, @end) EF.Functions.DateDiffHour(start, end) | DATEDIFF(hour, @start, @end) diff --git a/entity-framework/core/providers/sqlite/functions.md b/entity-framework/core/providers/sqlite/functions.md index 4a25714363..0a656c6ab8 100644 --- a/entity-framework/core/providers/sqlite/functions.md +++ b/entity-framework/core/providers/sqlite/functions.md @@ -72,6 +72,7 @@ dateOnly.DayOfYear | strftime('%j', @dateOnly) DateOnly.FromDateTime(dateTime) | date(@dateTime) | EF Core 8.0 dateOnly.Month | strftime('%m', @dateOnly) dateOnly.Year | strftime('%Y', @dateOnly) +dateOnly.DayNumber | CAST(julianday(@dateOnly) - julianday('0001-01-01') AS INTEGER) | EF Core 10.0 DateTime.Now | datetime('now', 'localtime') DateTime.Today | datetime('now', 'localtime', 'start of day') DateTime.UtcNow | datetime('now') diff --git a/entity-framework/core/what-is-new/ef-core-10.0/whatsnew.md b/entity-framework/core/what-is-new/ef-core-10.0/whatsnew.md index 66582ece20..04a15105b3 100644 --- a/entity-framework/core/what-is-new/ef-core-10.0/whatsnew.md +++ b/entity-framework/core/what-is-new/ef-core-10.0/whatsnew.md @@ -130,7 +130,8 @@ See [#12793](https://github.com/dotnet/efcore/issues/12793) and [#35367](https:/ ### Other query improvements -- Translate DateOnly.ToDateTime(timeOnly) ([#35194](https://github.com/dotnet/efcore/pull/35194), contributed by [@mseada94](https://github.com/mseada94)). +- Translate [DateOnly.ToDateTime()](/dotnet/api/system.dateonly.todatetime) ([#35194](https://github.com/dotnet/efcore/pull/35194), contributed by [@mseada94](https://github.com/mseada94)). +- Translate [DateOnly.DayNumber](/dotnet/api/system.dateonly.daynumber) and `DayNumber` subtraction for SQL Server and SQLite ([#36183](https://github.com/dotnet/efcore/issues/36183)). - Optimize multiple consecutive `LIMIT`s ([#35384](https://github.com/dotnet/efcore/pull/35384), contributed by [@ranma42](https://github.com/ranma42)). - Optimize use of `Count` operation on `ICollection` ([#35381](https://github.com/dotnet/efcore/pull/35381), contributed by [@ChrisJollyAU](https://github.com/ChrisJollyAU)). - Optimize `MIN`/`MAX` over `DISTINCT` ([#34699](https://github.com/dotnet/efcore/pull/34699), contributed by [@ranma42](https://github.com/ranma42)). From 34b650ad50a95cd6ee17caaa5e0afc1c261a4384 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sat, 14 Jun 2025 09:50:48 +0200 Subject: [PATCH 2/2] Work on global query filter docs (#5049) And document the new named query filter feature: https://github.com/dotnet/efcore/issues/8576 Fixes #773 Fixes #1117 --- entity-framework/core/querying/filters.md | 145 +++++--- .../core/what-is-new/ef-core-10.0/whatsnew.md | 16 + samples/core/NuGet.config | 7 + .../Querying/QueryFilters/AnimalContext.cs | 30 -- .../Querying/QueryFilters/BloggingContext.cs | 57 ---- .../core/Querying/QueryFilters/Entities.cs | 65 ---- .../FilteredBloggingContextRequired.cs | 56 --- .../Querying/QueryFilters/Multitenancy.cs | 64 ++++ .../Querying/QueryFilters/NamedFilters.cs | 113 ++++++ samples/core/Querying/QueryFilters/Program.cs | 322 +----------------- .../Querying/QueryFilters/QueryFilters.csproj | 6 +- .../QueryFiltersAndRequiredNavigations.cs | 142 ++++++++ .../Querying/QueryFilters/SoftDeletion.cs | 108 ++++++ 13 files changed, 550 insertions(+), 581 deletions(-) create mode 100644 samples/core/NuGet.config delete mode 100644 samples/core/Querying/QueryFilters/AnimalContext.cs delete mode 100644 samples/core/Querying/QueryFilters/BloggingContext.cs delete mode 100644 samples/core/Querying/QueryFilters/Entities.cs delete mode 100644 samples/core/Querying/QueryFilters/FilteredBloggingContextRequired.cs create mode 100644 samples/core/Querying/QueryFilters/Multitenancy.cs create mode 100644 samples/core/Querying/QueryFilters/NamedFilters.cs create mode 100644 samples/core/Querying/QueryFilters/QueryFiltersAndRequiredNavigations.cs create mode 100644 samples/core/Querying/QueryFilters/SoftDeletion.cs diff --git a/entity-framework/core/querying/filters.md b/entity-framework/core/querying/filters.md index cc0711dc80..f94af0cae6 100644 --- a/entity-framework/core/querying/filters.md +++ b/entity-framework/core/querying/filters.md @@ -7,83 +7,115 @@ uid: core/querying/filters --- # Global Query Filters -Global query filters are LINQ query predicates applied to Entity Types in the metadata model (usually in `OnModelCreating`). A query predicate is a boolean expression typically passed to the LINQ `Where` query operator. EF Core applies such filters automatically to any LINQ queries involving those Entity Types. EF Core also applies them to Entity Types, referenced indirectly through use of Include or navigation property. Some common applications of this feature are: +Global query filters allow attaching a filter to an entity type and having that filter applied whenever a query on that entity type is executed; think of them as an additional LINQ `Where` operator that's added whenever the entity type is queried. Such filters are useful in a variety of cases. -* **Soft delete** - An Entity Type defines an `IsDeleted` property. -* **Multi-tenancy** - An Entity Type defines a `TenantId` property. +> [!TIP] +> You can view this article's [sample](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Querying/QueryFilters) on GitHub. -## Example +## Basic example - soft deletion -The following example shows how to use Global Query Filters to implement multi-tenancy and soft-delete query behaviors in a simple blogging model. +In some scenarios, rather than deleting a row from the database, it's preferable to instead set an `IsDeleted` flag to mark the row as deleted; this pattern is called *soft deletion*. Soft deletion allows rows to be undeleted if needed, or to preserve an audit trail where deleted rows are still accessible. Global query filters can be used to filter out soft-deleted rows by default, while still allowing you to access them in specific places by disabling the filter for a specific query. -> [!TIP] -> You can view this article's [sample](https://github.com/dotnet/EntityFramework.Docs/tree/main/samples/core/Querying/QueryFilters) on GitHub. +To enable soft deletion, let's add an `IsDeleted` property to our Blog type: -> [!NOTE] -> Multi-tenancy is used here as a simple example. There is also an article with comprehensive guidance for [multi-tenancy in EF Core applications](xref:core/miscellaneous/multitenancy). +[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/SoftDeletion.cs#Blog)] -First, define the entities: +We now set up a global query filter, using the API in `OnModelCreating`: -[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/Entities.cs#Entities)] +[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/SoftDeletion.cs#FilterConfiguration)] -Note the declaration of a `_tenantId` field on the `Blog` entity. This field will be used to associate each Blog instance with a specific tenant. Also defined is an `IsDeleted` property on the `Post` entity type. This property is used to keep track of whether a post instance has been "soft-deleted". That is, the instance is marked as deleted without physically removing the underlying data. +We can now query our `Blog` entities as usual; the configured filter will ensure that all queries will - by default - filter out all instances where `IsDeleted` is true. -Next, configure the query filters in `OnModelCreating` using the `HasQueryFilter` API. +Note that at this point, you must manually set `IsDeleted` in order to soft-delete an entity. For a more end-to-end solution, you can override your context type's `SaveChangesAsync` method to add logic which goes over all entities which the user deleted, and changes them to be modified instead, setting the `IsDeleted` property to true: -[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/BloggingContext.cs#FilterConfiguration)] +[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/SoftDeletion.cs#SaveChangesAsyncOverride)] -The predicate expressions passed to the `HasQueryFilter` calls will now automatically be applied to any LINQ queries for those types. +This allows you to use EF APIs that delete an entity instance as usual and have them get soft-deleted instead. -> [!TIP] -> Note the use of a DbContext instance level field: `_tenantId` used to set the current tenant. Model-level filters will use the value from the correct context instance (that is, the instance that is executing the query). +## Using context data - multi-tenancy -> [!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#](/dotnet/csharp/language-reference/operators/boolean-logical-operators#conditional-logical-and-operator-)). +Another mainstream scenario for global query filters is *multi-tenancy*, where your application stores data belonging to different users in the same table. In such cases, there's usually a *tenant ID* column which associates the row to a specific tenant, and global query filters can be used to automatically filter for the rows of the current tenant. This provides strong tenant isolation for your queries by default, removing the need to think of filtering for the tenant in each and every query. -## Use of navigations +Unlike with soft deletion, multi-tenancy requires knowing the *current* tenant ID; this value is usually determined e.g. when the user authenticates over the web. For EF's purposes, the tenant ID must be available on the context instance, so that the global query filter can refer to it and use it when querying. Let's accept a `tenantId` parameter in our context type's constructor, and reference that from our filter: -You can also use navigations in defining global query filters. Using navigations in query filter will cause query filters to be applied recursively. When EF Core expands navigations used in query filters, it will also apply query filters defined on referenced entities. +```c# +public class MultitenancyContext(string tenantId) : DbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasQueryFilter(b => b.TenantId == tenantId); + } +} +``` + +This forces anyone constructing a context to specify its associated tenant ID, and ensures that only `Blog` entities with that ID are returned from queries by default. -To illustrate this configure query filters in `OnModelCreating` in the following way: -[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/FilteredBloggingContextRequired.cs#NavigationInFilter)] +> [!NOTE] +> This sample only showed basic multi-tenancy concepts needed in order to demonstrate global query filters. For more information on multi-tenancy and EF, see [multi-tenancy in EF Core applications](xref:core/miscellaneous/multitenancy). -Next, query for all `Blog` entities: -[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/Program.cs#QueriesNavigation)] +## Using multiple query filters -This query produces the following SQL, which applies query filters defined for both `Blog` and `Post` entities: +Calling with a simple filter overwrites any previous filter, so multiple filters **cannot** be defined on the same entity type in this way: -```sql -SELECT [b].[BlogId], [b].[Name], [b].[Url] -FROM [Blogs] AS [b] -WHERE ( - SELECT COUNT(*) - FROM [Posts] AS [p] - WHERE ([p].[Title] LIKE N'%fish%') AND ([b].[BlogId] = [p].[BlogId])) > 0 +```c# +modelBuilder.Entity().HasQueryFilter(b => !b.IsDeleted); +// The following overwrites the previous query filter: +modelBuilder.Entity().HasQueryFilter(b => b.TenantId == tenantId); ``` +### [EF 10+](#tab/ef10) + > [!NOTE] -> Currently EF Core does not detect cycles in global query filter definitions, so you should be careful when defining them. If specified incorrectly, cycles could lead to infinite loops during query translation. +> This feature is being introduced in EF Core 10.0 (in preview). + +In order to define multiple query filters on the same entity type, they must be *named*: + +[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/NamedFilters.cs#FilterConfiguration)] -## Accessing entity with query filter using required navigation +This allows you to manage each filter separately, including selectively disabling one but not the other. + +### [Older versions](#tab/older) + +Prior to EF 10, you can attach multiple filters to an entity type by calling once and combining your filters using the `&&` operator: + +```c# +modelBuilder.Entity().HasQueryFilter(b => !b.IsDeleted && b.TenantId == tenantId); +``` + +This unfortunately does not allow to selectively disable a single filter. + +*** + +## Disabling filters + +Filters may be disabled for individual LINQ queries by using the operator: + +[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/SoftDeletion.cs#DisableFilter)] + +If multiple named filters are configured, this disables all of them. To selectively disable specific filters (starting with EF 10), pass the list of filter names to be disabled: + +[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/NamedFilters.cs#DisableSoftDeletionFilter)] + +## Query filters and required navigations > [!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 necessary related entity is filtered out by the query filter, the parent entity wouldn't be in result either. So you may get fewer elements than expected in result. +Required navigations in EF imply that the related entity is always present. Since inner joins may be used to fetch related entities, if a required related entity is filtered out by the query filter, the parent entity may get filtered out as well. This can result in unexpectedly retrieving fewer elements than expected. -To illustrate the problem, we can use the `Blog` and `Post` entities specified above and the following `OnModelCreating` method: +To illustrate the problem, we can use `Blog` and `Post` entities and configure them as follows: -[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/FilteredBloggingContextRequired.cs#IncorrectFilter)] +[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/QueryFiltersAndRequiredNavigations.cs#IncorrectFilter)] The model can be seeded with the following data: -[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/Program.cs#SeedData)] +[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/QueryFiltersAndRequiredNavigations.cs#SeedData)] -The problem can be observed when executing two queries: +The problem can be observed when executing the following two queries: -[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/Program.cs#Queries)] +[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/QueryFiltersAndRequiredNavigations.cs#Queries)] -With above setup, the first query returns all 6 `Post`s, however the second query only returns 3. This mismatch 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: +With the above setup, the first query returns all 6 `Post` instances, but the second query returns only 3. This mismatch occurs because the `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] @@ -95,26 +127,33 @@ INNER JOIN ( ) 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. +Use of the `INNER JOIN` filters out all `Post` rows whose related `Blog` rows have been filtered out by a query filter. This problem can be addressed by configuring the navigation as optional navigation instead of required, causing EF to generate a `LEFT JOIN` instead of an `INNER JOIN`: -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. +[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/QueryFiltersAndRequiredNavigations.cs#OptionalNavigation)] -[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/FilteredBloggingContextRequired.cs#OptionalNavigation)] +An alternative approach is to specify consistent filters on both `Blog` and `Post` entity types; once matching filters are applied to both `Blog` and `Post`, `Post` rows that could end up in unexpected state are removed and both queries return 3 results. -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. +[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/QueryFiltersAndRequiredNavigations.cs#MatchingFilters)] -[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/FilteredBloggingContextRequired.cs#MatchingFilters)] +## Query filters and IEntityTypeConfiguration -## Disabling Filters +If your query filter needs to access a tenant ID or similar contextual information, can pose an additional complication as unlike with `OnModelCreating`, there's no instance of your context type readily available to reference from the query filter. As a workaround, add a dummy context to your configuration type and reference that as follows: -Filters may be disabled for individual LINQ queries by using the operator. +```c# +private sealed class CustomerEntityConfiguration : IEntityTypeConfiguration +{ + private readonly SomeDbContext _context == null!; -[!code-csharp[Main](../../../samples/core/Querying/QueryFilters/Program.cs#IgnoreFilters)] + public void Configure(EntityTypeBuilder builder) + { + builder.HasQueryFilter(d => d.TenantId == _context.TenantId); + } +} +``` ## Limitations Global query filters have the following limitations: -* Filters can only be defined for the root Entity Type of an inheritance hierarchy. +* Filters can only be defined for the root entity type of an inheritance hierarchy. +* Currently EF Core does not detect cycles in global query filter definitions, so you should be careful when defining them. If specified incorrectly, cycles could lead to infinite loops during query translation. diff --git a/entity-framework/core/what-is-new/ef-core-10.0/whatsnew.md b/entity-framework/core/what-is-new/ef-core-10.0/whatsnew.md index 04a15105b3..bdcaf1d610 100644 --- a/entity-framework/core/what-is-new/ef-core-10.0/whatsnew.md +++ b/entity-framework/core/what-is-new/ef-core-10.0/whatsnew.md @@ -19,6 +19,22 @@ EF10 requires the .NET 10 SDK to build and requires the .NET 10 runtime to run. +## Named query filters + +EF's [global query filters](xref:core/querying/filters) feature has long enabled users to configuring filters to entity types which apply to all queries by default. This has simplified implementing common patterns and scenarios such as soft deletion, multitenancy and others. However, up to now EF has only supported a single query filter per entity type, making it difficult to have multiple filters and selectively disabling only some of them in specific queries. + +EF 10 introduces *named query filters*, which allow attaching names to query filter and managing each one separately: + +[!code-csharp[Main](../../../../samples/core/Querying/QueryFilters/NamedFilters.cs#FilterConfiguration)] + +This notably allows disabling only certain filters in a specific LINQ query: + +[!code-csharp[Main](../../../../samples/core/Querying/QueryFilters/NamedFilters.cs#DisableSoftDeletionFilter)] + +For more information on named query filters, see the [documentation](xref:core/querying/filters). + +This feature was contributed by [@bittola](https://github.com/bittola). + ## Azure Cosmos DB for NoSQL diff --git a/samples/core/NuGet.config b/samples/core/NuGet.config new file mode 100644 index 0000000000..ddf33811ef --- /dev/null +++ b/samples/core/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/samples/core/Querying/QueryFilters/AnimalContext.cs b/samples/core/Querying/QueryFilters/AnimalContext.cs deleted file mode 100644 index 7612ba09fc..0000000000 --- a/samples/core/Querying/QueryFilters/AnimalContext.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace EFQuerying.QueryFilters; - -public class AnimalContext : DbContext -{ - 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=Querying.QueryFilters.Animals;Trusted_Connection=True;ConnectRetryCount=0"); - } - - 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 query filter configuration as it causes cycles in query filters - //modelBuilder.Entity().HasQueryFilter(a => a.Owner.Name != "John"); - } -} diff --git a/samples/core/Querying/QueryFilters/BloggingContext.cs b/samples/core/Querying/QueryFilters/BloggingContext.cs deleted file mode 100644 index c14d8e5eb8..0000000000 --- a/samples/core/Querying/QueryFilters/BloggingContext.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; - -namespace EFQuerying.QueryFilters; - -public class BloggingContext : DbContext -{ - private readonly string _tenantId; - - public BloggingContext(string tenant) - { - _tenantId = tenant; - } - - public DbSet Blogs { get; set; } - public DbSet Posts { get; set; } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder - .UseSqlServer( - @"Server=(localdb)\mssqllocaldb;Database=Querying.QueryFilters.Blogging;Trusted_Connection=True;ConnectRetryCount=0"); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity().Property("_tenantId").HasColumnName("TenantId"); - - // Configure entity filters - #region FilterConfiguration - modelBuilder.Entity().HasQueryFilter(b => EF.Property(b, "_tenantId") == _tenantId); - modelBuilder.Entity().HasQueryFilter(p => !p.IsDeleted); - #endregion - } - - public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) - { - ChangeTracker.DetectChanges(); - - foreach (var item in ChangeTracker.Entries().Where( - e => - e.State == EntityState.Added && e.Metadata.GetProperties().Any(p => p.Name == "_tenantId"))) - { - item.CurrentValues["_tenantId"] = _tenantId; - } - - foreach (var item in ChangeTracker.Entries().Where(e => e.State == EntityState.Deleted)) - { - item.State = EntityState.Modified; - item.CurrentValues["IsDeleted"] = true; - } - - return await base.SaveChangesAsync(cancellationToken); - } -} diff --git a/samples/core/Querying/QueryFilters/Entities.cs b/samples/core/Querying/QueryFilters/Entities.cs deleted file mode 100644 index 842db652ea..0000000000 --- a/samples/core/Querying/QueryFilters/Entities.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Collections.Generic; - -namespace EFQuerying.QueryFilters; - -#region Entities -public class Blog -{ -#pragma warning disable IDE0051, CS0169 // Remove unused private members - private string _tenantId; -#pragma warning restore IDE0051, CS0169 // Remove unused private members - - 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; } -} -#endregion - -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; } -} \ No newline at end of file diff --git a/samples/core/Querying/QueryFilters/FilteredBloggingContextRequired.cs b/samples/core/Querying/QueryFilters/FilteredBloggingContextRequired.cs deleted file mode 100644 index d9a4ad1a64..0000000000 --- a/samples/core/Querying/QueryFilters/FilteredBloggingContextRequired.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace EFQuerying.QueryFilters; - -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=Querying.QueryFilters.BloggingRequired;Trusted_Connection=True;ConnectRetryCount=0"); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - var setup = "OptionalNav"; - if (setup == "Faulty") - { - // 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. - #region IncorrectFilter - modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(); - modelBuilder.Entity().HasQueryFilter(b => b.Url.Contains("fish")); - #endregion - } - else if (setup == "OptionalNav") - { - // The relationship is marked as optional so dependent can exist even if principal is filtered out. - #region OptionalNavigation - modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(false); - modelBuilder.Entity().HasQueryFilter(b => b.Url.Contains("fish")); - #endregion - } - else if (setup == "NavigationInFilter") - { - #region NavigationInFilter - modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog); - modelBuilder.Entity().HasQueryFilter(b => b.Posts.Count > 0); - modelBuilder.Entity().HasQueryFilter(p => p.Title.Contains("fish")); - #endregion - } - else - { - // The relationship is still required but there is a matching filter configured on dependent side too, - // which matches principal side. So if principal is filtered out, the dependent would also be. - #region MatchingFilters - 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/Querying/QueryFilters/Multitenancy.cs b/samples/core/Querying/QueryFilters/Multitenancy.cs new file mode 100644 index 0000000000..c30fbb9e52 --- /dev/null +++ b/samples/core/Querying/QueryFilters/Multitenancy.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +#nullable enable + +namespace EFQuerying.QueryFilters; + +public static class Multitenancy +{ + public static async Task Sample() + { + // First, create the database and add some data to it + using (var context = new MultitenancyContext("John")) + { + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + + context.Blogs.AddRange( + new() { Name = "John's blog", TenantId = "John" }, + new() { Name = "Mary's blog", TenantId = "Mary" }); + await context.SaveChangesAsync(); + } + + // Now, let's query out all blogs. + // Since we specify Mary as the tenant ID for the context, only Mary's blogs will be returned. + using (var context = new MultitenancyContext("Mary")) + { + Console.WriteLine("Blogs:"); + await foreach (var blog in context.Blogs) + { + Console.WriteLine(blog.Name); + } + } + } + + public class MultitenancyContext(string tenantId) : DbContext + { + public DbSet Blogs { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseSqlServer( + @"Server=(localdb)\mssqllocaldb;Database=Querying.QueryFilters.Blogging;Trusted_Connection=True;ConnectRetryCount=0"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasQueryFilter(b => b.TenantId == tenantId); + } + } + + #region Blog + public class Blog + { + public int Id { get; set; } + public required string TenantId { get; set; } + + public string Name { get; set; } + } + #endregion +} diff --git a/samples/core/Querying/QueryFilters/NamedFilters.cs b/samples/core/Querying/QueryFilters/NamedFilters.cs new file mode 100644 index 0000000000..95cd3f4ad4 --- /dev/null +++ b/samples/core/Querying/QueryFilters/NamedFilters.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +#nullable enable + +namespace EFQuerying.QueryFilters; + +public static class NamedFilters +{ + public static async Task Sample() + { + // First, create the database and add some data to it + using (var context = new NamedFiltersContext("John")) + { + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + + context.Blogs.AddRange( + new() { Name = "John's first blog", TenantId = "John" }, + new() { Name = "John's second blog", TenantId = "John" }, + new() { Name = "Mary's blog", TenantId = "Mary" }); + await context.SaveChangesAsync(); + } + + // Let's delete a blog. Note that although our code seems to delete the blog in the regular way, + // our override of SaveChangesAsync below will actually modify it instead, setting the IsDeleted property to true. + using (var context = new NamedFiltersContext("John")) + { + var blog = await context.Blogs.FirstAsync(b => b.Name == "John's first blog"); + context.Blogs.Remove(blog); + await context.SaveChangesAsync(); + } + + // Now, let's query out all blogs. + // We only get John's second blog, because the first is filtered out by the soft deletion filter, and Mary's blog + // by the multitenancy filter. + using (var context = new NamedFiltersContext("John")) + { + Console.WriteLine("Blogs:"); + await foreach (var blog in context.Blogs) + { + Console.WriteLine(blog.Name); + } + } + + // Let's selectively disable only the soft deletion filter: + using (var context = new NamedFiltersContext("John")) + { + Console.WriteLine("Blogs (including soft-deleted ones):"); + #region DisableSoftDeletionFilter + var allBlogs = await context.Blogs.IgnoreQueryFilters(["SoftDeletionFlter"]).ToListAsync(); + #endregion + + foreach (var blog in allBlogs) + { + Console.WriteLine(blog.Name); + } + } + } + + public class NamedFiltersContext(string tenantId) : DbContext + { + public DbSet Blogs { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseSqlServer( + @"Server=(localdb)\mssqllocaldb;Database=Querying.QueryFilters.Blogging;Trusted_Connection=True;ConnectRetryCount=0"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region FilterConfiguration + modelBuilder.Entity() + .HasQueryFilter("SoftDeletionFlter", b => !b.IsDeleted) + .HasQueryFilter("TenantFilter", b => b.TenantId == tenantId); + #endregion + } + + // The following overrides SaveChangesAsync to add logic which goes over all entities which the user deleted, and changes + // them to be modified instead, setting the IsDeleted property to true. + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + ChangeTracker.DetectChanges(); + + foreach (var item in ChangeTracker.Entries().Where(e => e.State == EntityState.Deleted)) + { + item.State = EntityState.Modified; + item.CurrentValues["IsDeleted"] = true; + } + + return await base.SaveChangesAsync(cancellationToken); + } + + public override int SaveChanges() + => throw new NotSupportedException("Use SaveChangesAsync instead."); + } + + #region Blog + public class Blog + { + public int Id { get; set; } + public bool IsDeleted { get; set; } + public required string TenantId { get; set; } + + public string Name { get; set; } + } + #endregion +} diff --git a/samples/core/Querying/QueryFilters/Program.cs b/samples/core/Querying/QueryFilters/Program.cs index 0cbb694430..a133cb495f 100644 --- a/samples/core/Querying/QueryFilters/Program.cs +++ b/samples/core/Querying/QueryFilters/Program.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; +using System.Threading.Tasks; namespace EFQuerying.QueryFilters; @@ -10,317 +6,9 @@ internal class Program { private static async Task Main(string[] args) { - await QueryFiltersBasicExample(); - await QueryFiltersWithNavigationsExample(); - await QueryFiltersWithRequiredNavigationExample(); - await QueryFiltersUsingNavigationExample(); - } - - private static async Task QueryFiltersBasicExample() - { - using (var db = new BloggingContext("diego")) - { - if (await db.Database.EnsureCreatedAsync()) - { - 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" } - } - }); - - await db.SaveChangesAsync(); - - using (var andrewDb = new BloggingContext("andrew")) - { - andrewDb.Blogs.Add( - new Blog - { - Url = "http://sample.com/blogs/catfish", - Posts = new List - { - new Post { Title = "Catfish care 101" }, new Post { Title = "History of the catfish name" } - } - }); - - await andrewDb.SaveChangesAsync(); - } - - (await db.Posts - .Where( - p => p.Title == "Caring for tropical fish" - || p.Title == "Cat care 101") - .ToListAsync()) - .ForEach(p => db.Posts.Remove(p)); - - await db.SaveChangesAsync(); - } - } - - using (var db = new BloggingContext("Diego")) - { - var blogs = await db.Blogs - .Include(b => b.Posts) - .ToListAsync(); - - foreach (var blog in blogs) - { - Console.WriteLine( - $"{blog.Url,-33} [Tenant: {db.Entry(blog).Property("_tenantId").CurrentValue}]"); - - foreach (var post in blog.Posts) - { - Console.WriteLine($" - {post.Title,-30} [IsDeleted: {post.IsDeleted}]"); - } - - Console.WriteLine(); - } - - #region IgnoreFilters - blogs = await db.Blogs - .Include(b => b.Posts) - .IgnoreQueryFilters() - .ToListAsync(); - #endregion - - foreach (var blog in blogs) - { - Console.WriteLine( - $"{blog.Url,-33} [Tenant: {db.Entry(blog).Property("_tenantId").CurrentValue}]"); - - foreach (var post in blog.Posts) - { - Console.WriteLine($" - {post.Title,-30} [IsDeleted: {post.IsDeleted}]"); - } - } - } - } - - private static async Task QueryFiltersWithNavigationsExample() - { - using (var animalContext = new AnimalContext()) - { - await animalContext.Database.EnsureDeletedAsync(); - await animalContext.Database.EnsureCreatedAsync(); - - 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); - await animalContext.SaveChangesAsync(); - } - - 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 = await animalContext.People.ToListAsync(); - 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 = await animalContext.People - .Include(p => p.Pets) - .ThenInclude(p => ((Dog)p).FavoriteToy) - .ToListAsync(); - - DisplayResults(ownersAndTheirPets); - - Console.WriteLine("*********************************************************"); - Console.WriteLine("* Animal lovers and their pets - query filters disabled *"); - Console.WriteLine("*********************************************************"); - - var ownersAndTheirPetsUnfiltered = await animalContext.People - .IgnoreQueryFilters() - .Include(p => p.Pets) - .ThenInclude(p => ((Dog)p).FavoriteToy) - .ToListAsync(); - - DisplayResults(ownersAndTheirPetsUnfiltered); - } - - 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")}"); - } - } - } - } - } - } - - private static async Task QueryFiltersWithRequiredNavigationExample() - { - using (var db = new FilteredBloggingContextRequired()) - { - await db.Database.EnsureDeletedAsync(); - await db.Database.EnsureCreatedAsync(); - - #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 - - await db.SaveChangesAsync(); - } - - Console.WriteLine("Use of required navigations to access entity with query filter demo"); - using (var db = new FilteredBloggingContextRequired()) - { - #region Queries - var allPosts = await db.Posts.ToListAsync(); - var allPostsWithBlogsIncluded = await db.Posts.Include(p => p.Blog).ToListAsync(); - #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}."); - } - } - } - - private static async Task QueryFiltersUsingNavigationExample() - { - using (var db = new FilteredBloggingContextRequired()) - { - await db.Database.EnsureDeletedAsync(); - await db.Database.EnsureCreatedAsync(); - - #region SeedDataNavigation - 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" } - } - }); - - db.Blogs.Add( - new Blog - { - Url = "http://sample.com/blogs/catfish", - Posts = new List - { - new Post { Title = "Catfish care 101" }, new Post { Title = "History of the catfish name" } - } - }); - #endregion - - await db.SaveChangesAsync(); - } - - Console.WriteLine("Query filters using navigations demo"); - using (var db = new FilteredBloggingContextRequired()) - { - #region QueriesNavigation - var filteredBlogs = await db.Blogs.ToListAsync(); - #endregion - var filteredBlogsInclude = await db.Blogs.Include(b => b.Posts).ToListAsync(); - if (filteredBlogs.Count == 2 - && filteredBlogsInclude.Count == 2) - { - Console.WriteLine("Blogs without any Posts are also filtered out. Posts must contain 'fish' in title."); - Console.WriteLine( - "Filters are applied recursively, so Blogs that do have Posts, but those Posts don't contain 'fish' in the title will also be filtered out."); - } - } + await SoftDeletion.Sample(); + await Multitenancy.Sample(); + await NamedFilters.Sample(); + await QueryFiltersAndRequiredNavigations.Sample(); } } diff --git a/samples/core/Querying/QueryFilters/QueryFilters.csproj b/samples/core/Querying/QueryFilters/QueryFilters.csproj index b0eb3ed38b..0fd5561e65 100644 --- a/samples/core/Querying/QueryFilters/QueryFilters.csproj +++ b/samples/core/Querying/QueryFilters/QueryFilters.csproj @@ -2,14 +2,14 @@ Exe - net8.0 + net10.0 EFQuerying.QueryFilters EFQuerying.QueryFilters - - + + diff --git a/samples/core/Querying/QueryFilters/QueryFiltersAndRequiredNavigations.cs b/samples/core/Querying/QueryFilters/QueryFiltersAndRequiredNavigations.cs new file mode 100644 index 0000000000..1b3e8ddebd --- /dev/null +++ b/samples/core/Querying/QueryFilters/QueryFiltersAndRequiredNavigations.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace EFQuerying.QueryFilters; + +public static class QueryFiltersAndRequiredNavigations +{ + public static async Task Sample() + { + using (var db = new QueryFiltersAndRequiredNavigationsContext()) + { + await db.Database.EnsureDeletedAsync(); + await db.Database.EnsureCreatedAsync(); + + #region SeedData + 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" } + ] + }); + #endregion + + await db.SaveChangesAsync(); + } + + Console.WriteLine("Use of required navigations to access entity with query filter demo"); + using (var db = new QueryFiltersAndRequiredNavigationsContext()) + { + #region Queries + var allPosts = await db.Posts.ToListAsync(); + var allPostsWithBlogsIncluded = await db.Posts.Include(p => p.Blog).ToListAsync(); + #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}."); + } + } + } + + public class QueryFiltersAndRequiredNavigationsContext : DbContext + { + public DbSet Blogs { get; set; } + public DbSet Posts { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder + .UseSqlServer( + @"Server=(localdb)\mssqllocaldb;Database=Querying.QueryFilters.BloggingRequired;Trusted_Connection=True;ConnectRetryCount=0"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + var setup = "OptionalNav"; + if (setup == "Faulty") + { + // 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. + #region IncorrectFilter + modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(); + modelBuilder.Entity().HasQueryFilter(b => b.Url.Contains("fish")); + #endregion + } + else if (setup == "OptionalNav") + { + // The relationship is marked as optional so dependent can exist even if principal is filtered out. + #region OptionalNavigation + modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(false); + modelBuilder.Entity().HasQueryFilter(b => b.Url.Contains("fish")); + #endregion + } + else if (setup == "NavigationInFilter") + { + #region NavigationInFilter + modelBuilder.Entity().HasMany(b => b.Posts).WithOne(p => p.Blog); + modelBuilder.Entity().HasQueryFilter(b => b.Posts.Count > 0); + modelBuilder.Entity().HasQueryFilter(p => p.Title.Contains("fish")); + #endregion + } + else + { + // The relationship is still required but there is a matching filter configured on dependent side too, + // which matches principal side. So if principal is filtered out, the dependent would also be. + #region MatchingFilters + 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 + } + } + } + + public class Blog + { + #pragma warning disable IDE0051, CS0169 // Remove unused private members + private string _tenantId; + #pragma warning restore IDE0051, CS0169 // Remove unused private members + + 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; } + } +} diff --git a/samples/core/Querying/QueryFilters/SoftDeletion.cs b/samples/core/Querying/QueryFilters/SoftDeletion.cs new file mode 100644 index 0000000000..ee91b9a288 --- /dev/null +++ b/samples/core/Querying/QueryFilters/SoftDeletion.cs @@ -0,0 +1,108 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +#nullable enable + +namespace EFQuerying.QueryFilters; + +public static class SoftDeletion +{ + public static async Task Sample() + { + // First, create the database and add some data to it + using (var context = new SoftDeleteContext()) + { + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + + context.Blogs.AddRange( + new() { Name = "John's blog" }, + new() { Name = "Mary's blog" }); + await context.SaveChangesAsync(); + } + + // Let's delete a blog. Note that although our code seems to delete the blog in the regular way, + // our override of SaveChangesAsync below will actually modify it instead, setting the IsDeleted property to true. + using (var context = new SoftDeleteContext()) + { + var blog = await context.Blogs.FirstAsync(b => b.Name == "John's blog"); + context.Blogs.Remove(blog); + await context.SaveChangesAsync(); + } + + // Now, let's query out all blogs. The global query filter will ensure that John's blog is not returned, because it has been soft-deleted. + using (var context = new SoftDeleteContext()) + { + Console.WriteLine("Blogs:"); + await foreach (var blog in context.Blogs) + { + Console.WriteLine(blog.Name); + } + } + + // Finally, for auditing reasons, let's now query out all blogs, John's blog is returned even though it has been soft-deleted. + using (var context = new SoftDeleteContext()) + { + Console.WriteLine("Blogs (including soft-deleted ones):"); + #region DisableFilter + var allBlogs = await context.Blogs.IgnoreQueryFilters().ToListAsync(); + #endregion + + foreach (var blog in allBlogs) + { + Console.WriteLine(blog.Name); + } + } + } + + public class SoftDeleteContext : DbContext + { + public DbSet Blogs { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseSqlServer( + @"Server=(localdb)\mssqllocaldb;Database=Querying.QueryFilters.Blogging;Trusted_Connection=True;ConnectRetryCount=0"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region FilterConfiguration + modelBuilder.Entity().HasQueryFilter(b => !b.IsDeleted); + #endregion + } + + // The following overrides SaveChangesAsync to add logic which goes over all entities which the user deleted, and changes + // them to be modified instead, setting the IsDeleted property to true. + #region SaveChangesAsyncOverride + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + ChangeTracker.DetectChanges(); + + foreach (var item in ChangeTracker.Entries().Where(e => e.State == EntityState.Deleted)) + { + item.State = EntityState.Modified; + item.CurrentValues["IsDeleted"] = true; + } + + return await base.SaveChangesAsync(cancellationToken); + } + #endregion + + public override int SaveChanges() + => throw new NotSupportedException("Use SaveChangesAsync instead."); + } + + #region Blog + public class Blog + { + public int Id { get; set; } + public bool IsDeleted { get; set; } + + public string Name { get; set; } + } + #endregion +}