diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml index a03ee96..55cadcf 100644 --- a/.github/workflows/dotnet-core.yml +++ b/.github/workflows/dotnet-core.yml @@ -1,28 +1,66 @@ -name: .NET Core CI +name: .NET CI on: push: - branches: [ master ] + branches: + - master pull_request: - branches: [ master ] + branches: + - master + +permissions: + contents: read jobs: build: - runs-on: ubuntu-latest + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Setup .NET SDK + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore src/EFCore.UpdateGraph.slnx + + - name: Build + run: dotnet build src/EFCore.UpdateGraph.slnx --configuration Release --no-restore + + - name: Test + run: dotnet test src/EFCore.UpdateGraph.slnx --configuration Release --no-build --verbosity normal + + publish: + name: Publish NuGet package + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 3.1.301 - - name: Install dependencies - run: dotnet restore - - name: Build - run: dotnet build --configuration Release --no-restore - - name: Test - run: dotnet test --no-restore --verbosity normal - - name: Publish SimpleGraph Update - uses: brandedoutcast/publish-nuget@v2.5.2 - with: - PROJECT_FILE_PATH: src/Diwink.Extensions.EntityFrameworkCore/Diwink.Extensions.EntityFrameworkCore.csproj - NUGET_KEY: ${{secrets.NUGET_API_KEY}} + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Setup .NET SDK + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 + with: + dotnet-version: 10.0.x + + - name: Restore package project + run: dotnet restore src/Diwink.Extensions.EntityFrameworkCore/Diwink.Extensions.EntityFrameworkCore.csproj + + - name: Pack + run: dotnet pack src/Diwink.Extensions.EntityFrameworkCore/Diwink.Extensions.EntityFrameworkCore.csproj --configuration Release --no-restore --output ./artifacts + + - name: Skip publish when NUGET_API_KEY is not configured + if: env.NUGET_API_KEY == '' + run: echo "NUGET_API_KEY is not configured. Skipping package publish." + + - name: Publish to NuGet.org + if: env.NUGET_API_KEY != '' + run: dotnet nuget push "./artifacts/*.nupkg" --api-key "$NUGET_API_KEY" --source "https://api.nuget.org/v3/index.json" --skip-duplicate diff --git a/.gitignore b/.gitignore index dfcfd56..1159e29 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore # User-specific files +.idea/ +*.iml *.rsuser *.suo *.user diff --git a/EfCore.UpdateGraph.sln b/EfCore.UpdateGraph.sln deleted file mode 100644 index c2e2ba7..0000000 --- a/EfCore.UpdateGraph.sln +++ /dev/null @@ -1,50 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30413.136 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FakeModel", "src\FakeModel\FakeModel.csproj", "{0E794F80-E118-4DCA-9B16-77A1EFD016DB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Diwink.Extensions.EntityFrameworkCore", "src\Diwink.Extensions.EntityFrameworkCore\Diwink.Extensions.EntityFrameworkCore.csproj", "{00D8F0BB-7A3D-40E7-AB1F-5500F5D0B691}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests", "src\UnitTests\UnitTests.csproj", "{E65E0BCE-F23E-4E10-B031-08A932C8E67D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B88F5DA9-01EF-4705-B60D-725A2DFA8D82}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{59559C1B-D5D6-4520-B19D-860B70BFBB0F}" - ProjectSection(SolutionItems) = preProject - .github\workflows\dotnet-core.yml = .github\workflows\dotnet-core.yml - README.md = README.md - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {0E794F80-E118-4DCA-9B16-77A1EFD016DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0E794F80-E118-4DCA-9B16-77A1EFD016DB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0E794F80-E118-4DCA-9B16-77A1EFD016DB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0E794F80-E118-4DCA-9B16-77A1EFD016DB}.Release|Any CPU.Build.0 = Release|Any CPU - {00D8F0BB-7A3D-40E7-AB1F-5500F5D0B691}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {00D8F0BB-7A3D-40E7-AB1F-5500F5D0B691}.Debug|Any CPU.Build.0 = Debug|Any CPU - {00D8F0BB-7A3D-40E7-AB1F-5500F5D0B691}.Release|Any CPU.ActiveCfg = Release|Any CPU - {00D8F0BB-7A3D-40E7-AB1F-5500F5D0B691}.Release|Any CPU.Build.0 = Release|Any CPU - {E65E0BCE-F23E-4E10-B031-08A932C8E67D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E65E0BCE-F23E-4E10-B031-08A932C8E67D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E65E0BCE-F23E-4E10-B031-08A932C8E67D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E65E0BCE-F23E-4E10-B031-08A932C8E67D}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {0E794F80-E118-4DCA-9B16-77A1EFD016DB} = {B88F5DA9-01EF-4705-B60D-725A2DFA8D82} - {00D8F0BB-7A3D-40E7-AB1F-5500F5D0B691} = {B88F5DA9-01EF-4705-B60D-725A2DFA8D82} - {E65E0BCE-F23E-4E10-B031-08A932C8E67D} = {B88F5DA9-01EF-4705-B60D-725A2DFA8D82} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {8BD0A6B1-A839-46FD-8892-B1FDA1DF6A6B} - EndGlobalSection -EndGlobal diff --git a/README.md b/README.md index 86e33c7..6ab947a 100644 --- a/README.md +++ b/README.md @@ -23,4 +23,42 @@ dbContext.SaveChanges(); ``` -Please don't hesitate to contribute or give us your feedback and/or advice :rose: :rose: \ No newline at end of file +## v2 Rebuild (EF Core 10+) + +The v2 rebuild (`src/`) targets .NET 10 and EF Core 10.x with explicit, +contract-driven relationship semantics. + +### Supported Relationship Patterns + +| Pattern | Add | Update | Remove | Outcome | +|---------|-----|--------|--------|---------| +| Pure many-to-many (skip nav) | Link created | Properties updated | Link removed | Related entity preserved | +| Payload many-to-many (join entity) | Association inserted | Payload updated | Association deleted | Related entities preserved | +| Required one-to-one | Dependent inserted | Properties updated | Dependent deleted | Cascade delete | +| Optional one-to-one | Dependent inserted | Properties updated | FK nulled | Dependent preserved | + +### Rejection Behavior + +| Scenario | Exception | +|----------|-----------| +| Unsupported relationship mutated (e.g., one-to-many) | `UnsupportedNavigationMutatedException` | +| Unloaded navigation with mutations in updated graph | `UnloadedNavigationMutationException` | +| Mixed supported + unsupported mutations | `PartialMutationNotAllowedException` | +| Unsupported relationship unchanged | Silently skipped | + +### v2 Usage + +```csharp +var updated = BuildDesiredState(); // detached graph + +var existing = await dbContext.Courses + .Include(c => c.Tags) + .Include(c => c.Policy) + .Include(c => c.MentorAssignments) + .FirstAsync(c => c.Id == id); + +dbContext.InsertUpdateOrDeleteGraph(updated, existing); +await dbContext.SaveChangesAsync(); +``` + +Please don't hesitate to contribute or give us your feedback and/or advice :rose: :rose: diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/CourseConfiguration.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/CourseConfiguration.cs new file mode 100644 index 0000000..917762f --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/CourseConfiguration.cs @@ -0,0 +1,38 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Configurations; + +public class CourseConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the EF Core model mapping for the Course entity. + /// + /// The entity type builder for configuring Course. + /// + /// - Sets Id as the primary key. + /// - Marks Title and Code as required with maximum lengths of 300 and 50 respectively. + /// - Adds a unique composite index on { CatalogId, Code }. + /// - Configures a required one-to-one relationship to CoursePolicy with CoursePolicy.CourseId as the foreign key and cascade delete. + /// - Configures a many-to-many relationship with Tag using the join table named CourseTopicTag. + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + builder.Property(c => c.Title).IsRequired().HasMaxLength(300); + builder.Property(c => c.Code).IsRequired().HasMaxLength(50); + builder.HasIndex(c => new { c.CatalogId, c.Code }).IsUnique(); + + // Required one-to-one: CoursePolicy + builder.HasOne(c => c.Policy) + .WithOne(p => p.Course) + .HasForeignKey(p => p.CourseId) + .OnDelete(DeleteBehavior.Cascade); + + // Pure many-to-many via skip navigation + builder.HasMany(c => c.Tags) + .WithMany(t => t.Courses) + .UsingEntity("CourseTopicTag"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/CourseMentorAssignmentConfiguration.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/CourseMentorAssignmentConfiguration.cs new file mode 100644 index 0000000..621051c --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/CourseMentorAssignmentConfiguration.cs @@ -0,0 +1,35 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Configurations; + +public class CourseMentorAssignmentConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the EF Core model for the CourseMentorAssignment entity. + /// + /// + /// Defines a composite primary key (CourseId, MentorId), configures property constraints for Role, AssignedOnUtc and AllocationPercent, + /// and establishes required relationships to Course and Mentor with cascade delete behavior. + /// + /// The EntityTypeBuilder for CourseMentorAssignment used to apply the configuration. + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(a => new { a.CourseId, a.MentorId }); + + builder.Property(a => a.Role).IsRequired().HasMaxLength(100); + builder.Property(a => a.AssignedOnUtc).IsRequired(); + builder.Property(a => a.AllocationPercent).HasPrecision(5, 2); + + builder.HasOne(a => a.Course) + .WithMany(c => c.MentorAssignments) + .HasForeignKey(a => a.CourseId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(a => a.Mentor) + .WithMany(m => m.CourseAssignments) + .HasForeignKey(a => a.MentorId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/CoursePolicyConfiguration.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/CoursePolicyConfiguration.cs new file mode 100644 index 0000000..c3d6a52 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/CoursePolicyConfiguration.cs @@ -0,0 +1,18 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Configurations; + +public class CoursePolicyConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the CoursePolicy entity model: sets CourseId as the primary key and makes PolicyVersion required with a maximum length of 50 characters. + /// + /// The EntityTypeBuilder for configuring the CoursePolicy entity. + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(p => p.CourseId); + builder.Property(p => p.PolicyVersion).IsRequired().HasMaxLength(50); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/LearningCatalogConfiguration.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/LearningCatalogConfiguration.cs new file mode 100644 index 0000000..2d6aa5e --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/LearningCatalogConfiguration.cs @@ -0,0 +1,32 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Configurations; + +public class LearningCatalogConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the EF Core mapping for the LearningCatalog entity. + /// + /// The used to configure keys, properties, and relationships for the LearningCatalog entity. + /// + /// Sets the primary key to Id, requires Name with a maximum length of 200 characters, configures a one-to-many relationship to Courses with cascade delete, and configures a many-to-many relationship to Tags using the join table named "CatalogTopicTag". + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + builder.Property(c => c.Name).IsRequired().HasMaxLength(200); + + // One-to-many (unsupported in v2 — present for FR-018/FR-019 testing) + builder.HasMany(c => c.Courses) + .WithOne(c => c.Catalog) + .HasForeignKey(c => c.CatalogId) + .OnDelete(DeleteBehavior.Cascade); + + // Pure many-to-many via skip navigation + builder.HasMany(c => c.Tags) + .WithMany() + .UsingEntity("CatalogTopicTag"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/MentorConfiguration.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/MentorConfiguration.cs new file mode 100644 index 0000000..9d1bc28 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/MentorConfiguration.cs @@ -0,0 +1,30 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Configurations; + +public class MentorConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the EF Core mapping for the Mentor entity. + /// + /// + /// Sets the primary key, requires DisplayName (max length 200) and Status (max length 50), + /// and configures an optional one-to-one relationship to MentorWorkspace using MentorWorkspace.MentorId + /// with delete behavior set to SetNull. + /// + /// The used to configure the Mentor entity. + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(m => m.Id); + builder.Property(m => m.DisplayName).IsRequired().HasMaxLength(200); + builder.Property(m => m.Status).IsRequired().HasMaxLength(50); + + // Optional one-to-one: MentorWorkspace + builder.HasOne(m => m.Workspace) + .WithOne(w => w.Mentor) + .HasForeignKey(w => w.MentorId) + .OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/MentorWorkspaceConfiguration.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/MentorWorkspaceConfiguration.cs new file mode 100644 index 0000000..56c5a76 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/MentorWorkspaceConfiguration.cs @@ -0,0 +1,19 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Configurations; + +public class MentorWorkspaceConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the Entity Framework Core mapping for the MentorWorkspace entity. + /// + /// The EntityTypeBuilder for MentorWorkspace used to set the primary key and property constraints (DeskCode and Building). + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(w => w.Id); + builder.Property(w => w.DeskCode).IsRequired().HasMaxLength(20); + builder.Property(w => w.Building).IsRequired().HasMaxLength(100); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/TopicTagConfiguration.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/TopicTagConfiguration.cs new file mode 100644 index 0000000..d90ae94 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Configurations/TopicTagConfiguration.cs @@ -0,0 +1,19 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Configurations; + +public class TopicTagConfiguration : IEntityTypeConfiguration +{ + /// + /// Configures the EF Core mapping for the TopicTag entity. + /// + /// The EntityTypeBuilder for TopicTag used to configure the primary key, property constraints, and indexes. + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(t => t.Id); + builder.Property(t => t.Label).IsRequired().HasMaxLength(100); + builder.HasIndex(t => t.Label).IsUnique(); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Diwink.Extensions.EntityFrameworkCore.TestModel.csproj b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Diwink.Extensions.EntityFrameworkCore.TestModel.csproj new file mode 100644 index 0000000..2da0466 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Diwink.Extensions.EntityFrameworkCore.TestModel.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/Course.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/Course.cs new file mode 100644 index 0000000..a4a4f43 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/Course.cs @@ -0,0 +1,20 @@ +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; + +public class Course +{ + public Guid Id { get; set; } + public Guid CatalogId { get; set; } + public string Title { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + + public LearningCatalog Catalog { get; set; } = null!; + + // Required one-to-one + public CoursePolicy? Policy { get; set; } + + // Many-to-many with payload + public ICollection MentorAssignments { get; set; } = []; + + // Pure many-to-many (skip navigation) + public ICollection Tags { get; set; } = []; +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/CourseMentorAssignment.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/CourseMentorAssignment.cs new file mode 100644 index 0000000..4177622 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/CourseMentorAssignment.cs @@ -0,0 +1,13 @@ +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; + +public class CourseMentorAssignment +{ + public Guid CourseId { get; set; } + public Guid MentorId { get; set; } + public string Role { get; set; } = string.Empty; + public DateTime AssignedOnUtc { get; set; } + public decimal AllocationPercent { get; set; } + + public Course Course { get; set; } = null!; + public Mentor Mentor { get; set; } = null!; +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/CoursePolicy.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/CoursePolicy.cs new file mode 100644 index 0000000..1a05d31 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/CoursePolicy.cs @@ -0,0 +1,10 @@ +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; + +public class CoursePolicy +{ + public Guid CourseId { get; set; } + public string PolicyVersion { get; set; } = string.Empty; + public bool IsMandatory { get; set; } + + public Course Course { get; set; } = null!; +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/LearningCatalog.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/LearningCatalog.cs new file mode 100644 index 0000000..7f3e3fb --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/LearningCatalog.cs @@ -0,0 +1,13 @@ +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; + +public class LearningCatalog +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + + // One-to-many (unsupported in v2 — used for FR-018/FR-019 testing) + public ICollection Courses { get; set; } = []; + + // Pure many-to-many (skip navigation) + public ICollection Tags { get; set; } = []; +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/Mentor.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/Mentor.cs new file mode 100644 index 0000000..89364e8 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/Mentor.cs @@ -0,0 +1,14 @@ +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; + +public class Mentor +{ + public Guid Id { get; set; } + public string DisplayName { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + + // Many-to-many with payload + public ICollection CourseAssignments { get; set; } = []; + + // Optional one-to-one + public MentorWorkspace? Workspace { get; set; } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/MentorWorkspace.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/MentorWorkspace.cs new file mode 100644 index 0000000..ee74ac2 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/MentorWorkspace.cs @@ -0,0 +1,11 @@ +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; + +public class MentorWorkspace +{ + public Guid Id { get; set; } + public Guid? MentorId { get; set; } + public string DeskCode { get; set; } = string.Empty; + public string Building { get; set; } = string.Empty; + + public Mentor? Mentor { get; set; } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/TopicTag.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/TopicTag.cs new file mode 100644 index 0000000..d65665f --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/Entities/TopicTag.cs @@ -0,0 +1,10 @@ +namespace Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; + +public class TopicTag +{ + public Guid Id { get; set; } + public string Label { get; set; } = string.Empty; + + // Pure many-to-many (skip navigation) + public ICollection Courses { get; set; } = []; +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.TestModel/TestDbContext.cs b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/TestDbContext.cs new file mode 100644 index 0000000..0572e49 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.TestModel/TestDbContext.cs @@ -0,0 +1,33 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.TestModel; + +public class TestDbContext : DbContext +{ + public DbSet LearningCatalogs => Set(); + public DbSet Courses => Set(); + public DbSet TopicTags => Set(); + public DbSet Mentors => Set(); + public DbSet CourseMentorAssignments => Set(); + public DbSet CoursePolicies => Set(); + public DbSet MentorWorkspaces => Set(); + + /// + /// Creates a TestDbContext configured with the specified EF Core options. + /// + /// The EF Core used to configure the context (provider, connection string, and other behaviors). + public TestDbContext(DbContextOptions options) + : base(options) + { + } + + /// + /// Applies all entity type configurations defined in the TestDbContext assembly to the provided model. + /// + /// The used to configure the EF Core model. + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(TestDbContext).Assembly); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/ManyToMany/ManyToManySafetyContractTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/ManyToMany/ManyToManySafetyContractTests.cs new file mode 100644 index 0000000..5ec54f9 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/ManyToMany/ManyToManySafetyContractTests.cs @@ -0,0 +1,87 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel; +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Contracts.ManyToMany; + +/// +/// Safety contract tests ensuring many-to-many unlink/remove operations never +/// accidentally delete related entities (FR-003). +/// +[Collection(IntegrationTestCollection.Name)] +public class ManyToManySafetyContractTests : IntegrationTestBase +{ + public ManyToManySafetyContractTests(SqlServerContainerFixture fixture) + : base(fixture) { } + + [Fact] + public async Task Remove_all_tag_associations_preserves_all_tag_entities() + { + // Arrange + await using var seedCtx = CreateContext(); + await SeedData.SeedFullScenarioAsync(seedCtx); + + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + // Remove all tags from course + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + Tags = [] + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var courseTags = await verifyCtx.Courses + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.Course1Id); + courseTags.Tags.Should().BeEmpty(); + + // All tag entities must still exist + var tagCount = await verifyCtx.TopicTags.CountAsync(); + tagCount.Should().Be(3, "removing all many-to-many links must not delete any tag entities"); + } + + [Fact] + public async Task Remove_all_assignments_preserves_all_mentor_entities() + { + // Arrange + await using var seedCtx = CreateContext(); + await SeedData.SeedFullScenarioAsync(seedCtx); + + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.MentorAssignments) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + MentorAssignments = [] + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var mentorCount = await verifyCtx.Mentors.CountAsync(); + mentorCount.Should().Be(2, "removing all payload associations must not delete any mentor entities"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/ManyToMany/PayloadAssociationContractTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/ManyToMany/PayloadAssociationContractTests.cs new file mode 100644 index 0000000..7748883 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/ManyToMany/PayloadAssociationContractTests.cs @@ -0,0 +1,155 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel; +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Contracts.ManyToMany; + +/// +/// Contract tests for many-to-many with payload (association entity) create/update/remove. +/// Validates FR-002, FR-004, FR-005, FR-006 for payload associations. +/// +[Collection(IntegrationTestCollection.Name)] +public class PayloadAssociationContractTests : IntegrationTestBase +{ + public PayloadAssociationContractTests(SqlServerContainerFixture fixture) + : base(fixture) { } + + [Fact] + public async Task Add_new_assignment_creates_association_entity_with_payload() + { + // Arrange + await using var seedCtx = CreateContext(); + await SeedData.SeedFullScenarioAsync(seedCtx); + + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.MentorAssignments) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + MentorAssignments = + [ + new CourseMentorAssignment + { + CourseId = SeedData.Course1Id, + MentorId = SeedData.Mentor1Id, + Role = "Lead", + AssignedOnUtc = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), + AllocationPercent = 75m + }, + new CourseMentorAssignment + { + CourseId = SeedData.Course1Id, + MentorId = SeedData.Mentor2Id, + Role = "Reviewer", + AssignedOnUtc = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), + AllocationPercent = 25m + } + ] + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var assignments = await verifyCtx.CourseMentorAssignments + .Where(a => a.CourseId == SeedData.Course1Id) + .ToListAsync(); + + assignments.Should().HaveCount(2); + var newAssignment = assignments.Single(a => a.MentorId == SeedData.Mentor2Id); + newAssignment.Role.Should().Be("Reviewer"); + newAssignment.AllocationPercent.Should().Be(25m); + } + + [Fact] + public async Task Update_assignment_payload_preserves_link_and_updates_fields() + { + // Arrange + await using var seedCtx = CreateContext(); + await SeedData.SeedFullScenarioAsync(seedCtx); + + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.MentorAssignments) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + MentorAssignments = + [ + new CourseMentorAssignment + { + CourseId = SeedData.Course1Id, + MentorId = SeedData.Mentor1Id, + Role = "Senior Lead", // updated payload + AssignedOnUtc = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), + AllocationPercent = 100m // updated payload + } + ] + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var assignment = await verifyCtx.CourseMentorAssignments + .SingleAsync(a => a.CourseId == SeedData.Course1Id && a.MentorId == SeedData.Mentor1Id); + + assignment.Role.Should().Be("Senior Lead"); + assignment.AllocationPercent.Should().Be(100m); + } + + [Fact] + public async Task Remove_assignment_removes_association_entity_preserves_mentor() + { + // Arrange + await using var seedCtx = CreateContext(); + await SeedData.SeedFullScenarioAsync(seedCtx); + + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.MentorAssignments) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + // Remove Mentor1's assignment — empty assignments list + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + MentorAssignments = [] + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var assignments = await verifyCtx.CourseMentorAssignments + .Where(a => a.CourseId == SeedData.Course1Id) + .ToListAsync(); + assignments.Should().BeEmpty(); + + // Mentor1 must still exist (FR-003 equivalent for payload associations) + var mentorExists = await verifyCtx.Mentors.AnyAsync(m => m.Id == SeedData.Mentor1Id); + mentorExists.Should().BeTrue("removing an association entity must not delete the related mentor"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/ManyToMany/PureManyToManyContractTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/ManyToMany/PureManyToManyContractTests.cs new file mode 100644 index 0000000..0414579 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/ManyToMany/PureManyToManyContractTests.cs @@ -0,0 +1,179 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel; +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Contracts.ManyToMany; + +/// +/// Contract tests for pure many-to-many (skip navigation) add/update/unlink outcomes. +/// Validates FR-002, FR-003, FR-004 for pure association membership. +/// +[Collection(IntegrationTestCollection.Name)] +public class PureManyToManyContractTests : IntegrationTestBase +{ + public PureManyToManyContractTests(SqlServerContainerFixture fixture) + : base(fixture) { } + + [Fact] + public async Task Add_new_tag_association_creates_link_without_affecting_existing_tags() + { + // Arrange + await using var seedCtx = CreateContext(); + await SeedData.SeedFullScenarioAsync(seedCtx); + + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + Tags = + [ + new TopicTag { Id = SeedData.Tag1Id, Label = "Architecture" }, + new TopicTag { Id = SeedData.Tag2Id, Label = "Testing" }, + new TopicTag { Id = SeedData.Tag3Id, Label = "Security" } // new link + ] + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var result = await verifyCtx.Courses + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + result.Tags.Should().HaveCount(3); + result.Tags.Select(t => t.Id).Should().Contain(SeedData.Tag3Id); + } + + [Fact] + public async Task Remove_tag_association_unlinks_without_deleting_the_tag_entity() + { + // Arrange + await using var seedCtx = CreateContext(); + await SeedData.SeedFullScenarioAsync(seedCtx); + + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + // Remove Tag2 ("Testing") from course, keep Tag1 only + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + Tags = + [ + new TopicTag { Id = SeedData.Tag1Id, Label = "Architecture" } + ] + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var result = await verifyCtx.Courses + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + result.Tags.Should().HaveCount(1); + result.Tags.Single().Id.Should().Be(SeedData.Tag1Id); + + // Tag2 entity must still exist in the database (FR-003: unlink, not delete) + var tag2Exists = await verifyCtx.TopicTags.AnyAsync(t => t.Id == SeedData.Tag2Id); + tag2Exists.Should().BeTrue("removing a many-to-many link must not delete the related entity"); + } + + [Fact] + public async Task Add_link_to_new_related_entity_creates_both_entity_and_link() + { + // Arrange + await using var seedCtx = CreateContext(); + await SeedData.SeedFullScenarioAsync(seedCtx); + + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + var newTagId = Guid.Parse("c1000000-0000-0000-0000-000000000099"); + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + Tags = + [ + new TopicTag { Id = SeedData.Tag1Id, Label = "Architecture" }, + new TopicTag { Id = SeedData.Tag2Id, Label = "Testing" }, + new TopicTag { Id = newTagId, Label = "Observability" } // brand new entity + ] + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var result = await verifyCtx.Courses + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + result.Tags.Should().HaveCount(3); + var newTag = await verifyCtx.TopicTags.FindAsync(newTagId); + newTag.Should().NotBeNull(); + newTag!.Label.Should().Be("Observability"); + } + + [Fact] + public async Task Update_existing_related_entity_through_association_updates_entity_state() + { + // Arrange + await using var seedCtx = CreateContext(); + await SeedData.SeedFullScenarioAsync(seedCtx); + + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + Tags = + [ + new TopicTag { Id = SeedData.Tag1Id, Label = "Software Architecture" }, // updated label + new TopicTag { Id = SeedData.Tag2Id, Label = "Testing" } + ] + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var tag1 = await verifyCtx.TopicTags.FindAsync(SeedData.Tag1Id); + tag1!.Label.Should().Be("Software Architecture"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/OneToOne/OptionalOneToOneContractTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/OneToOne/OptionalOneToOneContractTests.cs new file mode 100644 index 0000000..2e41ac6 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/OneToOne/OptionalOneToOneContractTests.cs @@ -0,0 +1,178 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Contracts.OneToOne; + +/// +/// Integration contract tests for optional one-to-one (Mentor -> MentorWorkspace). +/// Optional dependent removal => null FK (detach), NOT delete (FR-009, FR-010, FR-011). +/// MentorWorkspace has nullable MentorId FK with SetNull delete behavior. +/// +public class OptionalOneToOneContractTests : IntegrationTestBase +{ + public OptionalOneToOneContractTests(SqlServerContainerFixture fixture) + : base(fixture) { } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await using var ctx = CreateContext(); + await SeedData.SeedFullScenarioAsync(ctx); + } + + [Fact] + public async Task Remove_optional_dependent_nulls_fk_preserves_entity() + { + // Arrange — Mentor1 has Workspace1 + await using var ctx = CreateContext(); + var existing = await ctx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == SeedData.Mentor1Id); + + existing.Workspace.Should().NotBeNull("seed data includes workspace for Mentor1"); + + // Updated graph removes the Workspace reference + var updated = new Mentor + { + Id = SeedData.Mentor1Id, + DisplayName = existing.DisplayName, + Status = existing.Status, + Workspace = null + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert — workspace should still exist but with null MentorId + await using var verifyCtx = CreateContext(); + var mentor = await verifyCtx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == SeedData.Mentor1Id); + mentor.Workspace.Should().BeNull("reference should be removed from mentor"); + + var workspace = await verifyCtx.Set() + .FirstOrDefaultAsync(w => w.Id == SeedData.Workspace1Id); + workspace.Should().NotBeNull("optional dependent should be preserved, not deleted"); + workspace!.MentorId.Should().BeNull("FK should be nulled for optional one-to-one"); + } + + [Fact] + public async Task Update_optional_dependent_scalar_properties() + { + // Arrange + await using var ctx = CreateContext(); + var existing = await ctx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == SeedData.Mentor1Id); + + var updated = new Mentor + { + Id = SeedData.Mentor1Id, + DisplayName = existing.DisplayName, + Status = existing.Status, + Workspace = new MentorWorkspace + { + Id = SeedData.Workspace1Id, + MentorId = SeedData.Mentor1Id, + DeskCode = "D-999", + Building = "Annex-B" + } + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var workspace = await verifyCtx.Set() + .FirstAsync(w => w.Id == SeedData.Workspace1Id); + workspace.DeskCode.Should().Be("D-999"); + workspace.Building.Should().Be("Annex-B"); + } + + [Fact] + public async Task Add_optional_dependent_when_none_exists() + { + // Arrange — Mentor2 has no workspace + await using var ctx = CreateContext(); + var existing = await ctx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == SeedData.Mentor2Id); + + existing.Workspace.Should().BeNull("seed data has no workspace for Mentor2"); + + var newWorkspaceId = Guid.NewGuid(); + var updated = new Mentor + { + Id = SeedData.Mentor2Id, + DisplayName = existing.DisplayName, + Status = existing.Status, + Workspace = new MentorWorkspace + { + Id = newWorkspaceId, + MentorId = SeedData.Mentor2Id, + DeskCode = "D-200", + Building = "West Wing" + } + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var mentor = await verifyCtx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == SeedData.Mentor2Id); + mentor.Workspace.Should().NotBeNull(); + mentor.Workspace!.DeskCode.Should().Be("D-200"); + mentor.Workspace.Building.Should().Be("West Wing"); + } + + [Fact] + public async Task Replace_optional_dependent_nulls_old_inserts_new() + { + // Arrange — Mentor1 has Workspace1, replace with a brand new workspace + await using var ctx = CreateContext(); + var existing = await ctx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == SeedData.Mentor1Id); + + var replacementId = Guid.NewGuid(); + var updated = new Mentor + { + Id = SeedData.Mentor1Id, + DisplayName = existing.DisplayName, + Status = existing.Status, + Workspace = new MentorWorkspace + { + Id = replacementId, + MentorId = SeedData.Mentor1Id, + DeskCode = "D-NEW", + Building = "Tower" + } + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert — old workspace should be detached (FK nulled), new one linked + await using var verifyCtx = CreateContext(); + var mentor = await verifyCtx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == SeedData.Mentor1Id); + mentor.Workspace.Should().NotBeNull(); + mentor.Workspace!.Id.Should().Be(replacementId); + + var oldWorkspace = await verifyCtx.Set() + .FirstOrDefaultAsync(w => w.Id == SeedData.Workspace1Id); + oldWorkspace.Should().NotBeNull("old dependent should be preserved"); + oldWorkspace!.MentorId.Should().BeNull("old FK should be nulled"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/OneToOne/RequiredOneToOneContractTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/OneToOne/RequiredOneToOneContractTests.cs new file mode 100644 index 0000000..ccf80cb --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/OneToOne/RequiredOneToOneContractTests.cs @@ -0,0 +1,144 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Contracts.OneToOne; + +/// +/// Integration contract tests for required one-to-one (Course -> CoursePolicy). +/// Required dependent removal => delete (FR-007, FR-008). +/// +public class RequiredOneToOneContractTests : IntegrationTestBase +{ + public RequiredOneToOneContractTests(SqlServerContainerFixture fixture) + : base(fixture) { } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await using var ctx = CreateContext(); + await SeedData.SeedFullScenarioAsync(ctx); + } + + [Fact] + public async Task Remove_required_dependent_deletes_it() + { + // Arrange — load Course1 with its required Policy + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.Policy) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + existing.Policy.Should().NotBeNull("seed data includes a CoursePolicy"); + + // Updated graph removes the Policy reference + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + Policy = null + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert — the CoursePolicy row should be deleted + await using var verifyCtx = CreateContext(); + var course = await verifyCtx.Courses + .Include(c => c.Policy) + .FirstAsync(c => c.Id == SeedData.Course1Id); + course.Policy.Should().BeNull(); + + var policyExists = await verifyCtx.Set() + .AnyAsync(p => p.CourseId == SeedData.Course1Id); + policyExists.Should().BeFalse("required dependent should be deleted, not just detached"); + } + + [Fact] + public async Task Update_required_dependent_scalar_properties() + { + // Arrange + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.Policy) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + Policy = new CoursePolicy + { + CourseId = SeedData.Course1Id, + PolicyVersion = "2.0-updated", + IsMandatory = false + } + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var policy = await verifyCtx.Set() + .FirstAsync(p => p.CourseId == SeedData.Course1Id); + policy.PolicyVersion.Should().Be("2.0-updated"); + policy.IsMandatory.Should().BeFalse(); + } + + [Fact] + public async Task Add_required_dependent_when_none_exists() + { + // Arrange — remove existing policy first + await using (var setupCtx = CreateContext()) + { + var policy = await setupCtx.Set() + .FirstOrDefaultAsync(p => p.CourseId == SeedData.Course2Id); + if (policy is not null) + { + setupCtx.Remove(policy); + await setupCtx.SaveChangesAsync(); + } + } + + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.Policy) + .FirstAsync(c => c.Id == SeedData.Course2Id); + + existing.Policy.Should().BeNull("we removed it in setup"); + + var updated = new Course + { + Id = SeedData.Course2Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + Policy = new CoursePolicy + { + CourseId = SeedData.Course2Id, + PolicyVersion = "3.0-new", + IsMandatory = true + } + }; + + // Act + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var result = await verifyCtx.Courses + .Include(c => c.Policy) + .FirstAsync(c => c.Id == SeedData.Course2Id); + result.Policy.Should().NotBeNull(); + result.Policy!.PolicyVersion.Should().Be("3.0-new"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/Rejection/PartialMutationNotAllowedTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/Rejection/PartialMutationNotAllowedTests.cs new file mode 100644 index 0000000..3fd8c42 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/Rejection/PartialMutationNotAllowedTests.cs @@ -0,0 +1,81 @@ +using Diwink.Extensions.EntityFrameworkCore.Exceptions; +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Contracts.Rejection; + +/// +/// Integration tests proving all-or-nothing rejection semantics (FR-017). +/// When a graph contains both supported and unsupported mutations, the entire +/// operation is rejected — no supported mutations are applied. +/// +public class PartialMutationNotAllowedTests : IntegrationTestBase +{ + public PartialMutationNotAllowedTests(SqlServerContainerFixture fixture) + : base(fixture) { } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await using var ctx = CreateContext(); + await SeedData.SeedFullScenarioAsync(ctx); + } + + [Fact] + public async Task Mixed_supported_and_unsupported_mutations_rejects_entire_operation() + { + // Arrange — load LearningCatalog with both Courses (unsupported 1:M) and Tags (supported M:M) + await using var ctx = CreateContext(); + var existing = await ctx.LearningCatalogs + .Include(c => c.Courses) + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.CatalogId); + + var originalName = existing.Name; + + // Updated graph has: + // - Supported mutation: change catalog name (scalar) + // - Supported mutation: modify tags (M:M) + // - Unsupported mutation: add a course (1:M) + var updated = new LearningCatalog + { + Id = SeedData.CatalogId, + Name = "Should NOT be applied", + Tags = existing.Tags.Select(t => new TopicTag + { + Id = t.Id, + Label = t.Label + }).ToList(), + Courses = + [ + ..existing.Courses.Select(c => new Course + { + Id = c.Id, + CatalogId = c.CatalogId, + Title = c.Title, + Code = c.Code + }), + new Course + { + Id = Guid.NewGuid(), + CatalogId = SeedData.CatalogId, + Title = "Injected Course", + Code = "INJ-001" + } + ] + }; + + // Act & Assert — entire operation rejected + var act = () => ctx.InsertUpdateOrDeleteGraph(updated, existing); + act.Should().Throw(); + + // Verify no mutations were applied (all-or-nothing) + await using var verifyCtx = CreateContext(); + var result = await verifyCtx.LearningCatalogs + .FirstAsync(c => c.Id == SeedData.CatalogId); + result.Name.Should().Be(originalName, + "scalar updates must NOT be applied when unsupported mutation causes rejection"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/Rejection/UnloadedNavigationMutationTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/Rejection/UnloadedNavigationMutationTests.cs new file mode 100644 index 0000000..c36355c --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/Rejection/UnloadedNavigationMutationTests.cs @@ -0,0 +1,128 @@ +using Diwink.Extensions.EntityFrameworkCore.Exceptions; +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Contracts.Rejection; + +/// +/// Integration tests proving that mutations depending on unloaded navigations +/// are rejected (FR-015, FR-016). Only explicitly loaded navigations participate +/// in graph mutation. +/// +public class UnloadedNavigationMutationTests : IntegrationTestBase +{ + public UnloadedNavigationMutationTests(SqlServerContainerFixture fixture) + : base(fixture) { } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await using var ctx = CreateContext(); + await SeedData.SeedFullScenarioAsync(ctx); + } + + [Fact] + public async Task Unloaded_collection_navigation_with_mutations_is_rejected() + { + // Arrange — load Course WITHOUT Tags navigation + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.Policy) // load policy but NOT tags + .FirstAsync(c => c.Id == SeedData.Course1Id); + + // Updated graph tries to modify Tags (which wasn't loaded) + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + Policy = new CoursePolicy + { + CourseId = SeedData.Course1Id, + PolicyVersion = existing.Policy!.PolicyVersion, + IsMandatory = existing.Policy.IsMandatory + }, + Tags = + [ + new TopicTag { Id = Guid.NewGuid(), Label = "ShouldReject" } + ] + }; + + // Act & Assert — should reject because Tags wasn't loaded + var act = () => ctx.InsertUpdateOrDeleteGraph(updated, existing); + act.Should().Throw() + .Which.NavigationName.Should().Be("Tags"); + } + + [Fact] + public async Task Unloaded_reference_navigation_with_mutations_is_rejected() + { + // Arrange — load Course WITHOUT Policy navigation + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.Tags) // load tags but NOT policy + .FirstAsync(c => c.Id == SeedData.Course1Id); + + // Updated graph tries to modify Policy (which wasn't loaded) + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = existing.Title, + Code = existing.Code, + Tags = existing.Tags.Select(t => new TopicTag + { + Id = t.Id, + Label = t.Label + }).ToList(), + Policy = new CoursePolicy + { + CourseId = SeedData.Course1Id, + PolicyVersion = "NewVersion", + IsMandatory = false + } + }; + + // Act & Assert — should reject because Policy wasn't loaded + var act = () => ctx.InsertUpdateOrDeleteGraph(updated, existing); + act.Should().Throw() + .Which.NavigationName.Should().Be("Policy"); + } + + [Fact] + public async Task Unloaded_navigation_with_no_mutations_is_silently_skipped() + { + // Arrange — load Course WITHOUT Tags navigation + await using var ctx = CreateContext(); + var existing = await ctx.Courses + .Include(c => c.Policy) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + // Updated graph does NOT provide Tags at all (empty/default) + var updated = new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = "Updated Title", + Code = existing.Code, + Policy = new CoursePolicy + { + CourseId = SeedData.Course1Id, + PolicyVersion = existing.Policy!.PolicyVersion, + IsMandatory = existing.Policy.IsMandatory + } + }; + + // Act — should NOT throw because Tags is unloaded and not mutated + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert + await using var verifyCtx = CreateContext(); + var result = await verifyCtx.Courses.FirstAsync(c => c.Id == SeedData.Course1Id); + result.Title.Should().Be("Updated Title"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/Rejection/UnsupportedRelationshipPatternTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/Rejection/UnsupportedRelationshipPatternTests.cs new file mode 100644 index 0000000..689641f --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Contracts/Rejection/UnsupportedRelationshipPatternTests.cs @@ -0,0 +1,164 @@ +using Diwink.Extensions.EntityFrameworkCore.Exceptions; +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Contracts.Rejection; + +/// +/// Integration tests proving that unsupported relationship types are rejected +/// when mutations are detected, and silently skipped when unchanged (FR-018, FR-019). +/// +public class UnsupportedRelationshipPatternTests : IntegrationTestBase +{ + public UnsupportedRelationshipPatternTests(SqlServerContainerFixture fixture) + : base(fixture) { } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + await using var ctx = CreateContext(); + await SeedData.SeedFullScenarioAsync(ctx); + } + + [Fact] + public async Task Unchanged_one_to_many_navigation_is_silently_skipped() + { + // Arrange — load LearningCatalog with its one-to-many Courses + await using var ctx = CreateContext(); + var existing = await ctx.LearningCatalogs + .Include(c => c.Courses) + .FirstAsync(c => c.Id == SeedData.CatalogId); + + // Updated graph keeps same courses (no mutation in unsupported nav) + var updated = new LearningCatalog + { + Id = SeedData.CatalogId, + Name = "Updated Name", + Courses = existing.Courses.Select(c => new Course + { + Id = c.Id, + CatalogId = c.CatalogId, + Title = c.Title, + Code = c.Code + }).ToList() + }; + + // Act — should NOT throw because one-to-many is unchanged + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + + // Assert — scalar update applied, courses unchanged + await using var verifyCtx = CreateContext(); + var result = await verifyCtx.LearningCatalogs + .Include(c => c.Courses) + .FirstAsync(c => c.Id == SeedData.CatalogId); + result.Name.Should().Be("Updated Name"); + result.Courses.Should().HaveCount(2); + } + + [Fact] + public async Task Mutated_one_to_many_navigation_is_rejected() + { + // Arrange — load LearningCatalog with its one-to-many Courses + await using var ctx = CreateContext(); + var existing = await ctx.LearningCatalogs + .Include(c => c.Courses) + .FirstAsync(c => c.Id == SeedData.CatalogId); + + // Updated graph adds a new course (mutation in unsupported nav) + var updated = new LearningCatalog + { + Id = SeedData.CatalogId, + Name = "Updated Name", + Courses = + [ + new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = "Software Design", + Code = "SD-101" + }, + new Course + { + Id = SeedData.Course2Id, + CatalogId = SeedData.CatalogId, + Title = "Security Patterns", + Code = "SP-201" + }, + new Course + { + Id = Guid.NewGuid(), + CatalogId = SeedData.CatalogId, + Title = "New Course", + Code = "NC-301" + } + ] + }; + + // Act & Assert — should throw because one-to-many has mutations + var act = () => ctx.InsertUpdateOrDeleteGraph(updated, existing); + act.Should().Throw() + .Which.RelationshipType.Should().Be("OneToMany"); + } + + [Fact] + public async Task Mutated_one_to_many_removal_is_rejected() + { + // Arrange — load LearningCatalog with its one-to-many Courses + await using var ctx = CreateContext(); + var existing = await ctx.LearningCatalogs + .Include(c => c.Courses) + .FirstAsync(c => c.Id == SeedData.CatalogId); + + // Updated graph removes one course (mutation in unsupported nav) + var updated = new LearningCatalog + { + Id = SeedData.CatalogId, + Name = existing.Name, + Courses = + [ + new Course + { + Id = SeedData.Course1Id, + CatalogId = SeedData.CatalogId, + Title = "Software Design", + Code = "SD-101" + } + ] + }; + + // Act & Assert + var act = () => ctx.InsertUpdateOrDeleteGraph(updated, existing); + act.Should().Throw(); + } + + [Fact] + public async Task In_place_scalar_edit_in_unsupported_one_to_many_is_rejected() + { + await using var ctx = CreateContext(); + var existing = await ctx.LearningCatalogs + .Include(c => c.Courses) + .FirstAsync(c => c.Id == SeedData.CatalogId); + + var updated = new LearningCatalog + { + Id = SeedData.CatalogId, + Name = existing.Name, + Courses = existing.Courses.Select(c => new Course + { + Id = c.Id, + CatalogId = c.CatalogId, + Title = c.Id == SeedData.Course1Id ? "Retitled Course" : c.Title, + Code = c.Code + }).ToList() + }; + + var act = () => ctx.InsertUpdateOrDeleteGraph(updated, existing); + + act.Should().Throw() + .Which.RelationshipPath.Should().Be("LearningCatalog.Courses"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Diwink.Extensions.EntityFrameworkCore.Tests.Integration.csproj b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Diwink.Extensions.EntityFrameworkCore.Tests.Integration.csproj new file mode 100644 index 0000000..64bee8c --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Diwink.Extensions.EntityFrameworkCore.Tests.Integration.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/DatabaseBootstrap.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/DatabaseBootstrap.cs new file mode 100644 index 0000000..3df5257 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/DatabaseBootstrap.cs @@ -0,0 +1,126 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; + +/// +/// Handles schema creation and optional seed data initialization +/// for each test run against the containerized SQL Server. +/// +public static class DatabaseBootstrap +{ + internal const string TestDatabaseName = "DiwinkEfCoreGraphUpdateTests"; + private const int SchemaOperationMaxAttempts = 3; + private static readonly TimeSpan SchemaOperationTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan SchemaOperationRetryDelay = TimeSpan.FromSeconds(2); + + /// + /// Creates a fresh DbContext pointing at the container and ensures the schema exists. + /// + /// Creates a TestDbContext configured to use SQL Server with a test-specific connection string. + /// + /// Base SQL Server connection string; if its initial catalog is empty or equals "master", the catalog will be replaced with the test database name. + /// A new TestDbContext configured with the adjusted connection string. + public static TestDbContext CreateContext(string connectionString) + { + var testConnectionString = GetTestConnectionString(connectionString); + var options = new DbContextOptionsBuilder() + .UseSqlServer(testConnectionString) + .Options; + + return new TestDbContext(options); + } + + /// + /// Ensures the provided SQL Server connection string targets the test database by replacing an empty or "master" Initial Catalog with the test database name. + /// + /// A SQL Server connection string to adjust. + /// The connection string with Initial Catalog set to the test database when the original catalog was empty or "master". + internal static string GetTestConnectionString(string connectionString) + { + var builder = new SqlConnectionStringBuilder(connectionString); + + if (string.IsNullOrWhiteSpace(builder.InitialCatalog) || + string.Equals(builder.InitialCatalog, "master", StringComparison.OrdinalIgnoreCase)) + { + builder.InitialCatalog = TestDatabaseName; + } + + return builder.ConnectionString; + } + + /// + /// Ensures the database schema is created. Called once per test collection. + /// + /// Ensures the test database schema exists for the provided SQL Server connection string. + /// + /// SQL Server connection string. If the Initial Catalog is empty or "master", the configured test database name will be used. + public static async Task EnsureSchemaAsync(string connectionString) + { + await ExecuteSchemaOperationWithRetryAsync( + connectionString, + "ensure the test schema exists", + static (context, cancellationToken) => context.Database.EnsureCreatedAsync(cancellationToken)); + } + + /// + /// Drops and recreates the database schema. Used for test isolation + /// when a test needs a guaranteed clean slate. + /// + /// Deletes and recreates the test database schema for the given connection string. + /// + /// The database connection string used to locate the server; if the connection string has no catalog or specifies "master", the configured test database name will be used. + public static async Task ResetSchemaAsync(string connectionString) + { + await ExecuteSchemaOperationWithRetryAsync( + connectionString, + "reset the test schema", + static async (context, cancellationToken) => + { + await context.Database.EnsureDeletedAsync(cancellationToken); + await context.Database.EnsureCreatedAsync(cancellationToken); + }); + } + + private static async Task ExecuteSchemaOperationWithRetryAsync( + string connectionString, + string operationName, + Func operation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + + Exception? lastException = null; + + for (var attempt = 1; attempt <= SchemaOperationMaxAttempts; attempt++) + { + using var cancellationSource = new CancellationTokenSource(SchemaOperationTimeout); + + try + { + await using var context = CreateContext(connectionString); + await operation(context, cancellationSource.Token); + return; + } + catch (Exception ex) when (IsRetryableSchemaException(ex, cancellationSource)) + { + lastException = ex; + + if (attempt == SchemaOperationMaxAttempts) + break; + + await Task.Delay(SchemaOperationRetryDelay); + } + } + + throw new InvalidOperationException( + $"Failed to {operationName} after {SchemaOperationMaxAttempts} attempts.", + lastException); + } + + private static bool IsRetryableSchemaException(Exception exception, CancellationTokenSource cancellationSource) + { + return exception is SqlException or TimeoutException or InvalidOperationException || + (cancellationSource.IsCancellationRequested && exception is OperationCanceledException); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/DatabaseBootstrapTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/DatabaseBootstrapTests.cs new file mode 100644 index 0000000..08bfb5d --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/DatabaseBootstrapTests.cs @@ -0,0 +1,29 @@ +using FluentAssertions; +using Microsoft.Data.SqlClient; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; + +public class DatabaseBootstrapTests +{ + [Fact] + public void GetTestConnectionString_replaces_master_with_dedicated_test_database() + { + var connectionString = "Server=localhost,1433;Database=master;User ID=sa;Password=Passw0rd!;"; + + var normalized = DatabaseBootstrap.GetTestConnectionString(connectionString); + var builder = new SqlConnectionStringBuilder(normalized); + + builder.InitialCatalog.Should().Be(DatabaseBootstrap.TestDatabaseName); + } + + [Fact] + public void GetTestConnectionString_preserves_existing_non_master_database() + { + var connectionString = "Server=localhost,1433;Database=CustomDb;User ID=sa;Password=Passw0rd!;"; + + var normalized = DatabaseBootstrap.GetTestConnectionString(connectionString); + var builder = new SqlConnectionStringBuilder(normalized); + + builder.InitialCatalog.Should().Be("CustomDb"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/IntegrationTestBase.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/IntegrationTestBase.cs new file mode 100644 index 0000000..3675069 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/IntegrationTestBase.cs @@ -0,0 +1,51 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; + +/// +/// Base class for integration tests. Provides per-test database isolation +/// by resetting the schema before each test, and a factory for creating +/// DbContext instances connected to the container. +/// +[Collection(IntegrationTestCollection.Name)] +public abstract class IntegrationTestBase : IAsyncLifetime +{ + private readonly SqlServerContainerFixture _fixture; + + protected string ConnectionString => _fixture.ConnectionString; + + /// + /// Initializes a new instance of the IntegrationTestBase class with the provided SQL Server container fixture. + /// + /// The SQL Server container fixture used to obtain the test database connection for each test. + protected IntegrationTestBase(SqlServerContainerFixture fixture) + { + _fixture = fixture; + } + + /// + /// Resets the test database schema prior to running a test. + /// + /// A task that completes when the schema reset operation has finished. + public virtual async Task InitializeAsync() + { + await DatabaseBootstrap.ResetSchemaAsync(ConnectionString); + } + + /// +/// Hook invoked after a test finishes to perform any necessary cleanup; no action is taken by default. +/// +/// A Task that completes when cleanup is finished. +public virtual Task DisposeAsync() => Task.CompletedTask; + + /// + /// Creates a fresh DbContext for the current test. + /// + /// Creates a new TestDbContext configured for the integration test database. + /// + /// The new TestDbContext instance connected to the test database. + protected TestDbContext CreateContext() + { + return DatabaseBootstrap.CreateContext(ConnectionString); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/IntegrationTestCollection.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/IntegrationTestCollection.cs new file mode 100644 index 0000000..05bc589 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/IntegrationTestCollection.cs @@ -0,0 +1,11 @@ +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; + +/// +/// xUnit collection definition that shares a single SQL Server container +/// across all integration test classes in this collection. +/// +[CollectionDefinition(Name)] +public class IntegrationTestCollection : ICollectionFixture +{ + public const string Name = "SqlServerIntegration"; +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/SeedData.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/SeedData.cs new file mode 100644 index 0000000..79abb4b --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/SeedData.cs @@ -0,0 +1,108 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel; +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; + +/// +/// Deterministic seed data used across integration contract tests. +/// Exposes well-known IDs for assertions and seeds complete test scenarios. +/// +public static class SeedData +{ + // Well-known IDs for deterministic test scenarios + public static readonly Guid CatalogId = Guid.Parse("a1000000-0000-0000-0000-000000000001"); + public static readonly Guid Course1Id = Guid.Parse("b1000000-0000-0000-0000-000000000001"); + public static readonly Guid Course2Id = Guid.Parse("b1000000-0000-0000-0000-000000000002"); + public static readonly Guid Tag1Id = Guid.Parse("c1000000-0000-0000-0000-000000000001"); + public static readonly Guid Tag2Id = Guid.Parse("c1000000-0000-0000-0000-000000000002"); + public static readonly Guid Tag3Id = Guid.Parse("c1000000-0000-0000-0000-000000000003"); + public static readonly Guid Mentor1Id = Guid.Parse("d1000000-0000-0000-0000-000000000001"); + public static readonly Guid Mentor2Id = Guid.Parse("d1000000-0000-0000-0000-000000000002"); + public static readonly Guid Workspace1Id = Guid.Parse("e1000000-0000-0000-0000-000000000001"); + + /// + /// Seeds a complete test scenario with all relationship types represented. + /// + /// Seeds the provided test database with a complete, deterministic integration scenario: three topic tags, two mentors (one with a workspace), a learning catalog containing two courses with policies, tag associations, and a mentor assignment for the first course, then saves the changes to the context. + /// + /// The TestDbContext to which seeded entities are added and persisted. + public static async Task SeedFullScenarioAsync(TestDbContext context) + { + var tag1 = new TopicTag { Id = Tag1Id, Label = "Architecture" }; + var tag2 = new TopicTag { Id = Tag2Id, Label = "Testing" }; + var tag3 = new TopicTag { Id = Tag3Id, Label = "Security" }; + + var mentor1 = new Mentor + { + Id = Mentor1Id, + DisplayName = "Alice", + Status = "Active", + Workspace = new MentorWorkspace + { + Id = Workspace1Id, + MentorId = Mentor1Id, + DeskCode = "D-101", + Building = "HQ" + } + }; + + var mentor2 = new Mentor + { + Id = Mentor2Id, + DisplayName = "Bob", + Status = "Active" + }; + + var catalog = new LearningCatalog + { + Id = CatalogId, + Name = "Engineering Fundamentals", + Courses = + [ + new Course + { + Id = Course1Id, + CatalogId = CatalogId, + Title = "Software Design", + Code = "SD-101", + Policy = new CoursePolicy + { + CourseId = Course1Id, + PolicyVersion = "1.0", + IsMandatory = true + }, + Tags = [tag1, tag2], + MentorAssignments = + [ + new CourseMentorAssignment + { + CourseId = Course1Id, + MentorId = Mentor1Id, + Role = "Lead", + AssignedOnUtc = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc), + AllocationPercent = 75m + } + ] + }, + new Course + { + Id = Course2Id, + CatalogId = CatalogId, + Title = "Security Patterns", + Code = "SP-201", + Policy = new CoursePolicy + { + CourseId = Course2Id, + PolicyVersion = "2.0", + IsMandatory = false + }, + Tags = [tag2, tag3] + } + ] + }; + + context.Mentors.AddRange(mentor1, mentor2); + context.LearningCatalogs.Add(catalog); + await context.SaveChangesAsync(); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/SeedDataStructureTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/SeedDataStructureTests.cs new file mode 100644 index 0000000..953ecd8 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/SeedDataStructureTests.cs @@ -0,0 +1,212 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel; +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; + +/// +/// Unit-style tests (no container required) for the SeedData helper. +/// Validates that well-known IDs are distinct, non-empty, and that the +/// seed method produces the expected entity graph using an in-memory store. +/// +public class SeedDataStructureTests +{ + // ------------------------------------------------------------------------- + // Well-known ID distinctness + // ------------------------------------------------------------------------- + + [Fact] + public void All_well_known_ids_are_distinct() + { + var ids = new[] + { + SeedData.CatalogId, + SeedData.Course1Id, + SeedData.Course2Id, + SeedData.Tag1Id, + SeedData.Tag2Id, + SeedData.Tag3Id, + SeedData.Mentor1Id, + SeedData.Mentor2Id, + SeedData.Workspace1Id + }; + + ids.Should().OnlyHaveUniqueItems("each well-known ID must be unique to prevent test cross-contamination"); + } + + [Fact] + public void All_well_known_ids_are_non_empty_guids() + { + SeedData.CatalogId.Should().NotBeEmpty(); + SeedData.Course1Id.Should().NotBeEmpty(); + SeedData.Course2Id.Should().NotBeEmpty(); + SeedData.Tag1Id.Should().NotBeEmpty(); + SeedData.Tag2Id.Should().NotBeEmpty(); + SeedData.Tag3Id.Should().NotBeEmpty(); + SeedData.Mentor1Id.Should().NotBeEmpty(); + SeedData.Mentor2Id.Should().NotBeEmpty(); + SeedData.Workspace1Id.Should().NotBeEmpty(); + } + + [Fact] + public void Course1Id_and_Course2Id_share_same_catalog_prefix() + { + // Both courses belong to the same catalog — verify IDs encode distinct entities + SeedData.Course1Id.Should().NotBe(SeedData.Course2Id); + } + + // ------------------------------------------------------------------------- + // SeedFullScenarioAsync — entity graph shape + // ------------------------------------------------------------------------- + + private static TestDbContext CreateInMemoryContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new TestDbContext(options); + } + + [Fact] + public async Task SeedFullScenarioAsync_creates_correct_number_of_entities() + { + await using var ctx = CreateInMemoryContext(); + await SeedData.SeedFullScenarioAsync(ctx); + + (await ctx.LearningCatalogs.CountAsync()).Should().Be(1); + (await ctx.Courses.CountAsync()).Should().Be(2); + (await ctx.TopicTags.CountAsync()).Should().Be(3); + (await ctx.Mentors.CountAsync()).Should().Be(2); + (await ctx.MentorWorkspaces.CountAsync()).Should().Be(1); + (await ctx.CoursePolicies.CountAsync()).Should().Be(2); + } + + [Fact] + public async Task SeedFullScenarioAsync_mentor1_has_workspace_mentor2_does_not() + { + await using var ctx = CreateInMemoryContext(); + await SeedData.SeedFullScenarioAsync(ctx); + + var mentor1 = await ctx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == SeedData.Mentor1Id); + + var mentor2 = await ctx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == SeedData.Mentor2Id); + + mentor1.Workspace.Should().NotBeNull("seed data assigns Workspace1 to Mentor1"); + mentor1.Workspace!.Id.Should().Be(SeedData.Workspace1Id); + mentor2.Workspace.Should().BeNull("seed data has no workspace for Mentor2"); + } + + [Fact] + public async Task SeedFullScenarioAsync_course1_has_two_tags_architecture_and_testing() + { + await using var ctx = CreateInMemoryContext(); + await SeedData.SeedFullScenarioAsync(ctx); + + var course1 = await ctx.Courses + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.Course1Id); + + course1.Tags.Should().HaveCount(2); + course1.Tags.Select(t => t.Id).Should().Contain(SeedData.Tag1Id); + course1.Tags.Select(t => t.Id).Should().Contain(SeedData.Tag2Id); + } + + [Fact] + public async Task SeedFullScenarioAsync_course2_has_two_tags_testing_and_security() + { + await using var ctx = CreateInMemoryContext(); + await SeedData.SeedFullScenarioAsync(ctx); + + var course2 = await ctx.Courses + .Include(c => c.Tags) + .FirstAsync(c => c.Id == SeedData.Course2Id); + + course2.Tags.Should().HaveCount(2); + course2.Tags.Select(t => t.Id).Should().Contain(SeedData.Tag2Id); + course2.Tags.Select(t => t.Id).Should().Contain(SeedData.Tag3Id); + } + + [Fact] + public async Task SeedFullScenarioAsync_course1_has_one_mentor_assignment_for_mentor1() + { + await using var ctx = CreateInMemoryContext(); + await SeedData.SeedFullScenarioAsync(ctx); + + var assignments = await ctx.CourseMentorAssignments + .Where(a => a.CourseId == SeedData.Course1Id) + .ToListAsync(); + + assignments.Should().HaveCount(1); + assignments[0].MentorId.Should().Be(SeedData.Mentor1Id); + assignments[0].Role.Should().Be("Lead"); + assignments[0].AllocationPercent.Should().Be(75m); + } + + [Fact] + public async Task SeedFullScenarioAsync_course2_has_no_mentor_assignments() + { + await using var ctx = CreateInMemoryContext(); + await SeedData.SeedFullScenarioAsync(ctx); + + var assignments = await ctx.CourseMentorAssignments + .Where(a => a.CourseId == SeedData.Course2Id) + .ToListAsync(); + + assignments.Should().BeEmpty("seed data only assigns Mentor1 to Course1"); + } + + [Fact] + public async Task SeedFullScenarioAsync_course1_policy_is_mandatory_version_1_0() + { + await using var ctx = CreateInMemoryContext(); + await SeedData.SeedFullScenarioAsync(ctx); + + var policy = await ctx.CoursePolicies + .FirstAsync(p => p.CourseId == SeedData.Course1Id); + + policy.PolicyVersion.Should().Be("1.0"); + policy.IsMandatory.Should().BeTrue(); + } + + [Fact] + public async Task SeedFullScenarioAsync_course2_policy_is_optional_version_2_0() + { + await using var ctx = CreateInMemoryContext(); + await SeedData.SeedFullScenarioAsync(ctx); + + var policy = await ctx.CoursePolicies + .FirstAsync(p => p.CourseId == SeedData.Course2Id); + + policy.PolicyVersion.Should().Be("2.0"); + policy.IsMandatory.Should().BeFalse(); + } + + [Fact] + public async Task SeedFullScenarioAsync_both_courses_belong_to_the_single_catalog() + { + await using var ctx = CreateInMemoryContext(); + await SeedData.SeedFullScenarioAsync(ctx); + + var courses = await ctx.Courses.ToListAsync(); + courses.Should().AllSatisfy(c => c.CatalogId.Should().Be(SeedData.CatalogId)); + } + + [Fact] + public async Task SeedFullScenarioAsync_tag2_is_shared_across_both_courses() + { + // Tag2 ("Testing") is linked to both Course1 and Course2 — validates M:M join rows + await using var ctx = CreateInMemoryContext(); + await SeedData.SeedFullScenarioAsync(ctx); + + var course1 = await ctx.Courses.Include(c => c.Tags).FirstAsync(c => c.Id == SeedData.Course1Id); + var course2 = await ctx.Courses.Include(c => c.Tags).FirstAsync(c => c.Id == SeedData.Course2Id); + + course1.Tags.Select(t => t.Id).Should().Contain(SeedData.Tag2Id); + course2.Tags.Select(t => t.Id).Should().Contain(SeedData.Tag2Id); + } +} \ No newline at end of file diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/SqlServerContainerFixture.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/SqlServerContainerFixture.cs new file mode 100644 index 0000000..840948d --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Integration/Infrastructure/SqlServerContainerFixture.cs @@ -0,0 +1,43 @@ +using Testcontainers.MsSql; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Integration.Infrastructure; + +/// +/// xUnit collection fixture that manages a SQL Server container lifecycle +/// shared across all integration tests in a collection. +/// +public class SqlServerContainerFixture : IAsyncLifetime +{ + internal const string DefaultSqlServerImage = "mcr.microsoft.com/mssql/server:2022-CU10-ubuntu-20.04"; + + private readonly MsSqlContainer _container = new MsSqlBuilder(GetSqlServerImage()) + .Build(); + + public string ConnectionString => _container.GetConnectionString(); + + /// + /// Starts the SQL Server container used by the fixture. + /// + /// A task that completes when the container has started. + public async Task InitializeAsync() + { + await _container.StartAsync(); + } + + /// + /// Disposes the SQL Server container and releases its resources. + /// + /// A task that completes when the container has been disposed. + public async Task DisposeAsync() + { + await _container.DisposeAsync().AsTask(); + } + + private static string GetSqlServerImage() + { + var configuredImage = Environment.GetEnvironmentVariable("SQL_SERVER_IMAGE"); + return string.IsNullOrWhiteSpace(configuredImage) + ? DefaultSqlServerImage + : configuredImage.Trim(); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/Diwink.Extensions.EntityFrameworkCore.Tests.Unit.csproj b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/Diwink.Extensions.EntityFrameworkCore.Tests.Unit.csproj new file mode 100644 index 0000000..e37c354 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/Diwink.Extensions.EntityFrameworkCore.Tests.Unit.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/Exceptions/GraphUpdateExceptionTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/Exceptions/GraphUpdateExceptionTests.cs new file mode 100644 index 0000000..24adf9d --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/Exceptions/GraphUpdateExceptionTests.cs @@ -0,0 +1,241 @@ +using Diwink.Extensions.EntityFrameworkCore.Exceptions; +using FluentAssertions; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Unit.Exceptions; + +/// +/// Unit tests for all GraphUpdateException-derived types, verifying constructor +/// argument storage, message content, property accessors, and inheritance chain. +/// +public class GraphUpdateExceptionTests +{ + // ------------------------------------------------------------------------- + // UnsupportedNavigationMutatedException + // ------------------------------------------------------------------------- + + [Fact] + public void UnsupportedNavigationMutatedException_stores_relationship_path() + { + var ex = new UnsupportedNavigationMutatedException("Course.Items", "OneToMany"); + + ex.RelationshipPath.Should().Be("Course.Items"); + } + + [Fact] + public void UnsupportedNavigationMutatedException_stores_relationship_type() + { + var ex = new UnsupportedNavigationMutatedException("Course.Items", "OneToMany"); + + ex.RelationshipType.Should().Be("OneToMany"); + } + + [Fact] + public void UnsupportedNavigationMutatedException_message_contains_path_and_type() + { + var ex = new UnsupportedNavigationMutatedException("Catalog.Courses", "OneToMany"); + + ex.Message.Should().Contain("Catalog.Courses"); + ex.Message.Should().Contain("OneToMany"); + } + + [Fact] + public void UnsupportedNavigationMutatedException_inherits_from_GraphUpdateException() + { + var ex = new UnsupportedNavigationMutatedException("A.B", "OneToMany"); + + ex.Should().BeAssignableTo(); + ex.Should().BeAssignableTo(); + } + + // ------------------------------------------------------------------------- + // UnloadedNavigationMutationException + // ------------------------------------------------------------------------- + + [Fact] + public void UnloadedNavigationMutationException_stores_relationship_path() + { + var ex = new UnloadedNavigationMutationException("Course.Tags", "Tags"); + + ex.RelationshipPath.Should().Be("Course.Tags"); + } + + [Fact] + public void UnloadedNavigationMutationException_stores_navigation_name() + { + var ex = new UnloadedNavigationMutationException("Course.Tags", "Tags"); + + ex.NavigationName.Should().Be("Tags"); + } + + [Fact] + public void UnloadedNavigationMutationException_message_contains_path_and_navigation() + { + var ex = new UnloadedNavigationMutationException("Course.Tags", "Tags"); + + ex.Message.Should().Contain("Course.Tags"); + ex.Message.Should().Contain("Tags"); + } + + [Fact] + public void UnloadedNavigationMutationException_inherits_from_GraphUpdateException() + { + var ex = new UnloadedNavigationMutationException("A.B", "B"); + + ex.Should().BeAssignableTo(); + ex.Should().BeAssignableTo(); + } + + // ------------------------------------------------------------------------- + // PartialMutationNotAllowedException + // ------------------------------------------------------------------------- + + [Fact] + public void PartialMutationNotAllowedException_stores_relationship_path() + { + var ex = new PartialMutationNotAllowedException("Catalog.Courses", "Catalog.Courses"); + + ex.RelationshipPath.Should().Be("Catalog.Courses"); + } + + [Fact] + public void PartialMutationNotAllowedException_stores_unsupported_branch() + { + var ex = new PartialMutationNotAllowedException("Catalog.Courses", "Catalog.Courses, Catalog.Tags"); + + ex.UnsupportedBranch.Should().Be("Catalog.Courses, Catalog.Tags"); + } + + [Fact] + public void PartialMutationNotAllowedException_message_contains_unsupported_branch() + { + var ex = new PartialMutationNotAllowedException("Catalog.Courses", "Catalog.Courses"); + + ex.Message.Should().Contain("Catalog.Courses"); + } + + [Fact] + public void PartialMutationNotAllowedException_inherits_from_GraphUpdateException() + { + var ex = new PartialMutationNotAllowedException("A.B", "A.B"); + + ex.Should().BeAssignableTo(); + ex.Should().BeAssignableTo(); + } + + // ------------------------------------------------------------------------- + // AmbiguousOwnershipSemanticsException + // ------------------------------------------------------------------------- + + [Fact] + public void AmbiguousOwnershipSemanticsException_stores_relationship_path() + { + var ex = new AmbiguousOwnershipSemanticsException("Mentor.Workspace", "FK nullability unknown"); + + ex.RelationshipPath.Should().Be("Mentor.Workspace"); + } + + [Fact] + public void AmbiguousOwnershipSemanticsException_stores_missing_detail() + { + var ex = new AmbiguousOwnershipSemanticsException("Mentor.Workspace", "FK nullability unknown"); + + ex.MissingDetail.Should().Be("FK nullability unknown"); + } + + [Fact] + public void AmbiguousOwnershipSemanticsException_message_contains_path_and_detail() + { + var ex = new AmbiguousOwnershipSemanticsException("Mentor.Workspace", "FK nullability unknown"); + + ex.Message.Should().Contain("Mentor.Workspace"); + ex.Message.Should().Contain("FK nullability unknown"); + } + + [Fact] + public void AmbiguousOwnershipSemanticsException_inherits_from_GraphUpdateException() + { + var ex = new AmbiguousOwnershipSemanticsException("A.B", "detail"); + + ex.Should().BeAssignableTo(); + ex.Should().BeAssignableTo(); + } + + // ------------------------------------------------------------------------- + // UnsupportedRelationshipPatternException + // ------------------------------------------------------------------------- + + [Fact] + public void UnsupportedRelationshipPatternException_stores_relationship_path() + { + var ex = new UnsupportedRelationshipPatternException("Course.Modules", "SelfReferential"); + + ex.RelationshipPath.Should().Be("Course.Modules"); + } + + [Fact] + public void UnsupportedRelationshipPatternException_stores_pattern_identifier() + { + var ex = new UnsupportedRelationshipPatternException("Course.Modules", "SelfReferential"); + + ex.PatternIdentifier.Should().Be("SelfReferential"); + } + + [Fact] + public void UnsupportedRelationshipPatternException_message_contains_path_and_pattern() + { + var ex = new UnsupportedRelationshipPatternException("Course.Modules", "SelfReferential"); + + ex.Message.Should().Contain("Course.Modules"); + ex.Message.Should().Contain("SelfReferential"); + } + + [Fact] + public void UnsupportedRelationshipPatternException_inherits_from_GraphUpdateException() + { + var ex = new UnsupportedRelationshipPatternException("A.B", "pattern"); + + ex.Should().BeAssignableTo(); + ex.Should().BeAssignableTo(); + } + + // ------------------------------------------------------------------------- + // Boundary / regression cases + // ------------------------------------------------------------------------- + + [Fact] + public void All_exception_types_are_sealed_or_abstract_not_further_subclassable() + { + // Verify all concrete exception types are sealed (design intent) + typeof(UnsupportedNavigationMutatedException).IsSealed.Should().BeTrue(); + typeof(UnloadedNavigationMutationException).IsSealed.Should().BeTrue(); + typeof(PartialMutationNotAllowedException).IsSealed.Should().BeTrue(); + typeof(AmbiguousOwnershipSemanticsException).IsSealed.Should().BeTrue(); + typeof(UnsupportedRelationshipPatternException).IsSealed.Should().BeTrue(); + } + + [Fact] + public void GraphUpdateException_is_abstract() + { + typeof(GraphUpdateException).IsAbstract.Should().BeTrue(); + } + + [Fact] + public void UnsupportedNavigationMutatedException_with_empty_strings_stores_them_as_is() + { + var ex = new UnsupportedNavigationMutatedException(string.Empty, string.Empty); + + ex.RelationshipPath.Should().Be(string.Empty); + ex.RelationshipType.Should().Be(string.Empty); + } + + [Fact] + public void UnloadedNavigationMutationException_navigation_name_differs_from_path_segment() + { + // RelationshipPath is the full dotted path; NavigationName is just the nav property name + var ex = new UnloadedNavigationMutationException("Course.Policy.Revision", "Revision"); + + ex.RelationshipPath.Should().Be("Course.Policy.Revision"); + ex.NavigationName.Should().Be("Revision"); + ex.RelationshipPath.Should().NotBe(ex.NavigationName); + } +} \ No newline at end of file diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/DbContextExtensionsTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/DbContextExtensionsTests.cs new file mode 100644 index 0000000..4566fce --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/DbContextExtensionsTests.cs @@ -0,0 +1,54 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel; +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Unit.GraphDiff; + +public class DbContextExtensionsTests +{ + private static TestDbContext CreateInMemoryContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new TestDbContext(options); + } + + [Fact] + public void InsertUpdateOrDeleteGraph_throws_when_context_is_null() + { + var updated = new Course { Id = Guid.NewGuid(), CatalogId = Guid.NewGuid(), Title = "Updated", Code = "UPD-1" }; + var existing = new Course { Id = updated.Id, CatalogId = updated.CatalogId, Title = "Existing", Code = "EX-1" }; + + var act = () => DbContextExtensions.InsertUpdateOrDeleteGraph(null!, updated, existing); + + act.Should().Throw() + .Which.ParamName.Should().Be("context"); + } + + [Fact] + public void InsertUpdateOrDeleteGraph_throws_when_updated_entity_is_null() + { + using var context = CreateInMemoryContext(); + var existing = new Course { Id = Guid.NewGuid(), CatalogId = Guid.NewGuid(), Title = "Existing", Code = "EX-1" }; + + var act = () => context.InsertUpdateOrDeleteGraph(null!, existing); + + act.Should().Throw() + .Which.ParamName.Should().Be("updatedEntity"); + } + + [Fact] + public void InsertUpdateOrDeleteGraph_throws_when_existing_entity_is_null() + { + using var context = CreateInMemoryContext(); + var updated = new Course { Id = Guid.NewGuid(), CatalogId = Guid.NewGuid(), Title = "Updated", Code = "UPD-1" }; + + var act = () => context.InsertUpdateOrDeleteGraph(updated, null!); + + act.Should().Throw() + .Which.ParamName.Should().Be("existingEntity"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/EntityKeyHelperTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/EntityKeyHelperTests.cs new file mode 100644 index 0000000..94fa027 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/EntityKeyHelperTests.cs @@ -0,0 +1,49 @@ +using Diwink.Extensions.EntityFrameworkCore.GraphUpdate; +using Diwink.Extensions.EntityFrameworkCore.TestModel; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Unit.GraphDiff; + +public class EntityKeyHelperTests +{ + private static TestDbContext CreateInMemoryContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new TestDbContext(options); + } + + [Fact] + public void KeysEqual_compares_byte_arrays_structurally() + { + var left = new object[] { new byte[] { 1, 2, 3 }, 42 }; + var right = new object[] { new byte[] { 1, 2, 3 }, 42 }; + + EntityKeyHelper.KeysEqual(left, right).Should().BeTrue(); + } + + [Fact] + public void KeysEqual_returns_false_when_only_one_side_is_a_byte_array() + { + var left = new object[] { new byte[] { 1, 2, 3 } }; + var right = new object[] { "AQID" }; + + EntityKeyHelper.KeysEqual(left, right).Should().BeFalse(); + } + + [Fact] + public void GetKeyValues_for_unmapped_entity_throws() + { + using var context = CreateInMemoryContext(); + + var act = () => EntityKeyHelper.GetKeyValues(context, new UnmappedEntity()); + + act.Should().Throw() + .WithMessage("*does not exist in the current DbContext model*"); + } + + private sealed class UnmappedEntity; +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/ExceptionValidationTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/ExceptionValidationTests.cs new file mode 100644 index 0000000..61e1e00 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/ExceptionValidationTests.cs @@ -0,0 +1,43 @@ +using Diwink.Extensions.EntityFrameworkCore.Exceptions; +using FluentAssertions; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Unit.GraphDiff; + +public class ExceptionValidationTests +{ + [Fact] + public void UnsupportedNavigationMutatedException_rejects_blank_relationship_path() + { + var act = () => new UnsupportedNavigationMutatedException(" ", "OneToMany"); + + act.Should().Throw() + .Which.ParamName.Should().Be("relationshipPath"); + } + + [Fact] + public void AmbiguousOwnershipSemanticsException_rejects_blank_missing_detail() + { + var act = () => new AmbiguousOwnershipSemanticsException("Course.Policy", " "); + + act.Should().Throw() + .Which.ParamName.Should().Be("missingDetail"); + } + + [Fact] + public void PartialMutationNotAllowedException_rejects_blank_unsupported_branch() + { + var act = () => new PartialMutationNotAllowedException("Course.Tags", " "); + + act.Should().Throw() + .Which.ParamName.Should().Be("unsupportedBranch"); + } + + [Fact] + public void UnsupportedRelationshipPatternException_rejects_blank_pattern_identifier() + { + var act = () => new UnsupportedRelationshipPatternException("Course.Tags", " "); + + act.Should().Throw() + .Which.ParamName.Should().Be("patternIdentifier"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/NestedNavigationValidationTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/NestedNavigationValidationTests.cs new file mode 100644 index 0000000..6aab11c --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/NestedNavigationValidationTests.cs @@ -0,0 +1,365 @@ +using Diwink.Extensions.EntityFrameworkCore.Exceptions; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Unit.GraphDiff; + +public class NestedNavigationValidationTests +{ + private static RecursiveGraphContext CreateInMemoryContext(string? dbName = null) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName ?? Guid.NewGuid().ToString()) + .Options; + + return new RecursiveGraphContext(options); + } + + [Fact] + public async Task Nested_unsupported_one_to_many_mutation_is_rejected() + { + var dbName = Guid.NewGuid().ToString(); + var rootId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + var itemId = Guid.NewGuid(); + + { + await using var seedCtx = CreateInMemoryContext(dbName); + seedCtx.Roots.Add(new RecursiveRoot + { + Id = rootId, + Name = "Root", + Child = new RecursiveChild + { + Id = childId, + RootId = rootId, + Name = "Child", + Items = + [ + new RecursiveChildItem + { + Id = itemId, + ChildId = childId, + Value = "Keep" + } + ] + } + }); + + await seedCtx.SaveChangesAsync(); + } + + { + await using var ctx = CreateInMemoryContext(dbName); + var existing = await ctx.Roots + .Include(r => r.Child) + .ThenInclude(c => c!.Items) + .FirstAsync(r => r.Id == rootId); + + var updated = new RecursiveRoot + { + Id = rootId, + Name = "Root", + Child = new RecursiveChild + { + Id = childId, + RootId = rootId, + Name = "Child", + Items = + [ + new RecursiveChildItem + { + Id = itemId, + ChildId = childId, + Value = "Changed" + } + ] + } + }; + + var act = () => ctx.InsertUpdateOrDeleteGraph(updated, existing); + + act.Should().Throw() + .Which.RelationshipPath.Should().Be("RecursiveChild.Items"); + } + } + + [Fact] + public async Task Nested_unloaded_reference_mutation_is_rejected() + { + var dbName = Guid.NewGuid().ToString(); + var rootId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + var metadataId = Guid.NewGuid(); + + { + await using var seedCtx = CreateInMemoryContext(dbName); + seedCtx.Roots.Add(new RecursiveRoot + { + Id = rootId, + Name = "Root", + Child = new RecursiveChild + { + Id = childId, + RootId = rootId, + Name = "Child", + Metadata = new RecursiveChildMetadata + { + Id = metadataId, + ChildId = childId, + Notes = "Existing" + } + } + }); + + await seedCtx.SaveChangesAsync(); + } + + { + await using var ctx = CreateInMemoryContext(dbName); + var existing = await ctx.Roots + .Include(r => r.Child) + .FirstAsync(r => r.Id == rootId); + + var updated = new RecursiveRoot + { + Id = rootId, + Name = "Root", + Child = new RecursiveChild + { + Id = childId, + RootId = rootId, + Name = "Child", + Metadata = new RecursiveChildMetadata + { + Id = metadataId, + ChildId = childId, + Notes = "Changed" + } + } + }; + + var act = () => ctx.InsertUpdateOrDeleteGraph(updated, existing); + + act.Should().Throw() + .Which.NavigationName.Should().Be("Metadata"); + } + } + + [Fact] + public async Task Nested_optional_reference_removal_detaches_dependent() + { + var dbName = Guid.NewGuid().ToString(); + var rootId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + var metadataId = Guid.NewGuid(); + + { + await using var seedCtx = CreateInMemoryContext(dbName); + seedCtx.Roots.Add(new RecursiveRoot + { + Id = rootId, + Name = "Root", + Child = new RecursiveChild + { + Id = childId, + RootId = rootId, + Name = "Child", + Metadata = new RecursiveChildMetadata + { + Id = metadataId, + ChildId = childId, + Notes = "Existing" + } + } + }); + + await seedCtx.SaveChangesAsync(); + } + + { + await using var ctx = CreateInMemoryContext(dbName); + var existing = await ctx.Roots + .Include(r => r.Child) + .ThenInclude(c => c!.Metadata) + .FirstAsync(r => r.Id == rootId); + + var updated = new RecursiveRoot + { + Id = rootId, + Name = "Root", + Child = new RecursiveChild + { + Id = childId, + RootId = rootId, + Name = "Child", + Metadata = null + } + }; + + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + } + + { + await using var verifyCtx = CreateInMemoryContext(dbName); + var root = await verifyCtx.Roots + .Include(r => r.Child) + .ThenInclude(c => c!.Metadata) + .FirstAsync(r => r.Id == rootId); + + root.Child.Should().NotBeNull(); + root.Child!.Metadata.Should().BeNull(); + + var metadata = await verifyCtx.ChildMetadata.FirstOrDefaultAsync(m => m.Id == metadataId); + metadata.Should().NotBeNull(); + metadata!.ChildId.Should().BeNull(); + } + } + + [Fact] + public async Task Nested_many_to_many_removal_unlinks_without_deleting_related_entity() + { + var dbName = Guid.NewGuid().ToString(); + var rootId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + var tag1Id = Guid.NewGuid(); + var tag2Id = Guid.NewGuid(); + + { + await using var seedCtx = CreateInMemoryContext(dbName); + seedCtx.Roots.Add(new RecursiveRoot + { + Id = rootId, + Name = "Root", + Child = new RecursiveChild + { + Id = childId, + RootId = rootId, + Name = "Child", + Tags = + [ + new RecursiveTag { Id = tag1Id, Label = "Keep" }, + new RecursiveTag { Id = tag2Id, Label = "Remove" } + ] + } + }); + + await seedCtx.SaveChangesAsync(); + } + + { + await using var ctx = CreateInMemoryContext(dbName); + var existing = await ctx.Roots + .Include(r => r.Child) + .ThenInclude(c => c!.Tags) + .FirstAsync(r => r.Id == rootId); + + var updated = new RecursiveRoot + { + Id = rootId, + Name = "Root", + Child = new RecursiveChild + { + Id = childId, + RootId = rootId, + Name = "Child", + Tags = + [ + new RecursiveTag { Id = tag1Id, Label = "Keep" } + ] + } + }; + + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + } + + { + await using var verifyCtx = CreateInMemoryContext(dbName); + var root = await verifyCtx.Roots + .Include(r => r.Child) + .ThenInclude(c => c!.Tags) + .FirstAsync(r => r.Id == rootId); + + root.Child.Should().NotBeNull(); + root.Child!.Tags.Select(t => t.Id).Should().ContainSingle().Which.Should().Be(tag1Id); + (await verifyCtx.Tags.AnyAsync(t => t.Id == tag2Id)).Should().BeTrue(); + } + } +} + +internal sealed class RecursiveGraphContext : DbContext +{ + public RecursiveGraphContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Roots => Set(); + public DbSet Children => Set(); + public DbSet ChildItems => Set(); + public DbSet ChildMetadata => Set(); + public DbSet Tags => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasOne(r => r.Child) + .WithOne() + .HasForeignKey(c => c.RootId) + .IsRequired(); + + modelBuilder.Entity() + .HasMany(c => c.Items) + .WithOne() + .HasForeignKey(i => i.ChildId); + + modelBuilder.Entity() + .HasOne(c => c.Metadata) + .WithOne() + .HasForeignKey(m => m.ChildId) + .IsRequired(false); + + modelBuilder.Entity() + .HasMany(c => c.Tags) + .WithMany(); + } +} + +internal sealed class RecursiveRoot +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public RecursiveChild? Child { get; set; } +} + +internal sealed class RecursiveChild +{ + public Guid Id { get; set; } + public Guid RootId { get; set; } + public string Name { get; set; } = string.Empty; + public ICollection Items { get; set; } = []; + public RecursiveChildMetadata? Metadata { get; set; } + public ICollection Tags { get; set; } = []; +} + +internal sealed class RecursiveChildItem +{ + public Guid Id { get; set; } + public Guid ChildId { get; set; } + public string Value { get; set; } = string.Empty; +} + +internal sealed class RecursiveChildMetadata +{ + public Guid Id { get; set; } + public Guid? ChildId { get; set; } + public string Notes { get; set; } = string.Empty; +} + +internal sealed class RecursiveTag +{ + public Guid Id { get; set; } + public string Label { get; set; } = string.Empty; +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/OperationGuardTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/OperationGuardTests.cs new file mode 100644 index 0000000..71f08ee --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/OperationGuardTests.cs @@ -0,0 +1,83 @@ +using Diwink.Extensions.EntityFrameworkCore.Exceptions; +using Diwink.Extensions.EntityFrameworkCore.GraphUpdate; +using FluentAssertions; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Unit.GraphDiff; + +/// +/// Unit tests for OperationGuard all-or-nothing behavior (FR-017). +/// +public class OperationGuardTests +{ + [Fact] + public void ThrowIfErrors_with_no_errors_does_not_throw() + { + var guard = new OperationGuard(); + + var act = () => guard.ThrowIfErrors(); + + act.Should().NotThrow(); + } + + [Fact] + public void ThrowIfErrors_with_single_error_throws_that_error() + { + var guard = new OperationGuard(); + var error = new UnsupportedNavigationMutatedException("Course.Items", "OneToMany"); + + guard.AddError(error); + var act = () => guard.ThrowIfErrors(); + + act.Should().Throw() + .Which.Should().BeSameAs(error); + } + + [Fact] + public void ThrowIfErrors_with_multiple_errors_throws_PartialMutationNotAllowed() + { + var guard = new OperationGuard(); + guard.AddError(new UnsupportedNavigationMutatedException("Course.Items", "OneToMany")); + guard.AddError(new UnsupportedNavigationMutatedException("Catalog.Students", "OneToMany")); + + var act = () => guard.ThrowIfErrors(); + + act.Should().Throw(); + } + + [Fact] + public void HasErrors_is_false_when_empty() + { + var guard = new OperationGuard(); + guard.HasErrors.Should().BeFalse(); + } + + [Fact] + public void HasErrors_is_true_after_adding_error() + { + var guard = new OperationGuard(); + guard.AddError(new UnsupportedNavigationMutatedException("X.Y", "OneToMany")); + guard.HasErrors.Should().BeTrue(); + } + + [Fact] + public void Errors_collection_tracks_all_added_errors() + { + var guard = new OperationGuard(); + guard.AddError(new UnsupportedNavigationMutatedException("A.B", "OneToMany")); + guard.AddError(new UnsupportedRelationshipPatternException("C.D", "Custom")); + + guard.Errors.Should().HaveCount(2); + guard.Errors.Should().NotBeAssignableTo>(); + } + + [Fact] + public void AddError_with_null_throws_ArgumentNullException() + { + var guard = new OperationGuard(); + + var act = () => guard.AddError(null!); + + act.Should().Throw() + .Which.ParamName.Should().Be("error"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/RelationshipClassificationTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/RelationshipClassificationTests.cs new file mode 100644 index 0000000..e2ddca8 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/RelationshipClassificationTests.cs @@ -0,0 +1,107 @@ +using Diwink.Extensions.EntityFrameworkCore.GraphUpdate; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Unit.GraphDiff; + +public class RelationshipClassificationTests +{ + [Fact] + public void Explicit_join_with_payload_is_classified_as_payload_many_to_many() + { + using var context = CreateContext(); + var navigation = context.Model + .FindEntityType(typeof(JoinRoot))! + .FindNavigation(nameof(JoinRoot.PayloadLinks))!; + + GraphUpdateOrchestrator.ClassifyNavigation(navigation) + .Should().Be(NavigationClassification.PayloadManyToMany); + } + + [Fact] + public void Explicit_join_without_payload_is_not_classified_as_payload_many_to_many() + { + using var context = CreateContext(); + var navigation = context.Model + .FindEntityType(typeof(JoinRoot))! + .FindNavigation(nameof(JoinRoot.PureLinks))!; + + GraphUpdateOrchestrator.ClassifyNavigation(navigation) + .Should().Be(NavigationClassification.Unsupported); + } + + private static JoinClassificationContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new JoinClassificationContext(options); + } + + private sealed class JoinClassificationContext : DbContext + { + public JoinClassificationContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(root => root.PayloadLinks) + .WithOne(link => link.Root) + .HasForeignKey(link => link.RootId); + + modelBuilder.Entity() + .HasKey(link => new { link.RootId, link.TagId }); + + modelBuilder.Entity() + .HasOne(link => link.Tag) + .WithMany() + .HasForeignKey(link => link.TagId); + + modelBuilder.Entity() + .HasMany(root => root.PureLinks) + .WithOne(link => link.Root) + .HasForeignKey(link => link.RootId); + + modelBuilder.Entity() + .HasKey(link => new { link.RootId, link.TagId }); + + modelBuilder.Entity() + .HasOne(link => link.Tag) + .WithMany() + .HasForeignKey(link => link.TagId); + } + } + + private sealed class JoinRoot + { + public int Id { get; set; } + public ICollection PayloadLinks { get; set; } = []; + public ICollection PureLinks { get; set; } = []; + } + + private sealed class JoinTag + { + public int Id { get; set; } + } + + private sealed class PayloadLink + { + public int RootId { get; set; } + public int TagId { get; set; } + public string Notes { get; set; } = string.Empty; + public JoinRoot Root { get; set; } = null!; + public JoinTag Tag { get; set; } = null!; + } + + private sealed class PureLink + { + public int RootId { get; set; } + public int TagId { get; set; } + public JoinRoot Root { get; set; } = null!; + public JoinTag Tag { get; set; } = null!; + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/UnsupportedRelationshipPatternTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/UnsupportedRelationshipPatternTests.cs new file mode 100644 index 0000000..b028efa --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/GraphDiff/UnsupportedRelationshipPatternTests.cs @@ -0,0 +1,76 @@ +using Diwink.Extensions.EntityFrameworkCore.Exceptions; +using Diwink.Extensions.EntityFrameworkCore.TestModel; +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Unit.GraphDiff; + +public class UnsupportedRelationshipPatternTests +{ + private static TestDbContext CreateInMemoryContext(string? dbName = null) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName ?? Guid.NewGuid().ToString()) + .Options; + + return new TestDbContext(options); + } + + [Fact] + public async Task In_place_scalar_edit_in_unsupported_one_to_many_is_rejected() + { + var dbName = Guid.NewGuid().ToString(); + var catalogId = Guid.NewGuid(); + var courseId = Guid.NewGuid(); + + { + await using var seedCtx = CreateInMemoryContext(dbName); + seedCtx.LearningCatalogs.Add(new LearningCatalog + { + Id = catalogId, + Name = "Catalog", + Courses = + [ + new Course + { + Id = courseId, + CatalogId = catalogId, + Title = "Original", + Code = "C-001" + } + ] + }); + + await seedCtx.SaveChangesAsync(); + } + + { + await using var ctx = CreateInMemoryContext(dbName); + var existing = await ctx.LearningCatalogs + .Include(c => c.Courses) + .FirstAsync(c => c.Id == catalogId); + + var updated = new LearningCatalog + { + Id = catalogId, + Name = "Catalog", + Courses = + [ + new Course + { + Id = courseId, + CatalogId = catalogId, + Title = "Retitled", + Code = "C-001" + } + ] + }; + + var act = () => ctx.InsertUpdateOrDeleteGraph(updated, existing); + + act.Should().Throw() + .Which.RelationshipPath.Should().Be("LearningCatalog.Courses"); + } + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/RelationshipSemantics/ManyToManyDiffStrategyTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/RelationshipSemantics/ManyToManyDiffStrategyTests.cs new file mode 100644 index 0000000..e69de29 diff --git a/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/RelationshipSemantics/OneToOneOwnershipResolverTests.cs b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/RelationshipSemantics/OneToOneOwnershipResolverTests.cs new file mode 100644 index 0000000..b0aad4e --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore.Tests.Unit/RelationshipSemantics/OneToOneOwnershipResolverTests.cs @@ -0,0 +1,394 @@ +using Diwink.Extensions.EntityFrameworkCore.TestModel; +using Diwink.Extensions.EntityFrameworkCore.TestModel.Entities; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; + +namespace Diwink.Extensions.EntityFrameworkCore.Tests.Unit.RelationshipSemantics; + +/// +/// Unit tests for one-to-one ownership resolver and removal strategy selection. +/// Uses InMemory provider for fast isolated testing of strategy mechanics. +/// +public class OneToOneOwnershipResolverTests +{ + private static TestDbContext CreateInMemoryContext(string? dbName = null) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: dbName ?? Guid.NewGuid().ToString()) + .Options; + return new TestDbContext(options); + } + + [Fact] + public async Task Required_one_to_one_removal_deletes_dependent() + { + // Arrange + var dbName = Guid.NewGuid().ToString(); + var courseId = Guid.NewGuid(); + var catalogId = Guid.NewGuid(); + + // Seed + { + await using var seedCtx = CreateInMemoryContext(dbName); + var catalog = new LearningCatalog { Id = catalogId, Name = "Cat" }; + var course = new Course + { + Id = courseId, + CatalogId = catalogId, + Title = "Test", + Code = "T-001", + Policy = new CoursePolicy + { + CourseId = courseId, + PolicyVersion = "1.0", + IsMandatory = true + } + }; + seedCtx.LearningCatalogs.Add(catalog); + seedCtx.Courses.Add(course); + await seedCtx.SaveChangesAsync(); + } + + // Act + { + await using var ctx = CreateInMemoryContext(dbName); + var existing = await ctx.Courses + .Include(c => c.Policy) + .FirstAsync(c => c.Id == courseId); + + var updated = new Course + { + Id = courseId, + CatalogId = catalogId, + Title = "Test", + Code = "T-001", + Policy = null + }; + + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + } + + // Assert + { + await using var verifyCtx = CreateInMemoryContext(dbName); + var course = await verifyCtx.Courses + .Include(c => c.Policy) + .FirstAsync(c => c.Id == courseId); + course.Policy.Should().BeNull(); + + var policyExists = await verifyCtx.Set() + .AnyAsync(p => p.CourseId == courseId); + policyExists.Should().BeFalse(); + } + } + + [Fact] + public async Task Optional_one_to_one_removal_nulls_fk() + { + // Arrange + var dbName = Guid.NewGuid().ToString(); + var mentorId = Guid.NewGuid(); + var workspaceId = Guid.NewGuid(); + + // Seed + { + await using var seedCtx = CreateInMemoryContext(dbName); + var mentor = new Mentor + { + Id = mentorId, + DisplayName = "M1", + Status = "Active", + Workspace = new MentorWorkspace + { + Id = workspaceId, + MentorId = mentorId, + DeskCode = "D-100", + Building = "HQ" + } + }; + seedCtx.Mentors.Add(mentor); + await seedCtx.SaveChangesAsync(); + } + + // Act + { + await using var ctx = CreateInMemoryContext(dbName); + var existing = await ctx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == mentorId); + + var updated = new Mentor + { + Id = mentorId, + DisplayName = "M1", + Status = "Active", + Workspace = null + }; + + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + } + + // Assert + { + await using var verifyCtx = CreateInMemoryContext(dbName); + var mentor = await verifyCtx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == mentorId); + mentor.Workspace.Should().BeNull(); + + var workspace = await verifyCtx.Set() + .FirstOrDefaultAsync(w => w.Id == workspaceId); + workspace.Should().NotBeNull("optional dependent should be preserved"); + workspace!.MentorId.Should().BeNull("FK should be nulled"); + } + } + + [Fact] + public async Task Optional_one_to_one_update_with_same_key_updates_in_place() + { + var dbName = Guid.NewGuid().ToString(); + var mentorId = Guid.NewGuid(); + var workspaceId = Guid.NewGuid(); + + { + await using var seedCtx = CreateInMemoryContext(dbName); + seedCtx.Mentors.Add(new Mentor + { + Id = mentorId, + DisplayName = "M1", + Status = "Active", + Workspace = new MentorWorkspace + { + Id = workspaceId, + MentorId = mentorId, + DeskCode = "D-100", + Building = "HQ" + } + }); + await seedCtx.SaveChangesAsync(); + } + + { + await using var ctx = CreateInMemoryContext(dbName); + var existing = await ctx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == mentorId); + + var updated = new Mentor + { + Id = mentorId, + DisplayName = "M1", + Status = "Active", + Workspace = new MentorWorkspace + { + Id = workspaceId, + MentorId = mentorId, + DeskCode = "D-200", + Building = "Annex" + } + }; + + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + } + + { + await using var verifyCtx = CreateInMemoryContext(dbName); + var workspaces = await verifyCtx.Set().ToListAsync(); + workspaces.Should().HaveCount(1); + workspaces[0].Id.Should().Be(workspaceId); + workspaces[0].DeskCode.Should().Be("D-200"); + workspaces[0].Building.Should().Be("Annex"); + workspaces[0].MentorId.Should().Be(mentorId); + } + } + + [Fact] + public async Task Optional_one_to_one_add_when_missing_inserts_and_links_new_dependent() + { + var dbName = Guid.NewGuid().ToString(); + var mentorId = Guid.NewGuid(); + var workspaceId = Guid.NewGuid(); + + { + await using var seedCtx = CreateInMemoryContext(dbName); + seedCtx.Mentors.Add(new Mentor + { + Id = mentorId, + DisplayName = "M1", + Status = "Active" + }); + await seedCtx.SaveChangesAsync(); + } + + { + await using var ctx = CreateInMemoryContext(dbName); + var existing = await ctx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == mentorId); + + var updated = new Mentor + { + Id = mentorId, + DisplayName = "M1", + Status = "Active", + Workspace = new MentorWorkspace + { + Id = workspaceId, + MentorId = mentorId, + DeskCode = "D-300", + Building = "West" + } + }; + + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + } + + { + await using var verifyCtx = CreateInMemoryContext(dbName); + var mentor = await verifyCtx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == mentorId); + + mentor.Workspace.Should().NotBeNull(); + mentor.Workspace!.Id.Should().Be(workspaceId); + mentor.Workspace.MentorId.Should().Be(mentorId); + mentor.Workspace.DeskCode.Should().Be("D-300"); + } + } + + [Fact] + public async Task Optional_one_to_one_replace_detaches_old_and_links_new_dependent() + { + var dbName = Guid.NewGuid().ToString(); + var mentorId = Guid.NewGuid(); + var oldWorkspaceId = Guid.NewGuid(); + var newWorkspaceId = Guid.NewGuid(); + + { + await using var seedCtx = CreateInMemoryContext(dbName); + seedCtx.Mentors.Add(new Mentor + { + Id = mentorId, + DisplayName = "M1", + Status = "Active", + Workspace = new MentorWorkspace + { + Id = oldWorkspaceId, + MentorId = mentorId, + DeskCode = "D-100", + Building = "HQ" + } + }); + await seedCtx.SaveChangesAsync(); + } + + { + await using var ctx = CreateInMemoryContext(dbName); + var existing = await ctx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == mentorId); + + var updated = new Mentor + { + Id = mentorId, + DisplayName = "M1", + Status = "Active", + Workspace = new MentorWorkspace + { + Id = newWorkspaceId, + MentorId = mentorId, + DeskCode = "D-999", + Building = "Tower" + } + }; + + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + } + + { + await using var verifyCtx = CreateInMemoryContext(dbName); + var mentor = await verifyCtx.Mentors + .Include(m => m.Workspace) + .FirstAsync(m => m.Id == mentorId); + mentor.Workspace.Should().NotBeNull(); + mentor.Workspace!.Id.Should().Be(newWorkspaceId); + mentor.Workspace.MentorId.Should().Be(mentorId); + + var oldWorkspace = await verifyCtx.Set() + .FirstAsync(w => w.Id == oldWorkspaceId); + oldWorkspace.MentorId.Should().BeNull(); + + var allWorkspaces = await verifyCtx.Set().ToListAsync(); + allWorkspaces.Should().HaveCount(2); + } + } + + [Fact] + public async Task Update_required_dependent_properties() + { + // Arrange + var dbName = Guid.NewGuid().ToString(); + var courseId = Guid.NewGuid(); + var catalogId = Guid.NewGuid(); + + { + await using var seedCtx = CreateInMemoryContext(dbName); + var catalog = new LearningCatalog { Id = catalogId, Name = "Cat" }; + var course = new Course + { + Id = courseId, + CatalogId = catalogId, + Title = "Test", + Code = "T-001", + Policy = new CoursePolicy + { + CourseId = courseId, + PolicyVersion = "1.0", + IsMandatory = true + } + }; + seedCtx.LearningCatalogs.Add(catalog); + seedCtx.Courses.Add(course); + await seedCtx.SaveChangesAsync(); + } + + // Act + { + await using var ctx = CreateInMemoryContext(dbName); + var existing = await ctx.Courses + .Include(c => c.Policy) + .FirstAsync(c => c.Id == courseId); + + var updated = new Course + { + Id = courseId, + CatalogId = catalogId, + Title = "Test", + Code = "T-001", + Policy = new CoursePolicy + { + CourseId = courseId, + PolicyVersion = "2.0", + IsMandatory = false + } + }; + + ctx.InsertUpdateOrDeleteGraph(updated, existing); + await ctx.SaveChangesAsync(); + } + + // Assert + { + await using var verifyCtx = CreateInMemoryContext(dbName); + var policy = await verifyCtx.Set() + .FirstAsync(p => p.CourseId == courseId); + policy.PolicyVersion.Should().Be("2.0"); + policy.IsMandatory.Should().BeFalse(); + } + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/DbContextExtensions.cs b/src/Diwink.Extensions.EntityFrameworkCore/DbContextExtensions.cs index b5056a3..41d855f 100644 --- a/src/Diwink.Extensions.EntityFrameworkCore/DbContextExtensions.cs +++ b/src/Diwink.Extensions.EntityFrameworkCore/DbContextExtensions.cs @@ -1,121 +1,48 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using Diwink.Extensions.EntityFrameworkCore.GraphUpdate; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; +namespace Diwink.Extensions.EntityFrameworkCore; -namespace Diwink.Extensions.EntityFrameworkCore +/// +/// Public extension methods for EF Core graph update v2. +/// Accepts a detached object graph and diffs it against the tracked original (FR-001a). +/// +public static class DbContextExtensions { - public static class DbContextExtensions + /// + /// Updates the tracked graph to match the + /// state of the detached graph. + /// + /// Supported relationship patterns: pure many-to-many, many-to-many with + /// payload, required one-to-one, optional one-to-one. + /// + /// Unsupported patterns in loaded navigations are silently skipped if unchanged, + /// or rejected if mutations are detected (FR-018/FR-019). + /// + /// All-or-nothing semantics: if any mutation is unsupported, the entire + /// operation is rejected before any changes are applied (FR-017). + /// + /// The aggregate root entity type. + /// The DbContext tracking the existing entity. + /// Detached entity graph representing desired state. + /// Tracked entity graph loaded from the database. + /// + /// Synchronizes the tracked entity graph represented by so it matches the detached graph. + /// + /// The detached entity graph containing the desired state. + /// The already-tracked entity graph to be updated to match . + /// The updated tracked entity. + /// Thrown if , , or is null. + public static T InsertUpdateOrDeleteGraph( + this DbContext context, + T updatedEntity, + T existingEntity) + where T : class { - /// - /// Get list of objects that represents the Primary Key of an entity - /// - /// - /// - public static object[] GetPrimaryKeyValues(this EntityEntry entry) - { - return entry.Metadata.FindPrimaryKey() - .Properties - .Select(p => entry.Property(p.Name).CurrentValue) - .ToArray(); - } + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(updatedEntity); + ArgumentNullException.ThrowIfNull(existingEntity); - /// - /// simple update method that will help you to do a full update to an aggregate graph with all related entities in it. - /// the update method will take the loaded aggregate entity from the DB and the passed one that may come from the API layer. - /// the method will update just the eager loaded entities in the aggregate "The included entities" - /// - /// - /// - /// The De-Attached Entity - /// The Attached BD Entity - public static T InsertUpdateOrDeleteGraph(this DbContext context, T newEntity, T existingEntity) where T : class - { - return insertUpdateOrDeleteGraph(context, newEntity, existingEntity, null); - } - - - private static T insertUpdateOrDeleteGraph(this DbContext context, T newEntity, T existingEntity, string aggregateType) where T : class - { - if (existingEntity == null) - { - context.Add(newEntity); - return newEntity; - } - else if (newEntity == null) - { - context.Remove(existingEntity); - return null; - } - else - { - var existingEntry = context.Entry(existingEntity); - existingEntry.CurrentValues.SetValues(newEntity); - - foreach (var navigationEntry in existingEntry.Navigations.Where(n => n.IsLoaded && n.Metadata.ClrType.FullName != aggregateType)) - { - var passedNavigationObject = existingEntry.Entity.GetType().GetProperty(navigationEntry.Metadata.Name)?.GetValue(newEntity); - - //if (navigationEntry.Metadata.IsCollection()) causes Error CS1929 'INavigationBase' does not contain a definition for 'IsCollection' and the best extension method overload 'NavigationExtensions.IsCollection(INavigation)' requires a receiver of type 'INavigation' - //use instead https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.changetracking.collectionentry?view=efcore-7.0 - if (navigationEntry is CollectionEntry) - { - // the navigation property is list - if (!(navigationEntry.CurrentValue is IEnumerable existingNavigationObject)) - throw new NullReferenceException($"Couldn't iterate through the DB value of the Navigation '{navigationEntry.Metadata.Name}'"); - IEnumerable passedNavigationObjectEnumerable = null; - - if (passedNavigationObject != null) //skip null children in newEntity. - { - passedNavigationObjectEnumerable = passedNavigationObject as IEnumerable; - if (passedNavigationObject ==null) - throw new NullReferenceException($"Couldn't iterate through the passed Navigation list of '{navigationEntry.Metadata.Name}'"); - - foreach (var newValue in passedNavigationObjectEnumerable) - { - var newId = context.Entry(newValue).GetPrimaryKeyValues(); - var existingValue = existingNavigationObject.FirstOrDefault(v => context.Entry(v).GetPrimaryKeyValues().SequenceEqual(newId)); - if (existingValue == null) - { - var addMethod = existingNavigationObject.GetType().GetMethod("Add"); - - if (addMethod == null) - throw new NullReferenceException($"The collection type in the Navigation property '{navigationEntry.Metadata.Name}' doesn't have an 'Add' method."); - - addMethod.Invoke(existingNavigationObject, new[] { newValue }); - } - - //Update sub navigation - insertUpdateOrDeleteGraph(context, newValue, existingValue, existingEntry.Metadata.ClrType.FullName); - } - } - - foreach (var existingValue in existingNavigationObject.ToList()) - { - var existingId = context.Entry(existingValue).GetPrimaryKeyValues(); - //If passedNavigationObject is null, delete existing records - if (passedNavigationObject==null || passedNavigationObjectEnumerable.All(v => !context.Entry(v).GetPrimaryKeyValues().SequenceEqual(existingId))) - { - var removeMethod = existingNavigationObject.GetType().GetMethod("Remove"); - - if (removeMethod == null) - throw new NullReferenceException($"The collection type in the Navigation property '{navigationEntry.Metadata.Name}' doesn't have a 'Remove' method."); - - removeMethod.Invoke(existingNavigationObject, new[] { existingValue }); - } - } - } - else - { - // the navigation is not a list - insertUpdateOrDeleteGraph(context, passedNavigationObject, navigationEntry.CurrentValue, existingEntry.Metadata.ClrType.FullName); - } - } - - return existingEntity; - } - } + return GraphUpdateOrchestrator.UpdateGraph(context, updatedEntity, existingEntity); } } diff --git a/src/Diwink.Extensions.EntityFrameworkCore/Diwink.Extensions.EntityFrameworkCore.csproj b/src/Diwink.Extensions.EntityFrameworkCore/Diwink.Extensions.EntityFrameworkCore.csproj index 8ff610e..85a4bf7 100644 --- a/src/Diwink.Extensions.EntityFrameworkCore/Diwink.Extensions.EntityFrameworkCore.csproj +++ b/src/Diwink.Extensions.EntityFrameworkCore/Diwink.Extensions.EntityFrameworkCore.csproj @@ -1,25 +1,26 @@ - + - netstandard2.0 + net10.0 + enable + enable + + Diwink.Extensions.EntityFrameworkCore + 10.0.0 Wahid Bitar Digital Wink - Diwink.Extensions - Helpers extentions for Entity Framework Core - https://github.com/WahidBitar/EF-Core-Simple-Graph-Update + Simple graph update extension for Entity Framework Core targeting EF Core 10+ https://github.com/WahidBitar/EF-Core-Simple-Graph-Update.git git - EntityFrameworkCore;Extensions;Digital Wink;EntityFramework;EF Core - simple update method that will help you to do a full update to an aggregate graph with all related entities in it. -the update method will take the loaded aggregate entity from the DB and the passed one that may come from the API layer. Internally the method will update just the eager loaded entities in the aggregate "The included entities" - false - 1.0.8.0 - 1.0.8.0 - 1.0.8 - + + + + + + diff --git a/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/AmbiguousOwnershipSemanticsException.cs b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/AmbiguousOwnershipSemanticsException.cs new file mode 100644 index 0000000..3cc6ac2 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/AmbiguousOwnershipSemanticsException.cs @@ -0,0 +1,26 @@ +namespace Diwink.Extensions.EntityFrameworkCore.Exceptions; + +/// +/// Thrown when required vs optional ownership semantics cannot be resolved +/// for a one-to-one mutation path. +/// +public sealed class AmbiguousOwnershipSemanticsException : GraphUpdateException +{ + public string MissingDetail { get; } + + /// + /// Creates a new AmbiguousOwnershipSemanticsException for a one-to-one mutation path when requiredness or ownership metadata is ambiguous or missing. + /// + /// The relationship path where the ambiguous ownership semantics were detected. + /// A specific detail describing the missing metadata that caused the ambiguity. + public AmbiguousOwnershipSemanticsException( + string relationshipPath, + string missingDetail) + : base( + $"Ambiguous ownership semantics at '{relationshipPath}': {missingDetail}. " + + "The contract requires explicit requiredness/ownership metadata.", + relationshipPath) + { + MissingDetail = ValidateAndNormalize(missingDetail, nameof(missingDetail), "Missing detail"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/GraphUpdateException.cs b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/GraphUpdateException.cs new file mode 100644 index 0000000..d519879 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/GraphUpdateException.cs @@ -0,0 +1,31 @@ +namespace Diwink.Extensions.EntityFrameworkCore.Exceptions; + +/// +/// Base exception for all graph update contract violations. +/// +public abstract class GraphUpdateException : InvalidOperationException +{ + public string RelationshipPath { get; } + + /// + /// Initializes a new instance of with a specified error message and the relationship path related to the contract violation. + /// + /// The error message that explains the reason for the exception. + /// The relationship path associated with the graph update contract violation. + protected GraphUpdateException(string message, string relationshipPath) + : base(message) + { + RelationshipPath = ValidateAndNormalize(relationshipPath, nameof(relationshipPath), "Relationship path"); + } + + protected static string ValidateAndNormalize(string? value, string paramName, string displayName) + { + ArgumentNullException.ThrowIfNull(value, paramName); + + var normalizedValue = value.Trim(); + if (normalizedValue.Length == 0) + throw new ArgumentException($"{displayName} cannot be empty or whitespace.", paramName); + + return normalizedValue; + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/PartialMutationNotAllowedException.cs b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/PartialMutationNotAllowedException.cs new file mode 100644 index 0000000..2ebd4a7 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/PartialMutationNotAllowedException.cs @@ -0,0 +1,26 @@ +namespace Diwink.Extensions.EntityFrameworkCore.Exceptions; + +/// +/// Thrown when a graph contains both supported and unsupported requested mutations. +/// The entire operation is rejected. +/// +public sealed class PartialMutationNotAllowedException : GraphUpdateException +{ + public string UnsupportedBranch { get; } + + /// + /// Creates an exception that indicates a graph update was rejected because a requested mutation contained an unsupported branch. + /// + /// The relationship path in the graph where the update was attempted. + /// The identifier or path of the branch that contains unsupported mutations. + public PartialMutationNotAllowedException( + string relationshipPath, + string unsupportedBranch) + : base( + $"Graph operation rejected: unsupported mutation detected at '{unsupportedBranch}'. " + + "The entire operation was rejected — partial mutation is not allowed.", + relationshipPath) + { + UnsupportedBranch = ValidateAndNormalize(unsupportedBranch, nameof(unsupportedBranch), "Unsupported branch"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/UnloadedNavigationMutationException.cs b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/UnloadedNavigationMutationException.cs new file mode 100644 index 0000000..52cab9e --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/UnloadedNavigationMutationException.cs @@ -0,0 +1,25 @@ +namespace Diwink.Extensions.EntityFrameworkCore.Exceptions; + +/// +/// Thrown when a requested mutation depends on a navigation branch not explicitly loaded. +/// +public sealed class UnloadedNavigationMutationException : GraphUpdateException +{ + public string NavigationName { get; } + + /// + /// Initializes a new exception indicating a mutation relied on a navigation that was not explicitly loaded. + /// + /// The relationship path identifying where the mutation was requested. + /// The name of the navigation property that was required but not loaded. + public UnloadedNavigationMutationException( + string relationshipPath, + string navigationName) + : base( + $"Mutation at '{relationshipPath}' depends on navigation '{navigationName}' " + + "which was not explicitly loaded. The entire operation was rejected without partial apply.", + relationshipPath) + { + NavigationName = navigationName; + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/UnsupportedNavigationMutatedException.cs b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/UnsupportedNavigationMutatedException.cs new file mode 100644 index 0000000..16daf8d --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/UnsupportedNavigationMutatedException.cs @@ -0,0 +1,29 @@ +namespace Diwink.Extensions.EntityFrameworkCore.Exceptions; + +/// +/// Thrown when a loaded navigation of a currently-unsupported relationship type +/// (e.g., one-to-many) has mutations detected during graph diff. +/// Unchanged unsupported navigations are silently skipped. +/// +public sealed class UnsupportedNavigationMutatedException : GraphUpdateException +{ + public string RelationshipType { get; } + + /// + /// Initializes a new for a detected mutation on a navigation whose relationship type is not supported by the v2 contract. + /// + /// The navigation path where the mutation was detected. + /// The unsupported relationship type (for example, "one-to-many"). + public UnsupportedNavigationMutatedException( + string relationshipPath, + string relationshipType) + : base( + $"Mutation detected in unsupported navigation '{relationshipPath}' " + + $"(relationship type: {relationshipType}). This relationship type is not " + + "in the v2 supported contract. The entire operation was rejected. " + + "Unchanged unsupported navigations would have been silently skipped.", + relationshipPath) + { + RelationshipType = ValidateAndNormalize(relationshipType, nameof(relationshipType), "Relationship type"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/UnsupportedRelationshipPatternException.cs b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/UnsupportedRelationshipPatternException.cs new file mode 100644 index 0000000..01b1fa2 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/Exceptions/UnsupportedRelationshipPatternException.cs @@ -0,0 +1,25 @@ +namespace Diwink.Extensions.EntityFrameworkCore.Exceptions; + +/// +/// Thrown when a requested mutation uses a relationship pattern not in the v2 contract. +/// +public sealed class UnsupportedRelationshipPatternException : GraphUpdateException +{ + public string PatternIdentifier { get; } + + /// + /// Initializes a new for a mutation that relies on a relationship pattern not supported by the v2 contract. + /// + /// The relationship path where the unsupported pattern was encountered. + /// The identifier of the unsupported relationship pattern. + public UnsupportedRelationshipPatternException( + string relationshipPath, + string patternIdentifier) + : base( + $"Unsupported relationship pattern '{patternIdentifier}' at '{relationshipPath}'. " + + "See the v2 contract documentation for supported patterns.", + relationshipPath) + { + PatternIdentifier = ValidateAndNormalize(patternIdentifier, nameof(patternIdentifier), "Pattern identifier"); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/EntityKeyHelper.cs b/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/EntityKeyHelper.cs new file mode 100644 index 0000000..cd35870 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/EntityKeyHelper.cs @@ -0,0 +1,168 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace Diwink.Extensions.EntityFrameworkCore.GraphUpdate; + +/// +/// Extracts and compares primary key values from entities using EF Core metadata. +/// +internal static class EntityKeyHelper +{ + /// + /// Gets the primary key values for a tracked entity. + /// + /// Extracts the primary key component values from a tracked EF Core entity entry. + /// + /// The tracked entity entry to read key values from. + /// An array of primary key component values in the primary key's property order. + /// + /// Thrown when the entity type does not define a primary key, or when any primary key component value on the tracked entity is null. + /// + public static object[] GetKeyValues(EntityEntry entry) + { + var primaryKey = entry.Metadata.FindPrimaryKey() + ?? throw new InvalidOperationException( + $"Entity '{entry.Metadata.ClrType.Name}' does not define a primary key."); + + return primaryKey.Properties + .Select(p => GetRequiredTrackedKeyValue(entry, p)) + .ToArray(); + } + + /// + /// Gets the primary key values for a detached entity using model metadata. + /// + /// Extracts the primary key component values for a detached CLR entity using the provided DbContext's model metadata. + /// + /// The DbContext whose model is used to resolve the entity type and primary key. + /// The CLR entity instance to read key values from. + /// An array containing the primary key component values in primary-key order, or an empty array if the entity type or its primary key metadata cannot be found. + /// Thrown if any primary key component value is null or if a key property cannot be read from the CLR entity (for example, when neither a PropertyInfo nor FieldInfo is available). + public static object[] GetKeyValues(DbContext context, object entity) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(entity); + + var entityType = context.Model.FindEntityType(entity.GetType()) + ?? throw new InvalidOperationException( + $"Entity type '{entity.GetType().FullName}' does not exist in the current DbContext model."); + + var primaryKey = entityType.FindPrimaryKey() + ?? throw new InvalidOperationException( + $"Entity '{entityType.ClrType.Name}' does not define a primary key."); + + return primaryKey.Properties + .Select(p => GetRequiredDetachedKeyValue(entityType, p, entity)) + .ToArray(); + } + + /// + /// Compares two key arrays for equality. + /// + /// Determines whether two arrays of primary-key component values are equal element-by-element in order. + /// + /// First array of key component values, in primary-key order. + /// Second array of key component values, in primary-key order. + /// true if both arrays have the same length and each element equals the corresponding element in the other array; false otherwise. + public static bool KeysEqual(object[] keys1, object[] keys2) + { + ArgumentNullException.ThrowIfNull(keys1); + ArgumentNullException.ThrowIfNull(keys2); + + if (keys1.Length != keys2.Length) + return false; + + for (var index = 0; index < keys1.Length; index++) + { + var left = keys1[index]; + var right = keys2[index]; + + if (left is byte[] leftBytes && right is byte[] rightBytes) + { + if (!leftBytes.AsSpan().SequenceEqual(rightBytes)) + return false; + + continue; + } + + if (left is byte[] || right is byte[]) + return false; + + if (!Equals(left, right)) + return false; + } + + return true; + } + + /// + /// Finds a matching entity in a collection by primary key comparison. + /// + /// Finds the first entity in whose primary key components, as determined from 's model, match . + /// + /// Sequence of entities to search. + /// Primary key component values to match, in the same order as the entity type's primary key properties. + /// The first matching entity from , or null if no match is found. + public static T? FindByKey( + DbContext context, + IEnumerable collection, + object[] targetKeys) where T : class + { + foreach (var item in collection) + { + var itemKeys = GetKeyValues(context, item); + if (KeysEqual(itemKeys, targetKeys)) + return item; + } + return null; + } + + /// + /// Read the CLR value of the given model property or field from a detached entity instance. + /// + /// The EF Core model property describing the mapped CLR property or field. + /// The CLR entity instance to read the value from. + /// The CLR value of the property or field, or null if the value is null. + /// Thrown if the does not expose a CLR PropertyInfo or FieldInfo. + internal static object? ReadDetachedPropertyValue(IProperty property, object entity) + { + if (property.PropertyInfo is not null) + return property.PropertyInfo.GetValue(entity); + + if (property.FieldInfo is not null) + return property.FieldInfo.GetValue(entity); + + throw new InvalidOperationException( + $"Property '{property.Name}' on entity '{entity.GetType().Name}' does not expose a CLR property or field."); + } + + /// + /// Get the non-null value of a primary key component for a tracked entity. + /// + /// The tracked entity entry containing the current property values. + /// The primary key property metadata whose value to read. + /// The primary key component value (guaranteed non-null). + /// Thrown if the primary key component value is null on the tracked entity. + private static object GetRequiredTrackedKeyValue(EntityEntry entry, IProperty property) + { + var value = entry.Property(property.Name).CurrentValue; + return value ?? throw new InvalidOperationException( + $"Primary key component '{property.Name}' on tracked entity '{entry.Metadata.ClrType.Name}' is null."); + } + + /// + /// Retrieves a primary key component value from a detached entity and ensures it is not null. + /// + /// The EF Core entity type metadata for the detached entity. + /// The primary key property metadata to read. + /// The detached CLR entity instance. + /// The non-null value of the specified primary key component. + /// Thrown if the key component value is null. + private static object GetRequiredDetachedKeyValue(IEntityType entityType, IProperty property, object entity) + { + var value = ReadDetachedPropertyValue(property, entity); + return value ?? throw new InvalidOperationException( + $"Primary key component '{property.Name}' on detached entity '{entityType.ClrType.Name}' is null."); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/GraphUpdateOrchestrator.cs b/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/GraphUpdateOrchestrator.cs new file mode 100644 index 0000000..5fc0a4b --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/GraphUpdateOrchestrator.cs @@ -0,0 +1,577 @@ +using Diwink.Extensions.EntityFrameworkCore.Exceptions; +using Diwink.Extensions.EntityFrameworkCore.RelationshipStrategies; +using Diwink.Extensions.EntityFrameworkCore.Traversal; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace Diwink.Extensions.EntityFrameworkCore.GraphUpdate; + +/// +/// Core orchestrator that diffs a detached updated entity graph against a tracked +/// existing entity graph and applies mutations using the appropriate relationship +/// strategy for each navigation. +/// +/// Enforces FR-001a (detached graph input), FR-015 (explicit loading), +/// FR-017 (all-or-nothing), FR-018/FR-019 (unsupported type handling). +/// +internal static class GraphUpdateOrchestrator +{ + /// + /// Entry point for graph update orchestration. + /// + /// Orchestrates validation and application of changes from a detached updated entity graph onto an existing tracked entity graph. + /// + /// The DbContext that is tracking and provides EF metadata and change tracking. + /// A detached entity graph containing proposed scalar and navigation changes. + /// The tracked root entity whose scalar properties and loaded navigations will be updated. + /// The same instance after applying validated updates from . + public static T UpdateGraph(DbContext context, T updatedEntity, T existingEntity) + where T : class + { + var existingEntry = context.Entry(existingEntity); + var aggregateType = typeof(T); + + // Phase 1: Validate — collect all errors before any mutation + var guard = new OperationGuard(); + ValidateNavigations(context, existingEntry, updatedEntity, aggregateType, guard); + guard.ThrowIfErrors(); + + // Phase 2: Apply — update scalar properties then process navigations + RelatedEntityMutationService.UpdateScalarProperties(existingEntry, updatedEntity); + ApplyNavigations(context, existingEntry, updatedEntity, aggregateType); + + return existingEntity; + } + + /// + /// Validates navigation mutation legality on a tracked entity against a detached updated graph and records any violations in the provided guard. + /// + /// The used to resolve metadata and keys. + /// The tracked whose navigations are being validated. + /// The detached updated entity graph to inspect for attempted navigation mutations. + /// The aggregate root CLR type used to ignore navigations that point back to the aggregate root. + /// An that will collect validation errors (e.g., unsupported navigation mutations, attempts to mutate unloaded navigations). + /// Optional set used to detect and prevent cycles during recursive validation; callers may supply a shared set for the top-level traversal. + private static void ValidateNavigations( + DbContext context, + EntityEntry existingEntry, + object updatedEntity, + Type aggregateType, + OperationGuard guard, + HashSet? recursionPath = null) + { + recursionPath ??= new HashSet(ReferenceEqualityComparer.Instance); + if (!recursionPath.Add(existingEntry.Entity)) + return; + + try + { + // Check loaded navigations for unsupported mutations + foreach (var navigation in existingEntry.Navigations + .Where(n => n.IsLoaded)) + { + var navMetadata = navigation.Metadata; + if (IsNavigationBackToAggregateRoot(navMetadata, aggregateType)) + continue; + + var entityPath = $"{existingEntry.Metadata.ClrType.Name}.{navMetadata.Name}"; + + var classification = ClassifyNavigation(navMetadata); + + if (classification == NavigationClassification.Unsupported) + { + // FR-018/FR-019: Check if mutations exist in unsupported navigation + if (HasMutations(context, navigation, updatedEntity, navMetadata)) + { + guard.AddError(new UnsupportedNavigationMutatedException( + entityPath, + GetRelationshipTypeName(navMetadata))); + } + // else: silently skip (FR-019) + continue; + } + + ValidateLoadedChildren( + context, + navigation, + updatedEntity, + aggregateType, + guard, + recursionPath); + } + + // FR-015/FR-016: Check unloaded navigations for attempted mutations + foreach (var navigation in existingEntry.Navigations + .Where(n => !n.IsLoaded)) + { + var navMetadata = navigation.Metadata; + + if (IsNavigationBackToAggregateRoot(navMetadata, aggregateType)) + continue; + + var entityPath = $"{existingEntry.Metadata.ClrType.Name}.{navMetadata.Name}"; + + if (HasUnloadedMutationAttempt(updatedEntity, navMetadata)) + { + guard.AddError(new UnloadedNavigationMutationException( + entityPath, + navMetadata.Name)); + } + } + } + finally + { + recursionPath.Remove(existingEntry.Entity); + } + } + + /// + /// Checks whether the updated entity provides non-empty values for a navigation + /// that was not loaded. A non-empty collection or non-null reference is treated + /// as an attempted mutation on an unloaded navigation (FR-015). + /// + /// Determines whether the detached updated entity attempted to mutate the specified unloaded navigation. + /// + /// The detached entity graph provided for the update. + /// Metadata for the navigation property being inspected. + /// `true` if the navigation property exists on the detached entity and represents an attempted mutation (non-null reference or a collection with any items), `false` otherwise. + private static bool HasUnloadedMutationAttempt( + object updatedEntity, + INavigationBase navMetadata) + { + var navProperty = updatedEntity.GetType().GetProperty(navMetadata.Name); + if (navProperty is null) + return false; + + var updatedValue = navProperty.GetValue(updatedEntity); + if (updatedValue is null) + return false; + + // Collection navigation: non-null with items = attempted mutation + if (updatedValue is IEnumerable collection) + return collection.Any(); + + // Reference navigation: non-null = attempted mutation + return true; + } + + /// + /// Applies navigation property updates from a detached updated entity onto a tracked existing entity entry. + /// + /// The detached updated entity graph to read navigation values from. + /// The aggregate root CLR type used to identify navigations that point back to the aggregate root and should be skipped. + internal static void ApplyNavigations( + DbContext context, + EntityEntry existingEntry, + object updatedEntity, + Type aggregateType) + { + foreach (var navigation in existingEntry.Navigations + .Where(n => n.IsLoaded)) + { + var navMetadata = navigation.Metadata; + if (IsNavigationBackToAggregateRoot(navMetadata, aggregateType)) + continue; + + var classification = ClassifyNavigation(navMetadata); + + // Skip unsupported navigations (already validated, no mutations) + if (classification == NavigationClassification.Unsupported) + continue; + + if (!TryGetUpdatedNavigationValue(updatedEntity, navMetadata, out var updatedValue)) + continue; + + if (navigation is CollectionEntry collectionEntry) + { + ApplyCollectionNavigation(context, collectionEntry, updatedValue, classification); + } + else if (navigation is ReferenceEntry referenceEntry) + { + ApplyReferenceNavigation(context, referenceEntry, updatedValue, classification, aggregateType); + } + } + } + + /// + /// Applies updates to a loaded collection navigation on the tracked entity using the appropriate many-to-many strategy. + /// + /// The used for attach/relationship operations. + /// The tracked collection navigation to update. + /// The detached navigation value from the updated graph; treated as an empty collection when null. + /// The navigation classification that determines which many-to-many strategy to apply. + private static void ApplyCollectionNavigation( + DbContext context, + CollectionEntry existingNavigation, + object? updatedValue, + NavigationClassification classification) + { + var updatedCollection = updatedValue as IEnumerable ?? []; + + switch (classification) + { + case NavigationClassification.PureManyToMany: + PureManyToManyStrategy.Apply(context, existingNavigation, updatedCollection); + break; + + case NavigationClassification.PayloadManyToMany: + PayloadManyToManyStrategy.Apply(context, existingNavigation, updatedCollection); + break; + + default: + // Should not reach here — unsupported already filtered + break; + } + } + + /// + /// Applies an updated reference navigation value to a tracked reference navigation, performing attach, replace, update, detach, or remove actions according to the navigation classification and the presence of existing and updated values. + /// + /// The tracked reference navigation entry to update. + /// The detached navigation value from the updated graph, or null to indicate removal. + /// The relationship classification for the navigation which determines attach/replace/remove semantics. + /// The aggregate root CLR type used when processing nested navigations. + private static void ApplyReferenceNavigation( + DbContext context, + ReferenceEntry existingNavigation, + object? updatedValue, + NavigationClassification classification, + Type aggregateType) + { + var existingValue = existingNavigation.CurrentValue; + + if (updatedValue is not null && existingValue is not null) + { + if (classification == NavigationClassification.OptionalOneToOne && + !ReferenceKeysMatch(context, existingValue, updatedValue)) + { + OptionalOneToOneStrategy.ReplaceDependent(context, existingNavigation, updatedValue); + return; + } + + // Update existing reference — scalars + nested navigations + var childEntry = context.Entry(existingValue); + RelatedEntityMutationService.UpdateScalarProperties(childEntry, updatedValue); + RelatedEntityMutationService.ProcessNavigations(context, childEntry, updatedValue, aggregateType); + } + else if (updatedValue is not null && existingValue is null) + { + if (classification == NavigationClassification.OptionalOneToOne) + { + OptionalOneToOneStrategy.AttachDependent(context, existingNavigation, updatedValue); + return; + } + + // Add new reference + existingNavigation.CurrentValue = updatedValue; + } + else if (updatedValue is null && existingValue is not null) + { + // Remove reference — behavior depends on required vs optional + if (classification == NavigationClassification.RequiredOneToOne) + { + RequiredOneToOneStrategy.RemoveDependent(context, existingNavigation); + } + else if (classification == NavigationClassification.OptionalOneToOne) + { + OptionalOneToOneStrategy.DetachDependent(context, existingNavigation); + } + } + } + + /// + /// Classifies a navigation as a supported or unsupported relationship pattern. + /// + /// Classifies the given navigation metadata into a relationship pattern used to drive graph update behavior. + /// + /// Metadata for the navigation property to classify. + /// + /// A NavigationClassification value indicating the relationship pattern: + /// `PureManyToMany`, `PayloadManyToMany`, `RequiredOneToOne`, `OptionalOneToOne`, or `Unsupported`. + /// + internal static NavigationClassification ClassifyNavigation(INavigationBase navMetadata) + { + if (navMetadata is ISkipNavigation) + return NavigationClassification.PureManyToMany; + + if (navMetadata is INavigation nav) + { + var foreignKey = nav.ForeignKey; + + // Collection navigation on the principal side = one-to-many + if (nav.IsCollection) + { + // Check if this is a payload many-to-many (explicit join entity) + if (IsPayloadJoinEntity(nav.TargetEntityType)) + return NavigationClassification.PayloadManyToMany; + + // Regular one-to-many — unsupported in v2 + return NavigationClassification.Unsupported; + } + + // Reference navigation — one-to-one + if (!foreignKey.IsUnique) + return NavigationClassification.Unsupported; + + if (foreignKey.IsRequired) + return NavigationClassification.RequiredOneToOne; + + return NavigationClassification.OptionalOneToOne; + } + + return NavigationClassification.Unsupported; + } + + /// + /// Detects whether an entity type is a payload join entity (explicit many-to-many). + /// A payload join entity has a composite key where all key parts are also foreign keys, + /// AND it has additional non-key, non-FK properties (the payload). + /// + /// Determines whether the given entity type represents an explicit many-to-many join entity that carries payload. + /// + /// The EF Core entity metadata to inspect. + /// `true` if the entity has a primary key with at least two properties and every primary key property is included in at least one foreign key; `false` otherwise. + private static bool IsPayloadJoinEntity(IEntityType entityType) + { + var primaryKey = entityType.FindPrimaryKey(); + if (primaryKey is null || primaryKey.Properties.Count < 2) + return false; + + var foreignKeys = entityType.GetForeignKeys().ToList(); + var allKeyPropsAreFk = primaryKey.Properties.All(keyProp => + foreignKeys.Any(fk => fk.Properties.Contains(keyProp))); + + if (!allKeyPropsAreFk) + return false; + + var foreignKeyProperties = foreignKeys + .SelectMany(fk => fk.Properties) + .ToHashSet(); + + var hasPayloadProperty = entityType.GetProperties().Any(property => + !primaryKey.Properties.Contains(property) && + !foreignKeyProperties.Contains(property)); + + return hasPayloadProperty; + } + + /// + /// Checks whether a loaded navigation has mutations between existing and updated state. + /// Used for FR-018 to detect changes in unsupported navigation types. + /// + /// Determines whether the detached updated entity proposes mutations for the specified loaded navigation. + /// + /// The tracked navigation entry on the existing entity to compare against. + /// The detached updated entity containing the candidate navigation value. + /// Metadata for the navigation property being inspected. + /// `true` if the updated entity contains mutations for the navigation — for collections: differing counts, a missing primary-key match, or scalar property differences on matched items; for references: null/non-null changes, differing primary keys, or scalar property differences; `false` otherwise. + private static bool HasMutations( + DbContext context, + NavigationEntry navigation, + object updatedEntity, + INavigationBase navMetadata) + { + if (!TryGetUpdatedNavigationValue(updatedEntity, navMetadata, out var updatedValue)) + return false; + + if (navigation is CollectionEntry collectionEntry) + { + var existingItems = collectionEntry.CurrentValue?.Cast().ToList() ?? []; + var updatedItems = (updatedValue as IEnumerable)?.ToList() ?? []; + + if (existingItems.Count != updatedItems.Count) + return true; + + // Compare by primary keys + foreach (var existingItem in existingItems) + { + var existingKeys = EntityKeyHelper.GetKeyValues(context.Entry(existingItem)); + var match = EntityKeyHelper.FindByKey(context, updatedItems, existingKeys); + if (match is null) + return true; + + if (HasScalarDifferences(context.Entry(existingItem), match)) + return true; + } + + return false; + } + + if (navigation is ReferenceEntry referenceEntry) + { + var existingValue = referenceEntry.CurrentValue; + if (existingValue is null && updatedValue is null) + return false; + if (existingValue is null || updatedValue is null) + return true; + + // Both non-null — check if keys match + var existingKeys = EntityKeyHelper.GetKeyValues(context.Entry(existingValue)); + var updatedKeys = EntityKeyHelper.GetKeyValues(context, updatedValue); + if (!EntityKeyHelper.KeysEqual(existingKeys, updatedKeys)) + return true; + + return HasScalarDifferences(context.Entry(existingValue), updatedValue); + } + + return false; + } + + /// + /// Recursively validates loaded child navigations when the detached updated entity provides values for them. + /// + /// The DbContext used to obtain tracked entries and metadata. + /// The loaded navigation on the tracked entity to validate against the detached value. + /// The detached entity (or detached navigation value) that may contain proposed child values. + /// The aggregate root CLR type used to detect navigations that point back to the aggregate root. + /// An OperationGuard that accumulates validation errors found during traversal. + /// A reference-equality HashSet used to track visited entities and prevent recursive cycles during validation. + private static void ValidateLoadedChildren( + DbContext context, + NavigationEntry navigation, + object updatedEntity, + Type aggregateType, + OperationGuard guard, + HashSet recursionPath) + { + if (!TryGetUpdatedNavigationValue(updatedEntity, navigation.Metadata, out var updatedValue) || + updatedValue is null) + { + return; + } + + if (navigation is ReferenceEntry referenceEntry && + referenceEntry.CurrentValue is not null) + { + ValidateNavigations( + context, + context.Entry(referenceEntry.CurrentValue), + updatedValue, + aggregateType, + guard, + recursionPath); + return; + } + + if (navigation is not CollectionEntry collectionEntry || + updatedValue is not IEnumerable updatedCollection) + { + return; + } + + var updatedItems = updatedCollection.ToList(); + var existingItems = collectionEntry.CurrentValue?.Cast() ?? []; + + foreach (var existingItem in existingItems) + { + var existingKeys = EntityKeyHelper.GetKeyValues(context.Entry(existingItem)); + var match = EntityKeyHelper.FindByKey(context, updatedItems, existingKeys); + if (match is null) + continue; + + ValidateNavigations( + context, + context.Entry(existingItem), + match, + aggregateType, + guard, + recursionPath); + } + } + + /// + /// Attempts to read the value of the navigation property named by from . + /// + /// The detached entity to read the navigation value from. + /// Metadata describing the navigation property to read (its Name is used to locate the CLR property). + /// The value of the navigation property if found; otherwise null. + /// `true` if the navigation property exists on and its value was retrieved; `false` otherwise. + private static bool TryGetUpdatedNavigationValue( + object updatedEntity, + INavigationBase navMetadata, + out object? updatedValue) + { + var navProperty = updatedEntity.GetType().GetProperty(navMetadata.Name); + if (navProperty is null) + { + updatedValue = null; + return false; + } + + updatedValue = navProperty.GetValue(updatedEntity); + return true; + } + + /// + /// Determines whether any non-shadow scalar property values differ between the tracked entity entry and the detached updated entity. + /// + /// The tracked entity entry to compare against. + /// The detached entity containing proposed property values. + /// `true` if any scalar (non-shadow) property value differs between the tracked entry and the detached entity, `false` otherwise. + private static bool HasScalarDifferences(EntityEntry existingEntry, object updatedEntity) + { + foreach (var property in existingEntry.Metadata.GetProperties() + .Where(p => !p.IsShadowProperty())) + { + var existingValue = existingEntry.Property(property.Name).CurrentValue; + var updatedValue = EntityKeyHelper.ReadDetachedPropertyValue(property, updatedEntity); + if (!Equals(existingValue, updatedValue)) + return true; + } + + return false; + } + + /// + /// Determines whether the primary key values of a tracked entity and a detached entity match. + /// + /// The tracked entity instance whose key values are read from the context. + /// The detached entity instance whose key values are read for comparison. + /// `true` if the primary key values are equal, `false` otherwise. + private static bool ReferenceKeysMatch(DbContext context, object existingValue, object updatedValue) + { + var existingKeys = EntityKeyHelper.GetKeyValues(context.Entry(existingValue)); + var updatedKeys = EntityKeyHelper.GetKeyValues(context, updatedValue); + return EntityKeyHelper.KeysEqual(existingKeys, updatedKeys); + } + + /// + /// Determines whether the navigation's target entity type is the aggregate root type. + /// + /// The navigation metadata to inspect. + /// The aggregate root CLR type to compare against. + /// `true` if the navigation's target entity CLR type equals , `false` otherwise. + private static bool IsNavigationBackToAggregateRoot(INavigationBase navMetadata, Type aggregateType) + { + return navMetadata is INavigation nav && + nav.TargetEntityType.ClrType == aggregateType; + } + + /// + /// Get a human-readable relationship type name for the given navigation metadata. + /// + /// The navigation metadata to classify. + /// + /// One of: "SkipNavigation", "OneToMany", "OneToOne", "ManyToOne", or "Unknown" describing the relationship type. + /// + private static string GetRelationshipTypeName(INavigationBase navMetadata) + { + if (navMetadata is ISkipNavigation) return "SkipNavigation"; + if (navMetadata is INavigation nav) + { + if (nav.IsCollection) return "OneToMany"; + return nav.ForeignKey.IsUnique ? "OneToOne" : "ManyToOne"; + } + return "Unknown"; + } +} + +internal enum NavigationClassification +{ + PureManyToMany, + PayloadManyToMany, + RequiredOneToOne, + OptionalOneToOne, + Unsupported +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/OperationGuard.cs b/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/OperationGuard.cs new file mode 100644 index 0000000..b5b862c --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/OperationGuard.cs @@ -0,0 +1,65 @@ +using Diwink.Extensions.EntityFrameworkCore.Exceptions; +using System.Collections.ObjectModel; + +namespace Diwink.Extensions.EntityFrameworkCore.GraphUpdate; + +/// +/// Enforces all-or-nothing rejection semantics (FR-017). +/// Collects validation errors during graph analysis before any mutations +/// are applied. If any error is found, the entire operation is rejected. +/// +internal sealed class OperationGuard +{ + private readonly List _errors = []; + private readonly ReadOnlyCollection _readonlyErrors; + + public OperationGuard() + { + _readonlyErrors = _errors.AsReadOnly(); + } + + public bool HasErrors => _errors.Count > 0; + + public IReadOnlyList Errors => _readonlyErrors; + + /// + /// Records a validation error. No mutations should be applied until + /// is called and passes. + /// + /// Adds a graph validation error to the guard so it can be enforced later. + /// + /// The validation error to record; cannot be null. + /// Thrown when is null. + public void AddError(GraphUpdateException error) + { + ArgumentNullException.ThrowIfNull(error); + _errors.Add(error); + } + + /// + /// Throws the first recorded error if any exist, enforcing all-or-nothing + /// rejection before mutations are applied to the change tracker. + /// + /// Enforces collected validation errors by throwing an appropriate exception when any errors were recorded. + /// + /// + /// If no errors are recorded, the method returns without side effects. + /// + /// Thrown when exactly one validation error was recorded; the single recorded exception is rethrown. + /// Thrown when multiple validation errors were recorded; constructed with the first recorded error's RelationshipPath and a comma-separated list of all recorded RelationshipPath values for diagnostic context. + public void ThrowIfErrors() + { + if (_errors.Count == 0) + return; + + if (_errors.Count == 1) + throw _errors[0]; + + // When multiple errors exist, wrap in PartialMutationNotAllowed + // with all unsupported branches listed for diagnostic clarity + var allPaths = string.Join(", ", _errors.Select(e => e.RelationshipPath)); + throw new PartialMutationNotAllowedException( + _errors[0].RelationshipPath, + allPaths); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/RelatedEntityMutationService.cs b/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/RelatedEntityMutationService.cs new file mode 100644 index 0000000..3b1f2b9 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/GraphUpdate/RelatedEntityMutationService.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Diwink.Extensions.EntityFrameworkCore.GraphUpdate; + +/// +/// Handles creation and update of related entities through supported +/// many-to-many relationship paths (FR-004, FR-005). +/// +internal static class RelatedEntityMutationService +{ + /// + /// Updates scalar properties of a tracked entity from a detached source entity. + /// + /// Copy scalar property values from a detached updated entity onto a tracked entity entry. + /// + /// The tracked EntityEntry whose current scalar values will be updated. + /// The detached entity instance providing the new scalar values. + public static void UpdateScalarProperties(EntityEntry existingEntry, object updatedEntity) + { + existingEntry.CurrentValues.SetValues(updatedEntity); + } + + /// + /// Recursively processes navigations on a tracked entity, applying graph updates + /// for supported child navigations. + /// + /// Processes and applies navigation (relationship) updates from a detached entity onto a tracked entity within the given DbContext. + /// + /// The detached entity containing the desired navigation state to apply. + /// The root aggregate CLR type used to resolve relationship paths when processing navigations. + public static void ProcessNavigations( + DbContext context, + EntityEntry existingEntry, + object updatedEntity, + Type aggregateType) + { + GraphUpdateOrchestrator.ApplyNavigations(context, existingEntry, updatedEntity, aggregateType); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/OneToOneOwnershipResolver.cs b/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/OneToOneOwnershipResolver.cs new file mode 100644 index 0000000..d158eff --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/OneToOneOwnershipResolver.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Metadata; + +namespace Diwink.Extensions.EntityFrameworkCore.RelationshipStrategies; + +/// +/// Resolves one-to-one ownership semantics from EF Core metadata. +/// Determines whether a one-to-one relationship is required (delete on removal) +/// or optional (null/detach on removal) based on FK configuration. +/// +internal static class OneToOneOwnershipResolver +{ + /// + /// Determines the removal behavior for a one-to-one relationship. + /// + /// The FK metadata for the one-to-one relationship. + /// + /// true if the relationship is required (dependent should be deleted on removal); + /// false if optional (FK should be nulled on removal). + /// + /// Determines whether the dependent end of the specified one-to-one relationship is required. + /// + /// EF Core foreign-key metadata representing the one-to-one relationship. + /// `true` if the dependent is required (cannot be null); `false` if the dependent is optional (foreign key can be nullified). + public static bool IsRequiredDependent(IForeignKey foreignKey) + { + ArgumentNullException.ThrowIfNull(foreignKey); + return foreignKey.IsRequired; + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/OptionalOneToOneStrategy.cs b/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/OptionalOneToOneStrategy.cs new file mode 100644 index 0000000..8eb125e --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/OptionalOneToOneStrategy.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace Diwink.Extensions.EntityFrameworkCore.RelationshipStrategies; + +/// +/// Handles optional one-to-one dependent removal. +/// When the reference is set to null in the updated graph, the FK on the +/// dependent is nulled (detached) — the dependent entity is preserved (FR-009, FR-010, FR-011). +/// +internal static class OptionalOneToOneStrategy +{ + /// + /// Detaches the optional dependent by nulling its FK properties. + /// The dependent entity is preserved in the database with a null FK. + /// + /// Clears the given optional dependent navigation on the tracked principal so the dependent's foreign key is set to null while leaving the dependent entity intact. + /// + /// The DbContext whose change tracker manages the principal and dependent entities. + /// The navigation entry on the principal that references the optional dependent to detach. + public static void DetachDependent(DbContext context, ReferenceEntry existingNavigation) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(existingNavigation); + + var existingValue = existingNavigation.CurrentValue; + if (existingValue is null) + return; + + // Clear the navigation — EF Core will null the FK on the dependent + // because the FK has DeleteBehavior.SetNull configured + existingNavigation.CurrentValue = null; + } + + /// + /// Attaches a new optional dependent to the navigation and ensures EF Core + /// treats it as an insert when it is not already tracked. + /// + /// Ensures the provided dependent entity is tracked for insertion if it is detached, then assigns it to the specified navigation. + /// + /// The reference navigation on the principal entity to set to the dependent. + /// The dependent entity to attach (if needed) and assign to the navigation. + public static void AttachDependent(DbContext context, ReferenceEntry existingNavigation, object dependent) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(existingNavigation); + ArgumentNullException.ThrowIfNull(dependent); + + var dependentEntry = context.Entry(dependent); + if (dependentEntry.State == EntityState.Detached) + { + dependentEntry.State = EntityState.Added; + } + + existingNavigation.CurrentValue = dependent; + } + + /// + /// Replaces the current optional dependent by detaching the old row and + /// linking a new dependent instance. + /// + /// Replaces the optional dependent referenced by an existing navigation with a new dependent instance. + /// + /// The DbContext used to obtain and modify entity tracking state. + /// The reference navigation entry that currently points to the dependent to be replaced. + /// The new dependent instance to assign; if it is not tracked it will be marked for insertion. + public static void ReplaceDependent(DbContext context, ReferenceEntry existingNavigation, object dependent) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(existingNavigation); + ArgumentNullException.ThrowIfNull(dependent); + + DetachDependent(context, existingNavigation); + AttachDependent(context, existingNavigation, dependent); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/PayloadManyToManyStrategy.cs b/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/PayloadManyToManyStrategy.cs new file mode 100644 index 0000000..1351a6e --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/PayloadManyToManyStrategy.cs @@ -0,0 +1,121 @@ +using System.Collections; +using Diwink.Extensions.EntityFrameworkCore.GraphUpdate; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Diwink.Extensions.EntityFrameworkCore.RelationshipStrategies; + +/// +/// Handles many-to-many with payload (association entity) mutations. +/// Creates, updates, and removes association entities with payload. +/// Related entities are preserved on removal (FR-002, FR-005, FR-006). +/// +internal static class PayloadManyToManyStrategy +{ + /// + /// Synchronizes a payload-based many-to-many collection navigation so its association entities match the provided target collection. + /// + /// + /// Removes association entities that are not present in , updates payload values on matching tracked associations, and adds new association entities to the navigation when no tracked match exists. Deleting an association entity only removes the association row; related non-association entities are not deleted. + /// + /// The DbContext used to inspect entity keys and apply changes. + /// The collection navigation that currently holds the association entities (may be null/empty). + /// The desired collection of association entity instances to reconcile the navigation with. + /// Thrown if the navigation's current collection value is null or does not expose a public Add method when attempting to add a new association entity. + public static void Apply( + DbContext context, + CollectionEntry existingNavigation, + IEnumerable updatedCollection) + { + var existingItems = existingNavigation.CurrentValue?.Cast().ToList() ?? []; + var updatedItems = updatedCollection.ToList(); + + // Remove association entities not present in updated collection + foreach (var existingItem in existingItems) + { + var existingKeys = EntityKeyHelper.GetKeyValues(context.Entry(existingItem)); + var match = EntityKeyHelper.FindByKey(context, updatedItems, existingKeys); + if (match is null) + { + // Remove the association entity — EF Core will delete the row + // Related entities are NOT deleted (FR-003 for payload associations) + context.Remove(existingItem); + } + } + + // Add new or update existing association entities + foreach (var updatedItem in updatedItems) + { + var updatedKeys = EntityKeyHelper.GetKeyValues(context, updatedItem); + var existingMatch = FindInTracked(context, existingItems, updatedKeys); + + if (existingMatch is not null) + { + // Update payload fields on existing association entity + context.Entry(existingMatch).CurrentValues.SetValues(updatedItem); + } + else + { + // New association entity — add to collection + AddToCollection(existingNavigation, updatedItem); + } + } + } + + /// + /// Finds the first object in the trackedItems whose entity key values match the provided targetKeys. + /// + /// List of currently tracked entity instances to search for a key match. + /// Array of key values to match against each tracked item's entity key. + /// The first tracked item whose key values equal , or null if no match is found. + private static object? FindInTracked( + DbContext context, + List trackedItems, + object[] targetKeys) + { + foreach (var item in trackedItems) + { + var itemKeys = EntityKeyHelper.GetKeyValues(context.Entry(item)); + if (EntityKeyHelper.KeysEqual(itemKeys, targetKeys)) + return item; + } + return null; + } + + /// + /// Adds an association entity instance to the specified collection navigation. + /// + /// The collection navigation whose underlying collection will receive the item. + /// The association entity instance to add to the collection. + /// + /// Thrown if the navigation's current collection value is null or if the collection type does not expose a public Add method. + /// + private static void AddToCollection(CollectionEntry navigation, object item) + { + var currentValue = navigation.CurrentValue ?? throw new InvalidOperationException( + $"Collection navigation '{navigation.Metadata.DeclaringEntityType.ClrType.Name}.{navigation.Metadata.Name}' is null; cannot add item type '{item.GetType().FullName}'."); + + if (currentValue is IList list) + { + list.Add(item); + return; + } + + var collectionInterface = currentValue.GetType().GetInterfaces() + .FirstOrDefault(i => + i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(ICollection<>) && + i.GenericTypeArguments[0].IsAssignableFrom(item.GetType())); + + if (collectionInterface is null) + { + throw new InvalidOperationException( + $"Collection type '{currentValue.GetType().FullName}' for navigation '{navigation.Metadata.DeclaringEntityType.ClrType.Name}.{navigation.Metadata.Name}' does not expose a public Add method for item type '{item.GetType().FullName}'."); + } + + var addMethod = collectionInterface.GetMethod(nameof(ICollection.Add)) ?? throw new InvalidOperationException( + $"Collection type '{currentValue.GetType().FullName}' for navigation '{navigation.Metadata.DeclaringEntityType.ClrType.Name}.{navigation.Metadata.Name}' does not expose a public Add method for item type '{item.GetType().FullName}'."); + + addMethod.Invoke(currentValue, [item]); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/PureManyToManyStrategy.cs b/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/PureManyToManyStrategy.cs new file mode 100644 index 0000000..6f01e91 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/PureManyToManyStrategy.cs @@ -0,0 +1,186 @@ +using System.Collections; +using Diwink.Extensions.EntityFrameworkCore.GraphUpdate; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Diwink.Extensions.EntityFrameworkCore.RelationshipStrategies; + +/// +/// Handles pure many-to-many (skip navigation) mutations. +/// Adds missing links, removes excess links, creates new related entities, +/// and updates existing related entities (FR-002, FR-003, FR-004). +/// +internal static class PureManyToManyStrategy +{ + /// + /// Reconciles a skip-navigation (pure many-to-many) collection with the provided updated set of related entities. + /// + /// + /// Removes links for entities that are no longer present in , updates properties of already-tracked related entities, and adds links for new or discovered entities (marking new entities as added so EF will insert them). + /// The DbContext used to access entity metadata, track entities, and query the store. + /// The existing collection navigation entry (skip navigation) to modify. + /// The desired related entities to be reflected in the navigation; each element represents a related entity instance or a DTO with matching key values. + public static void Apply( + DbContext context, + CollectionEntry existingNavigation, + IEnumerable updatedCollection) + { + var existingItems = existingNavigation.CurrentValue?.Cast().ToList() ?? []; + var updatedItems = updatedCollection.ToList(); + + // Remove links not present in the updated collection + foreach (var existingItem in existingItems) + { + var existingKeys = EntityKeyHelper.GetKeyValues(context.Entry(existingItem)); + var match = EntityKeyHelper.FindByKey(context, updatedItems, existingKeys); + if (match is null) + { + // Unlink only — remove from the collection navigation + // EF Core will handle removing the join table row + RemoveFromCollection(existingNavigation, existingItem); + } + } + + // Add new links or update existing related entities + foreach (var updatedItem in updatedItems) + { + var updatedKeys = EntityKeyHelper.GetKeyValues(context, updatedItem); + var existingMatch = FindInTracked(context, existingItems, updatedKeys); + + if (existingMatch is not null) + { + // Update existing related entity properties + ApplyValuesIfNotModified(context, existingMatch, updatedItem); + } + else + { + // Entity not in this collection — resolve via tracker or store + var entityType = context.Model.FindEntityType(updatedItem.GetType()); + var pk = entityType?.FindPrimaryKey(); + if (pk is not null) + { + // Find checks tracker first, then queries store + var knownEntity = context.Find(entityType!.ClrType, updatedKeys); + + if (knownEntity is not null) + { + // Entity exists — update properties and create link + ApplyValuesIfNotModified(context, knownEntity, updatedItem); + AddToCollection(existingNavigation, knownEntity); + } + else + { + // New entity — explicitly track as Added so EF inserts it + context.Add(updatedItem); + AddToCollection(existingNavigation, updatedItem); + } + } + else + { + AddToCollection(existingNavigation, updatedItem); + } + } + } + } + + private static void ApplyValuesIfNotModified(DbContext context, object trackedEntity, object updatedEntity) + { + var trackedEntry = context.Entry(trackedEntity); + if (trackedEntry.State is EntityState.Unchanged or EntityState.Detached) + { + trackedEntry.CurrentValues.SetValues(updatedEntity); + } + } + + /// + /// Finds the first entity in a list of tracked items whose primary key values match the provided key values. + /// + /// The DbContext used to extract key values for each tracked item. + /// A list of currently tracked entity instances to search. + /// An array of key values to match against each tracked item's primary key values. + /// The matching tracked entity instance if found; otherwise null. + private static object? FindInTracked( + DbContext context, + List trackedItems, + object[] targetKeys) + { + foreach (var item in trackedItems) + { + var itemKeys = EntityKeyHelper.GetKeyValues(context.Entry(item)); + if (EntityKeyHelper.KeysEqual(itemKeys, targetKeys)) + return item; + } + return null; + } + + /// + /// Remove an item from a collection-valued navigation property. + /// + /// The collection navigation entry whose current value will be modified. + /// The related entity instance to remove from the navigation collection. + private static void RemoveFromCollection(CollectionEntry navigation, object item) + { + ExecuteCollectionOperation(navigation, item, "remove", static (list, value) => + { + list.Remove(value); + }); + } + + /// + /// Add an entity instance to the given navigation collection (handles IList or compatible ICollection<T> implementations). + /// + /// The EF Core collection navigation entry to modify. + /// The entity instance to add to the navigation collection. + /// Thrown when the navigation's CurrentValue is null or does not support adding the item's type. + private static void AddToCollection(CollectionEntry navigation, object item) + { + ExecuteCollectionOperation(navigation, item, "add", static (list, value) => + { + list.Add(value); + }); + } + + /// + /// Performs an add/remove operation against the runtime collection held by a navigation's CurrentValue. + /// + /// The collection navigation whose CurrentValue will be mutated. + /// The item to add or remove from the collection. + /// A short operation name used in error messages (expected values: "add" or "remove"). + /// A fallback action that performs the operation when the collection implements . + /// + /// Thrown when the navigation's CurrentValue is null; when the CurrentValue's runtime type does not expose a compatible generic ICollection<T> for the item's type; or when the discovered collection interface does not expose the expected Add/Remove method. + /// + private static void ExecuteCollectionOperation( + CollectionEntry navigation, + object item, + string operation, + Action listOperation) + { + var currentValue = navigation.CurrentValue ?? throw new InvalidOperationException( + $"Collection navigation '{navigation.Metadata.DeclaringEntityType.ClrType.Name}.{navigation.Metadata.Name}' has null CurrentValue; cannot {operation} item '{item}'."); + + if (currentValue is IList list) + { + listOperation(list, item); + return; + } + + var collectionInterface = currentValue.GetType().GetInterfaces() + .FirstOrDefault(i => + i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(ICollection<>) && + i.GenericTypeArguments[0].IsAssignableFrom(item.GetType())); + + if (collectionInterface is null) + { + throw new InvalidOperationException( + $"Collection navigation '{navigation.Metadata.DeclaringEntityType.ClrType.Name}.{navigation.Metadata.Name}' with current value type '{currentValue.GetType().FullName}' does not support {operation} for item type '{item.GetType().FullName}'."); + } + + var methodName = operation == "add" ? nameof(ICollection.Add) : nameof(ICollection.Remove); + var method = collectionInterface.GetMethod(methodName) ?? throw new InvalidOperationException( + $"Collection interface '{collectionInterface.FullName}' for navigation '{navigation.Metadata.DeclaringEntityType.ClrType.Name}.{navigation.Metadata.Name}' does not expose '{methodName}'."); + + method.Invoke(currentValue, [item]); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/RequiredOneToOneStrategy.cs b/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/RequiredOneToOneStrategy.cs new file mode 100644 index 0000000..f1418d8 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/RelationshipStrategies/RequiredOneToOneStrategy.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Diwink.Extensions.EntityFrameworkCore.RelationshipStrategies; + +/// +/// Handles required one-to-one dependent removal. +/// When the reference is set to null in the updated graph, the existing +/// dependent entity is deleted (FR-007, FR-008). +/// +internal static class RequiredOneToOneStrategy +{ + /// + /// Removes the required dependent by marking it for deletion. + /// EF Core will delete the row on SaveChanges. + /// + /// Marks the dependent entity referenced by for deletion if it is currently tracked. + /// + /// The used to mark the dependent entity for removal. + /// The tracked reference entry whose CurrentValue is the dependent entity to remove; no action is taken if its value is null. + public static void RemoveDependent(DbContext context, ReferenceEntry existingNavigation) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(existingNavigation); + + var existingValue = existingNavigation.CurrentValue; + if (existingValue is null) + return; + + context.Remove(existingValue); + } +} diff --git a/src/Diwink.Extensions.EntityFrameworkCore/Traversal/NavigationLoadGuard.cs b/src/Diwink.Extensions.EntityFrameworkCore/Traversal/NavigationLoadGuard.cs new file mode 100644 index 0000000..1a04fa8 --- /dev/null +++ b/src/Diwink.Extensions.EntityFrameworkCore/Traversal/NavigationLoadGuard.cs @@ -0,0 +1,81 @@ +using Diwink.Extensions.EntityFrameworkCore.Exceptions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Diwink.Extensions.EntityFrameworkCore.Traversal; + +/// +/// Validates that all navigations required for a graph mutation are explicitly loaded. +/// Rejects with a clear exception when a mutation depends on a missing or partially +/// loaded navigation (FR-015, FR-016). +/// +internal static class NavigationLoadGuard +{ + /// + /// Ensures all navigations in the existing entity entry that the caller expects + /// to participate in graph mutation are explicitly loaded. + /// + /// Validates loaded navigations on the tracked entity and its reachable object graph, ensuring required navigations are present before mutation. + /// + /// The tracked entity entry whose navigations and reachable entities will be validated. + /// Thrown when a required navigation encountered during validation is not loaded. + public static void EnsureNavigationsLoaded(EntityEntry existingEntry) + { + EnsureNavigationsLoaded( + existingEntry, + new HashSet(ReferenceEqualityComparer.Instance)); + } + + /// + /// Traverse the tracked entity graph from the provided entry, following only navigations that are already loaded and recursing into their tracked child entries. + /// + /// The tracked that serves as the root of the traversal. + /// A reference-equality used to record entities already visited and prevent infinite recursion through cycles. + private static void EnsureNavigationsLoaded( + EntityEntry existingEntry, + HashSet visited) + { + if (!visited.Add(existingEntry.Entity)) + return; + + foreach (var navigation in existingEntry.Navigations) + { + if (!navigation.IsLoaded) + continue; + + // For collection navigations, recurse into loaded children + if (navigation is CollectionEntry collectionEntry && collectionEntry.CurrentValue is not null) + { + foreach (var child in collectionEntry.CurrentValue.Cast()) + { + var childEntry = existingEntry.Context.Entry(child); + EnsureNavigationsLoaded(childEntry, visited); + } + } + else if (navigation is ReferenceEntry referenceEntry && referenceEntry.CurrentValue is not null) + { + var childEntry = existingEntry.Context.Entry(referenceEntry.CurrentValue); + EnsureNavigationsLoaded(childEntry, visited); + } + } + } + + /// + /// Validates that a specific navigation is loaded before attempting mutation on it. + /// Throws if not. + /// + /// Ensures the specified navigation is loaded before performing a mutation. + /// + /// The navigation entry to validate is loaded. + /// A slash-delimited path that identifies the entity location used in the exception message. + /// Thrown when is not loaded. + public static void RequireLoaded(NavigationEntry navigation, string entityPath) + { + if (!navigation.IsLoaded) + { + throw new UnloadedNavigationMutationException( + entityPath, + navigation.Metadata.Name); + } + } +} diff --git a/src/EFCore.UpdateGraph.slnx b/src/EFCore.UpdateGraph.slnx new file mode 100644 index 0000000..3a279bd --- /dev/null +++ b/src/EFCore.UpdateGraph.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/FakeModel/FakeModel.csproj b/src/FakeModel/FakeModel.csproj deleted file mode 100644 index 9f5c4f4..0000000 --- a/src/FakeModel/FakeModel.csproj +++ /dev/null @@ -1,7 +0,0 @@ - - - - netstandard2.0 - - - diff --git a/src/FakeModel/Model.cs b/src/FakeModel/Model.cs deleted file mode 100644 index 8ba8b27..0000000 --- a/src/FakeModel/Model.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using System.Collections.Generic; - - -namespace FakeModel -{ - public class School - { - public int Id { get; set; } - public string Name { get; set; } - public SchoolType Type { get; set; } - public SchoolHouse House { get; set; } - public ICollection Address { get; set; } = new HashSet(); - public ICollection Classes { get; set; } = new HashSet(); - } - - - - public class SchoolHouse - { - public int SchoolId { get; set; } - public int Capacity { get; set; } - public School School { get; set; } - } - - - - public class Class - { - public int Id { get; set; } - public int SchoolId { get; set; } - public int Level { get; set; } - public int Capacity { get; set; } - public School School { get; set; } - public ClassLaboratory Laboratory { get; set; } - public ICollection ClassTeachers { get; set; } = new HashSet(); - public ICollection Students { get; set; } = new HashSet(); - } - - - - public class ClassLaboratory - { - public int ClassId { get; set; } - public string Name { get; set; } - public Class Class { get; set; } - } - - - - public class Student - { - public Guid Id { get; set; } - public int? DegreeId { get; set; } - public int ClassId { get; set; } - public string Name { get; set; } - public DateTimeOffset DateOfBirth { get; set; } - public Class Class { get; set; } - public Degree Degree { get; set; } - } - - - - public class Degree - { - public int Id { get; set; } - public string Name { get; set; } - public ICollection Students { get; set; } = new HashSet(); - } - - - - public class Teacher - { - public Guid Id { get; set; } - public string Name { get; set; } - public DateTimeOffset? DateOfBirth { get; set; } - public ICollection ClassTeachers { get; set; } = new HashSet(); - } - - - - public class ClassTeacher - { - public int ClassId { get; set; } - public Guid TeacherId { get; set; } - public Class Class { get; set; } - public Teacher Teacher { get; set; } - } - - - - public enum SchoolType - { - Elementary = 1, - Secondary = 2, - HighSchool = 3 - } -} diff --git a/src/UnitTests/FakeSchoolsDbContext.cs b/src/UnitTests/FakeSchoolsDbContext.cs deleted file mode 100644 index 0eaa30b..0000000 --- a/src/UnitTests/FakeSchoolsDbContext.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using FakeModel; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using System.Linq; - - -namespace UnitTests -{ - public class FakeSchoolsDbContext : DbContext - { - public FakeSchoolsDbContext(DbContextOptions options) : base(options) - { - - } - - - public DbSet Schools { get; set; } - public DbSet SchoolHouses { get; set; } - public DbSet Classes { get; set; } - public DbSet Degrees { get; set; } - public DbSet ClassLaboratories { get; set; } - public DbSet Teachers { get; set; } - public DbSet Students { get; set; } - - - protected override void OnModelCreating(ModelBuilder builder) - { - base.OnModelCreating(builder); - - builder.Entity(configureSchool); - builder.Entity(configureSchoolHouses); - builder.Entity(configureClass); - builder.Entity(configureClassLaboratory); - builder.Entity(configureTeacher); - builder.Entity(configureClassTeacher); - builder.Entity(configureStudent); - builder.Entity(configureDegree); - } - - - private void configureSchool(EntityTypeBuilder builder) - { - builder.Property(x => x.Address).HasConversion( - v => string.Join(";", v.Select(r => r)), - y => y.Split(';', StringSplitOptions.RemoveEmptyEntries)) - .HasMaxLength(200); - - builder.HasOne(x => x.House) - .WithOne(x => x.School) - .HasForeignKey(x => x.SchoolId) - .OnDelete(DeleteBehavior.Cascade); - } - - - private void configureSchoolHouses(EntityTypeBuilder builder) - { - builder.HasKey(x => x.SchoolId); - } - - - private void configureClass(EntityTypeBuilder builder) - { - builder.HasOne(x => x.Laboratory) - .WithOne(x => x.Class) - .HasForeignKey(x => x.ClassId) - .OnDelete(DeleteBehavior.Cascade); - } - - - private void configureClassLaboratory(EntityTypeBuilder builder) - { - builder.HasKey(x => x.ClassId); - } - - - private void configureTeacher(EntityTypeBuilder builder) - { - } - - - private void configureClassTeacher(EntityTypeBuilder builder) - { - builder.HasKey(x => new {x.ClassId, x.TeacherId}); - } - - - private void configureStudent(EntityTypeBuilder builder) - { - } - - - private void configureDegree(EntityTypeBuilder builder) - { - builder.HasMany(x => x.Students) - .WithOne(x => x.Degree) - .HasForeignKey(x => x.DegreeId) - .IsRequired(false); - } - } -} \ No newline at end of file diff --git a/src/UnitTests/FakeSchoolsDbContextFactory.cs b/src/UnitTests/FakeSchoolsDbContextFactory.cs deleted file mode 100644 index 412e9fa..0000000 --- a/src/UnitTests/FakeSchoolsDbContextFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; -using Microsoft.Extensions.Configuration; - - -namespace UnitTests -{ - public class FakeSchoolsDbContextFactory : IDesignTimeDbContextFactory - { - public FakeSchoolsDbContext CreateDbContext(string[] args) - { - var configuration = TestHelpers.InitConfiguration(); - - var optionsBuilder = new DbContextOptionsBuilder() - .EnableDetailedErrors() - .EnableSensitiveDataLogging() - .UseSqlServer(configuration.GetConnectionString("FakeSchoolsDb")); - - return new FakeSchoolsDbContext(optionsBuilder.Options); - } - } -} \ No newline at end of file diff --git a/src/UnitTests/GraphUpdateTests.cs b/src/UnitTests/GraphUpdateTests.cs deleted file mode 100644 index 30b87da..0000000 --- a/src/UnitTests/GraphUpdateTests.cs +++ /dev/null @@ -1,1415 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Diwink.Extensions.EntityFrameworkCore; -using FakeModel; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using NUnit.Framework; - - -namespace UnitTests -{ - [TestFixture] - public class GraphUpdateTests - { - private FakeSchoolsDbContext dbContext; - private readonly ServiceProvider serviceProvider; - private IServiceScope scope; - private bool inMemoryDb; - private School school; - private List teachers; - - - public GraphUpdateTests() - { - var configuration = TestHelpers.InitConfiguration(); - var services = new ServiceCollection(); - - services.AddDbContext(options => - { - options.EnableDetailedErrors().EnableSensitiveDataLogging(); - inMemoryDb = bool.Parse(configuration["InMemoryDB"]); - if (inMemoryDb) - options.UseInMemoryDatabase("FakeSchoolsDb"); - else - options.UseSqlServer(configuration.GetConnectionString("FakeSchoolsDb")); - } - ); - - serviceProvider = services.BuildServiceProvider(); - } - - /// - /// Setup operation will create a new DbContext for each test method - /// - [SetUp] - public void Setup() - { - teachers = new List() - { - new Teacher - { - Name = "Ahmad", - Id = Guid.Parse("{EC13122E-3EC5-4698-B254-E660D01F37CA}"), - }, - new Teacher - { - Name = "Mohammad", - Id = Guid.Parse("{7AB15219-5FFA-406C-B092-94636B413E05}"), - DateOfBirth = new DateTimeOffset(1980, 9, 1, 03, 0, 0, 0, TimeSpan.Zero), - } - }; - var students = new List - { - new Student - { - Name = "Saaid", - Id = Guid.Parse("{EF592B57-5691-415A-974E-D281B368545F}"), - DateOfBirth = DateTimeOffset.Now.AddYears(-6).Date - }, - new Student - { - Name = "Samir", - Id = Guid.Parse("{836FE019-6CEC-4F54-A39F-74448D6D86DC}"), - DateOfBirth = DateTimeOffset.Now.AddYears(-6).Date - }, - }; - var classes = new List - { - new Class - { - Capacity = 20, - Level = 1, - Students = students, - }, - new Class - { - Capacity = 10, - Level = 2, - Students = new List - { - new Student - { - Name = "Azoz", - Id = Guid.Parse("{35CE5E1C-AB25-448F-8CD9-E31DD3821DAD}"), - DateOfBirth = DateTimeOffset.Now.AddYears(-7).Date - }, - new Student - { - Name = "Bakri", - Id = Guid.Parse("{587FF49B-0306-448E-80FD-071C46F0B488}"), - DateOfBirth = DateTimeOffset.Now.AddYears(-7).Date - }, - } - }, - }; - - school = new School() - { - Name = "The First", - Address = new List {"Akdeniz", "Mersin"}, - Type = SchoolType.Elementary, - Classes = classes, - }; - - scope = serviceProvider.CreateScope(); - dbContext = scope.ServiceProvider.GetService(); - } - - - /// - /// Disposing the scope and DbContext - /// - [TearDown] - public void TearDown() - { - scope.Dispose(); - } - - - /// - /// Clean up everything - /// - [Test] - public void S000_Delete_Database() - { - dbContext.Database.EnsureDeleted(); - } - - - /// - /// Apply DB migrations if the test happens on real database - /// - [Test] - public void S001_Apply_Migrations() - { - if (!inMemoryDb) - dbContext.Database.Migrate(); - } - - - /// - /// Test insert new simple entities - /// - [Test] - public void S002_Seed_Teachers() - { - foreach (var teacher in teachers) - { - dbContext.InsertUpdateOrDeleteGraph(teacher, null); - } - - dbContext.SaveChanges(); - - Assert.AreEqual(teachers.Count, dbContext.Teachers.Count()); - } - - - /// - /// Test insert more complex Entity - /// - [Test] - public void S003_Add_The_School() - { - var dbSchool = dbContext.Schools.FirstOrDefault(); - dbContext.InsertUpdateOrDeleteGraph(school, dbSchool); - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(x => x.Students) - .FirstOrDefault(); - - Assert.IsNotNull(updatedDbSchool); - Assert.AreEqual(2, updatedDbSchool.Classes.First(c => c.Id == 1).Students.Count); - Assert.AreEqual(2, updatedDbSchool.Classes.First(c => c.Id == 2).Students.Count); - } - - - /// - /// Update an Aggregate by inserting one-to-one navigation property - /// - [Test] - public void S004_Add_House_To_The_School_Should_Update_House_Navigation_Property() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'House':{ - 'SchoolId': 1, - 'Capacity': 100 - } - }"); - - var dbSchool = dbContext.Schools - .Include(s => s.House) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.House) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.NotNull(updatedDbSchool.House); - } - - - /// - /// Update one-to-one navigation property in an Aggregate - /// - [Test] - public void S005_Update_The_House_Of_The_School() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'House':{ - 'SchoolId': 1, - 'Capacity': 150 - } - }"); - - var dbSchool = dbContext.Schools - .Include(s => s.House) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.House) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.NotNull(updatedDbSchool.House); - Assert.AreEqual(150, updatedDbSchool.House.Capacity); - } - - - /// - /// Update an Aggregate by deleting one-to-one navigation property - /// - [Test] - public void S006_Remove_The_House_From_The_School_Should_Make_The_House_Navigation_Null() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ] - }"); - - var dbSchool = dbContext.Schools - .Include(s => s.House) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.House) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.Null(updatedDbSchool.House); - } - - - /// - /// Update an inner Entity in the Aggregate should not affect the other sub Entities as they're not included in the changes - /// - [Test] - public void S007_Update_Classes_Info_Should_Not_Change_Teachers() - { - var updatedSchool = JsonConvert.DeserializeObject(@"{ - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'Classes': [ - { - 'Id': 1, - 'SchoolId': 1, - 'Level': 3, - 'Capacity': 20, - 'ClassTeachers': [ - { - 'ClassId': 1, - 'TeacherId': 'ec13122e-3ec5-4698-b254-e660d01f37ca' - }, - { - 'ClassId': 1, - 'TeacherId': '7ab15219-5ffa-406c-b092-94636b413e05' - } - ], - 'Students': [ - { - 'Id': 'ef592b57-5691-415a-974e-d281b368545f', - 'ClassId': 1, - 'Name': 'Saaid', - 'DateOfBirth': '2014-09-10T00:00:00+03:00' - }, - { - 'Id': '836fe019-6cec-4f54-a39f-74448d6d86dc', - 'ClassId': 1, - 'Name': 'Samir', - 'DateOfBirth': '2014-09-10T00:00:00+03:00' - } - ] - }, - { - 'Id': 2, - 'SchoolId': 1, - 'Level': 4, - 'Capacity': 10, - 'ClassTeachers': [ - ], - 'Students': [ - { - 'Id': '35ce5e1c-ab25-448f-8cd9-e31dd3821dad', - 'ClassId': 2, - 'Name': 'Azoz', - 'DateOfBirth': '2013-09-10T00:00:00+03:00', - 'Class': null - }, - { - 'Id': '587ff49b-0306-448e-80fd-071c46f0b488', - 'ClassId': 2, - 'Name': 'Bakri', - 'DateOfBirth': '2013-09-10T00:00:00+03:00' - } - ] - } - ] - }"); - - var dbSchool = dbContext.Schools - .Include(s => s.Classes) - .FirstOrDefault(); - - Assert.True(dbSchool.Classes.All(c => c.Level <= 2), "Classes Levels before update should be less than 3"); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(x => x.ClassTeachers) - .ThenInclude(x => x.Teacher) - .Include(s => s.Classes) - .ThenInclude(x => x.Students) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.True(updatedDbSchool.Classes.All(c => c.Level > 2), "One or more Classes hasn't been updated"); - Assert.True(updatedDbSchool.Classes.All(c => !c.ClassTeachers.Any()), "The teachers has been updated for one or more Classes"); - } - - - /// - /// Update Many-to-Many relation in an Aggregate by adding relation to two existing entities - /// - [Test] - public void S008_Update_Classes_Info_And_Add_Teachers_To_One_Of_Them() - { - var updatedSchool = JsonConvert.DeserializeObject(@"{ - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'Classes': [ - { - 'Id': 1, - 'SchoolId': 1, - 'Level': 1, - 'Capacity': 20, - 'ClassTeachers': [ - { - 'ClassId': 1, - 'TeacherId': 'ec13122e-3ec5-4698-b254-e660d01f37ca' - }, - { - 'ClassId': 1, - 'TeacherId': '7ab15219-5ffa-406c-b092-94636b413e05' - } - ], - 'Students': [ - { - 'Id': 'ef592b57-5691-415a-974e-d281b368545f', - 'ClassId': 1, - 'Name': 'Saaid', - 'DateOfBirth': '2014-09-10T00:00:00+03:00' - }, - { - 'Id': '836fe019-6cec-4f54-a39f-74448d6d86dc', - 'ClassId': 1, - 'Name': 'Samir', - 'DateOfBirth': '2014-09-10T00:00:00+03:00' - } - ] - }, - { - 'Id': 2, - 'SchoolId': 1, - 'Level': 2, - 'Capacity': 10, - 'ClassTeachers': [ - ], - 'Students': [ - { - 'Id': '35ce5e1c-ab25-448f-8cd9-e31dd3821dad', - 'ClassId': 2, - 'Name': 'Azoz', - 'DateOfBirth': '2013-09-10T00:00:00+03:00', - 'Class': null - }, - { - 'Id': '587ff49b-0306-448e-80fd-071c46f0b488', - 'ClassId': 2, - 'Name': 'Bakri', - 'DateOfBirth': '2013-09-10T00:00:00+03:00' - } - ] - } - ] - }"); - - var dbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.ClassTeachers) - .FirstOrDefault(); - - Assert.True(dbSchool.Classes.All(c => c.Level > 2), "Classes Levels before update should be more than 2"); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(x => x.ClassTeachers) - .ThenInclude(x => x.Teacher) - .Include(s => s.Classes) - .ThenInclude(x => x.Students) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.True(updatedDbSchool.Classes.All(c => c.Level <= 2), "One or more Classes hasn't been updated"); - Assert.AreEqual(2, updatedDbSchool.Classes.SelectMany(c => c.ClassTeachers).Count()); - } - - - /// - /// Update Many-to-Many relation in an Aggregate by removing the relation from one existing entity - /// - [Test] - public void S009_Update_First_Class_Teachers() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'Classes': [ - { - 'Id': 1, - 'SchoolId': 1, - 'Level': 1, - 'Capacity': 20, - 'ClassTeachers': [ - { - 'ClassId': 1, - 'TeacherId': 'ec13122e-3ec5-4698-b254-e660d01f37ca' - } - ], - 'Students': [ - { - 'Id': '836fe019-6cec-4f54-a39f-74448d6d86dc', - 'ClassId': 1, - 'Name': 'Samir', - 'DateOfBirth': '2014-09-10T00:00:00+03:00' - }, - { - 'Id': 'ef592b57-5691-415a-974e-d281b368545f', - 'ClassId': 1, - 'Name': 'Saaid', - 'DateOfBirth': '2014-09-10T00:00:00+03:00' - } - ] - }, - { - 'Id': 2, - 'SchoolId': 1, - 'Level': 2, - 'Capacity': 10, - 'ClassTeachers': [], - 'Students': [ - { - 'Id': '587ff49b-0306-448e-80fd-071c46f0b488', - 'ClassId': 2, - 'Name': 'Bakri', - 'DateOfBirth': '2013-09-10T00:00:00+03:00' - }, - { - 'Id': '35ce5e1c-ab25-448f-8cd9-e31dd3821dad', - 'ClassId': 2, - 'Name': 'Azoz', - 'DateOfBirth': '2013-09-10T00:00:00+03:00' - } - ] - } - ] - } -"); - - var dbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.ClassTeachers) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(x => x.ClassTeachers) - .ThenInclude(x => x.Teacher) - .Include(s => s.Classes) - .ThenInclude(x => x.Students) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.AreEqual(1, updatedDbSchool.Classes.SelectMany(c => c.ClassTeachers).Count()); - } - - - /// - /// Update One-to-Many relation in an Aggregate - /// - [Test] - public void S010_Update_Classes_Students() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'Classes': [ - { - 'Id': 1, - 'SchoolId': 1, - 'Level': 1, - 'Capacity': 20, - 'Students': [ - { - 'Id': '836fe019-6cec-4f54-a39f-74448d6d86dc', - 'ClassId': 1, - 'Name': 'Samir Matin', - 'DateOfBirth': '2014-09-10T00:00:00+03:00' - } - ] - }, - { - 'Id': 2, - 'SchoolId': 1, - 'Level': 2, - 'Capacity': 10, - 'Students': [ - { - 'Id': '587ff49b-0306-448e-80fd-071c46f0b488', - 'ClassId': 2, - 'Name': 'Bakri', - 'DateOfBirth': '2013-09-10T00:00:00+03:00' - }, - { - 'Id': 'ef592b57-5691-415a-974e-d281b368545f', - 'ClassId': 2, - 'Name': 'Saaid', - 'DateOfBirth': '2013-09-10T00:00:00+03:00' - } - ] - } - ] - } -"); - - var dbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.Students) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(x => x.ClassTeachers) - .ThenInclude(x => x.Teacher) - .Include(s => s.Classes) - .ThenInclude(x => x.Students) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.AreEqual(1, updatedDbSchool.Classes.First(c => c.Id == 1).Students.Count); - Assert.AreEqual(2, updatedDbSchool.Classes.First(c => c.Id == 2).Students.Count); - } - - - /// - /// Insert new simple Aggregate - /// - [Test] - public void S011_Add_Degrees() - { - var highschool = new Degree() - { - Name = "High-School" - }; - - dbContext.InsertUpdateOrDeleteGraph(highschool, null); - - dbContext.SaveChanges(); - - var updatedDbDegree = dbContext.Degrees - .Include(s => s.Students) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbDegree, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.IsNotNull(updatedDbDegree); - } - - - /// - /// Update Aggregate with one-to-many relation to an Entity in another Aggregate - /// - [Test] - public void S012_Add_Degrees_To_Students_Should_Update_The_Degree_Property_In_Students() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'Classes': [ - { - 'Id': 1, - 'SchoolId': 1, - 'Level': 1, - 'Capacity': 20, - 'Students': [ - { - 'Id': '836fe019-6cec-4f54-a39f-74448d6d86dc', - 'ClassId': 1, - 'DegreeId': 1, - 'Name': 'Samir Matin', - 'DateOfBirth': '2014-09-10T00:00:00+03:00' - } - ] - }, - { - 'Id': 2, - 'SchoolId': 1, - 'Level': 2, - 'Capacity': 10, - 'Students': [ - { - 'Id': '587ff49b-0306-448e-80fd-071c46f0b488', - 'ClassId': 2, - 'DegreeId': 1, - 'Name': 'Bakri', - 'DateOfBirth': '2013-09-10T00:00:00+03:00' - }, - { - 'Id': 'ef592b57-5691-415a-974e-d281b368545f', - 'ClassId': 2, - 'DegreeId': 1, - 'Name': 'Saaid', - 'DateOfBirth': '2013-09-10T00:00:00+03:00' - } - ] - } - ] - } -"); - - var dbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.Students) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(x => x.Students) - .ThenInclude(s => s.Degree) - .FirstOrDefault(); - - var updatedDbDegree = dbContext.Degrees - .Include(s => s.Students) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - Console.WriteLine("---------------------------------------"); - Console.WriteLine(JsonConvert.SerializeObject(updatedDbDegree, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.AreEqual(1, updatedDbSchool.Classes.First(c => c.Id == 1).Students.Count); - Assert.AreEqual(2, updatedDbSchool.Classes.First(c => c.Id == 2).Students.Count); - Assert.AreEqual(3, updatedDbDegree.Students.Count); - Assert.NotNull(updatedDbSchool.Classes.First(c => c.Id == 1).Students.First(s => s.Id == Guid.Parse("836fe019-6cec-4f54-a39f-74448d6d86dc")).Degree); - Assert.NotNull(updatedDbSchool.Classes.First(c => c.Id == 2).Students.First(s => s.Id == Guid.Parse("587ff49b-0306-448e-80fd-071c46f0b488")).Degree); - Assert.NotNull(updatedDbSchool.Classes.First(c => c.Id == 2).Students.First(s => s.Id == Guid.Parse("ef592b57-5691-415a-974e-d281b368545f")).Degree); - } - - - /// - /// Update Entity with Optional one-to-many relation to an Entity in another Aggregate - /// - [Test] - public void S013_Remove_Student_From_Degree_Should_NOT_Delete_The_Student_Entity() - { - var updatedDegree = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'High-School', - 'Students': [ - { - 'Id': '836fe019-6cec-4f54-a39f-74448d6d86dc', - 'DegreeId': 1, - 'ClassId': 1, - 'Name': 'Samir Matin', - 'DateOfBirth': '2014-09-10T00:00:00+03:00' - }, - { - 'Id': '587ff49b-0306-448e-80fd-071c46f0b488', - 'DegreeId': 1, - 'ClassId': 2, - 'Name': 'Bakri', - 'DateOfBirth': '2013-09-10T00:00:00+03:00' - } - ] - }"); - - var dbDegree = dbContext.Degrees - .Include(x => x.Students) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedDegree, dbDegree); - - dbContext.SaveChanges(); - - var updatedDbDegree = dbContext.Degrees - .Include(s => s.Students) - .FirstOrDefault(); - - var updatedDbStudent = dbContext.Students - .Include(s => s.Degree) - .FirstOrDefault(x => x.Id == Guid.Parse("ef592b57-5691-415a-974e-d281b368545f")); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbDegree, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - Console.WriteLine("------------------------------------"); - Console.WriteLine(JsonConvert.SerializeObject(updatedDbStudent, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.AreEqual(2, updatedDegree.Students.Count); - Assert.IsNotNull(updatedDbStudent); - Assert.IsNull(updatedDbStudent.Degree); - Assert.IsNull(updatedDbStudent.DegreeId); - } - - - [Test] - public void S014_Add_Classes_Laboratory() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'Classes': [ - { - 'Id': 1, - 'SchoolId': 1, - 'Level': 1, - 'Capacity': 20, - 'Laboratory':{ - 'ClassId':1, - 'Name': 'First Class Laboratory' - } - }, - { - 'Id': 2, - 'SchoolId': 1, - 'Level': 2, - 'Capacity': 10, - 'Laboratory':{ - 'ClassId':2, - 'Name': 'Second Class Laboratory' - } - } - ] - } -"); - - var dbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.Laboratory) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(x => x.ClassTeachers) - .ThenInclude(x => x.Teacher) - .Include(s => s.Classes) - .ThenInclude(x => x.Students) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.True(updatedDbSchool.Classes.First(c => c.Id == 1).Laboratory != null); - Assert.True(updatedDbSchool.Classes.First(c => c.Id == 2).Laboratory != null); - } - - - [Test] - public void S015_Delete_First_Class_Laboratory() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'Classes': [ - { - 'Id': 1, - 'SchoolId': 1, - 'Level': 1, - 'Capacity': 20 - }, - { - 'Id': 2, - 'SchoolId': 1, - 'Level': 2, - 'Capacity': 10, - 'Laboratory':{ - 'ClassId':2, - 'Name': 'Second Class Laboratory' - } - } - ] - } -"); - - var dbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.Laboratory) - .FirstOrDefault(); - var lll = dbContext - .Entry(updatedSchool) - .Navigations - .Select(x => new - { - MetadataName = x.Metadata.Name, - MetadataDeclaringEntityType = x.Metadata.DeclaringEntityType, - MetadataDeclaringTypeName = x.Metadata.DeclaringType.Name, - IsShadowProperty = x.Metadata.IsShadowProperty(), - IsDependentToPrincipal = x.Metadata.IsDependentToPrincipal(), - Inverse = x.Metadata.FindInverse(), - FirstClass = dbContext.Entry(updatedSchool.Classes.FirstOrDefault()) - .Navigations - .Select(y => new - { - MetadataName = y.Metadata.Name, - MetadataDeclaringEntityType = y.Metadata.DeclaringEntityType, - MetadataDeclaringTypeName = y.Metadata.DeclaringType.Name, - IsShadowProperty = y.Metadata.IsShadowProperty(), - IsDependentToPrincipal = y.Metadata.IsDependentToPrincipal(), - Inverse = y.Metadata.FindInverse(), - }), - FirstClassLaboratory = updatedSchool.Classes.FirstOrDefault().Laboratory != null - ? dbContext.Entry(updatedSchool.Classes.FirstOrDefault().Laboratory) - .Navigations - .Select(z => new - { - MetadataName = z.Metadata.Name, - MetadataDeclaringEntityType = z.Metadata.DeclaringEntityType, - MetadataDeclaringTypeName = z.Metadata.DeclaringType.Name, - IsShadowProperty = z.Metadata.IsShadowProperty(), - IsDependentToPrincipal = z.Metadata.IsDependentToPrincipal(), - Inverse = z.Metadata.FindInverse(), - }) - : null, - }).ToList(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(x => x.ClassTeachers) - .ThenInclude(x => x.Teacher) - .Include(s => s.Classes) - .ThenInclude(x => x.Students) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.True(updatedDbSchool.Classes.First(c => c.Id == 1).Laboratory == null); - Assert.True(updatedDbSchool.Classes.First(c => c.Id == 2).Laboratory != null); - } - - - [Test] - public void S016_Update_Class_Teachers_Of_Second_Class() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'Classes': [ - { - 'Id': 1, - 'SchoolId': 1, - 'Level': 1, - 'Capacity': 20, - 'ClassTeachers': [ - { - 'ClassId': 1, - 'TeacherId': 'ec13122e-3ec5-4698-b254-e660d01f37ca' - } - ] - }, - { - 'Id': 2, - 'SchoolId': 1, - 'Level': 2, - 'Capacity': 10, - 'ClassTeachers': [ - { - 'ClassId': 2, - 'TeacherId': 'ec13122e-3ec5-4698-b254-e660d01f37ca' - }, - { - 'ClassId': 2, - 'TeacherId': '7AB15219-5FFA-406C-B092-94636B413E05' - } - ] - } - ] - } -"); - - var dbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.ClassTeachers) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.ClassTeachers) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.AreEqual(1, updatedDbSchool.Classes.First(c => c.Id == 1).ClassTeachers.Count); - Assert.AreEqual(2, updatedDbSchool.Classes.First(c => c.Id == 2).ClassTeachers.Count); - } - - - - [Test] - public void S017_Add_New_Teacher_To_A_Class() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'Classes': [ - { - 'Id': 1, - 'SchoolId': 1, - 'Level': 1, - 'Capacity': 20, - 'ClassTeachers': [ - { - 'ClassId': 1, - 'TeacherId': 'ec13122e-3ec5-4698-b254-e660d01f37ca' - }, - { - 'ClassId': 1, - 'TeacherId': 'D814E2DE-D097-40AE-BCCF-C4C5F1415D6B', - 'Teacher':{ - 'Id': 'D814E2DE-D097-40AE-BCCF-C4C5F1415D6B', - 'Name': 'Hasan' - } - } - ] - }, - { - 'Id': 2, - 'SchoolId': 1, - 'Level': 2, - 'Capacity': 10, - 'ClassTeachers': [ - { - 'ClassId': 2, - 'TeacherId': 'ec13122e-3ec5-4698-b254-e660d01f37ca' - }, - { - 'ClassId': 2, - 'TeacherId': '7AB15219-5FFA-406C-B092-94636B413E05' - } - ] - } - ] - } -"); - - var dbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.ClassTeachers) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.ClassTeachers) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.AreEqual(2, updatedDbSchool.Classes.First(c => c.Id == 1).ClassTeachers.Count); - Assert.AreEqual(2, updatedDbSchool.Classes.First(c => c.Id == 2).ClassTeachers.Count); - } - - - [Test] - public void S018_Update_Teacher_Info_Through_The_Class() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'Classes': [ - { - 'Id': 1, - 'SchoolId': 1, - 'Level': 1, - 'Capacity': 20, - 'ClassTeachers': [ - { - 'ClassId': 1, - 'TeacherId': 'ec13122e-3ec5-4698-b254-e660d01f37ca', - 'Teacher':{ - 'Id': 'ec13122e-3ec5-4698-b254-e660d01f37ca', - 'Name': 'Ahmad Osta', - 'DateOfBirth': '1980-10-01 03:00:00.0000000 +00:00' - } - }, - { - 'ClassId': 1, - 'TeacherId': 'D814E2DE-D097-40AE-BCCF-C4C5F1415D6B', - 'Teacher':{ - 'Id': 'D814E2DE-D097-40AE-BCCF-C4C5F1415D6B', - 'Name': 'Hasan' - } - } - ] - }, - { - 'Id': 2, - 'SchoolId': 1, - 'Level': 2, - 'Capacity': 10, - 'ClassTeachers': [ - { - 'ClassId': 2, - 'TeacherId': 'ec13122e-3ec5-4698-b254-e660d01f37ca', - 'Teacher':{ - 'Id': 'ec13122e-3ec5-4698-b254-e660d01f37ca', - 'Name': 'Ahmad Osta', - 'DateOfBirth': '1980-10-01 03:00:00.0000000 +00:00' - } - }, - { - 'ClassId': 2, - 'TeacherId': '7AB15219-5FFA-406C-B092-94636B413E05', - 'Teacher':{ - 'Id': '7AB15219-5FFA-406C-B092-94636B413E05', - 'Name': 'Mohammad', - 'DateOfBirth': '1980-09-01 03:00:00.0000000 +00:00' - } - } - ] - } - ] - } -"); - - var dbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.ClassTeachers) - .ThenInclude(x => x.Teacher) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.ClassTeachers) - .ThenInclude(x => x.Teacher) - .FirstOrDefault(); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.AreEqual(2, updatedDbSchool.Classes.First(c => c.Id == 1).ClassTeachers.Count); - Assert.AreEqual(2, updatedDbSchool.Classes.First(c => c.Id == 2).ClassTeachers.Count); - - Assert.AreEqual("Ahmad Osta", updatedDbSchool - .Classes.First(c => c.Id == 2) - .ClassTeachers.First(t => t.TeacherId == Guid.Parse("ec13122e-3ec5-4698-b254-e660d01f37ca")) - .Teacher.Name); - - Assert.IsNotNull(updatedDbSchool - .Classes.First(c => c.Id == 2) - .ClassTeachers.First(t => t.TeacherId == Guid.Parse("ec13122e-3ec5-4698-b254-e660d01f37ca")) - .Teacher.DateOfBirth); - } - - - [Test] - public void S019_Update_Teacher_Info_Directly() - { - var updatedTeacher = JsonConvert.DeserializeObject(@" - { - 'Id': 'ec13122e-3ec5-4698-b254-e660d01f37ca', - 'Name': 'Ahmad Bey', - 'DateOfBirth': '1980-11-01 03:00:00.0000000 +00:00' - }"); - - var dbTeacher = dbContext.Teachers - .FirstOrDefault(x => x.Id == Guid.Parse("ec13122e-3ec5-4698-b254-e660d01f37ca")); - - dbContext.InsertUpdateOrDeleteGraph(updatedTeacher, dbTeacher); - - dbContext.SaveChanges(); - - var updatedDbTeacher = dbContext.Teachers - .FirstOrDefault(x => x.Id == Guid.Parse("ec13122e-3ec5-4698-b254-e660d01f37ca")); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbTeacher, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.AreEqual("Ahmad Bey", updatedDbTeacher.Name); - Assert.AreEqual(DateTimeOffset.Parse("1980-11-01 03:00:00.0000000 +00:00"), updatedDbTeacher.DateOfBirth); - } - - - [Test] - public void S020_Remove_Teacher_From_Class_Should_Not_Delete_The_Teacher() - { - var updatedSchool = JsonConvert.DeserializeObject(@" - { - 'Id': 1, - 'Name': 'The First', - 'Type': 1, - 'Address': [ - 'Akdeniz', - 'Mersin' - ], - 'Classes': [ - { - 'Id': 1, - 'SchoolId': 1, - 'Level': 1, - 'Capacity': 20, - 'ClassTeachers': [ - { - 'ClassId': 1, - 'TeacherId': 'ec13122e-3ec5-4698-b254-e660d01f37ca' - } - ] - }, - { - 'Id': 2, - 'SchoolId': 1, - 'Level': 2, - 'Capacity': 10, - 'ClassTeachers': [ - { - 'ClassId': 2, - 'TeacherId': 'ec13122e-3ec5-4698-b254-e660d01f37ca' - }, - { - 'ClassId': 2, - 'TeacherId': '7AB15219-5FFA-406C-B092-94636B413E05' - } - ] - } - ] - } -"); - - var dbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.ClassTeachers) - .FirstOrDefault(); - - dbContext.InsertUpdateOrDeleteGraph(updatedSchool, dbSchool); - - dbContext.SaveChanges(); - - var updatedDbSchool = dbContext.Schools - .Include(s => s.Classes) - .ThenInclude(s => s.ClassTeachers) - .FirstOrDefault(); - - var updatedDbTeacher = dbContext.Teachers - .Include(s => s.ClassTeachers) - .ThenInclude(s => s.Class) - .FirstOrDefault(t => t.Id == Guid.Parse("D814E2DE-D097-40AE-BCCF-C4C5F1415D6B")); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbSchool, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Console.WriteLine("-------------------------------------"); - - Console.WriteLine(JsonConvert.SerializeObject(updatedDbTeacher, new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented, - })); - - Assert.AreEqual(1, updatedDbSchool.Classes.First(c => c.Id == 1).ClassTeachers.Count); - Assert.AreEqual(2, updatedDbSchool.Classes.First(c => c.Id == 2).ClassTeachers.Count); - Assert.IsNotNull(updatedDbTeacher); - Assert.AreEqual(0, updatedDbTeacher.ClassTeachers.Count); - } - } -} \ No newline at end of file diff --git a/src/UnitTests/Migrations/20200914083038_initialMigration.Designer.cs b/src/UnitTests/Migrations/20200914083038_initialMigration.Designer.cs deleted file mode 100644 index 60d8e05..0000000 --- a/src/UnitTests/Migrations/20200914083038_initialMigration.Designer.cs +++ /dev/null @@ -1,226 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using UnitTests; - -namespace UnitTests.Migrations -{ - [DbContext(typeof(FakeSchoolsDbContext))] - [Migration("20200914083038_initialMigration")] - partial class initialMigration - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "3.1.8") - .HasAnnotation("Relational:MaxIdentifierLength", 128) - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - modelBuilder.Entity("FakeModel.Class", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - b.Property("Capacity") - .HasColumnType("int"); - - b.Property("Level") - .HasColumnType("int"); - - b.Property("SchoolId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("SchoolId"); - - b.ToTable("Classes"); - }); - - modelBuilder.Entity("FakeModel.ClassLaboratory", b => - { - b.Property("ClassId") - .HasColumnType("int"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("ClassId"); - - b.ToTable("ClassLaboratories"); - }); - - modelBuilder.Entity("FakeModel.ClassTeacher", b => - { - b.Property("ClassId") - .HasColumnType("int"); - - b.Property("TeacherId") - .HasColumnType("uniqueidentifier"); - - b.HasKey("ClassId", "TeacherId"); - - b.HasIndex("TeacherId"); - - b.ToTable("ClassTeacher"); - }); - - modelBuilder.Entity("FakeModel.Degree", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Degrees"); - }); - - modelBuilder.Entity("FakeModel.School", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - b.Property("Address") - .HasColumnType("nvarchar(200)") - .HasMaxLength(200); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Type") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.ToTable("Schools"); - }); - - modelBuilder.Entity("FakeModel.SchoolHouse", b => - { - b.Property("SchoolId") - .HasColumnType("int"); - - b.Property("Capacity") - .HasColumnType("int"); - - b.HasKey("SchoolId"); - - b.ToTable("SchoolHouses"); - }); - - modelBuilder.Entity("FakeModel.Student", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("ClassId") - .HasColumnType("int"); - - b.Property("DateOfBirth") - .HasColumnType("datetimeoffset"); - - b.Property("DegreeId") - .HasColumnType("int"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ClassId"); - - b.HasIndex("DegreeId"); - - b.ToTable("Students"); - }); - - modelBuilder.Entity("FakeModel.Teacher", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("DateOfBirth") - .HasColumnType("datetimeoffset"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Teachers"); - }); - - modelBuilder.Entity("FakeModel.Class", b => - { - b.HasOne("FakeModel.School", "School") - .WithMany("Classes") - .HasForeignKey("SchoolId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("FakeModel.ClassLaboratory", b => - { - b.HasOne("FakeModel.Class", "Class") - .WithOne("Laboratory") - .HasForeignKey("FakeModel.ClassLaboratory", "ClassId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("FakeModel.ClassTeacher", b => - { - b.HasOne("FakeModel.Class", "Class") - .WithMany("ClassTeachers") - .HasForeignKey("ClassId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("FakeModel.Teacher", "Teacher") - .WithMany("ClassTeachers") - .HasForeignKey("TeacherId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("FakeModel.SchoolHouse", b => - { - b.HasOne("FakeModel.School", "School") - .WithOne("House") - .HasForeignKey("FakeModel.SchoolHouse", "SchoolId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("FakeModel.Student", b => - { - b.HasOne("FakeModel.Class", "Class") - .WithMany("Students") - .HasForeignKey("ClassId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("FakeModel.Degree", "Degree") - .WithMany("Students") - .HasForeignKey("DegreeId"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/UnitTests/Migrations/20200914083038_initialMigration.cs b/src/UnitTests/Migrations/20200914083038_initialMigration.cs deleted file mode 100644 index 01f8e80..0000000 --- a/src/UnitTests/Migrations/20200914083038_initialMigration.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace UnitTests.Migrations -{ - public partial class initialMigration : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Degrees", - columns: table => new - { - Id = table.Column(nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Degrees", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Schools", - columns: table => new - { - Id = table.Column(nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(nullable: true), - Type = table.Column(nullable: false), - Address = table.Column(maxLength: 200, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Schools", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Teachers", - columns: table => new - { - Id = table.Column(nullable: false), - Name = table.Column(nullable: true), - DateOfBirth = table.Column(nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Teachers", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Classes", - columns: table => new - { - Id = table.Column(nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - SchoolId = table.Column(nullable: false), - Level = table.Column(nullable: false), - Capacity = table.Column(nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Classes", x => x.Id); - table.ForeignKey( - name: "FK_Classes_Schools_SchoolId", - column: x => x.SchoolId, - principalTable: "Schools", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "SchoolHouses", - columns: table => new - { - SchoolId = table.Column(nullable: false), - Capacity = table.Column(nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_SchoolHouses", x => x.SchoolId); - table.ForeignKey( - name: "FK_SchoolHouses_Schools_SchoolId", - column: x => x.SchoolId, - principalTable: "Schools", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "ClassLaboratories", - columns: table => new - { - ClassId = table.Column(nullable: false), - Name = table.Column(nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_ClassLaboratories", x => x.ClassId); - table.ForeignKey( - name: "FK_ClassLaboratories_Classes_ClassId", - column: x => x.ClassId, - principalTable: "Classes", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "ClassTeacher", - columns: table => new - { - ClassId = table.Column(nullable: false), - TeacherId = table.Column(nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ClassTeacher", x => new { x.ClassId, x.TeacherId }); - table.ForeignKey( - name: "FK_ClassTeacher_Classes_ClassId", - column: x => x.ClassId, - principalTable: "Classes", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_ClassTeacher_Teachers_TeacherId", - column: x => x.TeacherId, - principalTable: "Teachers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "Students", - columns: table => new - { - Id = table.Column(nullable: false), - DegreeId = table.Column(nullable: true), - ClassId = table.Column(nullable: false), - Name = table.Column(nullable: true), - DateOfBirth = table.Column(nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Students", x => x.Id); - table.ForeignKey( - name: "FK_Students_Classes_ClassId", - column: x => x.ClassId, - principalTable: "Classes", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_Students_Degrees_DegreeId", - column: x => x.DegreeId, - principalTable: "Degrees", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_Classes_SchoolId", - table: "Classes", - column: "SchoolId"); - - migrationBuilder.CreateIndex( - name: "IX_ClassTeacher_TeacherId", - table: "ClassTeacher", - column: "TeacherId"); - - migrationBuilder.CreateIndex( - name: "IX_Students_ClassId", - table: "Students", - column: "ClassId"); - - migrationBuilder.CreateIndex( - name: "IX_Students_DegreeId", - table: "Students", - column: "DegreeId"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ClassLaboratories"); - - migrationBuilder.DropTable( - name: "ClassTeacher"); - - migrationBuilder.DropTable( - name: "SchoolHouses"); - - migrationBuilder.DropTable( - name: "Students"); - - migrationBuilder.DropTable( - name: "Teachers"); - - migrationBuilder.DropTable( - name: "Classes"); - - migrationBuilder.DropTable( - name: "Degrees"); - - migrationBuilder.DropTable( - name: "Schools"); - } - } -} diff --git a/src/UnitTests/Migrations/FakeSchoolsDbContextModelSnapshot.cs b/src/UnitTests/Migrations/FakeSchoolsDbContextModelSnapshot.cs deleted file mode 100644 index 995c2fc..0000000 --- a/src/UnitTests/Migrations/FakeSchoolsDbContextModelSnapshot.cs +++ /dev/null @@ -1,224 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using UnitTests; - -namespace UnitTests.Migrations -{ - [DbContext(typeof(FakeSchoolsDbContext))] - partial class FakeSchoolsDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "3.1.8") - .HasAnnotation("Relational:MaxIdentifierLength", 128) - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - modelBuilder.Entity("FakeModel.Class", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - b.Property("Capacity") - .HasColumnType("int"); - - b.Property("Level") - .HasColumnType("int"); - - b.Property("SchoolId") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("SchoolId"); - - b.ToTable("Classes"); - }); - - modelBuilder.Entity("FakeModel.ClassLaboratory", b => - { - b.Property("ClassId") - .HasColumnType("int"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("ClassId"); - - b.ToTable("ClassLaboratories"); - }); - - modelBuilder.Entity("FakeModel.ClassTeacher", b => - { - b.Property("ClassId") - .HasColumnType("int"); - - b.Property("TeacherId") - .HasColumnType("uniqueidentifier"); - - b.HasKey("ClassId", "TeacherId"); - - b.HasIndex("TeacherId"); - - b.ToTable("ClassTeacher"); - }); - - modelBuilder.Entity("FakeModel.Degree", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Degrees"); - }); - - modelBuilder.Entity("FakeModel.School", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - b.Property("Address") - .HasColumnType("nvarchar(200)") - .HasMaxLength(200); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Type") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.ToTable("Schools"); - }); - - modelBuilder.Entity("FakeModel.SchoolHouse", b => - { - b.Property("SchoolId") - .HasColumnType("int"); - - b.Property("Capacity") - .HasColumnType("int"); - - b.HasKey("SchoolId"); - - b.ToTable("SchoolHouses"); - }); - - modelBuilder.Entity("FakeModel.Student", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("ClassId") - .HasColumnType("int"); - - b.Property("DateOfBirth") - .HasColumnType("datetimeoffset"); - - b.Property("DegreeId") - .HasColumnType("int"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ClassId"); - - b.HasIndex("DegreeId"); - - b.ToTable("Students"); - }); - - modelBuilder.Entity("FakeModel.Teacher", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("DateOfBirth") - .HasColumnType("datetimeoffset"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Teachers"); - }); - - modelBuilder.Entity("FakeModel.Class", b => - { - b.HasOne("FakeModel.School", "School") - .WithMany("Classes") - .HasForeignKey("SchoolId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("FakeModel.ClassLaboratory", b => - { - b.HasOne("FakeModel.Class", "Class") - .WithOne("Laboratory") - .HasForeignKey("FakeModel.ClassLaboratory", "ClassId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("FakeModel.ClassTeacher", b => - { - b.HasOne("FakeModel.Class", "Class") - .WithMany("ClassTeachers") - .HasForeignKey("ClassId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("FakeModel.Teacher", "Teacher") - .WithMany("ClassTeachers") - .HasForeignKey("TeacherId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("FakeModel.SchoolHouse", b => - { - b.HasOne("FakeModel.School", "School") - .WithOne("House") - .HasForeignKey("FakeModel.SchoolHouse", "SchoolId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("FakeModel.Student", b => - { - b.HasOne("FakeModel.Class", "Class") - .WithMany("Students") - .HasForeignKey("ClassId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("FakeModel.Degree", "Degree") - .WithMany("Students") - .HasForeignKey("DegreeId"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/UnitTests/TestHelpers.cs b/src/UnitTests/TestHelpers.cs deleted file mode 100644 index 6812f36..0000000 --- a/src/UnitTests/TestHelpers.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Linq; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; - - -namespace UnitTests -{ - public static class TestHelpers - { - public static IConfiguration InitConfiguration() - { - var config = new ConfigurationBuilder() - .AddJsonFile("appsettings.json") - .Build(); - return config; - } - } -} \ No newline at end of file diff --git a/src/UnitTests/UnitTests.csproj b/src/UnitTests/UnitTests.csproj deleted file mode 100644 index b52e22d..0000000 --- a/src/UnitTests/UnitTests.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - netcoreapp3.1 - - false - - - - - - Always - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - diff --git a/src/UnitTests/VariousTests.cs b/src/UnitTests/VariousTests.cs deleted file mode 100644 index 75b0c5c..0000000 --- a/src/UnitTests/VariousTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.Linq; -using Diwink.Extensions.EntityFrameworkCore; -using FakeModel; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; - - -namespace UnitTests -{ - [TestFixture] - public class VariousTests - { - private ServiceProvider serviceProvider; - private IServiceScope scope; - private FakeSchoolsDbContext dbContext; - - - public VariousTests() - { - var configuration = TestHelpers.InitConfiguration(); - var services = new ServiceCollection(); - - services.AddDbContext(options => options.UseSqlServer(configuration.GetConnectionString("FakeSchoolsDb"))); - - serviceProvider = services.BuildServiceProvider(); - } - - - [SetUp] - public void Setup() - { - scope = serviceProvider.CreateScope(); - dbContext = scope.ServiceProvider.GetService(); - } - - - [TearDown] - public void TearDown() - { - scope.Dispose(); - } - - - [Test] - public void Different_Composite_Key_Values_Should_Not_Equal() - { - var firstComposition = new ClassTeacher() - { - ClassId = 1, - TeacherId = Guid.NewGuid(), - }; - var secondComposition = new ClassTeacher() - { - ClassId = 1, - TeacherId = Guid.NewGuid(), - }; - - var firstKey = dbContext.Entry(firstComposition).GetPrimaryKeyValues(); - var secondKey = dbContext.Entry(secondComposition).GetPrimaryKeyValues(); - - Assert.False(firstKey.SequenceEqual(secondKey)); - } - - - [Test] - public void Same_Composite_Key_Values_Should_Be_Equal() - { - var firstComposition = new ClassTeacher() - { - ClassId = 1, - TeacherId = Guid.Parse("{6D83B2B3-F28E-4D2D-8671-93F8E6AB08C1}"), - }; - var secondComposition = new ClassTeacher() - { - TeacherId = Guid.Parse("{6D83B2B3-F28E-4D2D-8671-93F8E6AB08C1}"), - ClassId = 1, - }; - - var firstKey = dbContext.Entry(firstComposition).GetPrimaryKeyValues(); - var secondKey = dbContext.Entry(secondComposition).GetPrimaryKeyValues(); - - Assert.True(firstKey.SequenceEqual(secondKey)); - } - } -} \ No newline at end of file diff --git a/src/UnitTests/appsettings.json b/src/UnitTests/appsettings.json deleted file mode 100644 index 13db762..0000000 --- a/src/UnitTests/appsettings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "ConnectionStrings": { - "FakeSchoolsDb": "Server=.;Database=EfCore-UpdateGraph;Trusted_Connection=true;MultipleActiveResultSets=true" - }, - "InMemoryDB": true -} \ No newline at end of file