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.
That short-fragment tolerance is the 6.1 default and is configurable since 6.2: when a short
fragment is more likely a bug than a race — a stale totalMemberCount, a fragment sliced by the
wrong query — opt into strict checking with PageCreationOptions.Strict:
// default (lenient): a short fragment stays buildable, CurrentPageSize reports the metadata value
var page1 = Paginable.CreatePage(items, info);
// strict: the same situation throws ArgumentException naming the fragment
var page2 = Paginable.CreatePage(items, info, PageCreationOptions.Strict);
var page3 = items.ToPage(pageNumber: 3, pageSize: 5, totalMemberCount: 12, PageCreationOptions.Strict);Only the short-fragment behaviour differs: the over-long checks throw in both modes, the
overloads without an options parameter keep the lenient behaviour, and a null options argument
is rejected like any other.
When the caller's side wants an IPaginable<T> — or an IEnumerable<IPage<T>> — rather than a
single page, Paginable.CreateSinglePageSet wraps the same fragment in a one-page set:
PaginableSinglePage<Order> set = Paginable.CreateSinglePageSet(items, info);
set.PageCount; // 1 the set holds exactly the page you handed it
set.MemberCount; // 12 source-wide, as the page reports it
set.GetPage(1).CurrentPageNumber; // 3 the page keeps its global number
set.GetPage(2); // ArgumentOutOfRangeException: the set has one pagePageCount is always one, and deliberately not the wrapped page's own TotalPageCount: a set
has to be able to serve every page it claims, and this one physically holds a single page — there is
no source to slice the others out of. The set layer answers "how many pages am I handing you"; the
page inside keeps the source-wide numbering, so a fragment of page 3 of 12 still reports itself as
page 3 of 12. MemberCount follows PaginableSetBase<T> and reports the member count of the whole
source. The factory takes the same metadata and the same PageCreationOptions switch as
CreatePage, and returns the concrete PaginableSinglePage<T> rather than the bare interface, so
that PageCount is readable at all — IPaginable itself exposes only PageSize and MemberCount.
Every entry point rejects an out-of-range argument with ArgumentOutOfRangeException and names the
parameter that was wrong (ex.ParamName):
| Entry point | Rejected when | Thrown |
|---|---|---|
GetPage / GetPageAsync — all nine ORM integrations and the core IEnumerable<T> / IQueryable<T> / Task<IQueryable<T>> paths |
pageNumber < 1, pageSize < 1, or pageNumber points past the last page |
ArgumentOutOfRangeException |
GetFirstPageByKeyset / GetPageByKeyset (and the EF Core …Async pair) |
pageSize < 1 |
ArgumentOutOfRangeException |
ToPaginable / ToPaginableAsync |
pageSize < 1 |
ArgumentOutOfRangeException |
Paginable.CreatePage / fragment.ToPage / PageFragmentInfo |
see the fragment section above | ArgumentOutOfRangeException, plus ArgumentException for an over-long fragment |
Paginable.CreateSinglePageSet |
the same rules as CreatePage (its arguments are forwarded), plus pageNumber != 1 on GetPage |
ArgumentOutOfRangeException, plus ArgumentException for an over-long fragment |
Paginable.CreatePageAsync |
the same rules as CreatePage, thrown synchronously rather than through a faulted task |
ArgumentOutOfRangeException, plus ArgumentException for an over-long fragment |
The GetPage and keyset families used to throw IndexOutOfRangeException for these, which made the
same mistake (pageNumber: 0) report a different type depending on which API the caller used. As of
6.2 the whole family reports ArgumentOutOfRangeException too, so one catch (ArgumentException)
covers both input shapes. An empty source is not an out-of-range argument — it yields a single
empty page when the page number and the page size are valid.
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;
}Paginable.CreatePageAsync gives the fragment API the same shape so an awaitable path can be
awaited end to end. It completes synchronously: the fragment is already in memory, so the
returned task is already finished by the time it is handed back, and nothing about it touches I/O.
It says so rather than pretending otherwise — use a provider-specific async extension when real I/O
has to be awaited.
// the fragment is already in hand: the await here is for shape, not for I/O
IPage<Order> page = await Paginable.CreatePageAsync(items, pageNumber: 3, pageSize: 5, totalMemberCount: 12);Two consequences follow from Task.FromResult and are worth knowing, because they are the opposite
of what an …Async name usually implies: validation throws synchronously, from the call itself
rather than through a faulted task (so a try around the call catches it, an await would not), and
the CancellationToken parameter is accepted for signature symmetry but never observed. Both match
the ToPaginableAsync / GetPageAsync shape the core library has had since 6.0.
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>. |
OrderedMultiList<T> |
elements, in order | 1 element → N copies, sorted | CountOf(element) |
The same bag semantics as MultiList<T>, plus an order. Backed by a red-black tree instead of a hash table, so adding, looking up and removing cost O(log n) worst case while enumeration is ascending. Adds GetFirst() / GetLast(), Reverse(), and GetRange(from, to) for range queries. 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> |
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). 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> |
values, in order | 1 key → N values, both axes sorted | this[key] |
The ordered counterpart of MultiDictionary<TKey, TValue>: the same per-key value-set operations with the same set semantics, 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> |
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[]. Its second axis is queried through a maintained reverse index (K2 → set of K1), so GetBySecondKey / CountOfSecondKey / ContainsSecondKey / RemoveBySecondKey visit only the requested slice instead of scanning the map. |
ThreeKeyDictionary<K1, K2, K3, V> |
key components | 3 components → 1 value | this[k1, k2, k3] |
The same idea with exactly three differently typed components, following the same axis-tag scheme. 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 here — their slices scan, O(n), and the remarks say so. 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> |
nothing — both sides are unique | 1 left ↓ 1 right (bijective), both directions O(1) | this[left], GetLeft(right) |
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 to a different left, TryAdd reports instead of throwing, and there is deliberately no silently-overwriting setter, because overwriting would silently unbind an entry the caller never mentioned: break an existing 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. |
| ImmutableMultiList<T> / ImmutableMultiDictionary<TKey, TValue> | elements / values, frozen | as the mutable type, but write-once | any read member | 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) and whose ToImmutable() hands back the very source instance while untouched — structural sharing you can assert with ReferenceEquals. Both round-trip through the serializable models (ToSerializableModel / FromModel). |
| ConcurrentMultiDictionary<TKey, TValue> | values, thread-safe | 1 key → N values, sharded | this[key] | 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; enumeration is over a snapshot and immune to concurrent writes. |
| ConcurrentMultiList<T> | elements, thread-safe | 1 element → N copies, single lock | CountOf(element) | The thread-safe counterpart of MultiList<T>: every operation is serialized behind one lock — linearizable and trivially safe. Deliberately not sharded: a bag has one global state its operations compare against. Enumeration is over a snapshot. For read-mostly workloads prefer ImmutableMultiList<T> plus a builder. |
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>; - elements repeat, in sorted order →
OrderedMultiList<T>; - values repeat under one key →
MultiDictionary<TKey, TValue>; - values repeat under one key, keys and values both kept sorted →
OrderedMultiDictionary<TKey, TValue>; - key components combine, and exactly one value is stored per complete key →
MultiKeyDictionary<TKey, TValue>(orTwoKeyDictionary<K1, K2, V>/ThreeKeyDictionary<K1, K2, K3, V>for two or three differently typed components). - nothing repeats — each left maps to exactly one right and each right back to exactly one left, and both directions are first-class O(1) lookups →
BiDictionary<TLeft, TRight>.
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).
MultiList<T> and OrderedMultiList<T> are the same multiset with two different storage engines, and the difference shows up in exactly one place: the comparer. MultiList<T> takes an IEqualityComparer<T> and promises nothing about enumeration order; OrderedMultiList<T> takes an IComparer<T> and is defined by it, because a red-black tree has to know which of two elements comes first and uses "the comparison returns 0" as its notion of "the same element". Two elements that compare equal therefore share one node and one copy count, and the element that is stored is the one added first. Ordering says nothing about null by itself: Comparer<T>.Default sorts null below every reference, so under the default comparer a null element is simply the smallest one, while a custom comparer may put it last or reject it outright.
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>.
A multiset argument is read in place. Every set operation and subset/superset judgment of MultiList<T> — and its Equals — reads a MultiList<T> argument directly instead of copying it first, so a chained a.UnionWith(b) allocates nothing, and the three mutating operations (IntersectionWith / ExceptWith / SymmetricExceptWith) only ever pay for one small staging buffer that is reused across calls. An argument of any other shape (an array, a LINQ sequence, a different MultiList<T> whose comparer is not equivalent) is still counted once first, because its multiplicities have to be known before the operation can define its result.
All six types ship in DotNetCore.Collections.Multi and target the same frameworks as the package (see the matrix above). Equality always goes through a comparer, never through hash codes alone, so hash collisions between distinct elements/keys can not corrupt a collection: the hash-shaped types match with IEqualityComparer<T>, while OrderedMultiList<T> matches with its IComparer<T>, where "compares equal" is "is the same element". null handling follows the shape of each type: MultiList<T> and OrderedMultiList<T> support null elements (null sorts first under the default comparer), MultiDictionary<TKey, TValue> rejects null keys but allows null values, both trie types support null key components, and BiDictionary<TLeft, TRight> accepts null on both sides through dedicated buckets. None of the types is thread-safe.
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, no attributes and no interface implementations, so the library takes no dependency on any serializer. JSON (System.Text.Json included), XML, a database row or anything else 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 not part of it and are supplied to FromModel() — a round trip is only as faithful as the comparer passed back in. The model is also the export that always works. Unlike ToDictionary() (which throws when a multiset holds a null element, because a null can not be a dictionary key) it represents null like any other element, and unlike AsReadOnly() and ToDictionary()'s inner collections it is a snapshot rather than a live view, so it does not move under a serializer's feet.
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)
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"); // truedotnet 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