diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f9fc6f9..1114d92 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -47,7 +47,7 @@ jobs:
run: dotnet build -c Release --no-restore
- name: Test solution
- run: dotnet test -c Release --no-build --no-restore --verbosity normal --results-directory test-results --collect:"XPlat Code Coverage" `
+ run: dotnet test -c Release --no-build --no-restore --verbosity normal --filter "Category!=Integration" --results-directory test-results --collect:"XPlat Code Coverage" `
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=json,cobertura,lcov,teamcity,opencover
- name: Upload coverage
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1c6eb3e..a151035 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -47,7 +47,7 @@ jobs:
run: dotnet build -c Release --no-restore
- name: Test solution
- run: dotnet test -c Release --no-build --no-restore --verbosity normal --results-directory test-results --collect:"XPlat Code Coverage" `
+ run: dotnet test -c Release --no-build --no-restore --verbosity normal --filter "Category!=Integration" --results-directory test-results --collect:"XPlat Code Coverage" `
-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=json,cobertura,lcov,teamcity,opencover
- name: Upload coverage
diff --git a/.gitignore b/.gitignore
index c016bb4..76c17df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -332,3 +332,4 @@ ASALocalRun/
# MFractors (Xamarin productivity tool) working folder
.mfractor/
.vscode/
+nul
diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Casbin.Persist.Adapter.EFCore.IntegrationTest.csproj b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Casbin.Persist.Adapter.EFCore.IntegrationTest.csproj
new file mode 100644
index 0000000..1ecf2d2
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Casbin.Persist.Adapter.EFCore.IntegrationTest.csproj
@@ -0,0 +1,72 @@
+
+
+
+ net9.0;net8.0;net7.0;net6.0;net5.0;netcoreapp3.1;
+ false
+ 11
+ false
+ $(NoWarn);NU1701
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+ Always
+
+
+ Always
+
+
+ PreserveNewest
+
+
+
+
diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/AutoSaveTests.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/AutoSaveTests.cs
new file mode 100644
index 0000000..99ab71f
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/AutoSaveTests.cs
@@ -0,0 +1,984 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Casbin.Model;
+using Casbin.Persist.Adapter.EFCore.Entities;
+using Npgsql;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+using Xunit.Abstractions;
+
+#nullable enable
+
+namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration
+{
+ ///
+ /// Custom context classes for multi-schema testing
+ ///
+ public class TestCasbinDbContext1 : CasbinDbContext
+ {
+ public TestCasbinDbContext1(
+ DbContextOptions> options,
+ string schemaName,
+ string tableName)
+ : base(options, schemaName, tableName)
+ {
+ }
+ }
+
+ public class TestCasbinDbContext2 : CasbinDbContext
+ {
+ public TestCasbinDbContext2(
+ DbContextOptions> options,
+ string schemaName,
+ string tableName)
+ : base(options, schemaName, tableName)
+ {
+ }
+ }
+
+ public class TestCasbinDbContext3 : CasbinDbContext
+ {
+ public TestCasbinDbContext3(
+ DbContextOptions> options,
+ string schemaName,
+ string tableName)
+ : base(options, schemaName, tableName)
+ {
+ }
+ }
+
+ ///
+ /// Integration tests for AutoSave behavior using PostgreSQL.
+ /// These tests verify that the adapter correctly handles AutoSave ON and OFF modes
+ /// when working with both regular policies and grouping policies.
+ ///
+ /// Note: These are integration tests (not unit tests) because they:
+ /// - Use the full Casbin Enforcer (not just the adapter in isolation)
+ /// - Test the interaction between Enforcer and Adapter
+ /// - Use real PostgreSQL database (not SQLite in-memory)
+ ///
+ [Trait("Category", "Integration")]
+ [Collection("IntegrationTests")]
+ public class AutoSaveTests : TestUtil, IAsyncLifetime
+ {
+ private readonly TransactionIntegrityTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private const string ModelPath = "examples/multi_context_model.conf";
+
+ public AutoSaveTests(TransactionIntegrityTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ public async Task InitializeAsync()
+ {
+ // Clear all policies before each test
+ await _fixture.ClearAllPoliciesAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ // Restore any tables that may have been dropped during test execution
+ await _fixture.RunMigrationsAsync();
+ }
+
+ ///
+ /// Tests regular policies with AutoSave ON (default behavior).
+ /// Verifies that operations immediately persist to the database.
+ ///
+ [Fact]
+ public async Task TestPolicyAutoSaveOn()
+ {
+ await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
+ await connection.OpenAsync();
+
+ var options = new DbContextOptionsBuilder>()
+ .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema))
+ .Options;
+
+ await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema);
+ await InitPolicyAsync(context);
+
+ var adapter = new EFCoreAdapter(context);
+ var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf"));
+ var enforcer = new Enforcer(model, adapter);
+
+ #region Load policy test
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write"),
+ AsList("data2_admin", "data2", "read"),
+ AsList("data2_admin", "data2", "write")
+ ));
+ Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5);
+ #endregion
+
+ #region Add policy test
+ await enforcer.AddPolicyAsync("alice", "data1", "write");
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write"),
+ AsList("data2_admin", "data2", "read"),
+ AsList("data2_admin", "data2", "write"),
+ AsList("alice", "data1", "write")
+ ));
+ Assert.True(await context.Policies.AsNoTracking().CountAsync() is 6);
+ #endregion
+
+ #region Remove policy test
+ await enforcer.RemovePolicyAsync("alice", "data1", "write");
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write"),
+ AsList("data2_admin", "data2", "read"),
+ AsList("data2_admin", "data2", "write")
+ ));
+ Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5);
+
+ await enforcer.RemoveFilteredPolicyAsync(0, "data2_admin");
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write")
+ ));
+ Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3);
+ #endregion
+
+ #region Update policy test
+ await enforcer.UpdatePolicyAsync(AsList("alice", "data1", "read"),
+ AsList("alice", "data2", "write"));
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data2", "write"),
+ AsList("bob", "data2", "write")
+ ));
+ Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3);
+
+ await enforcer.UpdatePolicyAsync(AsList("alice", "data2", "write"),
+ AsList("alice", "data1", "read"));
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write")
+ ));
+ Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3);
+ #endregion
+
+ #region Batch APIs test
+ await enforcer.AddPoliciesAsync(new []
+ {
+ new System.Collections.Generic.List{"alice", "data2", "write"},
+ new System.Collections.Generic.List{"bob", "data1", "read"}
+ });
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write"),
+ AsList("alice", "data2", "write"),
+ AsList("bob", "data1", "read")
+ ));
+ Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5);
+
+ await enforcer.RemovePoliciesAsync(new []
+ {
+ new System.Collections.Generic.List{"alice", "data1", "read"},
+ new System.Collections.Generic.List{"bob", "data2", "write"}
+ });
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data2", "write"),
+ AsList("bob", "data1", "read")
+ ));
+ Assert.True(await context.Policies.AsNoTracking().CountAsync() is 3);
+ #endregion
+ }
+
+ ///
+ /// Tests async version of regular policies with AutoSave ON.
+ ///
+ [Fact]
+ public async Task TestPolicyAutoSaveOnAsync()
+ {
+ await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
+ await connection.OpenAsync();
+
+ var options = new DbContextOptionsBuilder>()
+ .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema))
+ .Options;
+
+ await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema);
+ await InitPolicyAsync(context);
+
+ var adapter = new EFCoreAdapter(context);
+ var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf"));
+ var enforcer = new Enforcer(model, adapter);
+
+ #region Load policy test
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write"),
+ AsList("data2_admin", "data2", "read"),
+ AsList("data2_admin", "data2", "write")
+ ));
+ Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5);
+ #endregion
+
+ #region Add policy test
+ await enforcer.AddPolicyAsync("alice", "data1", "write");
+ Assert.True(await context.Policies.AsNoTracking().CountAsync() is 6);
+ #endregion
+
+ #region Remove policy test
+ await enforcer.RemovePolicyAsync("alice", "data1", "write");
+ Assert.True(await context.Policies.AsNoTracking().CountAsync() is 5);
+ #endregion
+ }
+
+ ///
+ /// Tests regular policies with AutoSave OFF.
+ /// Verifies that AddPolicy() correctly respects AutoSave OFF setting.
+ /// This documents the CORRECT behavior (contrast with grouping policy bug).
+ ///
+ [Fact]
+ public async Task TestPolicyAutoSaveOff()
+ {
+ await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
+ await connection.OpenAsync();
+
+ var options = new DbContextOptionsBuilder>()
+ .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema))
+ .Options;
+
+ await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema);
+ await InitPolicyAsync(context);
+
+ var adapter = new EFCoreAdapter(context);
+ var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf"));
+ var enforcer = new Enforcer(model, adapter);
+
+ // Disable AutoSave
+ enforcer.EnableAutoSave(false);
+
+ // Verify initial state
+ Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync());
+
+ // Add policy - should NOT save to database with AutoSave OFF
+ enforcer.AddPolicy("charlie", "data3", "read");
+
+ // Verify policy was NOT saved yet (correct behavior for regular policies)
+ var countAfterAdd = await context.Policies.AsNoTracking().CountAsync();
+ Assert.Equal(5, countAfterAdd); // Still 5 - CORRECT BEHAVIOR
+
+ // Verify policy is NOT in database yet
+ var charlieBeforeSave = await context.Policies.AsNoTracking()
+ .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie");
+ Assert.Null(charlieBeforeSave); // Not in database yet - CORRECT
+
+ // When SavePolicy is called, it should save the policy
+ await enforcer.SavePolicyAsync();
+
+ // Now the policy should be in database
+ Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync()); // 5 + 1 = 6
+
+ // Verify it's in database after SavePolicy
+ var charlieAfterSave = await context.Policies.AsNoTracking()
+ .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie");
+ Assert.NotNull(charlieAfterSave);
+ }
+
+ ///
+ /// Tests async version of regular policies with AutoSave OFF.
+ /// Verifies that AddPolicyAsync() correctly respects AutoSave OFF setting.
+ ///
+ [Fact]
+ public async Task TestPolicyAutoSaveOffAsync()
+ {
+ await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
+ await connection.OpenAsync();
+
+ var options = new DbContextOptionsBuilder>()
+ .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.PoliciesSchema))
+ .Options;
+
+ await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.PoliciesSchema);
+ await InitPolicyAsync(context);
+
+ var adapter = new EFCoreAdapter(context);
+ var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf"));
+ var enforcer = new Enforcer(model, adapter);
+
+ // Disable AutoSave
+ enforcer.EnableAutoSave(false);
+
+ // Verify initial state
+ Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync());
+
+ // Add policy - should NOT save to database with AutoSave OFF
+ await enforcer.AddPolicyAsync("charlie", "data3", "read");
+
+ // Verify policy was NOT saved yet (correct behavior)
+ var countAfterAdd = await context.Policies.AsNoTracking().CountAsync();
+ Assert.Equal(5, countAfterAdd); // Still 5 - CORRECT BEHAVIOR
+
+ // When SavePolicy is called, it should save the policy
+ await enforcer.SavePolicyAsync();
+
+ // Now the policy should be in database
+ Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync());
+
+ // Verify it's in database
+ var charlieAfterSave = await context.Policies.AsNoTracking()
+ .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie");
+ Assert.NotNull(charlieAfterSave);
+ }
+
+ ///
+ /// Tests grouping policies with AutoSave ON (default behavior).
+ /// This test verifies that AddGroupingPolicy() immediately saves to database.
+ ///
+ [Fact]
+ public async Task TestGroupingPolicyAutoSaveOn()
+ {
+ await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
+ await connection.OpenAsync();
+
+ var options = new DbContextOptionsBuilder>()
+ .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema))
+ .Options;
+
+ await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema);
+ await InitPolicyAsync(context);
+
+ var adapter = new EFCoreAdapter(context);
+ var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf"));
+ var enforcer = new Enforcer(model, adapter);
+
+ // Verify initial grouping policy
+ TestGetGroupingPolicy(enforcer, AsList(
+ AsList("alice", "data2_admin")
+ ));
+ Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync());
+
+ // Add grouping policy - should save immediately with AutoSave ON
+ await enforcer.AddGroupingPolicyAsync("bob", "data2_admin");
+
+ // Verify it's in Casbin's memory
+ TestGetGroupingPolicy(enforcer, AsList(
+ AsList("alice", "data2_admin"),
+ AsList("bob", "data2_admin")
+ ));
+
+ // Verify it was saved to database immediately
+ Assert.Equal(6, await context.Policies.AsNoTracking().CountAsync());
+ var bobGrouping = await context.Policies.AsNoTracking()
+ .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin");
+ Assert.NotNull(bobGrouping);
+ }
+
+ ///
+ /// Tests grouping policies with AutoSave OFF.
+ ///
+ /// Verifies that AddGroupingPolicy() respects the EnableAutoSave(false) setting.
+ ///
+ /// Expected behavior (verified by this test):
+ /// - AddGroupingPolicy() should NOT save to database when AutoSave is OFF
+ /// - Only SavePolicy() should commit changes
+ ///
+ /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug.
+ ///
+ /// Related: Integration/README.md
+ ///
+ [Fact]
+ public async Task TestGroupingPolicyAutoSaveOff()
+ {
+ await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
+ await connection.OpenAsync();
+
+ var options = new DbContextOptionsBuilder>()
+ .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema))
+ .Options;
+
+ await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema);
+ await InitPolicyAsync(context);
+
+ var adapter = new EFCoreAdapter(context);
+ var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf"));
+ var enforcer = new Enforcer(model, adapter);
+
+ // Disable AutoSave
+ enforcer.EnableAutoSave(false);
+
+ // Verify initial state
+ Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync());
+
+ // Add regular policy - should NOT save to database with AutoSave OFF
+ enforcer.AddPolicy("charlie", "data3", "read");
+
+ // Verify regular policy was NOT saved (correct behavior)
+ Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync());
+
+ // Add grouping policy - should NOT save to database with AutoSave OFF
+ await enforcer.AddGroupingPolicyAsync("bob", "data2_admin");
+
+ // TEST EXPECTATION: Grouping policy should NOT be saved yet (AutoSave is OFF)
+ // BUG: This will fail because Casbin.NET incorrectly saves it (Actual: 6, Expected: 5)
+ var savedCountAfterAdd = await context.Policies.AsNoTracking().CountAsync();
+ Assert.Equal(5, savedCountAfterAdd); // FAILS due to bug: actual is 6
+
+ // Verify the grouping policy is NOT in database yet
+ var bobGroupingBeforeSave = await context.Policies.AsNoTracking()
+ .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin");
+ Assert.Null(bobGroupingBeforeSave); // FAILS due to bug: it exists
+
+ // When SavePolicy is called, it should save BOTH policies
+ await enforcer.SavePolicyAsync();
+
+ // Now both policies should be in database
+ Assert.Equal(7, await context.Policies.AsNoTracking().CountAsync()); // 5 original + 1 charlie + 1 bob = 7
+
+ // Verify both are in database after SavePolicy
+ var charliePolicy = await context.Policies.AsNoTracking()
+ .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie");
+ var bobGroupingAfterSave = await context.Policies.AsNoTracking()
+ .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin");
+
+ Assert.NotNull(charliePolicy);
+ Assert.NotNull(bobGroupingAfterSave);
+ }
+
+ ///
+ /// Tests async version of grouping policies with AutoSave OFF.
+ ///
+ /// Verifies that AddGroupingPolicyAsync() respects the EnableAutoSave(false) setting.
+ ///
+ /// Expected behavior (verified by this test): AddGroupingPolicyAsync() should NOT save when AutoSave is OFF.
+ ///
+ /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug.
+ ///
+ [Fact]
+ public async Task TestGroupingPolicyAutoSaveOffAsync()
+ {
+ await using var connection = new NpgsqlConnection(_fixture.ConnectionString);
+ await connection.OpenAsync();
+
+ var options = new DbContextOptionsBuilder>()
+ .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", TransactionIntegrityTestFixture.GroupingsSchema))
+ .Options;
+
+ await using var context = new CasbinDbContext(options, schemaName: TransactionIntegrityTestFixture.GroupingsSchema);
+ await InitPolicyAsync(context);
+
+ var adapter = new EFCoreAdapter(context);
+ var model = DefaultModel.CreateFromText(System.IO.File.ReadAllText("examples/rbac_model.conf"));
+ var enforcer = new Enforcer(model, adapter);
+
+ // Disable AutoSave
+ enforcer.EnableAutoSave(false);
+
+ // Verify initial state
+ Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync());
+
+ // Add regular policy - should NOT save to database with AutoSave OFF
+ await enforcer.AddPolicyAsync("charlie", "data3", "read");
+
+ // Verify regular policy was NOT saved (correct behavior)
+ Assert.Equal(5, await context.Policies.AsNoTracking().CountAsync());
+
+ // Add grouping policy - should NOT save to database with AutoSave OFF
+ await enforcer.AddGroupingPolicyAsync("bob", "data2_admin");
+
+ // TEST EXPECTATION: Grouping policy should NOT be saved yet
+ // BUG: This will fail because Casbin.NET incorrectly saves it (Actual: 6, Expected: 5)
+ var savedCountAfterAdd = await context.Policies.AsNoTracking().CountAsync();
+ Assert.Equal(5, savedCountAfterAdd); // FAILS due to bug: actual is 6
+
+ // When SavePolicy is called, it should save BOTH policies
+ await enforcer.SavePolicyAsync();
+
+ // Now both policies should be in database
+ Assert.Equal(7, await context.Policies.AsNoTracking().CountAsync()); // 5 original + 1 charlie + 1 bob = 7
+
+ // Verify both are in database
+ var charliePolicy = await context.Policies.AsNoTracking()
+ .FirstOrDefaultAsync(p => p.Type == "p" && p.Value1 == "charlie");
+ var bobGrouping = await context.Policies.AsNoTracking()
+ .FirstOrDefaultAsync(p => p.Type == "g" && p.Value1 == "bob" && p.Value2 == "data2_admin");
+
+ Assert.NotNull(charliePolicy);
+ Assert.NotNull(bobGrouping);
+ }
+
+ #region Multi-Context AutoSave Tests
+
+ ///
+ /// Provider that routes policy types to three separate contexts
+ ///
+ private class ThreeWayContextProvider : ICasbinDbContextProvider
+ {
+ private readonly CasbinDbContext _policyContext;
+ private readonly CasbinDbContext _groupingContext;
+ private readonly CasbinDbContext _roleContext;
+ private readonly System.Data.Common.DbConnection? _sharedConnection;
+
+ public ThreeWayContextProvider(
+ CasbinDbContext policyContext,
+ CasbinDbContext groupingContext,
+ CasbinDbContext roleContext,
+ System.Data.Common.DbConnection? sharedConnection)
+ {
+ _policyContext = policyContext;
+ _groupingContext = groupingContext;
+ _roleContext = roleContext;
+ _sharedConnection = sharedConnection;
+ }
+
+ public DbContext GetContextForPolicyType(string policyType)
+ {
+ return policyType switch
+ {
+ "p" => _policyContext, // p policies → casbin_policies schema
+ "g" => _groupingContext, // g groupings → casbin_groupings schema
+ "g2" => _roleContext, // g2 roles → casbin_roles schema
+ _ => _policyContext
+ };
+ }
+
+ public IEnumerable GetAllContexts()
+ {
+ return new[] { _policyContext, _groupingContext, _roleContext };
+ }
+
+ public System.Data.Common.DbConnection? GetSharedConnection()
+ {
+ return _sharedConnection;
+ }
+ }
+
+ ///
+ /// Tests AutoSave OFF with multiple contexts and rollback on failure.
+ ///
+ /// Verifies that:
+ /// - With AutoSave OFF, policies batch in memory (not commit)
+ /// - SavePolicy() uses shared transaction and rolls back atomically on failure
+ ///
+ /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug.
+ ///
+ [Fact]
+ public async Task TestAutoSaveOff_MultiContext_RollbackOnFailure()
+ {
+ _output.WriteLine("=== AUTOSAVE OFF - MULTI-CONTEXT ATOMIC ROLLBACK TEST ===");
+ _output.WriteLine("Goal: With AutoSave OFF, SavePolicy should use shared transaction and rollback atomically");
+ _output.WriteLine("");
+
+ // Clear all data first
+ await _fixture.ClearAllPoliciesAsync();
+
+ // Create ONE shared connection
+ var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString);
+ await sharedConnection.OpenAsync();
+
+ try
+ {
+ _output.WriteLine($"Shared connection: {sharedConnection.GetHashCode()}");
+ _output.WriteLine("");
+
+ // Create three contexts using the SAME connection object
+ var options1 = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection)
+ .Options;
+ var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule");
+
+ var options2 = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection)
+ .Options;
+ var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule");
+
+ var options3 = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection)
+ .Options;
+ var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule");
+
+ // Create provider and adapter
+ var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection);
+ var adapter = new EFCoreAdapter(provider);
+
+ // Create enforcer and DISABLE AutoSave
+ var model = DefaultModel.CreateFromFile(ModelPath);
+ var enforcer = new Enforcer(model);
+ enforcer.SetAdapter(adapter);
+ enforcer.EnableAutoSave(false); // ← CRITICAL: Disable AutoSave
+ _output.WriteLine("AutoSave disabled");
+ _output.WriteLine("");
+
+ // Add multiple policies to each type
+ _output.WriteLine("Adding policies with AutoSave OFF (should batch in memory):");
+ enforcer.AddPolicy("alice", "data1", "read");
+ enforcer.AddPolicy("bob", "data2", "write");
+ _output.WriteLine(" Added 2 p policies");
+
+ enforcer.AddGroupingPolicy("alice", "admin");
+ enforcer.AddGroupingPolicy("bob", "user");
+ _output.WriteLine(" Added 2 g groupings");
+
+ enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser");
+ enforcer.AddNamedGroupingPolicy("g2", "user", "role-basic");
+ _output.WriteLine(" Added 2 g2 roles");
+ _output.WriteLine("");
+
+ // Check database state - should be EMPTY (policies batched, not committed)
+ var beforePoliciesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema);
+ var beforeGroupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema);
+ var beforeRolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema);
+ _output.WriteLine($"STATE BEFORE DROP (should be 0,0,0): ({beforePoliciesCount}, {beforeGroupingsCount}, {beforeRolesCount})");
+
+ if (beforePoliciesCount == 0 && beforeGroupingsCount == 0 && beforeRolesCount == 0)
+ {
+ _output.WriteLine("✓ Confirmed: AutoSave OFF prevents immediate commits");
+ }
+ else
+ {
+ _output.WriteLine("✗ WARNING: Policies were committed despite AutoSave OFF!");
+ }
+ _output.WriteLine("");
+
+ // NOW: Drop the table for the third schema to force a failure
+ _output.WriteLine("FORCING FAILURE: Dropping casbin_roles.casbin_rule table...");
+ await using (var cmd = sharedConnection.CreateCommand())
+ {
+ cmd.CommandText = $"DROP TABLE {TransactionIntegrityTestFixture.RolesSchema}.casbin_rule";
+ await cmd.ExecuteNonQueryAsync();
+ }
+ _output.WriteLine("Table dropped!");
+ _output.WriteLine("");
+
+ // Try to save - this SHOULD fail
+ _output.WriteLine("Calling SavePolicyAsync()... (expecting exception)");
+ var exception = await Assert.ThrowsAnyAsync(async () =>
+ {
+ await enforcer.SavePolicyAsync();
+ });
+ _output.WriteLine($"✓ Exception caught as expected: {exception.GetType().Name}");
+ _output.WriteLine($" Message: {exception.Message}");
+ _output.WriteLine("");
+
+ // Count policies in the first two schemas (third schema table is gone)
+ var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema);
+ var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema);
+
+ _output.WriteLine("RESULTS - Policies per schema after failure:");
+ _output.WriteLine($" casbin_policies: {policiesCount}");
+ _output.WriteLine($" casbin_groupings: {groupingsCount}");
+ _output.WriteLine($" casbin_roles: N/A (table dropped)");
+ _output.WriteLine("");
+
+ // ASSERT: With AutoSave OFF and shared connection, SavePolicy should roll back ALL changes
+ if (policiesCount == 0 && groupingsCount == 0)
+ {
+ _output.WriteLine("✓✓✓ AUTOSAVE OFF ATOMIC TRANSACTION TEST PASSED!");
+ _output.WriteLine("SavePolicy used shared transaction and rolled back atomically");
+ }
+ else
+ {
+ _output.WriteLine("✗✗✗ AUTOSAVE OFF ATOMIC TRANSACTION TEST FAILED!");
+ _output.WriteLine($"Expected: (0, 0), Got: ({policiesCount}, {groupingsCount})");
+ }
+
+ Assert.Equal(0, policiesCount);
+ Assert.Equal(0, groupingsCount);
+
+ await policyContext.DisposeAsync();
+ await groupingContext.DisposeAsync();
+ await roleContext.DisposeAsync();
+ }
+ finally
+ {
+ await sharedConnection.DisposeAsync();
+ }
+ }
+
+ ///
+ /// Tests AutoSave ON with multiple contexts showing individual commits.
+ /// Verifies that each AddPolicy commits independently (no cross-context atomicity).
+ ///
+ [Fact]
+ public async Task TestAutoSaveOn_MultiContext_IndividualCommits()
+ {
+ _output.WriteLine("=== AUTOSAVE ON - INDIVIDUAL COMMITS TEST ===");
+ _output.WriteLine("Goal: With AutoSave ON, each Add should commit independently (no atomicity)");
+ _output.WriteLine("");
+
+ // Clear all data first
+ await _fixture.ClearAllPoliciesAsync();
+
+ // Create ONE shared connection
+ var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString);
+ await sharedConnection.OpenAsync();
+
+ try
+ {
+ // Create three contexts using the SAME connection object
+ var options1 = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection)
+ .Options;
+ var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule");
+
+ var options2 = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection)
+ .Options;
+ var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule");
+
+ var options3 = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection)
+ .Options;
+ var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule");
+
+ // Create provider and adapter
+ var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection);
+ var adapter = new EFCoreAdapter(provider);
+
+ // Create enforcer with AutoSave ON (default)
+ var model = DefaultModel.CreateFromFile(ModelPath);
+ var enforcer = new Enforcer(model);
+ enforcer.SetAdapter(adapter);
+ // enforcer.EnableAutoSave(true); // Default is true, no need to set
+ _output.WriteLine("AutoSave enabled (default)");
+ _output.WriteLine("");
+
+ // Add policies to context 1 and check DB immediately
+ _output.WriteLine("Step 1: Adding 2 p policies (should commit immediately):");
+ enforcer.AddPolicy("alice", "data1", "read");
+ enforcer.AddPolicy("bob", "data2", "write");
+ var step1Count = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema);
+ _output.WriteLine($" DB state after p policies: ({step1Count}, ?, ?)");
+ Assert.Equal(2, step1Count);
+ _output.WriteLine("");
+
+ // Add policies to context 2 and check DB immediately
+ _output.WriteLine("Step 2: Adding 2 g policies (should commit immediately):");
+ enforcer.AddGroupingPolicy("alice", "admin");
+ enforcer.AddGroupingPolicy("bob", "user");
+ var step2CountPolicy = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema);
+ var step2CountGrouping = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema);
+ _output.WriteLine($" DB state after g policies: ({step2CountPolicy}, {step2CountGrouping}, ?)");
+ Assert.Equal(2, step2CountPolicy);
+ Assert.Equal(2, step2CountGrouping);
+ _output.WriteLine("");
+
+ // NOW: Drop the table for the third schema
+ _output.WriteLine("Step 3: Dropping casbin_roles.casbin_rule table...");
+ await using (var cmd = sharedConnection.CreateCommand())
+ {
+ cmd.CommandText = $"DROP TABLE {TransactionIntegrityTestFixture.RolesSchema}.casbin_rule";
+ await cmd.ExecuteNonQueryAsync();
+ }
+ _output.WriteLine("Table dropped!");
+ _output.WriteLine("");
+
+ // Try to add policy to context 3 - should fail
+ _output.WriteLine("Step 4: Trying to add g2 policy (expecting exception):");
+ var exception = await Assert.ThrowsAnyAsync(async () =>
+ {
+ await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "role-superuser");
+ });
+ _output.WriteLine($"✓ Exception caught as expected: {exception.GetType().Name}");
+ _output.WriteLine($" Message: {exception.Message}");
+ _output.WriteLine("");
+
+ // Check final state - contexts 1 & 2 should still have their data
+ var finalPoliciesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema);
+ var finalGroupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema);
+
+ _output.WriteLine("FINAL RESULTS:");
+ _output.WriteLine($" casbin_policies: {finalPoliciesCount}");
+ _output.WriteLine($" casbin_groupings: {finalGroupingsCount}");
+ _output.WriteLine($" casbin_roles: N/A (table dropped)");
+ _output.WriteLine("");
+
+ // ASSERT: With AutoSave ON, each context committed independently
+ if (finalPoliciesCount == 2 && finalGroupingsCount == 2)
+ {
+ _output.WriteLine("✓✓✓ AUTOSAVE ON INDIVIDUAL COMMITS TEST PASSED!");
+ _output.WriteLine("Each AddPolicy committed independently, no cross-context atomicity");
+ }
+ else
+ {
+ _output.WriteLine("✗✗✗ AUTOSAVE ON INDIVIDUAL COMMITS TEST FAILED!");
+ _output.WriteLine($"Expected: (2, 2), Got: ({finalPoliciesCount}, {finalGroupingsCount})");
+ }
+
+ Assert.Equal(2, finalPoliciesCount);
+ Assert.Equal(2, finalGroupingsCount);
+
+ await policyContext.DisposeAsync();
+ await groupingContext.DisposeAsync();
+ await roleContext.DisposeAsync();
+ }
+ finally
+ {
+ await sharedConnection.DisposeAsync();
+ }
+ }
+
+ ///
+ /// Tests AutoSave OFF success path with batched commit across multiple contexts.
+ ///
+ /// Verifies that with AutoSave OFF, SavePolicy() batches all operations
+ /// in a shared transaction and commits atomically.
+ ///
+ /// This test now passes with Casbin.NET 2.19.1+ which fixed the AutoSave bug.
+ ///
+ [Fact]
+ public async Task TestAutoSaveOff_MultiContext_BatchedCommit()
+ {
+ _output.WriteLine("=== AUTOSAVE OFF - SUCCESS PATH TEST ===");
+ _output.WriteLine("Goal: With AutoSave OFF, SavePolicy should batch all operations in shared transaction");
+ _output.WriteLine("");
+
+ // Clear all data first
+ await _fixture.ClearAllPoliciesAsync();
+
+ // Create ONE shared connection
+ var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString);
+ await sharedConnection.OpenAsync();
+
+ try
+ {
+ // Create three contexts
+ var options1 = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection)
+ .Options;
+ var policyContext = new TestCasbinDbContext1(options1, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule");
+
+ var options2 = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection)
+ .Options;
+ var groupingContext = new TestCasbinDbContext2(options2, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule");
+
+ var options3 = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection)
+ .Options;
+ var roleContext = new TestCasbinDbContext3(options3, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule");
+
+ var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection);
+ var adapter = new EFCoreAdapter(provider);
+
+ var model = DefaultModel.CreateFromFile(ModelPath);
+ var enforcer = new Enforcer(model);
+ enforcer.SetAdapter(adapter);
+ enforcer.EnableAutoSave(false);
+ _output.WriteLine("AutoSave disabled");
+ _output.WriteLine("");
+
+ // Add policies
+ _output.WriteLine("Adding policies with AutoSave OFF:");
+ enforcer.AddPolicy("alice", "data1", "read");
+ enforcer.AddPolicy("bob", "data2", "write");
+ enforcer.AddGroupingPolicy("alice", "admin");
+ enforcer.AddGroupingPolicy("bob", "user");
+ enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser");
+ enforcer.AddNamedGroupingPolicy("g2", "user", "role-basic");
+ _output.WriteLine(" Added 6 policies total");
+ _output.WriteLine("");
+
+ // Check DB before SavePolicy
+ var beforeCount1 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema);
+ var beforeCount2 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema);
+ var beforeCount3 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema);
+ _output.WriteLine($"DB state BEFORE SavePolicy: ({beforeCount1}, {beforeCount2}, {beforeCount3})");
+
+ if (beforeCount1 == 0 && beforeCount2 == 0 && beforeCount3 == 0)
+ {
+ _output.WriteLine("✓ Confirmed: Policies batched in memory, not committed yet");
+ }
+ _output.WriteLine("");
+
+ // Call SavePolicy
+ _output.WriteLine("Calling SavePolicyAsync()...");
+ await enforcer.SavePolicyAsync();
+ _output.WriteLine("SavePolicyAsync() completed");
+ _output.WriteLine("");
+
+ // Check final state
+ var finalCount1 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema);
+ var finalCount2 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema);
+ var finalCount3 = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema);
+
+ _output.WriteLine($"DB state AFTER SavePolicy: ({finalCount1}, {finalCount2}, {finalCount3})");
+ _output.WriteLine("");
+
+ if (finalCount1 == 2 && finalCount2 == 2 && finalCount3 == 2)
+ {
+ _output.WriteLine("✓✓✓ AUTOSAVE OFF SUCCESS PATH TEST PASSED!");
+ _output.WriteLine("SavePolicy committed all batched policies atomically");
+ }
+ else
+ {
+ _output.WriteLine("✗✗✗ AUTOSAVE OFF SUCCESS PATH TEST FAILED!");
+ _output.WriteLine($"Expected: (2, 2, 2), Got: ({finalCount1}, {finalCount2}, {finalCount3})");
+ }
+
+ Assert.Equal(2, finalCount1);
+ Assert.Equal(2, finalCount2);
+ Assert.Equal(2, finalCount3);
+
+ await policyContext.DisposeAsync();
+ await groupingContext.DisposeAsync();
+ await roleContext.DisposeAsync();
+ }
+ finally
+ {
+ await sharedConnection.DisposeAsync();
+ }
+ }
+
+ #endregion
+
+ private static async Task InitPolicyAsync(CasbinDbContext context)
+ {
+ // Clear existing policies - use AsNoTracking to avoid concurrency exceptions
+ // when policies may have been deleted by fixture cleanup
+ var existing = await context.Policies.AsNoTracking().ToListAsync();
+ if (existing.Any())
+ {
+ context.Policies.AttachRange(existing);
+ context.Policies.RemoveRange(existing);
+ await context.SaveChangesAsync();
+ }
+
+ // Add test data
+ context.Policies.Add(new EFCorePersistPolicy()
+ {
+ Type = "p",
+ Value1 = "alice",
+ Value2 = "data1",
+ Value3 = "read",
+ });
+ context.Policies.Add(new EFCorePersistPolicy()
+ {
+ Type = "p",
+ Value1 = "bob",
+ Value2 = "data2",
+ Value3 = "write",
+ });
+ context.Policies.Add(new EFCorePersistPolicy()
+ {
+ Type = "p",
+ Value1 = "data2_admin",
+ Value2 = "data2",
+ Value3 = "read",
+ });
+ context.Policies.Add(new EFCorePersistPolicy()
+ {
+ Type = "p",
+ Value1 = "data2_admin",
+ Value2 = "data2",
+ Value3 = "write",
+ });
+ context.Policies.Add(new EFCorePersistPolicy()
+ {
+ Type = "g",
+ Value1 = "alice",
+ Value2 = "data2_admin",
+ });
+ await context.SaveChangesAsync();
+ }
+ }
+}
diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/IntegrationTestCollection.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/IntegrationTestCollection.cs
new file mode 100644
index 0000000..cbc4ced
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/IntegrationTestCollection.cs
@@ -0,0 +1,20 @@
+using Xunit;
+
+namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration
+{
+ ///
+ /// Collection definition for integration tests.
+ /// This ensures all test classes marked with [Collection("IntegrationTests")]
+ /// share a single TransactionIntegrityTestFixture instance.
+ ///
+ /// DisableParallelization = true ensures tests run sequentially to prevent
+ /// race conditions and schema conflicts.
+ ///
+ [CollectionDefinition("IntegrationTests", DisableParallelization = true)]
+ public class IntegrationTestCollection : ICollectionFixture
+ {
+ // This class has no code, and is never instantiated.
+ // Its purpose is simply to be the place to apply [CollectionDefinition]
+ // and all the ICollectionFixture<> interfaces.
+ }
+}
diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/README.md b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/README.md
new file mode 100644
index 0000000..2e2fdb1
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/README.md
@@ -0,0 +1,266 @@
+# Integration Tests for Multi-Context Transaction Integrity
+
+This directory contains integration tests that verify the transaction integrity guarantees of the multi-context EFCore adapter feature.
+
+## Separate Test Project
+
+These integration tests are in a **separate test project** (`Casbin.Persist.Adapter.EFCore.IntegrationTest`) to enable sequential framework execution.
+
+**Why separate?**
+- Integration tests run frameworks **sequentially** (one at a time) to avoid PostgreSQL database conflicts
+- Unit tests continue running frameworks **in parallel** for faster execution
+- .NET 9+ runs multi-targeted tests in parallel by default - this separation allows different configurations
+
+**Project Settings:**
+- `false` - Frameworks execute sequentially
+- Shares single PostgreSQL database: `casbin_integration_test`
+- Uses `DisableParallelization = true` on test collection for within-framework sequencing
+
+## Purpose
+
+These tests prove that when multiple `DbContext` instances share the same `DbConnection` object, operations across contexts are **atomic** - they either all succeed or all fail together.
+
+## Prerequisites
+
+### 1. PostgreSQL Installation
+
+You need PostgreSQL running locally on your development machine.
+
+**Install PostgreSQL:**
+- **Windows**: Download from [postgresql.org](https://www.postgresql.org/download/windows/)
+- **macOS**: `brew install postgresql@17` (or use [Postgres.app](https://postgresapp.com/))
+- **Linux**: `sudo apt-get install postgresql` (Debian/Ubuntu) or equivalent
+
+### 2. Database Setup
+
+Create the test database:
+
+```bash
+# Connect to PostgreSQL (default superuser is 'postgres')
+psql -U postgres
+
+# Create the test database
+CREATE DATABASE casbin_integration_test;
+
+# Exit psql
+\q
+```
+
+Alternatively, use a one-liner:
+
+```bash
+psql -U postgres -c "CREATE DATABASE casbin_integration_test;"
+```
+
+### 3. Connection Credentials
+
+The tests use these default credentials:
+- **Host**: `localhost:5432`
+- **Database**: `casbin_integration_test`
+- **Username**: `postgres`
+- **Password**: `postgres4all!`
+
+**If your PostgreSQL uses different credentials**, update the connection string in [TransactionIntegrityTestFixture.cs](TransactionIntegrityTestFixture.cs):
+
+```csharp
+ConnectionString = "Host=localhost;Database=casbin_integration_test;Username=YOUR_USER;Password=YOUR_PASSWORD";
+```
+
+## Running the Tests
+
+### Run All Integration Tests
+
+```bash
+dotnet test --filter "Category=Integration"
+```
+
+### Run a Specific Test
+
+```bash
+dotnet test --filter "FullyQualifiedName~SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically"
+```
+
+### Run with Specific Framework
+
+```bash
+dotnet test --filter "Category=Integration" -f net9.0
+```
+
+## Test Architecture
+
+### Test Fixture
+
+The [TransactionIntegrityTestFixture](TransactionIntegrityTestFixture.cs) automatically:
+1. Creates 3 PostgreSQL schemas: `casbin_policies`, `casbin_groupings`, `casbin_roles`
+2. Creates tables in each schema
+3. Clears all data before each test
+4. Cleans up schemas after all tests complete
+
+### Test Organization
+
+The integration tests are organized into 3 test classes:
+
+| Test Class | Tests | Purpose |
+|------------|-------|---------|
+| `TransactionIntegrityTests` | 7 | Multi-context transaction atomicity and rollback |
+| `AutoSaveTests` | 10 | Casbin.NET AutoSave behavior verification |
+| `SchemaDistributionTests` | 2 | Schema routing with shared connections |
+
+**Total:** 19 integration tests
+
+The tests use a three-way context provider that routes:
+- **p policies** → `casbin_policies` schema
+- **g groupings** → `casbin_groupings` schema
+- **g2 roles** → `casbin_roles` schema
+
+This simulates real-world multi-context scenarios where different policy types are stored separately for compliance, multi-tenancy, or organizational requirements.
+
+## Test Coverage
+
+| Test | What It Proves |
+|------|----------------|
+| `SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically` | Policies written to 3 schemas in a single atomic transaction |
+| `MultiContextSetup_WithSharedConnection_ShouldShareSamePhysicalConnection` | Reference equality confirms DbConnection object sharing |
+| `SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts` | Severe failures cause complete rollback |
+| `MultipleSaveOperations_WithSharedConnection_ShouldMaintainDataConsistency` | Multiple operations maintain consistency over time |
+| `SavePolicy_WithSeparateConnections_ShouldNotBeAtomic` | **Negative test**: Proves separate connections are NOT atomic |
+| `SavePolicy_ShouldReflectDatabaseStateNotCasbinMemory` | Tests verify actual database state, not just Casbin memory |
+
+### SchemaDistributionTests
+
+**File:** [SchemaDistributionTests.cs](SchemaDistributionTests.cs)
+**Test Count:** 2
+**Status:** ✅ All Passing
+
+**Purpose:**
+
+These tests verify that `CasbinDbContext.HasDefaultSchema()` correctly routes policies to their designated schemas when using shared connections, ensuring schema isolation is maintained.
+
+**Test Coverage:**
+
+| Test | Purpose | Status |
+|------|---------|--------|
+| `SavePolicy_SeparateConnections_ShouldDistributeAcrossSchemas` | Baseline behavior with separate connections | ✅ Passing |
+| `SavePolicy_SharedConnection_ShouldDistributeAcrossSchemas` | Schema routing with shared connection | ✅ Passing |
+
+**What They Test:**
+
+1. **Schema Routing:**
+ - `p` policies → `casbin_policies` schema
+ - `g` policies → `casbin_groupings` schema
+ - `g2` policies → `casbin_roles` schema
+
+2. **Shared Connection Impact:**
+ - Verifies `HasDefaultSchema()` returns correct schema name per context
+ - Confirms shared connection doesn't break schema isolation
+ - Validates multi-context routing works correctly
+
+3. **Database Verification:**
+ - Direct SQL queries to each schema
+ - Counts policies by type in each schema
+ - Asserts correct distribution (e.g., only `p` types in `casbin_policies` schema)
+
+**Why This Matters:**
+
+When using a shared connection for atomic transactions, each context must still route to its correct schema. These tests prove that sharing a connection object doesn't accidentally merge contexts or route to wrong schemas.
+
+**Running the Tests:**
+
+```bash
+# Run both SchemaDistributionTests
+dotnet test -f net6.0 --filter "FullyQualifiedName~SchemaDistributionTests" --verbosity normal
+
+# Run specific test
+dotnet test -f net6.0 --filter "FullyQualifiedName~SavePolicy_SharedConnection_ShouldDistributeAcrossSchemas" --verbosity normal
+```
+
+### AutoSaveTests
+
+**File:** [AutoSaveTests.cs](AutoSaveTests.cs)
+**Test Count:** 10
+**Status:** ✅ All Passing
+
+**Purpose:**
+
+These tests verify the Casbin Enforcer's `EnableAutoSave` behavior in multi-context scenarios and prove that `EnableAutoSave(false)` is required for atomic rollback testing.
+
+**Key Tests:**
+
+| Test | What It Proves | Status |
+|------|----------------|--------|
+| `TestPolicyAutoSaveOn` / `TestPolicyAutoSaveOnAsync` | AutoSave ON commits immediately | ✅ Passing |
+| `TestPolicyAutoSaveOff` | AutoSave OFF defers until SavePolicy | ✅ Passing |
+| `TestGroupingPolicyAutoSaveOn` | Grouping policies also commit immediately | ✅ Passing |
+| `TestGroupingPolicyAutoSaveOff` | Grouping policies defer with AutoSave OFF | ✅ Passing |
+| `TestAutoSaveOn_MultiContext_IndividualCommits` | Multi-context: operations commit independently | ✅ Passing |
+| `TestAutoSaveOff_MultiContext_RollbackOnFailure` | Multi-context: atomic rollback with AutoSave OFF | ✅ Passing |
+
+**Why AutoSave Testing Matters:**
+
+The rollback tests in `TransactionIntegrityTests` require `enforcer.EnableAutoSave(false)` (lines 302, 370) because:
+- With AutoSave ON: Policies commit immediately when `AddPolicyAsync()` is called
+- With AutoSave OFF: Policies stay in memory until `SavePolicyAsync()` is called
+- Atomic rollback testing requires all policies to be part of the same transaction
+
+**See:** [MULTI_CONTEXT_USAGE_GUIDE.md - EnableAutoSave and Transaction Atomicity](../../MULTI_CONTEXT_USAGE_GUIDE.md#enableautosave-and-transaction-atomicity) for detailed explanation.
+
+## Why These Tests Are Excluded from CI/CD
+
+These tests are marked with `[Trait("Category", "Integration")]` and **excluded from CI/CD** because:
+
+1. **Pipeline Ownership**: The CI/CD pipeline is not owned by this project's maintainers
+2. **External Dependency**: Requires a PostgreSQL instance with specific configuration
+3. **Local Verification**: These tests are for **local verification only** - they prove the documented transaction guarantees work correctly
+
+## Troubleshooting
+
+### Error: "could not connect to server"
+
+PostgreSQL is not running. Start it:
+- **Windows**: Open Services → Start "postgresql-x64-XX"
+- **macOS (Homebrew)**: `brew services start postgresql@17`
+- **Linux**: `sudo systemctl start postgresql`
+
+### Error: "database 'casbin_integration_test' does not exist"
+
+Create the database:
+```bash
+psql -U postgres -c "CREATE DATABASE casbin_integration_test;"
+```
+
+### Error: "password authentication failed for user 'postgres'"
+
+Either:
+1. Update your PostgreSQL password: `ALTER USER postgres PASSWORD 'postgres';`
+2. Or update the connection string in [TransactionIntegrityTestFixture.cs](TransactionIntegrityTestFixture.cs) to match your credentials
+
+### Error: "relation 'casbin_rule' does not exist"
+
+The test fixture should create tables automatically. If this fails:
+1. Ensure the database exists
+2. Ensure the user has CREATE privileges: `GRANT ALL PRIVILEGES ON DATABASE casbin_integration_test TO postgres;`
+3. Try manually creating schemas: `CREATE SCHEMA casbin_policies;` etc.
+
+## Verification of Transaction Guarantees
+
+### Critical Rollback Tests
+
+The **most critical tests** are the rollback verification tests:
+- `SavePolicy_WhenTableDroppedInOneContext_ShouldRollbackAllContexts`
+- `SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts`
+
+**Key Implementation Detail:**
+
+These tests call `enforcer.EnableAutoSave(false)` immediately after creating the enforcer (lines 302, 370 in `TransactionIntegrityTests.cs`). This is **critical** because:
+
+- **With AutoSave ON (default):** `AddPolicyAsync()` commits immediately to database. When `SavePolicyAsync()` is called later and fails, it only rolls back DELETE operations, not the earlier INSERT operations that already committed.
+
+- **With AutoSave OFF:** Policies stay in-memory until `SavePolicyAsync()` is called. When the transaction fails, ALL operations (INSERT and DELETE) roll back atomically.
+
+**Code Reference:** See lines 302, 370 in [TransactionIntegrityTests.cs](TransactionIntegrityTests.cs)
+
+## See Also
+
+- [MULTI_CONTEXT_DESIGN.md](../../MULTI_CONTEXT_DESIGN.md) - Technical design and architecture
+- [MULTI_CONTEXT_USAGE_GUIDE.md](../../MULTI_CONTEXT_USAGE_GUIDE.md) - User-facing usage guide
+- [Main README](../../README.md) - Project overview
diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/SchemaDistributionTests.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/SchemaDistributionTests.cs
new file mode 100644
index 0000000..b555ca7
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/SchemaDistributionTests.cs
@@ -0,0 +1,342 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Casbin;
+using Casbin.Model;
+using Npgsql;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+using Xunit.Abstractions;
+
+#nullable enable
+
+namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration
+{
+ ///
+ /// Tests to verify whether HasDefaultSchema() properly distributes policies across PostgreSQL schemas,
+ /// both with separate connections and with shared connections.
+ ///
+ /// Purpose: Determine if explicit SET search_path is necessary or if EF Core's HasDefaultSchema()
+ /// generates schema-qualified SQL that works correctly with shared connections.
+ ///
+ [Trait("Category", "Integration")]
+ [Collection("IntegrationTests")]
+ public class SchemaDistributionTests : IClassFixture, IAsyncLifetime
+ {
+ private readonly TransactionIntegrityTestFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private const string ModelPath = "examples/multi_context_model.conf";
+
+ public SchemaDistributionTests(TransactionIntegrityTestFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ public Task InitializeAsync() => _fixture.ClearAllPoliciesAsync();
+ public Task DisposeAsync() => _fixture.RunMigrationsAsync();
+
+ #region Helper: Derived Context Classes
+
+ ///
+ /// Derived context for policies schema
+ ///
+ public class TestCasbinDbContext1 : CasbinDbContext
+ {
+ public TestCasbinDbContext1(
+ DbContextOptions> options,
+ string schemaName,
+ string tableName)
+ : base(options, schemaName, tableName)
+ {
+ }
+ }
+
+ ///
+ /// Derived context for groupings schema
+ ///
+ public class TestCasbinDbContext2 : CasbinDbContext
+ {
+ public TestCasbinDbContext2(
+ DbContextOptions> options,
+ string schemaName,
+ string tableName)
+ : base(options, schemaName, tableName)
+ {
+ }
+ }
+
+ ///
+ /// Derived context for roles schema
+ ///
+ public class TestCasbinDbContext3 : CasbinDbContext
+ {
+ public TestCasbinDbContext3(
+ DbContextOptions> options,
+ string schemaName,
+ string tableName)
+ : base(options, schemaName, tableName)
+ {
+ }
+ }
+
+ #endregion
+
+ #region Helper: Three-Context Provider
+
+ ///
+ /// Provider that routes policy types to three separate contexts
+ ///
+ private class ThreeWayContextProvider : ICasbinDbContextProvider
+ {
+ private readonly CasbinDbContext _policyContext;
+ private readonly CasbinDbContext _groupingContext;
+ private readonly CasbinDbContext _roleContext;
+ private readonly System.Data.Common.DbConnection? _sharedConnection;
+
+ public ThreeWayContextProvider(
+ CasbinDbContext policyContext,
+ CasbinDbContext groupingContext,
+ CasbinDbContext roleContext,
+ System.Data.Common.DbConnection? sharedConnection)
+ {
+ _policyContext = policyContext;
+ _groupingContext = groupingContext;
+ _roleContext = roleContext;
+ _sharedConnection = sharedConnection;
+ }
+
+ public DbContext GetContextForPolicyType(string policyType)
+ {
+ return policyType switch
+ {
+ "p" => _policyContext, // p policies → casbin_policies schema
+ "g" => _groupingContext, // g groupings → casbin_groupings schema
+ "g2" => _roleContext, // g2 roles → casbin_roles schema
+ _ => _policyContext
+ };
+ }
+
+ public IEnumerable GetAllContexts()
+ {
+ return new[] { _policyContext, _groupingContext, _roleContext };
+ }
+
+ public System.Data.Common.DbConnection? GetSharedConnection()
+ {
+ return _sharedConnection;
+ }
+ }
+
+ #endregion
+
+ #region Test 1: Separate Connections (Control/Baseline)
+
+ ///
+ /// BASELINE TEST: Proves that HasDefaultSchema() correctly distributes policies across schemas
+ /// when contexts use SEPARATE connections (no shared connection).
+ ///
+ /// This is the baseline that should work regardless of any SET search_path logic.
+ ///
+ [Fact]
+ public async Task SavePolicy_SeparateConnections_ShouldDistributeAcrossSchemas()
+ {
+ _output.WriteLine("=== TEST: Separate Connections - Schema Distribution ===");
+
+ // Create three contexts with SEPARATE connection strings (no shared connection)
+ var policyOptions = new DbContextOptionsBuilder>()
+ .UseNpgsql(_fixture.ConnectionString) // Connection #1
+ .Options;
+ var policyContext = new TestCasbinDbContext1(policyOptions, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule");
+
+ var groupingOptions = new DbContextOptionsBuilder>()
+ .UseNpgsql(_fixture.ConnectionString) // Connection #2
+ .Options;
+ var groupingContext = new TestCasbinDbContext2(groupingOptions, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule");
+
+ var roleOptions = new DbContextOptionsBuilder>()
+ .UseNpgsql(_fixture.ConnectionString) // Connection #3
+ .Options;
+ var roleContext = new TestCasbinDbContext3(roleOptions, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule");
+
+ _output.WriteLine("Created three contexts with SEPARATE connections");
+
+ // Verify they are different connection objects
+ var conn1 = policyContext.Database.GetDbConnection();
+ var conn2 = groupingContext.Database.GetDbConnection();
+ var conn3 = roleContext.Database.GetDbConnection();
+
+ Assert.False(ReferenceEquals(conn1, conn2), "Connections 1 and 2 should be different objects");
+ Assert.False(ReferenceEquals(conn2, conn3), "Connections 2 and 3 should be different objects");
+ _output.WriteLine("Verified: Contexts use DIFFERENT DbConnection objects");
+
+ try
+ {
+ // Create provider and adapter
+ // Pass null since these contexts use separate connections
+ var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, null);
+ var adapter = new EFCoreAdapter(provider);
+
+ // Create enforcer without loading policy (tables might be empty)
+ var model = DefaultModel.CreateFromFile(ModelPath);
+ var enforcer = new Enforcer(model);
+ enforcer.SetAdapter(adapter);
+
+ // Add policies to in-memory model (not persisted yet)
+ enforcer.AddPolicy("alice", "data1", "read"); // → casbin_policies
+ enforcer.AddGroupingPolicy("alice", "admin"); // → casbin_groupings
+ enforcer.AddNamedGroupingPolicy("g2", "admin", "role-superuser"); // → casbin_roles
+
+ _output.WriteLine("Added policies to in-memory model:");
+ _output.WriteLine(" p policy → casbin_policies");
+ _output.WriteLine(" g policy → casbin_groupings");
+ _output.WriteLine(" g2 policy → casbin_roles");
+
+ // Save to database
+ await enforcer.SavePolicyAsync();
+ _output.WriteLine("Called SavePolicyAsync()");
+
+ // Verify distribution across schemas
+ var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema);
+ var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema);
+ var rolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema);
+
+ _output.WriteLine($"Schema distribution:");
+ _output.WriteLine($" casbin_policies: {policiesCount} policy");
+ _output.WriteLine($" casbin_groupings: {groupingsCount} policy");
+ _output.WriteLine($" casbin_roles: {rolesCount} policy");
+
+ // CRITICAL ASSERTION: Policies should be distributed across all three schemas
+ Assert.Equal(1, policiesCount);
+ Assert.Equal(1, groupingsCount);
+ Assert.Equal(1, rolesCount);
+
+ _output.WriteLine("✓ BASELINE TEST PASSED: HasDefaultSchema() distributes policies correctly with separate connections");
+ }
+ finally
+ {
+ await policyContext.DisposeAsync();
+ await groupingContext.DisposeAsync();
+ await roleContext.DisposeAsync();
+ }
+ }
+
+ #endregion
+
+ #region Test 2: Shared Connection (Critical Test)
+
+ ///
+ /// CRITICAL TEST: Determines if HasDefaultSchema() correctly distributes policies across schemas
+ /// when contexts share a SINGLE DbConnection object.
+ ///
+ /// If this test FAILS: SET search_path approach is necessary
+ /// If this test PASSES: SET search_path approach is NOT necessary
+ ///
+ [Fact]
+ public async Task SavePolicy_SharedConnection_ShouldDistributeAcrossSchemas()
+ {
+ _output.WriteLine("=== TEST: Shared Connection - Schema Distribution ===");
+
+ // Create ONE shared connection (CRITICAL for this test)
+ var sharedConnection = new NpgsqlConnection(_fixture.ConnectionString);
+ await sharedConnection.OpenAsync();
+ _output.WriteLine("Opened shared connection for all three contexts");
+
+ try
+ {
+ // Create three contexts using SAME connection object
+ var policyOptions = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection) // ✅ Shared connection
+ .Options;
+ var policyContext = new TestCasbinDbContext1(policyOptions, TransactionIntegrityTestFixture.PoliciesSchema, "casbin_rule");
+
+ var groupingOptions = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection) // ✅ Same connection
+ .Options;
+ var groupingContext = new TestCasbinDbContext2(groupingOptions, TransactionIntegrityTestFixture.GroupingsSchema, "casbin_rule");
+
+ var roleOptions = new DbContextOptionsBuilder>()
+ .UseNpgsql(sharedConnection) // ✅ Same connection
+ .Options;
+ var roleContext = new TestCasbinDbContext3(roleOptions, TransactionIntegrityTestFixture.RolesSchema, "casbin_rule");
+
+ _output.WriteLine("Created three contexts sharing the same connection");
+
+ // Verify reference equality
+ var conn1 = policyContext.Database.GetDbConnection();
+ var conn2 = groupingContext.Database.GetDbConnection();
+ var conn3 = roleContext.Database.GetDbConnection();
+
+ Assert.True(ReferenceEquals(conn1, conn2), "Connections 1 and 2 should be the SAME object");
+ Assert.True(ReferenceEquals(conn2, conn3), "Connections 2 and 3 should be the SAME object");
+ _output.WriteLine("Verified: All contexts share the SAME DbConnection object (reference equality)");
+
+ // Create provider and adapter
+ // Pass sharedConnection since all contexts share it
+ var provider = new ThreeWayContextProvider(policyContext, groupingContext, roleContext, sharedConnection);
+ var adapter = new EFCoreAdapter(provider);
+
+ // Create enforcer without loading policy (tables might be empty)
+ var model = DefaultModel.CreateFromFile(ModelPath);
+ var enforcer = new Enforcer(model);
+ enforcer.SetAdapter(adapter);
+
+ // Add policies to in-memory model (not persisted yet)
+ enforcer.AddPolicy("bob", "data2", "write"); // → casbin_policies
+ enforcer.AddGroupingPolicy("bob", "developer"); // → casbin_groupings
+ enforcer.AddNamedGroupingPolicy("g2", "developer", "role-contributor"); // → casbin_roles
+
+ _output.WriteLine("Added policies to in-memory model:");
+ _output.WriteLine(" p policy → should go to casbin_policies");
+ _output.WriteLine(" g policy → should go to casbin_groupings");
+ _output.WriteLine(" g2 policy → should go to casbin_roles");
+
+ // Save to database
+ await enforcer.SavePolicyAsync();
+ _output.WriteLine("Called SavePolicyAsync()");
+
+ // Verify distribution across schemas
+ var policiesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.PoliciesSchema);
+ var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.GroupingsSchema);
+ var rolesCount = await _fixture.CountPoliciesInSchemaAsync(TransactionIntegrityTestFixture.RolesSchema);
+
+ _output.WriteLine($"Schema distribution:");
+ _output.WriteLine($" casbin_policies: {policiesCount} policy");
+ _output.WriteLine($" casbin_groupings: {groupingsCount} policy");
+ _output.WriteLine($" casbin_roles: {rolesCount} policy");
+
+ // CRITICAL ASSERTION: Policies should be distributed across all three schemas
+ // If all policies end up in ONE schema, HasDefaultSchema() does NOT work with shared connections
+ // and we NEED the SET search_path approach
+
+ if (policiesCount == 1 && groupingsCount == 1 && rolesCount == 1)
+ {
+ _output.WriteLine("✓✓✓ SHARED CONNECTION TEST PASSED!");
+ _output.WriteLine("HasDefaultSchema() correctly distributes policies even with shared connection");
+ _output.WriteLine("CONCLUSION: SET search_path approach is NOT necessary");
+ }
+ else
+ {
+ _output.WriteLine("✗✗✗ SHARED CONNECTION TEST FAILED!");
+ _output.WriteLine($"Expected distribution: (1, 1, 1), Got: ({policiesCount}, {groupingsCount}, {rolesCount})");
+ _output.WriteLine("CONCLUSION: SET search_path approach IS necessary for shared connections");
+ }
+
+ Assert.Equal(1, policiesCount);
+ Assert.Equal(1, groupingsCount);
+ Assert.Equal(1, rolesCount);
+
+ await policyContext.DisposeAsync();
+ await groupingContext.DisposeAsync();
+ await roleContext.DisposeAsync();
+ }
+ finally
+ {
+ await sharedConnection.DisposeAsync();
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTestFixture.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTestFixture.cs
new file mode 100644
index 0000000..f1356d8
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTestFixture.cs
@@ -0,0 +1,227 @@
+using System;
+using System.Threading.Tasks;
+using Npgsql;
+using Xunit;
+
+namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration
+{
+ ///
+ /// Test fixture for transaction integrity tests using PostgreSQL.
+ /// Creates three separate schemas to simulate multi-context scenarios.
+ ///
+ /// Prerequisites:
+ /// - PostgreSQL running on localhost:5432
+ /// - Database "casbin_integration_test" must exist
+ /// - Default credentials: postgres/postgres4all! (or update ConnectionString)
+ ///
+ public class TransactionIntegrityTestFixture : IAsyncLifetime
+ {
+ // Schema names for three-way context split
+ public const string PoliciesSchema = "casbin_policies";
+ public const string GroupingsSchema = "casbin_groupings";
+ public const string RolesSchema = "casbin_roles";
+
+ // Connection string to local PostgreSQL
+ public string ConnectionString { get; private set; }
+
+ public TransactionIntegrityTestFixture()
+ {
+ // Use local PostgreSQL for integration tests
+ // Database must exist before running tests
+ ConnectionString = "Host=localhost;Database=casbin_integration_test;Username=postgres;Password=postgres4all!";
+ }
+
+ public async Task InitializeAsync()
+ {
+ try
+ {
+ // Create schemas
+ await CreateSchemasAsync();
+
+ // Run migrations for all three schemas
+ await RunMigrationsAsync();
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ $"Failed to initialize TransactionIntegrityTestFixture. " +
+ $"Ensure PostgreSQL is running and database 'casbin_integration_test' exists. " +
+ $"Connection string: {ConnectionString}", ex);
+ }
+ }
+
+ public async Task DisposeAsync()
+ {
+ // Clean up test schemas
+ // TEMPORARILY DISABLED: Comment out to leave tables for inspection
+ // await DropSchemasAsync();
+ await Task.CompletedTask;
+ }
+
+ private async Task CreateSchemasAsync()
+ {
+ await using var connection = new NpgsqlConnection(ConnectionString);
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+
+ // Create policies schema
+ cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS {PoliciesSchema}";
+ await cmd.ExecuteNonQueryAsync();
+
+ // Create groupings schema
+ cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS {GroupingsSchema}";
+ await cmd.ExecuteNonQueryAsync();
+
+ // Create roles schema
+ cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS {RolesSchema}";
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ ///
+ /// Runs migrations for all schemas. Public so tests can restore tables after dropping them.
+ ///
+ public async Task RunMigrationsAsync()
+ {
+ await using var connection = new NpgsqlConnection(ConnectionString);
+ await connection.OpenAsync();
+
+ foreach (var schemaName in new[] { PoliciesSchema, GroupingsSchema, RolesSchema })
+ {
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $@"
+ CREATE TABLE IF NOT EXISTS {schemaName}.casbin_rule (
+ id SERIAL PRIMARY KEY,
+ ptype VARCHAR(254) NOT NULL,
+ v0 VARCHAR(254),
+ v1 VARCHAR(254),
+ v2 VARCHAR(254),
+ v3 VARCHAR(254),
+ v4 VARCHAR(254),
+ v5 VARCHAR(254),
+ v6 VARCHAR(254),
+ v7 VARCHAR(254),
+ v8 VARCHAR(254),
+ v9 VARCHAR(254),
+ v10 VARCHAR(254),
+ v11 VARCHAR(254),
+ v12 VARCHAR(254),
+ v13 VARCHAR(254)
+ );
+ CREATE INDEX IF NOT EXISTS ix_casbin_rule_ptype ON {schemaName}.casbin_rule (ptype);
+ ";
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+
+ private async Task DropSchemasAsync()
+ {
+ await using var connection = new NpgsqlConnection(ConnectionString);
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+
+ // Drop tables first, then schemas
+ foreach (var schema in new[] { PoliciesSchema, GroupingsSchema, RolesSchema })
+ {
+ cmd.CommandText = $"DROP TABLE IF EXISTS {schema}.casbin_rule CASCADE";
+ await cmd.ExecuteNonQueryAsync();
+
+ cmd.CommandText = $"DROP SCHEMA IF EXISTS {schema} CASCADE";
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+
+ ///
+ /// Clears all policies from all schemas. Call before each test.
+ ///
+ public async Task ClearAllPoliciesAsync()
+ {
+ await using var connection = new NpgsqlConnection(ConnectionString);
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+
+ foreach (var schema in new[] { PoliciesSchema, GroupingsSchema, RolesSchema })
+ {
+ cmd.CommandText = $"DELETE FROM {schema}.casbin_rule";
+ try
+ {
+ await cmd.ExecuteNonQueryAsync();
+ }
+ catch (NpgsqlException)
+ {
+ // Table might not exist yet, ignore
+ }
+ }
+ }
+
+ ///
+ /// Counts policies of a specific type in a schema
+ ///
+ public async Task CountPoliciesInSchemaAsync(string schemaName, string policyType = null)
+ {
+ await using var connection = new NpgsqlConnection(ConnectionString);
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ if (policyType == null)
+ {
+ cmd.CommandText = $"SELECT COUNT(*) FROM {schemaName}.casbin_rule";
+ }
+ else
+ {
+ cmd.CommandText = $"SELECT COUNT(*) FROM {schemaName}.casbin_rule WHERE ptype = @ptype";
+ cmd.Parameters.AddWithValue("@ptype", policyType);
+ }
+
+ try
+ {
+ var result = await cmd.ExecuteScalarAsync();
+ return Convert.ToInt32(result);
+ }
+ catch (NpgsqlException)
+ {
+ // Table might not exist
+ return 0;
+ }
+ }
+
+ ///
+ /// Inserts a policy directly into the database (for conflict simulation)
+ ///
+ public async Task InsertPolicyDirectlyAsync(string schemaName, string ptype, params string[] values)
+ {
+ await using var connection = new NpgsqlConnection(ConnectionString);
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $@"
+ INSERT INTO {schemaName}.casbin_rule
+ (ptype, v0, v1, v2, v3, v4, v5)
+ VALUES (@ptype, @v0, @v1, @v2, @v3, @v4, @v5)";
+
+ cmd.Parameters.AddWithValue("@ptype", ptype);
+ for (int i = 0; i < 6; i++)
+ {
+ var value = i < values.Length ? values[i] : (object)DBNull.Value;
+ cmd.Parameters.AddWithValue($"@v{i}", value);
+ }
+
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ ///
+ /// Drops a table in a schema (for failure simulation)
+ ///
+ public async Task DropTableAsync(string schemaName)
+ {
+ await using var connection = new NpgsqlConnection(ConnectionString);
+ await connection.OpenAsync();
+
+ await using var cmd = connection.CreateCommand();
+ cmd.CommandText = $"DROP TABLE IF EXISTS {schemaName}.casbin_rule CASCADE";
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+}
diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTests.cs b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTests.cs
new file mode 100644
index 0000000..a68c8ec
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/Integration/TransactionIntegrityTests.cs
@@ -0,0 +1,598 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Casbin;
+using Casbin.Persist.Adapter.EFCore.Entities;
+using Npgsql;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+#nullable enable
+
+namespace Casbin.Persist.Adapter.EFCore.UnitTest.Integration
+{
+ ///
+ /// Integration tests verifying transaction integrity guarantees for multi-context scenarios.
+ /// These tests prove that shared DbConnection objects enable atomic transactions across multiple contexts.
+ ///
+ /// IMPORTANT: These tests are excluded from CI/CD via the "Integration" trait.
+ /// Run locally with: dotnet test --filter "Category=Integration"
+ ///
+ [Trait("Category", "Integration")]
+ [Collection("IntegrationTests")]
+ public class TransactionIntegrityTests : IClassFixture, IAsyncLifetime
+ {
+ private readonly TransactionIntegrityTestFixture _fixture;
+ private const string ModelPath = "examples/multi_context_model.conf";
+
+ public TransactionIntegrityTests(TransactionIntegrityTestFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ public Task InitializeAsync() => _fixture.ClearAllPoliciesAsync();
+
+ public Task DisposeAsync() => _fixture.RunMigrationsAsync();
+
+ #region Helper: Derived Context Classes
+
+ ///
+ /// Derived context for policies schema
+ ///
+ public class TestCasbinDbContext1 : CasbinDbContext
+ {
+ public TestCasbinDbContext1(
+ DbContextOptions> options,
+ string schemaName,
+ string tableName)
+ : base(options, schemaName, tableName)
+ {
+ }
+ }
+
+ ///
+ /// Derived context for groupings schema
+ ///
+ public class TestCasbinDbContext2 : CasbinDbContext
+ {
+ public TestCasbinDbContext2(
+ DbContextOptions> options,
+ string schemaName,
+ string tableName)
+ : base(options, schemaName, tableName)
+ {
+ }
+ }
+
+ ///
+ /// Derived context for roles schema
+ ///
+ public class TestCasbinDbContext3 : CasbinDbContext
+ {
+ public TestCasbinDbContext3(
+ DbContextOptions> options,
+ string schemaName,
+ string tableName)
+ : base(options, schemaName, tableName)
+ {
+ }
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ ///
+ /// Creates a three-way context provider routing policies to different schemas:
+ /// - p, p2, p3... → policies schema
+ /// - g → groupings schema
+ /// - g2, g3, g4... → roles schema
+ ///
+ private class ThreeWayPolicyTypeProvider : ICasbinDbContextProvider
+ {
+ private readonly DbContext _policyContext;
+ private readonly DbContext _groupingContext;
+ private readonly DbContext _roleContext;
+ private readonly System.Data.Common.DbConnection? _sharedConnection;
+
+ public ThreeWayPolicyTypeProvider(
+ DbContext policyContext,
+ DbContext groupingContext,
+ DbContext roleContext,
+ System.Data.Common.DbConnection? sharedConnection)
+ {
+ _policyContext = policyContext ?? throw new ArgumentNullException(nameof(policyContext));
+ _groupingContext = groupingContext ?? throw new ArgumentNullException(nameof(groupingContext));
+ _roleContext = roleContext ?? throw new ArgumentNullException(nameof(roleContext));
+ _sharedConnection = sharedConnection;
+ }
+
+ public DbContext GetContextForPolicyType(string policyType)
+ {
+ if (string.IsNullOrEmpty(policyType))
+ return _policyContext;
+
+ // Route p policies to policy context
+ if (policyType.StartsWith("p", StringComparison.OrdinalIgnoreCase))
+ return _policyContext;
+
+ // Route g2+ to role context
+ if (policyType.StartsWith("g2", StringComparison.OrdinalIgnoreCase) ||
+ policyType.StartsWith("g3", StringComparison.OrdinalIgnoreCase) ||
+ policyType.StartsWith("g4", StringComparison.OrdinalIgnoreCase))
+ return _roleContext;
+
+ // Route g to grouping context
+ return _groupingContext;
+ }
+
+ public IEnumerable GetAllContexts()
+ {
+ return new[] { _policyContext, _groupingContext, _roleContext };
+ }
+
+ public System.Data.Common.DbConnection? GetSharedConnection()
+ {
+ return _sharedConnection;
+ }
+ }
+
+ ///
+ /// Creates an enforcer with three contexts sharing the same DbConnection
+ ///
+ private async Task<(Enforcer enforcer, NpgsqlConnection connection)> CreateEnforcerWithSharedConnectionAsync()
+ {
+ // Create ONE shared connection
+ var connection = new NpgsqlConnection(_fixture.ConnectionString);
+ await connection.OpenAsync();
+
+ // Create three contexts sharing the same connection
+ var policyContext = CreateContext(connection, TransactionIntegrityTestFixture.PoliciesSchema);
+ var groupingContext = CreateContext(connection, TransactionIntegrityTestFixture.GroupingsSchema);
+ var roleContext = CreateContext(connection, TransactionIntegrityTestFixture.RolesSchema);
+
+ // Create provider routing policy types to appropriate contexts
+ var provider = new ThreeWayPolicyTypeProvider(policyContext, groupingContext, roleContext, connection);
+
+ // Create adapter and enforcer
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(ModelPath, adapter);
+
+ await enforcer.LoadPolicyAsync();
+
+ return (enforcer, connection);
+ }
+
+ ///
+ /// Creates an enforcer with three contexts using SEPARATE DbConnections (same connection string)
+ /// This is used to demonstrate non-atomic behavior when connections are not shared.
+ ///
+ private async Task<(Enforcer enforcer,
+ NpgsqlConnection policyConnection,
+ NpgsqlConnection groupingConnection,
+ NpgsqlConnection roleConnection,
+ CasbinDbContext policyContext,
+ CasbinDbContext groupingContext,
+ CasbinDbContext roleContext)> CreateEnforcerWithSeparateConnectionsAsync()
+ {
+ // Create THREE separate connections with same connection string
+ var policyConnection = new NpgsqlConnection(_fixture.ConnectionString);
+ var groupingConnection = new NpgsqlConnection(_fixture.ConnectionString);
+ var roleConnection = new NpgsqlConnection(_fixture.ConnectionString);
+
+ await policyConnection.OpenAsync();
+ await groupingConnection.OpenAsync();
+ await roleConnection.OpenAsync();
+
+ // Create three contexts with different connection objects
+ var policyContext = CreateContext(policyConnection, TransactionIntegrityTestFixture.PoliciesSchema);
+ var groupingContext = CreateContext(groupingConnection, TransactionIntegrityTestFixture.GroupingsSchema);
+ var roleContext = CreateContext(roleConnection, TransactionIntegrityTestFixture.RolesSchema);
+
+ // Create provider routing policy types to appropriate contexts
+ // Pass null for shared connection since these contexts use separate connections
+ var provider = new ThreeWayPolicyTypeProvider(policyContext, groupingContext, roleContext, null);
+
+ // Create adapter and enforcer
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(ModelPath, adapter);
+
+ await enforcer.LoadPolicyAsync();
+
+ return (enforcer, policyConnection, groupingConnection, roleConnection,
+ policyContext, groupingContext, roleContext);
+ }
+
+ private CasbinDbContext CreateContext(NpgsqlConnection connection, string schemaName)
+ {
+ var options = new DbContextOptionsBuilder>()
+ .UseNpgsql(connection, b => b.MigrationsHistoryTable("__EFMigrationsHistory", schemaName))
+ .Options;
+
+ // Return appropriate derived context based on schema name
+ if (schemaName == TransactionIntegrityTestFixture.PoliciesSchema)
+ return new TestCasbinDbContext1(options, schemaName, "casbin_rule");
+ else if (schemaName == TransactionIntegrityTestFixture.GroupingsSchema)
+ return new TestCasbinDbContext2(options, schemaName, "casbin_rule");
+ else if (schemaName == TransactionIntegrityTestFixture.RolesSchema)
+ return new TestCasbinDbContext3(options, schemaName, "casbin_rule");
+ else
+ throw new ArgumentException($"Unknown schema name: {schemaName}", nameof(schemaName));
+ }
+
+ #endregion
+
+ #region Test 1: Atomicity - Happy Path
+
+ [Fact]
+ public async Task SavePolicy_WithSharedConnection_ShouldWriteToAllContextsAtomically()
+ {
+ // Arrange
+ var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync();
+
+ try
+ {
+ // Add policies that will route to different contexts
+ await enforcer.AddPolicyAsync("alice", "data1", "read"); // → policies schema (p)
+ await enforcer.AddGroupingPolicyAsync("alice", "admin"); // → groupings schema (g)
+ await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); // → roles schema (g2)
+
+ // Act
+ await enforcer.SavePolicyAsync();
+
+ // Assert - Verify each schema has exactly the expected policies
+ var policiesCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.PoliciesSchema, "p");
+ var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.GroupingsSchema, "g");
+ var rolesCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.RolesSchema, "g2");
+
+ Assert.Equal(1, policiesCount);
+ Assert.Equal(1, groupingsCount);
+ Assert.Equal(1, rolesCount);
+ }
+ finally
+ {
+ await connection.CloseAsync();
+ await connection.DisposeAsync();
+ }
+ }
+
+ #endregion
+
+ #region Test 2: Connection Sharing Verification
+
+ [Fact]
+ public async Task MultiContextSetup_WithSharedConnection_ShouldShareSamePhysicalConnection()
+ {
+ // Arrange
+ var connection = new NpgsqlConnection(_fixture.ConnectionString);
+ await connection.OpenAsync();
+
+ try
+ {
+ var policyContext = CreateContext(connection, TransactionIntegrityTestFixture.PoliciesSchema);
+ var groupingContext = CreateContext(connection, TransactionIntegrityTestFixture.GroupingsSchema);
+ var roleContext = CreateContext(connection, TransactionIntegrityTestFixture.RolesSchema);
+
+ // Act & Assert - Verify reference equality (not just connection string equality)
+ var policyConn = policyContext.Database.GetDbConnection();
+ var groupingConn = groupingContext.Database.GetDbConnection();
+ var roleConn = roleContext.Database.GetDbConnection();
+
+ Assert.Same(connection, policyConn);
+ Assert.Same(connection, groupingConn);
+ Assert.Same(connection, roleConn);
+ Assert.Same(policyConn, groupingConn);
+ Assert.Same(groupingConn, roleConn);
+ }
+ finally
+ {
+ await connection.CloseAsync();
+ await connection.DisposeAsync();
+ }
+ }
+
+ #endregion
+
+ #region Test 3: Rollback - Missing Table (CRITICAL TEST)
+
+ [Fact]
+ public async Task SavePolicy_WhenTableDroppedInOneContext_ShouldRollbackAllContexts()
+ {
+ // Arrange
+ var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync();
+
+ try
+ {
+ // Disable AutoSave so policies stay in-memory until SavePolicyAsync() is called
+ enforcer.EnableAutoSave(false);
+
+ // Add policies to all contexts (in memory only, AutoSave is OFF)
+ await enforcer.AddPolicyAsync("alice", "data1", "read"); // → policies schema
+ await enforcer.AddGroupingPolicyAsync("alice", "admin"); // → groupings schema
+ await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser"); // → roles schema
+
+ // Drop table in roles schema AFTER policies are in memory but BEFORE SavePolicy
+ // This simulates a catastrophic failure scenario where database schema is inconsistent
+ await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema);
+
+ Exception? caughtException = null;
+
+ // Act - Try to save, should throw due to missing table in roles schema
+ try
+ {
+ await enforcer.SavePolicyAsync();
+ }
+ catch (Exception ex)
+ {
+ caughtException = ex;
+ }
+
+ // Assert - Verify exception was thrown
+ Assert.NotNull(caughtException);
+
+ // Recreate table for verification queries
+ await _fixture.RunMigrationsAsync();
+
+ // CRITICAL ASSERTION - Verify ZERO policies in all contexts (rollback successful)
+ // This proves that when one context fails, ALL contexts roll back atomically
+ var policiesCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.PoliciesSchema, "p");
+ var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.GroupingsSchema, "g");
+ var rolesCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.RolesSchema, "g2");
+
+ // Verify atomicity: All contexts rolled back (no partial commits)
+ Assert.Equal(0, policiesCount); // Should be 0 (rolled back)
+ Assert.Equal(0, groupingsCount); // Should be 0 (rolled back)
+ Assert.Equal(0, rolesCount); // Should be 0 (rolled back)
+
+ // If we got here, atomicity is PROVEN!
+ }
+ finally
+ {
+ await connection.CloseAsync();
+ await connection.DisposeAsync();
+
+ // Restore table for subsequent tests
+ await _fixture.RunMigrationsAsync();
+ }
+ }
+
+ #endregion
+
+ #region Test 4: Rollback - Missing Table
+
+ [Fact]
+ public async Task SavePolicy_WhenTableMissingInOneContext_ShouldRollbackAllContexts()
+ {
+ // Arrange
+ var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync();
+
+ try
+ {
+ // Disable AutoSave so policies stay in-memory until SavePolicyAsync() is called
+ enforcer.EnableAutoSave(false);
+
+ // Add policies to all contexts
+ await enforcer.AddPolicyAsync("alice", "data1", "read");
+ await enforcer.AddGroupingPolicyAsync("alice", "admin");
+ await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser");
+
+ // Drop table in roles schema AFTER enforcer is created
+ await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema);
+
+ Exception? caughtException = null;
+
+ // Act - Try to save, should throw due to missing table
+ try
+ {
+ await enforcer.SavePolicyAsync();
+ }
+ catch (Exception ex)
+ {
+ caughtException = ex;
+ }
+
+ // Assert - Verify exception was thrown
+ Assert.NotNull(caughtException);
+
+ // Recreate table for verification queries
+ await _fixture.RunMigrationsAsync();
+
+ // CRITICAL ASSERTION - Verify ZERO policies in all contexts (rollback successful)
+ var policiesCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.PoliciesSchema, "p");
+ var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.GroupingsSchema, "g");
+ var rolesCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.RolesSchema, "g2");
+
+ Assert.Equal(0, policiesCount);
+ Assert.Equal(0, groupingsCount);
+ Assert.Equal(0, rolesCount);
+ }
+ finally
+ {
+ await connection.CloseAsync();
+ await connection.DisposeAsync();
+
+ // Restore table for subsequent tests
+ await _fixture.RunMigrationsAsync();
+ }
+ }
+
+ #endregion
+
+ #region Test 5: Consistency Verification
+
+ [Fact]
+ public async Task MultipleSaveOperations_WithSharedConnection_ShouldMaintainDataConsistency()
+ {
+ // Arrange
+ var (enforcer, connection) = await CreateEnforcerWithSharedConnectionAsync();
+
+ try
+ {
+ // Act - Perform multiple incremental saves
+ // Save 1
+ await enforcer.AddPolicyAsync("alice", "data1", "read");
+ await enforcer.AddGroupingPolicyAsync("alice", "admin");
+ await enforcer.SavePolicyAsync();
+
+ // Save 2
+ await enforcer.AddPolicyAsync("bob", "data2", "write");
+ await enforcer.AddGroupingPolicyAsync("bob", "user");
+ await enforcer.SavePolicyAsync();
+
+ // Save 3
+ await enforcer.AddPolicyAsync("charlie", "data3", "read");
+ await enforcer.AddGroupingPolicyAsync("charlie", "user");
+ await enforcer.SavePolicyAsync();
+
+ // Assert - Verify all 6 policies present (3 p policies, 3 g policies)
+ var policiesCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.PoliciesSchema, "p");
+ var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.GroupingsSchema, "g");
+
+ Assert.Equal(3, policiesCount);
+ Assert.Equal(3, groupingsCount);
+
+ // Verify all policies are enforced correctly
+ Assert.True(await enforcer.EnforceAsync("alice", "data1", "read"));
+ Assert.True(await enforcer.EnforceAsync("bob", "data2", "write"));
+ Assert.True(await enforcer.EnforceAsync("charlie", "data3", "read"));
+ }
+ finally
+ {
+ await connection.CloseAsync();
+ await connection.DisposeAsync();
+ }
+ }
+
+ #endregion
+
+ #region Test 6: Non-Atomic Behavior Without Shared Connection
+
+ [Fact]
+ public async Task SavePolicy_WithSeparateConnections_ShouldNotBeAtomic()
+ {
+ // Arrange - Create enforcer with SEPARATE connection objects
+ var (enforcer, policyConnection, groupingConnection, roleConnection,
+ policyContext, groupingContext, roleContext) = await CreateEnforcerWithSeparateConnectionsAsync();
+
+ try
+ {
+ // Add policies to all contexts
+ await enforcer.AddPolicyAsync("alice", "data1", "read");
+ await enforcer.AddGroupingPolicyAsync("alice", "admin");
+ await enforcer.AddNamedGroupingPolicyAsync("g2", "admin", "superuser");
+
+ // Drop table in roles schema to force failure
+ await _fixture.DropTableAsync(TransactionIntegrityTestFixture.RolesSchema);
+
+ Exception? caughtException = null;
+
+ // Act - Try to save, should throw due to missing table in roles schema
+ try
+ {
+ await enforcer.SavePolicyAsync();
+ }
+ catch (Exception ex)
+ {
+ caughtException = ex;
+ }
+
+ // Assert - Verify exception was thrown
+ Assert.NotNull(caughtException);
+
+ // Recreate table for verification queries
+ await _fixture.RunMigrationsAsync();
+
+ // CRITICAL ASSERTION - Verify policies WERE written to functioning contexts (NOT atomic!)
+ var policiesCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.PoliciesSchema, "p");
+ var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.GroupingsSchema, "g");
+ var rolesCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.RolesSchema, "g2");
+
+ // This test DOCUMENTS the non-atomic behavior without shared connections
+ // Policies and groupings were committed despite roles context failure
+ Assert.Equal(1, policiesCount); // Written (NOT rolled back)
+ Assert.Equal(1, groupingsCount); // Written (NOT rolled back)
+ Assert.Equal(0, rolesCount); // Failed to write (table dropped)
+
+ // This proves that connection string matching alone is INSUFFICIENT for atomicity
+ // Must use shared DbConnection OBJECT for atomic transactions
+ }
+ finally
+ {
+ await policyContext.DisposeAsync();
+ await groupingContext.DisposeAsync();
+ await roleContext.DisposeAsync();
+ await policyConnection.DisposeAsync();
+ await groupingConnection.DisposeAsync();
+ await roleConnection.DisposeAsync();
+ }
+ }
+
+ #endregion
+
+ #region Test 7: Casbin In-Memory vs Database State
+
+ [Fact]
+ public async Task SavePolicy_ShouldReflectDatabaseStateNotCasbinMemory()
+ {
+ // Arrange - Create first enforcer and save policies
+ var (enforcer1, connection1) = await CreateEnforcerWithSharedConnectionAsync();
+
+ try
+ {
+ await enforcer1.AddPolicyAsync("alice", "data1", "read");
+ await enforcer1.AddGroupingPolicyAsync("alice", "admin");
+ await enforcer1.SavePolicyAsync();
+ }
+ finally
+ {
+ await connection1.CloseAsync();
+ await connection1.DisposeAsync();
+ }
+
+ // Create second enforcer - loads existing policies from database
+ var (enforcer2, connection2) = await CreateEnforcerWithSharedConnectionAsync();
+
+ try
+ {
+ // Act - Try to add same policies again
+ var addedPolicy = await enforcer2.AddPolicyAsync("alice", "data1", "read");
+ var addedGrouping = await enforcer2.AddGroupingPolicyAsync("alice", "admin");
+
+ // Assert - Casbin's in-memory check should prevent duplicates
+ Assert.False(addedPolicy);
+ Assert.False(addedGrouping);
+
+ // Verify database unchanged (validates tests check database, not just Casbin memory)
+ var policiesCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.PoliciesSchema, "p");
+ var groupingsCount = await _fixture.CountPoliciesInSchemaAsync(
+ TransactionIntegrityTestFixture.GroupingsSchema, "g");
+
+ Assert.Equal(1, policiesCount);
+ Assert.Equal(1, groupingsCount);
+ }
+ finally
+ {
+ await connection2.CloseAsync();
+ await connection2.DisposeAsync();
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/examples/multi_context_model.conf b/Casbin.Persist.Adapter.EFCore.IntegrationTest/examples/multi_context_model.conf
new file mode 100644
index 0000000..4e8b46d
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/examples/multi_context_model.conf
@@ -0,0 +1,15 @@
+[request_definition]
+r = sub, obj, act
+
+[policy_definition]
+p = sub, obj, act
+
+[role_definition]
+g = _, _
+g2 = _, _
+
+[policy_effect]
+e = some(where (p.eft == allow))
+
+[matchers]
+m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
diff --git a/Casbin.Persist.Adapter.EFCore.IntegrationTest/xunit.runner.json b/Casbin.Persist.Adapter.EFCore.IntegrationTest/xunit.runner.json
new file mode 100644
index 0000000..6b2d9df
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.IntegrationTest/xunit.runner.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
+ "parallelizeTestCollections": true,
+ "maxParallelThreads": -1
+}
diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/AutoTest.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/AutoTest.cs
index a0bb55a..7eb1196 100644
--- a/Casbin.Persist.Adapter.EFCore.UnitTest/AutoTest.cs
+++ b/Casbin.Persist.Adapter.EFCore.UnitTest/AutoTest.cs
@@ -9,307 +9,17 @@
namespace Casbin.Persist.Adapter.EFCore.UnitTest
{
- public class AdapterTest : TestUtil, IClassFixture, IClassFixture
+ public class EFCoreAdapterTest : TestUtil, IClassFixture, IClassFixture
{
private readonly ModelProvideFixture _modelProvideFixture;
private readonly DbContextProviderFixture _dbContextProviderFixture;
- public AdapterTest(ModelProvideFixture modelProvideFixture, DbContextProviderFixture dbContextProviderFixture)
+ public EFCoreAdapterTest(ModelProvideFixture modelProvideFixture, DbContextProviderFixture dbContextProviderFixture)
{
_modelProvideFixture = modelProvideFixture;
_dbContextProviderFixture = dbContextProviderFixture;
}
- [Fact]
- public void TestAdapterAutoSave()
- {
- using var context = _dbContextProviderFixture.GetContext("AutoSave");
- InitPolicy(context);
- var adapter = new EFCoreAdapter(context);
- var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
-
- #region Load policy test
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write"),
- AsList("data2_admin", "data2", "read"),
- AsList("data2_admin", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 5);
- #endregion
-
- #region Add policy test
- enforcer.AddPolicy("alice", "data1", "write");
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write"),
- AsList("data2_admin", "data2", "read"),
- AsList("data2_admin", "data2", "write"),
- AsList("alice", "data1", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 6);
- #endregion
-
- #region Remove poliy test
- enforcer.RemovePolicy("alice", "data1", "write");
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write"),
- AsList("data2_admin", "data2", "read"),
- AsList("data2_admin", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 5);
-
- enforcer.RemoveFilteredPolicy(0, "data2_admin");
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
- #endregion
-
- #region Update policy test
- enforcer.UpdatePolicy(AsList("alice", "data1", "read"),
- AsList("alice", "data2", "write"));
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data2", "write"),
- AsList("bob", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
-
- enforcer.UpdatePolicy(AsList("alice", "data2", "write"),
- AsList("alice", "data1", "read"));
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
- #endregion
-
- #region Batch APIs test
- enforcer.AddPolicies(new []
- {
- new List{"alice", "data2", "write"},
- new List{"bob", "data1", "read"}
- });
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write"),
- AsList("alice", "data2", "write"),
- AsList("bob", "data1", "read")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 5);
-
- enforcer.RemovePolicies(new []
- {
- new List{"alice", "data1", "read"},
- new List{"bob", "data2", "write"}
- });
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data2", "write"),
- AsList("bob", "data1", "read")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
-
- enforcer.UpdatePolicies(AsList(
- AsList("alice", "data2", "write"),
- AsList("bob", "data1", "read")
- ), AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write")
- ));
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
-
- enforcer.UpdatePolicies(AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write")
- ), AsList(
- AsList("alice", "data2", "write"),
- AsList("bob", "data1", "read")
- ));
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data2", "write"),
- AsList("bob", "data1", "read")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
- #endregion
-
- #region IFilteredAdapter test
- enforcer.LoadFilteredPolicy(new Filter
- {
- P = new List{"bob", "data1", "read"},
- });
- TestGetPolicy(enforcer, AsList(
- AsList("bob", "data1", "read")
- ));
- Assert.True(enforcer.GetGroupingPolicy().Count() is 0);
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
-
- enforcer.LoadFilteredPolicy(new Filter
- {
- P = new List{"", "data2", ""},
- G = new List{"", "data2_admin"},
- });
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data2", "write")
- ));
- TestGetGroupingPolicy(enforcer, AsList(
- AsList("alice", "data2_admin")
- ));
- Assert.True(enforcer.GetGroupingPolicy().Count() is 1);
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
- #endregion
- }
-
- [Fact]
- public async Task TestAdapterAutoSaveAsync()
- {
- await using var context = _dbContextProviderFixture.GetContext("AutoSaveAsync");
- InitPolicy(context);
- var adapter = new EFCoreAdapter(context);
- var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
-
- #region Load policy test
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write"),
- AsList("data2_admin", "data2", "read"),
- AsList("data2_admin", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 5);
- #endregion
-
- #region Add policy test
- await enforcer.AddPolicyAsync("alice", "data1", "write");
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write"),
- AsList("data2_admin", "data2", "read"),
- AsList("data2_admin", "data2", "write"),
- AsList("alice", "data1", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 6);
- #endregion
-
- #region Remove policy test
- await enforcer.RemovePolicyAsync("alice", "data1", "write");
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write"),
- AsList("data2_admin", "data2", "read"),
- AsList("data2_admin", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 5);
-
- await enforcer.RemoveFilteredPolicyAsync(0, "data2_admin");
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
- #endregion
-
- #region Update policy test
- await enforcer.UpdatePolicyAsync(AsList("alice", "data1", "read"),
- AsList("alice", "data2", "write"));
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data2", "write"),
- AsList("bob", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
-
- await enforcer.UpdatePolicyAsync(AsList("alice", "data2", "write"),
- AsList("alice", "data1", "read"));
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
- #endregion
-
- #region Batch APIs test
- await enforcer.AddPoliciesAsync(new []
- {
- new List{"alice", "data2", "write"},
- new List{"bob", "data1", "read"}
- });
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write"),
- AsList("alice", "data2", "write"),
- AsList("bob", "data1", "read")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 5);
-
- await enforcer.RemovePoliciesAsync(new []
- {
- new List{"alice", "data1", "read"},
- new List{"bob", "data2", "write"}
- });
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data2", "write"),
- AsList("bob", "data1", "read")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
-
- await enforcer.UpdatePoliciesAsync(AsList(
- AsList("alice", "data2", "write"),
- AsList("bob", "data1", "read")
- ), AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write")
- ));
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
-
- await enforcer.UpdatePoliciesAsync(AsList(
- AsList("alice", "data1", "read"),
- AsList("bob", "data2", "write")
- ), AsList(
- AsList("alice", "data2", "write"),
- AsList("bob", "data1", "read")
- ));
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data2", "write"),
- AsList("bob", "data1", "read")
- ));
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
- #endregion
-
- #region IFilteredAdapter test
- await enforcer.LoadFilteredPolicyAsync(new Filter
- {
- P = new List{"bob", "data1", "read"},
- });
- TestGetPolicy(enforcer, AsList(
- AsList("bob", "data1", "read")
- ));
- Assert.True(enforcer.GetGroupingPolicy().Count() is 0);
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
-
- await enforcer.LoadFilteredPolicyAsync(new Filter
- {
- P = new List{"", "data2", ""},
- G = new List{"", "data2_admin"},
- });
- TestGetPolicy(enforcer, AsList(
- AsList("alice", "data2", "write")
- ));
- TestGetGroupingPolicy(enforcer, AsList(
- AsList("alice", "data2_admin")
- ));
- Assert.True(enforcer.GetGroupingPolicy().Count() is 1);
- Assert.True(context.Policies.AsNoTracking().Count() is 3);
- #endregion
- }
-
private static void InitPolicy(CasbinDbContext context)
{
context.Clear();
diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/BackwardCompatibilityTest.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/BackwardCompatibilityTest.cs
new file mode 100644
index 0000000..702958f
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.UnitTest/BackwardCompatibilityTest.cs
@@ -0,0 +1,290 @@
+using System.Linq;
+using System.Threading.Tasks;
+using Casbin.Model;
+using Casbin.Persist.Adapter.EFCore.Entities;
+using Casbin.Persist.Adapter.EFCore.UnitTest.Extensions;
+using Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace Casbin.Persist.Adapter.EFCore.UnitTest
+{
+ ///
+ /// Tests to ensure backward compatibility with existing single-context behavior.
+ /// These tests verify that the multi-context changes don't break existing usage patterns.
+ ///
+ public class BackwardCompatibilityTest : TestUtil,
+ IClassFixture,
+ IClassFixture
+ {
+ private readonly ModelProvideFixture _modelProvideFixture;
+ private readonly DbContextProviderFixture _dbContextProviderFixture;
+
+ public BackwardCompatibilityTest(
+ ModelProvideFixture modelProvideFixture,
+ DbContextProviderFixture dbContextProviderFixture)
+ {
+ _modelProvideFixture = modelProvideFixture;
+ _dbContextProviderFixture = dbContextProviderFixture;
+ }
+
+ [Fact]
+ public void TestSingleContextConstructorStillWorks()
+ {
+ // Arrange - Using original constructor pattern
+ using var context = _dbContextProviderFixture.GetContext("SingleContextConstructor");
+ context.Clear();
+
+ // Act - Create adapter using single-context constructor (original API)
+ var adapter = new EFCoreAdapter(context);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Add policies
+ enforcer.AddPolicy("alice", "data1", "read");
+ enforcer.AddGroupingPolicy("alice", "admin");
+
+ // Assert - All policies should be in single context
+ Assert.Equal(2, context.Policies.Count());
+
+ var policies = context.Policies.ToList();
+ Assert.Contains(policies, p => p.Type == "p" && p.Value1 == "alice");
+ Assert.Contains(policies, p => p.Type == "g" && p.Value1 == "alice");
+ }
+
+ [Fact]
+ public async Task TestSingleContextAsyncOperationsStillWork()
+ {
+ // Arrange
+ await using var context = _dbContextProviderFixture.GetContext("SingleContextAsync");
+ context.Clear();
+
+ var adapter = new EFCoreAdapter(context);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act
+ await enforcer.AddPolicyAsync("alice", "data1", "read");
+ await enforcer.AddGroupingPolicyAsync("alice", "admin");
+
+ // Assert
+ Assert.Equal(2, await context.Policies.CountAsync());
+ }
+
+ [Fact]
+ public void TestSingleContextLoadAndSave()
+ {
+ // Arrange
+ using var context = _dbContextProviderFixture.GetContext("SingleContextLoadSave");
+ context.Clear();
+
+ var adapter = new EFCoreAdapter(context);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act - Add and save
+ enforcer.AddPolicy("alice", "data1", "read");
+ enforcer.AddGroupingPolicy("alice", "admin");
+ enforcer.SavePolicy();
+
+ // Create new enforcer and load
+ var newEnforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+ newEnforcer.LoadPolicy();
+
+ // Assert
+ TestGetPolicy(newEnforcer, AsList(
+ AsList("alice", "data1", "read")
+ ));
+
+ TestGetGroupingPolicy(newEnforcer, AsList(
+ AsList("alice", "admin")
+ ));
+ }
+
+ [Fact]
+ public void TestSingleContextWithExistingTests()
+ {
+ // This test mimics the pattern from EFCoreAdapterTest.cs to ensure compatibility
+ using var context = _dbContextProviderFixture.GetContext("ExistingPattern");
+ context.Clear();
+
+ // Initialize with data (like InitPolicy in EFCoreAdapterTest.cs)
+ context.Policies.AddRange(new[]
+ {
+ new EFCorePersistPolicy { Type = "p", Value1 = "alice", Value2 = "data1", Value3 = "read" },
+ new EFCorePersistPolicy { Type = "p", Value1 = "bob", Value2 = "data2", Value3 = "write" },
+ new EFCorePersistPolicy { Type = "g", Value1 = "alice", Value2 = "data2_admin" }
+ });
+ context.SaveChanges();
+
+ var adapter = new EFCoreAdapter(context);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act - Load policy
+ enforcer.LoadPolicy();
+
+ // Assert - Should match expected behavior
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write")
+ ));
+
+ TestGetGroupingPolicy(enforcer, AsList(
+ AsList("alice", "data2_admin")
+ ));
+ }
+
+ [Fact]
+ public void TestSingleContextRemoveOperations()
+ {
+ // Arrange
+ using var context = _dbContextProviderFixture.GetContext("SingleContextRemove");
+ context.Clear();
+
+ var adapter = new EFCoreAdapter(context);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ enforcer.AddPolicy("alice", "data1", "read");
+ enforcer.AddPolicy("bob", "data2", "write");
+
+ // Act
+ enforcer.RemovePolicy("alice", "data1", "read");
+
+ // Assert
+ Assert.Single(context.Policies);
+ var remaining = context.Policies.First();
+ Assert.Equal("bob", remaining.Value1);
+ }
+
+ [Fact]
+ public void TestSingleContextUpdateOperations()
+ {
+ // Arrange
+ using var context = _dbContextProviderFixture.GetContext("SingleContextUpdate");
+ context.Clear();
+
+ var adapter = new EFCoreAdapter(context);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ enforcer.AddPolicy("alice", "data1", "read");
+
+ // Act
+ enforcer.UpdatePolicy(
+ AsList("alice", "data1", "read"),
+ AsList("alice", "data1", "write")
+ );
+
+ // Assert
+ Assert.Single(context.Policies);
+ var policy = context.Policies.First();
+ Assert.Equal("write", policy.Value3);
+ }
+
+ [Fact]
+ public void TestSingleContextBatchOperations()
+ {
+ // Arrange
+ using var context = _dbContextProviderFixture.GetContext("SingleContextBatch");
+ context.Clear();
+
+ var adapter = new EFCoreAdapter(context);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act - Add multiple
+ enforcer.AddPolicies(new[]
+ {
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write"),
+ AsList("charlie", "data3", "read")
+ });
+
+ // Assert
+ Assert.Equal(3, context.Policies.Count());
+
+ // Act - Remove multiple
+ enforcer.RemovePolicies(new[]
+ {
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write")
+ });
+
+ // Assert
+ Assert.Single(context.Policies);
+ }
+
+ [Fact]
+ public void TestSingleContextFilteredLoading()
+ {
+ // Arrange
+ using var context = _dbContextProviderFixture.GetContext("SingleContextFiltered");
+ context.Clear();
+
+ context.Policies.AddRange(new[]
+ {
+ new EFCorePersistPolicy { Type = "p", Value1 = "alice", Value2 = "data1", Value3 = "read" },
+ new EFCorePersistPolicy { Type = "p", Value1 = "bob", Value2 = "data2", Value3 = "write" },
+ new EFCorePersistPolicy { Type = "g", Value1 = "alice", Value2 = "admin" }
+ });
+ context.SaveChanges();
+
+ var adapter = new EFCoreAdapter(context);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act - Load only alice's policies
+ enforcer.LoadFilteredPolicy(new SimpleFieldFilter("p", 0, Policy.ValuesFrom(AsList("alice", "", ""))));
+
+ // Assert
+ Assert.True(adapter.IsFiltered);
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data1", "read")
+ ));
+ }
+
+ [Fact]
+ public void TestSingleContextProviderWrapping()
+ {
+ // Arrange - Create adapter with explicit SingleContextProvider
+ using var context = _dbContextProviderFixture.GetContext("ProviderWrapping");
+ context.Clear();
+
+ var provider = new SingleContextProvider(context);
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act
+ enforcer.AddPolicy("alice", "data1", "read");
+
+ // Assert - Should behave identically to direct context constructor
+ Assert.Single(context.Policies);
+ Assert.Equal("alice", context.Policies.First().Value1);
+ }
+
+ [Fact]
+ public void TestSingleContextProviderGetAllContexts()
+ {
+ // Arrange
+ using var context = _dbContextProviderFixture.GetContext("ProviderGetAll");
+ var provider = new SingleContextProvider(context);
+
+ // Act
+ var contexts = provider.GetAllContexts().ToList();
+
+ // Assert
+ Assert.Single(contexts);
+ Assert.Same(context, contexts[0]);
+ }
+
+ [Fact]
+ public void TestSingleContextProviderGetContextForPolicyType()
+ {
+ // Arrange
+ using var context = _dbContextProviderFixture.GetContext("ProviderGetForType");
+ var provider = new SingleContextProvider(context);
+
+ // Act & Assert - All policy types should return same context
+ Assert.Same(context, provider.GetContextForPolicyType("p"));
+ Assert.Same(context, provider.GetContextForPolicyType("p2"));
+ Assert.Same(context, provider.GetContextForPolicyType("g"));
+ Assert.Same(context, provider.GetContextForPolicyType("g2"));
+ Assert.Same(context, provider.GetContextForPolicyType(null));
+ Assert.Same(context, provider.GetContextForPolicyType(""));
+ }
+ }
+}
diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj b/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj
index d482593..0c5dd09 100644
--- a/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj
+++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Casbin.Persist.Adapter.EFCore.UnitTest.csproj
@@ -4,9 +4,11 @@
net9.0;net8.0;net7.0;net6.0;net5.0;netcoreapp3.1;
false
11
+ $(NoWarn);NU1701
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -14,7 +16,7 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -23,32 +25,38 @@
+
-
+
+
+
+
+
+
@@ -62,6 +70,12 @@
Always
+
+ Always
+
+
+ PreserveNewest
+
diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Extensions/CasbinDbContextExtension.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Extensions/CasbinDbContextExtension.cs
index 59bbd0b..829d4f2 100644
--- a/Casbin.Persist.Adapter.EFCore.UnitTest/Extensions/CasbinDbContextExtension.cs
+++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Extensions/CasbinDbContextExtension.cs
@@ -1,4 +1,5 @@
using System;
+using System.Linq;
namespace Casbin.Persist.Adapter.EFCore.UnitTest.Extensions
{
@@ -6,8 +7,31 @@ public static class CasbinDbContextExtension
{
internal static void Clear(this CasbinDbContext dbContext) where TKey : IEquatable
{
- dbContext.RemoveRange(dbContext.Policies);
- dbContext.SaveChanges();
+ // Force model initialization before ensuring database exists
+ // This ensures EF Core knows about all entity configurations
+ _ = dbContext.Model;
+
+ // Ensure database and tables exist before attempting to clear
+ dbContext.Database.EnsureCreated();
+
+ // Try to access and clear policies
+ try
+ {
+ var policies = dbContext.Policies.ToList();
+ if (policies.Count > 0)
+ {
+ dbContext.RemoveRange(policies);
+ dbContext.SaveChanges();
+ }
+ }
+ catch (Microsoft.Data.Sqlite.SqliteException)
+ {
+ // If table still doesn't exist after EnsureCreated,
+ // force a second attempt with model refresh
+ dbContext.Database.EnsureDeleted();
+ _ = dbContext.Model;
+ dbContext.Database.EnsureCreated();
+ }
}
}
}
\ No newline at end of file
diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/MultiContextProviderFixture.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/MultiContextProviderFixture.cs
new file mode 100644
index 0000000..14036ae
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/MultiContextProviderFixture.cs
@@ -0,0 +1,84 @@
+using System;
+using Microsoft.EntityFrameworkCore;
+
+namespace Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures
+{
+ ///
+ /// Fixture for creating multi-context test scenarios with separate contexts for policies and groupings
+ ///
+ public class MultiContextProviderFixture : IDisposable
+ {
+ private bool _disposed;
+
+ ///
+ /// Creates a multi-context provider with separate contexts for policy and grouping rules.
+ /// Uses separate database files with the same table name for proper isolation.
+ /// This approach avoids SQLite transaction limitations across tables.
+ ///
+ /// Unique name for this test to avoid database conflicts
+ /// A PolicyTypeContextProvider configured for testing
+ public PolicyTypeContextProvider GetMultiContextProvider(string testName)
+ {
+ // Use separate database files for proper isolation
+ var policyDbName = $"MultiContext_{testName}_policy.db";
+ var groupingDbName = $"MultiContext_{testName}_grouping.db";
+
+ // Create policy context with its own database and default table name
+ var policyOptions = new DbContextOptionsBuilder>()
+ .UseSqlite($"Data Source={policyDbName}")
+ .Options;
+ var policyContext = new CasbinDbContext(policyOptions);
+ policyContext.Database.EnsureCreated();
+
+ // Create grouping context with its own database and default table name
+ var groupingOptions = new DbContextOptionsBuilder>()
+ .UseSqlite($"Data Source={groupingDbName}")
+ .Options;
+ var groupingContext = new CasbinDbContext(groupingOptions);
+ groupingContext.Database.EnsureCreated();
+
+ return new PolicyTypeContextProvider(policyContext, groupingContext);
+ }
+
+ ///
+ /// Gets separate contexts for direct verification in tests.
+ /// Returns NEW context instances pointing to the same databases as the provider.
+ ///
+ public (CasbinDbContext policyContext, CasbinDbContext groupingContext) GetSeparateContexts(string testName)
+ {
+ // Use same database file names as GetMultiContextProvider
+ var policyDbName = $"MultiContext_{testName}_policy.db";
+ var groupingDbName = $"MultiContext_{testName}_grouping.db";
+
+ // Create new context instances that point to the same database files
+ var policyOptions = new DbContextOptionsBuilder>()
+ .UseSqlite($"Data Source={policyDbName}")
+ .Options;
+ var policyContext = new CasbinDbContext(policyOptions);
+ policyContext.Database.EnsureCreated();
+
+ var groupingOptions = new DbContextOptionsBuilder>()
+ .UseSqlite($"Data Source={groupingDbName}")
+ .Options;
+ var groupingContext = new CasbinDbContext(groupingOptions);
+ groupingContext.Database.EnsureCreated();
+
+ return (policyContext, groupingContext);
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposed && disposing)
+ {
+ // Cleanup handled by test framework
+ _disposed = true;
+ }
+ }
+ }
+}
diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/PolicyTypeContextProvider.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/PolicyTypeContextProvider.cs
new file mode 100644
index 0000000..d01773f
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/PolicyTypeContextProvider.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+
+#nullable enable
+
+namespace Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures
+{
+ ///
+ /// Test context provider that routes policy types (p, p2, etc.) to one context
+ /// and grouping types (g, g2, etc.) to another context.
+ ///
+ public class PolicyTypeContextProvider : ICasbinDbContextProvider
+ {
+ private readonly CasbinDbContext _policyContext;
+ private readonly CasbinDbContext _groupingContext;
+
+ public PolicyTypeContextProvider(
+ CasbinDbContext policyContext,
+ CasbinDbContext groupingContext)
+ {
+ _policyContext = policyContext ?? throw new ArgumentNullException(nameof(policyContext));
+ _groupingContext = groupingContext ?? throw new ArgumentNullException(nameof(groupingContext));
+ }
+
+ public DbContext GetContextForPolicyType(string policyType)
+ {
+ if (string.IsNullOrEmpty(policyType))
+ {
+ return _policyContext;
+ }
+
+ // Route 'p' types (p, p2, p3, etc.) to policy context
+ // Route 'g' types (g, g2, g3, etc.) to grouping context
+ return policyType.StartsWith("p", StringComparison.OrdinalIgnoreCase)
+ ? _policyContext
+ : _groupingContext;
+ }
+
+ public IEnumerable GetAllContexts()
+ {
+ return new DbContext[] { _policyContext, _groupingContext };
+ }
+
+ public System.Data.Common.DbConnection? GetSharedConnection()
+ {
+ // Return null since this provider uses separate SQLite database files
+ // (each context has its own connection)
+ return null;
+ }
+ }
+}
diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/SimpleFieldFilter.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/SimpleFieldFilter.cs
new file mode 100644
index 0000000..78f2d5f
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/SimpleFieldFilter.cs
@@ -0,0 +1,36 @@
+using System.Linq;
+using Casbin.Model;
+using Casbin.Persist;
+
+#nullable enable
+
+namespace Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures
+{
+ ///
+ /// Simple field-based policy filter for testing.
+ /// Replaces the deprecated Filter class for basic field filtering scenarios.
+ ///
+ public class SimpleFieldFilter : IPolicyFilter
+ {
+ private readonly PolicyFilter _policyFilter;
+
+ ///
+ /// Creates a filter that filters policies of the specified type by field values.
+ ///
+ /// The policy type to filter (e.g., "p", "g", "g2")
+ /// The field index to start filtering from (usually 0)
+ /// The field values to filter by
+ public SimpleFieldFilter(string policyType, int fieldIndex, IPolicyValues values)
+ {
+ _policyFilter = new PolicyFilter(policyType, fieldIndex, values);
+ }
+
+ ///
+ /// Applies the filter to the policy collection.
+ ///
+ public IQueryable Apply(IQueryable policies) where T : IPersistPolicy
+ {
+ return _policyFilter.Apply(policies);
+ }
+ }
+}
diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/TestHostFixture.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/TestHostFixture.cs
index 97faf7d..b436420 100644
--- a/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/TestHostFixture.cs
+++ b/Casbin.Persist.Adapter.EFCore.UnitTest/Fixtures/TestHostFixture.cs
@@ -10,10 +10,13 @@ public class TestHostFixture
{
public TestHostFixture()
{
+ // Use unique database name to allow parallel test execution
+ var uniqueDbName = $"CasbinHostTest_{Guid.NewGuid():N}.db";
+
Services = new ServiceCollection()
.AddDbContext>(options =>
{
- options.UseSqlite("Data Source=CasbinHostTest.db");
+ options.UseSqlite($"Data Source={uniqueDbName}");
})
.AddEFCoreAdapter()
.BuildServiceProvider();
diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs
new file mode 100644
index 0000000..2525777
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.UnitTest/MultiContextTest.cs
@@ -0,0 +1,490 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Casbin.Model;
+using Casbin.Persist.Adapter.EFCore.Entities;
+using Casbin.Persist.Adapter.EFCore.UnitTest.Extensions;
+using Casbin.Persist.Adapter.EFCore.UnitTest.Fixtures;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace Casbin.Persist.Adapter.EFCore.UnitTest
+{
+ ///
+ /// Tests for multi-context functionality where different policy types
+ /// can be stored in separate database contexts/tables/schemas.
+ ///
+ public class MultiContextTest : TestUtil,
+ IClassFixture,
+ IClassFixture
+ {
+ private readonly ModelProvideFixture _modelProvideFixture;
+ private readonly MultiContextProviderFixture _multiContextProviderFixture;
+
+ public MultiContextTest(
+ ModelProvideFixture modelProvideFixture,
+ MultiContextProviderFixture multiContextProviderFixture)
+ {
+ _modelProvideFixture = modelProvideFixture;
+ _multiContextProviderFixture = multiContextProviderFixture;
+ }
+
+ [Fact]
+ public void TestMultiContextAddPolicy()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("AddPolicy");
+ var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("AddPolicy");
+
+ policyContext.Clear();
+ groupingContext.Clear();
+
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act - Add policy rules (should go to policy context)
+ enforcer.AddPolicy("alice", "data1", "read");
+ enforcer.AddPolicy("bob", "data2", "write");
+
+ // Add grouping rules (should go to grouping context)
+ enforcer.AddGroupingPolicy("alice", "admin");
+
+ // Assert - Verify policies are in the correct contexts
+ Assert.Equal(2, policyContext.Policies.Count());
+ Assert.Equal(1, groupingContext.Policies.Count());
+
+ // Verify policy data
+ var alicePolicy = policyContext.Policies.FirstOrDefault(p => p.Value1 == "alice");
+ Assert.NotNull(alicePolicy);
+ Assert.Equal("p", alicePolicy.Type);
+ Assert.Equal("data1", alicePolicy.Value2);
+ Assert.Equal("read", alicePolicy.Value3);
+
+ // Verify grouping data
+ var aliceGrouping = groupingContext.Policies.FirstOrDefault(p => p.Value1 == "alice");
+ Assert.NotNull(aliceGrouping);
+ Assert.Equal("g", aliceGrouping.Type);
+ Assert.Equal("admin", aliceGrouping.Value2);
+ }
+
+ [Fact]
+ public async Task TestMultiContextAddPolicyAsync()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("AddPolicyAsync");
+ var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("AddPolicyAsync");
+
+ policyContext.Clear();
+ groupingContext.Clear();
+
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act
+ await enforcer.AddPolicyAsync("alice", "data1", "read");
+ await enforcer.AddPolicyAsync("bob", "data2", "write");
+ await enforcer.AddGroupingPolicyAsync("alice", "admin");
+
+ // Assert
+ Assert.Equal(2, await policyContext.Policies.CountAsync());
+ Assert.Equal(1, await groupingContext.Policies.CountAsync());
+ }
+
+ [Fact]
+ public void TestMultiContextRemovePolicy()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("RemovePolicy");
+ var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("RemovePolicy");
+
+ policyContext.Clear();
+ groupingContext.Clear();
+
+ // Pre-populate data
+ policyContext.Policies.Add(new EFCorePersistPolicy
+ {
+ Type = "p",
+ Value1 = "alice",
+ Value2 = "data1",
+ Value3 = "read"
+ });
+ policyContext.SaveChanges();
+
+ groupingContext.Policies.Add(new EFCorePersistPolicy
+ {
+ Type = "g",
+ Value1 = "alice",
+ Value2 = "admin"
+ });
+ groupingContext.SaveChanges();
+
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+ enforcer.LoadPolicy();
+
+ // Act
+ enforcer.RemovePolicy("alice", "data1", "read");
+ enforcer.RemoveGroupingPolicy("alice", "admin");
+
+ // Assert
+ Assert.Equal(0, policyContext.Policies.Count());
+ Assert.Equal(0, groupingContext.Policies.Count());
+ }
+
+ [Fact]
+ public void TestMultiContextLoadPolicy()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("LoadPolicy");
+ var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("LoadPolicy");
+
+ policyContext.Clear();
+ groupingContext.Clear();
+
+ // Add test data to policy context
+ policyContext.Policies.AddRange(new[]
+ {
+ new EFCorePersistPolicy { Type = "p", Value1 = "alice", Value2 = "data1", Value3 = "read" },
+ new EFCorePersistPolicy { Type = "p", Value1 = "bob", Value2 = "data2", Value3 = "write" }
+ });
+ policyContext.SaveChanges();
+
+ // Add test data to grouping context
+ groupingContext.Policies.AddRange(new[]
+ {
+ new EFCorePersistPolicy { Type = "g", Value1 = "alice", Value2 = "admin" },
+ new EFCorePersistPolicy { Type = "g", Value1 = "bob", Value2 = "user" }
+ });
+ groupingContext.SaveChanges();
+
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act
+ enforcer.LoadPolicy();
+
+ // Assert - Verify all policies loaded from both contexts
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write")
+ ));
+
+ TestGetGroupingPolicy(enforcer, AsList(
+ AsList("alice", "admin"),
+ AsList("bob", "user")
+ ));
+ }
+
+ [Fact]
+ public async Task TestMultiContextLoadPolicyAsync()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("LoadPolicyAsync");
+ var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("LoadPolicyAsync");
+
+ policyContext.Clear();
+ groupingContext.Clear();
+
+ policyContext.Policies.AddRange(new[]
+ {
+ new EFCorePersistPolicy { Type = "p", Value1 = "alice", Value2 = "data1", Value3 = "read" }
+ });
+ await policyContext.SaveChangesAsync();
+
+ groupingContext.Policies.Add(new EFCorePersistPolicy { Type = "g", Value1 = "alice", Value2 = "admin" });
+ await groupingContext.SaveChangesAsync();
+
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act
+ await enforcer.LoadPolicyAsync();
+
+ // Assert
+ Assert.Single(enforcer.GetPolicy());
+ Assert.Single(enforcer.GetGroupingPolicy());
+ }
+
+ [Fact]
+ public void TestMultiContextSavePolicy()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("SavePolicy");
+ var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("SavePolicy");
+
+ policyContext.Clear();
+ groupingContext.Clear();
+
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Add policies via enforcer
+ enforcer.AddPolicy("alice", "data1", "read");
+ enforcer.AddPolicy("bob", "data2", "write");
+ enforcer.AddGroupingPolicy("alice", "admin");
+
+ // Act - Save should distribute policies to correct contexts
+ enforcer.SavePolicy();
+
+ // Assert - Verify data is in correct contexts
+ Assert.Equal(2, policyContext.Policies.Count());
+ Assert.Equal(1, groupingContext.Policies.Count());
+
+ // Verify we can reload from both contexts
+ var newEnforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+ newEnforcer.LoadPolicy();
+
+ TestGetPolicy(newEnforcer, AsList(
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write")
+ ));
+
+ TestGetGroupingPolicy(newEnforcer, AsList(
+ AsList("alice", "admin")
+ ));
+ }
+
+ [Fact]
+ public async Task TestMultiContextSavePolicyAsync()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("SavePolicyAsync");
+ var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("SavePolicyAsync");
+
+ policyContext.Clear();
+ groupingContext.Clear();
+
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ enforcer.AddPolicy("alice", "data1", "read");
+ enforcer.AddGroupingPolicy("alice", "admin");
+
+ // Act
+ await enforcer.SavePolicyAsync();
+
+ // Assert
+ Assert.Equal(1, await policyContext.Policies.CountAsync());
+ Assert.Equal(1, await groupingContext.Policies.CountAsync());
+ }
+
+ [Fact]
+ public void TestMultiContextBatchOperations()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("BatchOperations");
+ var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("BatchOperations");
+
+ policyContext.Clear();
+ groupingContext.Clear();
+
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act - Add multiple policies at once
+ enforcer.AddPolicies(new[]
+ {
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write"),
+ AsList("charlie", "data3", "read")
+ });
+
+ // Assert
+ Assert.Equal(3, policyContext.Policies.Count());
+
+ // Act - Remove multiple policies
+ enforcer.RemovePolicies(new[]
+ {
+ AsList("alice", "data1", "read"),
+ AsList("bob", "data2", "write")
+ });
+
+ // Assert
+ Assert.Equal(1, policyContext.Policies.Count());
+ Assert.Equal("charlie", policyContext.Policies.First().Value1);
+ }
+
+ [Fact]
+ public void TestMultiContextLoadFilteredPolicy()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("LoadFilteredPolicy");
+ var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("LoadFilteredPolicy");
+
+ policyContext.Clear();
+ groupingContext.Clear();
+
+ // Add multiple policies
+ policyContext.Policies.AddRange(new[]
+ {
+ new EFCorePersistPolicy { Type = "p", Value1 = "alice", Value2 = "data1", Value3 = "read" },
+ new EFCorePersistPolicy { Type = "p", Value1 = "bob", Value2 = "data2", Value3 = "write" }
+ });
+ policyContext.SaveChanges();
+
+ groupingContext.Policies.Add(new EFCorePersistPolicy { Type = "g", Value1 = "alice", Value2 = "admin" });
+ groupingContext.SaveChanges();
+
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act - Load only alice's policies
+ enforcer.LoadFilteredPolicy(new SimpleFieldFilter("p", 0, Policy.ValuesFrom(AsList("alice", "", ""))));
+
+ // Assert
+ TestGetPolicy(enforcer, AsList(
+ AsList("alice", "data1", "read")
+ ));
+
+ // Bob's policy should not be loaded
+ Assert.DoesNotContain(enforcer.GetPolicy(), p => p.Contains("bob"));
+ }
+
+ ///
+ /// Verifies that UpdatePolicy operations work across multiple contexts without throwing exceptions.
+ ///
+ /// NOTE: This is NOT a transaction rollback test. This test uses separate SQLite database files
+ /// (policy.db and grouping.db), making atomic cross-context transactions impossible.
+ ///
+ /// For actual transaction integrity and rollback verification across multiple contexts,
+ /// see Integration/TransactionIntegrityTests.cs (PostgreSQL tests with shared connections).
+ ///
+ [Fact]
+ public void TestMultiContextUpdatePolicyNoException()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("UpdatePolicyNoException");
+ var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("UpdatePolicyNoException");
+
+ policyContext.Clear();
+ groupingContext.Clear();
+
+ var adapter = new EFCoreAdapter(provider);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Add initial data
+ enforcer.AddPolicy("alice", "data1", "read");
+ enforcer.AddGroupingPolicy("alice", "admin");
+
+ // Act & Assert - UpdatePolicy should complete without throwing exceptions
+ enforcer.UpdatePolicy(
+ AsList("alice", "data1", "read"),
+ AsList("alice", "data1", "write")
+ );
+
+ // Verify the update was applied successfully
+ Assert.True(enforcer.HasPolicy("alice", "data1", "write"));
+ Assert.False(enforcer.HasPolicy("alice", "data1", "read"));
+ }
+
+ [Fact]
+ public void TestMultiContextProviderGetAllContexts()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("GetAllContexts");
+
+ // Act
+ var contexts = provider.GetAllContexts().ToList();
+
+ // Assert
+ Assert.Equal(2, contexts.Count);
+ Assert.All(contexts, ctx => Assert.NotNull(ctx));
+ }
+
+ [Fact]
+ public void TestMultiContextProviderGetContextForPolicyType()
+ {
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("GetContextForType");
+
+ // Act & Assert
+ var pContext = provider.GetContextForPolicyType("p");
+ var p2Context = provider.GetContextForPolicyType("p2");
+ var gContext = provider.GetContextForPolicyType("g");
+ var g2Context = provider.GetContextForPolicyType("g2");
+
+ // All 'p' types should route to same context
+ Assert.Same(pContext, p2Context);
+
+ // All 'g' types should route to same context
+ Assert.Same(gContext, g2Context);
+
+ // 'p' and 'g' types should route to different contexts
+ Assert.NotSame(pContext, gContext);
+ }
+
+ [Fact]
+ public void TestDbSetCachingByPolicyType()
+ {
+ // This test verifies that the DbSet cache uses (context, policyType) as the composite key
+ // rather than just context. This prevents the bug where different policy types would
+ // incorrectly share the same cached DbSet.
+
+ // Arrange
+ var provider = _multiContextProviderFixture.GetMultiContextProvider("DbSetCaching");
+ var (policyContext, groupingContext) = _multiContextProviderFixture.GetSeparateContexts("DbSetCaching");
+
+ policyContext.Clear();
+ groupingContext.Clear();
+
+ // Create a custom adapter that tracks GetCasbinRuleDbSet calls
+ var callTracker = new Dictionary();
+ var adapter = new DbSetCachingTestAdapter(provider, callTracker);
+ var enforcer = new Enforcer(_modelProvideFixture.GetNewRbacModel(), adapter);
+
+ // Act - Add policies of different types
+ enforcer.AddPolicy("alice", "data1", "read"); // Type 'p' - first call should invoke GetCasbinRuleDbSet
+ enforcer.AddPolicy("bob", "data2", "write"); // Type 'p' - should use cached DbSet
+ enforcer.AddGroupingPolicy("alice", "admin"); // Type 'g' - different type, should invoke GetCasbinRuleDbSet
+ enforcer.AddGroupingPolicy("bob", "user"); // Type 'g' - should use cached DbSet
+
+ // Assert - Verify GetCasbinRuleDbSet was called once per unique (context, policyType) combination
+ // If the cache key was only 'context', it would be called once and return wrong DbSet for 'g'
+ Assert.Equal(1, callTracker["p"]); // Called once for 'p', then cached
+ Assert.Equal(1, callTracker["g"]); // Called once for 'g', then cached
+
+ // Verify data went to correct contexts
+ Assert.Equal(2, policyContext.Policies.Count());
+ Assert.Equal(2, groupingContext.Policies.Count());
+
+ // Verify policy types are correct
+ Assert.All(policyContext.Policies, p => Assert.Equal("p", p.Type));
+ Assert.All(groupingContext.Policies, g => Assert.Equal("g", g.Type));
+ }
+ }
+
+ ///
+ /// Test adapter that tracks how many times GetCasbinRuleDbSet is called per policy type.
+ /// This is used to verify the DbSet caching behavior.
+ ///
+ internal class DbSetCachingTestAdapter : EFCoreAdapter
+ {
+ private readonly Dictionary _callTracker;
+
+ public DbSetCachingTestAdapter(
+ ICasbinDbContextProvider contextProvider,
+ Dictionary callTracker)
+ : base(contextProvider)
+ {
+ _callTracker = callTracker;
+ }
+
+ protected override DbSet> GetCasbinRuleDbSet(DbContext dbContext, string policyType)
+ {
+ // Track that this method was called for this policy type
+ // Only track non-null policy types (null is used for general operations)
+ if (policyType != null)
+ {
+ if (!_callTracker.ContainsKey(policyType))
+ {
+ _callTracker[policyType] = 0;
+ }
+ _callTracker[policyType]++;
+ }
+
+ // Call base implementation to get the actual DbSet
+ return base.GetCasbinRuleDbSet(dbContext, policyType);
+ }
+ }
+}
diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/SpecialPolicyTest.cs b/Casbin.Persist.Adapter.EFCore.UnitTest/SpecialPolicyTest.cs
index e3253c4..a7b5cc2 100644
--- a/Casbin.Persist.Adapter.EFCore.UnitTest/SpecialPolicyTest.cs
+++ b/Casbin.Persist.Adapter.EFCore.UnitTest/SpecialPolicyTest.cs
@@ -6,13 +6,13 @@
namespace Casbin.Persist.Adapter.EFCore.UnitTest
{
- public class SpecialPolicyTest : TestUtil, IClassFixture,
+ public class PolicyEdgeCasesTest : TestUtil, IClassFixture,
IClassFixture
{
private readonly ModelProvideFixture _modelProvideFixture;
private readonly DbContextProviderFixture _dbContextProviderFixture;
- public SpecialPolicyTest(ModelProvideFixture modelProvideFixture,
+ public PolicyEdgeCasesTest(ModelProvideFixture modelProvideFixture,
DbContextProviderFixture dbContextProviderFixture)
{
_modelProvideFixture = modelProvideFixture;
diff --git a/Casbin.Persist.Adapter.EFCore.UnitTest/xunit.runner.json b/Casbin.Persist.Adapter.EFCore.UnitTest/xunit.runner.json
new file mode 100644
index 0000000..6b2d9df
--- /dev/null
+++ b/Casbin.Persist.Adapter.EFCore.UnitTest/xunit.runner.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
+ "parallelizeTestCollections": true,
+ "maxParallelThreads": -1
+}
diff --git a/Casbin.Persist.Adapter.EFCore/Casbin.Persist.Adapter.EFCore.csproj b/Casbin.Persist.Adapter.EFCore/Casbin.Persist.Adapter.EFCore.csproj
index 1df76e6..78e04d3 100644
--- a/Casbin.Persist.Adapter.EFCore/Casbin.Persist.Adapter.EFCore.csproj
+++ b/Casbin.Persist.Adapter.EFCore/Casbin.Persist.Adapter.EFCore.csproj
@@ -17,7 +17,7 @@
-
+
diff --git a/Casbin.Persist.Adapter.EFCore/CasbinDbContext.cs b/Casbin.Persist.Adapter.EFCore/CasbinDbContext.cs
index 5a8c910..9389cef 100644
--- a/Casbin.Persist.Adapter.EFCore/CasbinDbContext.cs
+++ b/Casbin.Persist.Adapter.EFCore/CasbinDbContext.cs
@@ -1,4 +1,6 @@
using System;
+using System.Threading;
+using System.Threading.Tasks;
using Casbin.Persist.Adapter.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
@@ -54,5 +56,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
}
}
+
}
}
diff --git a/Casbin.Persist.Adapter.EFCore/DefaultPersistPolicyEntityTypeConfiguration.cs b/Casbin.Persist.Adapter.EFCore/DefaultPersistPolicyEntityTypeConfiguration.cs
index 9d41921..5853150 100644
--- a/Casbin.Persist.Adapter.EFCore/DefaultPersistPolicyEntityTypeConfiguration.cs
+++ b/Casbin.Persist.Adapter.EFCore/DefaultPersistPolicyEntityTypeConfiguration.cs
@@ -28,14 +28,14 @@ public virtual void Configure(EntityTypeBuilder> build
builder.Property(p => p.Value4).HasColumnName("v3");
builder.Property(p => p.Value5).HasColumnName("v4");
builder.Property(p => p.Value6).HasColumnName("v5");
- builder.Ignore(p => p.Value7);
- builder.Ignore(p => p.Value8);
- builder.Ignore(p => p.Value9);
- builder.Ignore(p => p.Value10);
- builder.Ignore(p => p.Value11);
- builder.Ignore(p => p.Value12);
- builder.Ignore(p => p.Value13);
- builder.Ignore(p => p.Value14);
+ builder.Property(p => p.Value7).HasColumnName("v6");
+ builder.Property(p => p.Value8).HasColumnName("v7");
+ builder.Property(p => p.Value9).HasColumnName("v8");
+ builder.Property(p => p.Value10).HasColumnName("v9");
+ builder.Property(p => p.Value11).HasColumnName("v10");
+ builder.Property(p => p.Value12).HasColumnName("v11");
+ builder.Property(p => p.Value13).HasColumnName("v12");
+ builder.Property(p => p.Value14).HasColumnName("v13");
builder.HasIndex(p => p.Type);
builder.HasIndex(p => p.Value1);
diff --git a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.Internal.cs b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.Internal.cs
index 1a05f34..e916ade 100644
--- a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.Internal.cs
+++ b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.Internal.cs
@@ -4,6 +4,8 @@
using System.Threading.Tasks;
using Casbin.Model;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage;
// ReSharper disable InconsistentNaming
// ReSharper disable MemberCanBeProtected.Global
@@ -18,82 +20,191 @@ public partial class EFCoreAdapter : IAdapter,
{
private void InternalAddPolicy(string section, string policyType, IPolicyValues values)
{
+ var context = GetContextForPolicyType(policyType);
+ InternalAddPolicy(context, section, policyType, values);
+ }
+
+ private void InternalAddPolicy(DbContext context, string section, string policyType, IPolicyValues values)
+ {
+ var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType);
var persistPolicy = PersistPolicy.Create(section, policyType, values);
persistPolicy = OnAddPolicy(section, policyType, values, persistPolicy);
- PersistPolicies.Add(persistPolicy);
+ dbSet.Add(persistPolicy);
}
private async ValueTask InternalAddPolicyAsync(string section, string policyType, IPolicyValues values)
{
+ var context = GetContextForPolicyType(policyType);
+ await InternalAddPolicyAsync(context, section, policyType, values);
+ }
+
+ private async ValueTask InternalAddPolicyAsync(DbContext context, string section, string policyType, IPolicyValues values)
+ {
+ var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType);
var persistPolicy = PersistPolicy.Create(section, policyType, values);
persistPolicy = OnAddPolicy(section, policyType, values, persistPolicy);
- await PersistPolicies.AddAsync(persistPolicy);
+ await dbSet.AddAsync(persistPolicy);
}
private void InternalUpdatePolicy(string section, string policyType, IPolicyValues oldValues , IPolicyValues newValues)
{
- InternalRemovePolicy(section, policyType, oldValues);
- InternalAddPolicy(section, policyType, newValues);
+ var context = GetContextForPolicyType(policyType);
+ InternalUpdatePolicy(context, section, policyType, oldValues, newValues);
+ }
+
+ private void InternalUpdatePolicy(DbContext context, string section, string policyType, IPolicyValues oldValues , IPolicyValues newValues)
+ {
+ InternalRemovePolicy(context, section, policyType, oldValues);
+ InternalAddPolicy(context, section, policyType, newValues);
}
private ValueTask InternalUpdatePolicyAsync(string section, string policyType, IPolicyValues oldValues , IPolicyValues newValues)
{
- InternalRemovePolicy(section, policyType, oldValues);
- return InternalAddPolicyAsync(section, policyType, newValues);
+ var context = GetContextForPolicyType(policyType);
+ return InternalUpdatePolicyAsync(context, section, policyType, oldValues, newValues);
+ }
+
+ private async ValueTask InternalUpdatePolicyAsync(DbContext context, string section, string policyType, IPolicyValues oldValues , IPolicyValues newValues)
+ {
+ InternalRemovePolicy(context, section, policyType, oldValues);
+ await InternalAddPolicyAsync(context, section, policyType, newValues);
}
private void InternalAddPolicies(string section, string policyType, IReadOnlyList valuesList)
{
+ var context = GetContextForPolicyType(policyType);
+ InternalAddPolicies(context, section, policyType, valuesList);
+ }
+
+ private void InternalAddPolicies(DbContext context, string section, string policyType, IReadOnlyList valuesList)
+ {
+ var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType);
var persistPolicies = valuesList.
Select(v => PersistPolicy.Create(section, policyType, v));
persistPolicies = OnAddPolicies(section, policyType, valuesList, persistPolicies);
- PersistPolicies.AddRange(persistPolicies);
+ dbSet.AddRange(persistPolicies);
}
private async ValueTask InternalAddPoliciesAsync(string section, string policyType, IReadOnlyList valuesList)
{
- var persistPolicies = valuesList.Select(v =>
+ var context = GetContextForPolicyType(policyType);
+ await InternalAddPoliciesAsync(context, section, policyType, valuesList);
+ }
+
+ private async ValueTask InternalAddPoliciesAsync(DbContext context, string section, string policyType, IReadOnlyList valuesList)
+ {
+ var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType);
+ var persistPolicies = valuesList.Select(v =>
PersistPolicy.Create(section, policyType, v));
persistPolicies = OnAddPolicies(section, policyType, valuesList, persistPolicies);
- await PersistPolicies.AddRangeAsync(persistPolicies);
+ await dbSet.AddRangeAsync(persistPolicies);
}
private void InternalUpdatePolicies(string section, string policyType, IReadOnlyList oldValuesList, IReadOnlyList newValuesList)
{
- InternalRemovePolicies(section, policyType, oldValuesList);
- InternalAddPolicies(section, policyType, newValuesList);
+ var context = GetContextForPolicyType(policyType);
+ InternalUpdatePolicies(context, section, policyType, oldValuesList, newValuesList);
+ }
+
+ private void InternalUpdatePolicies(DbContext context, string section, string policyType, IReadOnlyList oldValuesList, IReadOnlyList newValuesList)
+ {
+ InternalRemovePolicies(context, section, policyType, oldValuesList);
+ InternalAddPolicies(context, section, policyType, newValuesList);
}
private ValueTask InternalUpdatePoliciesAsync(string section, string policyType, IReadOnlyList oldValuesList, IReadOnlyList newValuesList)
{
- InternalRemovePolicies(section, policyType, oldValuesList);
- return InternalAddPoliciesAsync(section, policyType, newValuesList);
+ var context = GetContextForPolicyType(policyType);
+ return InternalUpdatePoliciesAsync(context, section, policyType, oldValuesList, newValuesList);
+ }
+
+ private async ValueTask InternalUpdatePoliciesAsync(DbContext context, string section, string policyType, IReadOnlyList oldValuesList, IReadOnlyList newValuesList)
+ {
+ InternalRemovePolicies(context, section, policyType, oldValuesList);
+ await InternalAddPoliciesAsync(context, section, policyType, newValuesList);
}
private void InternalRemovePolicy(string section, string policyType, IPolicyValues values)
{
- RemoveFilteredPolicy(section, policyType, 0, values);
+ var context = GetContextForPolicyType(policyType);
+ InternalRemovePolicy(context, section, policyType, values);
}
-
+
+ private void InternalRemovePolicy(DbContext context, string section, string policyType, IPolicyValues values)
+ {
+ InternalRemoveFilteredPolicy(context, section, policyType, 0, values);
+ }
+
private void InternalRemovePolicies(string section, string policyType, IReadOnlyList valuesList)
+ {
+ var context = GetContextForPolicyType(policyType);
+ InternalRemovePolicies(context, section, policyType, valuesList);
+ }
+
+ private void InternalRemovePolicies(DbContext context, string section, string policyType, IReadOnlyList valuesList)
{
foreach (var value in valuesList)
{
- InternalRemovePolicy(section, policyType, value);
+ InternalRemovePolicy(context, section, policyType, value);
}
}
private void InternalRemoveFilteredPolicy(string section, string policyType, int fieldIndex, IPolicyValues fieldValues)
{
+ var context = GetContextForPolicyType(policyType);
+ InternalRemoveFilteredPolicy(context, section, policyType, fieldIndex, fieldValues);
+ }
+
+ private void InternalRemoveFilteredPolicy(DbContext context, string section, string policyType, int fieldIndex, IPolicyValues fieldValues)
+ {
+ var dbSet = GetCasbinRuleDbSetForPolicyType(context, policyType);
var filter = new PolicyFilter(policyType, fieldIndex, fieldValues);
- var persistPolicies = filter.Apply(PersistPolicies);
+ var persistPolicies = filter.Apply(dbSet);
persistPolicies = OnRemoveFilteredPolicy(section, policyType, fieldIndex, fieldValues, persistPolicies);
- PersistPolicies.RemoveRange(persistPolicies);
+ dbSet.RemoveRange(persistPolicies);
}
+ #region Helper methods
+
+ ///
+ /// Gets or caches the DbSet for a specific context and policy type
+ ///
+ private DbSet GetCasbinRuleDbSetForPolicyType(DbContext context, string policyType)
+ {
+ var key = (context, policyType);
+ if (!_persistPoliciesByContext.TryGetValue(key, out var dbSet))
+ {
+ dbSet = GetCasbinRuleDbSet(context, policyType);
+ _persistPoliciesByContext[key] = dbSet;
+ }
+ return dbSet;
+ }
+
+ ///
+ /// Gets the context responsible for handling a specific policy type
+ ///
+ private DbContext GetContextForPolicyType(string policyType)
+ {
+ return _contextProvider.GetContextForPolicyType(policyType);
+ }
+
+ #endregion
+
#region virtual method
+ ///
+ /// Gets the DbSet for policies from the specified context (backward compatible)
+ ///
+ [Obsolete("Use GetCasbinRuleDbSet(DbContext, string) instead. This method will be removed in a future major version.", false)]
protected virtual DbSet GetCasbinRuleDbSet(TDbContext dbContext)
+ {
+ return GetCasbinRuleDbSet((DbContext)dbContext, null);
+ }
+
+ ///
+ /// Gets the DbSet for policies from the specified context with optional policy type routing
+ ///
+ protected virtual DbSet GetCasbinRuleDbSet(DbContext dbContext, string policyType)
{
return dbContext.Set();
}
diff --git a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs
index 3994b18..f0a7f02 100644
--- a/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs
+++ b/Casbin.Persist.Adapter.EFCore/EFCoreAdapter.cs
@@ -3,6 +3,8 @@
using System.Linq;
using System;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage;
using System.Threading.Tasks;
using Casbin.Persist.Adapter.EFCore.Extensions;
using Casbin.Persist.Adapter.EFCore.Entities;
@@ -24,6 +26,11 @@ public EFCoreAdapter(IServiceProvider serviceProvider) : base(serviceProvider)
{
}
+
+ public EFCoreAdapter(ICasbinDbContextProvider contextProvider) : base(contextProvider)
+ {
+
+ }
}
public class EFCoreAdapter : EFCoreAdapter>
@@ -41,57 +48,122 @@ public EFCoreAdapter(IServiceProvider serviceProvider) : base(serviceProvider)
{
}
+
+ public EFCoreAdapter(ICasbinDbContextProvider contextProvider) : base(contextProvider)
+ {
+
+ }
}
- public partial class EFCoreAdapter : IAdapter, IFilteredAdapter
+ ///
+ /// Entity Framework Core adapter for Casbin authorization library.
+ /// Supports both single-context and multi-context scenarios for policy storage.
+ ///
+ ///
+ /// Performance Considerations:
+ ///
+ /// The adapter caches DbSet instances per (DbContext, policyType) combination in an
+ /// internal dictionary for performance. This cache grows to at most (N contexts × M policy types)
+ /// entries, typically 2-8 entries in practice. Memory overhead is negligible (~224 bytes typical,
+ /// ~3.5 KB worst-case). The cache lifetime matches the adapter instance lifetime.
+ ///
+ /// Lifecycle:
+ ///
+ /// In multi-context scenarios, ensure DbContext instances live at least as long as the
+ /// adapter instance. Typical usage patterns (singleton DI registration or test fixtures)
+ /// naturally satisfy this requirement.
+ ///
+ ///
+ /// The type of the policy identifier (e.g., int, Guid, string)
+ /// The entity type for persisting policies
+ /// The DbContext type
+ public partial class EFCoreAdapter : IAdapter, IFilteredAdapter
where TDbContext : DbContext
where TPersistPolicy : class, IEFCorePersistPolicy, new()
where TKey : IEquatable
{
private DbSet _persistPolicies;
- private readonly IServiceProvider _serviceProvider;
- private readonly bool _useServiceProvider;
-
- protected TDbContext DbContext { get; private set; }
- protected DbSet PersistPolicies => _persistPolicies ??= GetCasbinRuleDbSet(GetOrResolveDbContext());
+ private readonly ICasbinDbContextProvider _contextProvider;
+ private readonly Dictionary<(DbContext context, string policyType), DbSet> _persistPoliciesByContext;
+
+ protected TDbContext DbContext { get; }
+ protected DbSet PersistPolicies => _persistPolicies ??= GetCasbinRuleDbSet(DbContext, null);
+ ///
+ /// Creates adapter with single context (backward compatible)
+ ///
public EFCoreAdapter(TDbContext context)
{
DbContext = context ?? throw new ArgumentNullException(nameof(context));
- _useServiceProvider = false;
+ _contextProvider = new SingleContextProvider(context);
+ _persistPoliciesByContext = new Dictionary<(DbContext context, string policyType), DbSet>();
}
+ ///
+ /// Creates adapter with single context resolved from IServiceProvider (for DI scenarios)
+ ///
public EFCoreAdapter(IServiceProvider serviceProvider)
{
- _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
- _useServiceProvider = true;
+ if (serviceProvider == null) throw new ArgumentNullException(nameof(serviceProvider));
+
+ // Resolve DbContext from service provider
+ var context = serviceProvider.GetService(typeof(TDbContext)) as TDbContext
+ ?? throw new InvalidOperationException(
+ $"Unable to resolve service for type '{typeof(TDbContext)}' from IServiceProvider. " +
+ $"Ensure the DbContext is registered in the service collection.");
+
+ DbContext = context;
+ _contextProvider = new SingleContextProvider(context);
+ _persistPoliciesByContext = new Dictionary<(DbContext context, string policyType), DbSet