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; }
}