Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 92 additions & 6 deletions Light.GuardClauses.SingleFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4440,7 +4440,7 @@ public static TStream MustBeReadable<TStream>([NotNull, ValidatedNotNull] this T
Throw.ArgumentNull(parameterName, message);
}

if (parameter.CanRead == false)
if (!parameter.CanRead)
{
Throw.MustBeReadable(parameterName, message);
}
Expand All @@ -4460,7 +4460,7 @@ public static TStream MustBeReadable<TStream>([NotNull, ValidatedNotNull] this T
public static TStream MustBeReadable<TStream>([NotNull, ValidatedNotNull] this TStream? parameter, Func<TStream?, Exception> exceptionFactory)
where TStream : Stream
{
if (parameter is null || parameter.CanRead == false)
if (parameter is null || !parameter.CanRead)
{
Throw.CustomException(exceptionFactory, parameter);
}
Expand Down Expand Up @@ -4525,7 +4525,7 @@ public static TStream MustBeSeekable<TStream>([NotNull, ValidatedNotNull] this T
Throw.ArgumentNull(parameterName, message);
}

if (parameter.CanSeek == false)
if (!parameter.CanSeek)
{
Throw.MustBeSeekable(parameterName, message);
}
Expand All @@ -4545,7 +4545,7 @@ public static TStream MustBeSeekable<TStream>([NotNull, ValidatedNotNull] this T
public static TStream MustBeSeekable<TStream>([NotNull, ValidatedNotNull] this TStream? parameter, Func<TStream?, Exception> exceptionFactory)
where TStream : Stream
{
if (parameter is null || parameter.CanSeek == false)
if (parameter is null || !parameter.CanSeek)
{
Throw.CustomException(exceptionFactory, parameter);
}
Expand Down Expand Up @@ -5373,7 +5373,7 @@ public static TStream MustBeWritable<TStream>([NotNull, ValidatedNotNull] this T
Throw.ArgumentNull(parameterName, message);
}

if (parameter.CanWrite == false)
if (!parameter.CanWrite)
{
Throw.MustBeWritable(parameterName, message);
}
Expand All @@ -5393,7 +5393,7 @@ public static TStream MustBeWritable<TStream>([NotNull, ValidatedNotNull] this T
public static TStream MustBeWritable<TStream>([NotNull, ValidatedNotNull] this TStream? parameter, Func<TStream?, Exception> exceptionFactory)
where TStream : Stream
{
if (parameter is null || parameter.CanWrite == false)
if (parameter is null || !parameter.CanWrite)
{
Throw.CustomException(exceptionFactory, parameter);
}
Expand Down Expand Up @@ -6765,6 +6765,85 @@ public static Uri MustHaveOneSchemeOf<TCollection>([NotNull][ValidatedNotNull] t
return parameter;
}

/// <summary>
/// Ensures that the collection has the same number of items as <paramref name = "otherCollection"/>, or otherwise
/// throws an <see cref = "InvalidCollectionCountException"/>.
/// </summary>
/// <param name = "parameter">The collection to be checked.</param>
/// <param name = "otherCollection">The collection whose count is compared with <paramref name = "parameter"/>.</param>
/// <param name = "parameterName">The name of the parameter (optional).</param>
/// <param name = "message">The message that will be passed to the resulting exception (optional).</param>
/// <returns>The original collection.</returns>
/// <exception cref = "InvalidCollectionCountException">Thrown when the collections have different counts.</exception>
/// <exception cref = "ArgumentNullException">Thrown when <paramref name = "parameter"/> or <paramref name = "otherCollection"/> is null.</exception>
/// <remarks>
/// The collections may have different concrete and element types, and their contents are not compared. Count-property
/// fast paths do not enumerate. Other enumerable implementations are enumerated at most once each, and their
/// enumerators are disposed. Comparing a collection with itself does not enumerate it. The operation uses O(n) time
/// for enumerated inputs and constant additional space.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; otherCollection:null => halt")]
public static TCollection MustHaveSameCountAs<TCollection, TOtherCollection>([NotNull][ValidatedNotNull] this TCollection? parameter, [NotNull][ValidatedNotNull] TOtherCollection? otherCollection, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null)
where TCollection : class, IEnumerable where TOtherCollection : class, IEnumerable
{
var validatedParameter = parameter.MustNotBeNull(parameterName, message);
var validatedOtherCollection = otherCollection.MustNotBeNull(message: message);
if (ReferenceEquals(validatedParameter, validatedOtherCollection))
{
return validatedParameter;
}

var count = validatedParameter.Count();
var otherCount = validatedOtherCollection.Count();
if (count != otherCount)
{
Throw.CollectionCountsNotEqual(count, otherCount, parameterName, message);
}

return validatedParameter;
}

/// <summary>
/// Ensures that the collection has the same number of items as <paramref name = "otherCollection"/>, or otherwise
/// throws your custom exception.
/// </summary>
/// <param name = "parameter">The collection to be checked.</param>
/// <param name = "otherCollection">The collection whose count is compared with <paramref name = "parameter"/>.</param>
/// <param name = "exceptionFactory">The delegate that creates your custom exception. Both original collections are passed to this delegate.</param>
/// <returns>The original collection.</returns>
/// <exception cref = "Exception">Your custom exception thrown when either collection is null or the collections have different counts.</exception>
/// <remarks>
/// The collections may have different concrete and element types, and their contents are not compared. Count-property
/// fast paths do not enumerate. Other enumerable implementations are enumerated at most once each, and their
/// enumerators are disposed. Comparing a collection with itself does not enumerate it. The operation uses O(n) time
/// for enumerated inputs and constant additional space.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; otherCollection:null => halt; exceptionFactory:null => halt")]
public static TCollection MustHaveSameCountAs<TCollection, TOtherCollection>([NotNull][ValidatedNotNull] this TCollection? parameter, [NotNull][ValidatedNotNull] TOtherCollection? otherCollection, Func<TCollection?, TOtherCollection?, Exception> exceptionFactory)
where TCollection : class, IEnumerable where TOtherCollection : class, IEnumerable
{
if (parameter is null || otherCollection is null)
{
Throw.CustomException(exceptionFactory, parameter, otherCollection);
}

if (ReferenceEquals(parameter, otherCollection))
{
return parameter;
}

var count = parameter.Count();
var otherCount = otherCollection.Count();
if (count != otherCount)
{
Throw.CustomException(exceptionFactory, parameter, otherCollection);
}

return parameter;
}

/// <summary>
/// Ensures that the <paramref name = "parameter"/> has the specified scheme, or otherwise throws an <see cref = "InvalidUriSchemeException"/>.
/// </summary>
Expand Down Expand Up @@ -11314,6 +11393,13 @@ internal static class Throw
[DoesNotReturn]
public static void CollectionCountNotInRange(IEnumerable parameter, int actualCount, Range<int> range, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) => throw new InvalidCollectionCountException(parameterName, message ?? $"{parameterName ?? "The collection"} must have its count between {range.CreateRangeDescriptionText("and")}, but it actually has count {actualCount}.");
/// <summary>
/// Throws the default <see cref = "InvalidCollectionCountException"/> indicating that two collections have different
/// counts, using the already observed counts and optional parameter name and message.
/// </summary>
[ContractAnnotation("=> halt")]
[DoesNotReturn]
public static void CollectionCountsNotEqual(int count, int otherCount, string? parameterName = null, string? message = null) => throw new InvalidCollectionCountException(parameterName, message ?? $"{parameterName ?? "The collection"} must have the same count as the comparison collection, but its count is {count} and the comparison collection's count is {otherCount}.");
/// <summary>
/// Throws the exception that is returned by <paramref name = "exceptionFactory"/>.
/// </summary>
[ContractAnnotation("=> halt")]
Expand Down
49 changes: 49 additions & 0 deletions ai-plans/0158-collection-count-equality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Collection Count Equality

## Rationale

Parallel collections such as key/value arrays and correlated batch inputs must often contain the same number of items. Callers currently have to obtain and compare both counts manually, losing Light.GuardClauses' standard null handling, exception contract, caller-expression capture, and fluent return value. Add `MustHaveSameCountAs` as the cross-collection counterpart to `MustHaveCount`.

The guard must support collections with different element and concrete types, preserve the receiver's concrete type, reuse the existing optimized count infrastructure, and remain safe for lazy or single-use enumerables.

## Acceptance Criteria

- [x] `MustHaveSameCountAs` is available with identical semantics on .NET Standard 2.0, .NET Standard 2.1, and .NET 10 for any two reference collections implementing `IEnumerable`, including collections with different element types.
- [x] The guard accepts equal counts, including two empty collections, and returns the original receiver in its concrete type without comparing collection contents.
- [x] A null receiver or comparison collection throws `ArgumentNullException` by default; unequal counts throw `InvalidCollectionCountException` whose parameter name identifies the receiver and whose default message reports both observed counts.
- [x] The default overload captures the receiver expression, accepts an optional custom message, and the custom-exception-factory overload receives both original nullable collections and is used for null inputs as well as unequal counts.
- [x] Count-property fast paths supported by the existing helpers do not enumerate their inputs; each arbitrary enumerable is enumerated at most once per invocation, its enumerator is disposed, and failure-message construction does not enumerate either input again.
- [x] Automated tests cover equal and unequal counts; empty inputs; different collection and element types; null values in both argument positions; default exception details; custom messages and exception factories; caller argument expressions; fluent concrete-type preservation; count-property fast paths; same-instance inputs; and distinct lazy/single-use enumerables.
- [x] The source-export whitelist catalog and committed settings contain `MustHaveSameCountAs`, and focused source-export tests cover its count-helper, throw-helper, exception, and custom-factory reachability and trimming.
- [x] The committed .NET Standard 2.0 single-file distribution is regenerated with the new guard and validates for both supported source-export targets.
- [x] XML documentation and the assertion overview describe the cross-collection semantics, supported receiver shapes, null and failure behavior, fluent return value, and enumeration characteristics.
- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK.

## Technical Details

Add the assertion as a new `Check.MustHaveSameCountAs.cs` family. Use separate generic parameters for the receiver and comparison collection so a call such as `keys.MustHaveSameCountAs(values)` works when the arrays have different element types while retaining the exact receiver type. The intended public shape is:

```csharp
public static TCollection MustHaveSameCountAs<TCollection, TOtherCollection>(
[NotNull, ValidatedNotNull] this TCollection? parameter,
[NotNull, ValidatedNotNull] TOtherCollection? otherCollection,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
) where TCollection : class, IEnumerable
where TOtherCollection : class, IEnumerable;

public static TCollection MustHaveSameCountAs<TCollection, TOtherCollection>(
[NotNull, ValidatedNotNull] this TCollection? parameter,
[NotNull, ValidatedNotNull] TOtherCollection? otherCollection,
Func<TCollection?, TOtherCollection?, Exception> exceptionFactory
) where TCollection : class, IEnumerable
where TOtherCollection : class, IEnumerable;
```

Apply the established aggressive-inlining, nullable-flow, and JetBrains contract annotations. Validate both references before observing either collection; the receiver retains the captured caller expression, while a null comparison collection follows the repository convention of naming the secondary argument itself. The custom factory is invoked once and receives the original values when either reference is null or the counts differ. Comparing a collection with itself is a tautology and should return immediately after null validation, without enumerating it.

Obtain each count through `FrameworkExtensions.EnumerableExtensions.Count`, not LINQ. This retains the existing O(1), allocation-free paths for non-generic `ICollection` receivers and strings and falls back to enumeration for other `IEnumerable` implementations. Store both counts and pass them to a focused non-returning throw helper that creates `InvalidCollectionCountException`; do not call the existing collection-rendering/counting failure path if it would recount an input. The generated message should state that the receiver must have the same count as the comparison collection and include both precomputed counts without formatting either collection.

Keep this family scoped to reference `IEnumerable` collections, matching `MustHaveCount`. Span, memory, and `ImmutableArray<T>` values use the library's length terminology and are not part of this request; no item-type equality constraint or content comparison should be introduced.

Add the assertion to `AssertionWhitelist` and `settings.json`, verify dependency reachability and removal of its factory overload when configured, and regenerate `Light.GuardClauses.SingleFile.cs` as the portable artifact. Add the guard to the collections table in `docs/assertion-overview.md`; detailed complexity and single-pass behavior belong in the public XML remarks.
3 changes: 3 additions & 0 deletions docs/assertion-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ percentage.MustBeIn(Range.FromExclusive(0).ToInclusive(100));
| `MustNotBeNullOrEmpty` | Requires a non-null, non-empty collection or string |
| `MustHaveCount` | Requires an exact collection count |
| `MustHaveCountIn` | Requires a collection count within an inclusive/exclusive `Range<int>` |
| `MustHaveSameCountAs` | Requires two reference collections implementing `IEnumerable` to have equal counts, even when their concrete and element types differ |
| `MustHaveMinimumCount`, `MustHaveMaximumCount` | Enforce inclusive collection-count bounds |
| `IsOneOf`, `MustBeOneOf`, `MustNotBeOneOf` | Test or enforce membership among supplied values |
| `MustContain`, `MustNotContain` | Require or reject an item in collections and immutable arrays; string overloads operate on substrings |
Expand All @@ -99,6 +100,8 @@ percentage.MustBeIn(Range.FromExclusive(0).ToInclusive(100));

Collection overloads preserve and return the original collection shape where possible. Check the XML documentation before using an `IEnumerable` guard in a hot path; annotations identify guards that do not enumerate.

`MustHaveSameCountAs` validates both collection references before observing either count, returns the original receiver, and compares counts without comparing contents. Null inputs throw `ArgumentNullException`, while differing counts throw `InvalidCollectionCountException`. Existing count-property fast paths avoid enumeration; otherwise, each input is enumerated at most once and its enumerator is disposed. Comparing a collection with itself returns immediately without enumeration.

The two collection-content guards treat empty collections and empty or default immutable arrays as valid. Arrays, lists, and other supported indexable receivers are inspected by index without allocating an enumerator; arbitrary enumerable receivers are enumerated at most once and dispose that enumerator normally. Both paths stop at the first invalid item, use O(n) time, and require constant additional space. `MustNotContainNull` uses non-generic item access for reference collections so it can preserve any receiver shape without knowing the item type; consequently, value-type items are boxed by that overload. Its dedicated immutable-array overload uses generic indexed access and avoids that cost.

The dictionary key guards bind to any dictionary type implementing `IReadOnlyDictionary<TKey, TValue>` (such as `ConcurrentDictionary`, `SortedDictionary`, `ReadOnlyDictionary`, `ImmutableDictionary`, and `FrozenDictionary` on .NET 10). A receiver statically typed as `IDictionary<TKey, TValue>` cannot use them; call `dictionary.Keys.MustContain(key)` as a workaround — the key collections of the BCL dictionaries implement `ICollection<TKey>.Contains` via `ContainsKey`, so this stays O(1).
Expand Down
Loading
Loading