diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/AbpAspNetCoreMvcUiThemeSharedModule.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/AbpAspNetCoreMvcUiThemeSharedModule.cs index b62d3f27ba..da418c8dc2 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/AbpAspNetCoreMvcUiThemeSharedModule.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/AbpAspNetCoreMvcUiThemeSharedModule.cs @@ -4,6 +4,7 @@ using Volo.Abp.AspNetCore.Mvc.UI.Bundling; using Volo.Abp.AspNetCore.Mvc.UI.Packages; using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Bundling; using Volo.Abp.AspNetCore.Mvc.UI.Widgets; +using Volo.Abp.Features; using Volo.Abp.Modularity; using Volo.Abp.VirtualFileSystem; @@ -12,7 +13,8 @@ namespace Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared; [DependsOn( typeof(AbpAspNetCoreMvcUiBootstrapModule), typeof(AbpAspNetCoreMvcUiPackagesModule), - typeof(AbpAspNetCoreMvcUiWidgetsModule) + typeof(AbpAspNetCoreMvcUiWidgetsModule), + typeof(AbpFeaturesModule) )] public class AbpAspNetCoreMvcUiThemeSharedModule : AbpModule { diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Toolbars/ToolbarManager.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Toolbars/ToolbarManager.cs index 7822277371..c4e25c98a9 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Toolbars/ToolbarManager.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Toolbars/ToolbarManager.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Mvc.UI.Theming; using Volo.Abp.Authorization.Permissions; using Volo.Abp.DependencyInjection; +using Volo.Abp.Features; using Volo.Abp.SimpleStateChecking; namespace Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Toolbars; @@ -37,6 +38,7 @@ public class ToolbarManager : IToolbarManager, ITransientDependency using (var scope = ServiceProvider.CreateScope()) { using (RequirePermissionsSimpleBatchStateChecker.Use(new RequirePermissionsSimpleBatchStateChecker())) + using (RequireFeaturesSimpleBatchStateChecker.Use(new RequireFeaturesSimpleBatchStateChecker())) { var context = new ToolbarConfigurationContext(ThemeManager.CurrentTheme, toolbar, scope.ServiceProvider); diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.csproj b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.csproj index f80e51eb05..c2bc439691 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.csproj +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.csproj @@ -30,6 +30,7 @@ + diff --git a/framework/src/Volo.Abp.Authorization/Volo/Abp/Authorization/Permissions/RequirePermissionsSimpleBatchStateChecker.cs b/framework/src/Volo.Abp.Authorization/Volo/Abp/Authorization/Permissions/RequirePermissionsSimpleBatchStateChecker.cs index 70e74a6bca..ad6c9b989e 100644 --- a/framework/src/Volo.Abp.Authorization/Volo/Abp/Authorization/Permissions/RequirePermissionsSimpleBatchStateChecker.cs +++ b/framework/src/Volo.Abp.Authorization/Volo/Abp/Authorization/Permissions/RequirePermissionsSimpleBatchStateChecker.cs @@ -47,13 +47,33 @@ public class RequirePermissionsSimpleBatchStateChecker : SimpleBatchStat var result = new SimpleStateCheckerResult(context.States); - var permissions = _models.Where(x => context.States.Any(s => s.Equals(x.State))).SelectMany(x => x.Permissions).Distinct().ToArray(); - var grantResult = await permissionChecker.IsGrantedAsync(permissions); + var stateSet = new HashSet(context.States); + var modelLookup = new Dictionary>(); + var allPermissions = new HashSet(); + + foreach (var model in _models) + { + if (!stateSet.Contains(model.State)) + { + continue; + } + + if (!modelLookup.ContainsKey(model.State)) + { + modelLookup[model.State] = model; + } + + foreach (var permission in model.Permissions) + { + allPermissions.Add(permission); + } + } + + var grantResult = await permissionChecker.IsGrantedAsync(allPermissions.ToArray()); foreach (var state in context.States) { - var model = _models.FirstOrDefault(x => x.State.Equals(state)); - if (model != null) + if (modelLookup.TryGetValue(state, out var model)) { if (model.RequiresAll) { diff --git a/framework/src/Volo.Abp.Core/Volo/Abp/SimpleStateChecking/SimpleStateCheckerManager.cs b/framework/src/Volo.Abp.Core/Volo/Abp/SimpleStateChecking/SimpleStateCheckerManager.cs index 1726a89f84..3d01c3c48d 100644 --- a/framework/src/Volo.Abp.Core/Volo/Abp/SimpleStateChecking/SimpleStateCheckerManager.cs +++ b/framework/src/Volo.Abp.Core/Volo/Abp/SimpleStateChecking/SimpleStateCheckerManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -57,7 +57,7 @@ public class SimpleStateCheckerManager : ISimpleStateCheckerManager globalStateChecker in Options.GlobalStateCheckers .Where(x => typeof(ISimpleBatchStateChecker).IsAssignableFrom(x)) - .Select(x => ServiceProvider.GetRequiredService(x))) + .Select(x => scope.ServiceProvider.GetRequiredService(x))) { var context = new SimpleBatchStateCheckerContext( scope.ServiceProvider.GetRequiredService(), @@ -69,11 +69,15 @@ public class SimpleStateCheckerManager : ISimpleStateCheckerManager !typeof(ISimpleBatchStateChecker).IsAssignableFrom(x)); + foreach (var state in states) { - if (result[state]) + if (result[state] && + (hasNonBatchGlobalCheckers || state.StateCheckers.Any(x => x is not ISimpleBatchStateChecker))) { - result[state] = await InternalIsEnabledAsync(state, false); + result[state] = await EvaluateCheckersAsync(state, false, scope); } } @@ -96,27 +100,36 @@ public class SimpleStateCheckerManager : ISimpleStateCheckerManager(scope.ServiceProvider.GetRequiredService(), state); + return await EvaluateCheckersAsync(state, useBatchChecker, scope); + } + } + + protected virtual async Task EvaluateCheckersAsync(TState state, bool useBatchChecker, IServiceScope scope) + { + var context = new SimpleStateCheckerContext( + !useBatchChecker + ? scope.ServiceProvider.GetRequiredService() + : scope.ServiceProvider.GetRequiredService(), + state); - foreach (var provider in state.StateCheckers.WhereIf(!useBatchChecker, x => x is not ISimpleBatchStateChecker)) + foreach (var provider in state.StateCheckers.WhereIf(!useBatchChecker, x => x is not ISimpleBatchStateChecker)) + { + if (!await provider.IsEnabledAsync(context)) { - if (!await provider.IsEnabledAsync(context)) - { - return false; - } + return false; } + } - foreach (ISimpleStateChecker provider in Options.GlobalStateCheckers - .WhereIf(!useBatchChecker, x => !typeof(ISimpleBatchStateChecker).IsAssignableFrom(x)) - .Select(x => ServiceProvider.GetRequiredService(x))) + foreach (ISimpleStateChecker provider in Options.GlobalStateCheckers + .WhereIf(!useBatchChecker, x => !typeof(ISimpleBatchStateChecker).IsAssignableFrom(x)) + .Select(x => scope.ServiceProvider.GetRequiredService(x))) + { + if (!await provider.IsEnabledAsync(context)) { - if (!await provider.IsEnabledAsync(context)) - { - return false; - } + return false; } - - return true; } + + return true; } } diff --git a/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeatureCheckerBase.cs b/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeatureCheckerBase.cs index f17213af2a..34c01820b3 100644 --- a/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeatureCheckerBase.cs +++ b/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeatureCheckerBase.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; using System.Threading.Tasks; using Volo.Abp.DependencyInjection; @@ -28,4 +29,16 @@ public abstract class FeatureCheckerBase : IFeatureChecker, ITransientDependency ); } } + + public virtual async Task> IsEnabledAsync(string[] names) + { + var result = new Dictionary(); + + foreach (var name in names) + { + result[name] = await IsEnabledAsync(name); + } + + return result; + } } diff --git a/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeatureSimpleStateCheckerExtensions.cs b/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeatureSimpleStateCheckerExtensions.cs index ff1bca8229..2c62c29870 100644 --- a/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeatureSimpleStateCheckerExtensions.cs +++ b/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeatureSimpleStateCheckerExtensions.cs @@ -1,4 +1,4 @@ -using JetBrains.Annotations; +using JetBrains.Annotations; using Volo.Abp.SimpleStateChecking; namespace Volo.Abp.Features; @@ -10,7 +10,7 @@ public static class FeatureSimpleStateCheckerExtensions params string[] features) where TState : IHasSimpleStateCheckers { - state.RequireFeatures(true, features); + state.RequireFeatures(requiresAll: true, batchCheck: true, features); return state; } @@ -19,11 +19,32 @@ public static class FeatureSimpleStateCheckerExtensions bool requiresAll, params string[] features) where TState : IHasSimpleStateCheckers + { + state.RequireFeatures(requiresAll: requiresAll, batchCheck: true, features); + return state; + } + + public static TState RequireFeatures( + [NotNull] this TState state, + bool requiresAll, + bool batchCheck, + params string[] features) + where TState : IHasSimpleStateCheckers { Check.NotNull(state, nameof(state)); Check.NotNullOrEmpty(features, nameof(features)); - state.StateCheckers.Add(new RequireFeaturesSimpleStateChecker(requiresAll, features)); + if (batchCheck) + { + RequireFeaturesSimpleBatchStateChecker.Current.AddCheckModels( + new RequireFeaturesSimpleBatchStateCheckerModel(state, features, requiresAll)); + state.StateCheckers.Add(RequireFeaturesSimpleBatchStateChecker.Current); + } + else + { + state.StateCheckers.Add(new RequireFeaturesSimpleStateChecker(requiresAll, features)); + } + return state; } } diff --git a/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeaturesSimpleStateCheckerSerializerContributor.cs b/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeaturesSimpleStateCheckerSerializerContributor.cs index c604cb75b4..5f652269b5 100644 --- a/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeaturesSimpleStateCheckerSerializerContributor.cs +++ b/framework/src/Volo.Abp.Features/Volo/Abp/Features/FeaturesSimpleStateCheckerSerializerContributor.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using System.Text.Json.Nodes; using Volo.Abp.DependencyInjection; using Volo.Abp.SimpleStateChecking; @@ -10,7 +10,7 @@ public class FeaturesSimpleStateCheckerSerializerContributor : ISingletonDependency { public const string CheckerShortName = "F"; - + public string? SerializeToJson(ISimpleStateChecker checker) where TState : IHasSimpleStateCheckers { @@ -53,4 +53,4 @@ public class FeaturesSimpleStateCheckerSerializerContributor : nameArray.Select(x => x!.ToString()).ToArray() ); } -} \ No newline at end of file +} diff --git a/framework/src/Volo.Abp.Features/Volo/Abp/Features/IFeatureChecker.cs b/framework/src/Volo.Abp.Features/Volo/Abp/Features/IFeatureChecker.cs index 501c5ff8f8..f97b4504a0 100644 --- a/framework/src/Volo.Abp.Features/Volo/Abp/Features/IFeatureChecker.cs +++ b/framework/src/Volo.Abp.Features/Volo/Abp/Features/IFeatureChecker.cs @@ -1,4 +1,5 @@ -using JetBrains.Annotations; +using System.Collections.Generic; +using JetBrains.Annotations; using System.Threading.Tasks; namespace Volo.Abp.Features; @@ -8,4 +9,6 @@ public interface IFeatureChecker Task GetOrNullAsync([NotNull] string name); Task IsEnabledAsync(string name); + + Task> IsEnabledAsync([NotNull] string[] names); } diff --git a/framework/src/Volo.Abp.Features/Volo/Abp/Features/RequireFeaturesSimpleBatchStateChecker.cs b/framework/src/Volo.Abp.Features/Volo/Abp/Features/RequireFeaturesSimpleBatchStateChecker.cs new file mode 100644 index 0000000000..24089e2364 --- /dev/null +++ b/framework/src/Volo.Abp.Features/Volo/Abp/Features/RequireFeaturesSimpleBatchStateChecker.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.SimpleStateChecking; + +namespace Volo.Abp.Features; + +public class RequireFeaturesSimpleBatchStateChecker : SimpleBatchStateCheckerBase + where TState : IHasSimpleStateCheckers +{ + public static RequireFeaturesSimpleBatchStateChecker Current => _current.Value!; + private static readonly AsyncLocal> _current = new(); + + private readonly List> _models; + + static RequireFeaturesSimpleBatchStateChecker() + { + _current.Value = new RequireFeaturesSimpleBatchStateChecker(); + } + + public RequireFeaturesSimpleBatchStateChecker() + { + _models = new List>(); + } + + public RequireFeaturesSimpleBatchStateChecker AddCheckModels( + params RequireFeaturesSimpleBatchStateCheckerModel[] models) + { + Check.NotNullOrEmpty(models, nameof(models)); + + _models.AddRange(models); + return this; + } + + public static IDisposable Use(RequireFeaturesSimpleBatchStateChecker checker) + { + var previousValue = Current; + _current.Value = checker; + return new DisposeAction(() => _current.Value = previousValue); + } + + public override async Task> IsEnabledAsync( + SimpleBatchStateCheckerContext context) + { + var featureChecker = context.ServiceProvider.GetRequiredService(); + + var result = new SimpleStateCheckerResult(context.States); + + var stateSet = new HashSet(context.States); + var modelLookup = new Dictionary>(); + var allFeatures = new HashSet(); + + foreach (var model in _models) + { + if (!stateSet.Contains(model.State)) + { + continue; + } + + if (!modelLookup.ContainsKey(model.State)) + { + modelLookup[model.State] = model; + } + + foreach (var featureName in model.FeatureNames) + { + allFeatures.Add(featureName); + } + } + + var featureValues = await featureChecker.IsEnabledAsync(allFeatures.ToArray()); + + foreach (var state in context.States) + { + if (modelLookup.TryGetValue(state, out var model)) + { + if (model.RequiresAll) + { + result[state] = model.FeatureNames.All(x => featureValues.TryGetValue(x, out var v) && v); + } + else + { + result[state] = model.FeatureNames.Any(x => featureValues.TryGetValue(x, out var v) && v); + } + } + } + + return result; + } +} diff --git a/framework/src/Volo.Abp.Features/Volo/Abp/Features/RequireFeaturesSimpleBatchStateCheckerModel.cs b/framework/src/Volo.Abp.Features/Volo/Abp/Features/RequireFeaturesSimpleBatchStateCheckerModel.cs new file mode 100644 index 0000000000..389ebac3af --- /dev/null +++ b/framework/src/Volo.Abp.Features/Volo/Abp/Features/RequireFeaturesSimpleBatchStateCheckerModel.cs @@ -0,0 +1,23 @@ +using Volo.Abp.SimpleStateChecking; + +namespace Volo.Abp.Features; + +public class RequireFeaturesSimpleBatchStateCheckerModel + where TState : IHasSimpleStateCheckers +{ + public TState State { get; } + + public string[] FeatureNames { get; } + + public bool RequiresAll { get; } + + public RequireFeaturesSimpleBatchStateCheckerModel(TState state, string[] featureNames, bool requiresAll = true) + { + Check.NotNull(state, nameof(state)); + Check.NotNullOrEmpty(featureNames, nameof(featureNames)); + + State = state; + FeatureNames = featureNames; + RequiresAll = requiresAll; + } +} diff --git a/framework/src/Volo.Abp.UI.Navigation/Volo.Abp.UI.Navigation.csproj b/framework/src/Volo.Abp.UI.Navigation/Volo.Abp.UI.Navigation.csproj index c5bc49a2bb..c01959bb61 100644 --- a/framework/src/Volo.Abp.UI.Navigation/Volo.Abp.UI.Navigation.csproj +++ b/framework/src/Volo.Abp.UI.Navigation/Volo.Abp.UI.Navigation.csproj @@ -23,6 +23,7 @@ + diff --git a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/AbpUiNavigationModule.cs b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/AbpUiNavigationModule.cs index 9074943396..70fb9d978d 100644 --- a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/AbpUiNavigationModule.cs +++ b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/AbpUiNavigationModule.cs @@ -1,4 +1,5 @@ using Volo.Abp.Authorization; +using Volo.Abp.Features; using Volo.Abp.Localization; using Volo.Abp.Modularity; using Volo.Abp.MultiTenancy; @@ -7,7 +8,7 @@ using Volo.Abp.VirtualFileSystem; namespace Volo.Abp.UI.Navigation; -[DependsOn(typeof(AbpUiModule), typeof(AbpAuthorizationModule), typeof(AbpMultiTenancyModule))] +[DependsOn(typeof(AbpUiModule), typeof(AbpAuthorizationModule), typeof(AbpFeaturesModule), typeof(AbpMultiTenancyModule))] public class AbpUiNavigationModule : AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) diff --git a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs index f1d8ae22d7..49cbfaae03 100644 --- a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs +++ b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Volo.Abp.Authorization.Permissions; using Volo.Abp.DependencyInjection; +using Volo.Abp.Features; using Volo.Abp.SimpleStateChecking; namespace Volo.Abp.UI.Navigation; @@ -82,6 +83,7 @@ public class MenuManager : IMenuManager, ITransientDependency using (var scope = ServiceScopeFactory.CreateScope()) { using (RequirePermissionsSimpleBatchStateChecker.Use(new RequirePermissionsSimpleBatchStateChecker())) + using (RequireFeaturesSimpleBatchStateChecker.Use(new RequireFeaturesSimpleBatchStateChecker())) { var context = new MenuConfigurationContext(menu, scope.ServiceProvider); diff --git a/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_BatchSingleScope_Tests.cs b/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_BatchSingleScope_Tests.cs new file mode 100644 index 0000000000..1edf00230d --- /dev/null +++ b/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_BatchSingleScope_Tests.cs @@ -0,0 +1,111 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Shouldly; +using Xunit; + +namespace Volo.Abp.SimpleStateChecking; + +/// +/// Tests that batch IsEnabledAsync evaluates non-batch state checkers correctly +/// when reusing a single DI scope (instead of creating N scopes via InternalIsEnabledAsync). +/// +public class SimpleStateChecker_BatchSingleScope_Tests : SimpleStateCheckerTestBase +{ + [Fact] + public async Task Batch_Should_Evaluate_NonBatch_Checkers_Correctly() + { + var enabled = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + enabled.AddSimpleStateChecker(new MySimpleStateChecker()); + + var disabled = new MyStateEntity + { + CreationTime = DateTime.Parse("2001-01-01", CultureInfo.InvariantCulture) + }; + disabled.AddSimpleStateChecker(new MySimpleStateChecker()); + + var result = await SimpleStateCheckerManager.IsEnabledAsync(new[] { enabled, disabled }); + + result[enabled].ShouldBeTrue(); + result[disabled].ShouldBeFalse(); + enabled.CheckCount.ShouldBe(1); + disabled.CheckCount.ShouldBe(1); + } + + [Fact] + public async Task Batch_Should_Skip_NonBatch_Check_When_BatchChecker_Already_Disabled() + { + // Entity disabled by batch checker should not have non-batch checker invoked + var entity = new MyStateEntity + { + CreationTime = DateTime.Parse("2001-01-01", CultureInfo.InvariantCulture) // fails batch checker + }; + entity.AddSimpleStateChecker(new MySimpleBatchStateChecker()); + entity.AddSimpleStateChecker(new MySimpleStateChecker()); + + var result = await SimpleStateCheckerManager.IsEnabledAsync(new[] { entity }); + + result[entity].ShouldBeFalse(); + entity.MultipleCheckCount.ShouldBe(1); // batch checker was called + entity.CheckCount.ShouldBe(0); // non-batch checker was NOT called (skipped because batch disabled it) + } + + [Fact] + public async Task Batch_Should_Handle_Mix_Of_Entities_With_And_Without_Checkers() + { + var noChecker = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + + var withChecker = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + withChecker.AddSimpleStateChecker(new MySimpleStateChecker()); + + var failingChecker = new MyStateEntity + { + CreationTime = DateTime.Parse("2001-01-01", CultureInfo.InvariantCulture) + }; + failingChecker.AddSimpleStateChecker(new MySimpleStateChecker()); + + var result = await SimpleStateCheckerManager.IsEnabledAsync( + new[] { noChecker, withChecker, failingChecker }); + + result[noChecker].ShouldBeTrue(); + result[withChecker].ShouldBeTrue(); + result[failingChecker].ShouldBeFalse(); + + noChecker.CheckCount.ShouldBe(0); + withChecker.CheckCount.ShouldBe(1); + failingChecker.CheckCount.ShouldBe(1); + } + + [Fact] + public async Task Batch_Should_Handle_Large_Number_Of_Entities() + { + var entities = new MyStateEntity[1000]; + for (int i = 0; i < 1000; i++) + { + entities[i] = new MyStateEntity + { + CreationTime = i % 2 == 0 + ? DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + : DateTime.Parse("2001-01-01", CultureInfo.InvariantCulture) + }; + entities[i].AddSimpleStateChecker(new MySimpleStateChecker()); + } + + var result = await SimpleStateCheckerManager.IsEnabledAsync(entities); + + for (int i = 0; i < 1000; i++) + { + result[entities[i]].ShouldBe(i % 2 == 0); + entities[i].CheckCount.ShouldBe(1); + } + } +} diff --git a/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_ScopeIsolation_Tests.cs b/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_ScopeIsolation_Tests.cs new file mode 100644 index 0000000000..0606dec6e8 --- /dev/null +++ b/framework/test/Volo.Abp.Core.Tests/Volo/Abp/SimpleStateChecking/SimpleStateChecker_ScopeIsolation_Tests.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Volo.Abp.SimpleStateChecking; + +/// +/// Probe-based tests that verify: +/// 1. Batch path shares a single DI scope but isolates CachedServiceProvider per state. +/// 2. Single-state path uses separate scopes (original behavior). +/// 3. Nested single-state calls from within batch checkers get their own scope. +/// +public class SimpleStateChecker_ScopeIsolation_Tests : SimpleStateCheckerTestBase +{ + protected override void AfterAddApplication(IServiceCollection services) + { + services.AddScoped(); + services.AddTransient(); + base.AfterAddApplication(services); + } + + [Fact] + public async Task Batch_Should_Share_Scope_But_Isolate_CachedProvider_Across_States() + { + var observations = new ConcurrentDictionary(); + var checker = new ScopeProbeStateChecker(observations); + + var stateA = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + stateA.AddSimpleStateChecker(checker); + + var stateB = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + stateB.AddSimpleStateChecker(checker); + + var stateC = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + stateC.AddSimpleStateChecker(checker); + + await SimpleStateCheckerManager.IsEnabledAsync(new[] { stateA, stateB, stateC }); + + observations.Count.ShouldBe(3); + + // All states in the batch should see the same scoped service (shared DI scope) + observations[stateA].ScopeId.ShouldBe(observations[stateB].ScopeId); + observations[stateB].ScopeId.ShouldBe(observations[stateC].ScopeId); + + // Each state should get its own transient instance (isolated cached provider) + observations[stateA].TransientId.ShouldNotBe(observations[stateB].TransientId); + observations[stateB].TransientId.ShouldNotBe(observations[stateC].TransientId); + } + + [Fact] + public async Task Single_State_Calls_Should_Use_Separate_Scopes() + { + var observations = new ConcurrentDictionary(); + var checker = new ScopeProbeStateChecker(observations); + + var stateA = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + stateA.AddSimpleStateChecker(checker); + + var stateB = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + stateB.AddSimpleStateChecker(checker); + + await SimpleStateCheckerManager.IsEnabledAsync(stateA); + await SimpleStateCheckerManager.IsEnabledAsync(stateB); + + observations.Count.ShouldBe(2); + + // Single-state calls should each get their own scope + observations[stateA].ScopeId.ShouldNotBe(observations[stateB].ScopeId); + } + + [Fact] + public async Task Nested_Single_State_Call_From_Batch_Checker_Should_Get_Own_Scope() + { + var observations = new ConcurrentDictionary(); + + // This state will be evaluated via a nested single-state call during batch evaluation + var nestedState = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + nestedState.AddSimpleStateChecker(new ScopeProbeStateChecker(observations)); + + // This checker triggers a nested IsEnabledAsync(state) during batch evaluation + var nestedCallChecker = new NestedSingleCallChecker( + SimpleStateCheckerManager, nestedState, observations); + + var batchState = new MyStateEntity + { + CreationTime = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture) + }; + batchState.AddSimpleStateChecker(nestedCallChecker); + + await SimpleStateCheckerManager.IsEnabledAsync(new[] { batchState }); + + observations.Count.ShouldBe(2); + + // The nested single-state call should get its own scope, not the batch scope + observations[batchState].ScopeId.ShouldNotBe(observations[nestedState].ScopeId); + } + + public record Observation(Guid ScopeId, Guid TransientId); + + public class ScopeIdProbe + { + public Guid Id { get; } = Guid.NewGuid(); + } + + public class TransientIdProbe + { + public Guid Id { get; } = Guid.NewGuid(); + } + + public class ScopeProbeStateChecker : ISimpleStateChecker + { + private readonly ConcurrentDictionary _observations; + + public ScopeProbeStateChecker( + ConcurrentDictionary observations) + { + _observations = observations; + } + + public Task IsEnabledAsync(SimpleStateCheckerContext context) + { + var scopeProbe = context.ServiceProvider.GetRequiredService(); + var transientProbe = context.ServiceProvider.GetRequiredService(); + _observations[context.State] = new Observation(scopeProbe.Id, transientProbe.Id); + return Task.FromResult(true); + } + } + + public class NestedSingleCallChecker : ISimpleStateChecker + { + private readonly ISimpleStateCheckerManager _manager; + private readonly MyStateEntity _nestedState; + private readonly ConcurrentDictionary _observations; + + public NestedSingleCallChecker( + ISimpleStateCheckerManager manager, + MyStateEntity nestedState, + ConcurrentDictionary observations) + { + _manager = manager; + _nestedState = nestedState; + _observations = observations; + } + + public async Task IsEnabledAsync(SimpleStateCheckerContext context) + { + var scopeProbe = context.ServiceProvider.GetRequiredService(); + var transientProbe = context.ServiceProvider.GetRequiredService(); + _observations[context.State] = new Observation(scopeProbe.Id, transientProbe.Id); + + // Trigger a nested single-state call during batch evaluation + await _manager.IsEnabledAsync(_nestedState); + + return true; + } + } +} diff --git a/framework/test/Volo.Abp.Features.Tests/Volo/Abp/Features/RequireFeaturesSimpleBatchStateChecker_Tests.cs b/framework/test/Volo.Abp.Features.Tests/Volo/Abp/Features/RequireFeaturesSimpleBatchStateChecker_Tests.cs new file mode 100644 index 0000000000..2ea3f2916b --- /dev/null +++ b/framework/test/Volo.Abp.Features.Tests/Volo/Abp/Features/RequireFeaturesSimpleBatchStateChecker_Tests.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.MultiTenancy; +using Volo.Abp.SimpleStateChecking; +using Xunit; + +namespace Volo.Abp.Features; + +public class RequireFeaturesSimpleBatchStateChecker_Tests : FeatureTestBase +{ + private readonly ISimpleStateCheckerManager _simpleStateCheckerManager; + private readonly ICurrentTenant _currentTenant; + + public RequireFeaturesSimpleBatchStateChecker_Tests() + { + _simpleStateCheckerManager = GetRequiredService>(); + _currentTenant = GetRequiredService(); + } + + [Fact] + public void Switch_Current_Checker_Test() + { + var checker = RequireFeaturesSimpleBatchStateChecker.Current; + checker.ShouldNotBeNull(); + + RequireFeaturesSimpleBatchStateChecker checker2 = null; + + using (RequireFeaturesSimpleBatchStateChecker.Use(new RequireFeaturesSimpleBatchStateChecker())) + { + checker2 = RequireFeaturesSimpleBatchStateChecker.Current; + checker2.ShouldNotBeNull(); + checker2.ShouldNotBe(checker); + } + + checker2.ShouldNotBeNull(); + checker2.ShouldNotBe(checker); + } + + [Fact] + public async Task RequireFeaturesSimpleBatchStateChecker_Test() + { + // Tenant1: BooleanTestFeature1=true, BooleanTestFeature2=true + // Tenant2: no boolean features set → false + using (_currentTenant.Change(TestFeatureStore.Tenant1Id)) + { + var myStateEntities = new MyStateEntity[] + { + new MyStateEntity().RequireFeatures(requiresAll: true, batchCheck: true, "BooleanTestFeature1"), + new MyStateEntity().RequireFeatures(requiresAll: true, batchCheck: true, "BooleanTestFeature2"), + new MyStateEntity().RequireFeatures(requiresAll: true, batchCheck: true, "BooleanTestFeature1", "BooleanTestFeature2"), + new MyStateEntity().RequireFeatures(requiresAll: true, batchCheck: true, "BooleanTestFeature1", "BooleanTestFeature2"), + }; + + var result = await _simpleStateCheckerManager.IsEnabledAsync(myStateEntities); + + result.Count.ShouldBe(myStateEntities.Length); + + result[myStateEntities[0]].ShouldBeTrue(); + result[myStateEntities[1]].ShouldBeTrue(); + result[myStateEntities[2]].ShouldBeTrue(); + result[myStateEntities[3]].ShouldBeTrue(); + } + + using (_currentTenant.Change(TestFeatureStore.Tenant2Id)) + { + var myStateEntities = new MyStateEntity[] + { + new MyStateEntity().RequireFeatures(requiresAll: true, batchCheck: true, "BooleanTestFeature1"), + new MyStateEntity().RequireFeatures(requiresAll: true, batchCheck: true, "BooleanTestFeature2"), + new MyStateEntity().RequireFeatures(requiresAll: true, batchCheck: true, "BooleanTestFeature1", "BooleanTestFeature2"), + new MyStateEntity().RequireFeatures(requiresAll: false, batchCheck: true, "BooleanTestFeature1", "BooleanTestFeature2"), + }; + + var result = await _simpleStateCheckerManager.IsEnabledAsync(myStateEntities); + + result.Count.ShouldBe(myStateEntities.Length); + + result[myStateEntities[0]].ShouldBeFalse(); + result[myStateEntities[1]].ShouldBeFalse(); + result[myStateEntities[2]].ShouldBeFalse(); + result[myStateEntities[3]].ShouldBeFalse(); + } + } + + class MyStateEntity : IHasSimpleStateCheckers + { + public List> StateCheckers { get; } + + public MyStateEntity() + { + StateCheckers = new List>(); + } + } + + class MyStateEntity2 : IHasSimpleStateCheckers + { + public List> StateCheckers { get; } + + public MyStateEntity2() + { + StateCheckers = new List>(); + } + } +} diff --git a/modules/permission-management/test/Volo.Abp.PermissionManagement.Domain.Tests/Volo/Abp/PermissionManagement/PermissionDefinitionSerializer_Tests.cs b/modules/permission-management/test/Volo.Abp.PermissionManagement.Domain.Tests/Volo/Abp/PermissionManagement/PermissionDefinitionSerializer_Tests.cs index 4dca621a63..a8bdc4b4ea 100644 --- a/modules/permission-management/test/Volo.Abp.PermissionManagement.Domain.Tests/Volo/Abp/PermissionManagement/PermissionDefinitionSerializer_Tests.cs +++ b/modules/permission-management/test/Volo.Abp.PermissionManagement.Domain.Tests/Volo/Abp/PermissionManagement/PermissionDefinitionSerializer_Tests.cs @@ -57,7 +57,7 @@ public class PermissionDefinitionSerializer_Tests : PermissionTestBase .WithProperty("CustomProperty2", "CustomValue2") .RequireAuthenticated() //For for testing, not so meaningful .RequireGlobalFeatures("GlobalFeature1", "GlobalFeature2") - .RequireFeatures("Feature1", "Feature2") + .RequireFeatures(requiresAll: true, batchCheck: false, "Feature1", "Feature2") .RequirePermissions(requiresAll: false, batchCheck: false,"Permission2", "Permission3"); // Act @@ -96,7 +96,7 @@ public class PermissionDefinitionSerializer_Tests : PermissionTestBase .WithProperty("CustomProperty2", "CustomValue2") .RequireAuthenticated() //For for testing, not so meaningful .RequireGlobalFeatures("GlobalFeature1", "GlobalFeature2") - .RequireFeatures("Feature1", "Feature2") + .RequireFeatures(requiresAll: true, batchCheck: false, "Feature1", "Feature2") .RequirePermissions(requiresAll: false, batchCheck: false,"Permission2", "Permission3"); // Act