SortableKit 1.0.0

dotnet add package SortableKit --version 1.0.0
                    
NuGet\Install-Package SortableKit -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SortableKit" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SortableKit" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="SortableKit" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SortableKit --version 1.0.0
                    
#r "nuget: SortableKit, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SortableKit@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SortableKit&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=SortableKit&version=1.0.0
                    
Install as a Cake Tool

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

# 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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