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.

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 page

PageCount 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.

Validation and exceptions

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.

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;
}

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.

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>.
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 strictAdd 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> (or TwoKeyDictionary<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.

Save and restore

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 back

The 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 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)
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

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

93 stars

Watchers

14 watching

Forks

Releases

Packages

Used by

Contributors

Languages