From a23337dc051399eeb28517f494371def699b4a58 Mon Sep 17 00:00:00 2001 From: SadPossum Date: Tue, 11 Aug 2026 15:17:04 +0000 Subject: [PATCH] perf(organizations): index membership scope export --- ...anizationMembershipExportIndex.Designer.cs | 743 +++++++++++++++++ ...12_AddOrganizationMembershipExportIndex.cs | 29 + .../OrganizationsDbContextModelSnapshot.cs | 4 +- ...anizationMembershipExportIndex.Designer.cs | 763 ++++++++++++++++++ ...24_AddOrganizationMembershipExportIndex.cs | 29 + .../OrganizationsDbContextModelSnapshot.cs | 4 +- .../OrganizationMembershipConfiguration.cs | 1 + ...tionMembershipExportIndexMigrationTests.cs | 87 ++ ...copeLifecyclePostgreSqlIntegrationTests.cs | 221 +++++ ...ationsSqlServerIdentityIntegrationTests.cs | 51 ++ .../OrganizationRepositoryTests.cs | 15 + 11 files changed, 1945 insertions(+), 2 deletions(-) create mode 100644 src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/20260811150412_AddOrganizationMembershipExportIndex.Designer.cs create mode 100644 src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/20260811150412_AddOrganizationMembershipExportIndex.cs create mode 100644 src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/20260811150424_AddOrganizationMembershipExportIndex.Designer.cs create mode 100644 src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/20260811150424_AddOrganizationMembershipExportIndex.cs create mode 100644 tests/Gma.Modules.Organizations.IntegrationTests/OrganizationMembershipExportIndexMigrationTests.cs diff --git a/src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/20260811150412_AddOrganizationMembershipExportIndex.Designer.cs b/src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/20260811150412_AddOrganizationMembershipExportIndex.Designer.cs new file mode 100644 index 0000000..d4ac744 --- /dev/null +++ b/src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/20260811150412_AddOrganizationMembershipExportIndex.Designer.cs @@ -0,0 +1,743 @@ +// +using System; +using Gma.Modules.Organizations.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Gma.Modules.Organizations.Persistence.PostgreSqlMigrations.Migrations +{ + [DbContext(typeof(OrganizationsDbContext))] + [Migration("20260811150412_AddOrganizationMembershipExportIndex")] + partial class AddOrganizationMembershipExportIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("organizations") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Gma.Framework.Messaging.Infrastructure.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Handler") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("FailedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LockedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessingStartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ScopeId"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id", "Handler"); + + b.HasIndex("Handler", "Status"); + + b.HasIndex("Status", "ProcessedAtUtc"); + + b.ToTable("inbox_messages", "organizations"); + }); + + modelBuilder.Entity("Gma.Framework.Messaging.Infrastructure.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("LockedBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LockedUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProcessedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ScopeId"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAtUtc", "NextAttemptAtUtc", "LockedUntilUtc", "CreatedAtUtc"); + + b.ToTable("outbox_messages", "organizations"); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActiveOwnerCount") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.Property("CreationRequestFingerprint") + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("LastChangedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.Property("LastMutationKind") + .HasColumnType("integer"); + + b.Property("LastMutationOperationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Status", "Slug"); + + b.ToTable("organizations", "organizations"); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationEnrollmentClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DecisionExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EnrollmentLinkId") + .HasColumnType("uuid"); + + b.Property("LastChangedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubjectId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("MembershipId"); + + b.HasIndex("EnrollmentLinkId", "SubjectId") + .IsUnique(); + + b.HasIndex("Status", "DecisionExpiresAtUtc"); + + b.HasIndex("OrganizationId", "Status", "CreatedAtUtc"); + + b.HasIndex("OrganizationId", "SubjectId", "Status", "DecisionExpiresAtUtc"); + + b.ToTable("organization_enrollment_claims", "organizations"); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationEnrollmentLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalMode") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.Property("CreatorSubjectId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastChangedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.Property("MaximumClaims") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ReplacesEnrollmentLinkId") + .HasColumnType("uuid"); + + b.Property("ReplacesEnrollmentLinkVersion") + .HasColumnType("bigint"); + + b.Property("ReservedClaims") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TokenDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("TokenVersion") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ReplacesEnrollmentLinkId") + .IsUnique(); + + b.HasIndex("TokenDigest") + .IsUnique(); + + b.HasIndex("Status", "ExpiresAtUtc"); + + b.HasIndex("Status", "LastChangedAtUtc"); + + b.HasIndex("OrganizationId", "Status", "CreatedAtUtc"); + + b.ToTable("organization_enrollment_links", "organizations", t => + { + t.HasCheckConstraint("CK_organization_enrollment_links_replacement_lineage", "(\"ReplacesEnrollmentLinkId\" IS NULL AND \"ReplacesEnrollmentLinkVersion\" IS NULL) OR (\"ReplacesEnrollmentLinkId\" IS NOT NULL AND \"ReplacesEnrollmentLinkId\" <> \"Id\" AND \"ReplacesEnrollmentLinkVersion\" > 0)"); + }); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AcceptedMembershipId") + .HasColumnType("uuid"); + + b.Property("AcceptedSubjectId") + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InviterSubjectId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("LastChangedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RecipientEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("ReplacesInvitationId") + .HasColumnType("uuid"); + + b.Property("ReplacesInvitationVersion") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TokenDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("TokenVersion") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ReplacesInvitationId") + .IsUnique(); + + b.HasIndex("TokenDigest") + .IsUnique(); + + b.HasIndex("Status", "ExpiresAtUtc"); + + b.HasIndex("Status", "LastChangedAtUtc"); + + b.HasIndex("OrganizationId", "Status", "CreatedAtUtc"); + + b.ToTable("organization_invitations", "organizations", t => + { + t.HasCheckConstraint("CK_organization_invitations_replacement_lineage", "(\"ReplacesInvitationId\" IS NULL AND \"ReplacesInvitationVersion\" IS NULL) OR (\"ReplacesInvitationId\" IS NOT NULL AND \"ReplacesInvitationId\" <> \"Id\" AND \"ReplacesInvitationVersion\" > 0)"); + }); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastChangedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("character varying(192)"); + + b.Property("LastMutationKind") + .HasColumnType("integer"); + + b.Property("LastMutationOperationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubjectId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Id"); + + b.HasIndex("OrganizationId", "SubjectId") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status", "Role"); + + b.HasIndex("SubjectId", "Status", "OrganizationId"); + + b.ToTable("organization_memberships", "organizations"); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeDestroyOperation", b => + { + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("BatchSize") + .HasColumnType("integer"); + + b.Property("CompletedBatchCount") + .HasColumnType("integer"); + + b.Property("ExpectedRevision") + .HasColumnType("bigint"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("ProofVersion") + .HasColumnType("integer"); + + b.Property("RemovalProofSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("RemovedRecordCount") + .HasColumnType("bigint"); + + b.Property("RequestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ResultingRevision") + .HasColumnType("bigint"); + + b.Property("Stage") + .HasColumnType("integer"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAtUtc") + .IsConcurrencyToken() + .HasColumnType("timestamp with time zone"); + + b.HasKey("OrganizationId"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.ToTable("organization_scope_destroy_operations", "organizations", t => + { + t.HasCheckConstraint("CK_organization_scope_destroy_operations_batch", "\"BatchSize\" >= 1 AND \"BatchSize\" <= 1000"); + + t.HasCheckConstraint("CK_organization_scope_destroy_operations_progress", "\"Stage\" >= 1 AND \"Stage\" <= 7 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"ProofVersion\" >= 1 AND \"UpdatedAtUtc\" >= \"StartedAtUtc\""); + + t.HasCheckConstraint("CK_organization_scope_destroy_operations_revisions", "\"ExpectedRevision\" >= 0 AND \"ResultingRevision\" > \"ExpectedRevision\""); + }); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeDestroyReceipt", b => + { + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("BatchSize") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CompletedBatchCount") + .HasColumnType("integer"); + + b.Property("ExpectedRevision") + .HasColumnType("bigint"); + + b.Property("OperationId") + .HasColumnType("uuid"); + + b.Property("RemovalProofSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("RemovalProofVersion") + .HasColumnType("integer"); + + b.Property("RemovedRecordCount") + .HasColumnType("bigint"); + + b.Property("RequestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ResultingRevision") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("OrganizationId"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.ToTable("organization_scope_destroy_receipts", "organizations", t => + { + t.HasTrigger("organization_scope_destroy_receipts_append_only"); + + t.HasCheckConstraint("CK_organization_scope_destroy_receipts_progress", "\"BatchSize\" >= 1 AND \"BatchSize\" <= 1000 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"RemovalProofVersion\" >= 1 AND \"CompletedAtUtc\" >= \"StartedAtUtc\""); + + t.HasCheckConstraint("CK_organization_scope_destroy_receipts_revisions", "\"ExpectedRevision\" >= 0 AND \"ResultingRevision\" > \"ExpectedRevision\""); + }); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeState", b => + { + b.Property("OrganizationId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CloseOperationId") + .HasColumnType("uuid"); + + b.Property("CloseRequestSha256") + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("ClosedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsClosed") + .HasColumnType("boolean"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("character(36)") + .IsFixedLength(); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("OrganizationId"); + + b.HasIndex("ScopeId") + .IsUnique(); + + b.HasIndex("IsClosed", "ClosedAtUtc", "OrganizationId"); + + b.ToTable("organization_scope_states", "organizations", t => + { + t.HasTrigger("organization_scope_states_closed_immutable"); + + t.HasCheckConstraint("CK_organization_scope_states_closure", "(CAST(\"IsClosed\" AS integer) = 0 AND \"CloseOperationId\" IS NULL AND \"CloseRequestSha256\" IS NULL AND \"ClosedAtUtc\" IS NULL) OR (CAST(\"IsClosed\" AS integer) = 1 AND \"CloseOperationId\" IS NOT NULL AND \"CloseRequestSha256\" IS NOT NULL AND \"ClosedAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_organization_scope_states_version", "\"Version\" >= 0"); + }); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationEnrollmentClaim", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.OrganizationEnrollmentLink", null) + .WithMany() + .HasForeignKey("EnrollmentLinkId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.OrganizationMembership", null) + .WithMany() + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationEnrollmentLink", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationInvitation", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationMembership", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeDestroyOperation", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeState", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeDestroyReceipt", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeState", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/20260811150412_AddOrganizationMembershipExportIndex.cs b/src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/20260811150412_AddOrganizationMembershipExportIndex.cs new file mode 100644 index 0000000..5dad4d0 --- /dev/null +++ b/src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/20260811150412_AddOrganizationMembershipExportIndex.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gma.Modules.Organizations.Persistence.PostgreSqlMigrations.Migrations +{ + /// + public partial class AddOrganizationMembershipExportIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_organization_memberships_OrganizationId_Id", + schema: "organizations", + table: "organization_memberships", + columns: new[] { "OrganizationId", "Id" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_organization_memberships_OrganizationId_Id", + schema: "organizations", + table: "organization_memberships"); + } + } +} diff --git a/src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/OrganizationsDbContextModelSnapshot.cs b/src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/OrganizationsDbContextModelSnapshot.cs index ae23b85..15f631b 100644 --- a/src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/OrganizationsDbContextModelSnapshot.cs +++ b/src/Gma.Modules.Organizations.Persistence.PostgreSqlMigrations/Migrations/OrganizationsDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Gma.Modules.Organizations.Persistence; using Microsoft.EntityFrameworkCore; @@ -489,6 +489,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("OrganizationId", "Id"); + b.HasIndex("OrganizationId", "SubjectId") .IsUnique(); diff --git a/src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/20260811150424_AddOrganizationMembershipExportIndex.Designer.cs b/src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/20260811150424_AddOrganizationMembershipExportIndex.Designer.cs new file mode 100644 index 0000000..f54fd46 --- /dev/null +++ b/src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/20260811150424_AddOrganizationMembershipExportIndex.Designer.cs @@ -0,0 +1,763 @@ +// +using System; +using Gma.Modules.Organizations.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Gma.Modules.Organizations.Persistence.SqlServerMigrations.Migrations +{ + [DbContext(typeof(OrganizationsDbContext))] + [Migration("20260811150424_AddOrganizationMembershipExportIndex")] + partial class AddOrganizationMembershipExportIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("organizations") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Gma.Framework.Messaging.Infrastructure.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Handler") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Attempts") + .HasColumnType("int"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("FailedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LockedBy") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessingStartedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("ScopeId"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id", "Handler"); + + b.HasIndex("Handler", "Status"); + + b.HasIndex("Status", "ProcessedAtUtc"); + + b.ToTable("inbox_messages", "organizations"); + }); + + modelBuilder.Entity("Gma.Framework.Messaging.Infrastructure.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Attempts") + .HasColumnType("int"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("LockedBy") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("LockedUntilUtc") + .HasColumnType("datetimeoffset"); + + b.Property("NextAttemptAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("ScopeId") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)") + .HasColumnName("ScopeId"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Version") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedAtUtc", "NextAttemptAtUtc", "LockedUntilUtc", "CreatedAtUtc"); + + b.ToTable("outbox_messages", "organizations"); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActiveOwnerCount") + .HasColumnType("int"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("nvarchar(192)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("CreationRequestFingerprint") + .HasMaxLength(64) + .HasColumnType("nchar(64)") + .IsFixedLength(); + + b.Property("LastChangedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("nvarchar(192)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("LastMutationKind") + .HasColumnType("int"); + + b.Property("LastMutationOperationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Status", "Slug"); + + b.ToTable("organizations", "organizations"); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationEnrollmentClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("DecisionExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("EnrollmentLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("LastChangedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("nvarchar(192)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("MembershipId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubjectId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("MembershipId"); + + b.HasIndex("EnrollmentLinkId", "SubjectId") + .IsUnique(); + + b.HasIndex("Status", "DecisionExpiresAtUtc"); + + b.HasIndex("OrganizationId", "Status", "CreatedAtUtc"); + + b.HasIndex("OrganizationId", "SubjectId", "Status", "DecisionExpiresAtUtc"); + + b.ToTable("organization_enrollment_claims", "organizations"); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationEnrollmentLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovalMode") + .HasColumnType("int"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("nvarchar(192)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("CreatorSubjectId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LastChangedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("nvarchar(192)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("MaximumClaims") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ReplacesEnrollmentLinkId") + .HasColumnType("uniqueidentifier"); + + b.Property("ReplacesEnrollmentLinkVersion") + .HasColumnType("bigint"); + + b.Property("ReservedClaims") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TokenDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nchar(64)") + .IsFixedLength(); + + b.Property("TokenVersion") + .HasColumnType("int"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ReplacesEnrollmentLinkId") + .IsUnique() + .HasFilter("[ReplacesEnrollmentLinkId] IS NOT NULL"); + + b.HasIndex("TokenDigest") + .IsUnique(); + + b.HasIndex("Status", "ExpiresAtUtc"); + + b.HasIndex("Status", "LastChangedAtUtc"); + + b.HasIndex("OrganizationId", "Status", "CreatedAtUtc"); + + b.ToTable("organization_enrollment_links", "organizations", t => + { + t.HasCheckConstraint("CK_organization_enrollment_links_replacement_lineage", "(\"ReplacesEnrollmentLinkId\" IS NULL AND \"ReplacesEnrollmentLinkVersion\" IS NULL) OR (\"ReplacesEnrollmentLinkId\" IS NOT NULL AND \"ReplacesEnrollmentLinkId\" <> \"Id\" AND \"ReplacesEnrollmentLinkVersion\" > 0)"); + }); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AcceptedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("AcceptedMembershipId") + .HasColumnType("uniqueidentifier"); + + b.Property("AcceptedSubjectId") + .HasMaxLength(160) + .HasColumnType("nvarchar(160)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("nvarchar(192)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("InviterSubjectId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("LastChangedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("nvarchar(192)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("RecipientEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("ReplacesInvitationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ReplacesInvitationVersion") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TokenDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nchar(64)") + .IsFixedLength(); + + b.Property("TokenVersion") + .HasColumnType("int"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ReplacesInvitationId") + .IsUnique() + .HasFilter("[ReplacesInvitationId] IS NOT NULL"); + + b.HasIndex("TokenDigest") + .IsUnique(); + + b.HasIndex("Status", "ExpiresAtUtc"); + + b.HasIndex("Status", "LastChangedAtUtc"); + + b.HasIndex("OrganizationId", "Status", "CreatedAtUtc"); + + b.ToTable("organization_invitations", "organizations", t => + { + t.HasCheckConstraint("CK_organization_invitations_replacement_lineage", "(\"ReplacesInvitationId\" IS NULL AND \"ReplacesInvitationVersion\" IS NULL) OR (\"ReplacesInvitationId\" IS NOT NULL AND \"ReplacesInvitationId\" <> \"Id\" AND \"ReplacesInvitationVersion\" > 0)"); + }); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("nvarchar(192)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("JoinedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LastChangedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("LastChangedBy") + .IsRequired() + .HasMaxLength(192) + .HasColumnType("nvarchar(192)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("LastMutationKind") + .HasColumnType("int"); + + b.Property("LastMutationOperationId") + .HasColumnType("uniqueidentifier"); + + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubjectId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("nvarchar(160)") + .UseCollation("Latin1_General_100_BIN2"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Id"); + + b.HasIndex("OrganizationId", "SubjectId") + .IsUnique(); + + b.HasIndex("OrganizationId", "Status", "Role"); + + b.HasIndex("SubjectId", "Status", "OrganizationId"); + + b.ToTable("organization_memberships", "organizations"); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeDestroyOperation", b => + { + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("BatchSize") + .HasColumnType("int"); + + b.Property("CompletedBatchCount") + .HasColumnType("int"); + + b.Property("ExpectedRevision") + .HasColumnType("bigint"); + + b.Property("OperationId") + .HasColumnType("uniqueidentifier"); + + b.Property("ProofVersion") + .HasColumnType("int"); + + b.Property("RemovalProofSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nchar(64)") + .IsFixedLength(); + + b.Property("RemovedRecordCount") + .HasColumnType("bigint"); + + b.Property("RequestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nchar(64)") + .IsFixedLength(); + + b.Property("ResultingRevision") + .HasColumnType("bigint"); + + b.Property("Stage") + .HasColumnType("int"); + + b.Property("StartedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedAtUtc") + .IsConcurrencyToken() + .HasColumnType("datetimeoffset"); + + b.HasKey("OrganizationId"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.ToTable("organization_scope_destroy_operations", "organizations", t => + { + t.HasCheckConstraint("CK_organization_scope_destroy_operations_batch", "\"BatchSize\" >= 1 AND \"BatchSize\" <= 1000"); + + t.HasCheckConstraint("CK_organization_scope_destroy_operations_progress", "\"Stage\" >= 1 AND \"Stage\" <= 7 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"ProofVersion\" >= 1 AND \"UpdatedAtUtc\" >= \"StartedAtUtc\""); + + t.HasCheckConstraint("CK_organization_scope_destroy_operations_revisions", "\"ExpectedRevision\" >= 0 AND \"ResultingRevision\" > \"ExpectedRevision\""); + }); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeDestroyReceipt", b => + { + b.Property("OrganizationId") + .HasColumnType("uniqueidentifier"); + + b.Property("BatchSize") + .HasColumnType("int"); + + b.Property("CompletedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CompletedBatchCount") + .HasColumnType("int"); + + b.Property("ExpectedRevision") + .HasColumnType("bigint"); + + b.Property("OperationId") + .HasColumnType("uniqueidentifier"); + + b.Property("RemovalProofSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nchar(64)") + .IsFixedLength(); + + b.Property("RemovalProofVersion") + .HasColumnType("int"); + + b.Property("RemovedRecordCount") + .HasColumnType("bigint"); + + b.Property("RequestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nchar(64)") + .IsFixedLength(); + + b.Property("ResultingRevision") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetimeoffset"); + + b.HasKey("OrganizationId"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.ToTable("organization_scope_destroy_receipts", "organizations", t => + { + t.HasTrigger("organization_scope_destroy_receipts_append_only"); + + t.HasCheckConstraint("CK_organization_scope_destroy_receipts_progress", "\"BatchSize\" >= 1 AND \"BatchSize\" <= 1000 AND \"RemovedRecordCount\" >= 0 AND \"CompletedBatchCount\" >= 0 AND \"RemovalProofVersion\" >= 1 AND \"CompletedAtUtc\" >= \"StartedAtUtc\""); + + t.HasCheckConstraint("CK_organization_scope_destroy_receipts_revisions", "\"ExpectedRevision\" >= 0 AND \"ResultingRevision\" > \"ExpectedRevision\""); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeState", b => + { + b.Property("OrganizationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CloseOperationId") + .HasColumnType("uniqueidentifier"); + + b.Property("CloseRequestSha256") + .HasMaxLength(64) + .HasColumnType("nchar(64)") + .IsFixedLength(); + + b.Property("ClosedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("IsClosed") + .HasColumnType("bit"); + + b.Property("ScopeId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("nchar(36)") + .IsFixedLength(); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("OrganizationId"); + + b.HasIndex("ScopeId") + .IsUnique(); + + b.HasIndex("IsClosed", "ClosedAtUtc", "OrganizationId"); + + b.ToTable("organization_scope_states", "organizations", t => + { + t.HasTrigger("organization_scope_states_closed_immutable"); + + t.HasCheckConstraint("CK_organization_scope_states_closure", "(CAST(\"IsClosed\" AS integer) = 0 AND \"CloseOperationId\" IS NULL AND \"CloseRequestSha256\" IS NULL AND \"ClosedAtUtc\" IS NULL) OR (CAST(\"IsClosed\" AS integer) = 1 AND \"CloseOperationId\" IS NOT NULL AND \"CloseRequestSha256\" IS NOT NULL AND \"ClosedAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_organization_scope_states_version", "\"Version\" >= 0"); + }); + + b.HasAnnotation("SqlServer:UseSqlOutputClause", false); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationEnrollmentClaim", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.OrganizationEnrollmentLink", null) + .WithMany() + .HasForeignKey("EnrollmentLinkId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.OrganizationMembership", null) + .WithMany() + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationEnrollmentLink", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationInvitation", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Aggregates.OrganizationMembership", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Aggregates.Organization", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeDestroyOperation", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeState", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeDestroyReceipt", b => + { + b.HasOne("Gma.Modules.Organizations.Domain.Entities.OrganizationScopeState", null) + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/20260811150424_AddOrganizationMembershipExportIndex.cs b/src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/20260811150424_AddOrganizationMembershipExportIndex.cs new file mode 100644 index 0000000..2bd1856 --- /dev/null +++ b/src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/20260811150424_AddOrganizationMembershipExportIndex.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gma.Modules.Organizations.Persistence.SqlServerMigrations.Migrations +{ + /// + public partial class AddOrganizationMembershipExportIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_organization_memberships_OrganizationId_Id", + schema: "organizations", + table: "organization_memberships", + columns: new[] { "OrganizationId", "Id" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_organization_memberships_OrganizationId_Id", + schema: "organizations", + table: "organization_memberships"); + } + } +} diff --git a/src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/OrganizationsDbContextModelSnapshot.cs b/src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/OrganizationsDbContextModelSnapshot.cs index a924f05..4937a29 100644 --- a/src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/OrganizationsDbContextModelSnapshot.cs +++ b/src/Gma.Modules.Organizations.Persistence.SqlServerMigrations/Migrations/OrganizationsDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Gma.Modules.Organizations.Persistence; using Microsoft.EntityFrameworkCore; @@ -505,6 +505,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("OrganizationId", "Id"); + b.HasIndex("OrganizationId", "SubjectId") .IsUnique(); diff --git a/src/Gma.Modules.Organizations.Persistence/Configurations/OrganizationMembershipConfiguration.cs b/src/Gma.Modules.Organizations.Persistence/Configurations/OrganizationMembershipConfiguration.cs index 82513b3..a10b280 100644 --- a/src/Gma.Modules.Organizations.Persistence/Configurations/OrganizationMembershipConfiguration.cs +++ b/src/Gma.Modules.Organizations.Persistence/Configurations/OrganizationMembershipConfiguration.cs @@ -27,6 +27,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(membership => membership.LastMutationKind) .HasConversion(); builder.HasIndex(membership => new { membership.OrganizationId, membership.SubjectId }).IsUnique(); + builder.HasIndex(membership => new { membership.OrganizationId, membership.Id }); builder.HasIndex(membership => new { membership.SubjectId, membership.Status, membership.OrganizationId }); builder.HasIndex(membership => new { membership.OrganizationId, membership.Status, membership.Role }); builder.HasOne() diff --git a/tests/Gma.Modules.Organizations.IntegrationTests/OrganizationMembershipExportIndexMigrationTests.cs b/tests/Gma.Modules.Organizations.IntegrationTests/OrganizationMembershipExportIndexMigrationTests.cs new file mode 100644 index 0000000..ccb2549 --- /dev/null +++ b/tests/Gma.Modules.Organizations.IntegrationTests/OrganizationMembershipExportIndexMigrationTests.cs @@ -0,0 +1,87 @@ +namespace Gma.Modules.Organizations.IntegrationTests; + +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using PostgreSqlMigration = + Gma.Modules.Organizations.Persistence.PostgreSqlMigrations.Migrations + .AddOrganizationMembershipExportIndex; +using SqlServerMigration = + Gma.Modules.Organizations.Persistence.SqlServerMigrations.Migrations + .AddOrganizationMembershipExportIndex; +using Xunit; + +[Trait("Category", "Unit")] +public sealed class OrganizationMembershipExportIndexMigrationTests +{ + private const string IndexName = + "IX_organization_memberships_OrganizationId_Id"; + + [Fact] + public void PostgreSql_migration_adds_the_membership_export_keyset_index() => + AssertMigration( + new PostgreSqlMigrationProbe().BuildUpOperations(), + new PostgreSqlMigrationProbe().BuildDownOperations()); + + [Fact] + public void Sql_server_migration_adds_the_membership_export_keyset_index() => + AssertMigration( + new SqlServerMigrationProbe().BuildUpOperations(), + new SqlServerMigrationProbe().BuildDownOperations()); + + private static void AssertMigration( + IReadOnlyList upOperations, + IReadOnlyList downOperations) + { + CreateIndexOperation create = Assert.IsType( + Assert.Single(upOperations)); + Assert.Equal(IndexName, create.Name); + Assert.Equal("organizations", create.Schema); + Assert.Equal("organization_memberships", create.Table); + Assert.Equal(["OrganizationId", "Id"], create.Columns); + Assert.False(create.IsUnique); + + DropIndexOperation drop = Assert.IsType( + Assert.Single(downOperations)); + Assert.Equal(IndexName, drop.Name); + Assert.Equal("organizations", drop.Schema); + Assert.Equal("organization_memberships", drop.Table); + } + + private sealed class PostgreSqlMigrationProbe : PostgreSqlMigration + { + public List BuildUpOperations() + { + MigrationBuilder builder = new( + "Npgsql.EntityFrameworkCore.PostgreSQL"); + base.Up(builder); + return builder.Operations; + } + + public List BuildDownOperations() + { + MigrationBuilder builder = new( + "Npgsql.EntityFrameworkCore.PostgreSQL"); + base.Down(builder); + return builder.Operations; + } + } + + private sealed class SqlServerMigrationProbe : SqlServerMigration + { + public List BuildUpOperations() + { + MigrationBuilder builder = new( + "Microsoft.EntityFrameworkCore.SqlServer"); + base.Up(builder); + return builder.Operations; + } + + public List BuildDownOperations() + { + MigrationBuilder builder = new( + "Microsoft.EntityFrameworkCore.SqlServer"); + base.Down(builder); + return builder.Operations; + } + } +} diff --git a/tests/Gma.Modules.Organizations.IntegrationTests/OrganizationScopeLifecyclePostgreSqlIntegrationTests.cs b/tests/Gma.Modules.Organizations.IntegrationTests/OrganizationScopeLifecyclePostgreSqlIntegrationTests.cs index c0b9564..810fa1c 100644 --- a/tests/Gma.Modules.Organizations.IntegrationTests/OrganizationScopeLifecyclePostgreSqlIntegrationTests.cs +++ b/tests/Gma.Modules.Organizations.IntegrationTests/OrganizationScopeLifecyclePostgreSqlIntegrationTests.cs @@ -25,6 +25,9 @@ namespace Gma.Modules.Organizations.IntegrationTests; [Trait("Category", "Integration")] public sealed class OrganizationScopeLifecyclePostgreSqlIntegrationTests { + private const int LargeMembershipCount = 10_001; + private const string MembershipExportIndexName = + "IX_organization_memberships_OrganizationId_Id"; private static readonly DateTimeOffset Now = new(2026, 8, 4, 14, 0, 0, TimeSpan.Zero); @@ -133,6 +136,224 @@ await VerifyTerminalProtectionAsync( seeded.OrganizationId); } + [DockerFact] + public async Task Membership_export_keyset_pages_more_than_ten_thousand_and_rejects_revision_drift() + { + await using PostgreSqlContainer postgreSql = + new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("organization_membership_export_tests") + .Build(); + await postgreSql.StartAsync(); + string connectionString = postgreSql.GetConnectionString(); + Guid organizationId = Id(50_000); + + await using (OrganizationsDbContext seed = + CreateDbContext(connectionString)) + { + await seed.Database.MigrateAsync(); + Organization organization = Organization.Create( + organizationId, + "Large Export House", + "large-export-house", + "user:owner", + Id(50_001), + Now).Value; + OrganizationMembership owner = CreateMembership( + Id(1), + organizationId, + "subject-1", + DomainMembershipRole.Owner); + seed.AddRange(organization, owner); + await seed.SaveChangesAsync(); + await seed.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO organizations.organization_memberships + ("Id", "OrganizationId", "SubjectId", "Role", "Status", + "Version", "CreatedBy", "JoinedAtUtc", "LastChangedBy", + "LastChangedAtUtc") + SELECT + ('00000000-0000-0000-0000-' || + lpad(value::text, 12, '0'))::uuid, + {organizationId}, + 'subject-' || value::text, + 1, + 1, + 1, + 'system:large-export', + {Now}, + 'system:large-export', + {Now} + FROM generate_series(2, {LargeMembershipCount}) AS value; + """); + await seed.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE organizations.organization_scope_states + SET "Version" = "Version" + 1 + WHERE "OrganizationId" = {organizationId}; + """); + } + + await VerifyMembershipExportIndexAsync( + connectionString, + MembershipExportIndexName); + long revision = await ReadOpenRevisionAsync( + connectionString, + organizationId); + Assert.Equal(2, revision); + await VerifyLargeMembershipExportAsync( + connectionString, + organizationId, + revision); + await VerifyMembershipExportRevisionDriftAsync( + connectionString, + organizationId, + revision); + } + + private static async Task VerifyMembershipExportIndexAsync( + string connectionString, + string expectedIndexName) + { + await using NpgsqlConnection connection = new(connectionString); + await connection.OpenAsync(); + await using NpgsqlCommand command = connection.CreateCommand(); + command.CommandText = """ + SELECT indexdef + FROM pg_indexes + WHERE schemaname = 'organizations' + AND tablename = 'organization_memberships' + AND indexname = @indexName; + """; + command.Parameters.AddWithValue("indexName", expectedIndexName); + string? definition = (string?)await command.ExecuteScalarAsync(); + + Assert.NotNull(definition); + Assert.Contains( + "(\"OrganizationId\", \"Id\")", + definition, + StringComparison.Ordinal); + } + + private static async Task ReadOpenRevisionAsync( + string connectionString, + Guid organizationId) + { + await using OrganizationsDbContext dbContext = + CreateDbContext(connectionString); + OrganizationScopeSnapshot snapshot = await new + OrganizationScopeLifecycleService(dbContext, new FixedClock()) + .GetSnapshotAsync(organizationId, CancellationToken.None); + Assert.Equal(OrganizationScopeStatus.Open, snapshot.Status); + return snapshot.Revision; + } + + private static async Task VerifyLargeMembershipExportAsync( + string connectionString, + Guid organizationId, + long revision) + { + await using OrganizationsDbContext dbContext = + CreateDbContext(connectionString); + OrganizationScopeLifecycleService lifecycle = + new(dbContext, new FixedClock()); + string? afterCursor = null; + Guid? previousMembershipId = null; + int exportedCount = 0; + int pageCount = 0; + bool hasMore; + do + { + OrganizationScopeExportPage page = await lifecycle.ExportAsync( + new OrganizationScopeExportRequest( + organizationId, + revision, + OrganizationScopeExportStore.Memberships, + afterCursor, + OrganizationScopeLifecycleLimits.MaximumPageSize), + CancellationToken.None); + Assert.Equal(OrganizationScopeExportStatus.Completed, page.Status); + Assert.Equal(revision, page.ScopeRevision); + Assert.InRange( + page.Records.Count, + 1, + OrganizationScopeLifecycleLimits.MaximumPageSize); + foreach (OrganizationScopeExportRecord record in page.Records) + { + OrganizationScopeMembershipExportRecord membership = + Assert.IsType( + record); + Assert.Equal(organizationId, membership.OrganizationId); + if (previousMembershipId.HasValue) + { + Assert.True(membership.MembershipId.CompareTo( + previousMembershipId.Value) > 0); + } + + previousMembershipId = membership.MembershipId; + exportedCount++; + } + + Assert.Equal( + "id:" + previousMembershipId!.Value.ToString("D"), + page.NextCursor); + afterCursor = page.NextCursor; + hasMore = page.HasMore; + pageCount++; + } + while (hasMore); + + Assert.Equal(LargeMembershipCount, exportedCount); + Assert.Equal( + (int)Math.Ceiling( + LargeMembershipCount / + (double)OrganizationScopeLifecycleLimits.MaximumPageSize), + pageCount); + Assert.Equal(Id(LargeMembershipCount), previousMembershipId); + } + + private static async Task VerifyMembershipExportRevisionDriftAsync( + string connectionString, + Guid organizationId, + long selectedRevision) + { + await using OrganizationsDbContext reader = + CreateDbContext(connectionString); + OrganizationScopeLifecycleService lifecycle = + new(reader, new FixedClock()); + OrganizationScopeExportPage first = await lifecycle.ExportAsync( + new OrganizationScopeExportRequest( + organizationId, + selectedRevision, + OrganizationScopeExportStore.Memberships, + AfterCursor: null, + PageSize: 200), + CancellationToken.None); + Assert.Equal(OrganizationScopeExportStatus.Completed, first.Status); + Assert.True(first.HasMore); + + await using (OrganizationsDbContext writer = + CreateDbContext(connectionString)) + { + writer.Memberships.Add(CreateMembership( + Id(LargeMembershipCount + 1), + organizationId, + "subject-after-selection", + DomainMembershipRole.Member)); + await writer.SaveChangesAsync(); + } + + OrganizationScopeExportPage stale = await lifecycle.ExportAsync( + new OrganizationScopeExportRequest( + organizationId, + selectedRevision, + OrganizationScopeExportStore.Memberships, + first.NextCursor, + PageSize: 200), + CancellationToken.None); + Assert.Equal(OrganizationScopeExportStatus.Stale, stale.Status); + Assert.Empty(stale.Records); + Assert.False(stale.HasMore); + Assert.Equal(selectedRevision + 1, stale.ScopeRevision); + } + private static async Task SeedAsync(string connectionString) { await using OrganizationsDbContext dbContext = diff --git a/tests/Gma.Modules.Organizations.IntegrationTests/OrganizationsSqlServerIdentityIntegrationTests.cs b/tests/Gma.Modules.Organizations.IntegrationTests/OrganizationsSqlServerIdentityIntegrationTests.cs index a480175..d51dbcb 100644 --- a/tests/Gma.Modules.Organizations.IntegrationTests/OrganizationsSqlServerIdentityIntegrationTests.cs +++ b/tests/Gma.Modules.Organizations.IntegrationTests/OrganizationsSqlServerIdentityIntegrationTests.cs @@ -1,5 +1,7 @@ namespace Gma.Modules.Organizations.IntegrationTests; +using System.Data; +using System.Data.Common; using Gma.Framework.Pagination; using Gma.Modules.Organizations.Domain.Aggregates; using Gma.Modules.Organizations.Domain.Enums; @@ -28,6 +30,7 @@ public async Task Case_distinct_subjects_and_actors_remain_exact_on_sql_server() await using OrganizationsDbContext dbContext = CreateDbContext( sqlServer.GetConnectionString()); await dbContext.Database.MigrateAsync(); + await VerifyMembershipExportIndexAsync(dbContext); Organization organization = Organization.Create( Guid.NewGuid(), @@ -139,6 +142,54 @@ public async Task Case_distinct_subjects_and_actors_remain_exact_on_sql_server() membership => membership.CreatedBy == "actor-case")); } + private static async Task VerifyMembershipExportIndexAsync( + OrganizationsDbContext dbContext) + { + DbConnection connection = dbContext.Database.GetDbConnection(); + bool shouldClose = connection.State != ConnectionState.Open; + if (shouldClose) + { + await connection.OpenAsync(); + } + + try + { + await using DbCommand command = connection.CreateCommand(); + command.CommandText = """ + SELECT STRING_AGG(column_definition.name, ',') + WITHIN GROUP (ORDER BY index_column.key_ordinal) + FROM sys.indexes AS index_definition + INNER JOIN sys.tables AS table_definition + ON table_definition.object_id = + index_definition.object_id + INNER JOIN sys.schemas AS schema_definition + ON schema_definition.schema_id = + table_definition.schema_id + INNER JOIN sys.index_columns AS index_column + ON index_column.object_id = index_definition.object_id + AND index_column.index_id = index_definition.index_id + AND index_column.key_ordinal > 0 + INNER JOIN sys.columns AS column_definition + ON column_definition.object_id = index_column.object_id + AND column_definition.column_id = index_column.column_id + WHERE schema_definition.name = 'organizations' + AND table_definition.name = 'organization_memberships' + AND index_definition.name = + 'IX_organization_memberships_OrganizationId_Id' + GROUP BY index_definition.name; + """; + string? columns = (string?)await command.ExecuteScalarAsync(); + Assert.Equal("OrganizationId,Id", columns); + } + finally + { + if (shouldClose) + { + await connection.CloseAsync(); + } + } + } + private static OrganizationEnrollmentClaim CreatePendingClaim( Guid organizationId, Guid linkId, diff --git a/tests/Gma.Modules.Organizations.Tests/Persistence/OrganizationRepositoryTests.cs b/tests/Gma.Modules.Organizations.Tests/Persistence/OrganizationRepositoryTests.cs index 166e45b..4c42fc2 100644 --- a/tests/Gma.Modules.Organizations.Tests/Persistence/OrganizationRepositoryTests.cs +++ b/tests/Gma.Modules.Organizations.Tests/Persistence/OrganizationRepositoryTests.cs @@ -138,6 +138,21 @@ public void Membership_model_has_one_unique_organization_subject_index() Assert.True(index.IsUnique); } + [Fact] + public void Membership_model_has_organization_id_keyset_export_index() + { + using OrganizationsDbContext dbContext = CreateDbContext(); + + var entity = dbContext.Model.FindEntityType( + typeof(OrganizationMembership)); + var index = Assert.Single(entity!.GetIndexes(), item => + item.Properties.Select(property => property.Name).SequenceEqual( + [nameof(OrganizationMembership.OrganizationId), + nameof(OrganizationMembership.Id)])); + + Assert.False(index.IsUnique); + } + [Fact] public void Organization_creation_fingerprint_is_optional_fixed_length_state() {