From b4a385831882b88b1928cf226233ca5fc5e92385 Mon Sep 17 00:00:00 2001 From: Alexander Zeitler Date: Sat, 29 Apr 2023 15:52:47 +0200 Subject: [PATCH 1/5] feat: multi tenancy with wildcard tenant names --- .../WildCardConjoinedMultiTenancyTests.cs | 140 ++++++++++++++++++ src/Marten/Marten.csproj | 48 +++--- .../WildcardSingleServerMultiTenancy.cs | 78 ++++++++++ 3 files changed, 242 insertions(+), 24 deletions(-) create mode 100644 src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs create mode 100644 src/Marten/Storage/WildcardSingleServerMultiTenancy.cs diff --git a/src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs b/src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs new file mode 100644 index 0000000000..d9ffd56f06 --- /dev/null +++ b/src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Threading.Tasks; +using Marten; +using Marten.Storage; +using Marten.Testing.Harness; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Weasel.Core; +using Xunit; + +namespace CoreTests.DatabaseMultiTenancy; + +public class User +{ + public Guid Id { get; set; } + public string Username { get; set; } +} + +public class When_using_wildcard_conjoined_multi_tenancy: IAsyncLifetime +{ + private IHost _host; + private IDocumentStore _store; + private Guid _id; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .ConfigureServices( + services => services.AddMarten( + _ => + { + _.Connection(ConnectionSource.ConnectionString); + _.Tenancy = new WildcardConjoinedMultiTenancy( + _, + ConnectionSource.ConnectionString, + "tenants", + "shared" + ); + _.AutoCreateSchemaObjects = AutoCreate.All; + } + ) + ) + .StartAsync(); + _store = _host.Services.GetService(); + _id = Guid.NewGuid(); + var user = new User() { Id = _id, Username = "Jane" }; + await using var session = _store.LightweightSession("shared-green"); + session.Insert(user); + await session.SaveChangesAsync(); + } + + [Fact] + public async Task Should_persist_document_for_tenant_id() + { + await using var session = _store.LightweightSession("shared-green"); + var user = session.Load(_id); + user.ShouldNotBeNull(); + } + + public Task DisposeAsync() + { + _host.Dispose(); + return Task.CompletedTask; + } +} + +public class When_using_wildcard_conjoined_multi_tenancy_for_two_tenants: IAsyncLifetime +{ + private IHost _host; + private IDocumentStore _store; + private Guid _tenant1UserId; + private Guid _tenant2UserId; + private string _janeGreen; + private string _janeRed; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .ConfigureServices( + services => services.AddMarten( + _ => + { + _.Connection(ConnectionSource.ConnectionString); + _.Tenancy = new WildcardConjoinedMultiTenancy( + _, + ConnectionSource.ConnectionString, + "tenants", + "shared" + ); + _.AutoCreateSchemaObjects = AutoCreate.All; + } + ) + ) + .StartAsync(); + _store = _host.Services.GetService(); + _tenant1UserId = Guid.NewGuid(); + _tenant2UserId = Guid.NewGuid(); + _janeGreen = "Jane Green"; + _janeRed = "Jane Red"; + var tenant1User = new User { Id = _tenant1UserId, Username = _janeGreen }; + var tenant2User = new User { Id = _tenant2UserId, Username = _janeRed }; + await using var tenant1Session = _store.LightweightSession("shared-green"); + tenant1Session.Insert(tenant1User); + await tenant1Session.SaveChangesAsync(); + await using var tenant2Session = _store.LightweightSession("shared-red"); + tenant2Session.Insert(tenant2User); + await tenant2Session.SaveChangesAsync(); + } + + [Fact] + public async Task Should_load_document_for_tenant1() + { + await using var session1 = _store.LightweightSession("shared-green"); + var janeGreen = session1.Load(_tenant1UserId); + janeGreen.Username.ShouldBe(_janeGreen); + } + + [Fact] + public async Task Should_load_document_for_tenant2() + { + await using var session2 = _store.LightweightSession("shared-red"); + var janeRed = session2.Load(_tenant2UserId); + janeRed.Username.ShouldBe(_janeRed); + } + + [Fact] + public async Task Should_not_load_tenant1_document_for_tenant2_session() + { + await using var session2 = _store.LightweightSession("shared-red"); + var janeGreen = session2.Load(_tenant1UserId); + janeGreen.ShouldBeNull(); + } + + public Task DisposeAsync() + { + _host.Dispose(); + return Task.CompletedTask; + } +} diff --git a/src/Marten/Marten.csproj b/src/Marten/Marten.csproj index 614a40f168..c8ab33c873 100644 --- a/src/Marten/Marten.csproj +++ b/src/Marten/Marten.csproj @@ -12,39 +12,39 @@ true - - - - + + + + - - + + - - - + + + - - - + + + - - - - - - - - - - + + + + + + + + + + @@ -60,7 +60,7 @@ snupkg - + - + diff --git a/src/Marten/Storage/WildcardSingleServerMultiTenancy.cs b/src/Marten/Storage/WildcardSingleServerMultiTenancy.cs new file mode 100644 index 0000000000..9dfe6bc4d5 --- /dev/null +++ b/src/Marten/Storage/WildcardSingleServerMultiTenancy.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using JasperFx.Core; +using Marten.Schema; +using Weasel.Core.Migrations; + +namespace Marten.Storage; + +public class WildcardConjoinedMultiTenancy: ITenancy +{ + private readonly MartenDatabase _database; + private readonly string _prefix; + private ImHashMap _tenants = ImHashMap.Empty; + + public WildcardConjoinedMultiTenancy( + StoreOptions options, + string connectionString, + string identifier, + string prefix + ) + { + options.Policies.AllDocumentsAreMultiTenanted(); + _database = new MartenDatabase( + options, + new ConnectionFactory(connectionString), + identifier + ); + _prefix = prefix; + } + + public ValueTask> BuildDatabases() + { + return new ValueTask>(new[] { _database }); + } + + public Tenant Default { get; } + public IDocumentCleaner Cleaner { get; } + + public Tenant GetTenant( + string tenantId + ) + { + if (!tenantId.StartsWith(_prefix)) return null; + var tenant = new Tenant(tenantId, _database); + _tenants = _tenants.AddOrUpdate(tenantId, tenant); + return tenant; + } + + public ValueTask GetTenantAsync( + string tenantId + ) + { + if (!tenantId.StartsWith(_prefix)) return new ValueTask(); + + var tenant = new Tenant(tenantId, _database); + _tenants = _tenants.AddOrUpdate(tenantId, tenant); + return ValueTask.FromResult(tenant); + } + + public async ValueTask FindOrCreateDatabase( + string tenantIdOrDatabaseIdentifier + ) + { + var tenant = await GetTenantAsync(tenantIdOrDatabaseIdentifier) + .ConfigureAwait(false); + return tenant.Database; + } + + public bool IsTenantStoredInCurrentDatabase( + IMartenDatabase database, + string tenantId + ) + { + var tenant = GetTenant(tenantId); + return ReferenceEquals(database, tenant.Database); + } +} From 04f0d30d13613488a3b39dc3c50f0c3bcca4f717 Mon Sep 17 00:00:00 2001 From: Alexander Zeitler Date: Sat, 29 Apr 2023 16:09:48 +0200 Subject: [PATCH 2/5] test: should throw if tenant id for session doesn't match wildcard --- .../WildCardConjoinedMultiTenancyTests.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs b/src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs index d9ffd56f06..dbd5242c48 100644 --- a/src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs +++ b/src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs @@ -138,3 +138,53 @@ public Task DisposeAsync() return Task.CompletedTask; } } + +public class When_using_wildcard_conjoined_multi_tenancy_when_session_id_doesnt_match_wildcard: IAsyncLifetime +{ + private IHost _host; + private IDocumentStore _store; + private Guid _id; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .ConfigureServices( + services => services.AddMarten( + _ => + { + _.Connection(ConnectionSource.ConnectionString); + _.Tenancy = new WildcardConjoinedMultiTenancy( + _, + ConnectionSource.ConnectionString, + "tenants", + "shared" + ); + _.AutoCreateSchemaObjects = AutoCreate.All; + } + ) + ) + .StartAsync(); + _store = _host.Services.GetService(); + _id = Guid.NewGuid(); + } + + [Fact] + public async Task Should_throw_argument_null_exception_for_tenant() + { + await Should.ThrowAsync( + async () => + { + var user = new User() { Id = _id, Username = "Jane" }; + await using var session = _store.LightweightSession("green"); + session.Insert(user); + await session.SaveChangesAsync(); + } + ); + } + + public Task DisposeAsync() + { + _host.Dispose(); + return Task.CompletedTask; + } +} From 787214585b46e159bbd09564d31a853970becf43 Mon Sep 17 00:00:00 2001 From: Alexander Zeitler Date: Sat, 29 Apr 2023 23:08:20 +0200 Subject: [PATCH 3/5] feat: set document cleaner for wildcard tenancy --- .../WildCardConjoinedMultiTenancyTests.cs | 61 +++++++++++++++++++ .../WildcardSingleServerMultiTenancy.cs | 1 + 2 files changed, 62 insertions(+) diff --git a/src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs b/src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs index dbd5242c48..4f7ea9872a 100644 --- a/src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs +++ b/src/CoreTests/DatabaseMultiTenancy/WildCardConjoinedMultiTenancyTests.cs @@ -1,12 +1,15 @@ using System; +using System.Linq; using System.Threading.Tasks; using Marten; using Marten.Storage; using Marten.Testing.Harness; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Npgsql; using Shouldly; using Weasel.Core; +using Weasel.Postgresql; using Xunit; namespace CoreTests.DatabaseMultiTenancy; @@ -17,6 +20,7 @@ public class User public string Username { get; set; } } +[CollectionDefinition("multi-tenancy", DisableParallelization = true)] public class When_using_wildcard_conjoined_multi_tenancy: IAsyncLifetime { private IHost _host; @@ -65,6 +69,7 @@ public Task DisposeAsync() } } +[CollectionDefinition("multi-tenancy", DisableParallelization = true)] public class When_using_wildcard_conjoined_multi_tenancy_for_two_tenants: IAsyncLifetime { private IHost _host; @@ -139,6 +144,7 @@ public Task DisposeAsync() } } +[CollectionDefinition("multi-tenancy", DisableParallelization = true)] public class When_using_wildcard_conjoined_multi_tenancy_when_session_id_doesnt_match_wildcard: IAsyncLifetime { private IHost _host; @@ -188,3 +194,58 @@ public Task DisposeAsync() return Task.CompletedTask; } } + +[CollectionDefinition("multi-tenancy", DisableParallelization = true)] +public class When_using_wildcard_conjoined_multi_tenancy_and_cleaning_up_all_marten_schema_objects: IAsyncLifetime +{ + private IHost _host; + private IDocumentStore _store; + private Guid _id; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .ConfigureServices( + services => services.AddMarten( + _ => + { + _.Connection(ConnectionSource.ConnectionString); + _.Tenancy = new WildcardConjoinedMultiTenancy( + _, + ConnectionSource.ConnectionString, + "tenants", + "shared" + ); + _.AutoCreateSchemaObjects = AutoCreate.All; + } + ) + ) + .StartAsync(); + _store = _host.Services.GetService(); + _id = Guid.NewGuid(); + + var tenant1User = new User { Id = Guid.NewGuid(), Username = "Jane" }; + + await using var tenant1Session = _store.LightweightSession("shared-green"); + tenant1Session.Insert(tenant1User); + await tenant1Session.SaveChangesAsync(); + } + + [Fact] + public async Task Should_remove_user_table() + { + await _store.Advanced.Clean.CompletelyRemoveAllAsync(); + await using var conn = new NpgsqlConnection(ConnectionSource.ConnectionString); + await conn.OpenAsync(); + + + var tables = await conn.ExistingTablesAsync(); + tables.Any(x => x.QualifiedName == "public.mt_doc_user").ShouldBeFalse(); + } + + public Task DisposeAsync() + { + _host.Dispose(); + return Task.CompletedTask; + } +} diff --git a/src/Marten/Storage/WildcardSingleServerMultiTenancy.cs b/src/Marten/Storage/WildcardSingleServerMultiTenancy.cs index 9dfe6bc4d5..5e08150b11 100644 --- a/src/Marten/Storage/WildcardSingleServerMultiTenancy.cs +++ b/src/Marten/Storage/WildcardSingleServerMultiTenancy.cs @@ -27,6 +27,7 @@ string prefix identifier ); _prefix = prefix; + Cleaner = new CompositeDocumentCleaner(this); } public ValueTask> BuildDatabases() From c1e956e42a5bb04e0dcba9ba99bac10ffb66b7a2 Mon Sep 17 00:00:00 2001 From: Alexander Zeitler Date: Fri, 24 May 2024 16:55:42 +0200 Subject: [PATCH 4/5] refactor: revert white space before closing tag to avoid merge conflicts --- src/Marten/Marten.csproj | 48 ++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/Marten/Marten.csproj b/src/Marten/Marten.csproj index c8ab33c873..614a40f168 100644 --- a/src/Marten/Marten.csproj +++ b/src/Marten/Marten.csproj @@ -12,39 +12,39 @@ true - - - - + + + + - - + + - - - + + + - - - + + + - - - - - - - - - - + + + + + + + + + + @@ -60,7 +60,7 @@ snupkg - + - + From 32f5146ecc7343c8ee561fd6f8bc7ac389216d64 Mon Sep 17 00:00:00 2001 From: Alexander Zeitler Date: Fri, 24 May 2024 17:13:03 +0200 Subject: [PATCH 5/5] fix: implement ITenancy to match changed interface --- .../Storage/WildcardSingleServerMultiTenancy.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Marten/Storage/WildcardSingleServerMultiTenancy.cs b/src/Marten/Storage/WildcardSingleServerMultiTenancy.cs index 5e08150b11..d2f05aff33 100644 --- a/src/Marten/Storage/WildcardSingleServerMultiTenancy.cs +++ b/src/Marten/Storage/WildcardSingleServerMultiTenancy.cs @@ -1,8 +1,8 @@ -using System; using System.Collections.Generic; using System.Threading.Tasks; using JasperFx.Core; using Marten.Schema; +using Npgsql; using Weasel.Core.Migrations; namespace Marten.Storage; @@ -23,11 +23,11 @@ string prefix options.Policies.AllDocumentsAreMultiTenanted(); _database = new MartenDatabase( options, - new ConnectionFactory(connectionString), + NpgsqlDataSource.Create(connectionString), identifier ); _prefix = prefix; - Cleaner = new CompositeDocumentCleaner(this); + Cleaner = new CompositeDocumentCleaner(this, options); } public ValueTask> BuildDatabases() @@ -76,4 +76,9 @@ string tenantId var tenant = GetTenant(tenantId); return ReferenceEquals(database, tenant.Database); } + + public void Dispose() + { + _database.Dispose(); + } }