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).
| 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 |
Install-Package DotNetCore.Collections.Paginable
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;
}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.
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;
}
}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"), };
}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);
}
}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;
//...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;
//...
}//... 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;
//...
}
//...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.
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 snapshotThe 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.
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;
}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
};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;
//...
}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.
| 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>(orTwoKeyDictionary<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-Package DotNetCore.Collections.Multi
// 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 docsdotnet build DotNetCore.Collections.sln -c Release
dotnet test tests/DotNetCore.Collections.Paginable.Tests -c Release
dotnet test tests/DotNetCore.Collections.Multi.Tests -c ReleaseThe 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 testsDotNetCore.Collections.Multi.
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).
Member project of The NCC, MIT