Skip to content

Repository files navigation

Collections

Member project of .NET Core Community GitHub license FOSSA Status

NCC Collections consists of a set of collection-based extensions and tools, such as paging extensions and multiset/multimap collections.

See CHANGELOG.md for what is new in each release, including the 6.0 modernization notes (keyset pagination, end-to-end async, expanded target frameworks and the rewritten Multi module).

Supported target frameworks

Package Target frameworks
DotNetCore.Collections.Paginable net451, net461, net47, net48, netstandard2.0, netstandard2.1, net6.0, net7.0, net8.0, net9.0, net10.0
DotNetCore.Collections.Paginable.Chloe net461, net47, net48, netstandard2.0, net6.0, net7.0, net8.0, net9.0, net10.0
DotNetCore.Collections.Paginable.DosORM netstandard2.1, net6.0, net7.0, net8.0, net9.0, net10.0
DotNetCore.Collections.Paginable.EntityFramework net451, net461, net47, net48, netstandard2.1, net6.0, net7.0, net8.0, net9.0, net10.0
DotNetCore.Collections.Paginable.EntityFrameworkCore net6.0, net7.0, net8.0, net9.0, net10.0
DotNetCore.Collections.Paginable.FreeSql net451, net461, net47, net48, netstandard2.0, netstandard2.1, net6.0, net7.0, net8.0, net9.0, net10.0
DotNetCore.Collections.Paginable.FreeSql.DbContext net451, net461, net47, net48, netstandard2.0, netstandard2.1, net6.0, net7.0, net8.0, net9.0, net10.0
DotNetCore.Collections.Paginable.NHibernate net461, net47, net48, netstandard2.0, netstandard2.1, net6.0, net7.0, net8.0, net9.0, net10.0
DotNetCore.Collections.Paginable.SqlKata net451, net461, net47, net48, netstandard2.0, netstandard2.1, net6.0, net7.0, net8.0, net9.0, net10.0
DotNetCore.Collections.Paginable.SqlSugar net451, net461, net47, net48, netstandard2.1, net6.0, net7.0, net8.0, net9.0, net10.0
DotNetCore.Collections.Multi net451, net461, net47, net48, netstandard2.0, netstandard2.1, net6.0, net7.0, net8.0, net9.0, net10.0

Nuget Packages

Package Name Version Downloads
DotNetCore.Collections.Paginable
DotNetCore.Collections.Multi
DotNetCore.Collections.Paginable.Chloe
DotNetCore.Collections.Paginable.DosOrm
DotNetCore.Collections.Paginable.EntityFrameworkCore
DotNetCore.Collections.Paginable.FreeSql
DotNetCore.Collections.Paginable.NHibernate
DotNetCore.Collections.Paginable.SqlKata
DotNetCore.Collections.Paginable.SqlSugar

Usage

Install the package

Install-Package DotNetCore.Collections.Paginable

Write code

IEnumerable<ExampleModel> list = GetList();//...

//Get a collection of Page, each page has 50 PageMembers
var paginableList = list.ToPaginable(50);

//Get page 15th
var page = paginableList.GetPage(15);

for (var i = 0; i < page.CurrentPageSize; i++)
{
    var itemNumber = page[i].ItemNumber;
    var itemValue = page[i].Value;
}

Or use a more streamlined code:

IEnumerable<ExampleModel> list = GetList();//...

//Get page 15th, each page has 50 items.
var page = list.GetPage(15, 50);

for (var i = 0; i < page.CurrentPageSize; i++)
{
    var itemNumber = page[i].ItemNumber;
    var itemValue = page[i].Value;
}

Work with IQueryable<T>

You can get IQueryable<T> from Where in EfCore or Query<T> in NHibernate, and then:

IQueryable<ExampleModel> queryable = GetQueryable();//...

var page = queryable.GetPage(15, 50);

var totalMemberCount = page.TotalMemberCount;

for(var i = 0; i < page.CurrentPageSize; i++)
{
    var itemNumber = page[i].ItemNumber;
    var itemValue = page[i].Value;
}

Just do it.

Work with ORMs

For Chloe ORM

Install DotNetCore.Collections.Paginable.Chloe package:

Install-Package DotNetCore.Collections.Paginable.Chloe

then:

//... do some config for Chloe by EntityTypeBuilder<ExampleModel>

using(var db = new MsSqlContext(connectionString))
{
    var page = db.Query<ExampleModel>().GetPage(15, 50);

    var totalPageCount = page.TotalPageCount;
    var totalMemberCount = page.TotalMemberCount;
    var pageSize = page.PageSize;

    var currentPageNumber = page.CurrentPageNumber;
    var currentPageSize = page.CurrentPageSize;

    var hasNext = page.HasNext;
    var HasPrevious = page.HasPrevious;

    for(var i = 0; i < currentPageSize; i++)
    {
        var id = page[i].Value.Id;
    }
}

For Dos.ORM

Install DotNetCore.Collections.Paginable.DosOrm package:

Install-Package DotNetCore.Collections.Paginable.DosOrm

then:

var _session = new DbSession(DatabaseType.SqlServer, connectionString);

var page = _dosOrmSession.From<ExampleModel>().GetPage(1, 9);

var totalPageCount = page.TotalPageCount;
//...

.
.
.

class ExampleModel : Entity
{
    public ExampleModel() : base("ExampleModels") { }

    public virtual int Id { get; set; }

    public override Field[] GetPrimaryKeyFields() => new Field[] { new Field("Id"), };
}

For FreeSql

Install DotNetCore.Collections.Paginable.FreeSql package:

Install-Package DotNetCore.Collections.Paginable.FreeSql

then:

var _freeSql = new FreeSql.FreeSqlBuilder()
    .UseConnectionString(DataType.SqlServer, connectionString)
    .UseAutoSyncStructure(false)
    .Build();

//... do some config for FreeSql

var page = _freeSql.Select<ExampleModel>().GetPage(1, 9);

var totalPageCount = page.TotalPageCount;
//...

or call the extension method of DbSet directly:

var ctx = _freeSql.CreateDbContext();
var source = ctx.Set<ExampleModel>();

var page = source.GetPage(1, 9);

var totalPageCount = page.TotalPageCount;
//...

or

using(var ctx = new ExampleDbContext())
{
    var page = ctx.ExampleModels.GetPage(1, 9);

    var totalPageCount = page.TotalPageCount;
    //...
}

.
.
.

class ExampleDbContext: DbContext
{
    public DbSet<ExampleModel> ExampleModel {get; set;}

    protected override void OnConfiguring(DbContextOptionsBuilder builder)
    {
        builder.UseFreeSql(_freeSqlInstance);
    }
}

For SqlSugar

Install DotNetCore.Collections.Paginable.SqlSugar package:

Install-Package DotNetCore.Collections.Paginable.SqlSugar

then:

var sqlSugar = new SqlSugarClient(new ConnectionConfig{
    ConnectionString = connectionString,
    DbType = DbType.SqlServer,
    IsAutoCloseConnection = true
});

//... do some config for sqlSugar

var page = _sqlSugar.Query<ExampleModel>().GetPage(1, 9);

var totalPageCount = page.TotalPageCount;
//...

For NHibernate

Install DotNetCore.Collections.Paginable.NHibernate package:

Install-Package DotNetCore.Collections.Paginable.NHibernate

then:

//... do some config for NHibernate by FluentNHibernate.ClassMap<ExampleModel>

using(var session = GetAndOpenSession())
{
    var page = session.QueryOver<ExampleModel>().GetPage(1, 9);

    var totalPageCount = page.TotalPageCount;
    //...
}

For Microsoft.EntityFrameworkCore

//... do come config for EFCore

using(var context = new ExampleDbContext())
{
    var page = context.ExampleModels.Where(x => x.Id > 100).GetPage(1, 9);

    var totalPageCount = page.TotalPageCount;
    //...
}

or call the extension method of DbSet directly:

Install DotNetCore.Collections.Paginable.EntityFrameworkCore package first:

Install-Package DotNetCore.Collections.Paginable.EntityFrameworkCore

then:

using(var context = new ExampleDbContext())
{
    var page = context.ExampleModels.GetPage(1, 9);

    var totalPageCount = page.TotalPageCount;
    //...
}
//...

Keyset (seek) pagination

Offset pagination degrades on deep pages because the database still scans the skipped rows. Keyset (a.k.a. seek / cursor) pagination replaces OFFSET n with a WHERE key > @lastKey predicate, so every page costs the same and the COUNT(*) round trip is avoided. It is the recommended mode for infinite-scroll and cursor-style APIs.

IQueryable<ExampleModel> queryable = GetQueryable();//...

// First page: no anchor key yet.
var first = queryable.GetFirstPageByKeyset(x => x.Id, pageSize: 50);

// Subsequent pages: pass the ordering key of the last row of the previous page.
var lastId = first.LastMember.Id;
var next = queryable.GetPageByKeyset(x => x.Id, lastId, pageSize: 50);

foreach (var item in next.Members) { /* ... */ }

var hasMore = next.HasNext; // resolved without COUNT(*)

GetFirstPageByKeyset / GetPageByKeyset also have IEnumerable<T> overloads for in-memory sources, and an optional descending switch for reverse ordering. Use keyset pagination when you do not need TotalPageCount / TotalMemberCount; use the offset APIs above when you do.

Create a page from a list fragment

Sometimes you already hold one page of data — a hand-written SQL query with OFFSET / FETCH, a cached page, or an upstream API that answers with items plus totalCount. There is no need to hand the library the whole source: Paginable.CreatePage wraps the fragment you have. The fragment is never re-sliced — it is taken to be the exact content of the page you name.

var items = connection.Query<Order>(sql, new { offset = 10, fetch = 5 });  // 5 rows
var total  = connection.ExecuteScalar<int>(countSql);                      // 12

IPage<Order> page = Paginable.CreatePage(items, pageNumber: 3, pageSize: 5, totalMemberCount: total);

page.TotalPageCount;   // 3
page.CurrentPageSize;  // 2   (a short last page)
page.HasNext;          // false
page[0].ItemNumber;    // 11  (the global row number, exactly as full-source paging would give)
page.GetMetadata();    // a serializable PageMetadata snapshot

The PageFragmentInfo overload suits metadata that arrives on its own, ToPage is the same thing as an extension method, and the metadata converts both ways:

// Metadata from an upstream service or a cache entry.
var info = new PageFragmentInfo(pageNumber: 3, pageSize: 5, totalMemberCount: 12);
var page1 = Paginable.CreatePage(items, info);

// Sugar: this sequence already *is* one page.
var page2 = items.ToPage(pageNumber: 3, pageSize: 5, totalMemberCount: 12);

// Round trip from a page that already exists.
var info2 = PageFragmentInfo.FromMetadata(existingPage.GetMetadata());

GetPage and ToPage read alike but do opposite things, so keep them apart:

Input Slices? Use when
source.GetPage(pageNumber, pageSize) the whole source yes (Skip + Take) you have the full result set and want one page out of it
Paginable.CreatePage(fragment, …) / fragment.ToPage(…) one already-sliced page no the page is already in hand and only the metadata has to be attached

Validation is eager: a null fragment throws ArgumentNullException; pageNumber < 1, pageSize < 1, a negative totalMemberCount, a count above MaxMemberItems, or a page number past the last page throw ArgumentOutOfRangeException; a fragment carrying more members than pageSize — or more than the metadata says the page holds — throws ArgumentException. A fragment that is shorter than the metadata expects is tolerated (an upstream row may have been deleted between the count and the fetch) and CurrentPageSize keeps reporting the metadata value. The total count must be known: when it is not, use the keyset API above rather than inventing a number.

Asynchronous paging

The core library exposes ToPaginableAsync / GetPageAsync for in-memory and IQueryable<T> sources, and the EF Core, FreeSql and SqlSugar integrations provide true end-to-end async (CountAsync + ToListAsync, no synchronous database calls) with CancellationToken support.

using(var context = new ExampleDbContext())
{
    var page = await context.ExampleModels
        .GetPageAsync(pageNumber: 1, pageSize: 50, cancellationToken: ct);

    var totalMemberCount = page.TotalMemberCount;
}

Configuration

PaginableSettingsManager holds a process-wide settings snapshot. Values are validated on assignment, so any instance handed out by the library is always in a valid state — configure it once at startup and treat it as read-only afterwards.

PaginableSettingsManager.Settings = new PaginableSettings
{
    DefaultPageSize = 50,          // must be >= 1
    MaxMemberItems = 10_000_000    // must be >= 1
};

For SqlKata with Dapper

Install DotNetCore.Collections.Paginable.SqlKata package:

Install-Package DotNetCore.Collections.Paginable.SqlKata

then:

using(var connection = new SqlConnection(connectionString))
{
    connection.Open();

    var compiler = new SqlServerCompiler();
    var db = new QueryFactory(connection, compiler);

    var page = db.Query("ExampleModels").GetPage<ExampleModel>(1, 9);

    var totalPageCount = page.TotalCount;
    //...
}

Examples

MultiSet, MultiDictionary & MultiKeyDictionary

DotNetCore.Collections.Multi is independent of the paging extensions and ships in its own package. Every type it exposes shares the Multi prefix, but the three core types multiply three different things and are orthogonal to each other.

The three "multi" types at a glance

Type What repeats Shape Lookup Reach for it when
MultiList<T> elements 1 element → N copies CountOf(element) You need multiset (bag) semantics: duplicates matter and must be counted. Supports UnionWith / IntersectionWith / ExceptWith / SymmetricExceptWith, subset & superset judgments, Overlaps / IsDisjointFrom, multiset structural equality (Equals / GetHashCode, via IEquatable<MultiList<T>>), copy-expanded enumeration and injectable IEqualityComparer<T>.
MultiDictionary<TKey, TValue> values 1 key → N values this[key] One key genuinely owns several values — a multimap. Implements IReadOnlyDictionary<TKey, IReadOnlyCollection<TValue>>, offers AsLookup() (an ILookup view), the per-key value set operations UnionWith / IntersectionWith / ExceptWith / SymmetricExceptWith, the batch pair AddRange / RemoveRange, per-key counting via ValueCount(key) (alongside TotalValueCount), and a configurable inner-collection factory (allowDuplicateValues or a custom factory).
MultiKeyDictionary<TKey, TValue> key components N components → 1 value this[TKey[]], GetByPrefix The key is composite and you want to query it by a partial prefix — a trie over (region, country, city) style keys of any arity.
TwoKeyDictionary<K1, K2, V> key components 2 components → 1 value this[k1, k2] Exactly the above with exactly two components of different types, with a typed indexer instead of a TKey[].

Read the name as "what is multiplied": MultiList multiplies elements, MultiDictionary multiplies values, MultiKeyDictionary multiplies keys. Pick by asking what is allowed to repeat, never by name similarity:

  • elements repeat → MultiList<T>;
  • values repeat under one key → MultiDictionary<TKey, TValue>;
  • key components combine, and exactly one value is stored per complete key → MultiKeyDictionary<TKey, TValue> (or TwoKeyDictionary<K1, K2, V> for two differently typed components).

In particular, do not expect MultiDictionary<A, B> to answer "everything for B": it maps one key to many values, not many keys to one value. Looking a composite key up by one of its components is the trie's job — MultiKeyDictionary<TKey,TValue>.GetByPrefix (any arity) or TwoKeyDictionary<K1,K2,V>.GetByFirstKey / GetBySecondKey (arity 2).

One multiplicity convention is worth knowing before mixing the two dictionary-shaped types: the per-key operations of MultiDictionary<TKey, TValue> all treat their argument as a set (a repeated value in the argument does not count twice, matching ISet<T>), whereas MultiList<T> treats its argument as a multiset (multiplicities count, and SymmetricExceptWith keeps the absolute difference of the copy counts).

That set convention also fixes what the batch delete means: RemoveRange(key, values) removes one occurrence per distinct argument value, exactly like calling Remove(key, value) once per distinct value — so a value stored N times keeps N-1 copies. Use ExceptWith(key, values) when every occurrence must go. The batch form is named RemoveRange rather than being an overload Remove(key, IEnumerable<V>) on purpose: with the overload, the documented map.Remove(key, null) (removing a stored null value) would become ambiguous at compile time, because null converts to both TValue and IEnumerable<TValue>.

All four types ship in DotNetCore.Collections.Multi and target the same frameworks as the package (see the matrix above). Equality always goes through IEqualityComparer (never hash codes alone), so hash collisions between distinct elements/keys can not corrupt a collection. null handling follows the shape of each type: MultiList<T> supports null elements, MultiDictionary<TKey, TValue> rejects null keys but allows null values, and both trie types support null key components. None of the types is thread-safe.

Install the package

Install-Package DotNetCore.Collections.Multi

Write code

// MultiList<T>: a bag counting occurrences
var bag = new MultiList<string> { "apple", "apple", "banana" };
bag.CountOf("apple");      // 2
bag.TotalCount;            // 3
bag.UnionWith(new[] { "apple", "cherry" });
bag.IsSupersetOf(new[] { "banana" }); // true
bag.Equals(new MultiList<string> { "banana", "apple", "apple" }); // true (bag equality, any order)

// MultiDictionary<K, V>: one key, many values
var map = new MultiDictionary<string, int>();
map.Add("orders", 1001);
map.Add("orders", 1002);
foreach (var order in map["orders"]) { /* 1001, 1002 */ }
var lookup = map.AsLookup();          // LINQ-friendly ILookup view
map.ValueCount("orders");             // 2 (0 for a missing key, never throws)
map.AddRange("orders", new[] { 1003, 1004 });
map.RemoveRange("orders", new[] { 1002, 1003 }); // batch delete, set semantics

// MultiKeyDictionary<K, V>: many key components, one value (a trie)
var tree = new MultiKeyDictionary<string, int>();
tree.Add(new[] { "eu", "de", "berlin" }, 1);
tree.Add(new[] { "eu", "de", "munich" }, 2);
tree.Add(new[] { "eu", "fr", "paris" }, 3);

tree[new[] { "eu", "de", "berlin" }];             // 1        (exact key lookup)
tree.CountOfPrefix(new[] { "eu", "de" });         // 2        (prefix projection)
foreach (var e in tree.GetByPrefix(new[] { "eu" }, relative: true))
{
    // e.Key is the *suffix*: ["de","berlin"], ["de","munich"], ["fr","paris"]
}
tree.RemovePrefix(new[] { "eu", "de" });          // drops the whole subtree at once

// TwoKeyDictionary<K1, K2, V>: the same idea for two differently typed components
var rates = new TwoKeyDictionary<int, string, decimal>();
rates[1, "USD"] = 1.00m;
rates[1, "EUR"] = 0.92m;
rates.CountOfFirstKey(1);             // 2
rates.GetBySecondKey("USD");          // (1, 1.00m) — O(n) scan, see the XML docs

Examples

Building and testing

dotnet build DotNetCore.Collections.sln -c Release

dotnet test tests/DotNetCore.Collections.Paginable.Tests -c Release
dotnet test tests/DotNetCore.Collections.Multi.Tests      -c Release

The unit tests run offline. The integration tests in tests/DotNetCore.Collections.Paginable.DbTests need a SQL Server instance and read their connection string from the PAGINABLE_DBTESTS_CONNECTION_STRING environment variable; on CI they run against a SQL Server 2022 service container.

Two GitHub Actions workflows gate the dev and master branches:

  • paginable-tests.yml — builds all 11 TFMs, verifies packing (including .snupkg), then runs the unit tests and the SQL Server integration tests.
  • multi-tests.yml — builds, packs and tests DotNetCore.Collections.Multi.

Releasing

Versions are driven by build/version.props, which is the single source of truth for every package — bump the version there and all 11 packages follow.

Publishing to nuget.org is automated by the GitHub Actions Release workflow (.github/workflows/release.yml): pushing a tag such as 6.0.0 (or v6.0.0) packs all 11 projects and pushes every .nupkg / .snupkg with the key stored in the NUGET_API_KEY repository secret.

For a local fallback, run scripts\Publish.bat, which packs the same 11 projects and pushes them with a key taken from the NUGET_API_KEY environment variable (or from an interactive prompt).

License

Member project of The NCC, MIT

FOSSA Status

About

Utilities and extensions for Collections includes Collections.Paginable and so on...

Topics

Resources

Stars

92 stars

Watchers

14 watching

Forks

Releases

Packages

Used by

Contributors

Languages