Skip to content

Repository files navigation

Collections

Member project of .NET Core Community GitHub license FOSSA Status

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.

Contents

NuGet Packages

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

Paginable

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.

Installation

Install-Package DotNetCore.Collections.Paginable

For a specific ORM, install the matching integration package — see ORM integrations.

Quick start

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

Paging an IQueryable<T>

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;

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

Paging an existing fragment

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 snapshot

GetPage(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 throws

Asynchronous paging

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

Configuration

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

ORM integrations

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

Examples

Multi

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.

Installation

Install-Package DotNetCore.Collections.Multi

Choosing a type

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> (or TwoKeyDictionary<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, or ReverseMultiDictionary<V, K> for a self-contained snapshot.

The following sections describe each family.

Multisets

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.

Multimaps

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.

Composite keys

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.

One-to-one and inverted

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 strictAdd 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().

Immutable and thread-safe

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.

Conventions

  • 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, matching ISet<T>), whereas MultiList<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 — use ExceptWith(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 its IComparer<T>, where "compares equal" is "is the same element".
  • null handling follows each type's shape. 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; BiDictionary<TLeft, TRight> accepts null on both sides; ReverseMultiDictionary<V, K> mirrors its source map inverted.
  • None of these types is thread-safe — use the Concurrent* or Immutable* counterparts for that guarantee.

Usage

// 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"]

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 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 back

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

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.

Publishing

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:

  1. Pack — all 11 projects are packed in Release configuration into nuget_pub on a windows-latest runner, with the full git history fetched so SourceLink can attach sources to the deterministic build.
  2. Authenticate — the workflow uses NuGet trusted publishing (OIDC) instead of a stored API key. The id-token: write permission lets GitHub mint a short-lived OIDC token, which the NuGet/login@v1 step 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).
  3. Push — every .nupkg and .snupkg is 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.

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

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