diff --git a/docs/en/framework/infrastructure/data-filtering.md b/docs/en/framework/infrastructure/data-filtering.md index 341c790277a..a2b63f70797 100644 --- a/docs/en/framework/infrastructure/data-filtering.md +++ b/docs/en/framework/infrastructure/data-filtering.md @@ -327,6 +327,21 @@ public override string GetCompiledQueryCacheKey() } ```` +The `HasTranslation` callback runs only when EF Core compiles the query, and the generated SQL is cached. That's why the example adds `IsActiveFilterEnabled` to the cache key. + +The default compiled-query cache key does not include the tenant ID. ABP's built-in tenant filter uses a runtime parameter, so queries can be reused across tenants. + +> If your custom `HasTranslation` callbacks read tenant-specific values at compilation time or generate different SQL by tenant, add the tenant ID to the cache key. Otherwise, another tenant can reuse SQL compiled for the first tenant. + +````csharp +public override string GetCompiledQueryCacheKey() +{ + return $"{base.GetCompiledQueryCacheKey()}:{IsActiveFilterEnabled}:{CurrentTenantId?.ToString() ?? "Null"}"; +} +```` + +Include any other values read at compilation time in the cache key as well. Keep the call to `base.GetCompiledQueryCacheKey()` to preserve ABP's filter-state keys. + ##### Make User-defined function mapping compatible with [DevExtreme.AspNet.Data's](https://github.com/DevExpress/DevExtreme.AspNet.Data) `IAsyncAdapter` If you are using [DevExtreme.AspNet.Data](https://github.com/DevExpress/DevExtreme.AspNet.Data) and `User-defined function mapping`, You need to create a custom adapter to compatible with `IAsyncAdapter`. @@ -466,4 +481,4 @@ public class MyMongoDbModule : AbpModule .Replace(ServiceDescriptor.Transient(typeof(IMongoDbRepositoryFilterer<,>),typeof(MyMongoDbRepositoryFilterer<,>))); } } -```` \ No newline at end of file +```` diff --git a/docs/en/release-info/migration-guides/abp-10-7.md b/docs/en/release-info/migration-guides/abp-10-7.md index 4ae3f982af3..cf20765a4a9 100644 --- a/docs/en/release-info/migration-guides/abp-10-7.md +++ b/docs/en/release-info/migration-guides/abp-10-7.md @@ -228,6 +228,33 @@ Until you can upgrade, give every authorization server its own client signing ke > See [#26148](https://github.com/abpframework/abp/pull/26148) for details. +### Compiled Query Cache Key of Global Filters + +**Who is affected** + +- Applications that map a custom global filter with user-defined function mapping and read the current tenant, or another tenant specific value, inside its `HasTranslation` callback. + +**What changed** + +- `AbpDbContext.GetCompiledQueryCacheKey()` no longer includes the current tenant ID. It contains only the soft delete and multi-tenant filter states, so EF Core compiles a query once and reuses it for all tenants. Previously every tenant compiled and cached its own copy of every query, so applications with many tenants kept compiling the same queries again. +- ABP's built-in tenant filter passes the current tenant to the SQL as a parameter, so it is not affected. Applications that set `AbpEfCoreGlobalFilterOptions.UseDbFunction` to `false` are not affected either, because this key is only used with user-defined function mapping. +- A `HasTranslation` callback runs only when EF Core compiles the query. If it reads the current tenant and writes it into the SQL, for example as a `SqlConstantExpression`, other tenants now reuse that SQL and get the data of the tenant that compiled it first. + +**What to do** + +No action is required if your `HasTranslation` callbacks do not read tenant specific values. Otherwise, add the tenant ID to the cache key of that `DbContext` and keep the base key: + +```csharp +public override string GetCompiledQueryCacheKey() +{ + return $"{base.GetCompiledQueryCacheKey()}:{CurrentTenantId?.ToString() ?? "Null"}"; +} +``` + +Add any other value the callback reads at compilation time to the key the same way. + +> See the [Data Filtering](../../framework/infrastructure/data-filtering.md) document and [#26282](https://github.com/abpframework/abp/pull/26282) for details. + ### Dependency Updates **Who is affected** diff --git a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs index a4aa3a510ad..36f9f2051d2 100644 --- a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs +++ b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs @@ -997,6 +997,6 @@ public virtual bool UseDbFunction() public virtual string GetCompiledQueryCacheKey() { - return $"{CurrentTenantId?.ToString() ?? "Null"}:{IsSoftDeleteFilterEnabled}:{IsMultiTenantFilterEnabled}"; + return $"{IsSoftDeleteFilterEnabled}:{IsMultiTenantFilterEnabled}"; } } diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestBase.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestBase.cs new file mode 100644 index 00000000000..3e901dd7952 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestBase.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Data; +using Volo.Abp.EntityFrameworkCore.GlobalFilters; +using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; +using Volo.Abp.TestApp.Testing; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +public abstract class CompiledQueryCacheTestBase : TestAppTestBase + where TStartupModule : IAbpModule +{ + private readonly string _queryTag = Guid.NewGuid().ToString(); + + protected abstract bool UseDbFunction { get; } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Should_Reuse_Compiled_Query_Without_Reusing_Tenant_Or_Filter_State(bool asyncQuery, bool hostFirst) + { + var counter = GetRequiredService(); + var before = counter.Count; + IModel sharedModel = null; + var cacheKeys = new Dictionary<(bool SoftDelete, bool MultiTenant), object>(); + var tenants = hostFirst + ? new Guid?[] + { + null, + CompiledQueryCacheTestModuleBase.TenantA, + CompiledQueryCacheTestModuleBase.TenantB, + null, + CompiledQueryCacheTestModuleBase.TenantA + } + : new Guid?[] + { + CompiledQueryCacheTestModuleBase.TenantA, + CompiledQueryCacheTestModuleBase.TenantB, + null, + CompiledQueryCacheTestModuleBase.TenantA + }; + + foreach (var tenantId in tenants) + { + foreach (var softDeleteEnabled in new[] { true, false, true }) + { + foreach (var multiTenantEnabled in new[] { true, false, true }) + { + using var scope = ServiceProvider.CreateScope(); + using var tenant = scope.ServiceProvider.GetRequiredService().Change(tenantId); + var dataFilter = scope.ServiceProvider.GetRequiredService(); + using var softDelete = softDeleteEnabled + ? dataFilter.Enable() + : dataFilter.Disable(); + using var multiTenant = multiTenantEnabled + ? dataFilter.Enable() + : dataFilter.Disable(); + using var uow = scope.ServiceProvider.GetRequiredService().Begin(); + var context = scope.ServiceProvider.GetRequiredService(); + context.UseDbFunction().ShouldBe(UseDbFunction); + + sharedModel ??= context.Model; + context.Model.ShouldBeSameAs(sharedModel); + + var query = context.Rows + .AsNoTracking() + .TagWith(_queryTag) + .OrderBy(x => x.Name) + .Select(x => x.Name); + var cacheKeyGenerator = context.GetService(); + if (UseDbFunction) + { + cacheKeyGenerator.ShouldBeOfType(); + } + else + { + cacheKeyGenerator.ShouldNotBeOfType(); + } + + var cacheKey = cacheKeyGenerator.GenerateCacheKey(query.Expression, asyncQuery); + var filterState = (softDeleteEnabled, multiTenantEnabled); + if (cacheKeys.TryGetValue(filterState, out var previousKey)) + { + cacheKey.ShouldBe(previousKey); + } + else + { + cacheKeys.Add(filterState, cacheKey); + } + + var names = asyncQuery ? await query.ToListAsync() : query.ToList(); + names.ShouldBe(CompiledQueryCacheTestModuleBase.Rows + .Where(row => !softDeleteEnabled || !row.Deleted) + .Where(row => !multiTenantEnabled || row.TenantId == tenantId) + .Select(row => row.Name) + .OrderBy(name => name) + .ToArray()); + await uow.CompleteAsync(); + } + } + } + + var expectedCompilationCount = UseDbFunction ? 4 : 1; + cacheKeys.Values.Distinct().Count().ShouldBe(expectedCompilationCount); + (counter.Count - before).ShouldBe(expectedCompilationCount); + } + + [Fact] + public async Task Should_Read_Current_Tenant_When_Reusing_Query_In_Same_DbContext() + { + using var uow = GetRequiredService().Begin(); + var context = GetRequiredService(); + context.UseDbFunction().ShouldBe(UseDbFunction); + var currentTenant = GetRequiredService(); + var counter = GetRequiredService(); + var before = counter.Count; + var query = context.Rows + .AsNoTracking() + .TagWith(_queryTag) + .OrderBy(x => x.Name) + .Select(x => x.Name); + var tenants = new Guid?[] + { + CompiledQueryCacheTestModuleBase.TenantA, + CompiledQueryCacheTestModuleBase.TenantB, + null, + CompiledQueryCacheTestModuleBase.TenantA + }; + + foreach (var tenantId in tenants) + { + using var tenant = currentTenant.Change(tenantId); + var names = await query.ToListAsync(); + names.ShouldBe(CompiledQueryCacheTestModuleBase.Rows + .Where(row => !row.Deleted && row.TenantId == tenantId) + .Select(row => row.Name) + .OrderBy(name => name) + .ToArray()); + } + + (counter.Count - before).ShouldBe(1); + await uow.CompleteAsync(); + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestDbContext.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestDbContext.cs new file mode 100644 index 00000000000..5ccd9f01192 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; + +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +public class CompiledQueryCacheTestDbContext : AbpDbContext +{ + public DbSet Rows { get; set; } + + public CompiledQueryCacheTestDbContext(DbContextOptions options) + : base(options) + { + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestModule.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestModule.cs new file mode 100644 index 00000000000..30680f84acf --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestModule.cs @@ -0,0 +1,11 @@ +using Volo.Abp.Autofac; +using Volo.Abp.EntityFrameworkCore.Sqlite; +using Volo.Abp.Modularity; + +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +[DependsOn(typeof(AbpEntityFrameworkCoreSqliteModule), typeof(AbpAutofacModule))] +public class CompiledQueryCacheTestModule : CompiledQueryCacheTestModuleBase +{ + protected override bool UseDbFunction => true; +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestModuleBase.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestModuleBase.cs new file mode 100644 index 00000000000..2724d2e0650 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestModuleBase.cs @@ -0,0 +1,79 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Data; +using Volo.Abp.EntityFrameworkCore.GlobalFilters; +using Volo.Abp.EntityFrameworkCore.Sqlite; +using Volo.Abp.Modularity; +using Volo.Abp.Uow; + +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +public abstract class CompiledQueryCacheTestModuleBase : AbpModule +{ + public static readonly Guid TenantA = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + public static readonly Guid TenantB = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + public static readonly (string Name, Guid? TenantId, bool Deleted)[] Rows = + { + ("A-live", TenantA, false), + ("A-deleted", TenantA, true), + ("B-live", TenantB, false), + ("B-deleted", TenantB, true), + ("Host-live", null, false), + ("Host-deleted", null, true) + }; + + private readonly AbpUnitTestSqliteDatabase _database = new(); + private ServiceProvider _efServiceProvider; + + protected abstract bool UseDbFunction { get; } + + public override void ConfigureServices(ServiceConfigurationContext context) + { + var counter = new QueryCompilationCounter(); + + // Avoid growing EF's global service-provider cache across test applications. + var efServices = new ServiceCollection(); + efServices.AddEntityFrameworkSqlite(); + if (UseDbFunction) + { + new AbpDbContextOptionsExtension().ApplyServices(efServices); + } + + _efServiceProvider = efServices.BuildServiceProvider(); + context.Services.AddSingleton(counter); + context.Services.AddAbpDbContext(); + Configure(options => options.UseDbFunction = UseDbFunction); + Configure(options => options.ConnectionStrings.Default = _database.ConnectionString); + Configure(options => options.Configure(configuration => + { + configuration.UseSqlite(); + configuration.DbContextOptions.UseLoggerFactory(null); + configuration.DbContextOptions.UseInternalServiceProvider(_efServiceProvider); + configuration.DbContextOptions.LogTo( + _ => counter.Increment(), + new[] { CoreEventId.QueryCompilationStarting }); + })); + } + + public override void OnApplicationInitialization(ApplicationInitializationContext context) + { + using var scope = context.ServiceProvider.CreateScope(); + using var uow = scope.ServiceProvider.GetRequiredService().Begin(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.EnsureCreated(); + foreach (var row in Rows) + { + dbContext.Database.ExecuteSqlInterpolated( + $@"INSERT INTO Rows (Id, TenantId, IsDeleted, Name) + VALUES ({Guid.NewGuid()}, {row.TenantId}, {row.Deleted}, {row.Name})"); + } + } + + public override void OnApplicationShutdown(ApplicationShutdownContext context) + { + _database.Dispose(); + _efServiceProvider.Dispose(); + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestModuleWithoutDbFunction.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestModuleWithoutDbFunction.cs new file mode 100644 index 00000000000..47614639668 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestModuleWithoutDbFunction.cs @@ -0,0 +1,11 @@ +using Volo.Abp.Autofac; +using Volo.Abp.EntityFrameworkCore.Sqlite; +using Volo.Abp.Modularity; + +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +[DependsOn(typeof(AbpEntityFrameworkCoreSqliteModule), typeof(AbpAutofacModule))] +public class CompiledQueryCacheTestModuleWithoutDbFunction : CompiledQueryCacheTestModuleBase +{ + protected override bool UseDbFunction => false; +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestRow.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestRow.cs new file mode 100644 index 00000000000..f32ebf6b14f --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCacheTestRow.cs @@ -0,0 +1,13 @@ +using System; +using Volo.Abp.Data; +using Volo.Abp.Domain.Entities; +using Volo.Abp.MultiTenancy; + +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +public class CompiledQueryCacheTestRow : Entity, IMultiTenant, ISoftDelete +{ + public Guid? TenantId { get; set; } + public bool IsDeleted { get; set; } + public string Name { get; set; } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCache_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCache_Tests.cs new file mode 100644 index 00000000000..299fd8b58d8 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCache_Tests.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +public class CompiledQueryCache_Tests : CompiledQueryCacheTestBase +{ + protected override bool UseDbFunction => true; +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCache_WithoutDbFunction_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCache_WithoutDbFunction_Tests.cs new file mode 100644 index 00000000000..37b93df9241 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/CompiledQueryCache_WithoutDbFunction_Tests.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +public class CompiledQueryCache_WithoutDbFunction_Tests : CompiledQueryCacheTestBase +{ + protected override bool UseDbFunction => false; +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/QueryCompilationCounter.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/QueryCompilationCounter.cs new file mode 100644 index 00000000000..bdd3a7c7527 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/QueryCompilationCounter.cs @@ -0,0 +1,14 @@ +using System.Threading; + +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +public class QueryCompilationCounter +{ + private int _count; + public int Count => Volatile.Read(ref _count); + + public void Increment() + { + Interlocked.Increment(ref _count); + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslationTestDbContext.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslationTestDbContext.cs new file mode 100644 index 00000000000..89cb930f57f --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslationTestDbContext.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Volo.Abp.EntityFrameworkCore.GlobalFilters; + +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +public class TenantDependentTranslationTestDbContext : AbpDbContext +{ + public DbSet Rows { get; set; } + + public TenantDependentTranslationTestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity(builder => + { + builder.ToTable("CustomTranslationRows"); + builder.Property(row => row.Name).HasMaxLength(100); + builder.HasQueryFilter(row => MatchesCurrentTenant(row.OwnerTenantId, true)); + }); + + var currentDbContext = this.GetService(); + modelBuilder.HasDbFunction(typeof(TenantDependentTranslationTestDbContext) + .GetMethod(nameof(MatchesCurrentTenant))) + .HasTranslation(arguments => + { + var ownerTenantId = arguments[0]; + var boolParameter = arguments[1]; + var currentTenantId = currentDbContext.Context.CurrentTenant.Id; + + return new SqlBinaryExpression( + ExpressionType.Equal, + ownerTenantId, + new SqlConstantExpression(currentTenantId, typeof(Guid?), ownerTenantId.TypeMapping), + boolParameter.Type, + boolParameter.TypeMapping); + }); + } + + public static bool MatchesCurrentTenant(Guid? ownerTenantId, bool boolParameter) + { + throw new NotSupportedException(AbpEfCoreDataFilterDbFunctionMethods.NotSupportedExceptionMessage); + } + + public override string GetCompiledQueryCacheKey() + { + return $"{base.GetCompiledQueryCacheKey()}:{CurrentTenantId?.ToString() ?? "Null"}"; + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslationTestModule.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslationTestModule.cs new file mode 100644 index 00000000000..d3e2aa0045f --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslationTestModule.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Autofac; +using Volo.Abp.EntityFrameworkCore.Sqlite; +using Volo.Abp.Modularity; +using Volo.Abp.Uow; + +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +[DependsOn(typeof(AbpEntityFrameworkCoreSqliteModule), typeof(AbpAutofacModule))] +public class TenantDependentTranslationTestModule : CompiledQueryCacheTestModuleBase +{ + public static readonly (string Name, Guid? OwnerTenantId)[] CustomRows = + { + ("A-row", TenantA), + ("B-row", TenantB), + ("Host-row", null) + }; + + protected override bool UseDbFunction => true; + + public override void ConfigureServices(ServiceConfigurationContext context) + { + base.ConfigureServices(context); + context.Services.AddAbpDbContext(); + } + + public override void OnApplicationInitialization(ApplicationInitializationContext context) + { + base.OnApplicationInitialization(context); + + using var scope = context.ServiceProvider.CreateScope(); + using var uow = scope.ServiceProvider.GetRequiredService().Begin(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.GetService().CreateTables(); + foreach (var row in CustomRows) + { + dbContext.Database.ExecuteSqlInterpolated( + $@"INSERT INTO CustomTranslationRows (Id, OwnerTenantId, Name) + VALUES ({Guid.NewGuid()}, {row.OwnerTenantId}, {row.Name})"); + } + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslationTestRow.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslationTestRow.cs new file mode 100644 index 00000000000..4cc7a6a63f8 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslationTestRow.cs @@ -0,0 +1,11 @@ +using System; +using Volo.Abp.Domain.Entities; + +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +public class TenantDependentTranslationTestRow : Entity +{ + public Guid? OwnerTenantId { get; set; } + + public string Name { get; set; } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslation_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslation_Tests.cs new file mode 100644 index 00000000000..633711618b9 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DataFiltering/CompiledQueryCache/TenantDependentTranslation_Tests.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.EntityFrameworkCore.GlobalFilters; +using Volo.Abp.MultiTenancy; +using Volo.Abp.TestApp.Testing; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.EntityFrameworkCore.DataFiltering.CompiledQueryCache; + +public class TenantDependentTranslation_Tests : TestAppTestBase +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Should_Isolate_Tenants_When_Custom_Translation_Includes_Tenant_In_Cache_Key( + bool asyncQuery, + bool hostFirst) + { + var queryTag = Guid.NewGuid().ToString(); + var counter = GetRequiredService(); + var before = counter.Count; + IModel sharedModel = null; + var cacheKeys = new HashSet(); + var tenants = hostFirst + ? new Guid?[] + { + null, + CompiledQueryCacheTestModuleBase.TenantA, + CompiledQueryCacheTestModuleBase.TenantB, + null, + CompiledQueryCacheTestModuleBase.TenantA + } + : new Guid?[] + { + CompiledQueryCacheTestModuleBase.TenantA, + CompiledQueryCacheTestModuleBase.TenantB, + CompiledQueryCacheTestModuleBase.TenantA, + null, + CompiledQueryCacheTestModuleBase.TenantB + }; + + foreach (var tenantId in tenants) + { + using var scope = ServiceProvider.CreateScope(); + using var tenant = scope.ServiceProvider.GetRequiredService().Change(tenantId); + using var uow = scope.ServiceProvider.GetRequiredService().Begin(); + var context = scope.ServiceProvider.GetRequiredService(); + context.UseDbFunction().ShouldBeTrue(); + context.CurrentTenant.Id.ShouldBe(tenantId); + + sharedModel ??= context.Model; + context.Model.ShouldBeSameAs(sharedModel); + + var query = context.Rows + .AsNoTracking() + .TagWith(queryTag) + .OrderBy(row => row.Name) + .Select(row => row.Name); + var cacheKeyGenerator = context.GetService(); + cacheKeyGenerator.ShouldBeOfType(); + var cacheKey = cacheKeyGenerator.GenerateCacheKey(query.Expression, asyncQuery); + cacheKeys.Add(cacheKey); + var names = asyncQuery ? await query.ToListAsync() : query.ToList(); + (counter.Count - before).ShouldBe(cacheKeys.Count); + + names.ShouldBe(TenantDependentTranslationTestModule.CustomRows + .Where(row => row.OwnerTenantId == tenantId) + .Select(row => row.Name) + .OrderBy(name => name) + .ToArray()); + await uow.CompleteAsync(); + } + + cacheKeys.Count.ShouldBe(3); + } +}