From 7ea237f5d55fa466f7c81a376f0e8d77d993dc8a Mon Sep 17 00:00:00 2001 From: Thomas Rittson Date: Thu, 1 Jan 2026 09:30:24 +1000 Subject: [PATCH] Remove redundant OrganizationId column; remove private read method used by bulk insert --- .../Entities/DefaultCollectionSemaphore.cs | 3 +- .../Repositories/ICollectionRepository.cs | 12 +++- .../Repositories/CollectionRepository.cs | 55 +++++-------------- ...lectionSemaphoreEntityTypeConfiguration.cs | 12 +--- .../Models/DefaultCollectionSemaphore.cs | 4 +- .../Repositories/CollectionRepository.cs | 27 +++------ ...> Collection_CreateDefaultCollections.sql} | 2 - .../Tables/DefaultCollectionSemaphore.sql | 7 +-- ...llectionSemaphore_ReadByOrganizationId.sql | 13 ----- ...ionSemaphore_ReadByOrganizationUserIds.sql | 13 +++++ .../CreateDefaultCollectionsTests.cs | 8 +-- .../DefaultCollectionSemaphoreTests.cs | 16 ++---- .../UpsertDefaultCollectionsBulkTests.cs | 13 +++-- ...25-12-30_00_DefaultCollectionSemaphore.sql | 30 ++++------ ...1_Collection_CreateDefaultCollections.sql} | 2 - 15 files changed, 79 insertions(+), 138 deletions(-) rename src/Sql/dbo/AdminConsole/Stored Procedures/{Collection_UpsertDefaultCollections.sql => Collection_CreateDefaultCollections.sql} (97%) delete mode 100644 src/Sql/dbo/Stored Procedures/DefaultCollectionSemaphore_ReadByOrganizationId.sql create mode 100644 src/Sql/dbo/Stored Procedures/DefaultCollectionSemaphore_ReadByOrganizationUserIds.sql rename util/Migrator/DbScripts/{2025-12-30_01_Collection_UpsertDefaultCollections.sql => 2025-12-30_01_Collection_CreateDefaultCollections.sql} (97%) diff --git a/src/Core/Entities/DefaultCollectionSemaphore.cs b/src/Core/Entities/DefaultCollectionSemaphore.cs index 808227e61e..ea2568e671 100644 --- a/src/Core/Entities/DefaultCollectionSemaphore.cs +++ b/src/Core/Entities/DefaultCollectionSemaphore.cs @@ -1,8 +1,7 @@ -namespace Bit.Core.Entities; +namespace Bit.Core.Entities; public class DefaultCollectionSemaphore { - public Guid OrganizationId { get; set; } public Guid OrganizationUserId { get; set; } public DateTime CreationDate { get; set; } = DateTime.UtcNow; } diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index 80a55087ea..f51f64a7d8 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -82,9 +82,15 @@ public interface ICollectionRepository : IRepository Task UpsertDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); /// - /// Gets organization user IDs that have default collection semaphore entries for the specified organization. + /// Gets default collection semaphores for the given organizationUserIds. + /// If an organizationUserId is missing from the result set, they do not have a semaphore set. /// - /// The Organization ID. + /// The organization User IDs to check semaphores for. /// Collection of organization user IDs that have default collection semaphores. - Task> GetDefaultCollectionSemaphoresAsync(Guid organizationId); + /// + /// The semaphore table is used to ensure that an organizationUser can only have 1 default collection. + /// (That is, a user may only have 1 default collection per organization.) + /// If a semaphore is returned, that user already has a default collection for that organization. + /// + Task> GetDefaultCollectionSemaphoresAsync(IEnumerable organizationUserIds); } diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index c94bff4e85..df82a0c158 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -391,15 +391,15 @@ public class CollectionRepository : Repository, ICollectionRep return; } + var orgUserIdWithDefaultCollection = await GetDefaultCollectionSemaphoresAsync(organizationUserIds); + var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection); + await using var connection = new SqlConnection(ConnectionString); connection.Open(); await using var transaction = connection.BeginTransaction(); + try { - var orgUserIdWithDefaultCollection = await GetOrgUserIdsWithDefaultCollectionAsync(connection, transaction, organizationId); - - var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection); - var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName); if (!collectionUsers.Any() || !collections.Any()) @@ -412,7 +412,6 @@ public class CollectionRepository : Repository, ICollectionRep var now = DateTime.UtcNow; var semaphores = collectionUsers.Select(c => new DefaultCollectionSemaphore { - OrganizationId = organizationId, OrganizationUserId = c.OrganizationUserId, CreationDate = now }).ToList(); @@ -430,37 +429,16 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task> GetDefaultCollectionSemaphoresAsync(Guid organizationId) + public async Task> GetDefaultCollectionSemaphoresAsync(IEnumerable organizationUserIds) { - using (var connection = new SqlConnection(ConnectionString)) - { - var results = await connection.QueryAsync( - "[dbo].[DefaultCollectionSemaphore_ReadByOrganizationId]", - new { OrganizationId = organizationId }, - commandType: CommandType.StoredProcedure); + await using var connection = new SqlConnection(ConnectionString); - return results.ToList(); - } - } + var results = await connection.QueryAsync( + "[dbo].[DefaultCollectionSemaphore_ReadByOrganizationUserIds]", + new { OrganizationUserIds = organizationUserIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); - private async Task> GetOrgUserIdsWithDefaultCollectionAsync(SqlConnection connection, SqlTransaction transaction, Guid organizationId) - { - const string sql = @" - SELECT - OrganizationUserId - FROM - [DefaultCollectionSemaphore] dcs - WHERE - OrganizationId = @OrganizationId - "; - - var organizationUserIds = await connection.QueryAsync( - sql, - new { OrganizationId = organizationId, CollectionType = CollectionType.DefaultUserCollection }, - transaction: transaction - ); - - return organizationUserIds.ToHashSet(); + return results.ToHashSet(); } private (List collectionUser, List collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable missingDefaultCollectionUserIds, string defaultCollectionName) @@ -506,8 +484,7 @@ public class CollectionRepository : Repository, ICollectionRep // Sort by composite key to reduce deadlocks var sortedSemaphores = semaphores - .OrderBy(s => s.OrganizationId) - .ThenBy(s => s.OrganizationUserId) + .OrderBy(s => s.OrganizationUserId) .ToList(); using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity & SqlBulkCopyOptions.CheckConstraints, transaction); @@ -518,8 +495,6 @@ public class CollectionRepository : Repository, ICollectionRep var dataTable = new DataTable("DefaultCollectionSemaphoreDataTable"); - var organizationIdColumn = new DataColumn(nameof(DefaultCollectionSemaphore.OrganizationId), typeof(Guid)); - dataTable.Columns.Add(organizationIdColumn); var organizationUserIdColumn = new DataColumn(nameof(DefaultCollectionSemaphore.OrganizationUserId), typeof(Guid)); dataTable.Columns.Add(organizationUserIdColumn); var creationDateColumn = new DataColumn(nameof(DefaultCollectionSemaphore.CreationDate), typeof(DateTime)); @@ -530,15 +505,13 @@ public class CollectionRepository : Repository, ICollectionRep bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); } - var keys = new DataColumn[2]; - keys[0] = organizationIdColumn; - keys[1] = organizationUserIdColumn; + var keys = new DataColumn[1]; + keys[0] = organizationUserIdColumn; dataTable.PrimaryKey = keys; foreach (var semaphore in sortedSemaphores) { var row = dataTable.NewRow(); - row[organizationIdColumn] = semaphore.OrganizationId; row[organizationUserIdColumn] = semaphore.OrganizationUserId; row[creationDateColumn] = semaphore.CreationDate; dataTable.Rows.Add(row); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/DefaultCollectionSemaphoreEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/DefaultCollectionSemaphoreEntityTypeConfiguration.cs index 33d59bae4a..3892941111 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/DefaultCollectionSemaphoreEntityTypeConfiguration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/DefaultCollectionSemaphoreEntityTypeConfiguration.cs @@ -1,4 +1,4 @@ -using Bit.Infrastructure.EntityFramework.AdminConsole.Models; +using Bit.Infrastructure.EntityFramework.AdminConsole.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -9,15 +9,7 @@ public class DefaultCollectionSemaphoreEntityTypeConfiguration : IEntityTypeConf public void Configure(EntityTypeBuilder builder) { builder - .HasKey(dcs => new { dcs.OrganizationId, dcs.OrganizationUserId }); - - // Cascade behavior: Organization -> OrganizationUser (CASCADE) -> DefaultCollectionSemaphore (CASCADE) - // Organization FK uses NoAction to avoid competing cascade paths - builder - .HasOne(dcs => dcs.Organization) - .WithMany() - .HasForeignKey(dcs => dcs.OrganizationId) - .OnDelete(DeleteBehavior.NoAction); + .HasKey(dcs => new { dcs.OrganizationUserId }); // OrganizationUser FK cascades deletions to ensure automatic cleanup builder diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/DefaultCollectionSemaphore.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/DefaultCollectionSemaphore.cs index e900b36d15..6d51bb9f21 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/DefaultCollectionSemaphore.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/DefaultCollectionSemaphore.cs @@ -1,11 +1,10 @@ -using AutoMapper; +using AutoMapper; using Bit.Infrastructure.EntityFramework.Models; namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; public class DefaultCollectionSemaphore : Core.Entities.DefaultCollectionSemaphore { - public virtual Organization? Organization { get; set; } public virtual OrganizationUser? OrganizationUser { get; set; } } @@ -14,7 +13,6 @@ public class DefaultCollectionSemaphoreMapperProfile : Profile public DefaultCollectionSemaphoreMapperProfile() { CreateMap() - .ForMember(dcs => dcs.Organization, opt => opt.Ignore()) .ForMember(dcs => dcs.OrganizationUser, opt => opt.Ignore()) .ReverseMap(); } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 8453b99dd6..d152a67afb 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -803,12 +803,12 @@ public class CollectionRepository : Repository new DefaultCollectionSemaphore { - OrganizationId = organizationId, OrganizationUserId = c.OrganizationUserId, CreationDate = now }).ToList(); @@ -839,27 +838,19 @@ public class CollectionRepository : Repository> GetDefaultCollectionSemaphoresAsync(Guid organizationId) + public async Task> GetDefaultCollectionSemaphoresAsync(IEnumerable organizationUserIds) { + var organizationUserIdsHashSet = organizationUserIds.ToHashSet(); + using var scope = ServiceScopeFactory.CreateScope(); var dbContext = GetDatabaseContext(scope); - var organizationUserIds = await dbContext.DefaultCollectionSemaphores - .Where(s => s.OrganizationId == organizationId) + var result = await dbContext.DefaultCollectionSemaphores + .Where(s => organizationUserIdsHashSet.Contains(s.OrganizationUserId)) .Select(s => s.OrganizationUserId) .ToListAsync(); - return organizationUserIds; - } - - private async Task> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId) - { - var results = await dbContext.DefaultCollectionSemaphores - .Where(ou => ou.OrganizationId == organizationId) - .Select(x => x.OrganizationUserId) - .ToListAsync(); - - return results.ToHashSet(); + return result.ToHashSet(); } private (List collectionUser, List collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable missingDefaultCollectionUserIds, string defaultCollectionName) diff --git a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpsertDefaultCollections.sql b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql similarity index 97% rename from src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpsertDefaultCollections.sql rename to src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql index 9885ac6ef4..98c6ee3b18 100644 --- a/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_UpsertDefaultCollections.sql +++ b/src/Sql/dbo/AdminConsole/Stored Procedures/Collection_CreateDefaultCollections.sql @@ -35,12 +35,10 @@ BEGIN -- If this fails due to duplicate key, the entire transaction will be rolled back INSERT INTO [dbo].[DefaultCollectionSemaphore] ( - [OrganizationId], [OrganizationUserId], [CreationDate] ) SELECT - @OrganizationId, ou.[OrganizationUserId], GETUTCDATE() FROM diff --git a/src/Sql/dbo/AdminConsole/Tables/DefaultCollectionSemaphore.sql b/src/Sql/dbo/AdminConsole/Tables/DefaultCollectionSemaphore.sql index 34f81f237c..6161781c8c 100644 --- a/src/Sql/dbo/AdminConsole/Tables/DefaultCollectionSemaphore.sql +++ b/src/Sql/dbo/AdminConsole/Tables/DefaultCollectionSemaphore.sql @@ -3,10 +3,9 @@ -- OrganizationId FK has NO ACTION to avoid competing cascade paths CREATE TABLE [dbo].[DefaultCollectionSemaphore] ( - [OrganizationId] UNIQUEIDENTIFIER NOT NULL, [OrganizationUserId] UNIQUEIDENTIFIER NOT NULL, [CreationDate] DATETIME2 (7) NOT NULL, - CONSTRAINT [PK_DefaultCollectionSemaphore] PRIMARY KEY CLUSTERED ([OrganizationId] ASC, [OrganizationUserId] ASC), - CONSTRAINT [FK_DefaultCollectionSemaphore_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]), -- NO ACTION to avoid competing cascades - CONSTRAINT [FK_DefaultCollectionSemaphore_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) REFERENCES [dbo].[OrganizationUser] ([Id]) ON DELETE CASCADE -- Cascades from OrganizationUser deletion + CONSTRAINT [PK_DefaultCollectionSemaphore] PRIMARY KEY CLUSTERED ([OrganizationUserId] ASC), + CONSTRAINT [FK_DefaultCollectionSemaphore_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) + REFERENCES [dbo].[OrganizationUser] ([Id]) ON DELETE CASCADE ); diff --git a/src/Sql/dbo/Stored Procedures/DefaultCollectionSemaphore_ReadByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/DefaultCollectionSemaphore_ReadByOrganizationId.sql deleted file mode 100644 index f5a8b4d9c3..0000000000 --- a/src/Sql/dbo/Stored Procedures/DefaultCollectionSemaphore_ReadByOrganizationId.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE PROCEDURE [dbo].[DefaultCollectionSemaphore_ReadByOrganizationId] - @OrganizationId UNIQUEIDENTIFIER -AS -BEGIN - SET NOCOUNT ON - - SELECT - [OrganizationUserId] - FROM - [dbo].[DefaultCollectionSemaphore] - WHERE - [OrganizationId] = @OrganizationId -END diff --git a/src/Sql/dbo/Stored Procedures/DefaultCollectionSemaphore_ReadByOrganizationUserIds.sql b/src/Sql/dbo/Stored Procedures/DefaultCollectionSemaphore_ReadByOrganizationUserIds.sql new file mode 100644 index 0000000000..b1bfae98b6 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/DefaultCollectionSemaphore_ReadByOrganizationUserIds.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[DefaultCollectionSemaphore_ReadByOrganizationUserIds] + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + [OrganizationUserId] + FROM + [dbo].[DefaultCollectionSemaphore] DCS + INNER JOIN + @OrganizationUserIds OU ON [OU].[Id] = [DCS].[OrganizationUserId] +END diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs index 852a11e0bc..b8ca61a3c7 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Repositories; using Xunit; @@ -40,8 +40,8 @@ public class CreateDefaultCollectionsTests Assert.All(defaultCollections, c => Assert.Equal("My Items", c.Item1.Name)); Assert.All(defaultCollections, c => Assert.Equal(organization.Id, c.Item1.OrganizationId)); - var semaphores = await collectionRepository.GetDefaultCollectionSemaphoresAsync(organization.Id); - Assert.Equal([orgUser1.Id, orgUser2.Id], semaphores.ToHashSet()); + var semaphores = await collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser1.Id, orgUser2.Id]); + Assert.Equal([orgUser1.Id, orgUser2.Id], semaphores); // Verify each user has exactly 1 collection with correct permissions var orgUser1Collection = Assert.Single(defaultCollections, @@ -100,7 +100,7 @@ public class CreateDefaultCollectionsTests Assert.Single(defaultCollections); - var semaphores = await collectionRepository.GetDefaultCollectionSemaphoresAsync(organization.Id); + var semaphores = await collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser.Id]); Assert.Equal([orgUser.Id], semaphores); var access = await collectionRepository.GetManyUsersByIdAsync(defaultCollections.Single().Id); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/DefaultCollectionSemaphoreTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/DefaultCollectionSemaphoreTests.cs index 234fa9ab35..39cd1d775c 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/DefaultCollectionSemaphoreTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/DefaultCollectionSemaphoreTests.cs @@ -1,10 +1,4 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Enums; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Repositories; -using Bit.Infrastructure.EntityFramework.Repositories; -using Microsoft.EntityFrameworkCore; +using Bit.Core.Repositories; using Xunit; namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository; @@ -32,14 +26,14 @@ public class DefaultCollectionSemaphoreTests "My Items"); // Verify semaphore exists - var semaphoreBefore = await collectionRepository.GetDefaultCollectionSemaphoresAsync(organization.Id); + var semaphoreBefore = await collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser.Id]); Assert.Single(semaphoreBefore, s => s == orgUser.Id); // Act - Delete organization user await organizationUserRepository.DeleteAsync(orgUser); // Assert - Semaphore should be cascade deleted - var semaphoreAfter = await collectionRepository.GetDefaultCollectionSemaphoresAsync(organization.Id); + var semaphoreAfter = await collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser.Id]); Assert.Empty(semaphoreAfter); } @@ -65,14 +59,14 @@ public class DefaultCollectionSemaphoreTests "My Items"); // Verify semaphore exists - var semaphoreBefore = await collectionRepository.GetDefaultCollectionSemaphoresAsync(organization.Id); + var semaphoreBefore = await collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser.Id]); Assert.Single(semaphoreBefore, s => s == orgUser.Id); // Act - Delete organization (which cascades to OrganizationUser, which cascades to semaphore) await organizationRepository.DeleteAsync(organization); // Assert - Semaphore should be cascade deleted via OrganizationUser - var semaphoreAfter = await collectionRepository.GetDefaultCollectionSemaphoresAsync(organization.Id); + var semaphoreAfter = await collectionRepository.GetDefaultCollectionSemaphoresAsync([orgUser.Id]); Assert.Empty(semaphoreAfter); } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsBulkTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsBulkTests.cs index 075ff41f91..1376465a5b 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsBulkTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsBulkTests.cs @@ -31,7 +31,7 @@ public class UpsertDefaultCollectionsBulkTests // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id); - await AssertSempahoresCreatedAsync(collectionRepository, affectedOrgUserIds, organization.Id); + await AssertSempahoresCreatedAsync(collectionRepository, affectedOrgUserIds); await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers); } @@ -69,7 +69,7 @@ public class UpsertDefaultCollectionsBulkTests // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, arrangedOrganizationUsers, organization.Id); - await AssertSempahoresCreatedAsync(collectionRepository, affectedOrgUserIds, organization.Id); + await AssertSempahoresCreatedAsync(collectionRepository, affectedOrgUserIds); await CleanupAsync(organizationRepository, userRepository, organization, affectedOrgUsers); } @@ -99,7 +99,7 @@ public class UpsertDefaultCollectionsBulkTests // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id); - await AssertSempahoresCreatedAsync(collectionRepository, affectedOrgUserIds, organization.Id); + await AssertSempahoresCreatedAsync(collectionRepository, affectedOrgUserIds); await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers); } @@ -138,10 +138,11 @@ public class UpsertDefaultCollectionsBulkTests } private static async Task AssertSempahoresCreatedAsync(ICollectionRepository collectionRepository, - IEnumerable organizationUserIds, Guid organizationId) + IEnumerable organizationUserIds) { - var semaphores = await collectionRepository.GetDefaultCollectionSemaphoresAsync(organizationId); - Assert.Equal(organizationUserIds.ToHashSet(), semaphores.ToHashSet()); + var organizationUserIdHashSet = organizationUserIds.ToHashSet(); + var semaphores = await collectionRepository.GetDefaultCollectionSemaphoresAsync(organizationUserIdHashSet); + Assert.Equal(organizationUserIdHashSet, semaphores); } private static async Task CleanupAsync(IOrganizationRepository organizationRepository, diff --git a/util/Migrator/DbScripts/2025-12-30_00_DefaultCollectionSemaphore.sql b/util/Migrator/DbScripts/2025-12-30_00_DefaultCollectionSemaphore.sql index e60fe1072d..285c39e77d 100644 --- a/util/Migrator/DbScripts/2025-12-30_00_DefaultCollectionSemaphore.sql +++ b/util/Migrator/DbScripts/2025-12-30_00_DefaultCollectionSemaphore.sql @@ -5,26 +5,18 @@ IF OBJECT_ID('[dbo].[DefaultCollectionSemaphore]') IS NULL BEGIN CREATE TABLE [dbo].[DefaultCollectionSemaphore] ( - [OrganizationId] UNIQUEIDENTIFIER NOT NULL, - [OrganizationUserId] UNIQUEIDENTIFIER NOT NULL, - [CreationDate] DATETIME2(7) NOT NULL, - CONSTRAINT [PK_DefaultCollectionSemaphore] PRIMARY KEY CLUSTERED - ( - [OrganizationId] ASC, - [OrganizationUserId] ASC - ), - CONSTRAINT [FK_DefaultCollectionSemaphore_Organization] FOREIGN KEY ([OrganizationId]) - REFERENCES [dbo].[Organization] ([Id]), -- NO ACTION to avoid competing cascades - CONSTRAINT [FK_DefaultCollectionSemaphore_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) - REFERENCES [dbo].[OrganizationUser] ([Id]) - ON DELETE CASCADE -- Cascades from OrganizationUser deletion + [OrganizationUserId] UNIQUEIDENTIFIER NOT NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + CONSTRAINT [PK_DefaultCollectionSemaphore] PRIMARY KEY CLUSTERED ([OrganizationUserId] ASC), + CONSTRAINT [FK_DefaultCollectionSemaphore_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) + REFERENCES [dbo].[OrganizationUser] ([Id]) ON DELETE CASCADE ); END GO --- Create stored procedure to read semaphores by organization -CREATE OR ALTER PROCEDURE [dbo].[DefaultCollectionSemaphore_ReadByOrganizationId] - @OrganizationId UNIQUEIDENTIFIER +-- Create stored procedure to read semaphores by OrganizationUserId +CREATE OR ALTER PROCEDURE [dbo].[DefaultCollectionSemaphore_ReadByOrganizationUserIds] + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY AS BEGIN SET NOCOUNT ON @@ -32,8 +24,8 @@ BEGIN SELECT [OrganizationUserId] FROM - [dbo].[DefaultCollectionSemaphore] - WHERE - [OrganizationId] = @OrganizationId + [dbo].[DefaultCollectionSemaphore] DCS + INNER JOIN + @OrganizationUserIds OU ON [OU].[Id] = [DCS].[OrganizationUserId] END GO diff --git a/util/Migrator/DbScripts/2025-12-30_01_Collection_UpsertDefaultCollections.sql b/util/Migrator/DbScripts/2025-12-30_01_Collection_CreateDefaultCollections.sql similarity index 97% rename from util/Migrator/DbScripts/2025-12-30_01_Collection_UpsertDefaultCollections.sql rename to util/Migrator/DbScripts/2025-12-30_01_Collection_CreateDefaultCollections.sql index 8c9cab9daf..3e8fe14b1a 100644 --- a/util/Migrator/DbScripts/2025-12-30_01_Collection_UpsertDefaultCollections.sql +++ b/util/Migrator/DbScripts/2025-12-30_01_Collection_CreateDefaultCollections.sql @@ -31,12 +31,10 @@ BEGIN -- If this fails due to duplicate key, the entire transaction will be rolled back INSERT INTO [dbo].[DefaultCollectionSemaphore] ( - [OrganizationId], [OrganizationUserId], [CreationDate] ) SELECT - @OrganizationId, ou.[OrganizationUserId], GETUTCDATE() FROM