diff --git a/src/EfOrderBy/Conventions/ConventionPlugin.cs b/src/EfOrderBy/Conventions/ConventionPlugin.cs new file mode 100644 index 0000000..f1e0f29 --- /dev/null +++ b/src/EfOrderBy/Conventions/ConventionPlugin.cs @@ -0,0 +1,19 @@ +/// +/// Convention plugin that marks the model as having UseDefaultOrderBy() configured. +/// +class ConventionPlugin(bool createIndexes) : IConventionSetPlugin +{ + static FinalizingConvention finalizingConvention = new(); + + public ConventionSet ModifyConventions(ConventionSet conventions) + { + conventions.ModelInitializedConventions.Add(new InitializedConvention(createIndexes)); + + if (createIndexes) + { + conventions.ModelFinalizingConventions.Add(finalizingConvention); + } + + return conventions; + } +} diff --git a/src/EfOrderBy/Conventions/FinalizingConvention.cs b/src/EfOrderBy/Conventions/FinalizingConvention.cs new file mode 100644 index 0000000..53a2b63 --- /dev/null +++ b/src/EfOrderBy/Conventions/FinalizingConvention.cs @@ -0,0 +1,33 @@ +/// +/// Convention that creates database indexes for all configured default orderings during model finalization. +/// +class FinalizingConvention : IModelFinalizingConvention +{ + const int maxIndexNameLength = 128; + + public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) + { + foreach (var entity in modelBuilder.Metadata.GetEntityTypes()) + { + var annotation = entity.FindAnnotation(OrderByExtensions.AnnotationName); + if (annotation?.Value is not Configuration config) + { + continue; + } + + var index = config.CustomIndexName ?? $"IX_{entity.ClrType.Name}_DefaultOrder"; + + if (index.Length > maxIndexNameLength) + { + throw new InvalidOperationException( + $""" + The auto-generated index name '{index}' exceeds the maximum length of {maxIndexNameLength} characters. + Use .WithIndexName() to specify a shorter custom index name. + """); + } + + var builder = entity.Builder.HasIndex(config.PropertyNames, fromDataAnnotation: false); + builder?.HasDatabaseName(index, fromDataAnnotation: false); + } + } +} diff --git a/src/EfOrderBy/Conventions/InitializedConvention.cs b/src/EfOrderBy/Conventions/InitializedConvention.cs new file mode 100644 index 0000000..6868c12 --- /dev/null +++ b/src/EfOrderBy/Conventions/InitializedConvention.cs @@ -0,0 +1,16 @@ +/// +/// Convention that sets annotations on the model indicating UseDefaultOrderBy() was called +/// and whether index creation is enabled. +/// +class InitializedConvention(bool createIndexes) : IModelInitializedConvention +{ + public void ProcessModelInitialized(IConventionModelBuilder builder, IConventionContext context) + { + builder.HasAnnotation(OrderByExtensions.InterceptorRegisteredAnnotation, true); + + if (!createIndexes) + { + builder.HasAnnotation(OrderByExtensions.IndexCreationDisabledAnnotation, true); + } + } +} diff --git a/src/EfOrderBy/IncludeOrderingApplicator.cs b/src/EfOrderBy/IncludeOrderingApplicator.cs index 00b922b..d4632fa 100644 --- a/src/EfOrderBy/IncludeOrderingApplicator.cs +++ b/src/EfOrderBy/IncludeOrderingApplicator.cs @@ -59,14 +59,15 @@ MethodCallExpression ProcessInclude(MethodCallExpression includeCall) var orderedNavigation = ApplyOrdering(lambda.Body, configuration); var orderedLambda = Expression.Lambda(orderedNavigation, lambda.Parameters); - // Get the Include method with the new return type - // Original: Include>(...) - // New: Include>(...) - var sourceType = includeCall.Method.GetGenericArguments()[0]; // TEntity - var newPropertyType = orderedNavigation.Type; // IOrderedEnumerable + // Get the Include/ThenInclude method with the new return type + // Include has 2 generic args: + // ThenInclude has 3: + // In both cases, the last generic arg is the property type to replace + var genericArgs = includeCall.Method.GetGenericArguments().ToArray(); + genericArgs[^1] = orderedNavigation.Type; var includeMethod = includeCall.Method.GetGenericMethodDefinition() - .MakeGenericMethod(sourceType, newPropertyType); + .MakeGenericMethod(genericArgs); // Recreate the Include call with the new method and ordered lambda return Expression.Call( diff --git a/src/EfOrderBy/OrderRequiredExtension.cs b/src/EfOrderBy/OrderRequiredExtension.cs index bd9870a..03ccb5c 100644 --- a/src/EfOrderBy/OrderRequiredExtension.cs +++ b/src/EfOrderBy/OrderRequiredExtension.cs @@ -12,7 +12,7 @@ sealed class OrderRequiredExtension(bool requireOrderingForAllEntities, bool cre public void ApplyServices(IServiceCollection services) { var createIndexes = CreateIndexes; - services.AddSingleton(_ => new UseDefaultOrderByConventionPlugin(createIndexes)); + services.AddSingleton(_ => new ConventionPlugin(createIndexes)); } public void Validate(IDbContextOptions options) diff --git a/src/EfOrderBy/UseDefaultOrderByConventionPlugin.cs b/src/EfOrderBy/UseDefaultOrderByConventionPlugin.cs deleted file mode 100644 index 987824b..0000000 --- a/src/EfOrderBy/UseDefaultOrderByConventionPlugin.cs +++ /dev/null @@ -1,66 +0,0 @@ -/// -/// Convention plugin that marks the model as having UseDefaultOrderBy() configured. -/// -sealed class UseDefaultOrderByConventionPlugin(bool createIndexes) : IConventionSetPlugin -{ - public ConventionSet ModifyConventions(ConventionSet conventionSet) - { - conventionSet.ModelInitializedConventions.Add(new UseDefaultOrderByConvention(createIndexes)); - - if (createIndexes) - { - conventionSet.ModelFinalizingConventions.Add(new OrderByIndexConvention()); - } - - return conventionSet; - } -} - -/// -/// Convention that sets annotations on the model indicating UseDefaultOrderBy() was called -/// and whether index creation is enabled. -/// -sealed class UseDefaultOrderByConvention(bool createIndexes) : IModelInitializedConvention -{ - public void ProcessModelInitialized(IConventionModelBuilder modelBuilder, IConventionContext context) - { - modelBuilder.HasAnnotation(OrderByExtensions.InterceptorRegisteredAnnotation, true); - - if (!createIndexes) - { - modelBuilder.HasAnnotation(OrderByExtensions.IndexCreationDisabledAnnotation, true); - } - } -} - -/// -/// Convention that creates database indexes for all configured default orderings during model finalization. -/// -sealed class OrderByIndexConvention : IModelFinalizingConvention -{ - const int maxIndexNameLength = 128; - - public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) - { - foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) - { - var annotation = entityType.FindAnnotation(OrderByExtensions.AnnotationName); - if (annotation?.Value is not Configuration config) - { - continue; - } - - var indexName = config.CustomIndexName ?? $"IX_{entityType.ClrType.Name}_DefaultOrder"; - - if (indexName.Length > maxIndexNameLength) - { - throw new InvalidOperationException( - $"The auto-generated index name '{indexName}' exceeds the maximum length of {maxIndexNameLength} characters. " + - $"Use .WithIndexName() to specify a shorter custom index name."); - } - - var indexBuilder = entityType.Builder.HasIndex(config.PropertyNames.ToList(), fromDataAnnotation: false); - indexBuilder?.HasDatabaseName(indexName, fromDataAnnotation: false); - } - } -} diff --git a/src/Tests/DefaultOrderByTests.Schema.verified.md b/src/Tests/DefaultOrderByTests.Schema.verified.md index 49cdc0c..49ea803 100644 --- a/src/Tests/DefaultOrderByTests.Schema.verified.md +++ b/src/Tests/DefaultOrderByTests.Schema.verified.md @@ -65,6 +65,30 @@ CREATE NONCLUSTERED INDEX [IX_Employees_DepartmentId] ON [dbo].[Employees] ) ON [PRIMARY] ``` +### EmployeeTasks + +```sql +CREATE TABLE [dbo].[EmployeeTasks]( + [Id] [int] IDENTITY(1,1) NOT NULL, + [EmployeeId] [int] NOT NULL, + [Title] [nvarchar](max) NOT NULL, + [Priority] [int] NOT NULL, + CONSTRAINT [PK_EmployeeTasks] PRIMARY KEY CLUSTERED +( + [Id] ASC +) ON [PRIMARY] +) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY] + +CREATE NONCLUSTERED INDEX [IX_EmployeeTask_DefaultOrder] ON [dbo].[EmployeeTasks] +( + [Priority] ASC +) ON [PRIMARY] +CREATE NONCLUSTERED INDEX [IX_EmployeeTasks_EmployeeId] ON [dbo].[EmployeeTasks] +( + [EmployeeId] ASC +) ON [PRIMARY] +``` + ### EntitiesWithMultipleOrderings ```sql diff --git a/src/Tests/DefaultOrderByTests.ThenInclude_AppliesDefaultOrderingToThirdLevel.verified.txt b/src/Tests/DefaultOrderByTests.ThenInclude_AppliesDefaultOrderingToThirdLevel.verified.txt new file mode 100644 index 0000000..a9e618e --- /dev/null +++ b/src/Tests/DefaultOrderByTests.ThenInclude_AppliesDefaultOrderingToThirdLevel.verified.txt @@ -0,0 +1,145 @@ +{ + target: [ + { + Id: 1, + Name: Engineering, + DisplayOrder: 1, + Employees: [ + { + Id: 2, + DepartmentId: 1, + Name: Bob, + HireDate: 2024-03-20, + Salary: 85000, + Tasks: [ + { + Id: 5, + EmployeeId: 2, + Title: Monitor, + Priority: 1 + }, + { + Id: 4, + EmployeeId: 2, + Title: Deploy, + Priority: 2 + } + ] + }, + { + Id: 1, + DepartmentId: 1, + Name: Alice, + HireDate: 2024-01-15, + Salary: 90000, + Tasks: [ + { + Id: 2, + EmployeeId: 1, + Title: Code review, + Priority: 1 + }, + { + Id: 3, + EmployeeId: 1, + Title: Testing, + Priority: 2 + }, + { + Id: 1, + EmployeeId: 1, + Title: Design, + Priority: 3 + } + ] + }, + { + Id: 3, + DepartmentId: 1, + Name: Charlie, + HireDate: 2023-06-10, + Salary: 95000 + } + ] + }, + { + Id: 2, + Name: Sales, + DisplayOrder: 2, + Employees: [ + { + Id: 4, + DepartmentId: 2, + Name: Diana, + HireDate: 2024-02-05, + Salary: 70000 + }, + { + Id: 5, + DepartmentId: 2, + Name: Eve, + HireDate: 2023-11-01, + Salary: 72000 + } + ] + }, + { + Id: 3, + Name: HR, + DisplayOrder: 3, + Employees: [ + { + Id: 6, + DepartmentId: 3, + Name: Frank, + HireDate: 2024-04-10, + Salary: 65000 + } + ] + } + ], + sql: [ + { + Text: +select d.Id, + d.DisplayOrder, + d.Name +from Departments as d +order by d.DisplayOrder, d.Id, + HasTransaction: false + }, + { + Text: +select e.Id, + e.DepartmentId, + e.HireDate, + e.Name, + e.Salary, + d.Id +from Departments as d + inner join + Employees as e + on d.Id = e.DepartmentId +order by d.DisplayOrder, d.Id, e.HireDate desc, e.Id, + HasTransaction: false + }, + { + Text: +select e0.Id, + e0.EmployeeId, + e0.Priority, + e0.Title, + d.Id, + e.Id +from Departments as d + inner join + Employees as e + on d.Id = e.DepartmentId + inner join + EmployeeTasks as e0 + on e.Id = e0.EmployeeId +order by d.DisplayOrder, d.Id, e.HireDate desc, e.Id, e0.Priority, + HasTransaction: false + } + ] +} \ No newline at end of file diff --git a/src/Tests/DefaultOrderByTests.cs b/src/Tests/DefaultOrderByTests.cs index 70ffffa..fa70c5a 100644 --- a/src/Tests/DefaultOrderByTests.cs +++ b/src/Tests/DefaultOrderByTests.cs @@ -157,6 +157,41 @@ public async Task IncludeWithoutExplicitOrdering_AppliesDefaultOrderingToNestedC await Verify(results); } + [Test] + public async Task ThenInclude_AppliesDefaultOrderingToThirdLevel() + { + await using var database = await ModuleInitializer.SqlInstance.Build(); + await using var context = database.NewDbContext(); + + Recording.Start(); + var results = await context.Departments + .Include(_ => _.Employees) + .ThenInclude(_ => _.Tasks) + .AsSplitQuery() + .ToListAsync(); + + // Departments ordered by DisplayOrder + Assert.That(results[0].Name, Is.EqualTo("Engineering")); + + // Employees ordered by HireDate descending + var engEmployees = results[0].Employees; + Assert.That(engEmployees[0].Name, Is.EqualTo("Bob")); + + // Tasks ordered by Priority ascending (via ThenInclude) + var aliceTasks = engEmployees[1].Tasks; // Alice + Assert.That(aliceTasks, Has.Count.EqualTo(3)); + Assert.That(aliceTasks[0].Title, Is.EqualTo("Code review")); // Priority 1 + Assert.That(aliceTasks[1].Title, Is.EqualTo("Testing")); // Priority 2 + Assert.That(aliceTasks[2].Title, Is.EqualTo("Design")); // Priority 3 + + var bobTasks = engEmployees[0].Tasks; // Bob + Assert.That(bobTasks, Has.Count.EqualTo(2)); + Assert.That(bobTasks[0].Title, Is.EqualTo("Monitor")); // Priority 1 + Assert.That(bobTasks[1].Title, Is.EqualTo("Deploy")); // Priority 2 + + await Verify(results); + } + [Test] public async Task IncludeWithExplicitOrdering_DoesNotApplyDefaultToNestedCollection() { diff --git a/src/Tests/ModuleInitializer.cs b/src/Tests/ModuleInitializer.cs index 1f4740c..f4be1b4 100644 --- a/src/Tests/ModuleInitializer.cs +++ b/src/Tests/ModuleInitializer.cs @@ -111,13 +111,24 @@ public static void Initialize() { Name = "Alice", HireDate = new(2024, 1, 15), - Salary = 90000 + Salary = 90000, + Tasks = + [ + new() { Title = "Design", Priority = 3 }, + new() { Title = "Code review", Priority = 1 }, + new() { Title = "Testing", Priority = 2 } + ] }, new() { Name = "Bob", HireDate = new(2024, 3, 20), - Salary = 85000 + Salary = 85000, + Tasks = + [ + new() { Title = "Deploy", Priority = 2 }, + new() { Title = "Monitor", Priority = 1 } + ] }, new() { diff --git a/src/Tests/TestDbContext.cs b/src/Tests/TestDbContext.cs index 9aecbf0..04330ef 100644 --- a/src/Tests/TestDbContext.cs +++ b/src/Tests/TestDbContext.cs @@ -7,6 +7,7 @@ public class TestDbContext(DbContextOptions options) : public DbSet EntitiesWithMultipleOrderings => Set(); public DbSet Departments => Set(); public DbSet Employees => Set(); + public DbSet EmployeeTasks => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -41,5 +42,16 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Default ordering for Employee: HireDate descending (newest first) modelBuilder.Entity() .OrderByDescending(_ => _.HireDate); + + // Configure Employee-EmployeeTask relationship + modelBuilder.Entity() + .HasOne(_ => _.Employee) + .WithMany(_ => _.Tasks) + .HasForeignKey(_ => _.EmployeeId) + .IsRequired(); + + // Default ordering for EmployeeTask: Priority ascending + modelBuilder.Entity() + .OrderBy(_ => _.Priority); } } diff --git a/src/Tests/TestEntities.cs b/src/Tests/TestEntities.cs index ee81634..75d7c92 100644 --- a/src/Tests/TestEntities.cs +++ b/src/Tests/TestEntities.cs @@ -42,4 +42,14 @@ public class Employee public string Name { get; set; } = ""; public DateTime HireDate { get; set; } public int Salary { get; set; } + public List Tasks { get; set; } = []; +} + +public class EmployeeTask +{ + public int Id { get; set; } + public int EmployeeId { get; set; } + public Employee Employee { get; set; } = null!; + public string Title { get; set; } = ""; + public int Priority { get; set; } }