Skip to content

Commit 83ff8f1

Browse files
authored
Merge pull request #169 from feO2x/167-MustBeAssignableTo
MustBeAssignableTo
2 parents 1bcb39e + 18daf30 commit 83ff8f1

11 files changed

Lines changed: 464 additions & 2 deletions

File tree

Light.GuardClauses.SingleFile.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2083,6 +2083,63 @@ public static ReadOnlyMemory<byte> MustBeAscii(this ReadOnlyMemory<byte> paramet
20832083
return parameter;
20842084
}
20852085

2086+
/// <summary>
2087+
/// Ensures that values of <paramref name = "parameter"/> can be assigned to variables of
2088+
/// <paramref name = "requiredType"/>, or otherwise throws an <see cref = "ArgumentException"/>.
2089+
/// </summary>
2090+
/// <param name = "parameter">The candidate type to be checked.</param>
2091+
/// <param name = "requiredType">The type to which values of the candidate type must be assignable.</param>
2092+
/// <param name = "parameterName">The name of the parameter (optional).</param>
2093+
/// <param name = "message">The message that will be passed to the resulting exception (optional).</param>
2094+
/// <returns>The original candidate type.</returns>
2095+
/// <exception cref = "ArgumentException">
2096+
/// Thrown when values of <paramref name = "parameter"/> cannot be assigned to variables of
2097+
/// <paramref name = "requiredType"/>.
2098+
/// </exception>
2099+
/// <exception cref = "ArgumentNullException">
2100+
/// Thrown when <paramref name = "parameter"/> or <paramref name = "requiredType"/> is null.
2101+
/// </exception>
2102+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
2103+
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt")]
2104+
public static Type MustBeAssignableTo([NotNull][ValidatedNotNull] this Type? parameter, [NotNull][ValidatedNotNull] Type? requiredType, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null)
2105+
{
2106+
parameter.MustNotBeNull(parameterName, message);
2107+
requiredType.MustNotBeNull(nameof(requiredType), message);
2108+
if (!requiredType.IsAssignableFrom(parameter))
2109+
{
2110+
Throw.MustBeAssignableTo(parameter, requiredType, parameterName, message);
2111+
}
2112+
2113+
return parameter;
2114+
}
2115+
2116+
/// <summary>
2117+
/// Ensures that values of <paramref name = "parameter"/> can be assigned to variables of
2118+
/// <paramref name = "requiredType"/>, or otherwise throws your custom exception.
2119+
/// </summary>
2120+
/// <param name = "parameter">The candidate type to be checked.</param>
2121+
/// <param name = "requiredType">The type to which values of the candidate type must be assignable.</param>
2122+
/// <param name = "exceptionFactory">
2123+
/// The delegate that creates your custom exception. The original candidate and required types are passed to this
2124+
/// delegate.
2125+
/// </param>
2126+
/// <returns>The original candidate type.</returns>
2127+
/// <exception cref = "Exception">
2128+
/// Your custom exception thrown when either type is null or the candidate type is not assignable to the required
2129+
/// type.
2130+
/// </exception>
2131+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
2132+
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt; exceptionFactory:null => halt")]
2133+
public static Type MustBeAssignableTo([NotNull][ValidatedNotNull] this Type? parameter, [NotNull][ValidatedNotNull] Type? requiredType, Func<Type?, Type?, Exception> exceptionFactory)
2134+
{
2135+
if (parameter is null || requiredType is null || !requiredType.IsAssignableFrom(parameter))
2136+
{
2137+
Throw.CustomException(exceptionFactory, parameter, requiredType);
2138+
}
2139+
2140+
return parameter;
2141+
}
2142+
20862143
/// <summary>
20872144
/// Ensures that the string is standard Base64 with valid padding. Space, tab, carriage return, and line feed are ignored.
20882145
/// Empty and whitespace-only strings are valid.
@@ -11800,6 +11857,13 @@ public static void InvalidMinimumImmutableArrayLength<T>(ImmutableArray<T> param
1180011857
[DoesNotReturn]
1180111858
public static void MustBeApproximately<T>(T parameter, T other, T tolerance, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) => throw new ArgumentOutOfRangeException(parameterName, message ?? $"{parameterName ?? "The value"} must be approximately equal to {other} with a tolerance of {tolerance}, but it actually is {parameter}.");
1180211859
/// <summary>
11860+
/// Throws the default <see cref = "ArgumentException"/> indicating that values of the candidate type cannot be
11861+
/// assigned to variables of the required type, using the optional parameter name and message.
11862+
/// </summary>
11863+
[ContractAnnotation("=> halt")]
11864+
[DoesNotReturn]
11865+
public static void MustBeAssignableTo(Type parameter, Type requiredType, string? parameterName = null, string? message = null) => throw new ArgumentException(message ?? $"Values of type \"{parameter}\" must be assignable to variables of type \"{requiredType}\", but they are not.", parameterName);
11866+
/// <summary>
1180311867
/// Throws the default <see cref = "ArgumentOutOfRangeException"/> indicating that a comparable value must be greater
1180411868
/// than the given boundary value, using the optional parameter name and message.
1180511869
/// </summary>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Type-Assignability Assertion
2+
3+
## Rationale
4+
5+
Parent issue #162 (point 3) identifies six BrilliantMessaging guards that reject a `Type` unless it can be assigned to a required base type or interface. The existing type-relation predicates do not provide a direct throwing assertion, so callers must translate the relationship into a boolean condition and choose the correct comparison direction themselves.
6+
7+
Add `MustBeAssignableTo` with the exact semantics of `Type.IsAssignableFrom`, expressed in the fluent direction `candidateType.MustBeAssignableTo(requiredType)`, and keep it available on `netstandard2.0`. Do not add `MustImplement`: the assignability guard already covers interfaces, while a second API would duplicate behavior and risk suggesting the open-generic equivalence semantics of the existing `Implements` predicate.
8+
9+
## Acceptance Criteria
10+
11+
- [x] `candidateType.MustBeAssignableTo(requiredType, parameterName, message)` returns the original candidate `Type` when `requiredType.IsAssignableFrom(candidateType)` is true and throws `ArgumentException` when it is false, identically on .NET Standard 2.0, .NET Standard 2.1, and .NET 10; the exception exposes the candidate parameter name and honors an optional custom message.
12+
- [x] The default overload throws `ArgumentNullException` when either the candidate or required type is null, attributing a null candidate to the caller-captured parameter name and a null required type to `requiredType`; no new public exception type is introduced.
13+
- [x] A custom-exception-factory overload accepts `Func<Type?, Type?, Exception>`, passes the original candidate and required types to the factory, invokes it only when either input is null or the assignability check fails, and a null factory on a failing check throws `ArgumentNullException` via the existing `Throw.CustomException` convention.
14+
- [x] Automated tests cover identity, direct and indirect base-class relationships, interface implementation, value types, variant generics, and representative open-generic BCL semantics; reversed and unrelated failure cases; both null inputs; return-value identity; parameter-name and custom-message propagation; the factory arguments and concrete exception; no factory invocation on success; null-factory behavior; and nullable-flow analysis.
15+
- [x] No `MustImplement` convenience assertion is added; interface assignability is documented and tested through `MustBeAssignableTo`.
16+
- [x] The source-export whitelist catalog and committed settings contain `MustBeAssignableTo`, and focused source-export tests cover retention of the guard, its throw helper, and the two-argument custom-exception helper as well as trimming of the exception-factory overload when configured.
17+
- [x] The committed .NET Standard 2.0 single-file distribution is regenerated with the assertion and validates for both supported source-export targets.
18+
- [x] The type-relation assertion documentation lists `MustBeAssignableTo`, and the package release notes mention the new guard.
19+
- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK.
20+
21+
## Technical Details
22+
23+
Add `Check.MustBeAssignableTo.cs` using the conventions of the other value-returning guards (aggressive inlining, JetBrains contract annotations, nullable flow annotations, caller-argument-expression capture, and XML documentation). The exact public shape is:
24+
25+
```csharp
26+
public static Type MustBeAssignableTo(
27+
[NotNull] [ValidatedNotNull] this Type? parameter,
28+
[NotNull] [ValidatedNotNull] Type? requiredType,
29+
[CallerArgumentExpression("parameter")] string? parameterName = null,
30+
string? message = null
31+
);
32+
33+
public static Type MustBeAssignableTo(
34+
[NotNull] [ValidatedNotNull] this Type? parameter,
35+
[NotNull] [ValidatedNotNull] Type? requiredType,
36+
Func<Type?, Type?, Exception> exceptionFactory
37+
);
38+
```
39+
40+
The check must evaluate `requiredType.IsAssignableFrom(parameter)`. This makes the direction explicit: after success, a value whose runtime type is `parameter` can be stored in a variable declared as `requiredType`. Use the BCL behavior verbatim, including equality, inheritance, interface implementation, array compatibility, and generic variance; do not route through `InheritsFrom`, `IsOrInheritsFrom`, or `IsEquivalentTypeTo`, whose constructed/open-generic handling is intentionally different. No generic `MustBeAssignableTo<T>` overload is included because the motivating scenario supplies the required type at runtime.
41+
42+
Route default relation failures through a non-returning `Throw.MustBeAssignableTo` helper that constructs `ArgumentException`. Its default message should state both types and the assignment direction without implying that the `Type` object itself failed a CLR cast. The default overload validates both type arguments before evaluating assignability; the factory overload treats either null as a validation failure and passes both original values to the factory. No trimming annotations or microbenchmarks are required because `Type.IsAssignableFrom` does not enumerate reflected members and this guard is a thin wrapper around the BCL operation.
43+
44+
Register `MustBeAssignableTo` in `AssertionWhitelist` and `settings.json`, add focused `SourceFileMergerWhitelistTests`, add `MustBeAssignableToTests` under the type assertions, extend the value-returning assertion coverage in `Issue72NotNullAttributeTests`, update the type-relation table in `docs/assertion-overview.md`, and regenerate `Light.GuardClauses.SingleFile.cs` through the source-export tool's committed settings.

docs/assertion-overview.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,13 +169,16 @@ while preserving its concrete stream type, and all three guards support custom m
169169

170170
| Assertion | Behavior |
171171
| --- | --- |
172+
| `MustBeAssignableTo` | Requires CLR assignability from a candidate type to a required base type or interface |
172173
| `IsEquivalentTypeTo` | Treats equal types and constructed-generic/definition pairs as equivalent |
173174
| `Implements`, `IsOrImplements` | Test interface implementation, optionally allowing equality |
174175
| `DerivesFrom`, `IsOrDerivesFrom` | Test base-class derivation, optionally allowing equality |
175176
| `InheritsFrom`, `IsOrInheritsFrom` | Test derivation or interface implementation, optionally allowing equality |
176177
| `IsOpenConstructedGenericType` | Tests for a constructed generic type that still has open parameters |
177178

178-
The relation methods provide comparer overloads where applicable.
179+
`MustBeAssignableTo` uses `requiredType.IsAssignableFrom(candidateType)` directly, so it covers interface
180+
assignability, generic variance, and the BCL's open-generic behavior. The other relation methods provide comparer
181+
overloads where applicable.
179182

180183
## URI assertions
181184

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
Light.GuardClauses 15.1.0
2121
--------------------------------
2222

23-
- new assertions: MustBeUri, ObjectDisposed
23+
- new assertions: MustBeAssignableTo, MustBeUri, ObjectDisposed
2424
</PackageReleaseNotes>
2525
</PropertyGroup>
2626

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using System;
2+
using System.Runtime.CompilerServices;
3+
using JetBrains.Annotations;
4+
using Light.GuardClauses.ExceptionFactory;
5+
using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute;
6+
7+
namespace Light.GuardClauses;
8+
9+
public static partial class Check
10+
{
11+
/// <summary>
12+
/// Ensures that values of <paramref name="parameter" /> can be assigned to variables of
13+
/// <paramref name="requiredType" />, or otherwise throws an <see cref="ArgumentException" />.
14+
/// </summary>
15+
/// <param name="parameter">The candidate type to be checked.</param>
16+
/// <param name="requiredType">The type to which values of the candidate type must be assignable.</param>
17+
/// <param name="parameterName">The name of the parameter (optional).</param>
18+
/// <param name="message">The message that will be passed to the resulting exception (optional).</param>
19+
/// <returns>The original candidate type.</returns>
20+
/// <exception cref="ArgumentException">
21+
/// Thrown when values of <paramref name="parameter" /> cannot be assigned to variables of
22+
/// <paramref name="requiredType" />.
23+
/// </exception>
24+
/// <exception cref="ArgumentNullException">
25+
/// Thrown when <paramref name="parameter" /> or <paramref name="requiredType" /> is null.
26+
/// </exception>
27+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
28+
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt")]
29+
public static Type MustBeAssignableTo(
30+
[NotNull] [ValidatedNotNull] this Type? parameter,
31+
[NotNull] [ValidatedNotNull] Type? requiredType,
32+
[CallerArgumentExpression("parameter")] string? parameterName = null,
33+
string? message = null
34+
)
35+
{
36+
parameter.MustNotBeNull(parameterName, message);
37+
requiredType.MustNotBeNull(nameof(requiredType), message);
38+
39+
if (!requiredType.IsAssignableFrom(parameter))
40+
{
41+
Throw.MustBeAssignableTo(parameter, requiredType, parameterName, message);
42+
}
43+
44+
return parameter;
45+
}
46+
47+
/// <summary>
48+
/// Ensures that values of <paramref name="parameter" /> can be assigned to variables of
49+
/// <paramref name="requiredType" />, or otherwise throws your custom exception.
50+
/// </summary>
51+
/// <param name="parameter">The candidate type to be checked.</param>
52+
/// <param name="requiredType">The type to which values of the candidate type must be assignable.</param>
53+
/// <param name="exceptionFactory">
54+
/// The delegate that creates your custom exception. The original candidate and required types are passed to this
55+
/// delegate.
56+
/// </param>
57+
/// <returns>The original candidate type.</returns>
58+
/// <exception cref="Exception">
59+
/// Your custom exception thrown when either type is null or the candidate type is not assignable to the required
60+
/// type.
61+
/// </exception>
62+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
63+
[ContractAnnotation(
64+
"parameter:null => halt; parameter:notnull => notnull; requiredType:null => halt; exceptionFactory:null => halt"
65+
)]
66+
public static Type MustBeAssignableTo(
67+
[NotNull] [ValidatedNotNull] this Type? parameter,
68+
[NotNull] [ValidatedNotNull] Type? requiredType,
69+
Func<Type?, Type?, Exception> exceptionFactory
70+
)
71+
{
72+
if (parameter is null || requiredType is null || !requiredType.IsAssignableFrom(parameter))
73+
{
74+
Throw.CustomException(exceptionFactory, parameter, requiredType);
75+
}
76+
77+
return parameter;
78+
}
79+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System;
2+
using System.Diagnostics.CodeAnalysis;
3+
using JetBrains.Annotations;
4+
5+
namespace Light.GuardClauses.ExceptionFactory;
6+
7+
public static partial class Throw
8+
{
9+
/// <summary>
10+
/// Throws the default <see cref="ArgumentException" /> indicating that values of the candidate type cannot be
11+
/// assigned to variables of the required type, using the optional parameter name and message.
12+
/// </summary>
13+
[ContractAnnotation("=> halt")]
14+
[DoesNotReturn]
15+
public static void MustBeAssignableTo(
16+
Type parameter,
17+
Type requiredType,
18+
string? parameterName = null,
19+
string? message = null
20+
) =>
21+
throw new ArgumentException(
22+
message ??
23+
$"Values of type \"{parameter}\" must be assignable to variables of type \"{requiredType}\", but they are not.",
24+
parameterName
25+
);
26+
}

tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,51 @@ public static void ObjectDisposedWhitelistExportsGuardThrowHelperAndExceptionFac
528528
sourceCode.Should().NotContain("public static void InvalidOperation(");
529529
}
530530

531+
[Fact]
532+
public static void MustBeAssignableToWhitelistExportsGuardThrowHelperAndFactoryOnBothTargets()
533+
{
534+
using var temporaryDirectory = new TemporaryDirectory();
535+
var portableFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeAssignableToPortable.cs");
536+
var modernFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeAssignableToModern.cs");
537+
var whitelist = CreateWhitelist(
538+
includedAssertions: [new ("MustBeAssignableTo", true)]
539+
);
540+
541+
SourceFileMerger.CreateSingleSourceFile(CreateOptions(portableFile, whitelist));
542+
SourceFileMerger.CreateSingleSourceFile(
543+
CreateOptions(modernFile, whitelist, SourceTargetFramework.Net10_0)
544+
);
545+
546+
foreach (var sourceCode in new[] { File.ReadAllText(portableFile), File.ReadAllText(modernFile) })
547+
{
548+
sourceCode.Should().Contain("public static Type MustBeAssignableTo(");
549+
sourceCode.Should().Contain("public static void MustBeAssignableTo(");
550+
sourceCode.Should().Contain("Func<Type?, Type?, Exception> exceptionFactory");
551+
sourceCode.Should().Contain("public static void CustomException<T1, T2>(");
552+
sourceCode.Should().NotContain("public static bool IsEquivalentTypeTo(");
553+
}
554+
}
555+
556+
[Fact]
557+
public static void MustBeAssignableToWhitelistTrimsExceptionFactoryOverload()
558+
{
559+
using var temporaryDirectory = new TemporaryDirectory();
560+
var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeAssignableToWithoutFactory.cs");
561+
562+
SourceFileMerger.CreateSingleSourceFile(
563+
CreateOptions(
564+
targetFile,
565+
CreateWhitelist(includedAssertions: [new ("MustBeAssignableTo", false)])
566+
)
567+
);
568+
var sourceCode = File.ReadAllText(targetFile);
569+
570+
sourceCode.Should().Contain("public static Type MustBeAssignableTo(");
571+
sourceCode.Should().Contain("public static void MustBeAssignableTo(");
572+
sourceCode.Should().NotContain("Func<Type?, Type?, Exception> exceptionFactory");
573+
sourceCode.Should().NotContain("public static void CustomException<T1, T2>(");
574+
}
575+
531576
[Fact]
532577
public static void MustBeUriWhitelistExportsGuardThrowHelperExceptionAndFactories()
533578
{

0 commit comments

Comments
 (0)