All notable changes to the DotNetCore.Collections packages are documented here.
Versions follow Semantic Versioning; every package in this
repository ships the same version (see build/version.props).
-
ToPagesAsync, a stream of pages. Any paged source now streams as anIAsyncEnumerable<IPage<T>>, so pages can be consumed one at a time instead of being materialised as a set:await foreach (var page in list.ToPagesAsync(50, ct)). The stream is lazy — page N is materialised when the consumer moves to it, nothing is prefetched, andbreakstops the walk where it stands — and cancellation is observed between pages, so an already cancelled token stops the stream before the first page is fetched. Pages come from the same cached page sets the synchronous API uses, so a page read during a stream is the same page read again afterwards.Three public methods join the surface, on one new public type:
ToPagesAsynconIPaginable<T>, plus the twoIEnumerable<T>overloads (configured default page size, and an explicit page size). The existing single-page API is untouched — this is not a rewrite ofGetPageAsync/ToPaginableAsync— and argument validation stays synchronous, exactly like theGetPagefamily: the exceptions leave the call itself, not a faulted task and not the firstMoveNextAsync. -
A real asynchronous page fetch when streaming a query. The same
ToPagesAsyncname is also defined by the Entity Framework Core, FreeSql and SqlSugar integrations on their own query types, where the fetch itself can be asynchronous: oneCountAsyncfor the whole walk, then oneToListAsync— or the provider's equivalent — per page, with the token observed before every provider call. A page set built withToPaginableAsyncstreams the same way and reuses the member count it already knows, so N pages cost N queries rather than N + 1; a consumer that reads two of ten pages issues three round trips, not eleven. Pages from this tier are built from each page's materialised members — equal in content and metadata to the page set's own, but not its cached instances. The plain page-set andIEnumerable<T>overloads stay what they were, an asynchronous shape over a synchronous fetch, and the README states which overload is which.This is the same mechanism the package already uses for
GetPageAsync— same name, more specific receiver, identical parameter types — so a call site upgrades to the asynchronous implementation by referencing an integration, without changing a character. The page size is typedinton both tiers on purpose: a nullable one would makequery.ToPagesAsync(50)ambiguous between the better receiver and the better argument conversion. Nothing was removed, retyped or resealed; the page sets gained three read-only properties (QueryableonPaginableQueryable<T>andPaginableSqlSugarQuery<T>,QueryableplusIncludeNestedMembersonPaginableFreeSqlQuery<T>) so the asynchronous fetch can use the very query the set pages over. -
IAsyncEnumerable<T>on every target framework butnet451. The type is in-box fromnetstandard2.1and from .NET 6 upwards. The four lower assets (net461/net47/net48/netstandard2.0) take it fromMicrosoft.Bcl.AsyncInterfaces, which those assets ofDotNetCore.Collections.Paginablenow depend on — pinned to 1.1.1, the lowest-floor release that still carries a completenet461asset (IAsyncEnumerable<T>,IAsyncEnumerator<T>,EnumeratorCancellationAttribute,ConfiguredCancelableAsyncEnumerable<T>). The last release with anet461asset at all is 6.0.0; it disappears from 7.0.0 upwards. Onnet451there is no reachableIAsyncEnumerable<T>at all (the polyfill has nonet451asset, and that generation caps atnetstandard1.2), so the streaming face is compiled out there:net451keeps the synchronous face unchanged, and no packed target lost a member it had. -
A documented, asserted asynchronous seam across the nine integrations. The availability of an async page fetch, and whether it accepts a
CancellationToken, now differs by integration and is stated in one table in the README: Entity Framework Core and FreeSql forward the token to the provider; SqlSugar declares one and observes it before each call, because 5.1.3 exposes no token overload to forward to; FreeSql.DbContext, NHibernate and SqlKata expose an async entry without a token; Chloe, Dos.ORM and Entity Framework 6 have no async entry at all. The table is backed by tests rather than prose — a signature matrix over the nine adapter assemblies plus, for the integrations that can run without a server, runtime cases on EF Core's in-memory provider. Nothing in the suite contacts a database, so it runs in the same gate as the unit tests.
-
None. No source or binary contract changed. The evidence:
- No packed project lost anything; two project files changed, both additively.
DotNetCore.Collections.Paginable.csprojgains one conditionedPackageReference(Microsoft.Bcl.AsyncInterfaces1.1.1, onnet461/net47/net48/netstandard2.0only) — the polyfill the new streaming face needs on targets where the type is not in-box. It is a new dependency, not a changed or removed one: no existing reference moved, no target framework was dropped, noPackageIdor pack item changed, and the assets abovenetstandard2.1keep takingIAsyncEnumerable<T>from the box.DotNetCore.Collections.Paginable.SqlSugar.csprojgains a build-time<NoWarn>forNETSDK1206, a dependency-side warning fromSQLitePCLRaw's RID graph that never affects the emitted IL or the package contents — verified: the packed assembly still reports7.4.0.0after that commit. - Nothing was deleted or renamed under
src/.git diff 7.4.0..HEAD --diff-filter=Dand--diff-filter=Rare both empty oversrc/. - Not one line of source was removed. The seven
.csfiles that changed undersrc/are all additions — 753 inserted lines, 0 deleted. The 86 deleted lines elsewhere undersrc/are all insidepackages.lock.jsonfiles, regenerated lock entries for the new dependency edge, not source. - Every new member is additive. One new public type (
AsyncPageExtensions) and three integration-localAsyncPageStreamExtensionsclasses join the surface, withToPagesAsyncoverloads and three read-only page-set properties (QueryableonPaginableQueryable<T>andPaginableSqlSugarQuery<T>,QueryableplusIncludeNestedMembersonPaginableFreeSqlQuery<T>); none overrides, shadows or re-signs an existing member. The existingGetPage/GetPageAsync/ToPaginable/ToPaginableAsyncsignatures are untouched. - Existing
System.Linqcode binds exactly as before. Nothing was injected intoSystem.Linqand the engine's query face was not touched in this release. The source-level binding tests are still in place and still pass: the same-name operators bind to the engine on a queryable receiver and still bind to the BCL on a sequence receiver, on every target framework.
One thing to check when upgrading, though it is not a code change: the four lower assets (
net461/net47/net48/netstandard2.0) ofDotNetCore.Collections.Paginablenow carry a transitive dependency onMicrosoft.Bcl.AsyncInterfaces1.1.1. If your project pins that package to a different version, NuGet will unify to the higher of the two; if you deliberately kept it off the graph entirely, that is no longer possible on those four assets.net451and thenetstandard2.1/net6.0+ assets are unaffected.ToPaginableAsync(50)on anIQueryable<T>receiver is already ambiguous with the Entity Framework Core adapter present (intvsint?page size) — that predates this release and is unchanged; omit the page size or construct the page set directly. - No packed project lost anything; two project files changed, both additively.
-
A translatable query face. The engine now extends
IQueryable<T>alongside the value-enumerable arms, so a query built on an ORM's queryable can be described once and rendered by an adapter instead of being pulled into memory.AsCollectionQueryable()enters the face from either a queryable or a plain sequence,ToQueryModel()returns the provider-agnostic model, and the operators the BCL does not own —DistinctBy,Order/OrderDescendingandCountBy— join the operators the subset covers: filtering, projection, paging, grouping, joining, ordering and the five aggregates.The subset is closed on purpose. An operator outside it fails with
NotSupportedExceptionnaming the operator and pointing atAsEnumerable(), rather than being bent into a translation it does not have:Skip/Taketake a count,GroupByis key-only or key-plus-element, aJointakes a closed-over inner sequence, and the comparer overloads stay on the value-enumerable face. -
QueryModel, the translated form of a query. A query becomes an ordered list of stages plus an optional terminal aggregate, each stage carrying the lambda expressions it was built from — which is what an adapter renders. The engine also ships a reference executor that replays a model in memory, so a translation is verifiable without an ORM in the loop. -
IQueryable<T>as a documented receiver. The engine's three keyed operators and the two entry points now take a queryable receiver. The BCL extendsIEnumerable<T>, so for a queryable receiver the engine's candidate is strictly better — an identity conversion against an interface conversion — and the call resolves to the engine even on targets whoseEnumerablehas since grown a member of the same name (DistinctByin .NET 6,Orderin .NET 7,CountByin .NET 9). -
QueryModel.AsQueryable(), which turns a model back into a provider's own query. A model says what a query does in provider-neutral terms; this replays it onto anyIQueryableas the expression tree that provider translates, which is the whole of what an adapter integration needs. What it will not do is invent a translation: an aggregate terminal is applied through the provider's ownCount/Sum/Min/Max,DistinctByandCountBystay in memory, and a join whose inner sequence was resolved to a value stays on the client side — each refused by name rather than approximated. -
The P3 tier: eleven operators, for the parts of a pipeline that are about failure rather than about data. The engine so far has been a faster walk; these decide what a walk does when it goes wrong, or build a sequence out of something that is not one yet:
Do;Catch,RetryandOnErrorResumeNext;UsingandDefer;Repeat, which repeats a sequence rather than an element;ThrowandReturn, which build a failing and a one-element sequence; andOrderedMergewithSortedMerge.All but
Doare recovery or factory machines: a recovery operator holds the enumerator it may have to abandon next to the one it may have to continue with, so it is built from a class-backed sequence and pays one heap object per walk instead of staying on the per-element struct path.Dodoes stay on it — it is the one operator of the tier that is a pure per-element decoration, which is exactly the shape a counter or a logger in the middle of a hot pipeline needs.Return,Throw,DeferandUsingare factory methods rather than extension methods: a factory has no receiver to extend, and extendingFunc<T>or a bare element would put engine candidates on receiver shapes the BCL owns. -
Retryre-walks, and says so. A sequence cannot be resumed from the element after the one that failed — an enumerator offers no such operation — so a retry restarts the source and therefore re-yields the elements that were already handed over. That is a property of retrying a sequence rather than a request, it is stated at the operator, and it is why the count it takes is the number of extra attempts:0lets the first failure through. -
OrderedMergeandSortedMergeare deliberately different operators.OrderedMergemerges two sequences that are already ordered: O(n), constant memory, and it will stream an endless source.SortedMergeassumes nothing about its inputs, so it sorts both sides first — O(n log n) and a buffer of both — and it sorts them stably, because two equal elements from the same side swapping places is an observable difference. Both take ties from the first side. One operator could not be both, and shipping the streaming one alone would silently produce the wrong order for input that was never sorted.
-
None. No source or binary contract changed. The evidence:
- No packed project changed at all. The only project file added in this release is a test
project (
tests/DotNetCore.Collections.Paginable.OrmQueryTests), which is not packed. NoPackageReferencewas removed, no target framework was dropped, no.nuspecchanged, and no packed project gained a dependency — the query face takes no ORM dependency on purpose, which is why the three provider smoke tests live in a test project instead. - Nothing was deleted or renamed under
src/.git diff 7.3.0..HEAD --diff-filter=Dand--diff-filter=Rare both empty oversrc/. - Not one line was removed from
src/. The sixteen files that changed undersrc/are all additions — 3,978 inserted lines, 0 deleted — so there is no removedpublicdeclaration to look for, not merely none that matters. - Every new member is additive. Nineteen public types join the surface, along with eleven
operators, the query face's seven extension members and
QueryModel.AsQueryable(); none overrides, shadows or re-signs an existing member. - Existing
System.Linqcode binds exactly as before. The new receivers areIQueryable<T>, which the BCL does not extend, and the namespace staysDotNetCore.Collections— nothing is injected intoSystem.Linq. The build verifies this at source level rather than assuming it: the same-name operators bind to the engine on a queryable receiver and still bind to the BCL on a sequence receiver, on every target framework.
One thing to check when upgrading, though it is not a code change: the engine now offers extension candidates that take an
IQueryable<T>receiver, including three names the BCL also owns (DistinctBy,Order,CountBy). If your own library defines an extension method with one of those names on anIQueryable<T>receiver and also importsDotNetCore.Collections, a call site could become ambiguous. No existing call changes meaning: a new candidate can only be reached by new code. - No packed project changed at all. The only project file added in this release is a test
project (
-
Sixteen more operators: the P2 tier. Eight window and time-series operators and eight join, product, traversal, extreme and shuffle operators, all following the same four-arm dispatch pattern as the rest of the engine — one overload per source shape (
T[],List<T>,IReadOnlyList<T>,IEnumerable<T>), so the enumerator stays a value type end to end.Window and time-series (8):
Window/WindowLeft(sliding windows, complete-only and padded respectively);GroupAdjacent(groups of consecutive equal keys —[1, 1, 2, 1]is three groups, not two);Split(in both the separator-value and the predicate form, keeping empty segments);Lag/Lead(neighbours kept on the edge rows, missing neighbour arriving asdefault, which is the SQL window-function convention rather than truncation);ZipLongest(runs to the longer side, unlikeZip);Interleave(round-robin, stopping when either side runs out).Join, product, traversal, extreme, shuffle (8):
FullJoinandFullGroupJoin(the BCL still has no full outer join, so this is a genuine gap rather than a polyfill);Cartesian(two- and three-source);TraverseBreadthFirst/TraverseDepthFirst(tree traversal over a child selector, with and without a result selector);Maxima/Minima(every tied extreme, and an empty source yields an empty sequence rather than throwing — deliberately the complement ofMaxBy/MinBy);Shuffle(Fisher-Yates, injectableRandomfor reproducible runs).One deviation from the plan is worth recording:
Cartesianwas scoped as "seven overloads" following the arity-2-to-8 convention, but the engine requires an engine-owned receiver for every extension method, so arity cannot be an overload axis. It ships as eight overloads across the four source shapes and the two arities; higher arities compose by chaining, and the result stays inside the engine. -
MultiSetCombinatorsin theMultimodule.CrossJoin,InterleaveandFullJoin, all defined onIMultiSet<T>and each delegating to the corresponding engine operator. They differ in what multiplicity means, deliberately:CrossJoinmultiplies it (two copies ofacrossed with three copies ofbgives six copies of the pair),Interleavekeeps both sides' copies and only decides order — which is why it returns a list rather than a multiset, a multiset not carrying order — andFullJoinemits the cross product of the values sharing a key and projects one-sided values againstdefault. -
PaginableSettings.MemoizeLazySources, off by default. When on, a paginable built from a lazy sequence memoizes that sequence so the member-count pass and every later page read share one walk of the source instead of each restarting from the beginning. A full pass over P pages used to costO(P^2 * pageSize)pulls; it now costsO(memberCount). Two source shapes are excluded regardless of the setting, because for them the cache is pure overhead or actively harmful:ICollection<T>(already cheap to re-enumerate, with anO(1)count) andIQueryable<T>(memoizing it would evaluate the whole result set on the client and defeat the provider'sSkip/Take).
-
PaginableandMultinow depend on the engine. Both modules' internals were meant to run on this engine rather than falling back toSystem.Linq, but neither package referenced it — the three packages were independent, so there was nothing to consume. Both now carry a project reference toDotNetCore.Collections. The nine ORM integrations inherit the dependency transitively. Nothing in either package's public surface exposes an engine type: the engine is used internally and its results are handed back asIEnumerable<T>. -
In-memory keyset paging orders with
PartialSortByinstead ofOrderBy. The twoIEnumerable<T>keyset overloads needpageSize + 1members and were sorting the whole source to get them,O(n log n)for a constant-size cut. They now take a heap of that size,O(n log k). TheIQueryable<T>overloads are untouched:OrderBy().Take()already translates toORDER BY ... FETCHthere, and a client-side partial sort could not. Two consequences are documented on the methods — the partial sort is not stable, and the key selector runs once per sift rather than once per element — and both are invisible as long as the ordering key is unique, which keyset pagination requires anyway. -
The engine's test suite grew from 330 to 334 tests;
Multi.Testsfrom 1434 to 1451;Paginable.Testsfrom 184 to 203. All pass, andMulti.NetFxTestsremains at 40.
-
Memoizecould hand a concurrent enumerator the wrong element. The shared cache filled "one more element" rather than "up to the index the caller asked for", so of two enumerators missing the same slot, the loser of the race was given the element atindex + 1and reported it atindex. Filling to the requested index makes the loser's fill a no-op instead. -
Memoizewas a net loss on in-memory sources because of a per-element lock. The read path took the same monitor as the fill path, and replaying a cached element is the hot path — everySkipstep and every second walk. A monitor acquire per element cost more than the walk it saved: on the paging benchmark, reading every page of one paging set took 1126 us memoized against 410 us not memoized, a 2.75x regression against the operator's own purpose. Reads now go through a published fill count, release/acquire paired with the append. The same benchmark then runs at 529 us memoized against 555 us not, with the allocation column down from 1187 KB to 260 KB. The single-page case is still slower (30 us to 126 us), because the count pass has already walked the whole source and only the fill cost is left to pay — which is why the switch defaults to off.
-
None. No source or binary contract changed. The evidence:
- No
.csprojin the packing face lost anything. Three project files changed in this release: the two modules gained aProjectReference(an addition, above) and the polyfill sample gained one. NoPackageReferencewas removed, no TFM was dropped, and no.nuspecchanged. - Nothing was deleted or renamed under
src/.git diff 7.2.0..HEAD --diff-filter=Dand--diff-filter=Rare both empty oversrc/. - No
publicdeclaration line was removed. Every-line matchingpublicacross the wholesrc/diff is empty; the 162 deleted lines are all implementation bodies, comments and#if-gated internals.MemoizeCache<T>'s fill method wasinternaland stays internal under a new name. - Every new member is additive. Sixteen operators,
MultiSetCombinators, andPaginableSettings.MemoizeLazySourcesadd to the surface; none overrides, shadows or re-signs an existing member. - The two behaviour changes are defect repairs, not semantics changes.
Memoizestill memoizes, and keyset paging still returns the same pages in the same order whenever the ordering key is unique — which is the only shape keyset pagination is defined for.
One thing to check when upgrading, though it is not a code change:
PaginableandMultinow pullDotNetCore.Collectionstransitively. If you pin transitive package versions, re-check the pin; onnet451/net461the engine also bringsSystem.ValueTuple. Existing code compiles and runs unchanged, and no upgrade step is required. - No
-
Nineteen polyfill operators for the BCL's version-gapped APIs. The engine now ships implementations of operators the BCL added in .NET 6 through .NET 10, so a consumer on
net451throughnetstandard2.0gets the same API shape as a consumer onnet10.0. The polyfill layer is split into two priority tiers:P0 layer (13 operators):
DistinctBy,ExceptBy,IntersectBy,UnionBy(keyed-set);MaxBy,MinBy(extreme-key);Chunk(batching);Order,OrderDescending(keyless ordering);CountBy,AggregateBy(keyed-aggregation);LeftJoin,RightJoin(outer-join). The keyed-set, extreme-key, chunk, and keyed-aggregation operators are BCL APIs from .NET 6 through .NET 9; the engine's implementations are available on every TFM the engine targets.OrderandOrderDescendingare .NET 7 APIs; the engine's implementations are available on every TFM.LeftJoinandRightJoinare .NET 10 APIs; the engine's implementations are available onnet451throughnet9.0only — onnet10.0and above, the BCL's own implementations are used, so the engine does not compete with the platform.P1 layer (6 operators):
DistinctUntilChanged(consecutive-duplicate removal);Memoize(cache-on-first-enumeration for replay);FallbackIfEmpty(alternative sequence when empty);AtLeast,Exactly,CountBetween(short-circuit count predicates);PartialSortBy(top-N by key, O(n log k));Choose(combined filter and projection, F#Seq.choosesemantics). These operators are not in the BCL on any TFM; they are engine-only additions.All nineteen operators follow the same four-arm dispatch pattern as the engine's existing operators: one overload per source shape (
T[],List<T>,IReadOnlyList<T>,IEnumerable<T>), so the enumerator stays a value type through the whole pipeline. On high TFMs where the BCL already ships the operator, the arbitrary-sequence arm (IEnumerable<T>) forwards to the BCL's implementation; the three contiguous arms (T[],List<T>,IReadOnlyList<T>) always walk the engine's value-type path, so a contiguous source never pays for the BCL's interface-based enumeration. -
Cross-TFM behavior consistency tests for the P0 polyfill layer. A new test suite verifies that the polyfill operators behave identically to the BCL on
net8.0, where the BCL's own implementation is available. The tests cover boundary cases, exception behavior, and edge cases for the eleven BCL-backed operators. The comparison is exact: every engine answer is compared with theSystem.Linqexpression it claims to be equivalent to. This is the contract the polyfill layer exists for — a low-TFM consumer must not be able to tell the polyfill from the platform.
- The engine's test suite grew from 105 to 249 tests. The P0 polyfill layer added 77 tests
(105 → 182); the P1 layer added 42 tests; the cross-TFM consistency suite added 25 tests. All 249
tests pass on
net8.0.
- None. No packaged defect was addressed.
-
None. This release is a pure addition on every axis:
- No existing operator signature changed. Every operator added in 7.1.0 (
Index,ForEach,TagFirstLast,Pairwise,Scan) is exactly what it was. - No
PaginableorMultidependency changed. Neither package references the engine, so neither package's dependency graph moved. - The polyfill operators are new public API, not replacements. They add to the engine's surface; they do not override or shadow any existing method.
Existing code therefore keeps compiling and running unchanged, and no upgrade step is required.
- No existing operator signature changed. Every operator added in 7.1.0 (
- A new package —
DotNetCore.Collections, a LINQ engine built on value-type enumerators. The package is additive:PaginableandMultiare untouched and neither references it. It exists to give the two things the base class library'sEnumerablecannot — an enumerator that is a plainstructrather than aref struct(so it can be a type argument and can cross anawaiton every target,net451included), and hot paths that allocate nothing. The engine ships the same eleven target frameworks as the rest of the repository,net451throughnet10.0, from one C# 8 code path, with the older generations served by polyfill packages (System.Memory,System.Buffers,System.Runtime.CompilerServices.Unsafe,System.ValueTuple) that are referenced only where the framework does not already ship the type. Nothing in a public signature names a type the oldest target lacks, so referencing the package never forces a consumer onto a newer framework. ToValueEnumerable()— four entry points, one per source shape. An overload forT[], one forList<T>, one forIReadOnlyList<T>and one for anyIEnumerable<T>; the most specific overload wins, so aList<T>takes the list arm rather than the enumerable arm. The wrappers are lazy and hold a reference to the source, so nothing is enumerated until the result is walked, and each also implementsIEnumerable<T>, so a wrapped source can still be handed to any API that expects a sequence.ValueEnumerable.From<T>()is the same four entry points as plain static methods, for callers who would rather not import an extension. The four wrapper types (ArrayValueEnumerable<T>,ListValueEnumerable<T>,ReadOnlyListValueEnumerable<T>,EnumerableValueEnumerable<T>) implementIValueEnumerable<out T, out TEnumerator>, which isIEnumerable<T>plus aGetEnumerator()that returns the value-type enumerator instead of the boxed interface.- Five operators —
Index,ForEach,TagFirstLast,PairwiseandScan. Each is declared once per source arm, so the enumerator stays a value type through the whole pipeline.Indexpairs every element with its zero-based position as(int Index, T Item), matching the shapeSystem.Linq.Enumerable.Indexgained in .NET 9, and counts withcheckedarithmetic so a sequence longer thanint.MaxValueraisesOverflowExceptionrather than wrapping.ForEachruns an action per element eagerly, with a second overload that also passes the position.TagFirstLasthands the selector two extra flags — whether the element is the first and whether it is the last — so a single-element source tags its one element as both.Pairwisehands the selector the previous and the current element, so n elements yield n−1 results and a single-element source yields none.Scanis the running aggregate in the two shapes the name implies: without a seed it emits the first element and then one value per element (as long as the source), and with a seed it emits the seed first and then one value per element (one longer than the source). - The engine's hot paths were measured before they were trusted, and the numbers are the
release's premise.
Where/Select/WhereSelectwere benchmarked againstSystem.LinqwithBenchmarkDotNetoverint[]andList<int>, on both the in-boxSpanpath (net8.0) and the polyfill path (net461, the stand-in fornet451):WhereSelectcame out 6.0 % and 13.0 % faster onnet8.0, and 18.0 % and 29.0 % faster on the polyfill path. Allocation on the hot paths is 0 bytes per walk on both paths — measured withGC.GetAllocatedBytesForCurrentThreadwhere the runtime can report it, and with a noise-floor-corrected counter on the .NET Framework generations where it cannot. The project's own go/no-go gate (if the win is under 10 % on both sources, stop chasing zero-allocation) was not triggered, so the engine keeps its zero-allocation target. - The engine's namespace and overload resolution are guarded in CI. A consumer that writes
using System.Linq;next tousing DotNetCore.Collections;must not get CS0121, and importing the engine must not change what their existingEnumerablecode binds to. Two halves enforce that: a source scan that fails if any file undersrc/DotNetCore.Collectionsdeclares a namespace outside the engine root, and reflection assertions over the built assembly (nothing underSystem.Linq, every public type and extension method under the engine namespace, and any extension method sharing a name withEnumerabletaking an engine-owned receiver). A compiled sample proves both facts onnet8.0— the last target where the BCL has noIndexat all — and onnet10.0, where the twin exists, the engine still wins the call and a bareT[]still binds to the BCL.
- The release workflow now packs twelve packages instead of eleven.
DotNetCore.Collectionsjoins the pack list, so the NuGet trusted-publishing policy must name the new package as well. The engine's own CI workflow verifies the pack, including the.snupkgsymbol package.
- None. No packaged defect was addressed.
-
None. This release is a pure addition on every axis:
- The new package is new; the existing packages are untouched. No file under
src/DotNetCore.Collections.Multiorsrc/DotNetCore.Collections.Paginable*appears in the diff at all, so everyPaginableandMultipublic signature is exactly what it was. - No
PaginableorMultidependency changed. Neither package references the engine, so neither package's dependency graph moved. - The engine adds a namespace, it does not take one over. Its public surface is rooted at
DotNetCore.Collectionsand it injects nothing intoSystem.Linq— the property the CI guard above exists to keep true — so addingusing DotNetCore.Collections;to existing code cannot change what that code binds to.
Existing code therefore keeps compiling and running unchanged, and no upgrade step is required.
- The new package is new; the existing packages are untouched. No file under
BoundedBag<T>— a bag whose total number of copies is capped. The sliding-window member of the bag family: it holds at most a fixedCapacitycopies and, when a new copy would exceed it, silently evicts the oldest copy, so the bag always describes the most recent additions. Adding never fails (Addreturnsvoid— there is no overflow to report). Storage is a single ring buffer of one slot per copy, soAddand eviction are O(1); the lookup and projection members (Contains,CountOf,DistinctCount,EntrySet, …) scan the bounded window, which is the whole point of the type.nullis a first-class element, as inMultiList<T>. It is deliberately a standalone type rather than anIMultiSet<T>implementation, because that contract states thatAddRangeorder does not affect the result — which is false for a first-in-first-out bounded bag, where the earliest elements are the ones evicted.CollectionDifference<TKey, TValue>and theDifferenceextension methods — a four-way comparison of two bags or two dictionaries.left.Difference(right)returns an immutable snapshot split into four disjoint partitions:OnlyInLeft,OnlyInRight,InCommon(present on both sides with an equal value) andDiffering(present on both with a different value, mapped to its(Left, Right)pair), withAreEqualas the "nothing changed" shortcut. Two multisets are compared by copy count, so the result answers which elements were added, removed, or changed multiplicity; twoIReadOnlyDictionary<TKey, TValue>instances are compared by mapped value, so it answers which keys were added, removed, or changed value — a multimap satisfies that overload with its per-key value collection as the value. Both overloads accept a key comparer, and the dictionary one a value comparer, so key identity and value equality stay the caller's to define.CollectionDifference<TKey, TValue>.Ofis the underlying factory for any key/value sequences. The result is a snapshot taken at the call, never a live view — this library does not ship writable through-views. Anullkey can not be represented (the partitions are dictionaries) and is rejected withArgumentExceptionrather than silently dropped, the same policy asToDictionary().CollectionChanged— a self-authored change notification onMultiList<T>andMultiDictionary<TKey, TValue>. Every mutation that actually changes the collection raises one event naming the element (for a bag) or the key (for a multimap), how many copies or values moved, and the resulting count;CollectionChangeTypedistinguishesAdd,RemoveandReset(a clear). The event is raised after the collection has reached its new, consistent state, so a handler that reads the collection back sees the change it was told about. It is deliberately not the framework'sINotifyCollectionChanged: that contract is index-oriented, while a multiset has no positions and can move an element by more than one copy in a single call — neither of which a positional notification can express. The notification is self-contained (noSystem.ObjectModeldependency), so it behaves identically on every target framework, and subscribing costs nothing until a handler is attached.Snapshot()onMultiList<T>andMultiDictionary<TKey, TValue>— the independent copy, under the name the rest of the family already uses. Both types could produce an independent copy throughClone(), and the concurrent types expose exactly that operation asSnapshot()(ConcurrentMultiList<T>.Snapshot()returns aMultiList<T>built fromClone()). The two core types now spell it the same way, so the family reads consistently, and the member documents the case it exists for: enumerating a collection while it is still being mutated. Enumerating the live instance is not modification-safe, but a snapshot is detached, so the enumeration can not observe a later change. The copy is shallow in the same sense asClone()— element references are shared, copy counts and inner value collections are independent; useImmutableMultiList<T>/ImmutableMultiDictionary<TKey, TValue>when the copy itself must be immutable.- Bag statistics —
Mode(),Median()andEntropy()on anyIMultiSet<T>. Three extension methods that summarise a bag as a frequency distribution.Mode()returns every element tied for the highest copy count, because a multiset can be multimodal and a single winner is not always meaningful (an empty bag yields an empty list).Median()takes anIComparer<T>and returns the upper median copy — the single middle copy for an odd total, the larger of the two middle copies for an even one — always an element of the bag and never an interpolated value, since a multiset need not be numeric; an empty bag has no median and throws.Entropy()returns the Shannon entropy of the copy-count distribution in bits (-Σ p·log2(p)), so a bag of one distinct element scores0, a bag spreading its copies evenly over d elements scoreslog2(d), and an empty bag scores0. All three read the bag throughEntrySet()— the distinct elements with their copy counts — so they never expand the copies and work on every bag implementation;Median()orders the distinct elements once and walks their counts, O(d log d) for d distinct elements however many copies each holds. The counts are plainint, so there is no generic-math dependency and the helpers hold on every target framework,net451included.
None. No existing member changed its signature, its behaviour or its documented contract.
None. No packaged defect was addressed.
-
None. Every change in this release is a pure addition — no member was removed, renamed or re-signed, and no existing behaviour changed. The evidence, each item checked against the previous release (
6.8.0):- Packaging, target-framework and dependency surface are untouched.
git diff 6.8.0..HEAD -- '*.csproj' 'build/*'is empty, so no project, target-framework list or package reference moved. - Nothing was deleted or renamed in the library sources.
git diff 6.8.0..HEAD --diff-filter=D --name-only -- src/and the same with--diff-filter=Rboth return nothing. - Every source change is an addition. The source diff is six new files —
BoundedBag.cs,CollectionChangeType.cs,CollectionChangedEventArgs.cs,CollectionDifference.cs,CollectionDifferenceExtensions.csandMultiSetStatisticsExtensions.cs— plusMultiList.csandMultiDictionary.cs, whose only removed lines are bodies refactored in place. - No public member line was removed. No
-line in the source diff declares a public member; the one rename (MultiList<T>'s privateSnapshot(IEnumerable<T>)helper, nowMaterialize, plus its eight call sites) is entirely private. - The integration packages are untouched. No
src/DotNetCore.Collections.Paginable*file appears in the diff at all, so every Paginable public signature is unchanged. The singlePaginable-named file in the diff is the test workflow, not a package. - No behaviour changed. The refactored bodies in
MultiList<T>andMultiDictionary<TKey, TValue>perform the same assignments as before, with the early return restructured into a branch so the new event can carry the resulting count; and the newCollectionChangedevent does nothing at all until a handler is attached.
Existing code therefore keeps compiling and running unchanged, and no upgrade step is required.
- Packaging, target-framework and dependency surface are untouched.
MultiDictionaryExtensionsadds three entry points for building a multimap from an existing source.IndexBy(keySelector)groups a sequence into aMultiDictionary<TKey, TSource>, keeping duplicates (list semantics);UniqueIndexBy(keySelector)indexes a sequence into aBiDictionary<TKey, TSource>and raisesArgumentExceptionwhen a key repeats;AsMultiDictionary()adapts anIReadOnlyDictionary<TKey, TValue>into aMultiDictionary<TKey, TValue>whose every key owns exactly one value, carrying the key comparer over when the source is aDictionary<TKey, TValue>. Each returns an independent copy, never a live view, so the result can be mutated without affecting the source. Before this, the multimap types could only be filled oneAddat a time.MultiKeyDictionary<TKey, TValue>andMultiKeyMultiDictionary<TKey, TValue>gainedEntrySet(). Both were the only composite-key types without a per-entry projection. Each yields(TKey[] Key, TValue Value)pairs — the whole key sequence together with its value, the same sequence the type's own enumeration yields;MultiKeyMultiDictionary<TKey, TValue>flattens one pair per stored value, so a key holding several values contributes several pairs. UseGetByPrefixorToDictionary()where the grouped, one-entry-per-key shape is wanted instead.
- The per-entry projection is now spelled
EntrySet()across the wholeMultifamily.TwoKeyDictionary<K1, K2, V>andThreeKeyDictionary<K1, K2, K3, V>gained anEntrySet()method returning exactly what theirEntriesproperty returned, so every type that exposes the projection now names it the same way.Entrieson both types is obsolete: it still compiles and returns the same sequence, but it now raises a compiler deprecation warning. Callers should switch toEntrySet().
None. No packaged defect was addressed.
- None. Every change is a pure addition: no member was removed, renamed or re-signed, and no
existing behaviour changed.
EntriesonTwoKeyDictionary<K1, K2, V>andThreeKeyDictionary<K1, K2, K3, V>is marked obsolete but remains fully functional, so existing code keeps compiling and running unchanged. The only observable difference is the CS0618 deprecation warning a deliberate deprecation is meant to raise; a project that treats warnings as errors can either suppress it or rename the call toEntrySet().
The first patch release in this repository's history. The 6.7 window is closed, and this is a
sub-window authorised on its own terms: it carries four consistency items the 6.7 window did not,
and nothing else. All four are pure additions to the Multi package — no member was removed,
renamed or re-signed, and no existing behaviour changed. If you are on 6.7.0, this release adds
the missing members to the map family's remaining types; there is nothing to unlearn.
TwoKeyDictionary<K1, K2, V>andThreeKeyDictionary<K1, K2, K3, V>gainedValuesandContainsValue(value).Valuesflattens every stored value — one per entry, the key components dropped — matching theValuesthe other dictionary types in this package already expose;ContainsValuereports whether any entry holds a given value, under any key combination.ContainsValueis a full scan (O(n)): neither type keeps a value-side index, since the reverse indexTwoKeyDictionarydoes maintain is keyed byK2, not by the value. Where an O(1) value lookup is required, keep usingMultiDictionary<TKey, TValue>.ContainsValue.ConcurrentMultiList<T>gained the read-only projections its siblings already had —EntrySet(),DistinctItems(),ToList(),ToArray()andToDictionary(). Each is taken fromSnapshot(), so the result is detached from the bag and unaffected by concurrent writers, which is how enumeration on this type already behaves.TwoKeyDictionary<K1, K2, V>andThreeKeyDictionary<K1, K2, K3, V>gainedAsReverse(), which swaps the key axes:TwoKeyDictionary<K2, K1, V>andThreeKeyDictionary<K3, K2, K1, V>respectively. It returns an independent, fully-typed copy built in O(n) — not a live view — because both types are concrete classes rather than interface implementations, so a view could only be a thinIReadOnlyDictionaryand would lose the per-axis projection surface (GetByFirstKey/GetBySecondKey/GetByThirdKey). The per-axis comparers travel with the axes. The point of the swap is that the original last axis takes the first-axis position, which the underlying trie resolves by prefix walk:map.AsReverse().GetByFirstKey(k)answers the same question asmap.GetBySecondKey(k)(two-key) ormap.GetByThirdKey(k)(three-key), from a structure indexed that way.AsReverse()reached the rest of the map family, each with the semantics its own type can honour.ImmutableMultiDictionary<TKey, TValue>.AsReverse()returns the live reverse view at zero cost — the state it reads can never change, so "live" and "snapshot" coincide there.ConcurrentMultiDictionary<TKey, TValue>.AsReverse()returns an independentReverseMultiDictionary<TValue, TKey>built fromSnapshot(): the type keeps no index above its shards, so a live reverse view could only be an index that silently goes stale or an O(n) locked scan per read, and its whole read surface is snapshot-based already.OrderedMultiDictionary<TKey, TValue>.AsReverse()returns an ordered copy,OrderedMultiDictionary<TValue, TKey>, because this type's key identity is anIComparer<TKey>and a hash-based reversal would silently re-define which keys count as the same key. In every case the comparers travel with the axes, and the three-key/two-keyAsReverse()members from the previous entry are unchanged.ToSerializableModel()/FromModel()reached the rest of the family.OrderedMultiDictionary<TKey, TValue>,ConcurrentMultiDictionary<TKey, TValue>andReverseMultiDictionary<V, K>reuse the existingMultiDictionaryModel<TKey, TValue>, all three being multimaps; the ordered map can use it even though it refuses aToDictionary()export, because the model stores its keys in aListand never needs the hash codes anIComparer<TKey>can not supply. ForReverseMultiDictionary<V, K>the indexed axis (the values) plays the model'sKeysrole, and the stored keys playValues; the stored keys are a set, so inner-list order is not preserved and a repeated key collapses.BiDictionary<TLeft, TRight>gets a new dedicated model,BiDictionaryModel<TLeft, TRight>(Lefts+Rights, parallel lists): a bijection is one-to-one, so the multimap model's inner list would hold exactly one element per entry and would misrepresent the type. That model also carries anull-left binding, whichBiDictionary.ToDictionary()has to drop because aDictionarycan not key onnull. A model that is not a bijection is rejected byFromModelwith the same conflictAddraises.
None. No packaged behaviour, target framework, dependency or package content changed.
None. No packaged defect was addressed.
- None. Every change is a pure addition: no member was removed, renamed or re-signed, and no
existing behaviour changed.
ConcurrentMultiList<T>.ToDictionary()inherits theInvalidOperationExceptionthatMultiList<T>.ToDictionary()raises for anullelement;EntrySet()is thenull-safe alternative on both.BiDictionaryModel<TLeft, TRight>is a new public type; the existingMultiDictionaryModel<TKey, TValue>is unchanged, and so is everyAsReverse()member that already existed.
This release ships no package changes, and says so rather than dressing it up. The 6.7 version
window went entirely into the benchmark side — a head-to-head multimap comparison whose code and
desensitised summary live under performance/, which is not packed — so every package here is the
same code as 6.6.0. It is published anyway to keep the window numbering in step with the rest of the
release history, and because build/version.props is the one place all 11 packages take their
version from. Already on 6.6.0? There is nothing to gain by upgrading.
None. No package gained a type, a member or an overload in this release.
None. No packaged behaviour, target framework, dependency or package content changed.
None. No packaged defect was addressed in this window.
- None. Nothing shipped changed, and that was checked rather than assumed.
git diff 6.6.0..HEAD -- src/is empty — not "no API change", but no change of any kind: no file undersrc/was added, deleted, renamed or edited, so no member line was dropped, no signature moved, and nothing needs a same-shaped replacement. The packaging surface is untouched as well: nothing underbuild/changed, and the only project file the window touches isperformance/DotNetCore.Collections.Multi.Benchmarks, which the release workflow never packs — it packssrc/DotNetCore.Collections.*only — so the target frameworks, the dependencies and the package contents are identical to 6.6.0. There is no behaviour change to weigh, and no upgrade steps are required.
-
Type.ToReadableString()turns aTypeinto one readable line, for diagnostics, log messages and test failure output. Array ranks are kept, generic arguments are rendered recursively and namespaces are dropped, sotypeof(Dictionary<DateTimeOffset, IReadOnlyDictionary<string, int>>[,])reads asDictionary<DateTimeOffset,IReadOnlyDictionary<String,Int32>>[,]— the shape of the type rather than its assembly-qualified identity. Rank specifiers follow C# source order (outermost first), so the two jagged shapes stay distinguishable; nested types are joined with.instead of the reflection+separator; and an overload takingTypeNameFormat.CSharpgives the C# spelling of the same type, with primitive aliases as keywords andNullable<T>asT?. A third overload caps how many levels of array / generic nesting are rendered and collapses the remainder to.... That cap is not decoration: a type built through reflection can nest arbitrarily deep, and an unbounded walk would overflow the stack inside the very diagnostic meant to explain the failure. The output is stable enough to assert on. No existing signature changed, so there is no### Breakingentry for this. -
IMultiSet<T>,IMultiDictionary<TKey, TValue>andIBiMap<TLeft, TRight>give the multiset, the multimap and the bidirectional map a shared contract, so code can be written against the behaviour instead of a concrete class and the implementation can be mocked, replaced or swapped. Each interface extends the read-only BCL abstraction that already describes it rather than restating it:IMultiSet<T>is anIReadOnlyCollection<T>,IMultiDictionary<TKey, TValue>anIReadOnlyDictionary<TKey, IReadOnlyCollection<TValue>>andIBiMap<TLeft, TRight>anIReadOnlyDictionary<TLeft, TRight>.MultiList<T>andOrderedMultiList<T>implementIMultiSet<T>;MultiDictionary<TKey, TValue>andOrderedMultiDictionary<TKey, TValue>implementIMultiDictionary<TKey, TValue>;BiDictionary<TLeft, TRight>implementsIBiMap<TLeft, TRight>. The member sets are deliberately minimal — only what every implementation can honour. Set algebra, the comparer properties and the view/export helpers (AsReadOnly,AsReverse,ToList,Clone) stay on the concrete types: they are not uniformly available across the family (the concurrent and immutable variants have no algebra, and an immutable variant returns a new instance from what looks like a mutating call), and exposing them would have leaked which implementation sits behind the interface. Two members are where a naive abstraction would have gone wrong, and both are spelled out in the XML documentation of the interface.Countis the number of copies onIMultiSet<T>and the number of keys onIMultiDictionary<TKey, TValue>— the inherited member keeps exactly the meaning the concrete types already gave it, so re-typing a variable from a concrete type to the interface does not silently change what it counts. AndIMultiDictionary<TKey, TValue>.Valuesdeliberately hides the inherited per-keyValuesto stay the flattened sequence, matching the concrete types, with the grouped view left reachable through the inherited dictionary members. No existing behaviour or signature changed — every implementing member already existed with the same shape, so no overload resolution moved and no### Breakingentry is needed. The member sets are pinned by tests, and each interface is additionally exercised by a hand-written implementation that shares no code with these packages: the abstraction is only worth having if a third party can satisfy it. -
OrderedMultiDictionary<TKey, TValue>gained the positional read surface that its list counterpart received in 6.5.0.GetByRank(rank)returns the(key, value)pair holding the copy at a rank of the expanded sequence — the order the map itself enumerates in, keys ascending and each key's values ascending, counting copies rather than distinct values —GetRank(key, value)returns the rank of a value's first copy under the key, or-1when the key or the value is absent,TotalValueCountis the length of that sequence in O(1), andGetMedian()/GetQuantile(q)answer the two statistical reads on top of them with the same definitions the list uses (GetMediantakes the lower middle copy;GetQuantileuses the nearest-rank rule and rejectsNaNand anything outside[0, 1]). All of them are O(log n) worst case, on both duplicate policies, and that is what forced the map's per-key buckets to become one uniform type: the deduplicating policy (allowDuplicateValues: false) held aSortedSet<TValue>, which has no notion of rank at all, so the two policies could not both have answeredGetByRankbefore. Nothing observable changed besides resource use — the ordering, the duplicate policy, the views and every existing member keep the semantics they had, which the pre-existing suite confirms by passing with no test edit at all. -
Deque<T>is a double-ended queue: a sequence that can be added to and removed from both ends in O(1) amortized time, and read at any position in O(1). The base class library ships no deque —Queue<T>andStack<T>are single-ended, andLinkedList<T>, the only in-box type with two ends, keeps one heap node per element — so this fills a gap rather than adding a variant. It is one array used as a ring buffer with two moving indices, soAddFirst/AddLast/RemoveFirst/RemoveLastare an index move plus one array slot,GetFirst/GetLastandthis[int]are a ring read, and growth is the only thing that ever allocates; capacity doubles from 4 the wayList<T>'s does, andTrimExcessandCapacityhand the slack back.TryRemoveFirst/TryRemoveLastandTryGetFirst/TryGetLastare the non-throwing forms for the cases where an empty deque is a normal outcome rather than a caller mistake. Both list faces are implemented —IReadOnlyList<T>andIList<T>— because a deque's order is the caller's and not a comparer's, so the indexer setter andInsertare meaningful here, unlike onOrderedMultiList<T>where a position belongs to the sort and those two have to throw.AsReadOnly()returns the widerIReadOnlyList<T>rather than theIReadOnlyCollection<T>the rest of the package hands out: those types keep the narrower declaration only because widening it would be binary-breaking for consumers they already have, and this type has none.GetEnumerator()returns astruct, so aforeachover aDeque<T>allocates nothing. What is not O(1) is spelled out rather than papered over:Contains,IndexOfandRemove(T)are O(n) — a deque trades random search away for O(1) access at its two ends — andInsert/RemoveAtslide whichever side is nearer, costing O(min(index, Count - 1 - index)) and O(1) amortized at either end. Null elements are ordinary elements: no comparer and no hash table sit behind this type, so nothing inspects an element on the way in, and the three members that do compare go throughEqualityComparer<T>.Default— the same conventionMultiList<T>follows, where anullis an element with a copy count of its own. No existing signature changed, so there is no### Breakingentry for this. Measured against the in-box alternatives with BenchmarkDotNet +MemoryDiagnoser(R6-02). The rotation arms runCountadd-then-remove pairs over a collection already holdingCountelements, so nothing grows and only the end operations are on the clock:Deque<T>costs 2.38 / 2.00 / 2.20 ns per end operation at 64 / 4,096 / 65,536 elements — flat across a 1,024x range of work, which is what O(1) means — andQueue<T>doing the same at its one end costs 2.40 / 2.38 / 2.32 ns, so the second end is free.LinkedList<T>costs 12.5 / 14.9 / 24.2 ns per operation and rises with size. On allocation the difference is categorical: the deque rotates at 0 B on every size, whileLinkedList<T>allocates one 48-byte node per element — 3,072 B at 64, 196,608 B at 4,096 and 3,145,730 B at 65,536. Building a deque of 65,536 by appending allocates 525,147 B againstList<T>'s 525,143 B for the same doubling path (a 4-byte difference) andLinkedList<T>'s 3,145,769 B, which is 6.0x. Positional reads are 1.35 / 1.26 / 1.28 ns per position — flat, so O(1) — againstList<T>'s 0.95 / 0.78 / 0.74 ns: the ring's index arithmetic is a constant ~1.7x over a plain bounds-checked load, and that is the honest cost of supporting the wrap. Enumeration is 1.27 / 1.16 / 1.54 ns per element at 0 B.AddFirstandAddLastare symmetric (287 / 11,476 / 343,438 ns against 281 / 11,734 / 368,438 ns). One caveat on reading those figures: at 65,536 the short-run standard deviation reaches roughly a third of the mean on the rotation arms, so the O(1) claim rests on the per-operation cost staying flat, not on any single absolute number.
OrderedMultiDictionary<TKey, TValue>is now backed by the same order-statistic B+ tree asOrderedMultiList<T>(OrderStatisticTree<TKey, TPayload>), closing the gap 6.5.0 left open when it recorded that this type "never had a self-implemented engine, being built on the BCL's sorted dictionary over a per-key collection". Each key's slot now carries its bucket and how many copies hang under it, which is what makes the rank reads above a single descent, and enumeration a single sweep of the tree's leaf chain through value-type enumerators instead of one iterator per key. A payload channel added to the engine is what lets one tree serve both types:OrderedMultiList<T>instantiates it with an empty payload struct and pays nothing, while the map stores its bucket object in the same slot as the key and its count. The engine's depth bound was tightened from 40 to 12 in the same pass — 9 levels already cover more distinct keys thanintcan hold — cutting its per-instance scratch arrays from 480 to 144 bytes, and that is where the write path's allocation win below comes from: the buckets are the same type they were, but no longer carry 336 bytes of over-sized scratch space each. The compensation is not uniform, and the other direction is recorded too: keeping the cached copy totals in step makes writes slower,Addgoing 492.5 → 862.6 ns at 64 keys and 1,097.1 → 1,547.9 ns at 4,096 keys in the benchmark's own terms — the same cost 6.5.0 recorded for the list'sAddwhen it gained this engine. The reference arm moved by at most 8% between the two runs, so the direction is not machine drift. Enumeration, meanwhile, is about three times faster: 410.90 → 138.43 µs over 4,096 keys and 9,180.87 → 5,872.90 µs over 65,536.
-
DotNetCore.Collections.Paginable.SqlSugar:ToPaginableAsyncand the shortGetPageAsyncoverload accepted aCancellationTokenand then dropped it — it never reached the factory, so cancelling had no effect on either call. The token is now threaded through the factory and through the page fetch, and it is honoured at the boundary this library owns: an already-cancelled token stops the call before the first database round-trip instead of performing it. The token could not simply be handed on because SqlSugar 5.1.3 exposesCountAsync(),CountAsync(Expression<Func<T, bool>>)andToPageListAsync(int, int, RefAsync<int>), and noCancellationTokenoverload on any of them; mid-query cancellation therefore stays the provider's to provide, andSqlSugarHelperis the single place that changes when it does. Callers who passed a token and relied on it being ignored now observeOperationCanceledException— the behaviour the parameter always promised, so there is no### Breakingentry for this. -
OrderedMultiDictionary<TKey, TValue>no longer allocates per key when it is enumerated. Enumerating 65,536 keys allocated 7,340,454 bytes — a flat ≈112 bytes per key — and 459,104 bytes at 4,096 keys, the same per-key figure at both sizes, which is what identified it as a fixed cost per key rather than growth. The cause was in the enumeration itself: the map walked aSortedDictionary<TKey, ICollection<TValue>>, sopair.Value's static type was the interfaceICollection<TValue>, and every key therefore built anOrderedMultiList<TValue>.GetEnumerator()— ayielditerator — which in turn walked the likewise-yieldtree iterator: two iterator objects per key. Measured withBenchmarkDotNet'sMemoryDiagnoseron the same benchmark, before and after, the same figures are now 96 bytes at 4,096 keys and 99 bytes at 65,536 — a constant that does not grow with the key count. The write path allocates less as well,Addgoing 93 → 46 bytes at 64 keys and 656 → 423 bytes at 4,096 keys.
- None. This release is additive and behaviour-preserving on every shipped surface, and that was
checked rather than assumed. Nothing was removed, renamed or re-signed: no file under
src/was deleted or renamed, every member line the diff drops has a same-shaped replacement, and the packaging surface is untouched — no project file and nothing underbuild/changed between6.5.0and this release, so the target frameworks, the dependencies and the package contents are the same. The new public types (Deque<T>,IMultiSet<T>,IMultiDictionary<TKey, TValue>,IBiMap<TLeft, TRight>,TypeExtensions,TypeNameFormat) are additions, and the only edits to existing type declarations are the interfaces appended toMultiList<T>,OrderedMultiList<T>andBiDictionary<TLeft, TRight>. The engine swap behindOrderedMultiDictionary<TKey, TValue>is internal, and the one behaviour change — the SqlSugar integration now honouring theCancellationTokenit accepts instead of silently dropping it — is the semantics that parameter always promised, so only a caller that relied on the token being ignored can observe a difference. No upgrade steps are required.
-
OrderedMultiList<T>gained its positional read surface:GetByRank(rank)returns the element sitting at a rank of the expanded sorted sequence — counting copies, not distinct elements, so the valid range is[0, TotalCount-1]and anything outside throwsArgumentOutOfRangeException—GetRank(item)returns the rank of an element's first copy or-1when it is absent, andGetMedian()/GetQuantile(q)answer the two statistical reads on top of them (GetMediantakes the lower of the two middle copies;GetQuantileuses the nearest-rank rule and rejectsNaNand anything outside[0, 1]). All four are O(log n) worst case, which the engine swap recorded below is what makes possible: each of the type's nodes caches how many copies hang in its sub-tree, so a rank read is one descent down that sum instead of a scan. Measured against the only thing the type could do before — give up and materialise the expanded sequence, then index or search that copy — with BenchmarkDotNet + MemoryDiagnoser (net8.0,Distinctkeys at three copies apiece, 64 reads per operation): over 4096 distinct keysGetByRankis 31.7 ns against 1,943 ns,GetRank78.9 ns against 2,453 ns, and at 65,536 distinct keys the ratios widen to 688x and 1,020x, with 0 B of garbage against 12 kB per read. No existing signature changed, so there is no### Breakingentry for this. -
OrderedMultiList<T>now implementsIReadOnlyList<T>andIList<T>, so it can be handed to anything expecting a list — data binding, position-based LINQ, third-party libraries — without copying it into aList<T>first. The index domain is the expanded sequence, matching what the type already did:Countcounts copies, soshelf[i]isGetByRank(i)andIndexOf(item)isGetRank(item), both O(log n) and both addressing the i-th copy rather than the i-th distinct element.RemoveAt(i)drops exactly one copy. The two members that would let a caller place an element are narrowed rather than free, because a comparer decides where an element belongs:Insert(index, item)accepts only a slot inside the run of equal elements — every accepted index yields the same multiset, so the parameter is a consistency check and not a placement — and throwsArgumentOutOfRangeExceptionfor any other, since a sorted sequence has no such position; and assigning through the indexer throwsNotSupportedException, since writing over a position would break the order the whole type is built on.IsReadOnlystaysfalseandAdd/Remove/RemoveAllCopiesare unchanged.AsReadOnly()keeps its declaredIReadOnlyCollection<T>return type — widening it would be a binary-breaking change for existing consumers — but the view it returns now also answersIReadOnlyList<T>, with the same positional contract and no mutating member at all, which is why there is noIsReadOnly/IsFixedSizeto define for it. The unorderedMultiList<T>deliberately does not get the list interfaces: its enumeration order is an implementation detail of the hash table, so an index would name a different element from one call to the next, and that is now stated on the type. No member was removed, renamed or re-signed, so there is no### Breakingentry: the one consumer-visible hazard of implementing an interface — a call that used to resolve against anIEnumerable<T>arm now also seeing anIList<T>arm — is an overload-set ambiguity in the caller, not a change in what this type does. -
Documentation:
docs/positioning.mdstates where the library fits — the capability axes it was built around (multisets and multimaps, composite keys, bijection, inverted lookup, order and position, concurrency and immutability as explicit types, paging as a library, packed and stack-only specializations,net451…net10.0from one code path), the six things it deliberately does not provide and what to use instead, and how to choose between the hash path and the tree path. It also records the limits in both directions rather than only the favourable ones. -
Documentation:
docs/migrating.mdis the guide for a codebase moving its sorted, positional and multiset work ontoDotNetCore.Collections.Multifrom one of the classic ordered-collection libraries — type and member mapping by shape, the twelve semantic differences that change behaviour rather than spelling, a migration checklist, and the operations that have no counterpart here.
OrderedMultiList<T>is now backed by an order-statistic B+ tree (OrderStatisticTree<TKey>) instead of the internal left-leaning red-black tree, which is deleted. This is an engine swap, not a semantic one: counted duplicates, the first-added key winning a comparer collision,nullhandled entirely by the comparer, injected comparers, and shallow-copy semantics (Clone) all behave exactly as before, verified by the pre-existing ordered-multiset suite running unchanged against the new engine - the only test edits were assertions about the old tree's internal shape, its height bound and the layout of a single-key tree. The swap is what the rank reads above need, and it pays off independently on the paths that were already there: ascending iteration over 12,288 copies went 129.9 → 75.6 µs and over 196,608 copies 2,662 → 1,284 µs with per-operation allocations 440 → 112 B, a 512-wideGetRangeinside 65,536 distinct keys went 176 → 11.3 µs and 92 kB → 152 B,RemoveAllCopieswent 3.35 → 1.28 µs per key. The compensation is not uniform and the other direction is recorded too: a wide node trades the red-black tree's single-key hop for a binary search inside a page, so a small, single-key probe got slower (CountOfover 64 distinct 12.5 → 30.8 ns,Add390 → 472 ns).OrderedMultiDictionaryis deliberately untouched — it never had a self-implemented engine, being built on the BCL's sorted dictionary over a per-key collection, so giving it the same treatment is a separate piece of work. Alongside: the XML example forGetRangewas corrected, sinceGetRange("a", "n")overmug, bean, beanreturns"bean", "bean", "mug"—"mug"sorts below"n".- The paging extensions of six ORM integration packages (
Chloe,DosOrm,FreeSql,SqlSugar,NHibernate,SqlKata) are now emitted at build time by a Roslyn source generator (src/DotNetCore.Collections.Paginable.SourceGenerators) instead of being handwritten once per provider. Each package declares what its paging surface looks like — source type and parameter name, page / paginable-collection / factory / helper type names, and which optional shapes it carries (additionalQueryFunc,includeNestedMembers, async members, theclassconstraint) — with a single[SolidPageExtensionsFor]attribute on theSolidPageExtensionspartial class, and the generator writes out theToPaginable/ToPaginableAsync/GetPage/GetPageAsyncmembers from that declaration. Provider-specific members that do not fit the shape stay handwritten inside the same partial (NHibernate'sISessionoverloads, SqlKata'sGetPageAsync). Chloe's extension class is now a 19-line declaration where it was 95 lines of handwriting, Dos.ORM's 21 where it was 99. The three packages that also targetnet451(FreeSql, SqlKata, SqlSugar) reference the generator for every other target and keep their handwritten members behind#if NET451; the legacy target keeps the same members, and carries the class-level XML summary in the handwritten partial itself so that it too compiles warning-free (see### Fixedbelow). No consumer-visible change, and therefore no### Breakingentry for this: the generated members are the same members with the same signatures - including parameter names, which are source-affecting for named-argument callers - and the same XML documentation, so the shipped IntelliSense is unchanged. Verified by a regression suite that runs the generator over each of the six packages and compares every emitted member (signature, body, documentation) against the pre-change source, held as test resources. Packaging is untouched as well: the generator is an analyzer, so no package gains a dependency and none of them ships the generator assembly. The EF6, EF Core and FreeSql.DbContext integrations are deliberately not migrated - they delegate to the queryable extensions rather than to a provider-specific paging factory, so their bodies were never the duplicated shape this replaces.
- The three ORM integration packages that still target
net451(FreeSql,SqlKata,SqlSugar) emittedCS1591for theirSolidPageExtensionsclass on that target alone. The class-level<summary>had moved into the source generator, and the generator is deliberately not referenced onnet451, so on that target nothing documented the type while the project still compiles with XML documentation required. Each handwritten#if NET451partial now carries the same class-level summary the generator emits, so the shipped XML documentation for the type is identical on every target and the build is warning-free again. No API change, and the three packages that never targetednet451(Chloe,DosOrm,NHibernate) were unaffected.
FrequencyPriorityBag<T>- the frequency priority bag: a bag that knows which element occurs most often, so "pop the most frequent element" and Top-K queries are first-class operations (PeekMost/TryPeekMost/PopMost/TryPopMost; repeatedPopMostdrains the bag in descending-frequency order). ADictionaryfrequency index is the source of truth; the max-heap over its counts is materialized lazily - every update touches only the index (O(1)) and marks the heap dirty, the next priority query rebuilds it in O(n) (bottom-up heapify), and while it stays clean each further peek is O(1) and each pop O(log n). Tie policy (documented on the type): among elements with the same count, the one that reached its current count earliest pops first - every count change stamps a fresh sequence number and the heap breaks ties on the earlier stamp, a deterministic FIFO-flavoured rule. Bag semantics mirrorMultiList<T>: counted duplicates,Add(item, times)/Remove(item)(returns the number remaining), an element disappearing with its last copy,nullas a first-class element in a dedicated bucket (with its own tie stamp), copy-expanded enumeration andEntrySet(). Measured against re-sorting aMultiList<int>'s entries (BenchmarkDotNet + MemoryDiagnoser, net8.0, 512 adds over 32 distinct): for the workload the type exists for - repeated Top-1 queries against live data - ~3.2x faster with ~2.9x less garbage; build-once-query-once and churn-interleaved workloads land at parity, where sorting once is just as good - the boundary is documented rather than hidden. Not thread-safe.SpanBag<T>- the stack-only temporary bag: a fixed-capacity counting bag overunmanagedelements backed by caller-providedSpan<T>storage (typicallystackalloc), for short-lived frequency counting inside one method with zero heap allocation. The bag is aref struct, so the compiler itself enforces the lifetime contract - no fields, no boxing, no capture, no crossingawait/yieldboundaries - and there is deliberately no factory method: the caller allocates (astackallocinside a factory would die with the factory's frame), the caller owns the lifetime, andClear()reuses the same stack memory for the next counting round without touching the allocator. Bag semantics mirrorMultiList<T>where the two meet (counted duplicates,CountOf,Removereturns the number remaining, entries packed with an element disappearing at its last copy); adding a new element when full returnsfalse- a sizing condition, not an exception - while an already-present element always fits. Pattern-basedforeachyields the packed(value, count)entries. Measured (BenchmarkDotNet + MemoryDiagnoser, net8.0, 64 reads over 8 distinct values): the whole counting round runs ~1.9x faster than aDictionary<int, int>and allocates 0 B against its 352 B (andPackedBag's 224 B); against aHashSet<int>in a first-duplicate scan the set is slightly faster but pays 392 B per call - the span bag's win is the garbage-free path. Available only whereSpan<T>is in-box (netstandard2.1, net6.0+): the legacy targets would require the external System.Memory package, and the package has stayed dependency-free on every TFM so far.PackedBag<T>- the packed value-type counting histogram: a bag overstructelements (int, enums, small structs) stored as one contiguous array of(value, count)struct entries instead of a hash table - no boxing in storage, one cache line per element, zero steady-state allocation on add and lookup. Bag semantics mirrorMultiList<T>where the two meet: duplicates are counted (Add(item, times),CountOf, copy-expanded enumeration with an element's copies consecutive),Remove(item)takes one copy away and returns the number remaining, and an entry is dropped - keeping the array packed, no zero-count holes - once its last copy goes.EntrySet()/DistinctItems()give the compact histogram views,TotalCount/DistinctCountthe cached counts, plusClone/ToDictionary/TrimExcess. The measured trade-off (BenchmarkDotNet + MemoryDiagnoser, net8.0, againstMultiList<int>): building a 32-value histogram allocates ~2.7x less (608 B vs 1616 B) and runs ~1.4x faster, whole-histogram enumeration is ~1.5x faster with ~1.5x less garbage, and at 4 distinct values point operations are 1.7-2.1x faster; the O(1) dictionary lookup catches up around 16-32 distinct values, so the type targets dense domains of roughly up to ~16 distinct values. Thestructconstraint is the documented boundary -nullandNullable<T>are excluded by design, set operations, equality and comparer injection are deliberately not carried over, andMultiList<T>remains the general-purpose bag.MultiKeyMultiDictionary<TKey, TValue>- the composite-key multimap: a dictionary whose keys are sequences of components of one type (TKey[]) and each complete key maps to a collection of values — the combination ofMultiKeyDictionary<TKey, TValue>(N components → 1 value, a trie) andMultiDictionary<TKey, TValue>(1 key → N values, a multimap). The trie's prefix projection carries over:GetByPrefixenumerates every complete key stored under a partial key together with its live value collection (full keys, or suffix-only withrelative: true),CountOfPrefixcounts those keys,GetSuffixes/GetBranchesexpose the trie's shape, andRemovePrefixcascade-deletes a whole subtree, returning the number of values destroyed. The value side mirrorsMultiDictionary: a configurable inner collection factory (a duplicatingList<TValue>by default, a deduplicatingHashSet<TValue>underallowDuplicateValues: false),AddRange/RemoveRange, the per-key value set operationsUnionWith/IntersectionWith/ExceptWith/SymmetricExceptWith(their argument is a set, exactly as inMultiDictionary),ValueCount(key)plus the O(1) cachedTotalValueCount, and the "no value-less key" invariant — a key is pruned from the trie automatically once its last value is removed, soCount/KeyCountnever report a key without values.nullkey components are supported through the trie's dedicated bucket (anullkey array is rejected withArgumentNullException), andnullvalues are ordinary values. The composite key of this type is a trie key of homogeneous components of any arity, addressable by prefix — not the fixed-arity, per-axis-typed scheme ofTwoKeyDictionary<K1, K2, V>/ThreeKeyDictionary<K1, K2, K3, V>, which remain the strongly typed facades for two or three differently typed components addressed in full. Inner value collections are exposed asIReadOnlyCollection<TValue>through an internal live wrapper rather than a bare cast, so the contract holds on every supported target — including net451 / net461, where the framework'sHashSet<T>does not declare that interface (the hazard this release fixes forMultiDictionary).
- The read-only value views of the multimaps are now served through an internal live wrapper
instead of a bare cast to
IReadOnlyCollection<TValue>(root cure): the affected members areMultiDictionary<TKey, TValue>'s indexer /TryGetValue/AsReadOnly/ToDictionary/ enumerations, itsAsReverse()key collections, and the same surface ofOrderedMultiDictionary<TKey, TValue>. No signature changes anywhere - every member still declares the sameIReadOnlyCollection<TValue>/IReadOnlyDictionary<...>return type, and the views remain live. What changes is the concrete object behind the interface: it used to be the innerList<TValue>/HashSet<TValue>/SortedSet<TValue>itself on runtimes where those declareIReadOnlyCollection<T>(modern .NET), and is now always an internal wrapper. Code that cast the returned view back to the concrete inner collection type (e.g.(HashSet<TValue>)map[key]) - never a supported pattern - will now throw; code working against the declared interfaces is unaffected. The reason for the change: the .NET Framework generation the package supports does not declare the interface onHashSet<T>/SortedSet<T>- the net451/net461 reference assemblies reject a direct assignment (CS0266) and the 4.5.1/4.6.1-era runtimes throwInvalidCastExceptionon the cast - so the bare cast was a runtime crash exactly on the deduplicating inner collections (allowDuplicateValues: false) of those low-generation consumers. The wrapper removes the dependency on the interface declaration entirely, on every target. - Nothing else. The four new types of this cycle (
MultiKeyMultiDictionary<TKey, TValue>,PackedBag<T>,SpanBag<T>,FrequencyPriorityBag<T>) are additive.
BiDictionary<TLeft, TRight>- a strict one-to-one (bijective) 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) by two indexes kept in step on every write path — so a removed or rebound entry frees its partner immediately on the other side. Conflict handling is strict (R3-01 decision):AddthrowsArgumentExceptionwhen the left value already has a binding or when the right value is already bound to a different left value,TryAddreports the same conditions without throwing, and there is deliberately no silently-overwriting setter — overwriting a right value would silently unbind the left value it used to belong to, an entry the caller never mentioned, so breaking an existing binding is always an explicitRemove(left)orRemoveRight(right)first. The type implementsIReadOnlyDictionary<TLeft, TRight>for the forward direction and exposes the reverse direction throughTryGetLeft/GetLeft(right)/ContainsRightplusAsReverse(), a live read-onlyIReadOnlyDictionary<TRight, TLeft>view served from the same indexes.nullis accepted on both sides through dedicated buckets (the binding(null, null)is expressible and occupies one entry);ToDictionary()exports an independent snapshot that omits anull-left binding, because aDictionary<TLeft, TRight>can not key onnull— the remark says so explicitly.ReverseMultiDictionary<V, K>- the inverse ofMultiDictionary<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) (withMultiDictionary.AsReverse()as the live counterpart, see below). The constructors copy the bindings out of the source map into a fully self-contained instance — no reference chain, so the snapshot stays as it was built while the map changes or is dropped, and it is safe to serialize (R3-02 decision: snapshot type + live view, no bidirectional synchronization). The instance is also a standalone mutable collection of its own, and the "no value-less key" invariant ofMultiDictionaryis mirrored — a value disappears automatically once its last key is removed. Per value the keys form a set, so a key that stores one value several times is listed once (multiplicities collapse); value equality on the indexed axis isEqualityComparer<V>.Default(the notion the map's own backwards index uses) and the stored keys compare with the source map's key comparer by default. The member names mirrorMultiDictionarywith the axes swapped:Count/ValueCountcount distinct values,TotalKeyCountcounts distinct bindings,KeyCount(value)counts one value's keys,ContainsValue(value)is the O(1) presence check whileContainsKey(key)scans the stored keys, and a missing value yields an empty collection rather than throwing. Anullvalue of the source surfaces as anullentry of the snapshot through a dedicated bucket, while anullkey is rejected withArgumentNullException— the exact mirror of the source map, which allowsnullvalues and rejectsnullkeys.MultiDictionary<TKey, TValue>.AsReverse()- the live half of the inversion entry points: a read-onlyIReadOnlyDictionary<TValue, IReadOnlyCollection<TKey>>view served directly from the backwards index the map already maintains forContainsValue(plus thenull-value bucket), so building it costs nothing, every read runs in O(1), and every subsequent mutation of the map is visible immediately. The same collapse, comparer andnull-entry semantics asReverseMultiDictionary<V, K>; a value nobody stores yields an empty collection rather than an exception, matching the map's own indexer. The view exposes internal state without copying and is not thread-safe.
- All eleven shipped packages now compile with nullable reference annotations enabled and
ship a fully annotated public API:
DotNetCore.Collections.Multi,DotNetCore.Collections.Paginableand the nine ORM integration packages build warning-free under<Nullable>enable</Nullable>across their whole target matrix — zero CS86xx diagnostics, none silenced withNoWarn— so consumers who enable nullable in their own project get accurate null-state analysis of these signatures instead of oblivious ones. The full audit confirmedDotNetCore.Collections.Multihad been annotated since its rebuild; the annotation work landed in the Paginable module and the integration packages. - One annotation is visible to consumers:
KeysetPage<T>.LastMemberis now declaredT?. It has always returneddefault— i.e.nullfor reference element types — when the page is empty; the annotation now says so, so nullable-enabled callers are prompted to checkCurrentPageSizebefore dereferencing the anchor. No runtime behaviour changed anywhere; this entry is annotation-only. - Low-generation targets (
net451/net461/net47/net48/netstandard2.0) have no in-box nullable attribute types; a single shared internal polyfill of the compiler's nullability attributes (NullableAttribute/NullableContextAttribute/NullablePublicOnlyAttribute,build/NullabilityAttributes.cs) is compiled into every assembly for exactly those targets, mirroring what Roslyn embeds by itself when no definition is reachable.netstandard2.1andnet6.0+define the types in-box and do not take the copy. No target framework was raised and no external package was introduced. - Dead private parameterless constructors on the paging collections (never callable, kept only as ReSharper noise) were removed; they were the only path on which a paging collection could exist with an unassigned internal state.
- None. This release is additive and annotation-only: the three new public types
(
BiDictionary<TLeft,TRight>,ReverseMultiDictionary<V,K>, andMultiDictionary<TKey,TValue>.AsReverse()) extend the surface without changing any existing signature, and the nullable rollout is annotation-only —KeysetPage<T>.LastMemberis now declaredT?but its IL signature and runtime behaviour are unchanged.
Release covering both shipped modules (Paginable and Multi); every package ships version
6.2.0.0 (see build/version.props).
OrderedMultiList<T>- an ordered multiset (sorted bag), the ordered counterpart ofMultiList<T>and the equivalent of PowerCollections'OrderedBag<T>. It sharesMultiList<T>'s copy-counting semantics - one distinct element with N copies, duplicates consecutive and expanded on enumeration, the sameUnionWith/IntersectionWith/ExceptWith/SymmetricExceptWithand subset / superset judgments - and adds an order: enumeration is ascending, and lookup, insertion and removal are O(log n) worst case. Elements are compared with an injectableIComparer<T>, deliberately not anIEqualityComparer<T>: an equality comparer supplies hash codes but no ordering, whereas a red-black tree has to know which of two elements comes first, and it is the comparison result0that decides two elements are the same element (the stored element is the first one added).nullis supported - underComparer<T>.Defaultit sorts first, while a custom comparer decides for itself wherenullbelongs and may reject it.OrderedMultiList<T>ordered access:GetFirst()/GetLast()(each O(log n), throwingInvalidOperationExceptionon an empty multiset),Reverse()for descending enumeration, andGetRange(from, to)plus aGetRange(from, to, inclusiveFrom, inclusiveTo)overload for range queries. Subtrees outside the bounds are skipped, so a query costs O(log n + k) for k copies reported rather than a full traversal. As withMultiList<T>, the type implementsICollection<T>andIReadOnlyCollection<T>(copy-expandedCount), and its set operations treat their argument as a multiset, so multiplicities count.- The O(log n) claim is proved rather than timed. The storage engine is a self-implemented
left-leaning red-black tree, and the test suite asserts the red-black invariants - black
root, no red node with a red child, equal black height, plus the left-leaning rule - together
with the height bound
height <= 2 * log2(n + 1). It re-checks them after every single removal of a 300-key drain, and across 5,000 randomized operations compared with aSortedDictionarymodel; 10,000 sequentially ascending inserts stay under 30 levels where an unbalanced tree would be 10,000 deep. Timing baselines are deliberately avoided - they are noise on CI. - Three things worth stating outright, so that none of them is mistaken for an oversight.
OrderedMultiList<T>does not overrideEquals/GetHashCode: the package's rule is thatMultiList<T>is the only type carrying structural equality, and adding equality later is a non-breaking change while removing it would not be. It has noToDictionary()either, because itsIComparer<T>orders elements but supplies no hash codes for a dictionary to use, soEntrySet()is the export path. And itsAdd(item, times)/Remove(item, times)reject a non-positivetimeswithArgumentOutOfRangeException, matchingMultiList<T>: the legacy "a non-positivetimesis coerced to one copy" behaviour is not inherited (see Breaking). DotNetCore.Collections.Multinow declaresInternalsVisibleTofor its test assembly. The red-black invariants live on internal members and asserting them is how the engine justifies its complexity, so the suite has to reach them. Nothing becomes public: the members stay internal.OrderedMultiDictionary<TKey, TValue>- the ordered counterpart ofMultiDictionary<TKey, TValue>: a multimap whose keys are kept in ascending order by anIComparer<TKey>(aSortedDictionaryaxis) and whose per-key values are kept in ascending order by anIComparer<TValue>(anOrderedMultiList<TValue>in the default duplicating configuration, aSortedSet<TValue>when duplicate values are disallowed). Adding, looking up and removing a single pair costs O(log n) worst case on both axes. The per-key value-set operations (UnionWith/IntersectionWith/ExceptWith/SymmetricExceptWith/RemoveRange) keepMultiDictionary<TKey, TValue>'s semantics verbatim: the argument is a set of values, a value stored N times survivesRemoveRangewith N-1 copies,ExceptWithdrops every occurrence, and a key whose value collection empties is removed automatically. The type also implementsIReadOnlyDictionary<TKey, IReadOnlyCollection<TValue>>and shipsAsLookup()/AsReadOnly()/Clone()/EntrySet(),ValueCount(key)/TotalValueCount,ContainsValue, andToString()walking keys ascending. Identity on both axes is decided by the respective comparers (nullkeys are rejected withArgumentNullException;nullvalues follow the value comparer, sorting first under the default one), and thenotnullkey constraint of the annotatedSortedDictionaryis suppressed file-locally, exactly as inMultiDictionary, soTKeystays nullable-friendly.TwoKeyDictionary<K1, K2, V>now answers its second axis from a maintained reverse index. The underlying trie is keyed by(K1, K2)in that order, so the second component is not a prefix and a subtree walk can not reach it;GetBySecondKey,CountOfSecondKey,ContainsSecondKeyandRemoveBySecondKeytherefore used to scan the whole map. They now read aK2 -> set of K1index kept in step on every write path -Add(both overloads),TryAdd,Remove(both overloads), theRemoveByFirstKeycascade,RemoveBySecondKeyandClear- so only the requested slice is visited: O(1) forCountOfSecondKeyandContainsSecondKey, and O(s) trie lookups for a slice of s entries for the other two. The index uses the same injected comparers as the map (the second one keys the index, the first one the per-second-key set), and anullsecond component gets a dedicated bucket becauseDictionary<TKey, TValue>rejects anullkey. The cost is one extra dictionary operation per write and one set entry per stored pair, plus one temporary list perRemoveByFirstKeycascade.Keys2deliberately still walks the trie, preserving its first-encounter enumeration order. TheGetBySecondKeyXML docs no longer describe the method as O(n).- Explicit serialization entry points for
MultiList<T>andMultiDictionary<TKey, TValue>:ToSerializableModel()and the staticFromModel(), backed by two new plain models.MultiListModel<T>carriesItems(the distinct elements) plusCounts(one multiplicity per item);MultiDictionaryModel<TKey, TValue>carriesKeysplusValues(one value list per key). Both are ordinary mutable POCOs - public settable properties, no attributes, no interface implementations, no base type - so serializing them needs no particular serializer and the library takes a dependency on none;System.Text.Jsonis one option among many rather than a requirement, and the tests assert that the shipped assembly references no serializer at all. Three points are deliberate. The models carry data only: comparers and the multimap's inner-collection strategy are configuration rather than data, so they are not serialized andFromModeltakes them as arguments (the rebuilt collection is therefore only as faithful as the comparer passed back in). The models handle whatToDictionary()can not: anullelement is an ordinary entry inItems, whereasToDictionary()has to throw because anullcan not be a dictionary key (anullkey still can not be rebuilt, since the multimap rejects those). And a model is a snapshot whileAsReadOnly()andToDictionary()'s inner collections are live views, which is the property a serializer needs.FromModelvalidates the model rather than trusting it (null model, null list, mismatched lengths, non-positive copy count, null inner value list), names the offending argument, and merges elements a comparer calls equal in the multiset reading. - Two caches under
MultiDictionary<TKey, TValue>, both invisible from the outside: no signature changed and no behaviour moved, so the full suite passes unchanged and the pair is a pure performance change.TotalValueCountis now a stored count maintained by every mutation rather than a walk over all inner collections, andContainsValueis answered by a backwards index - value → keys - maintained on the write path rather than by scanning every key's values. Both caches are updated by every path that can move them, which is the whole risk of the change:Add(including the branch where a custom inner factory hands back a non-empty collection),AddRange, bothRemoveoverloads,RemoveRange,IntersectionWith,ExceptWith,SymmetricExceptWith,ClearandClone(rebuilt through the publicAddso the caches are maintained rather than copied). Three details are deliberate. Anullvalue is an ordinary value and gets a dedicated bucket, because aDictionary<TValue, …>can not key onnull; value equality isEqualityComparer<TValue>.Default, the same notion the per-key collections use. Key membership in the index uses the map's ownIEqualityComparer<TKey>, so removing"a"clears the entry indexed under"A"when the comparer says they are the same key. And a value index entry is dropped as soon as its last key is gone, soContainsValuenever answers from a value nobody stores. The write-path cost the acceptance criteria asked to measure is pinned the same way the gain is: a value type counts its own equality and hash operations, and the suite asserts that maintaining the index during anAddcosts a constant few, whether 1 or 4,096 values are already stored, while aContainsValueon 4,096 values costs the same as on 64. Allocation of the read paths is asserted to be zero viaGC.GetAllocatedBytesForCurrentThread(). As usual the claim is proved by counting, not by a stopwatch. A 3,000-step randomized differential re-checks both caches after every single step against a naive recomputation. - The set operations of
MultiList<T>no longer copy their argument. Every one of them -UnionWith,IntersectionWith,ExceptWith,SymmetricExceptWith, the four subset/superset judgments, andEquals/GetHashCode's helpers - used to start by materialisingotheras a whole secondMultiList<T>, so a chaineda.UnionWith(b)allocated a full multiset just to read it back; on a 32-element argument that was 1,712-2,440 bytes per call. When the argument already is aMultiList<T>whose element comparer is equivalent to the receiver's, it is now read in place: the operation walks its count table directly, which is a struct-enumerator walk. The three mutating operations additionally stage their target state in a single buffer that is allocated once per instance and reused (cleared afterwards, so it retains no element references). Measured withGC.GetAllocatedBytesForCurrentThread(): every one of the eight operations allocates 0 bytes/call in steady state, and five of them (the four judgments andUnionWith) allocate 0 bytes/call even on a receiver that has never run a set operation before - the remaining three pay ~310 bytes once, for that buffer, on a cold receiver only. Three points are deliberate. An argument of any other shape - an array, a LINQ sequence, or aMultiList<T>built with a different comparer - still goes through the original materialising path, because its multiplicities have to be counted under the receiver's comparer before the operation can define its result; that path is unchanged and still correct, and the tests assert the two argument shapes never disagree. Thenullbucket is handled separately and only touched when it can be non-empty, because for a value element type it is always empty anddefault!would otherwise name the elementdefault(T)-0in aMultiList<int>- and wipe it. And the behaviour is otherwise bit-for-bit what it was:IsSubsetOfBag/IsSupersetOfBagwere rewritten to walk the count tables instead ofEntrySet(), whose iterator allocated on every call, with the subset/superset andnullcomparisons preserved. A 3,000-step randomized differential runs each operation twice, once with a multiset argument and once with an equivalent plain array, and requires the two outcomes to agree at every step. ImmutableMultiList<T>,ImmutableMultiDictionary<TKey, TValue>,ConcurrentMultiDictionary<TKey, TValue>andConcurrentMultiList<T>— the thread-safe faces of the package. The two immutable types wrap a snapshot of the mutable type that is never touched after construction, so reading needs no locks or fences. Every mutation returns a new instance, or the receiver itself when nothing would change (an immutable instance may safely be shared, so "no change" needs no copy); bulk mutation is expected throughToBuilder(). The builder shares the source's state until its first write — a copy-on-write the caller can observe, becauseToImmutable()on an untouched builder hands back the very source instance — and a freeze moves the working state into the frozen instance while the builder continues on a private copy, so writes after a freeze can never leak into it. The two concurrent types keep the mutable semantics under contention.ConcurrentMultiDictionary<TKey, TValue>routes keys to shards, each an independentMultiDictionary<TKey, TValue>behind its own lock, so writes on different keys proceed in parallel, while whole-map reads take a consistent snapshot by locking every shard once, in index order.ConcurrentMultiList<T>deliberately stays single-lock, because a bag has one global state its operations compare against, and sharding would trade that simple semantics for little gain. Enumeration on both concurrent types is over a snapshot, immune to concurrent writes. The stress tests run eight writers over a shared key domain while three readers hammer snapshots and whole-map reads mid-flight; the settled state is exactly predictable, because every writer removes only occurrences it added itself.ThreeKeyDictionary<K1, K2, K3, V>— the three-component counterpart ofTwoKeyDictionary<K1, K2, V>, kept deliberately thin (R2-03): the same axis-tag scheme — every component stored wrapped in a tag recording the position it came from, so three same-typed axes can not collide and each axis's injected comparer is dispatched by position rather than runtime type — a typed three-axis indexer, and per-axis projections (Keys1/Keys2/Keys3). The first axis is a prefix and 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 at O(n), and the XML remarks say so — when a later axis must be queried hard,MultiKeyDictionary<TKey, TValue>with a key order that puts that axis first is the tool. No logic was sunk out ofTwoKeyDictionaryand nothing in it changed.PageCreationOptionsand strict fragment checking. 6.1 tolerated a fragment shorter than its metadata says - a concurrent delete upstream must not make the page unbuildable - and that stays the default:Paginable.CreatePage(fragment, info), the four-argumentCreatePageandfragment.ToPage(...)behave exactly as before. When a short fragment is more likely a bug than a race - a staletotalMemberCount, a fragment sliced by the wrong query - passPageCreationOptions.Strictthrough the new overloadsCreatePage(fragment, info, options)/CreatePage(fragment, pageNumber, pageSize, totalMemberCount, options)/fragment.ToPage(..., options): the same situation throwsArgumentExceptionnaming the fragment. Only the short-fragment behaviour differs - the over-long checks throw in both modes - and anulloptions argument is rejected like any other.Paginable.CreateSinglePageSetandPaginableSinglePage<T>- the set shape of the same fragment API, for callers whose signature wants anIPaginable<T>(or anIEnumerable<IPage<T>>) while the data in hand is one already-assembled page. The fragment is wrapped as-is, soGetPage(1)is exactly the pageCreatePagewould have built, with identical metadata and members, and the same metadata andPageCreationOptionsarguments apply.PageCountis always one, which is deliberately not the wrapped page's ownTotalPageCount: a set must be able to serve every page it claims and this one holds a single page with no source to slice the others from. The set layer therefore answers "how many pages am I handing you" while the page keeps the source-wide numbering (a fragment of page 3 of 12 still reports page 3 of 12), andMemberCountreports the source-wide total, asPaginableSetBase<T>does.GetPageaccepts only one and reports anything else asArgumentOutOfRangeException. The factories return the concretePaginableSinglePage<T>, not the bare interface, becauseIPaginableexposes onlyPageSizeandMemberCount- without the concrete type thePageCountguarantee would be unreadable.Paginable.CreatePageAsync- the fragment entry points in an awaitable shape, so a caller that pages asynchronously does not have to special-case the path where the data is already in hand. It completes synchronously: a fragment is already in memory, soTask.FromResultwraps the same synchronous result and the task is finished before it is returned. Nothing here pretends to be I/O - use a provider-specific async extension when real I/O has to be awaited. Two consequences ofTask.FromResultare documented on every overload because they invert what an…Asyncname usually promises: validation throws synchronously from the call itself rather than through a faulted task, and theCancellationTokenparameter is accepted for signature symmetry but never observed. Both matchToPaginableAsync/GetPageAsyncas they have been since 6.0. All four overloads mirrorCreatePage,PageCreationOptionsincluded.
DotNetCore.Collections.Paginable.SqlKatano longer carries vulnerable transitives in its dependency graph. SqlKata's two usable lines are both frozen with advisories still open underneath them, and neither can be upgraded past the problem:2.2.0is the last release that targetsnet451,3.2.3is what thenetstandard2.x/net6.0/net7.0group can take, and4.xrequiresnet8.0. The three affected transitives are therefore pinned forward, one framework group at a time. Onnet451,NETStandard.Library 1.6.1(via SqlKata 2.2.0) used to supplySystem.Net.Http 4.3.0(CVE-2018-8292) andSystem.Text.RegularExpressions 4.3.0(CVE-2019-0820); they are now4.3.4and4.3.1. Becausenet451takes both of them from the framework — their NuGet assets are the_._placeholders — that pin costs no assembly and only settles the audit. Onnetstandard2.0/2.1/net6.0/net7.0,Dapper 1.50.5(viaSqlKata.Execution 3.2.3) used to pinSystem.Data.SqlClient 4.4.0(CVE-2024-0056 / CVE-2022-41064); it is now4.8.6, the first release past both advisories.net461/net47/net48resolve the twonetstandard1.xtransitives from the framework andnet8.0and above run on SqlKata 4.0.1, so those groups were already clean and are untouched. The audit is settled by fixing the graph rather than byNoWarn, and the raised floors are recorded in the shippedpackages.lock.json.- The
<example>sections ofPaginableCalc.GetRealMemberCount/GetRealPageCountshow correctly-arity samples. Both were added by the same docs pass and the first calledGetRealMemberCount(0, 50, 120)— three arguments for a two-argument method — so the shipped IntelliSense sample did not compile. The pair is now a worked example that mirrors the actual call sites (GetRealMemberCount(limitedMemberCount, count)thenGetRealPageCount(realMemberCount, size)), and the "null means unlimited" contract is stated where a reader meets it.
-
An out-of-range argument is now reported as
ArgumentOutOfRangeExceptionby every paging entry point, replacing theIndexOutOfRangeExceptionthat theGetPagefamily and the keyset (GetFirstPageByKeyset/GetPageByKeyset) family used to throw. The change covers the coreIEnumerable<T>/IQueryable<T>/Task<IQueryable<T>>paths, both keyset extension classes including the EF Core async pair, and all nine ORM integration packages — 40 throw sites in 10 files. It closes a split the 6.1 fragment API opened:Paginable.CreatePage/fragment.ToPagealready answered an out-of-range argument withArgumentOutOfRangeExceptionand named the offending parameter, so the same mistake (pageNumber: 0) reported a different type depending on which entry point the caller happened to use.ArgumentOutOfRangeExceptionderives fromArgumentException, so callers catchingArgumentException(orException) are unaffected; a caller that specifically caught or asserted onIndexOutOfRangeExceptionmust be updated. Only the type changes — the rejection conditions are untouched (an empty source still yields a single empty page), and the exceptions now carryParamName. The XML<exception>docs declare the type on all 21 affected public members, and the 15 new tests inGetPageExceptionTypeTestpin the exact type and parameter name per path. -
MultiList<T>.Add(item, times)/MultiList<T>.Remove(item, times)and theirOrderedMultiList<T>counterparts now throwArgumentOutOfRangeExceptionwhentimesis less than or equal to zero, replacing the legacy behaviour that silently coerced a non-positive count to one copy. The coercion hid mistakes:Add(item, 0)reported success while inserting a copy nobody asked for, andRemove(item, 0)silently removed one. Zero copies is already expressible as a no-op by simply not calling, so any non-positive value is now treated as a programming error and rejected with the offending parameter named. The single-copyAdd(item)/Remove(item)overloads andAddRange(items)are unchanged. The XML<exception>docs declare the behaviour on all four affected members, and the tests pin the exception type on both classes.
Release covering both shipped modules (Paginable and Multi); every package ships version
6.1.0.0 (see build/version.props).
- Paging objects can now be created directly from a materialized fragment plus paging metadata:
Paginable.CreatePage(fragment, pageNumber, pageSize, totalMemberCount), thePageFragmentInfooverload, thePageFragmentInfo.FromMetadata/ToMetadataround trip, and theIEnumerable<T>.ToPage(...)sugar. The fragment is never re-sliced and member item numbers match full-source pagination exactly, so a page built from a Dapper / hand-written SQL result, a cached page or an upstream API response (items+totalCount) is indistinguishable from one sliced out of the full source. The metadata is validated eagerly:nullfragment, non-positive page numbers, an out-of-range page, a negative or oversized total count, and a fragment larger than the page it claims to be are all rejected at construction time. A fragment shorter than the metadata expects is tolerated andCurrentPageSizekeeps reporting the metadata value. Closes #8. MultiList<T>now implementsIEquatable<MultiList<T>>: multiset structural equality, where two multisets are equal when both hold the same distinct elements with the same number of copies in any order, plus aGetHashCodethat agrees with it (structurally equal multisets collapse into a singleHashSet<MultiList<T>>entry). Element matching goes through the comparer of each side, exactly like the existing subset and superset judgments, and copy counts take part, so this is bag equality rather than set equality.Equals(object)is overridden to match;==/!=are deliberately left as reference comparisons, and no otherMultitype gains equality.MultiDictionary<TKey, TValue>can now delete several values under a key in one call, and report how many it holds:RemoveRange(key, values)removes one occurrence per distinct argument value — the batch form ofRemove(key, value), so a value stored N times keeps N-1 copies (ExceptWith(key, values)stays the way to drop every occurrence) — andValueCount(key)returns the number of values stored under a key, or0for a missing key instead of throwing. Both follow the conventions the type already had: the argument is a set,nullelements are ordinary values, matching goes through the inner collection's own comparer, and the key is recycled as soon as its last value is removed. The batch form is a separate name rather than an overloadRemove(key, IEnumerable<TValue>)because that overload is a source-breaking change:map.Remove(key, null)would become ambiguous (CS0121), sincenullconverts to bothTValueandIEnumerable<TValue>;RemoveRangealso mirrors the existingAddRange(key, values).
6.0.0 - 2026-09-10
Modernization release covering both shipped modules (Paginable and Multi).
- Keyset (seek) pagination:
GetFirstPageByKeyset/GetPageByKeysetoverIQueryable<T>andIEnumerable<T>, with an optionaldescendingswitch. Each page is a singleWHERE key > @lastKey ORDER BY key LIMIT @sizequery — noOFFSETscan and noCOUNT(*)round trip. - True end-to-end asynchronous paging for EF Core, FreeSql and SqlSugar
(
CountAsync+ToListAsync, no synchronous database calls), withCancellationTokenpassthrough throughout the core and ORM integrations. Multimodule rewritten:MultiList<T>(multiset / bag) plus a completeMultiDictionary<TKey, TValue>(multimap) withAsLookup(), per-key value set operations and a configurable inner-collection factory.- XML documentation with
<example>and<exception>sections on the public API surface. - Symbol packages (
.snupkg) and SourceLink on every package. - Automated nuget.org publishing through GitHub Actions (
Releaseworkflow).
- Target frameworks expanded to 11 TFMs (
net451,net461,net47,net48,netstandard2.0,netstandard2.1,net6.0–net10.0), now including theMultipackage. Multitargetsnetstandard2.0,netstandard2.1andnet6.0upwards (previouslynetstandard2.0only).- Paginable EF Core integration:
net8.0consumes the EF Core 8.0.x assets andnet9.0the 9.0.x assets (both previously resolved to 9.0.x). PaginableSettingsvalues are validated on assignment;PaginableSettingsManagerswaps an immutable snapshot atomically.- Deterministic builds with locked dependencies (
packages.lock.json).
EnumerablePage: singleSkip/Takematerialization for non-IListsources, removing the O(skip²) cost of per-memberElementAtcalls.CurrentPageSizeinteger-division defect on exact-multiple last pages.MaxMemberItemsboundary is now an open interval (exactly 10,000,000 rows allowed).- Argument validation (
pageNumber >= 1,pageSize >= 1) enforced across the core and every ORM integration. - NHibernate
AllValuesand coreQueryEntryStatenow materialize lazily, once.
PublishToMyget.bat: the MyGet feed is no longer part of the release flow; local publishing is done withscripts/Publish.bat, CI publishing with theReleaseworkflow.
5.0.0 - 2023-11-03
- Target frameworks
net451,net461,netstandard2.1,net5.0. Multimodule introduced (MultiList,MultiDictionary,netstandard2.0).
3.2.0 - 2020-05-16
- Added
DotNetCore.Collections.Paginable.SqlKataintegration. - Split the FreeSql integration into
FreeSqlandFreeSql.DbContext.
2.1.4 - 2019-05-30
- Maintenance release of the 2.x line (Chloe, Dos.ORM, EF6, EF Core, FreeSql, NHibernate and SqlSugar integrations).
2.0.1 - 2019-02-16
- First stable 2.x release of the pagination extensions.
1.0.0-beta1 - 2017-08-03
- Initial preview of the pagination extensions.