NCC Collections is a set of collection-based extensions and tools, shipped as two independent modules.
| Module | Package | What it does |
|---|---|---|
| Paginable | DotNetCore.Collections.Paginable + 9 ORM integrations |
Turns any IEnumerable<T> / IQueryable<T> into paged results, with keyset (cursor) paging and async support. |
| Multi | DotNetCore.Collections.Multi |
Multiset, multimap, composite-key and thread-safe/immutable collection types. |
See CHANGELOG.md for the full per-release history. Highlights of the current
6.3.0 release: BiDictionary<TLeft, TRight> (a strict one-to-one bijective map),
ReverseMultiDictionary<V, K> plus the MultiDictionary.AsReverse() live view, and nullable
reference annotations enabled across all eleven packages.
Paging over IEnumerable<T> and IQueryable<T>. The core library covers in-memory and queryable
sources; the nine ORM integration packages add a provider-specific GetPage / GetPageAsync
extension, so paging stays a single call on the source you already have.
Install-Package DotNetCore.Collections.Paginable
For a specific ORM, install the matching integration package — see ORM integrations.
Materialise a page from any sequence:
IEnumerable<ExampleModel> list = GetList();
// Page 15, 50 items per page.
var paginableList = list.ToPaginable(50);
var page = paginableList.GetPage(15);
for (var i = 0; i < page.CurrentPageSize; i++)
{
var itemNumber = page[i].ItemNumber;
var itemValue = page[i].Value;
}Or page the source directly, with no intermediate IPaginable<T>:
var page = GetList().GetPage(15, 50);IQueryable<T> sources (EF Core Where, NHibernate Query<T>, …) page the same way. The query is
translated to SQL, so only the requested slice is fetched:
IQueryable<ExampleModel> queryable = GetQueryable();
var page = queryable.GetPage(15, 50);
var totalMemberCount = page.TotalMemberCount;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 — 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.
When you already hold one page of data — a hand-written OFFSET / FETCH query, a cached page, or
an upstream API that answers with items plus totalCount — wrap it with Paginable.CreatePage /
fragment.ToPage. The fragment is taken to be the exact content of the named page and is never
re-sliced:
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 (global row number, as full-source paging would give)
page.GetMetadata(); // a serializable PageMetadata snapshotGetPage(source, …) and CreatePage(fragment, …) read alike but do opposite things: GetPage
takes the whole source and slices it (Skip + Take); CreatePage takes one already-sliced
page and only attaches metadata. Use Paginable.CreateSinglePageSet when the caller needs an
IPaginable<T> wrapping that single fragment.
Validation is eager: a null fragment throws ArgumentNullException; pageNumber < 1,
pageSize < 1, a negative totalMemberCount, or a page past the last page throw
ArgumentOutOfRangeException; a fragment longer than pageSize (or than the metadata expects)
throws ArgumentException. A fragment shorter than the metadata expects is tolerated by default
(an upstream row may have been deleted between the count and the fetch) and CurrentPageSize keeps
reporting the metadata value. Opt into strict checking — where a short fragment is more likely a bug
than a race — with PageCreationOptions.Strict:
var page1 = Paginable.CreatePage(items, info); // lenient (default)
var page2 = Paginable.CreatePage(items, info, PageCreationOptions.Strict); // short fragment throwsThe core library exposes ToPaginableAsync / GetPageAsync for in-memory and IQueryable<T>
sources. The EF Core, FreeSql and SqlSugar integrations provide true end-to-end async (CountAsync
ToListAsync, no synchronous database calls), withCancellationTokensupport:
using(var context = new ExampleDbContext())
{
var page = await context.ExampleModels
.GetPageAsync(pageNumber: 1, pageSize: 50, cancellationToken: ct);
var totalMemberCount = page.TotalMemberCount;
}Paginable.CreatePageAsync mirrors the fragment API but completes synchronously — the fragment
is already in memory, so validation throws from the call itself (catch it with try, not await)
and the CancellationToken is accepted for signature symmetry only.
PaginableSettingsManager holds a process-wide, validated settings snapshot. 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
};Every integration adds a GetPage (and, where the provider supports it, GetPageAsync) extension
on the source type you already use.
| Provider | Package | Extension on |
|---|---|---|
| Chloe | …Paginable.Chloe |
db.Query<ExampleModel>() |
| Dos.ORM | …Paginable.DosOrm |
_session.From<ExampleModel>() |
| FreeSql | …Paginable.FreeSql |
_freeSql.Select<ExampleModel>() |
| SqlSugar | …Paginable.SqlSugar |
_sqlSugar.Query<ExampleModel>() |
| NHibernate | …Paginable.NHibernate |
session.QueryOver<ExampleModel>() |
| EF6 | …Paginable.EntityFramework |
context.ExampleModels.Where(…) |
| EF Core | …Paginable.EntityFrameworkCore |
context.ExampleModels |
| SqlKata + Dapper | …Paginable.SqlKata |
db.Query("ExampleModels") |
A representative call (each of the above ends the same way):
var page = source.GetPage(1, 9); // where `source` is the receiver from the table
var totalPageCount = page.TotalPageCount;FreeSql and EF Core additionally expose the DbSet / DbContext directly:
// FreeSql — page a DbSet:
var ctx = _freeSql.CreateDbContext();
var page = ctx.Set<ExampleModel>().GetPage(1, 9);
// EF Core — page a DbSet:
using(var context = new ExampleDbContext())
{
var page = context.ExampleModels.GetPage(1, 9);
}DotNetCore.Collections.Multi ships in its own package and is independent of the paging
extensions. Every type carries the Multi prefix, but the families multiply different things
and are orthogonal to each other.
Install-Package DotNetCore.Collections.Multi
Read each 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.
| Type | Multiplies | Shape |
|---|---|---|
MultiList<T> |
elements | 1 element → N copies |
OrderedMultiList<T> |
elements, ordered | 1 element → N copies, sorted |
MultiDictionary<TKey, TValue> |
values | 1 key → N values |
OrderedMultiDictionary<TKey, TValue> |
values, ordered | 1 key → N values, sorted |
MultiKeyDictionary<TKey, TValue> |
key components | N components → 1 value |
TwoKeyDictionary<K1, K2, V> |
key components | 2 components → 1 value |
ThreeKeyDictionary<K1, K2, K3, V> |
key components | 3 components → 1 value |
BiDictionary<TLeft, TRight> |
nothing (bijective) | 1 left ↔ 1 right |
ReverseMultiDictionary<V, K> |
values, inverted | 1 value → N keys |
ImmutableMultiList<T> / ImmutableMultiDictionary<TKey, TValue> |
frozen | write-once |
ConcurrentMultiDictionary<TKey, TValue> |
values, thread-safe | 1 key → N values, sharded |
ConcurrentMultiList<T> |
elements, thread-safe | 1 element → N copies, single lock |
The quick decision list:
- elements repeat →
MultiList<T>; - elements repeat, in sorted order →
OrderedMultiList<T>; - values repeat under one key →
MultiDictionary<TKey, TValue>; - values repeat under one key, both axes sorted →
OrderedMultiDictionary<TKey, TValue>; - key components combine, one value per complete key →
MultiKeyDictionary<TKey, TValue>(orTwoKeyDictionary<K1, K2, V>/ThreeKeyDictionary<K1, K2, K3, V>for two or three differently typed components); - each left maps to exactly one right and each right back to exactly one left, both directions O(1)
→
BiDictionary<TLeft, TRight>; - many keys share one value and the question is "which keys hold this value?" → invert the map:
MultiDictionary<TKey, TValue>.AsReverse()for a live read-only view, orReverseMultiDictionary<V, K>for a self-contained snapshot.
The following sections describe each family.
MultiList<T> — a multiset (bag): duplicates matter and are counted. It implements the usual
set operations (UnionWith / IntersectionWith / ExceptWith / SymmetricExceptWith), subset and
superset judgments, Overlaps / IsDisjointFrom, multiset structural equality (Equals /
GetHashCode, via IEquatable<MultiList<T>>), copy-expanded enumeration, and an injectable
IEqualityComparer<T>.
OrderedMultiList<T> — the same bag semantics, plus an order. It is backed by a red-black tree
instead of a hash table, so add / lookup / remove cost O(log n) worst case while enumeration is
ascending. It adds GetFirst() / GetLast(), Reverse(), and GetRange(from, to). It takes an
IComparer<T> rather than an IEqualityComparer<T>, because ordering needs a comparison — and that
comparison is also what decides which elements are the same element.
MultiDictionary<TKey, TValue> — one key genuinely owns several values. It implements
IReadOnlyDictionary<TKey, IReadOnlyCollection<TValue>> and 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.
ContainsValue(value) and TotalValueCount are answered in O(1) from two caches kept in step on
the write path — neither ever scans the inner collections.
OrderedMultiDictionary<TKey, TValue> — the ordered counterpart. The same per-key value-set
operations, the same "no value-less key" invariant, and the same IReadOnlyDictionary /
AsLookup() / RemoveRange shape — but keys enumerate ascending under an IComparer<TKey> and
each key's values enumerate ascending under an IComparer<TValue>, with single-pair add / lookup /
removal costing O(log n) worst case on both axes.
MultiKeyDictionary<TKey, TValue> — the key is composite and you query it by a partial
prefix: a trie over (region, country, city)-style keys of any arity.
TwoKeyDictionary<K1, K2, V> — the same idea with exactly two components of different
types, with a typed indexer instead of a TKey[]. Its second axis is queried through a maintained
reverse index (K2 → set of K1), so GetBySecondKey / CountOfSecondKey /
ContainsSecondKey / RemoveBySecondKey visit only the requested slice.
ThreeKeyDictionary<K1, K2, K3, V> — the same idea with exactly three differently typed
components. The first axis is a prefix, so its slice (GetByFirstKey / CountOfFirstKey /
RemoveByFirstKey) is a trie walk; the second and third axes are deliberately not backed by an
index and scan O(n). Use MultiKeyDictionary<TKey, TValue> when an axis other than the first must be
queried hard, with the key order putting that axis first.
BiDictionary<TLeft, TRight> — a strict one-to-one map: every left value maps to exactly one
right value and no right value is shared by two lefts, with both directions answered in O(1) from
two indexes kept in step on every write path. Conflicts are strict — Add throws
ArgumentException when the right value is already bound, TryAdd reports instead, and there is
deliberately no silently-overwriting setter (break the old binding with an explicit Remove /
RemoveRight first). null is accepted on both sides through dedicated buckets; AsReverse()
returns a live read-only view of the right-to-left direction.
ReverseMultiDictionary<V, K> — the inverse of MultiDictionary<TKey, TValue>: a snapshot
mapping every stored value to the set of keys that hold it, so "which keys store this value?" is
answered in O(1). The constructor copies the bindings out of the source map into a self-contained
instance (safe to serialize), and the instance stays mutable on its own with the "no value-less key"
invariant mirrored. Member names mirror MultiDictionary with the axes swapped: Count /
ValueCount count distinct values, TotalKeyCount counts bindings, KeyCount(value) counts one
value's keys, ContainsValue(value) is the O(1) presence check and ContainsKey(key) scans. For a
read-only view that keeps answering from the live map, use MultiDictionary<TKey, TValue>.AsReverse().
ImmutableMultiList<T> / ImmutableMultiDictionary<TKey, TValue> — the immutable
counterparts of the two core types. An instance never changes, so any number of threads may read it
without locks. Mutations return a new instance (or the receiver itself when nothing would change);
bulk mutation goes through ToBuilder(), whose builder shares the source's state until its first
write (copy-on-write). Both round-trip through serializable models.
ConcurrentMultiDictionary<TKey, TValue> — the thread-safe counterpart of
MultiDictionary<TKey, TValue>. Keys are routed to shards, each an independent MultiDictionary
behind its own lock, so writes on different keys proceed in parallel. Whole-map reads (Count,
TotalValueCount, ContainsValue, enumeration, Snapshot()) take a consistent snapshot by locking
every shard once, in index order.
ConcurrentMultiList<T> — the thread-safe counterpart of MultiList<T>. Every operation is
serialized behind one lock — linearizable and trivially safe. It is deliberately not sharded: a
bag has one global state its operations compare against. For read-mostly workloads, prefer
ImmutableMultiList<T> plus a builder.
- Set vs. multiset arguments. The per-key operations of
MultiDictionary<TKey, TValue>treat their argument as a set (a repeated value does not count twice, matchingISet<T>), whereasMultiList<T>treats its argument as a multiset (multiplicities count). That convention also fixes the batch delete:RemoveRange(key, values)removes one occurrence per distinct argument value, so a value stored N times keeps N-1 copies — useExceptWith(key, values)when every occurrence must go. - Equality goes through a comparer, never hash codes alone, so hash collisions between distinct
elements/keys can not corrupt a collection. The hash-shaped types match with
IEqualityComparer<T>;OrderedMultiList<T>matches with itsIComparer<T>, where "compares equal" is "is the same element". nullhandling follows each type's shape.MultiList<T>andOrderedMultiList<T>supportnullelements (nullsorts first under the default comparer);MultiDictionary<TKey, TValue>rejectsnullkeys but allowsnullvalues; both trie types supportnullkey components;BiDictionary<TLeft, TRight>acceptsnullon both sides;ReverseMultiDictionary<V, K>mirrors its source map inverted.- None of these types is thread-safe — use the
Concurrent*orImmutable*counterparts for that guarantee.
// 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)
bag.ToSerializableModel(); // plain snapshot: Items + Counts (see "Save and restore")
// OrderedMultiList<T>: the same bag, kept sorted (red-black tree, O(log n) worst case)
var shelf = new OrderedMultiList<string> { "mug", "bean", "bean" };
foreach (var item in shelf) { /* "bean", "bean", "mug" */ }
shelf.GetFirst(); // "bean"
shelf.GetLast(); // "mug"
shelf.GetRange("a", "n"); // "bean", "bean" (both bounds included)
shelf.Reverse(); // "mug", "bean", "bean"
shelf.EntrySet(); // sorted (element, copies) pairs
// 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
map.ContainsValue(1001); // true — O(1) via a backwards value index, not a scan
map.TotalValueCount; // 2 — all values across all keys, cached
map.ToSerializableModel(); // plain snapshot: Keys + Values (see "Save and restore")
// OrderedMultiDictionary<K, V>: the ordered multimap (keys and values both sorted)
var index = new OrderedMultiDictionary<string, int>();
index.Add("orders", 1002);
index.Add("orders", 1001);
foreach (var order in index["orders"]) { /* 1001, 1002 — values ascending */ }
foreach (var key in index.Keys) { /* keys ascending */ }
index.ExceptWith("orders", new[] { 1001 }); // drops every occurrence; an emptied key is removed automatically
// 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 (a prefix walk over the trie)
rates.GetBySecondKey("USD"); // (1, 1.00m) — served from the second-axis reverse index
// ImmutableMultiList<T> / ImmutableMultiDictionary<K, V>: freeze, never mutate
var frozen = new ImmutableMultiList<string>(new[] { "a", "b" });
var grown = frozen.Add("c"); // returns a new instance; `frozen` is untouched
var builder = frozen.ToBuilder(); // shares state until the first write (copy-on-write)
builder.Add("d");
var frozen2 = builder.ToImmutable(); // a fresh instance; `frozen` is still exactly 2 elements
ReferenceEquals(frozen.ToBuilder().ToImmutable(), frozen); // true — untouched builder, same instance
// ConcurrentMultiDictionary<K, V>: same per-key semantics, sharded locks
var concurrent = new ConcurrentMultiDictionary<int, string>();
concurrent.Add(1, "a"); // locks only the shard that owns key 1
concurrent.TryGetValue(1, out var values);
var snapshot = concurrent.Snapshot(); // consistent whole-map view; enumeration is snapshot-based too
// ThreeKeyDictionary<K1, K2, K3, V>: the same idea for three differently typed components
var seats = new ThreeKeyDictionary<string, string, int, bool>();
seats["2026-09-11", "7A", 14] = true; // date, aircraft, row → occupied
seats.GetByFirstKey("2026-09-11"); // the whole day's slice — a prefix walk
// BiDictionary<L, R>: a strict one-to-one map, both directions O(1)
var users = new BiDictionary<int, string>();
users.Add(1, "alice");
users.Add(2, "bob");
users[1]; // "alice"
users.GetLeft("bob"); // 2
users.AsReverse()["bob"]; // 1 — live right-to-left view
users.TryAdd(3, "bob"); // false — "bob" is already bound to 2, reported instead of thrown
// users.Add(3, "bob"); // throws ArgumentException — break the old binding explicitly:
users.Remove(2); // frees "bob"
users.TryAdd(3, "bob"); // true
// ReverseMultiDictionary<V, K>: the inverted snapshot — which keys store this value?
var map = new MultiDictionary<string, int>();
map.Add("orders", 1001);
map.Add("customers", 1001);
map.Add("customers", 1002);
var inverted = new ReverseMultiDictionary<int, string>(map);
inverted[1001]; // ["orders", "customers"] — O(1)
inverted.KeyCount(1001); // 2
inverted.ContainsKey("orders"); // true — some value stores the key "orders"
inverted.TotalKeyCount; // 3 distinct (value, key) bindings
map.Add("invoices", 1001); // the snapshot does not follow the map ...
inverted[1001]; // still ["orders", "customers"]
map.AsReverse()[1001]; // ... but the live view does: ["orders", "customers", "invoices"]MultiList<T> and MultiDictionary<TKey, TValue> have an explicit serialization entry point:
ToSerializableModel() hands back a plain snapshot and FromModel() rebuilds the collection from
one. The models — MultiListModel<T> (Items + Counts, parallel lists) and
MultiDictionaryModel<TKey, TValue> (Keys + Values, parallel lists) — are ordinary mutable
classes with public settable properties and no serializer dependencies, so JSON
(System.Text.Json included), XML or a database row is the caller's choice:
var model = bag.ToSerializableModel();
string json = JsonSerializer.Serialize(model);
var typed = JsonSerializer.Deserialize<MultiListModel<string>>(json);
var restored = MultiList<string>.FromModel(typed, comparer); // pass the comparer backThe model carries data only — a comparer, and a multimap's inner-collection strategy, are
configuration rather than data, so they are supplied to FromModel(). Unlike ToDictionary()
(which throws on a null element) it represents null like any other element, and unlike the live
views it is a snapshot that does not move under a serializer's feet.
dotnet 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.
Publishing is automated by the GitHub Actions Release workflow
(.github/workflows/release.yml); no local tooling is involved. It runs on a version-tag push —
6.3.0 or v6.3.0 — and can also be started manually through workflow_dispatch:
- Pack — all 11 projects are packed in Release configuration into
nuget_pubon awindows-latestrunner, with the full git history fetched so SourceLink can attach sources to the deterministic build. - Authenticate — the workflow uses NuGet trusted publishing (OIDC) instead of a stored API
key. The
id-token: writepermission lets GitHub mint a short-lived OIDC token, which theNuGet/login@v1step exchanges for a temporary API key (one key per token, valid for roughly an hour, which is why the login step runs immediately before the push). - Push — every
.nupkgand.snupkgis pushed to nuget.org with--skip-duplicate.
Because publishing relies on trusted publishing, the workflow needs an exactly matching policy on nuget.org, configured outside the repository:
| Policy field | Value |
|---|---|
| Repository owner | dotnetcore |
| Repository | Collections |
| Workflow file | release.yml (file name only, no .github/workflows/ prefix) |
| Environment | (empty) |
| Secret | NUGET_USERNAME — the nuget.org profile name, not the e-mail |
The policy owner must own all 11 DotNetCore.Collections.* packages. See
Releasing for how the version being published is chosen.
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. Tag the commit with the matching
version (6.3.0 or v6.3.0) to trigger the automated publish; see Publishing for
what the release workflow does and the one-time nuget.org policy it requires.
Member project of The NCC, MIT