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
335 changes: 335 additions & 0 deletions Light.GuardClauses.SingleFile.cs

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions ai-plans/0151-collection-content-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Collection Content Guards

## Rationale

Collections received from deserialization, binding, and caller-supplied arrays frequently have invariants about their contents rather than only their own nullability or size. Callers currently express these checks with manual loops or LINQ, losing the intent and the library's standard exception, nullability, caller-expression, and fluent-return behavior. Add `MustNotContainNull` and `MustNotContainNullOrWhiteSpace` as named collection guards.

The additions must work across every package target, enumerate safely and no more than once, preserve collection shapes, remain Native AOT compatible, and participate in the customizable single-file source distribution.

## Acceptance Criteria

- [x] `MustNotContainNull` and `MustNotContainNullOrWhiteSpace` are available with identical semantics on .NET Standard 2.0, .NET Standard 2.1, and .NET 10.
- [x] `MustNotContainNull` accepts nullable reference collections implementing `IEnumerable`, rejects any null item (including an empty nullable value type boxed through non-generic enumeration), and accepts empty collections and collections containing only non-null items.
- [x] `MustNotContainNullOrWhiteSpace` accepts nullable reference collections of strings and rejects null, empty, or whitespace-only items using the same Unicode whitespace semantics as `string.IsNullOrWhiteSpace`; empty collections and collections of non-blank strings are accepted.
- [x] Both guards preserve and return the original reference collection shape, and dedicated overloads preserve `ImmutableArray<T>` without boxing. A default immutable array is treated as having no items and therefore succeeds.
- [x] Each invocation obtains at most one enumerator, disposes it normally, and stops at the first invalid item. Failure-message construction does not enumerate the input again, and validation uses constant additional space.
- [x] Common indexable receivers such as arrays and `List<T>` are inspected by index without allocating an enumerator; arbitrary enumerable receivers retain the single-enumerator fallback.
- [x] A null reference collection passed to a default overload throws `ArgumentNullException`, while invalid null or blank content throws `ExistingItemException`. Default messages identify the guarded expression, the failure category, and the zero-based position of the first offending item without rendering the whole collection.
- [x] Every overload returns the successfully validated input, captures the guarded expression for default exceptions, accepts an optional custom message, and has a custom-exception-factory counterpart consistent with the existing API and the receiver shape.
- [x] Automated tests cover empty, valid, and failing inputs; arrays, `List<T>`, lazy/single-use enumerables, nullable reference and value-type items, and default/empty/non-empty immutable arrays; null, empty, ASCII-whitespace, and Unicode-whitespace strings; null receivers; default exceptions and messages; custom messages and factories; caller argument expressions; fluent return types; early termination; single enumeration; and enumerator disposal.
- [x] Microbenchmarks compare both guards' indexable `List<T>` fast paths with equivalent imperative loops and report allocations.
- [x] The source-export whitelist catalog and committed settings contain both assertion families, and focused source-export tests verify their guards, exception and throw-helper reachability, immutable-array overloads, and exception-factory trimming.
- [x] The committed .NET Standard 2.0 single-file distribution is regenerated and validates with the new portable API surface.
- [x] XML documentation and the assertion overview describe the supported receiver shapes, vacuous success for empty/default immutable collections, the guards' O(n), constant-additional-space, single-pass, short-circuiting enumeration behavior, and the boxing incurred when `MustNotContainNull` enumerates value-type items through non-generic `IEnumerable`.
- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK.

## Technical Details

Use one `Check.<Assertion>.cs` file per family. The null-content guard can preserve any reference receiver through non-generic enumeration because it needs no item type; the string guard can likewise infer the receiver while fixing the item type in its constraint (illustrative signatures):

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

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

Add factory counterparts receiving `TCollection?`, with the established `[ContractAnnotation]` and nullable-flow attributes. Add dedicated `ImmutableArray<T>` / `ImmutableArray<string?>` overloads and factories so the value-type receiver is neither boxed nor shape-erased. Checking each item with `is null` covers reference types and `Nullable<T>` values through `IEnumerable`; this non-generic enumeration boxes non-null value-type items, so call out that cost in the public XML remarks rather than implying the guard is allocation-free for those collections. The string family should use the existing `IsNullOrWhiteSpace` predicate. Do not compose the string guard from multiple guards, because that would enumerate a lazy input more than once.

Inspect indexable receivers without obtaining an enumerator: use non-generic `IList` for the null-content guard and `IList<string?>` / `IReadOnlyList<string?>` for the string guard, and index dedicated immutable-array overloads directly. This covers arrays, `List<T>`, and other common list shapes while retaining the receiver-preserving public signatures. Fall back to a direct `foreach`-style loop for arbitrary enumerable receivers and short-circuit on the first failure. Do not pre-count or call LINQ `Any` or `Count`; a lazy or stateful enumerable must be observed only once. Keep a zero-based position while inspecting and pass the discovered value/category and position to throw helpers; throw helpers must not append collection contents because doing so would re-enumerate a possibly one-shot input.

Reuse `ExistingItemException` for prohibited null and blank items, with focused throw helpers for the two message forms. Custom messages replace the generated message exactly as in existing guards. A null collection follows `MustNotBeNull`; custom factories are invoked for both a null receiver and invalid contents. A default immutable array is handled before enumeration and succeeds, consistent with `MustNotContain`; callers that also need initialization or non-emptiness can compose `MustNotBeDefaultOrEmpty`.

Add both names to `AssertionWhitelist` and `settings.json`, cover dependency reachability and factory removal in `SourceFileMergerWhitelistTests`, and regenerate `Light.GuardClauses.SingleFile.cs` for .NET Standard 2.0. Document the new families in the collections section of `docs/assertion-overview.md`, and put the enumeration behavior and complexity in each public method's XML remarks. Boolean predicate counterparts are out of scope: the requested value is the named throwing precondition, while `Any` already covers non-throwing inspection.
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;

namespace Light.GuardClauses.Performance.CollectionAssertions;

[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[CategoriesColumn]
public class CollectionContentGuardBenchmark
{
public List<string> Collection = ["Alpha", "Beta", "Gamma", "Delta"];

[Benchmark(Baseline = true)]
[BenchmarkCategory(nameof(MustNotContainNull))]
public List<string> ImperativeNullCheck()
{
for (var i = 0; i < Collection.Count; ++i)
{
if (Collection[i] is null)
{
throw new InvalidOperationException();
}
}

return Collection;
}

[Benchmark]
[BenchmarkCategory(nameof(MustNotContainNull))]
public List<string> MustNotContainNull() => Collection.MustNotContainNull();

[Benchmark(Baseline = true)]
[BenchmarkCategory(nameof(MustNotContainNullOrWhiteSpace))]
public List<string> ImperativeNullOrWhiteSpaceCheck()
{
for (var i = 0; i < Collection.Count; ++i)
{
if (string.IsNullOrWhiteSpace(Collection[i]))
{
throw new InvalidOperationException();
}
}

return Collection;
}

[Benchmark]
[BenchmarkCategory(nameof(MustNotContainNullOrWhiteSpace))]
public List<string> MustNotContainNullOrWhiteSpace() => Collection.MustNotContainNullOrWhiteSpace();
}
4 changes: 4 additions & 0 deletions docs/assertion-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,16 @@ percentage.MustBeIn(Range.FromExclusive(0).ToInclusive(100));
| `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 |
| `MustNotContainNull` | Reject null items in reference collections implementing non-generic `IEnumerable`, with a dedicated `ImmutableArray<T>` overload |
| `MustNotContainNullOrWhiteSpace` | Reject null, empty, or Unicode-whitespace-only strings in reference collections implementing `IEnumerable<string?>`, with a dedicated immutable-array overload |
| `MustContainKey`, `MustNotContainKey` | Require or reject a dictionary key via `ContainsKey`, never enumerating and honoring the dictionary's key comparer; accept `IReadOnlyDictionary<TKey, TValue>` receivers, with dedicated overloads preserving the `Dictionary<TKey, TValue>` shape |
| `MustNotBeDefaultOrEmpty` | Requires an initialized, non-empty `ImmutableArray<T>` |
| `MustHaveLength`, `MustHaveLengthIn`, `MustHaveMinimumLength`, `MustHaveMaximumLength` | Validate string, span, or immutable-array length as provided by their overloads |

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.

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

## Text, character, span, and memory assertions
Expand Down
170 changes: 170 additions & 0 deletions src/Light.GuardClauses/Check.MustNotContainNull.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System;
using System.Collections;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
using Light.GuardClauses.ExceptionFactory;
using Light.GuardClauses.Exceptions;
using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute;

namespace Light.GuardClauses;

public static partial class Check
{
/// <summary>
/// Ensures that the collection does not contain a null item, or otherwise throws an <see cref="ExistingItemException" />.
/// </summary>
/// <param name="parameter">The collection to be checked.</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>
/// <exception cref="ExistingItemException">Thrown when <paramref name="parameter" /> contains a null item.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="parameter" /> is null.</exception>
/// <remarks>
/// This method inspects the collection once, stops at the first null item, runs in O(n) time, and uses constant
/// additional space. <see cref="IList" /> receivers are inspected by index without allocating an enumerator; other
/// receivers are enumerated once. Empty collections succeed. Non-generic access boxes value-type items.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")]
public static TCollection MustNotContainNull<TCollection>(
[NotNull] [ValidatedNotNull] this TCollection? parameter,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
) where TCollection : class, IEnumerable
{
var position = FindNullItem(parameter.MustNotBeNull(parameterName, message));
if (position >= 0)
{
Throw.NullItem(position, parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that the collection does not contain a null item, or otherwise throws your custom exception.
/// </summary>
/// <param name="parameter">The collection to be checked.</param>
/// <param name="exceptionFactory">The delegate that creates your custom exception. <paramref name="parameter" /> is passed to this delegate.</param>
/// <exception cref="Exception">Your custom exception thrown when <paramref name="parameter" /> is null or contains a null item.</exception>
/// <remarks>
/// This method inspects the collection once, stops at the first null item, runs in O(n) time, and uses constant
/// additional space. <see cref="IList" /> receivers are inspected by index without allocating an enumerator; other
/// receivers are enumerated once. Empty collections succeed. Non-generic access boxes value-type items.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; exceptionFactory:null => halt")]
public static TCollection MustNotContainNull<TCollection>(
[NotNull] [ValidatedNotNull] this TCollection? parameter,
Func<TCollection?, Exception> exceptionFactory
) where TCollection : class, IEnumerable
{
if (parameter is null)
{
Throw.CustomException(exceptionFactory, parameter);
}

if (FindNullItem(parameter) >= 0)
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}

/// <summary>
/// Ensures that the <see cref="ImmutableArray{T}" /> does not contain a null item, or otherwise throws an <see cref="ExistingItemException" />.
/// </summary>
/// <param name="parameter">The immutable array to be checked.</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>
/// <exception cref="ExistingItemException">Thrown when <paramref name="parameter" /> contains a null item.</exception>
/// <remarks>
/// This method inspects an initialized array by index without allocating an enumerator, stops at the first null
/// item, runs in O(n) time, and uses constant additional space. Empty and default immutable arrays succeed.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ImmutableArray<T> MustNotContainNull<T>(
this ImmutableArray<T> parameter,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
)
{
if (parameter.IsDefault)
{
return parameter;
}

for (var position = 0; position < parameter.Length; ++position)
{
if (parameter[position] is null)
{
Throw.NullItem(position, parameterName, message);
}
}

return parameter;
}

/// <summary>
/// Ensures that the <see cref="ImmutableArray{T}" /> does not contain a null item, or otherwise throws your custom exception.
/// </summary>
/// <param name="parameter">The immutable array to be checked.</param>
/// <param name="exceptionFactory">The delegate that creates your custom exception. <paramref name="parameter" /> is passed to this delegate.</param>
/// <exception cref="Exception">Your custom exception thrown when <paramref name="parameter" /> contains a null item.</exception>
/// <remarks>
/// This method inspects an initialized array by index without allocating an enumerator, stops at the first null
/// item, runs in O(n) time, and uses constant additional space. Empty and default immutable arrays succeed.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("exceptionFactory:null => halt")]
public static ImmutableArray<T> MustNotContainNull<T>(
this ImmutableArray<T> parameter,
Func<ImmutableArray<T>, Exception> exceptionFactory
)
{
if (parameter.IsDefault)
{
return parameter;
}

for (var position = 0; position < parameter.Length; ++position)
{
if (parameter[position] is null)
{
Throw.CustomException(exceptionFactory, parameter);
}
}

return parameter;
}

private static int FindNullItem(IEnumerable parameter)
{
if (parameter is IList list)
{
for (var position = 0; position < list.Count; ++position)
{
if (list[position] is null)
{
return position;
}
}

return -1;
}

var currentPosition = 0;
foreach (var item in parameter)
{
if (item is null)
{
return currentPosition;
}

++currentPosition;
}

return -1;
}
}
Loading
Loading