SortableKit 1.0.0
dotnet add package SortableKit --version 1.0.0
NuGet\Install-Package SortableKit -Version 1.0.0
<PackageReference Include="SortableKit" Version="1.0.0" />
<PackageVersion Include="SortableKit" Version="1.0.0" />
<PackageReference Include="SortableKit" />
paket add SortableKit --version 1.0.0
#r "nuget: SortableKit, 1.0.0"
#:package SortableKit@1.0.0
#addin nuget:?package=SortableKit&version=1.0.0
#tool nuget:?package=SortableKit&version=1.0.0
SortableKit
Full-featured sort order management for EF Core and Dapper entities with scoped ordering, auto-incrementing, position manipulation, and bulk reordering. Inspired by spatie/eloquent-sortable.
Table of Contents
- Installation
- Packages
- Quick Start
- Core Concepts
- EF Core Integration
- Dapper Integration
- Operations Reference
- Configuration
- License
Installation
# Core interfaces and attributes
dotnet add package SortableKit
# EF Core integration
dotnet add package SortableKit.EntityFrameworkCore
# Dapper integration
dotnet add package SortableKit.Dapper
# ASP.NET Core DI helpers (optional)
dotnet add package SortableKit.AspNetCore
Packages
| Package | Description |
|---|---|
SortableKit |
Core interfaces, attributes, and options |
SortableKit.EntityFrameworkCore |
SaveChanges interceptor and ISortableService<T> |
SortableKit.Dapper |
Raw SQL operations via ISortableRepository<T> |
SortableKit.AspNetCore |
DI builder extensions for ASP.NET Core |
All packages multi-target net8.0, net9.0, and net10.0.
Quick Start
1. Implement ISortable
using SortableKit;
using SortableKit.Attributes;
public class TaskItem : ISortable
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public int ProjectId { get; set; }
// Scoped: each project has its own sort order sequence
[SortOrder(Scope = nameof(ProjectId))]
public int SortOrder { get; set; }
}
2. Register services
// Program.cs
builder.Services
.AddSortableKit(options =>
{
options.StartAt = 0; // first item gets SortOrder = 0
})
.UseEntityFrameworkCore<AppDbContext, TaskItem>()
.UseDapper();
3. Configure DbContext
public class AppDbContext : DbContext
{
private readonly SortableInsertInterceptor _interceptor;
public AppDbContext(
DbContextOptions<AppDbContext> options,
SortableInsertInterceptor interceptor) : base(options)
{
_interceptor = interceptor;
}
public DbSet<TaskItem> Tasks => Set<TaskItem>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(_interceptor);
}
4. Use
// Auto-assigned sort order on insert
var task = new TaskItem { Title = "My Task", ProjectId = 1 };
dbContext.Tasks.Add(task);
await dbContext.SaveChangesAsync();
// task.SortOrder is now automatically set to the next value in ProjectId=1 scope
// Query in order
var tasks = await dbContext.Tasks
.Where(t => t.ProjectId == 1)
.OrderBySortOrder()
.ToListAsync();
// Move to a specific position
await sortableService.MoveToPositionAsync(task, 2);
// Bulk reorder from frontend drag-and-drop
await sortableService.SetOrderAsync(projectId: 1, orderedIds: [id3, id1, id2, id4]);
Core Concepts
ISortable Interface
All sortable entities must implement ISortable:
public interface ISortable
{
int SortOrder { get; set; }
}
The SortOrder property holds the entity's position within its scope. Lower values sort first.
SortOrder Attribute
Apply [SortOrder] to the SortOrder property to configure behaviour:
[AttributeUsage(AttributeTargets.Property)]
public sealed class SortOrderAttribute : Attribute
{
/// <summary>
/// Property name that defines the scope boundary.
/// </summary>
public string? Scope { get; set; }
}
Scoped Ordering
When Scope is set, sort order is maintained independently within each distinct value of the scope property. This allows multiple independent lists within the same database table.
public class BoardCard : ISortable
{
public int Id { get; set; }
public int ColumnId { get; set; }
// Each column has its own sort order: 0, 1, 2...
[SortOrder(Scope = nameof(ColumnId))]
public int SortOrder { get; set; }
}
Operations like MoveToPositionAsync, RecalculateAsync, and SetOrderAsync always operate within the same scope as the entity being manipulated.
EF Core Integration
Registration
// Register once per entity type that uses ISortableService<T>
services.AddSortableKit(o => o.StartAt = 0)
.UseEntityFrameworkCore<AppDbContext, TaskItem>()
.UseEntityFrameworkCore<AppDbContext, BoardCard>();
Auto-increment on Insert
SortableInsertInterceptor hooks into SaveChanges and automatically assigns the next SortOrder to any newly-added ISortable entity whose SortOrder is still at its CLR default (0).
The interceptor processes all new entities in a single pass before saving, grouping them by scope and issuing one MAX(SortOrder) query per distinct scope.
// All three tasks get sequential sort orders: 0, 1, 2
dbContext.Tasks.AddRange(
new TaskItem { Title = "Alpha", ProjectId = 1 },
new TaskItem { Title = "Beta", ProjectId = 1 },
new TaskItem { Title = "Gamma", ProjectId = 1 });
await dbContext.SaveChangesAsync();
// Adding to a different project starts at 0 independently
dbContext.Tasks.Add(new TaskItem { Title = "First", ProjectId = 2 });
await dbContext.SaveChangesAsync();
// SortOrder = 0 (ProjectId 2 scope is independent)
Important: The interceptor only auto-assigns when SortOrder == 0. If you explicitly set a non-zero SortOrder, it will be preserved.
ISortableService
ISortableService<T> provides all position management operations:
public interface ISortableService<T> where T : class, ISortable
{
Task MoveToPositionAsync(T entity, int position, CancellationToken ct = default);
Task SwapAsync(T entityA, T entityB, CancellationToken ct = default);
Task MoveToStartAsync(T entity, CancellationToken ct = default);
Task MoveToEndAsync(T entity, CancellationToken ct = default);
Task SetOrderAsync(object? scopeId, IReadOnlyList<object> orderedIds, CancellationToken ct = default);
Task RecalculateAsync(object? scopeId, CancellationToken ct = default);
}
Inject it into your services:
public class TaskService(ISortableService<TaskItem> sortable)
{
public async Task ReorderAsync(int projectId, int[] orderedIds)
=> await sortable.SetOrderAsync(projectId, orderedIds.Cast<object>().ToList());
}
Query Extensions
using SortableKit.EntityFrameworkCore.Extensions;
// Ascending (default)
var tasks = await dbContext.Tasks
.Where(t => t.ProjectId == 1)
.OrderBySortOrder()
.ToListAsync();
// Descending
var reversed = await dbContext.Tasks
.OrderBySortOrderDescending()
.ToListAsync();
Dapper Integration
Registration
services.AddSortableKit()
.UseDapper(options =>
{
options.Dialect = SqlDialect.SqlServer;
});
ISortableRepository
For Dapper-based data access, use ISortableRepository<T>. You must configure a SortableRepositoryOptions instance per entity type (or rely on DI-registered defaults).
// Direct instantiation (e.g. in tests or minimal APIs)
var repo = new SortableRepository<TaskItem>(
new SortableOptions { StartAt = 0 },
new SortableRepositoryOptions
{
TableName = "Tasks",
IdColumn = "Id",
SortOrderColumn = "SortOrder",
ScopeColumn = "ProjectId",
Dialect = SqlDialect.Sqlite,
});
using var connection = new SqliteConnection(connectionString);
connection.Open();
// Get next available sort order
var nextOrder = await repo.GetNextSortOrderAsync(connection, scopeId: 1);
// Move to position
await repo.MoveToPositionAsync(connection, entityId: 42, position: 2, scopeId: 1);
// Swap two entities
await repo.SwapAsync(connection, entityIdA: 1, entityIdB: 3, scopeId: 1);
// Bulk reorder
await repo.SetOrderAsync(connection, scopeId: 1, orderedIds: [3, 1, 2, 4]);
// Recalculate
await repo.RecalculateAsync(connection, scopeId: 1);
Operations Reference
Move to Position
Moves an entity to a specific 1-based position, shifting all entities between the old and new position.
// Move to position 1 (first)
await sortableService.MoveToPositionAsync(entity, 1);
// Move to position 3
await sortableService.MoveToPositionAsync(entity, 3);
Positions are clamped to [1, count]. Throws ArgumentOutOfRangeException for position < 1.
Before: [A(0), B(1), C(2), D(3)]
After MoveToPosition(D, 1): [D(0), A(1), B(2), C(3)]
Move to Start / End
Convenience wrappers around MoveToPositionAsync:
await sortableService.MoveToStartAsync(entity); // → position 1
await sortableService.MoveToEndAsync(entity); // → position count
Swap
Atomically exchanges the SortOrder of two entities:
await sortableService.SwapAsync(entityA, entityB);
Before: [A(0), B(1), C(2)]
After Swap(A, C): [C(0), B(1), A(2)] (queried by sort order)
Bulk Reorder (SetOrder)
Applies a full reorder from an ordered list of primary key values. Typically called after a drag-and-drop operation on the frontend:
// orderedIds represents the desired order from the UI
await sortableService.SetOrderAsync(
scopeId: projectId,
orderedIds: [id3, id1, id2, id4]);
After the call, entities are assigned SortOrder values StartAt, StartAt+1, StartAt+2, ... matching the order of orderedIds.
Recalculate
Rebuilds sequential sort order values for a scope, preserving the current relative ordering:
// After manual database modifications or deletions left gaps
await sortableService.RecalculateAsync(scopeId: projectId);
// Global (unscoped) recalculate
await sortableService.RecalculateAsync(scopeId: null);
Before (with gaps): [A(0), C(5), D(10)]
After Recalculate: [A(0), C(1), D(2)]
Configuration
SortableOptions controls global behaviour:
| Property | Default | Description |
|---|---|---|
StartAt |
0 |
The starting sort order value for the first item in an empty scope |
services.AddSortableKit(options =>
{
options.StartAt = 1; // 1-based sort order
});
SortableRepositoryOptions configures the Dapper repository per entity type:
| Property | Default | Description |
|---|---|---|
TableName |
Pluralized entity name | Database table name |
IdColumn |
"Id" |
Primary key column name |
SortOrderColumn |
"SortOrder" |
Sort order column name |
ScopeColumn |
null |
Scope column name (null = global ordering) |
Dialect |
SqlDialect.Sqlite |
SQL dialect for query generation |
License
MIT — Copyright (c) 2026 SortableKit Contributors
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.3)
- Microsoft.Extensions.Options (>= 10.0.3)
-
net8.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.3)
- Microsoft.Extensions.Options (>= 9.0.3)
-
net9.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.3)
- Microsoft.Extensions.Options (>= 9.0.3)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on SortableKit:
| Package | Downloads |
|---|---|
|
SortableKit.EntityFrameworkCore
Entity Framework Core integration for SortableKit — SaveChanges interceptor for automatic sort order assignment and ISortableService implementation. |
|
|
SortableKit.Dapper
Dapper integration for SortableKit — raw SQL sort order operations with dialect-aware query generation. |
|
|
SortableKit.AspNetCore
ASP.NET Core integration for SortableKit — DI registration helpers and builder extensions. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 160 | 3/26/2026 |