Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/EfOrderBy/Conventions/ConventionPlugin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// <summary>
/// Convention plugin that marks the model as having UseDefaultOrderBy() configured.
/// </summary>
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;
}
}
33 changes: 33 additions & 0 deletions src/EfOrderBy/Conventions/FinalizingConvention.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/// <summary>
/// Convention that creates database indexes for all configured default orderings during model finalization.
/// </summary>
class FinalizingConvention : IModelFinalizingConvention
{
const int maxIndexNameLength = 128;

public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> 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);
}
}
}
16 changes: 16 additions & 0 deletions src/EfOrderBy/Conventions/InitializedConvention.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/// <summary>
/// Convention that sets annotations on the model indicating UseDefaultOrderBy() was called
/// and whether index creation is enabled.
/// </summary>
class InitializedConvention(bool createIndexes) : IModelInitializedConvention
{
public void ProcessModelInitialized(IConventionModelBuilder builder, IConventionContext<IConventionModelBuilder> context)
{
builder.HasAnnotation(OrderByExtensions.InterceptorRegisteredAnnotation, true);

if (!createIndexes)
{
builder.HasAnnotation(OrderByExtensions.IndexCreationDisabledAnnotation, true);
}
}
}
13 changes: 7 additions & 6 deletions src/EfOrderBy/IncludeOrderingApplicator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Department, List<Employee>>(...)
// New: Include<Department, IOrderedEnumerable<Employee>>(...)
var sourceType = includeCall.Method.GetGenericArguments()[0]; // TEntity
var newPropertyType = orderedNavigation.Type; // IOrderedEnumerable<Employee>
// Get the Include/ThenInclude method with the new return type
// Include has 2 generic args: <TEntity, TProperty>
// ThenInclude has 3: <TEntity, TPreviousProperty, TProperty>
// 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(
Expand Down
2 changes: 1 addition & 1 deletion src/EfOrderBy/OrderRequiredExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ sealed class OrderRequiredExtension(bool requireOrderingForAllEntities, bool cre
public void ApplyServices(IServiceCollection services)
{
var createIndexes = CreateIndexes;
services.AddSingleton<IConventionSetPlugin>(_ => new UseDefaultOrderByConventionPlugin(createIndexes));
services.AddSingleton<IConventionSetPlugin>(_ => new ConventionPlugin(createIndexes));
}

public void Validate(IDbContextOptions options)
Expand Down
66 changes: 0 additions & 66 deletions src/EfOrderBy/UseDefaultOrderByConventionPlugin.cs

This file was deleted.

24 changes: 24 additions & 0 deletions src/Tests/DefaultOrderByTests.Schema.verified.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
]
}
35 changes: 35 additions & 0 deletions src/Tests/DefaultOrderByTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading