diff --git a/src/NHibernate.Test/Async/CacheTest/CacheFixture.cs b/src/NHibernate.Test/Async/CacheTest/CacheFixture.cs index ad8af003134..5d12747ec68 100644 --- a/src/NHibernate.Test/Async/CacheTest/CacheFixture.cs +++ b/src/NHibernate.Test/Async/CacheTest/CacheFixture.cs @@ -29,7 +29,7 @@ public async Task TestSimpleCacheAsync() private CacheKey CreateCacheKey(string text) { - return new CacheKey(text, NHibernateUtil.String, "Foo", null); + return new CacheKey(text, NHibernateUtil.String, "Foo", null, null); } public async Task DoTestCacheAsync(ICacheProvider cacheProvider, CancellationToken cancellationToken = default(CancellationToken)) diff --git a/src/NHibernate.Test/Async/ConnectionStringTest/NamedConnectionStringFixture.cs b/src/NHibernate.Test/Async/ConnectionStringTest/NamedConnectionStringFixture.cs index 8d3b82709d0..48a945bba1c 100644 --- a/src/NHibernate.Test/Async/ConnectionStringTest/NamedConnectionStringFixture.cs +++ b/src/NHibernate.Test/Async/ConnectionStringTest/NamedConnectionStringFixture.cs @@ -26,9 +26,8 @@ public partial class MockConnectionProvider : ConnectionProvider /// /// Get an open . /// - /// A cancellation token that can be used to cancel the work /// An open . - public override Task GetConnectionAsync(CancellationToken cancellationToken) + public override Task GetConnectionAsync(string connectionString, CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/src/NHibernate.Test/Async/DebugSessionFactory.cs b/src/NHibernate.Test/Async/DebugSessionFactory.cs index 54b9e7ac6ad..5c24cd724c5 100644 --- a/src/NHibernate.Test/Async/DebugSessionFactory.cs +++ b/src/NHibernate.Test/Async/DebugSessionFactory.cs @@ -26,6 +26,7 @@ using NHibernate.Id; using NHibernate.Impl; using NHibernate.Metadata; +using NHibernate.MultiTenancy; using NHibernate.Persister.Collection; using NHibernate.Persister.Entity; using NHibernate.Proxy; diff --git a/src/NHibernate.Test/Async/FilterTest/DynamicFilterTest.cs b/src/NHibernate.Test/Async/FilterTest/DynamicFilterTest.cs index 96d43ef72d0..9dd89c37955 100644 --- a/src/NHibernate.Test/Async/FilterTest/DynamicFilterTest.cs +++ b/src/NHibernate.Test/Async/FilterTest/DynamicFilterTest.cs @@ -45,7 +45,7 @@ public async Task SecondLevelCachedCollectionsFilteringAsync() var persister = Sfi .GetCollectionPersister(typeof(Salesperson).FullName + ".Orders"); var cacheKey = - new CacheKey(testData.steveId, persister.KeyType, persister.Role, Sfi); + new CacheKey(testData.steveId, persister.KeyType, persister.Role, Sfi, null); CollectionCacheEntry cachedData; using (var session = OpenSession()) diff --git a/src/NHibernate.Test/Async/MultiTenancy/DatabaseStrategyNoDbSpecificFixture.cs b/src/NHibernate.Test/Async/MultiTenancy/DatabaseStrategyNoDbSpecificFixture.cs new file mode 100644 index 00000000000..3fc06fb5a44 --- /dev/null +++ b/src/NHibernate.Test/Async/MultiTenancy/DatabaseStrategyNoDbSpecificFixture.cs @@ -0,0 +1,265 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by AsyncGenerator. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +using System; +using System.Data.Common; +using System.Data.SqlClient; +using System.IO; +using System.Linq; +using System.Runtime.Serialization.Formatters.Binary; +using NHibernate.Cfg; +using NHibernate.Cfg.MappingSchema; +using NHibernate.Dialect; +using NHibernate.Driver; +using NHibernate.Engine; +using NHibernate.Linq; +using NHibernate.Mapping.ByCode; +using NHibernate.MultiTenancy; +using NHibernate.Util; +using NUnit.Framework; + +namespace NHibernate.Test.MultiTenancy +{ + using System.Threading.Tasks; + [TestFixture] + public class DatabaseStrategyNoDbSpecificFixtureAsync : TestCaseMappingByCode + { + private Guid _id; + + protected override void Configure(Configuration configuration) + { + configuration.DataBaseIntegration( + x => + { + x.MultiTenancy = MultiTenancyStrategy.Database; + x.MultiTenancyConnectionProvider(); + }); + configuration.Properties[Cfg.Environment.GenerateStatistics] = "true"; + base.Configure(configuration); + } + + private static void ValidateSqlServerConnectionAppName(ISession s, string tenantId) + { + var builder = new SqlConnectionStringBuilder(s.Connection.ConnectionString); + Assert.That(builder.ApplicationName, Is.EqualTo(tenantId)); + } + + private static void ValidateSqlServerConnectionAppName(IStatelessSession s, string tenantId) + { + var builder = new SqlConnectionStringBuilder(s.Connection.ConnectionString); + Assert.That(builder.ApplicationName, Is.EqualTo(tenantId)); + } + + [Test] + public async Task SecondLevelCacheReusedForSameTenantAsync() + { + using (var sesTen1 = OpenTenantSession("tenant1")) + { + var entity = await (sesTen1.GetAsync(_id)); + } + + Sfi.Statistics.Clear(); + using (var sesTen2 = OpenTenantSession("tenant1")) + { + var entity = await (sesTen2.GetAsync(_id)); + } + + Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(0)); + Assert.That(Sfi.Statistics.SecondLevelCacheHitCount, Is.EqualTo(1)); + } + + [Test] + public async Task SecondLevelCacheSeparationPerTenantAsync() + { + using (var sesTen1 = OpenTenantSession("tenant1")) + { + var entity = await (sesTen1.GetAsync(_id)); + } + + Sfi.Statistics.Clear(); + using (var sesTen2 = OpenTenantSession("tenant2")) + { + var entity = await (sesTen2.GetAsync(_id)); + } + + Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(1)); + Assert.That(Sfi.Statistics.SecondLevelCacheHitCount, Is.EqualTo(0)); + } + + [Test] + public async Task QueryCacheReusedForSameTenantAsync() + { + using (var sesTen1 = OpenTenantSession("tenant1")) + { + var entity = await (sesTen1.Query().WithOptions(x => x.SetCacheable(true)).Where(e => e.Id == _id).SingleOrDefaultAsync()); + } + + Sfi.Statistics.Clear(); + using (var sesTen2 = OpenTenantSession("tenant1")) + { + var entity = await (sesTen2.Query().WithOptions(x => x.SetCacheable(true)).Where(e => e.Id == _id).SingleOrDefaultAsync()); + } + + Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(0)); + Assert.That(Sfi.Statistics.QueryCacheHitCount, Is.EqualTo(1)); + } + + [Test] + public async Task QueryCacheSeparationPerTenantAsync() + { + using (var sesTen1 = OpenTenantSession("tenant1")) + { + var entity = await (sesTen1.Query().WithOptions(x => x.SetCacheable(true)).Where(e => e.Id == _id).SingleOrDefaultAsync()); + } + + Sfi.Statistics.Clear(); + using (var sesTen2 = OpenTenantSession("tenant2")) + { + var entity = await (sesTen2.Query().WithOptions(x => x.SetCacheable(true)).Where(e => e.Id == _id).SingleOrDefaultAsync()); + } + + Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(1)); + Assert.That(Sfi.Statistics.QueryCacheHitCount, Is.EqualTo(0)); + } + + [Test] + public async Task TenantSessionIsSerializableAndCanBeReconnectedAsync() + { + ISession deserializedSession = null; + using (var sesTen1 = OpenTenantSession("tenant1")) + { + var entity = await (sesTen1.Query().WithOptions(x => x.SetCacheable(true)).Where(e => e.Id == _id).SingleOrDefaultAsync()); + sesTen1.Disconnect(); + deserializedSession = SpoofSerialization(sesTen1); + } + + Sfi.Statistics.Clear(); + using (deserializedSession) + { + deserializedSession.Reconnect(); + + //Expect session cache hit + var entity = await (deserializedSession.GetAsync(_id)); + if (IsSqlServerDialect) + ValidateSqlServerConnectionAppName(deserializedSession, "tenant1"); + deserializedSession.Clear(); + + //Expect second level cache hit + await (deserializedSession.GetAsync(_id)); + Assert.That(GetTenantId(deserializedSession), Is.EqualTo("tenant1")); + } + + Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(0)); + Assert.That(Sfi.Statistics.SecondLevelCacheHitCount, Is.EqualTo(1)); + } + + private static string GetTenantId(ISession session) + { + return session.GetSessionImplementation().GetTenantIdentifier(); + } + + private static string GetTenantId(IStatelessSession session) + { + return session.GetSessionImplementation().GetTenantIdentifier(); + } + + private T SpoofSerialization(T session) + { + var formatter = new BinaryFormatter + { +#if !NETFX + SurrogateSelector = new SerializationHelper.SurrogateSelector() +#endif + }; + var stream = new MemoryStream(); + formatter.Serialize(stream, session); + + stream.Position = 0; + + return (T) formatter.Deserialize(stream); + } + + private ISession OpenTenantSession(string tenantId) + { + return Sfi.WithOptions().Tenant(GetTenantConfig(tenantId)).OpenSession(); + } + + private IStatelessSession OpenTenantStatelessSession(string tenantId) + { + return Sfi.WithStatelessOptions().Tenant(GetTenantConfig(tenantId)).OpenStatelessSession(); + } + + private TenantConfiguration GetTenantConfig(string tenantId) + { + return new TestTenantConfiguration(tenantId, IsSqlServerDialect); + } + + private bool IsSqlServerDialect => Sfi.Dialect is MsSql2000Dialect && !(Sfi.ConnectionProvider.Driver is OdbcDriver); + + #region Test Setup + + protected override HbmMapping GetMappings() + { + var mapper = new ModelMapper(); + + mapper.Class( + rc => + { + rc.Cache(m => m.Usage(CacheUsage.NonstrictReadWrite)); + rc.Id(x => x.Id, m => m.Generator(Generators.GuidComb)); + rc.Property(x => x.Name); + }); + + return mapper.CompileMappingForAllExplicitlyAddedEntities(); + } + + protected override DbConnection OpenConnectionForSchemaExport() + { + return Sfi.Settings.MultiTenancyConnectionProvider + .GetConnectionAccess(GetTenantConfig("defaultTenant"), Sfi).GetConnection(); + } + + protected override ISession OpenSession() + { + return OpenTenantSession("defaultTenant"); + } + + protected override void OnTearDown() + { + using (var session = OpenSession()) + using (var transaction = session.BeginTransaction()) + { + session.Delete("from System.Object"); + + session.Flush(); + transaction.Commit(); + } + } + + protected override void OnSetUp() + { + using (var session = OpenSession()) + using (var transaction = session.BeginTransaction()) + { + var e1 = new Entity {Name = "Bob"}; + session.Save(e1); + + var e2 = new Entity {Name = "Sally"}; + session.Save(e2); + + session.Flush(); + transaction.Commit(); + _id = e1.Id; + } + } + + #endregion Test Setup + } +} diff --git a/src/NHibernate.Test/CacheTest/CacheFixture.cs b/src/NHibernate.Test/CacheTest/CacheFixture.cs index 2c96ba341dd..5c86f6ced46 100644 --- a/src/NHibernate.Test/CacheTest/CacheFixture.cs +++ b/src/NHibernate.Test/CacheTest/CacheFixture.cs @@ -18,7 +18,7 @@ public void TestSimpleCache() private CacheKey CreateCacheKey(string text) { - return new CacheKey(text, NHibernateUtil.String, "Foo", null); + return new CacheKey(text, NHibernateUtil.String, "Foo", null, null); } public void DoTestCache(ICacheProvider cacheProvider) diff --git a/src/NHibernate.Test/CacheTest/QueryKeyFixture.cs b/src/NHibernate.Test/CacheTest/QueryKeyFixture.cs index dd62799eb28..6ed9fd8a584 100644 --- a/src/NHibernate.Test/CacheTest/QueryKeyFixture.cs +++ b/src/NHibernate.Test/CacheTest/QueryKeyFixture.cs @@ -35,13 +35,13 @@ private void QueryKeyFilterDescLikeToCompare(out QueryKey qk, out QueryKey qk1, f.SetParameter("pLike", "so%"); var fk = new FilterKey(f); ISet fks = new HashSet { fk }; - qk = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null); + qk = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null, null); var f1 = new FilterImpl(Sfi.GetFilterDefinition(filterName)); f1.SetParameter("pLike", sameValue ? "so%" : "%ing"); var fk1 = new FilterKey(f1); fks = new HashSet { fk1 }; - qk1 = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null); + qk1 = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null, null); } private void QueryKeyFilterDescValueToCompare(out QueryKey qk, out QueryKey qk1, bool sameValue) @@ -52,13 +52,13 @@ private void QueryKeyFilterDescValueToCompare(out QueryKey qk, out QueryKey qk1, f.SetParameter("pDesc", "something").SetParameter("pValue", 10); var fk = new FilterKey(f); ISet fks = new HashSet { fk }; - qk = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null); + qk = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null, null); var f1 = new FilterImpl(Sfi.GetFilterDefinition(filterName)); f1.SetParameter("pDesc", "something").SetParameter("pValue", sameValue ? 10 : 11); var fk1 = new FilterKey(f1); fks = new HashSet { fk1 }; - qk1 = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null); + qk1 = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null, null); } [Test] @@ -122,7 +122,7 @@ public void ToStringWithFilters() f.SetParameter("pLike", "so%"); var fk = new FilterKey(f); ISet fks = new HashSet { fk }; - var qk = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null); + var qk = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null, null); Assert.That(qk.ToString(), Does.Contain($"filters: ['{fk}']"), "Like"); filterName = "DescriptionEqualAndValueGT"; @@ -130,7 +130,7 @@ public void ToStringWithFilters() f.SetParameter("pDesc", "something").SetParameter("pValue", 10); fk = new FilterKey(f); fks = new HashSet { fk }; - qk = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null); + qk = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null, null); Assert.That(qk.ToString(), Does.Contain($"filters: ['{fk}']"), "Value"); } @@ -148,7 +148,7 @@ public void ToStringWithMoreFilters() var fvk = new FilterKey(fv); ISet fks = new HashSet { fk, fvk }; - var qk = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null); + var qk = new QueryKey(Sfi, SqlAll, new QueryParameters(), fks, null, null); Assert.That(qk.ToString(), Does.Contain($"filters: ['{fk}', '{fvk}']")); } } diff --git a/src/NHibernate.Test/CfgTest/Loquacious/LambdaConfigurationFixture.cs b/src/NHibernate.Test/CfgTest/Loquacious/LambdaConfigurationFixture.cs index fcf841757a0..373543bc103 100644 --- a/src/NHibernate.Test/CfgTest/Loquacious/LambdaConfigurationFixture.cs +++ b/src/NHibernate.Test/CfgTest/Loquacious/LambdaConfigurationFixture.cs @@ -3,7 +3,6 @@ using NHibernate.Bytecode; using NHibernate.Cache; using NHibernate.Cfg; -using NHibernate.Cfg.Loquacious; using NHibernate.Dialect; using NHibernate.Driver; using NHibernate.Hql.Ast.ANTLR; @@ -11,6 +10,7 @@ using NHibernate.Type; using NUnit.Framework; using NHibernate.Exceptions; +using NHibernate.MultiTenancy; namespace NHibernate.Test.CfgTest.Loquacious { @@ -64,6 +64,7 @@ public void FullConfiguration() db.HqlToSqlSubstitutions = "true 1, false 0, yes 'Y', no 'N'"; db.SchemaAction = SchemaAutoAction.Validate; db.ThrowOnSchemaUpdate = true; + db.MultiTenancy = MultiTenancyStrategy.Database; }); Assert.That(configure.Properties[Environment.SessionFactoryName], Is.EqualTo("SomeName")); @@ -109,7 +110,8 @@ public void FullConfiguration() Assert.That(configure.Properties[Environment.Hbm2ddlAuto], Is.EqualTo("validate")); Assert.That(configure.Properties[Environment.Hbm2ddlThrowOnUpdate], Is.EqualTo("true")); Assert.That(configure.Properties[Environment.LinqToHqlGeneratorsRegistry], Is.EqualTo(typeof(DefaultLinqToHqlGeneratorsRegistry).AssemblyQualifiedName)); - + Assert.That(configure.Properties[Environment.MultiTenancy], Is.EqualTo(nameof(MultiTenancyStrategy.Database))); + // Keywords import and auto-validation require a valid connection string, disable them before checking // the session factory can be built. configure.SetProperty(Environment.Hbm2ddlKeyWords, "none"); diff --git a/src/NHibernate.Test/ConnectionStringTest/NamedConnectionStringFixture.cs b/src/NHibernate.Test/ConnectionStringTest/NamedConnectionStringFixture.cs index 5030067e5e8..5933d5bb65d 100644 --- a/src/NHibernate.Test/ConnectionStringTest/NamedConnectionStringFixture.cs +++ b/src/NHibernate.Test/ConnectionStringTest/NamedConnectionStringFixture.cs @@ -58,7 +58,7 @@ public string PublicConnectionString /// Get an open . /// /// An open . - public override DbConnection GetConnection() + public override DbConnection GetConnection(string connectionString) { throw new NotImplementedException(); } diff --git a/src/NHibernate.Test/DebugSessionFactory.cs b/src/NHibernate.Test/DebugSessionFactory.cs index eb43b825eee..7643924119f 100644 --- a/src/NHibernate.Test/DebugSessionFactory.cs +++ b/src/NHibernate.Test/DebugSessionFactory.cs @@ -16,6 +16,7 @@ using NHibernate.Id; using NHibernate.Impl; using NHibernate.Metadata; +using NHibernate.MultiTenancy; using NHibernate.Persister.Collection; using NHibernate.Persister.Entity; using NHibernate.Proxy; @@ -400,7 +401,9 @@ public static ISessionCreationOptions GetCreationOptions(IStatelessSessionBuilde (ISessionCreationOptions)sessionBuilder; } - internal class SessionBuilder : ISessionBuilder + internal class SessionBuilder : ISessionBuilder, + //TODO 6.0: Remove interface with implementation (will be replaced TenantConfiguration ISessionBuilder method) + ISessionCreationOptionsWithMultiTenancy { private readonly ISessionBuilder _actualBuilder; private readonly DebugSessionFactory _debugFactory; @@ -465,9 +468,17 @@ ISessionBuilder ISessionBuilder.FlushMode(FlushMode flushMode) } #endregion + + TenantConfiguration ISessionCreationOptionsWithMultiTenancy.TenantConfiguration + { + get => (_actualBuilder as ISessionCreationOptionsWithMultiTenancy)?.TenantConfiguration; + set => _actualBuilder.Tenant(value); + } } - internal class StatelessSessionBuilder : IStatelessSessionBuilder + internal class StatelessSessionBuilder : IStatelessSessionBuilder, + //TODO 6.0: Remove interface with implementation (will be replaced TenantConfiguration IStatelessSessionBuilder method) + ISessionCreationOptionsWithMultiTenancy { private readonly IStatelessSessionBuilder _actualBuilder; private readonly DebugSessionFactory _debugFactory; @@ -501,6 +512,12 @@ IStatelessSessionBuilder IStatelessSessionBuilder.AutoJoinTransaction(bool autoJ return this; } + TenantConfiguration ISessionCreationOptionsWithMultiTenancy.TenantConfiguration + { + get => (_actualBuilder as ISessionCreationOptionsWithMultiTenancy)?.TenantConfiguration; + set => _actualBuilder.Tenant(value); + } + #endregion } } diff --git a/src/NHibernate.Test/FilterTest/DynamicFilterTest.cs b/src/NHibernate.Test/FilterTest/DynamicFilterTest.cs index b345b642ab8..5830894d526 100644 --- a/src/NHibernate.Test/FilterTest/DynamicFilterTest.cs +++ b/src/NHibernate.Test/FilterTest/DynamicFilterTest.cs @@ -33,7 +33,7 @@ public void SecondLevelCachedCollectionsFiltering() var persister = Sfi .GetCollectionPersister(typeof(Salesperson).FullName + ".Orders"); var cacheKey = - new CacheKey(testData.steveId, persister.KeyType, persister.Role, Sfi); + new CacheKey(testData.steveId, persister.KeyType, persister.Role, Sfi, null); CollectionCacheEntry cachedData; using (var session = OpenSession()) diff --git a/src/NHibernate.Test/MultiTenancy/DatabaseStrategyNoDbSpecificFixture.cs b/src/NHibernate.Test/MultiTenancy/DatabaseStrategyNoDbSpecificFixture.cs new file mode 100644 index 00000000000..2a37052266b --- /dev/null +++ b/src/NHibernate.Test/MultiTenancy/DatabaseStrategyNoDbSpecificFixture.cs @@ -0,0 +1,334 @@ +using System; +using System.Data.Common; +using System.Data.SqlClient; +using System.IO; +using System.Linq; +using System.Runtime.Serialization.Formatters.Binary; +using NHibernate.Cfg; +using NHibernate.Cfg.MappingSchema; +using NHibernate.Dialect; +using NHibernate.Driver; +using NHibernate.Engine; +using NHibernate.Linq; +using NHibernate.Mapping.ByCode; +using NHibernate.MultiTenancy; +using NHibernate.Util; +using NUnit.Framework; + +namespace NHibernate.Test.MultiTenancy +{ + [TestFixture] + public class DatabaseStrategyNoDbSpecificFixture : TestCaseMappingByCode + { + private Guid _id; + + protected override void Configure(Configuration configuration) + { + configuration.DataBaseIntegration( + x => + { + x.MultiTenancy = MultiTenancyStrategy.Database; + x.MultiTenancyConnectionProvider(); + }); + configuration.Properties[Cfg.Environment.GenerateStatistics] = "true"; + base.Configure(configuration); + } + + [Test] + public void ShouldThrowWithNoTenantIdentifier() + { + Assert.Throws(() => Sfi.WithOptions().Tenant(new TenantConfiguration(null))); + } + + [Test] + public void DifferentConnectionStringForDifferentTenants() + { + if (!IsSqlServerDialect) + Assert.Ignore("MSSqlServer specific test"); + + using (var session1 = OpenTenantSession("tenant1")) + using (var session2 = OpenTenantSession("tenant2")) + { + Assert.That(session1.Connection.ConnectionString, Is.Not.EqualTo(session2.Connection.ConnectionString)); + ValidateSqlServerConnectionAppName(session1, "tenant1"); + ValidateSqlServerConnectionAppName(session2, "tenant2"); + Assert.That(GetTenantId(session1), Is.EqualTo("tenant1")); + Assert.That(GetTenantId(session2), Is.EqualTo("tenant2")); + } + } + + [Test] + public void StatelessSessionShouldThrowWithNoTenantIdentifier() + { + Assert.Throws(() => Sfi.WithStatelessOptions().Tenant(new TenantConfiguration(null))); + } + + [Test] + public void StatelessSessionDifferentConnectionStringForDifferentTenants() + { + if (!IsSqlServerDialect) + Assert.Ignore("MSSqlServer specific test"); + + using (var session1 = OpenTenantStatelessSession("tenant1")) + using (var session2 = OpenTenantStatelessSession("tenant2")) + { + Assert.That(session1.Connection.ConnectionString, Is.Not.EqualTo(session2.Connection.ConnectionString)); + ValidateSqlServerConnectionAppName(session1, "tenant1"); + ValidateSqlServerConnectionAppName(session2, "tenant2"); + Assert.That(GetTenantId(session1), Is.EqualTo("tenant1")); + Assert.That(GetTenantId(session2), Is.EqualTo("tenant2")); + } + } + + [Test] + public void SharedSessionSameConnectionString() + { + using (var session1 = OpenTenantSession("tenant1")) + using (var session2 = session1.SessionWithOptions().OpenSession()) + { + Assert.That(session1.Connection, Is.Not.EqualTo(session2.Connection)); + Assert.That(session1.Connection.ConnectionString, Is.EqualTo(session2.Connection.ConnectionString)); + Assert.That(session2.GetSessionImplementation().GetTenantIdentifier(), Is.EqualTo("tenant1")); + } + } + + [Test] + public void SharedSessionSameConnection() + { + using (var session1 = OpenTenantSession("tenant1")) + using (var session2 = session1.SessionWithOptions().Connection().OpenSession()) + { + Assert.That(session1.Connection, Is.EqualTo(session2.Connection)); + Assert.That(GetTenantId(session2), Is.EqualTo("tenant1")); + } + } + + [Test] + public void SharedStatelessSessionSameConnectionString() + { + using (var session1 = OpenTenantSession("tenant1")) + using (var session2 = session1.StatelessSessionWithOptions().OpenStatelessSession()) + { + Assert.That(session1.Connection.ConnectionString, Is.EqualTo(session2.Connection.ConnectionString)); + Assert.That(GetTenantId(session2), Is.EqualTo("tenant1")); + } + } + + private static void ValidateSqlServerConnectionAppName(ISession s, string tenantId) + { + var builder = new SqlConnectionStringBuilder(s.Connection.ConnectionString); + Assert.That(builder.ApplicationName, Is.EqualTo(tenantId)); + } + + private static void ValidateSqlServerConnectionAppName(IStatelessSession s, string tenantId) + { + var builder = new SqlConnectionStringBuilder(s.Connection.ConnectionString); + Assert.That(builder.ApplicationName, Is.EqualTo(tenantId)); + } + + [Test] + public void SecondLevelCacheReusedForSameTenant() + { + using (var sesTen1 = OpenTenantSession("tenant1")) + { + var entity = sesTen1.Get(_id); + } + + Sfi.Statistics.Clear(); + using (var sesTen2 = OpenTenantSession("tenant1")) + { + var entity = sesTen2.Get(_id); + } + + Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(0)); + Assert.That(Sfi.Statistics.SecondLevelCacheHitCount, Is.EqualTo(1)); + } + + [Test] + public void SecondLevelCacheSeparationPerTenant() + { + using (var sesTen1 = OpenTenantSession("tenant1")) + { + var entity = sesTen1.Get(_id); + } + + Sfi.Statistics.Clear(); + using (var sesTen2 = OpenTenantSession("tenant2")) + { + var entity = sesTen2.Get(_id); + } + + Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(1)); + Assert.That(Sfi.Statistics.SecondLevelCacheHitCount, Is.EqualTo(0)); + } + + [Test] + public void QueryCacheReusedForSameTenant() + { + using (var sesTen1 = OpenTenantSession("tenant1")) + { + var entity = sesTen1.Query().WithOptions(x => x.SetCacheable(true)).Where(e => e.Id == _id).SingleOrDefault(); + } + + Sfi.Statistics.Clear(); + using (var sesTen2 = OpenTenantSession("tenant1")) + { + var entity = sesTen2.Query().WithOptions(x => x.SetCacheable(true)).Where(e => e.Id == _id).SingleOrDefault(); + } + + Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(0)); + Assert.That(Sfi.Statistics.QueryCacheHitCount, Is.EqualTo(1)); + } + + [Test] + public void QueryCacheSeparationPerTenant() + { + using (var sesTen1 = OpenTenantSession("tenant1")) + { + var entity = sesTen1.Query().WithOptions(x => x.SetCacheable(true)).Where(e => e.Id == _id).SingleOrDefault(); + } + + Sfi.Statistics.Clear(); + using (var sesTen2 = OpenTenantSession("tenant2")) + { + var entity = sesTen2.Query().WithOptions(x => x.SetCacheable(true)).Where(e => e.Id == _id).SingleOrDefault(); + } + + Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(1)); + Assert.That(Sfi.Statistics.QueryCacheHitCount, Is.EqualTo(0)); + } + + [Test] + public void TenantSessionIsSerializableAndCanBeReconnected() + { + ISession deserializedSession = null; + using (var sesTen1 = OpenTenantSession("tenant1")) + { + var entity = sesTen1.Query().WithOptions(x => x.SetCacheable(true)).Where(e => e.Id == _id).SingleOrDefault(); + sesTen1.Disconnect(); + deserializedSession = SpoofSerialization(sesTen1); + } + + Sfi.Statistics.Clear(); + using (deserializedSession) + { + deserializedSession.Reconnect(); + + //Expect session cache hit + var entity = deserializedSession.Get(_id); + if (IsSqlServerDialect) + ValidateSqlServerConnectionAppName(deserializedSession, "tenant1"); + deserializedSession.Clear(); + + //Expect second level cache hit + deserializedSession.Get(_id); + Assert.That(GetTenantId(deserializedSession), Is.EqualTo("tenant1")); + } + + Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(0)); + Assert.That(Sfi.Statistics.SecondLevelCacheHitCount, Is.EqualTo(1)); + } + + private static string GetTenantId(ISession session) + { + return session.GetSessionImplementation().GetTenantIdentifier(); + } + + private static string GetTenantId(IStatelessSession session) + { + return session.GetSessionImplementation().GetTenantIdentifier(); + } + + private T SpoofSerialization(T session) + { + var formatter = new BinaryFormatter + { +#if !NETFX + SurrogateSelector = new SerializationHelper.SurrogateSelector() +#endif + }; + var stream = new MemoryStream(); + formatter.Serialize(stream, session); + + stream.Position = 0; + + return (T) formatter.Deserialize(stream); + } + + private ISession OpenTenantSession(string tenantId) + { + return Sfi.WithOptions().Tenant(GetTenantConfig(tenantId)).OpenSession(); + } + + private IStatelessSession OpenTenantStatelessSession(string tenantId) + { + return Sfi.WithStatelessOptions().Tenant(GetTenantConfig(tenantId)).OpenStatelessSession(); + } + + private TenantConfiguration GetTenantConfig(string tenantId) + { + return new TestTenantConfiguration(tenantId, IsSqlServerDialect); + } + + private bool IsSqlServerDialect => Sfi.Dialect is MsSql2000Dialect && !(Sfi.ConnectionProvider.Driver is OdbcDriver); + + #region Test Setup + + protected override HbmMapping GetMappings() + { + var mapper = new ModelMapper(); + + mapper.Class( + rc => + { + rc.Cache(m => m.Usage(CacheUsage.NonstrictReadWrite)); + rc.Id(x => x.Id, m => m.Generator(Generators.GuidComb)); + rc.Property(x => x.Name); + }); + + return mapper.CompileMappingForAllExplicitlyAddedEntities(); + } + + protected override DbConnection OpenConnectionForSchemaExport() + { + return Sfi.Settings.MultiTenancyConnectionProvider + .GetConnectionAccess(GetTenantConfig("defaultTenant"), Sfi).GetConnection(); + } + + protected override ISession OpenSession() + { + return OpenTenantSession("defaultTenant"); + } + + protected override void OnTearDown() + { + using (var session = OpenSession()) + using (var transaction = session.BeginTransaction()) + { + session.Delete("from System.Object"); + + session.Flush(); + transaction.Commit(); + } + } + + protected override void OnSetUp() + { + using (var session = OpenSession()) + using (var transaction = session.BeginTransaction()) + { + var e1 = new Entity {Name = "Bob"}; + session.Save(e1); + + var e2 = new Entity {Name = "Sally"}; + session.Save(e2); + + session.Flush(); + transaction.Commit(); + _id = e1.Id; + } + } + + #endregion Test Setup + } +} diff --git a/src/NHibernate.Test/MultiTenancy/Entity.cs b/src/NHibernate.Test/MultiTenancy/Entity.cs new file mode 100644 index 00000000000..04636a58484 --- /dev/null +++ b/src/NHibernate.Test/MultiTenancy/Entity.cs @@ -0,0 +1,11 @@ +using System; + +namespace NHibernate.Test.MultiTenancy +{ + [Serializable] + class Entity + { + public virtual Guid Id { get; set; } + public virtual string Name { get; set; } + } +} diff --git a/src/NHibernate.Test/MultiTenancy/TestMultiTenancyConnectionProvider.cs b/src/NHibernate.Test/MultiTenancy/TestMultiTenancyConnectionProvider.cs new file mode 100644 index 00000000000..b690b78f14c --- /dev/null +++ b/src/NHibernate.Test/MultiTenancy/TestMultiTenancyConnectionProvider.cs @@ -0,0 +1,19 @@ +using System; +using System.Data.SqlClient; +using NHibernate.Connection; +using NHibernate.Engine; +using NHibernate.MultiTenancy; + +namespace NHibernate.Test.MultiTenancy +{ + [Serializable] + public class TestMultiTenancyConnectionProvider : AbstractMultiTenancyConnectionProvider + { + protected override string GetTenantConnectionString(TenantConfiguration tenantConfiguration, ISessionFactoryImplementor sessionFactory) + { + return tenantConfiguration is TestTenantConfiguration tenant && tenant.IsSqlServerDialect + ? new SqlConnectionStringBuilder(sessionFactory.ConnectionProvider.GetConnectionString()) {ApplicationName = tenantConfiguration.TenantIdentifier}.ToString() + : sessionFactory.ConnectionProvider.GetConnectionString(); + } + } +} diff --git a/src/NHibernate.Test/MultiTenancy/TestTenantConfiguration.cs b/src/NHibernate.Test/MultiTenancy/TestTenantConfiguration.cs new file mode 100644 index 00000000000..b743cc95ae0 --- /dev/null +++ b/src/NHibernate.Test/MultiTenancy/TestTenantConfiguration.cs @@ -0,0 +1,16 @@ +using System; +using NHibernate.MultiTenancy; + +namespace NHibernate.Test.MultiTenancy +{ + [Serializable] + public class TestTenantConfiguration : TenantConfiguration + { + public TestTenantConfiguration(string tenantIdentifier, bool isSqlServerDialect) : base(tenantIdentifier) + { + IsSqlServerDialect = isSqlServerDialect; + } + + public bool IsSqlServerDialect { get; } + } +} diff --git a/src/NHibernate.Test/TestCase.cs b/src/NHibernate.Test/TestCase.cs index f7505d3971f..f1d04a96640 100644 --- a/src/NHibernate.Test/TestCase.cs +++ b/src/NHibernate.Test/TestCase.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Data; +using System.Data.Common; using System.Reflection; using log4net; using NHibernate.Cfg; @@ -237,7 +238,7 @@ protected virtual bool CheckDatabaseWasCleaned() } bool empty; - using (ISession s = Sfi.OpenSession()) + using (ISession s = OpenSession()) { IList objects = s.CreateQuery("from System.Object o").List(); empty = objects.Count == 0; @@ -291,15 +292,16 @@ protected virtual void AddMappings(Configuration configuration) protected virtual void CreateSchema() { - SchemaExport.Create(OutputDdl, true); + using (var optionalConnection = OpenConnectionForSchemaExport()) + SchemaExport.Create(OutputDdl, true, optionalConnection); } protected virtual void DropSchema() { - DropSchema(OutputDdl, SchemaExport, Sfi); + DropSchema(OutputDdl, SchemaExport, Sfi, OpenConnectionForSchemaExport); } - public static void DropSchema(bool useStdOut, SchemaExport export, ISessionFactoryImplementor sfi) + public static void DropSchema(bool useStdOut, SchemaExport export, ISessionFactoryImplementor sfi, Func getConnection = null) { if (sfi?.ConnectionProvider.Driver is FirebirdClientDriver fbDriver) { @@ -312,7 +314,16 @@ public static void DropSchema(bool useStdOut, SchemaExport export, ISessionFacto fbDriver.ClearPool(null); } - export.Drop(useStdOut, true); + using(var optionalConnection = getConnection?.Invoke()) + export.Drop(useStdOut, true, optionalConnection); + } + + /// + /// Specific connection is required only for Database multi-tenancy. In other cases can be null + /// + protected virtual DbConnection OpenConnectionForSchemaExport() + { + return null; } protected virtual DebugSessionFactory BuildSessionFactory() diff --git a/src/NHibernate/Action/CollectionAction.cs b/src/NHibernate/Action/CollectionAction.cs index ca5be713bf6..e676e7d4f18 100644 --- a/src/NHibernate/Action/CollectionAction.cs +++ b/src/NHibernate/Action/CollectionAction.cs @@ -124,7 +124,7 @@ public virtual void BeforeExecutions() public virtual void ExecuteAfterTransactionCompletion(bool success) { - var ck = new CacheKey(key, persister.KeyType, persister.Role, Session.Factory); + var ck = session.GenerateCacheKey(key, persister.KeyType, persister.Role); persister.Cache.Release(ck, softLock); } diff --git a/src/NHibernate/AdoNet/ConnectionManager.cs b/src/NHibernate/AdoNet/ConnectionManager.cs index ee7f4898651..5ebf51a7fb1 100644 --- a/src/NHibernate/AdoNet/ConnectionManager.cs +++ b/src/NHibernate/AdoNet/ConnectionManager.cs @@ -4,8 +4,9 @@ using System.Data.Common; using System.Runtime.Serialization; using System.Security; - +using NHibernate.Connection; using NHibernate.Engine; +using NHibernate.Impl; namespace NHibernate.AdoNet { @@ -19,6 +20,7 @@ namespace NHibernate.AdoNet [Serializable] public partial class ConnectionManager : ISerializable, IDeserializationCallback { + private readonly IConnectionAccess _connectionAccess; private static readonly INHibernateLogger _log = NHibernateLogger.For(typeof(ConnectionManager)); [NonSerialized] @@ -78,7 +80,8 @@ public ConnectionManager( DbConnection suppliedConnection, ConnectionReleaseMode connectionReleaseMode, IInterceptor interceptor, - bool shouldAutoJoinTransaction) + bool shouldAutoJoinTransaction, + IConnectionAccess connectionAccess) { Session = session; _connection = suppliedConnection; @@ -89,6 +92,18 @@ public ConnectionManager( _ownConnection = suppliedConnection == null; ShouldAutoJoinTransaction = shouldAutoJoinTransaction; + _connectionAccess = connectionAccess ?? throw new ArgumentNullException(nameof(connectionAccess)); + } + + //Since 5.3 + [Obsolete("Use overload with connectionAccess parameter")] + public ConnectionManager( + ISessionImplementor session, + DbConnection suppliedConnection, + ConnectionReleaseMode connectionReleaseMode, + IInterceptor interceptor, + bool shouldAutoJoinTransaction) : this(session, suppliedConnection, connectionReleaseMode, interceptor, shouldAutoJoinTransaction, new NonContextualConnectionAccess(session.Factory)) + { } public void AddDependentSession(ISessionImplementor session) @@ -148,7 +163,7 @@ public DbConnection Close() if (_backupConnection != null) { _log.Warn("Backup connection was still defined at time of closing."); - Factory.ConnectionProvider.CloseConnection(_backupConnection); + _connectionAccess.CloseConnection(_backupConnection); _backupConnection = null; } @@ -205,7 +220,7 @@ public DbConnection Disconnect() private void CloseConnection() { - Factory.ConnectionProvider.CloseConnection(_connection); + _connectionAccess.CloseConnection(_connection); _connection = null; } @@ -239,7 +254,7 @@ public DbConnection GetConnection() { if (_ownConnection) { - _connection = Factory.ConnectionProvider.GetConnection(); + _connection = _connectionAccess.GetConnection(); // Will fail if the connection is already enlisted in another transaction. // Probable case: nested transaction scope with connection auto-enlistment enabled. // That is an user error. @@ -362,6 +377,7 @@ private ConnectionManager(SerializationInfo info, StreamingContext context) _connectionReleaseMode = (ConnectionReleaseMode)info.GetValue("connectionReleaseMode", typeof(ConnectionReleaseMode)); _interceptor = (IInterceptor)info.GetValue("interceptor", typeof(IInterceptor)); + _connectionAccess = (IConnectionAccess) info.GetValue("connectionAccess", typeof(IConnectionAccess)); } [SecurityCritical] @@ -371,6 +387,7 @@ public void GetObjectData(SerializationInfo info, StreamingContext context) info.AddValue("session", Session, typeof(ISessionImplementor)); info.AddValue("connectionReleaseMode", _connectionReleaseMode, typeof(ConnectionReleaseMode)); info.AddValue("interceptor", _interceptor, typeof(IInterceptor)); + info.AddValue("connectionAccess", _connectionAccess, typeof(IConnectionAccess)); } #endregion diff --git a/src/NHibernate/Async/Action/CollectionAction.cs b/src/NHibernate/Async/Action/CollectionAction.cs index 16a2a27460e..237d2c34afe 100644 --- a/src/NHibernate/Async/Action/CollectionAction.cs +++ b/src/NHibernate/Async/Action/CollectionAction.cs @@ -74,8 +74,15 @@ public virtual Task ExecuteAfterTransactionCompletionAsync(bool success, Cancell { return Task.FromCanceled(cancellationToken); } - var ck = new CacheKey(key, persister.KeyType, persister.Role, Session.Factory); - return persister.Cache.ReleaseAsync(ck, softLock, cancellationToken); + try + { + var ck = session.GenerateCacheKey(key, persister.KeyType, persister.Role); + return persister.Cache.ReleaseAsync(ck, softLock, cancellationToken); + } + catch (Exception ex) + { + return Task.FromException(ex); + } } #endregion diff --git a/src/NHibernate/Async/AdoNet/ConnectionManager.cs b/src/NHibernate/Async/AdoNet/ConnectionManager.cs index 5ea057310b2..90487416762 100644 --- a/src/NHibernate/Async/AdoNet/ConnectionManager.cs +++ b/src/NHibernate/Async/AdoNet/ConnectionManager.cs @@ -14,8 +14,9 @@ using System.Data.Common; using System.Runtime.Serialization; using System.Security; - +using NHibernate.Connection; using NHibernate.Engine; +using NHibernate.Impl; namespace NHibernate.AdoNet { @@ -61,7 +62,7 @@ async Task InternalGetConnectionAsync() { if (_ownConnection) { - _connection = await (Factory.ConnectionProvider.GetConnectionAsync(cancellationToken)).ConfigureAwait(false); + _connection = await (_connectionAccess.GetConnectionAsync(cancellationToken)).ConfigureAwait(false); // Will fail if the connection is already enlisted in another transaction. // Probable case: nested transaction scope with connection auto-enlistment enabled. // That is an user error. diff --git a/src/NHibernate/Async/Connection/ConnectionProvider.cs b/src/NHibernate/Async/Connection/ConnectionProvider.cs index 3d04b34668b..df3ab7cced7 100644 --- a/src/NHibernate/Async/Connection/ConnectionProvider.cs +++ b/src/NHibernate/Async/Connection/ConnectionProvider.cs @@ -29,6 +29,23 @@ public abstract partial class ConnectionProvider : IConnectionProvider /// /// A cancellation token that can be used to cancel the work /// An open . - public abstract Task GetConnectionAsync(CancellationToken cancellationToken); + public virtual Task GetConnectionAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return GetConnectionAsync(ConnectionString, cancellationToken); + } + + //TODO 6.0: Make abstract + /// + /// Gets an open for given connectionString + /// + /// An open . + public virtual Task GetConnectionAsync(string connectionString, CancellationToken cancellationToken) + { + throw new NotImplementedException("This method must be overriden."); + } } } diff --git a/src/NHibernate/Async/Connection/DriverConnectionProvider.cs b/src/NHibernate/Async/Connection/DriverConnectionProvider.cs index 4c3358a5943..c0c21554592 100644 --- a/src/NHibernate/Async/Connection/DriverConnectionProvider.cs +++ b/src/NHibernate/Async/Connection/DriverConnectionProvider.cs @@ -22,21 +22,20 @@ public partial class DriverConnectionProvider : ConnectionProvider /// Gets a new open through /// the . /// - /// A cancellation token that can be used to cancel the work /// /// An Open . /// /// /// If there is any problem creating or opening the . /// - public override async Task GetConnectionAsync(CancellationToken cancellationToken) + public override async Task GetConnectionAsync(string connectionString, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); log.Debug("Obtaining DbConnection from Driver"); var conn = Driver.CreateConnection(); try { - conn.ConnectionString = ConnectionString; + conn.ConnectionString = connectionString; await (conn.OpenAsync(cancellationToken)).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } @@ -49,4 +48,4 @@ public override async Task GetConnectionAsync(CancellationToken ca return conn; } } -} \ No newline at end of file +} diff --git a/src/NHibernate/Async/Connection/IConnectionAccess.cs b/src/NHibernate/Async/Connection/IConnectionAccess.cs new file mode 100644 index 00000000000..eb8c1ebd506 --- /dev/null +++ b/src/NHibernate/Async/Connection/IConnectionAccess.cs @@ -0,0 +1,28 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by AsyncGenerator. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +using System.Data.Common; + +namespace NHibernate.Connection +{ + using System.Threading.Tasks; + using System.Threading; + public partial interface IConnectionAccess + { + + //ObtainConnection in hibernate + /// + /// Gets the database connection. + /// + /// A cancellation token that can be used to cancel the work + /// The database connection. + Task GetConnectionAsync(CancellationToken cancellationToken); + } +} diff --git a/src/NHibernate/Async/Connection/IConnectionProvider.cs b/src/NHibernate/Async/Connection/IConnectionProvider.cs index 61587ec2e6f..3a74648f9fd 100644 --- a/src/NHibernate/Async/Connection/IConnectionProvider.cs +++ b/src/NHibernate/Async/Connection/IConnectionProvider.cs @@ -12,11 +12,31 @@ using System.Collections.Generic; using System.Data.Common; using NHibernate.Driver; +using NHibernate.Util; namespace NHibernate.Connection { using System.Threading.Tasks; using System.Threading; + public static partial class ConnectionProviderExtensions + { + internal static Task GetConnectionAsync(this IConnectionProvider connectionProvider, string connectionString, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + try + { + return ReflectHelper.CastOrThrow(connectionProvider, "open connection by connectionString").GetConnectionAsync(connectionString, cancellationToken); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + } + public partial interface IConnectionProvider : IDisposable { @@ -27,4 +47,4 @@ public partial interface IConnectionProvider : IDisposable /// An open . Task GetConnectionAsync(CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/src/NHibernate/Async/Connection/UserSuppliedConnectionProvider.cs b/src/NHibernate/Async/Connection/UserSuppliedConnectionProvider.cs index 23422dd1bc8..a4950405bf4 100644 --- a/src/NHibernate/Async/Connection/UserSuppliedConnectionProvider.cs +++ b/src/NHibernate/Async/Connection/UserSuppliedConnectionProvider.cs @@ -23,7 +23,6 @@ public partial class UserSuppliedConnectionProvider : ConnectionProvider /// Throws an if this method is called /// because the user is responsible for creating s. /// - /// A cancellation token that can be used to cancel the work /// /// No value is returned because an is thrown. /// @@ -31,7 +30,7 @@ public partial class UserSuppliedConnectionProvider : ConnectionProvider /// Thrown when this method is called. User is responsible for creating /// s. /// - public override Task GetConnectionAsync(CancellationToken cancellationToken) + public override Task GetConnectionAsync(string connectionString, CancellationToken cancellationToken) { throw new InvalidOperationException("The user must provide an ADO.NET connection - NHibernate is not creating it."); } diff --git a/src/NHibernate/Async/Engine/ISessionImplementor.cs b/src/NHibernate/Async/Engine/ISessionImplementor.cs index d5108c3e8f8..0036bb9918e 100644 --- a/src/NHibernate/Async/Engine/ISessionImplementor.cs +++ b/src/NHibernate/Async/Engine/ISessionImplementor.cs @@ -30,7 +30,7 @@ namespace NHibernate.Engine { using System.Threading.Tasks; using System.Threading; - internal static partial class SessionImplementorExtensions + public static partial class SessionImplementorExtensions { internal static async Task AutoFlushIfRequiredAsync(this ISessionImplementor implementor, ISet querySpaces, CancellationToken cancellationToken) diff --git a/src/NHibernate/Async/ISessionFactory.cs b/src/NHibernate/Async/ISessionFactory.cs index 744c59b3bde..482f8af4ea5 100644 --- a/src/NHibernate/Async/ISessionFactory.cs +++ b/src/NHibernate/Async/ISessionFactory.cs @@ -17,6 +17,7 @@ using NHibernate.Impl; using NHibernate.Metadata; using NHibernate.Stat; +using NHibernate.Util; namespace NHibernate { @@ -24,6 +25,44 @@ namespace NHibernate using System.Threading; public static partial class SessionFactoryExtension { + /// + /// Evict an entry from the second-level cache. This method occurs outside + /// of any transaction; it performs an immediate "hard" remove, so does not respect + /// any transaction isolation semantics of the usage strategy. Use with care. + /// + /// The session factory. + /// The name of the entity to evict. + /// + /// Tenant identifier + /// A cancellation token that can be used to cancel the work + public static async Task EvictEntityAsync(this ISessionFactory factory, string entityName, object id, string tenantIdentifier, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (tenantIdentifier == null) + await (factory.EvictEntityAsync(entityName, id, cancellationToken)).ConfigureAwait(false); + + await (ReflectHelper.CastOrThrow(factory, "multi-tenancy").EvictEntityAsync(entityName, id, tenantIdentifier, cancellationToken)).ConfigureAwait(false); + } + + /// + /// Evict an entry from the process-level cache. This method occurs outside + /// of any transaction; it performs an immediate "hard" remove, so does not respect + /// any transaction isolation semantics of the usage strategy. Use with care. + /// + /// The session factory. + /// Collection role name. + /// Collection id + /// Tenant identifier + /// A cancellation token that can be used to cancel the work + public static async Task EvictCollectionAsync(this ISessionFactory factory, string roleName, object id, string tenantIdentifier, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (tenantIdentifier == null) + await (factory.EvictCollectionAsync(roleName, id, cancellationToken)).ConfigureAwait(false); + + await (ReflectHelper.CastOrThrow(factory, "multi-tenancy").EvictCollectionAsync(roleName, id, tenantIdentifier, cancellationToken)).ConfigureAwait(false); + } + /// /// Evict all entries from the process-level cache. This method occurs outside /// of any transaction; it performs an immediate "hard" remove, so does not respect diff --git a/src/NHibernate/Async/Impl/AbstractSessionImpl.cs b/src/NHibernate/Async/Impl/AbstractSessionImpl.cs index be4e02a2734..ed3948ad38e 100644 --- a/src/NHibernate/Async/Impl/AbstractSessionImpl.cs +++ b/src/NHibernate/Async/Impl/AbstractSessionImpl.cs @@ -17,6 +17,7 @@ using NHibernate.AdoNet; using NHibernate.Cache; using NHibernate.Collection; +using NHibernate.Connection; using NHibernate.Engine; using NHibernate.Engine.Query; using NHibernate.Engine.Query.Sql; @@ -27,6 +28,7 @@ using NHibernate.Loader.Custom; using NHibernate.Loader.Custom.Sql; using NHibernate.Multi; +using NHibernate.MultiTenancy; using NHibernate.Persister.Entity; using NHibernate.Transaction; using NHibernate.Type; diff --git a/src/NHibernate/Async/Impl/NonContextualConnectionAccess.cs b/src/NHibernate/Async/Impl/NonContextualConnectionAccess.cs new file mode 100644 index 00000000000..99e8efa7c31 --- /dev/null +++ b/src/NHibernate/Async/Impl/NonContextualConnectionAccess.cs @@ -0,0 +1,33 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by AsyncGenerator. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +using System; +using System.Data.Common; +using NHibernate.Connection; +using NHibernate.Engine; + +namespace NHibernate.Impl +{ + using System.Threading.Tasks; + using System.Threading; + partial class NonContextualConnectionAccess : IConnectionAccess + { + + /// + public Task GetConnectionAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return _sessionFactory.ConnectionProvider.GetConnectionAsync(cancellationToken); + } + } +} diff --git a/src/NHibernate/Async/Impl/SessionFactoryImpl.cs b/src/NHibernate/Async/Impl/SessionFactoryImpl.cs index 8fb68cb28cb..50d4a4d6a11 100644 --- a/src/NHibernate/Async/Impl/SessionFactoryImpl.cs +++ b/src/NHibernate/Async/Impl/SessionFactoryImpl.cs @@ -31,6 +31,7 @@ using NHibernate.Id; using NHibernate.Mapping; using NHibernate.Metadata; +using NHibernate.MultiTenancy; using NHibernate.Persister; using NHibernate.Persister.Collection; using NHibernate.Persister.Entity; @@ -131,24 +132,7 @@ public sealed partial class SessionFactoryImpl : ISessionFactoryImplementor, IOb { return Task.FromCanceled(cancellationToken); } - try - { - IEntityPersister p = GetEntityPersister(persistentClass.FullName); - if (p.HasCache) - { - if (log.IsDebugEnabled()) - { - log.Debug("evicting second-level cache: {0}", MessageHelper.InfoString(p, id)); - } - CacheKey ck = GenerateCacheKeyForEvict(id, p.IdentifierType, p.RootEntityName); - return p.Cache.RemoveAsync(ck, cancellationToken); - } - return Task.CompletedTask; - } - catch (Exception ex) - { - return Task.FromException(ex); - } + return EvictEntityAsync(persistentClass.FullName, id, cancellationToken); } public Task EvictAsync(System.Type persistentClass, CancellationToken cancellationToken = default(CancellationToken)) @@ -244,6 +228,15 @@ async Task InternalEvictEntityAsync() } public Task EvictEntityAsync(string entityName, object id, CancellationToken cancellationToken = default(CancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return EvictEntityAsync(entityName, id, null, cancellationToken); + } + + public Task EvictEntityAsync(string entityName, object id, string tenantIdentifier, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { @@ -256,9 +249,9 @@ async Task InternalEvictEntityAsync() { if (log.IsDebugEnabled()) { - log.Debug("evicting second-level cache: {0}", MessageHelper.InfoString(p, id, this)); + LogEvict(tenantIdentifier, MessageHelper.InfoString(p, id, this)); } - CacheKey cacheKey = GenerateCacheKeyForEvict(id, p.IdentifierType, p.RootEntityName); + CacheKey cacheKey = GenerateCacheKeyForEvict(id, p.IdentifierType, p.RootEntityName, tenantIdentifier); return p.Cache.RemoveAsync(cacheKey, cancellationToken); } return Task.CompletedTask; @@ -270,6 +263,15 @@ async Task InternalEvictEntityAsync() } public Task EvictCollectionAsync(string roleName, object id, CancellationToken cancellationToken = default(CancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return EvictCollectionAsync(roleName, id, null, cancellationToken); + } + + public Task EvictCollectionAsync(string roleName, object id, string tenantIdentifier, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { @@ -282,9 +284,10 @@ async Task InternalEvictEntityAsync() { if (log.IsDebugEnabled()) { - log.Debug("evicting second-level cache: {0}", MessageHelper.CollectionInfoString(p, id)); + LogEvict(tenantIdentifier, MessageHelper.CollectionInfoString(p, id)); } - CacheKey ck = GenerateCacheKeyForEvict(id, p.KeyType, p.Role); + + CacheKey ck = GenerateCacheKeyForEvict(id, p.KeyType, p.Role, tenantIdentifier); return p.Cache.RemoveAsync(ck, cancellationToken); } return Task.CompletedTask; diff --git a/src/NHibernate/Async/Impl/SessionImpl.cs b/src/NHibernate/Async/Impl/SessionImpl.cs index 03d89beaa52..a8a01f90dae 100644 --- a/src/NHibernate/Async/Impl/SessionImpl.cs +++ b/src/NHibernate/Async/Impl/SessionImpl.cs @@ -26,6 +26,7 @@ using NHibernate.Intercept; using NHibernate.Loader.Criteria; using NHibernate.Loader.Custom; +using NHibernate.MultiTenancy; using NHibernate.Persister.Collection; using NHibernate.Persister.Entity; using NHibernate.Proxy; diff --git a/src/NHibernate/Async/MultiTenancy/AbstractMultiTenancyConnectionProvider.cs b/src/NHibernate/Async/MultiTenancy/AbstractMultiTenancyConnectionProvider.cs new file mode 100644 index 00000000000..123bcad9827 --- /dev/null +++ b/src/NHibernate/Async/MultiTenancy/AbstractMultiTenancyConnectionProvider.cs @@ -0,0 +1,36 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by AsyncGenerator. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +using System; +using System.Data.Common; +using NHibernate.Connection; +using NHibernate.Engine; + +namespace NHibernate.MultiTenancy +{ + using System.Threading.Tasks; + using System.Threading; + public abstract partial class AbstractMultiTenancyConnectionProvider : IMultiTenancyConnectionProvider + { + partial class ContextualConnectionAccess : IConnectionAccess + { + + /// + public Task GetConnectionAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return _sessionFactory.ConnectionProvider.GetConnectionAsync(ConnectionString, cancellationToken); + } + } + } +} diff --git a/src/NHibernate/Async/Tool/hbm2ddl/SchemaExport.cs b/src/NHibernate/Async/Tool/hbm2ddl/SchemaExport.cs index 046f3aebda5..6c44940aefa 100644 --- a/src/NHibernate/Async/Tool/hbm2ddl/SchemaExport.cs +++ b/src/NHibernate/Async/Tool/hbm2ddl/SchemaExport.cs @@ -17,6 +17,7 @@ using NHibernate.AdoNet.Util; using NHibernate.Cfg; using NHibernate.Connection; +using NHibernate.MultiTenancy; using NHibernate.Util; using Environment=NHibernate.Cfg.Environment; @@ -46,9 +47,11 @@ public partial class SchemaExport dropSQL = cfg.GenerateDropSchemaScript(dialect); createSQL = cfg.GenerateSchemaCreationScript(dialect); formatter = (PropertiesHelper.GetBoolean(Environment.FormatSql, configProperties, true) ? FormatStyle.Ddl : FormatStyle.None).Formatter; + _requireTenantConnection = PropertiesHelper.GetEnum(Environment.MultiTenancy, configProperties, MultiTenancyStrategy.None) == MultiTenancyStrategy.Database; wasInitialized = true; } + //TODO 6.0: Remove (replaced by method with optional connection parameter) /// /// Run the schema creation script /// @@ -65,9 +68,39 @@ public partial class SchemaExport { return Task.FromCanceled(cancellationToken); } - return ExecuteAsync(useStdOut, execute, false, cancellationToken); + return CreateAsync(useStdOut, execute, null, cancellationToken); } + //TODO 6.0: Make connection parameter optional: DbConnection connection = null + /// + /// Run the schema creation script + /// + /// if the ddl should be outputted in the Console. + /// if the ddl should be executed against the Database. + /// Optional explicit connection. Required for multi-tenancy. + /// Must be an opened connection. The method doesn't close the connection. + /// A cancellation token that can be used to cancel the work + /// + /// This is a convenience method that calls and sets + /// the justDrop parameter to false. + /// + public Task CreateAsync(bool useStdOut, bool execute, DbConnection connection, CancellationToken cancellationToken = default(CancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + try + { + return InitConnectionAndExecuteAsync(GetAction(useStdOut), execute, false, connection, null, cancellationToken); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + } + + //TODO 6.0: Remove (replaced by method with optional connection parameter) /// /// Run the schema creation script /// @@ -84,9 +117,32 @@ public partial class SchemaExport { return Task.FromCanceled(cancellationToken); } - return ExecuteAsync(scriptAction, execute, false, cancellationToken); + return CreateAsync(scriptAction, execute, null, cancellationToken); } + //TODO 6.0: Make connection parameter optional: DbConnection connection = null + /// + /// Run the schema creation script + /// + /// an action that will be called for each line of the generated ddl. + /// if the ddl should be executed against the Database. + /// Optional explicit connection. Required for multi-tenancy. + /// Must be an opened connection. The method doesn't close the connection. + /// A cancellation token that can be used to cancel the work + /// + /// This is a convenience method that calls and sets + /// the justDrop parameter to false. + /// + public Task CreateAsync(Action scriptAction, bool execute, DbConnection connection, CancellationToken cancellationToken = default(CancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return InitConnectionAndExecuteAsync(scriptAction, execute, false, connection, null, cancellationToken); + } + + //TODO 6.0: Remove (replaced by method with optional connection parameter) /// /// Run the schema creation script /// @@ -103,9 +159,32 @@ public partial class SchemaExport { return Task.FromCanceled(cancellationToken); } - return ExecuteAsync(null, execute, false, exportOutput, cancellationToken); + return CreateAsync(exportOutput, execute, null, cancellationToken); } + //TODO 6.0: Make connection parameter optional: DbConnection connection = null + /// + /// Run the schema creation script + /// + /// if non-null, the ddl will be written to this TextWriter. + /// if the ddl should be executed against the Database. + /// Optional explicit connection. Required for multi-tenancy. + /// Must be an opened connection. The method doesn't close the connection. + /// A cancellation token that can be used to cancel the work + /// + /// This is a convenience method that calls and sets + /// the justDrop parameter to false. + /// + public Task CreateAsync(TextWriter exportOutput, bool execute, DbConnection connection, CancellationToken cancellationToken = default(CancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return InitConnectionAndExecuteAsync(null, execute, false, connection, exportOutput, cancellationToken); + } + + //TODO 6.0: Remove (replaced by method with optional connection parameter) /// /// Run the drop schema script /// @@ -122,9 +201,39 @@ public partial class SchemaExport { return Task.FromCanceled(cancellationToken); } - return ExecuteAsync(useStdOut, execute, true, cancellationToken); + return DropAsync(useStdOut, execute, null, cancellationToken); + } + + //TODO 6.0: Make connection parameter optional: DbConnection connection = null + /// + /// Run the drop schema script + /// + /// if the ddl should be outputted in the Console. + /// if the ddl should be executed against the Database. + /// Optional explicit connection. Required for multi-tenancy. + /// Must be an opened connection. The method doesn't close the connection. + /// A cancellation token that can be used to cancel the work + /// + /// This is a convenience method that calls and sets + /// the justDrop parameter to true. + /// + public Task DropAsync(bool useStdOut, bool execute, DbConnection connection, CancellationToken cancellationToken = default(CancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + try + { + return InitConnectionAndExecuteAsync(GetAction(useStdOut), execute, true, connection, null, cancellationToken); + } + catch (Exception ex) + { + return Task.FromException(ex); + } } + //TODO 6.0: Remove (replaced by method with optional connection parameter) /// /// Run the drop schema script /// @@ -141,7 +250,29 @@ public partial class SchemaExport { return Task.FromCanceled(cancellationToken); } - return ExecuteAsync(null, execute, true, exportOutput, cancellationToken); + return DropAsync(exportOutput, execute, null, cancellationToken); + } + + //TODO 6.0: Make connection parameter optional: DbConnection connection = null + /// + /// Run the drop schema script + /// + /// if non-null, the ddl will be written to this TextWriter. + /// if the ddl should be executed against the Database. + /// Optional explicit connection. Required for multi-tenancy. + /// Must be an opened connection. The method doesn't close the connection. + /// A cancellation token that can be used to cancel the work + /// + /// This is a convenience method that calls and sets + /// the justDrop parameter to true. + /// + public Task DropAsync(TextWriter exportOutput, bool execute, DbConnection connection, CancellationToken cancellationToken = default(CancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return InitConnectionAndExecuteAsync(null, execute, true, connection, exportOutput, cancellationToken); } private async Task ExecuteInitializedAsync(Action scriptAction, bool execute, bool throwOnError, TextWriter exportOutput, @@ -228,14 +359,7 @@ public Task ExecuteAsync(bool useStdOut, bool execute, bool justDrop, DbConnecti } try { - if (useStdOut) - { - return ExecuteAsync(Console.WriteLine, execute, justDrop, connection, exportOutput, cancellationToken); - } - else - { - return ExecuteAsync(null, execute, justDrop, connection, exportOutput, cancellationToken); - } + return ExecuteAsync(GetAction(useStdOut), execute, justDrop, connection, exportOutput, cancellationToken); } catch (Exception ex) { @@ -319,14 +443,7 @@ public async Task ExecuteAsync(Action scriptAction, bool execute, bool j } try { - if (useStdOut) - { - return ExecuteAsync(Console.WriteLine, execute, justDrop, cancellationToken); - } - else - { - return ExecuteAsync(null, execute, justDrop, cancellationToken); - } + return InitConnectionAndExecuteAsync(GetAction(useStdOut), execute, justDrop, null, null, cancellationToken); } catch (Exception ex) { @@ -343,11 +460,19 @@ public async Task ExecuteAsync(Action scriptAction, bool execute, bool j return ExecuteAsync(scriptAction, execute, justDrop, null, cancellationToken); } - public async Task ExecuteAsync(Action scriptAction, bool execute, bool justDrop, TextWriter exportOutput, CancellationToken cancellationToken = default(CancellationToken)) + public Task ExecuteAsync(Action scriptAction, bool execute, bool justDrop, TextWriter exportOutput, CancellationToken cancellationToken = default(CancellationToken)) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + return InitConnectionAndExecuteAsync(scriptAction, execute, justDrop, null, exportOutput, cancellationToken); + } + + private async Task InitConnectionAndExecuteAsync(Action scriptAction, bool execute, bool justDrop, DbConnection connection, TextWriter exportOutput, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); await (InitializeAsync(cancellationToken)).ConfigureAwait(false); - DbConnection connection = null; TextWriter fileOutput = exportOutput; IConnectionProvider connectionProvider = null; @@ -358,8 +483,13 @@ public async Task ExecuteAsync(Action scriptAction, bool execute, bool j fileOutput = new StreamWriter(outputFile); } - if (execute) + if (execute && connection == null) { + if (_requireTenantConnection) + { + throw new ArgumentException("When Database multi-tenancy is enabled you need to provide explicit connection. Please use overload with connection parameter."); + } + var props = new Dictionary(); foreach (var de in dialect.DefaultProperties) { @@ -393,7 +523,7 @@ public async Task ExecuteAsync(Action scriptAction, bool execute, bool j } finally { - if (connection != null) + if (connectionProvider != null) { connectionProvider.CloseConnection(connection); connectionProvider.Dispose(); diff --git a/src/NHibernate/Cache/CacheKey.cs b/src/NHibernate/Cache/CacheKey.cs index 3b0a46d7266..f3f050175dd 100644 --- a/src/NHibernate/Cache/CacheKey.cs +++ b/src/NHibernate/Cache/CacheKey.cs @@ -20,6 +20,7 @@ public class CacheKey : IDeserializationCallback [NonSerialized] private int? _hashCode; private readonly ISessionFactoryImplementor _factory; + private readonly string _tenantIdentifier; /// /// Construct a new key for a collection or entity instance. @@ -30,28 +31,40 @@ public class CacheKey : IDeserializationCallback /// The Hibernate type mapping /// The entity or collection-role name. /// The session factory for which we are caching - public CacheKey(object id, IType type, string entityOrRoleName, ISessionFactoryImplementor factory) + /// + public CacheKey(object id, IType type, string entityOrRoleName, ISessionFactoryImplementor factory, string tenantIdentifier) { key = id; this.type = type; this.entityOrRoleName = entityOrRoleName; _factory = factory; + _tenantIdentifier = tenantIdentifier; _hashCode = GenerateHashCode(); } + //Since 5.3 + [Obsolete("Use constructor with tenantIdentifier")] + public CacheKey(object id, IType type, string entityOrRoleName, ISessionFactoryImplementor factory) + : this(id, type, entityOrRoleName, factory, null) + { + } + //Mainly for SysCache and Memcache public override String ToString() { // For Component the user can override ToString - return entityOrRoleName + '#' + key; + return + string.IsNullOrEmpty(_tenantIdentifier) + ? entityOrRoleName + "#" + key + : string.Join("#", entityOrRoleName, key, _tenantIdentifier); } public override bool Equals(object obj) { CacheKey that = obj as CacheKey; if (that == null) return false; - return entityOrRoleName.Equals(that.entityOrRoleName) && type.IsEqual(key, that.key); + return entityOrRoleName.Equals(that.entityOrRoleName) && type.IsEqual(key, that.key) && _tenantIdentifier == that._tenantIdentifier; } public override int GetHashCode() @@ -71,7 +84,14 @@ public void OnDeserialization(object sender) private int GenerateHashCode() { - return type.GetHashCode(key, _factory); + var hashCode = type.GetHashCode(key, _factory); + + if (_tenantIdentifier != null) + { + hashCode = (37 * hashCode) + _tenantIdentifier.GetHashCode(); + } + + return hashCode; } public object Key diff --git a/src/NHibernate/Cache/QueryKey.cs b/src/NHibernate/Cache/QueryKey.cs index c5f9eb17d1b..d336a58d323 100644 --- a/src/NHibernate/Cache/QueryKey.cs +++ b/src/NHibernate/Cache/QueryKey.cs @@ -21,6 +21,7 @@ public class QueryKey : IDeserializationCallback, IEquatable private readonly object[] _values; private readonly int _firstRow = RowSelection.NoValue; private readonly int _maxRows = RowSelection.NoValue; + private readonly string _tenantIdentifier; // Sets and dictionaries are populated last during deserialization, causing them to be potentially empty // during the deserialization callback. This causes them to be unreliable when used in hashcode or equals @@ -47,8 +48,9 @@ public class QueryKey : IDeserializationCallback, IEquatable /// The query parameters. /// The filters. /// The result transformer; should be null if data is not transformed before being cached. + /// Tenant identifier or null public QueryKey(ISessionFactoryImplementor factory, SqlString queryString, QueryParameters queryParameters, - ISet filters, CacheableResultTransformer customTransformer) + ISet filters, CacheableResultTransformer customTransformer, string tenantIdentifier) { _factory = factory; _sqlQueryString = queryString; @@ -70,10 +72,19 @@ public QueryKey(ISessionFactoryImplementor factory, SqlString queryString, Query _namedParameters = queryParameters.NamedParameters?.ToArray(); _filters = filters?.ToArray(); _customTransformer = customTransformer; + _tenantIdentifier = tenantIdentifier; _hashCode = ComputeHashCode(); } + //Since 5.3 + [Obsolete("Please use overload with tenantIdentifier")] + public QueryKey(ISessionFactoryImplementor factory, SqlString queryString, QueryParameters queryParameters, + ISet filters, CacheableResultTransformer customTransformer) + : this(factory, queryString, queryParameters, filters, customTransformer, null) + { + } + public CacheableResultTransformer ResultTransformer { get { return _customTransformer; } @@ -161,6 +172,11 @@ public bool Equals(QueryKey other) { return false; } + + if (_tenantIdentifier != other._tenantIdentifier) + { + return false; + } return true; } @@ -223,6 +239,10 @@ public int ComputeHashCode() result = 37 * result + (_customTransformer == null ? 0 : _customTransformer.GetHashCode()); result = 37 * result + _sqlQueryString.GetHashCode(); + if (_tenantIdentifier != null) + { + result = (37 * result) + _tenantIdentifier.GetHashCode(); + } return result; } } @@ -284,6 +304,11 @@ public override string ToString() buf.Append("; "); } + if (_tenantIdentifier != null) + { + buf.Append("; tenantIdentifier=").Append(_tenantIdentifier).Append("; "); + } + return buf.ToString(); } } diff --git a/src/NHibernate/Cfg/Environment.cs b/src/NHibernate/Cfg/Environment.cs index 7c19f5c6421..973657f803d 100644 --- a/src/NHibernate/Cfg/Environment.cs +++ b/src/NHibernate/Cfg/Environment.cs @@ -7,6 +7,7 @@ using NHibernate.Engine; using NHibernate.Linq; using NHibernate.Linq.Visitors; +using NHibernate.MultiTenancy; using NHibernate.Util; namespace NHibernate.Cfg @@ -385,6 +386,16 @@ public static string Version private static readonly Dictionary GlobalProperties = new Dictionary(); + /// + /// Strategy for multi-tenancy. + /// See also + public const string MultiTenancy = "multi_tenancy.strategy"; + + /// + /// Connection provider for given multi-tenancy strategy. Class name implementing IMultiTenancyConnectionProvider. + /// + public const string MultiTenancyConnectionProvider = "multi_tenancy.connection_provider"; + private static IBytecodeProvider BytecodeProviderInstance; private static bool EnableReflectionOptimizer; diff --git a/src/NHibernate/Cfg/Loquacious/DbIntegrationConfigurationProperties.cs b/src/NHibernate/Cfg/Loquacious/DbIntegrationConfigurationProperties.cs index 1901e92b3ce..f19619bc251 100644 --- a/src/NHibernate/Cfg/Loquacious/DbIntegrationConfigurationProperties.cs +++ b/src/NHibernate/Cfg/Loquacious/DbIntegrationConfigurationProperties.cs @@ -4,6 +4,7 @@ using NHibernate.Driver; using NHibernate.Exceptions; using NHibernate.Linq.Visitors; +using NHibernate.MultiTenancy; using NHibernate.Transaction; namespace NHibernate.Cfg.Loquacious @@ -153,6 +154,16 @@ public void PreTransformerRegistrar() where TRegistrar : IExpression configuration.SetProperty(Environment.PreTransformerRegistrar, typeof(TRegistrar).AssemblyQualifiedName); } + public MultiTenancy.MultiTenancyStrategy MultiTenancy + { + set { configuration.SetProperty(Environment.MultiTenancy, value.ToString()); } + } + + public void MultiTenancyConnectionProvider() where TProvider : IMultiTenancyConnectionProvider + { + configuration.SetProperty(Environment.MultiTenancyConnectionProvider, typeof(TProvider).AssemblyQualifiedName); + } + #endregion } } diff --git a/src/NHibernate/Cfg/Settings.cs b/src/NHibernate/Cfg/Settings.cs index 080a773b47c..f973766013e 100644 --- a/src/NHibernate/Cfg/Settings.cs +++ b/src/NHibernate/Cfg/Settings.cs @@ -10,6 +10,7 @@ using NHibernate.Hql; using NHibernate.Linq.Functions; using NHibernate.Linq.Visitors; +using NHibernate.MultiTenancy; using NHibernate.Transaction; namespace NHibernate.Cfg @@ -207,5 +208,9 @@ internal string GetFullCacheRegionName(string name) return prefix + '.' + name; return name; } + + public MultiTenancyStrategy MultiTenancyStrategy { get; internal set; } + + public IMultiTenancyConnectionProvider MultiTenancyConnectionProvider { get; internal set; } } } diff --git a/src/NHibernate/Cfg/SettingsFactory.cs b/src/NHibernate/Cfg/SettingsFactory.cs index 4c1aec2ff04..f1f1a0ffb29 100644 --- a/src/NHibernate/Cfg/SettingsFactory.cs +++ b/src/NHibernate/Cfg/SettingsFactory.cs @@ -12,6 +12,7 @@ using NHibernate.Linq; using NHibernate.Linq.Functions; using NHibernate.Linq.Visitors; +using NHibernate.MultiTenancy; using NHibernate.Transaction; using NHibernate.Util; @@ -325,6 +326,14 @@ public Settings BuildSettings(IDictionary properties) log.Debug("Track session id: " + EnabledDisabled(trackSessionId)); settings.TrackSessionId = trackSessionId; + var multiTenancyStrategy = PropertiesHelper.GetEnum(Environment.MultiTenancy, properties, MultiTenancyStrategy.None); + settings.MultiTenancyStrategy = multiTenancyStrategy; + if (multiTenancyStrategy != MultiTenancyStrategy.None) + { + log.Debug("multi-tenancy strategy : " + multiTenancyStrategy); + settings.MultiTenancyConnectionProvider = CreateMultiTenancyConnectionProvider(properties); + } + return settings; } @@ -411,6 +420,29 @@ private static System.Type CreateLinqQueryProviderType(IDictionary properties) + { + string className = PropertiesHelper.GetString( + Environment.MultiTenancyConnectionProvider, + properties, + null); + log.Info("Multi-tenancy connection provider: {0}", className); + if (className == null) + { + return null; + } + + try + { + return (IMultiTenancyConnectionProvider) + Environment.ObjectsFactory.CreateInstance(System.Type.GetType(className, true)); + } + catch (Exception cnfe) + { + throw new HibernateException("could not find Multi-tenancy connection provider class: " + className, cnfe); + } + } + private static ITransactionFactory CreateTransactionFactory(IDictionary properties) { string className = PropertiesHelper.GetString( diff --git a/src/NHibernate/Connection/ConnectionProvider.cs b/src/NHibernate/Connection/ConnectionProvider.cs index e30c2fdf21d..19293b5ca0e 100644 --- a/src/NHibernate/Connection/ConnectionProvider.cs +++ b/src/NHibernate/Connection/ConnectionProvider.cs @@ -118,7 +118,8 @@ protected virtual void ConfigureDriver(IDictionary settings) /// The for the /// to connect to the database. /// - protected virtual string ConnectionString + //TODO 6.0: Make public + protected internal virtual string ConnectionString { get { return connString; } } @@ -138,7 +139,20 @@ public IDriver Driver /// Get an open . /// /// An open . - public abstract DbConnection GetConnection(); + public virtual DbConnection GetConnection() + { + return GetConnection(ConnectionString); + } + + //TODO 6.0: Make abstract + /// + /// Gets an open for given connectionString + /// + /// An open . + public virtual DbConnection GetConnection(string connectionString) + { + throw new NotImplementedException("This method must be overriden."); + } #region IDisposable Members diff --git a/src/NHibernate/Connection/DriverConnectionProvider.cs b/src/NHibernate/Connection/DriverConnectionProvider.cs index e3c43f4c0d0..94503d0ca75 100644 --- a/src/NHibernate/Connection/DriverConnectionProvider.cs +++ b/src/NHibernate/Connection/DriverConnectionProvider.cs @@ -30,13 +30,13 @@ public override void CloseConnection(DbConnection conn) /// /// If there is any problem creating or opening the . /// - public override DbConnection GetConnection() + public override DbConnection GetConnection(string connectionString) { log.Debug("Obtaining DbConnection from Driver"); var conn = Driver.CreateConnection(); try { - conn.ConnectionString = ConnectionString; + conn.ConnectionString = connectionString; conn.Open(); } catch (Exception) @@ -48,4 +48,4 @@ public override DbConnection GetConnection() return conn; } } -} \ No newline at end of file +} diff --git a/src/NHibernate/Connection/IConnectionAccess.cs b/src/NHibernate/Connection/IConnectionAccess.cs new file mode 100644 index 00000000000..d76f6f5194f --- /dev/null +++ b/src/NHibernate/Connection/IConnectionAccess.cs @@ -0,0 +1,32 @@ +using System.Data.Common; + +namespace NHibernate.Connection +{ + //JdbcConnectionAccess.java in hibernate + /// + /// Provides centralized access to connections. Centralized to hide the complexity of accounting for contextual + /// (multi-tenant) versus non-contextual access. + /// Implementation must be serializable + /// + public partial interface IConnectionAccess + { + /// + /// The connection string of the database connection. + /// + string ConnectionString { get; } + + //ObtainConnection in hibernate + /// + /// Gets the database connection. + /// + /// The database connection. + DbConnection GetConnection(); + + //ReleaseConnection in hibernate + /// + /// Closes the given database connection. + /// + /// The connection to close. + void CloseConnection(DbConnection connection); + } +} diff --git a/src/NHibernate/Connection/IConnectionProvider.cs b/src/NHibernate/Connection/IConnectionProvider.cs index 2657c4090a7..d40943915ea 100644 --- a/src/NHibernate/Connection/IConnectionProvider.cs +++ b/src/NHibernate/Connection/IConnectionProvider.cs @@ -2,9 +2,25 @@ using System.Collections.Generic; using System.Data.Common; using NHibernate.Driver; +using NHibernate.Util; namespace NHibernate.Connection { + //6.0 TODO: Merge into IConnectionProvider + public static partial class ConnectionProviderExtensions + { + internal static DbConnection GetConnection(this IConnectionProvider connectionProvider, string connectionString) + { + return ReflectHelper.CastOrThrow(connectionProvider, "open connection by connectionString").GetConnection(connectionString); + } + + //6.0 TODO: Expose as ConnectionString property + public static string GetConnectionString(this IConnectionProvider connectionProvider) + { + return ReflectHelper.CastOrThrow(connectionProvider, "retrieve connectionString").ConnectionString; + } + } + /// /// A strategy for obtaining ADO.NET . /// @@ -42,4 +58,4 @@ public partial interface IConnectionProvider : IDisposable /// An open . DbConnection GetConnection(); } -} \ No newline at end of file +} diff --git a/src/NHibernate/Connection/UserSuppliedConnectionProvider.cs b/src/NHibernate/Connection/UserSuppliedConnectionProvider.cs index cbdf5bb3f16..ff15a643252 100644 --- a/src/NHibernate/Connection/UserSuppliedConnectionProvider.cs +++ b/src/NHibernate/Connection/UserSuppliedConnectionProvider.cs @@ -40,7 +40,7 @@ public override void CloseConnection(DbConnection conn) /// Thrown when this method is called. User is responsible for creating /// s. /// - public override DbConnection GetConnection() + public override DbConnection GetConnection(string connectionString) { throw new InvalidOperationException("The user must provide an ADO.NET connection - NHibernate is not creating it."); } diff --git a/src/NHibernate/Engine/ISessionImplementor.cs b/src/NHibernate/Engine/ISessionImplementor.cs index 84b30da88ae..9a0cb58b77a 100644 --- a/src/NHibernate/Engine/ISessionImplementor.cs +++ b/src/NHibernate/Engine/ISessionImplementor.cs @@ -18,9 +18,24 @@ namespace NHibernate.Engine { - // 6.0 TODO: Convert to interface methods, excepted SwitchCacheMode - internal static partial class SessionImplementorExtensions + // 6.0 TODO: Convert to interface methods, excepted SwitchCacheMode, GetTenantIdentifier + public static partial class SessionImplementorExtensions { + //NOTE: Keep it as extension + /// + /// Obtain the tenant identifier associated with this session. + /// + /// The tenant identifier associated with this session or null + public static string GetTenantIdentifier(this ISessionImplementor session) + { + if (session is AbstractSessionImpl sessionImpl) + { + return sessionImpl.TenantConfiguration?.TenantIdentifier; + } + + return null; + } + /// /// Instantiate the entity class, initializing with the given identifier /// diff --git a/src/NHibernate/ISessionBuilder.cs b/src/NHibernate/ISessionBuilder.cs index 8fa6112914d..b1c5c7b4de3 100644 --- a/src/NHibernate/ISessionBuilder.cs +++ b/src/NHibernate/ISessionBuilder.cs @@ -1,14 +1,41 @@ using System.Data.Common; using NHibernate.Connection; +using NHibernate.Impl; +using NHibernate.MultiTenancy; +using NHibernate.Util; namespace NHibernate { + public static class SessionBuilderExtensions + { + /// + /// Associates session with given tenantIdentifier when multi-tenancy is enabled. + /// See + /// + public static T Tenant(this T builder, string tenantIdentifier) where T : ISessionBuilder + { + return builder.Tenant(new TenantConfiguration(tenantIdentifier)); + } + + //TODO 6.0: Merge into ISessionBuilder + /// + /// Associates session with given tenantConfig when multi-tenancy is enabled. + /// See + /// + public static T Tenant(this T builder, TenantConfiguration tenantConfig) where T: ISessionBuilder + { + ReflectHelper.CastOrThrow(builder, "multi tenancy").TenantConfiguration = tenantConfig; + return builder; + } + } + // NH specific: Java does not require this, it looks as still having a better covariance support. /// /// Represents a consolidation of all session creation options into a builder style delegate. /// public interface ISessionBuilder : ISessionBuilder { } + //TODO 6.0: Make T covariant ISessionBuilder -> ISessionBuilder /// /// Represents a consolidation of all session creation options into a builder style delegate. /// @@ -82,4 +109,4 @@ public interface ISessionBuilder where T : ISessionBuilder /// , for method chaining. T FlushMode(FlushMode flushMode); } -} \ No newline at end of file +} diff --git a/src/NHibernate/ISessionFactory.cs b/src/NHibernate/ISessionFactory.cs index 8e85f6258d0..4dee98fde1d 100644 --- a/src/NHibernate/ISessionFactory.cs +++ b/src/NHibernate/ISessionFactory.cs @@ -7,12 +7,47 @@ using NHibernate.Impl; using NHibernate.Metadata; using NHibernate.Stat; +using NHibernate.Util; namespace NHibernate { // 6.0 TODO: move below methods directly in ISessionFactory then remove SessionFactoryExtension public static partial class SessionFactoryExtension { + /// + /// Evict an entry from the second-level cache. This method occurs outside + /// of any transaction; it performs an immediate "hard" remove, so does not respect + /// any transaction isolation semantics of the usage strategy. Use with care. + /// + /// The session factory. + /// The name of the entity to evict. + /// + /// Tenant identifier + public static void EvictEntity(this ISessionFactory factory, string entityName, object id, string tenantIdentifier) + { + if (tenantIdentifier == null) + factory.EvictEntity(entityName, id); + + ReflectHelper.CastOrThrow(factory, "multi-tenancy").EvictEntity(entityName, id, tenantIdentifier); + } + + /// + /// Evict an entry from the process-level cache. This method occurs outside + /// of any transaction; it performs an immediate "hard" remove, so does not respect + /// any transaction isolation semantics of the usage strategy. Use with care. + /// + /// The session factory. + /// Collection role name. + /// Collection id + /// Tenant identifier + public static void EvictCollection(this ISessionFactory factory, string roleName, object id, string tenantIdentifier) + { + if (tenantIdentifier == null) + factory.EvictCollection(roleName, id); + + ReflectHelper.CastOrThrow(factory, "multi-tenancy").EvictCollection(roleName, id, tenantIdentifier); + } + /// /// Evict all entries from the process-level cache. This method occurs outside /// of any transaction; it performs an immediate "hard" remove, so does not respect diff --git a/src/NHibernate/IStatelessSessionBuilder.cs b/src/NHibernate/IStatelessSessionBuilder.cs index 77641ad9e4e..a8d2ecfcac7 100644 --- a/src/NHibernate/IStatelessSessionBuilder.cs +++ b/src/NHibernate/IStatelessSessionBuilder.cs @@ -1,8 +1,34 @@ using System.Data.Common; using NHibernate.Connection; +using NHibernate.Impl; +using NHibernate.MultiTenancy; +using NHibernate.Util; namespace NHibernate { + public static class StatelessSessionBuilderExtensions + { + /// + /// Associates stateless session with given tenantIdentifier when multi-tenancy is enabled. + /// See + /// + public static T Tenant(this T builder, string tenantIdentifier) where T : ISessionBuilder + { + return builder.Tenant(new TenantConfiguration(tenantIdentifier)); + } + + //TODO 6.0: Merge into IStatelessSessionBuilder + /// + /// Associates stateless session with given tenantConfig when multi-tenancy is enabled. + /// See + /// + public static IStatelessSessionBuilder Tenant(this IStatelessSessionBuilder builder, TenantConfiguration tenantConfig) + { + ReflectHelper.CastOrThrow(builder, "multi tenancy").TenantConfiguration = tenantConfig; + return builder; + } + } + // NH different implementation: will not try to support covariant return type for specializations // until it is needed. /// @@ -38,7 +64,5 @@ public interface IStatelessSessionBuilder /// enlisted in ambient transaction. /// , for method chaining. IStatelessSessionBuilder AutoJoinTransaction(bool autoJoinTransaction); - - // NH remark: seems a bit overkill for now. On Hibernate side, they have at least another option: the tenant. } -} \ No newline at end of file +} diff --git a/src/NHibernate/Impl/AbstractSessionImpl.cs b/src/NHibernate/Impl/AbstractSessionImpl.cs index 583b183b75e..d2fd81b1b72 100644 --- a/src/NHibernate/Impl/AbstractSessionImpl.cs +++ b/src/NHibernate/Impl/AbstractSessionImpl.cs @@ -7,6 +7,7 @@ using NHibernate.AdoNet; using NHibernate.Cache; using NHibernate.Collection; +using NHibernate.Connection; using NHibernate.Engine; using NHibernate.Engine.Query; using NHibernate.Engine.Query.Sql; @@ -17,6 +18,7 @@ using NHibernate.Loader.Custom; using NHibernate.Loader.Custom.Sql; using NHibernate.Multi; +using NHibernate.MultiTenancy; using NHibernate.Persister.Entity; using NHibernate.Transaction; using NHibernate.Type; @@ -57,6 +59,31 @@ public ITransactionContext TransactionContext internal AbstractSessionImpl() { } + private TenantConfiguration ValidateTenantConfiguration(ISessionFactoryImplementor factory, ISessionCreationOptions options) + { + if (factory.Settings.MultiTenancyStrategy == MultiTenancyStrategy.None) + return null; + + var tenantConfiguration = ReflectHelper.CastOrThrow(options, "multi-tenancy").TenantConfiguration; + + if (string.IsNullOrEmpty(tenantConfiguration?.TenantIdentifier)) + { + throw new ArgumentException( + $"Tenant configuration with `{nameof(TenantConfiguration.TenantIdentifier)}` defined is required for multi-tenancy.", + nameof(tenantConfiguration)); + } + + if (_factory.Settings.MultiTenancyConnectionProvider == null) + { + throw new ArgumentException( + $"`{nameof(IMultiTenancyConnectionProvider)}` is required for multi-tenancy." + + $" Provide it via '{Cfg.Environment.MultiTenancyConnectionProvider}` session factory setting." + + $" You can use `{nameof(AbstractMultiTenancyConnectionProvider)}` as a base."); + } + + return tenantConfiguration; + } + protected internal AbstractSessionImpl(ISessionFactoryImplementor factory, ISessionCreationOptions options) { SessionId = factory.Settings.TrackSessionId ? Guid.NewGuid() : Guid.Empty; @@ -67,6 +94,8 @@ protected internal AbstractSessionImpl(ISessionFactoryImplementor factory, ISess _flushMode = options.InitialSessionFlushMode; Interceptor = options.SessionInterceptor ?? EmptyInterceptor.Instance; + _tenantConfiguration = ValidateTenantConfiguration(factory, options); + if (options is ISharedSessionCreationOptions sharedOptions && sharedOptions.IsTransactionCoordinatorShared) { // NH specific implementation: need to port Hibernate transaction management. @@ -84,7 +113,10 @@ protected internal AbstractSessionImpl(ISessionFactoryImplementor factory, ISess options.UserSuppliedConnection, options.SessionConnectionReleaseMode, Interceptor, - options.ShouldAutoJoinTransaction); + options.ShouldAutoJoinTransaction, + _tenantConfiguration == null + ? new NonContextualConnectionAccess(_factory) + : _factory.Settings.MultiTenancyConnectionProvider.GetConnectionAccess(_tenantConfiguration, _factory)); } } } @@ -109,7 +141,7 @@ public EntityKey GenerateEntityKey(object id, IEntityPersister persister) public CacheKey GenerateCacheKey(object id, IType type, string entityOrRoleName) { - return new CacheKey(id, type, entityOrRoleName, Factory); + return new CacheKey(id, type, entityOrRoleName, Factory, TenantIdentifier); } public ISessionFactoryImplementor Factory @@ -388,6 +420,7 @@ public IDisposable BeginContext() } private ProcessHelper _processHelper = new ProcessHelper(); + private TenantConfiguration _tenantConfiguration; [Serializable] private sealed class ProcessHelper : IDisposable @@ -465,6 +498,15 @@ protected bool IsAlreadyDisposed /// public virtual bool TransactionInProgress => ConnectionManager.IsInActiveTransaction; + //6.0 TODO Add to ISessionImplementor + public TenantConfiguration TenantConfiguration + { + get => _tenantConfiguration; + protected set => _tenantConfiguration = value; + } + + public string TenantIdentifier => _tenantConfiguration?.TenantIdentifier; + #endregion protected internal void SetClosed() diff --git a/src/NHibernate/Impl/ISessionCreationOptions.cs b/src/NHibernate/Impl/ISessionCreationOptions.cs index 0e11afa2352..3dbd0632354 100644 --- a/src/NHibernate/Impl/ISessionCreationOptions.cs +++ b/src/NHibernate/Impl/ISessionCreationOptions.cs @@ -1,7 +1,14 @@ using System.Data.Common; +using NHibernate.MultiTenancy; namespace NHibernate.Impl { + public interface ISessionCreationOptionsWithMultiTenancy + { + //TODO 6.0: Merge to ISessionCreationOptions without setter + TenantConfiguration TenantConfiguration { get; set; } + } + /// /// Options for session creation. /// @@ -23,4 +30,4 @@ public interface ISessionCreationOptions // Todo: port PhysicalConnectionHandlingMode ConnectionReleaseMode SessionConnectionReleaseMode { get; } } -} \ No newline at end of file +} diff --git a/src/NHibernate/Impl/NonContextualConnectionAccess.cs b/src/NHibernate/Impl/NonContextualConnectionAccess.cs new file mode 100644 index 00000000000..2f3e2670de9 --- /dev/null +++ b/src/NHibernate/Impl/NonContextualConnectionAccess.cs @@ -0,0 +1,36 @@ +using System; +using System.Data.Common; +using NHibernate.Connection; +using NHibernate.Engine; + +namespace NHibernate.Impl +{ + /// + /// A non contextual connection access used when multi-tenancy is not enabled. + /// + [Serializable] + partial class NonContextualConnectionAccess : IConnectionAccess + { + private readonly ISessionFactoryImplementor _sessionFactory; + + public NonContextualConnectionAccess(ISessionFactoryImplementor connectionProvider) + { + _sessionFactory = connectionProvider; + } + + /// + public string ConnectionString => _sessionFactory.ConnectionProvider.GetConnectionString(); + + /// + public DbConnection GetConnection() + { + return _sessionFactory.ConnectionProvider.GetConnection(); + } + + /// + public void CloseConnection(DbConnection connection) + { + _sessionFactory.ConnectionProvider.CloseConnection(connection); + } + } +} diff --git a/src/NHibernate/Impl/SessionFactoryImpl.cs b/src/NHibernate/Impl/SessionFactoryImpl.cs index f381b7162d0..a4b72cea96d 100644 --- a/src/NHibernate/Impl/SessionFactoryImpl.cs +++ b/src/NHibernate/Impl/SessionFactoryImpl.cs @@ -21,6 +21,7 @@ using NHibernate.Id; using NHibernate.Mapping; using NHibernate.Metadata; +using NHibernate.MultiTenancy; using NHibernate.Persister; using NHibernate.Persister.Collection; using NHibernate.Persister.Entity; @@ -932,16 +933,7 @@ public void Close() public void Evict(System.Type persistentClass, object id) { - IEntityPersister p = GetEntityPersister(persistentClass.FullName); - if (p.HasCache) - { - if (log.IsDebugEnabled()) - { - log.Debug("evicting second-level cache: {0}", MessageHelper.InfoString(p, id)); - } - CacheKey ck = GenerateCacheKeyForEvict(id, p.IdentifierType, p.RootEntityName); - p.Cache.Remove(ck); - } + EvictEntity(persistentClass.FullName, id); } public void Evict(System.Type persistentClass) @@ -994,34 +986,45 @@ public void EvictEntity(IEnumerable entityNames) } public void EvictEntity(string entityName, object id) + { + EvictEntity(entityName, id, null); + } + + public void EvictEntity(string entityName, object id, string tenantIdentifier) { IEntityPersister p = GetEntityPersister(entityName); if (p.HasCache) { if (log.IsDebugEnabled()) { - log.Debug("evicting second-level cache: {0}", MessageHelper.InfoString(p, id, this)); + LogEvict(tenantIdentifier, MessageHelper.InfoString(p, id, this)); } - CacheKey cacheKey = GenerateCacheKeyForEvict(id, p.IdentifierType, p.RootEntityName); + CacheKey cacheKey = GenerateCacheKeyForEvict(id, p.IdentifierType, p.RootEntityName, tenantIdentifier); p.Cache.Remove(cacheKey); } } public void EvictCollection(string roleName, object id) + { + EvictCollection(roleName, id, null); + } + + public void EvictCollection(string roleName, object id, string tenantIdentifier) { ICollectionPersister p = GetCollectionPersister(roleName); if (p.HasCache) { if (log.IsDebugEnabled()) { - log.Debug("evicting second-level cache: {0}", MessageHelper.CollectionInfoString(p, id)); + LogEvict(tenantIdentifier, MessageHelper.CollectionInfoString(p, id)); } - CacheKey ck = GenerateCacheKeyForEvict(id, p.KeyType, p.Role); + + CacheKey ck = GenerateCacheKeyForEvict(id, p.KeyType, p.Role, tenantIdentifier); p.Cache.Remove(ck); } } - private CacheKey GenerateCacheKeyForEvict(object id, IType type, string entityOrRoleName) + private CacheKey GenerateCacheKeyForEvict(object id, IType type, string entityOrRoleName, string tenantIdentifier) { // if there is a session context, use that to generate the key. if (CurrentSessionContext != null) @@ -1032,7 +1035,12 @@ private CacheKey GenerateCacheKeyForEvict(object id, IType type, string entityOr .GenerateCacheKey(id, type, entityOrRoleName); } - return new CacheKey(id, type, entityOrRoleName, this); + if (settings.MultiTenancyStrategy != MultiTenancyStrategy.None && tenantIdentifier == null) + { + throw new ArgumentException("Use overload with tenantIdentifier or initialize CurrentSessionContext."); + } + + return new CacheKey(id, type, entityOrRoleName, this, tenantIdentifier); } public void EvictCollection(string roleName) @@ -1263,6 +1271,17 @@ public QueryPlanCache QueryPlanCache #endregion + private static void LogEvict(string tenantIdentifier, string infoString) + { + if (string.IsNullOrEmpty(tenantIdentifier)) + { + log.Debug("evicting second-level cache: {0}", infoString); + return; + } + + log.Debug("evicting second-level cache for tenant '{1}': {0}", infoString, tenantIdentifier); + } + private void Init() { statistics = new StatisticsImpl(this); @@ -1423,7 +1442,7 @@ public SessionBuilderImpl(SessionFactoryImpl sessionFactory) : base(sessionFacto } } - internal class SessionBuilderImpl : ISessionBuilder, ISessionCreationOptions where T : ISessionBuilder + internal class SessionBuilderImpl : ISessionBuilder, ISessionCreationOptions, ISessionCreationOptionsWithMultiTenancy where T : ISessionBuilder { // NH specific: implementing return type covariance with interface is a mess in .Net. private T _this; @@ -1532,6 +1551,13 @@ public virtual T FlushMode(FlushMode flushMode) _flushMode = flushMode; return _this; } + + public TenantConfiguration TenantConfiguration + { + get; + //TODO 6.0: Make protected + set; + } } // NH specific: implementing return type covariance with interface is a mess in .Net. @@ -1543,7 +1569,7 @@ public StatelessSessionBuilderImpl(SessionFactoryImpl sessionFactory) : base(ses } } - internal class StatelessSessionBuilderImpl : IStatelessSessionBuilder, ISessionCreationOptions where T : IStatelessSessionBuilder + internal class StatelessSessionBuilderImpl : IStatelessSessionBuilder, ISessionCreationOptionsWithMultiTenancy, ISessionCreationOptions where T : IStatelessSessionBuilder { // NH specific: implementing return type covariance with interface is a mess in .Net. private T _this; @@ -1584,6 +1610,13 @@ public IStatelessSessionBuilder AutoJoinTransaction(bool autoJoinTransaction) public IInterceptor SessionInterceptor => EmptyInterceptor.Instance; public ConnectionReleaseMode SessionConnectionReleaseMode => ConnectionReleaseMode.AfterTransaction; + + public TenantConfiguration TenantConfiguration + { + get; + //TODO 6.0: Make protected + set; + } } } } diff --git a/src/NHibernate/Impl/SessionImpl.cs b/src/NHibernate/Impl/SessionImpl.cs index 341a8df46aa..5104aea9f27 100644 --- a/src/NHibernate/Impl/SessionImpl.cs +++ b/src/NHibernate/Impl/SessionImpl.cs @@ -16,6 +16,7 @@ using NHibernate.Intercept; using NHibernate.Loader.Criteria; using NHibernate.Loader.Custom; +using NHibernate.MultiTenancy; using NHibernate.Persister.Collection; using NHibernate.Persister.Entity; using NHibernate.Proxy; @@ -104,6 +105,7 @@ private SessionImpl(SerializationInfo info, StreamingContext context) enabledFilterNames = (List)info.GetValue("enabledFilterNames", typeof(List)); ConnectionManager = (ConnectionManager)info.GetValue("connectionManager", typeof(ConnectionManager)); + TenantConfiguration = info.GetValue(nameof(TenantConfiguration)); } /// @@ -143,6 +145,7 @@ void ISerializable.GetObjectData(SerializationInfo info, StreamingContext contex info.AddValue("enabledFilterNames", enabledFilterNames, typeof(List)); info.AddValue("connectionManager", ConnectionManager, typeof(ConnectionManager)); + info.AddValue(nameof(TenantConfiguration), TenantConfiguration); } #endregion @@ -2412,6 +2415,7 @@ public SharedSessionBuilderImpl(SessionImpl session) : base((SessionFactoryImpl)session.Factory) { _session = session; + TenantConfiguration = session.TenantConfiguration; SetSelf(this); } @@ -2464,6 +2468,7 @@ public SharedStatelessSessionBuilderImpl(SessionImpl session) : base((SessionFactoryImpl)session.Factory) { _session = session; + TenantConfiguration = session.TenantConfiguration; SetSelf(this); } diff --git a/src/NHibernate/Loader/Loader.cs b/src/NHibernate/Loader/Loader.cs index 49fd0974e38..8069cc9a544 100644 --- a/src/NHibernate/Loader/Loader.cs +++ b/src/NHibernate/Loader/Loader.cs @@ -1881,7 +1881,7 @@ internal QueryKey GenerateQueryKey(ISessionImplementor session, QueryParameters { ISet filterKeys = FilterKey.CreateFilterKeys(session.EnabledFilters); return new QueryKey(Factory, SqlString, queryParameters, filterKeys, - CreateCacheableResultTransformer(queryParameters)); + CreateCacheableResultTransformer(queryParameters), session.GetTenantIdentifier()); } private CacheableResultTransformer CreateCacheableResultTransformer(QueryParameters queryParameters) diff --git a/src/NHibernate/MultiTenancy/AbstractMultiTenancyConnectionProvider.cs b/src/NHibernate/MultiTenancy/AbstractMultiTenancyConnectionProvider.cs new file mode 100644 index 00000000000..1c19f8c0018 --- /dev/null +++ b/src/NHibernate/MultiTenancy/AbstractMultiTenancyConnectionProvider.cs @@ -0,0 +1,61 @@ +using System; +using System.Data.Common; +using NHibernate.Connection; +using NHibernate.Engine; + +namespace NHibernate.MultiTenancy +{ + /// + /// Base implementation for multi-tenancy strategy. + /// + [Serializable] + public abstract partial class AbstractMultiTenancyConnectionProvider : IMultiTenancyConnectionProvider + { + /// + public IConnectionAccess GetConnectionAccess(TenantConfiguration tenantConfiguration, ISessionFactoryImplementor sessionFactory) + { + var tenantConnectionString = GetTenantConnectionString(tenantConfiguration, sessionFactory); + if (string.IsNullOrEmpty(tenantConnectionString)) + { + throw new HibernateException($"Tenant '{tenantConfiguration.TenantIdentifier}' connection string is empty."); + } + + return new ContextualConnectionAccess(tenantConnectionString, sessionFactory); + } + + /// + /// Gets the connection string for the given tenant configuration. + /// + /// The tenant configuration. + /// The session factory. + /// The connection string for the tenant. + protected abstract string GetTenantConnectionString(TenantConfiguration tenantConfiguration, ISessionFactoryImplementor sessionFactory); + + [Serializable] + partial class ContextualConnectionAccess : IConnectionAccess + { + private readonly ISessionFactoryImplementor _sessionFactory; + + public ContextualConnectionAccess(string connectionString, ISessionFactoryImplementor sessionFactory) + { + ConnectionString = connectionString; + _sessionFactory = sessionFactory; + } + + /// + public string ConnectionString { get; } + + /// + public DbConnection GetConnection() + { + return _sessionFactory.ConnectionProvider.GetConnection(ConnectionString); + } + + /// + public void CloseConnection(DbConnection connection) + { + _sessionFactory.ConnectionProvider.CloseConnection(connection); + } + } + } +} diff --git a/src/NHibernate/MultiTenancy/IMultiTenancyConnectionProvider.cs b/src/NHibernate/MultiTenancy/IMultiTenancyConnectionProvider.cs new file mode 100644 index 00000000000..045e8f0fdc0 --- /dev/null +++ b/src/NHibernate/MultiTenancy/IMultiTenancyConnectionProvider.cs @@ -0,0 +1,20 @@ +using NHibernate.Connection; +using NHibernate.Engine; + +namespace NHibernate.MultiTenancy +{ + /// + /// A specialized Connection provider contract used when the application is using multi-tenancy support requiring + /// tenant aware connections. + /// + public interface IMultiTenancyConnectionProvider + { + /// + /// Gets the tenant connection access. + /// + /// The tenant configuration. + /// The session factory. + /// The tenant connection access. + IConnectionAccess GetConnectionAccess(TenantConfiguration tenantConfiguration, ISessionFactoryImplementor sessionFactory); + } +} diff --git a/src/NHibernate/MultiTenancy/MultiTenancyStrategy.cs b/src/NHibernate/MultiTenancy/MultiTenancyStrategy.cs new file mode 100644 index 00000000000..cca618ad39a --- /dev/null +++ b/src/NHibernate/MultiTenancy/MultiTenancyStrategy.cs @@ -0,0 +1,28 @@ +namespace NHibernate.MultiTenancy +{ + /// + /// Strategy for multi-tenancy + /// + /// + public enum MultiTenancyStrategy + { + /// + /// No multi-tenancy + /// + None, + + /// + /// Multi-tenancy implemented as separate database per tenant. + /// + Database, +// /// +// /// Multi-tenancy implemented by use of discriminator columns. +// /// +// Discriminator, +// +// /// +// /// Multi-tenancy implemented as separate schemas. +// /// +// Schema, + } +} diff --git a/src/NHibernate/MultiTenancy/TenantConfiguration.cs b/src/NHibernate/MultiTenancy/TenantConfiguration.cs new file mode 100644 index 00000000000..b7fee880437 --- /dev/null +++ b/src/NHibernate/MultiTenancy/TenantConfiguration.cs @@ -0,0 +1,23 @@ +using System; + +namespace NHibernate.MultiTenancy +{ + /// + /// Tenant specific configuration. + /// This class can be used as base class for user complex tenant configurations. + /// + [Serializable] + public class TenantConfiguration + { + /// + /// Tenant identifier must uniquely identify tenant + /// Note: Among other things this value is used for data separation between tenants in cache so not unique value will leak data to other tenants + /// + public string TenantIdentifier { get; } + + public TenantConfiguration(string tenantIdentifier) + { + TenantIdentifier = tenantIdentifier ?? throw new ArgumentNullException(nameof(tenantIdentifier)); + } + } +} diff --git a/src/NHibernate/Tool/hbm2ddl/SchemaExport.cs b/src/NHibernate/Tool/hbm2ddl/SchemaExport.cs index 2fe8c652109..1cdd27614fe 100644 --- a/src/NHibernate/Tool/hbm2ddl/SchemaExport.cs +++ b/src/NHibernate/Tool/hbm2ddl/SchemaExport.cs @@ -7,6 +7,7 @@ using NHibernate.AdoNet.Util; using NHibernate.Cfg; using NHibernate.Connection; +using NHibernate.MultiTenancy; using NHibernate.Util; using Environment=NHibernate.Cfg.Environment; @@ -31,6 +32,7 @@ public partial class SchemaExport private IFormatter formatter; private string delimiter; private string outputFile; + private bool _requireTenantConnection; /// /// Create a schema exported for a given Configuration @@ -68,6 +70,7 @@ private void Initialize() dropSQL = cfg.GenerateDropSchemaScript(dialect); createSQL = cfg.GenerateSchemaCreationScript(dialect); formatter = (PropertiesHelper.GetBoolean(Environment.FormatSql, configProperties, true) ? FormatStyle.Ddl : FormatStyle.None).Formatter; + _requireTenantConnection = PropertiesHelper.GetEnum(Environment.MultiTenancy, configProperties, MultiTenancyStrategy.None) == MultiTenancyStrategy.Database; wasInitialized = true; } @@ -93,6 +96,7 @@ public SchemaExport SetDelimiter(string delimiter) return this; } + //TODO 6.0: Remove (replaced by method with optional connection parameter) /// /// Run the schema creation script /// @@ -104,9 +108,27 @@ public SchemaExport SetDelimiter(string delimiter) /// public void Create(bool useStdOut, bool execute) { - Execute(useStdOut, execute, false); + Create(useStdOut, execute, null); } + //TODO 6.0: Make connection parameter optional: DbConnection connection = null + /// + /// Run the schema creation script + /// + /// if the ddl should be outputted in the Console. + /// if the ddl should be executed against the Database. + /// Optional explicit connection. Required for multi-tenancy. + /// Must be an opened connection. The method doesn't close the connection. + /// + /// This is a convenience method that calls and sets + /// the justDrop parameter to false. + /// + public void Create(bool useStdOut, bool execute, DbConnection connection) + { + InitConnectionAndExecute(GetAction(useStdOut), execute, false, connection, null); + } + + //TODO 6.0: Remove (replaced by method with optional connection parameter) /// /// Run the schema creation script /// @@ -118,9 +140,27 @@ public void Create(bool useStdOut, bool execute) /// public void Create(Action scriptAction, bool execute) { - Execute(scriptAction, execute, false); + Create(scriptAction, execute, null); + } + + //TODO 6.0: Make connection parameter optional: DbConnection connection = null + /// + /// Run the schema creation script + /// + /// an action that will be called for each line of the generated ddl. + /// if the ddl should be executed against the Database. + /// Optional explicit connection. Required for multi-tenancy. + /// Must be an opened connection. The method doesn't close the connection. + /// + /// This is a convenience method that calls and sets + /// the justDrop parameter to false. + /// + public void Create(Action scriptAction, bool execute, DbConnection connection) + { + InitConnectionAndExecute(scriptAction, execute, false, connection, null); } + //TODO 6.0: Remove (replaced by method with optional connection parameter) /// /// Run the schema creation script /// @@ -132,9 +172,27 @@ public void Create(Action scriptAction, bool execute) /// public void Create(TextWriter exportOutput, bool execute) { - Execute(null, execute, false, exportOutput); + Create(exportOutput, execute, null); + } + + //TODO 6.0: Make connection parameter optional: DbConnection connection = null + /// + /// Run the schema creation script + /// + /// if non-null, the ddl will be written to this TextWriter. + /// if the ddl should be executed against the Database. + /// Optional explicit connection. Required for multi-tenancy. + /// Must be an opened connection. The method doesn't close the connection. + /// + /// This is a convenience method that calls and sets + /// the justDrop parameter to false. + /// + public void Create(TextWriter exportOutput, bool execute, DbConnection connection) + { + InitConnectionAndExecute(null, execute, false, connection, exportOutput); } + //TODO 6.0: Remove (replaced by method with optional connection parameter) /// /// Run the drop schema script /// @@ -146,9 +204,27 @@ public void Create(TextWriter exportOutput, bool execute) /// public void Drop(bool useStdOut, bool execute) { - Execute(useStdOut, execute, true); + Drop(useStdOut, execute, null); + } + + //TODO 6.0: Make connection parameter optional: DbConnection connection = null + /// + /// Run the drop schema script + /// + /// if the ddl should be outputted in the Console. + /// if the ddl should be executed against the Database. + /// Optional explicit connection. Required for multi-tenancy. + /// Must be an opened connection. The method doesn't close the connection. + /// + /// This is a convenience method that calls and sets + /// the justDrop parameter to true. + /// + public void Drop(bool useStdOut, bool execute, DbConnection connection) + { + InitConnectionAndExecute(GetAction(useStdOut), execute, true, connection, null); } + //TODO 6.0: Remove (replaced by method with optional connection parameter) /// /// Run the drop schema script /// @@ -160,7 +236,24 @@ public void Drop(bool useStdOut, bool execute) /// public void Drop(TextWriter exportOutput, bool execute) { - Execute(null, execute, true, exportOutput); + Drop(exportOutput, execute, null); + } + + //TODO 6.0: Make connection parameter optional: DbConnection connection = null + /// + /// Run the drop schema script + /// + /// if non-null, the ddl will be written to this TextWriter. + /// if the ddl should be executed against the Database. + /// Optional explicit connection. Required for multi-tenancy. + /// Must be an opened connection. The method doesn't close the connection. + /// + /// This is a convenience method that calls and sets + /// the justDrop parameter to true. + /// + public void Drop(TextWriter exportOutput, bool execute, DbConnection connection) + { + InitConnectionAndExecute(null, execute, true, connection, exportOutput); } private void ExecuteInitialized(Action scriptAction, bool execute, bool throwOnError, TextWriter exportOutput, @@ -237,14 +330,7 @@ private void ExecuteSql(DbCommand cmd, string sql) public void Execute(bool useStdOut, bool execute, bool justDrop, DbConnection connection, TextWriter exportOutput) { - if (useStdOut) - { - Execute(Console.WriteLine, execute, justDrop, connection, exportOutput); - } - else - { - Execute(null, execute, justDrop, connection, exportOutput); - } + Execute(GetAction(useStdOut), execute, justDrop, connection, exportOutput); } public void Execute(Action scriptAction, bool execute, bool justDrop, DbConnection connection, @@ -315,14 +401,7 @@ public void Execute(Action scriptAction, bool execute, bool justDrop, Db /// public void Execute(bool useStdOut, bool execute, bool justDrop) { - if (useStdOut) - { - Execute(Console.WriteLine, execute, justDrop); - } - else - { - Execute(null, execute, justDrop); - } + InitConnectionAndExecute(GetAction(useStdOut), execute, justDrop, null, null); } public void Execute(Action scriptAction, bool execute, bool justDrop) @@ -331,9 +410,13 @@ public void Execute(Action scriptAction, bool execute, bool justDrop) } public void Execute(Action scriptAction, bool execute, bool justDrop, TextWriter exportOutput) + { + InitConnectionAndExecute(scriptAction, execute, justDrop, null, exportOutput); + } + + private void InitConnectionAndExecute(Action scriptAction, bool execute, bool justDrop, DbConnection connection, TextWriter exportOutput) { Initialize(); - DbConnection connection = null; TextWriter fileOutput = exportOutput; IConnectionProvider connectionProvider = null; @@ -344,8 +427,13 @@ public void Execute(Action scriptAction, bool execute, bool justDrop, Te fileOutput = new StreamWriter(outputFile); } - if (execute) + if (execute && connection == null) { + if (_requireTenantConnection) + { + throw new ArgumentException("When Database multi-tenancy is enabled you need to provide explicit connection. Please use overload with connection parameter."); + } + var props = new Dictionary(); foreach (var de in dialect.DefaultProperties) { @@ -378,12 +466,17 @@ public void Execute(Action scriptAction, bool execute, bool justDrop, Te } finally { - if (connection != null) + if (connectionProvider != null) { connectionProvider.CloseConnection(connection); connectionProvider.Dispose(); } } } + + private static Action GetAction(bool useStdOut) + { + return useStdOut ? Console.WriteLine : (Action) null; + } } } diff --git a/src/NHibernate/Util/PropertiesHelper.cs b/src/NHibernate/Util/PropertiesHelper.cs index da80f5b0860..fcd7f350741 100644 --- a/src/NHibernate/Util/PropertiesHelper.cs +++ b/src/NHibernate/Util/PropertiesHelper.cs @@ -55,6 +55,17 @@ public static string GetString(string property, IDictionary prop return value ?? defaultValue; } + public static TEnum GetEnum(string property, IDictionary properties, TEnum defaultValue) where TEnum : struct + { + var enumValue = GetString(property, properties, null); + if (enumValue == null) + { + return defaultValue; + } + + return (TEnum) Enum.Parse(typeof(TEnum), enumValue, false); + } + public static IDictionary ToDictionary(string property, string delim, IDictionary properties) { IDictionary map = new Dictionary(); diff --git a/src/NHibernate/nhibernate-configuration.xsd b/src/NHibernate/nhibernate-configuration.xsd index 9bfd1e9f7b9..178be1bfe37 100644 --- a/src/NHibernate/nhibernate-configuration.xsd +++ b/src/NHibernate/nhibernate-configuration.xsd @@ -336,6 +336,20 @@ + + + + Strategy for multi-tenancy. Supported Values: Database, None. Corresponds to MultiTenancyStrategy enum. + + + + + + + Connection provider for given multi-tenancy strategy. Class name implementing IMultiTenancyConnectionProvider. + + +