Skip to content

Commit c890856

Browse files
authored
feat: add nullability filters and assertions for members (#332)
- `IsNullable()` / `IsNotNullable()` on single properties and fields - `AreNullable()` / `AreNotNullable()` on property and field collections - `WhichAreNullable()` / `WhichAreNotNullable()` filters for properties and fields - `OnlyHasNullableMembers()` / `OnlyHasNonNullableMembers()` on single types - `OnlyHaveNullableMembers()` / `OnlyHaveNonNullableMembers()` on type collections - `WhichOnlyHaveNullableMembers()` / `WhichOnlyHaveNonNullableMembers()` type filters Nullability covers `Nullable<T>` value types and nullable reference types. Detection uses `NullabilityInfoContext` on .NET 8+ and falls back to reading the `NullableAttribute` / `NullableContextAttribute` metadata on netstandard2.0.
1 parent 2defc14 commit c890856

38 files changed

Lines changed: 3939 additions & 4 deletions

File tree

README.md

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,8 @@ outside the namespace.
292292
| instantiable | `.WhichAreInstantiable()` | `.IsInstantiable()` | `.AreInstantiable()` |
293293
| immutable | `.WhichAreImmutable()` | `.IsImmutable()` | `.AreImmutable()` |
294294
| default constructor | `.WhichHaveADefaultConstructor()` | `.HasADefaultConstructor()` | `.HaveADefaultConstructor()` |
295+
| only nullable members | `.WhichOnlyHaveNullableMembers()` | `.OnlyHasNullableMembers()` | `.OnlyHaveNullableMembers()` |
296+
| only non-nullable members | `.WhichOnlyHaveNonNullableMembers()` | `.OnlyHasNonNullableMembers()` | `.OnlyHaveNonNullableMembers()` |
295297
| custom predicate | `.Which(t => …)` | `.Satisfies(t => …)` | `.All().Satisfy(t => …)` |
296298

297299
`WhichInheritFrom` / `InheritsFrom` consider only the **base-class chain** (not implemented interfaces) and
@@ -325,6 +327,16 @@ A type is *immutable* when all instance fields (including inherited ones) are `r
325327
properties (including inherited ones) have no setter or an `init`-only setter. Static members do not affect
326328
immutability. Failure messages list the offending mutable members for actionable feedback.
327329

330+
`OnlyHasNullableMembers` / `OnlyHaveNullableMembers` (and the non-nullable counterparts) verify the
331+
[nullability](#nullability) of all declared fields and properties of the type; the failure message lists the
332+
non-compliant members per type:
333+
334+
```csharp
335+
await Expect.That(In.AssemblyContaining<MyRequest>()
336+
.Types().WithName("Request").AsSuffix())
337+
.OnlyHaveNullableMembers();
338+
```
339+
328340
> **Negation:** every kind/modifier row above has a negated form. Most use `WhichAreNot…` on filters and
329341
> `IsNot…` / `AreNot…` on assertions (e.g. `WhichAreNotSealed()`, `IsNotAClass()`, `AreNotStatic()`,
330342
> `IsNotInstantiable()`). The *default constructor* row uses `WhichDoNotHaveADefaultConstructor()`,
@@ -526,6 +538,7 @@ In addition to [access modifiers](#access-modifiers),
526538
| of type (or a subtype) | `.OfType<T>()` | `.IsOfType<T>()` | `.AreOfType<T>()` |
527539
| of exact type | `.OfExactType<T>()` | `.IsOfExactType<T>()` | `.AreOfExactType<T>()` |
528540
| static *(properties & fields)* | `.WhichAreStatic()` | `.IsStatic()` | `.AreStatic()` |
541+
| nullable *(properties & fields)* | `.WhichAreNullable()` | `.IsNullable()` | `.AreNullable()` |
529542
| abstract / sealed *(properties only)* | `.WhichAreAbstract()` / `.WhichAreSealed()` | `.IsAbstract()` / `.IsSealed()` | `.AreAbstract()` / `.AreSealed()` |
530543
| virtual *(properties only)* | `.WhichAreVirtual()` | `.IsVirtual()` | `.AreVirtual()` |
531544
| override *(properties only)* | `.WhichOverride()` | `.Overrides()` | `.Override()` |
@@ -543,10 +556,27 @@ In addition to [access modifiers](#access-modifiers),
543556
| read-only *(fields only)* | `.WhichAreReadOnly()` | `.IsReadOnly()` | `.AreReadOnly()` |
544557
| constant *(fields only)* | `.WhichAreConstant()` | `.IsConstant()` | `.AreConstant()` |
545558

546-
> **Negation:** the `static`, `abstract`, `sealed`, `virtual`, `required`, `indexer`, `extension property`,
547-
> `read-only` *(fields)* and `constant` rows have a negated form: `WhichAreNot…` on filters and `IsNot…` /
548-
> `AreNot…` on assertions (e.g. `WhichAreNotConstant()`, `IsNotConstant()`, `AreNotConstant()`); `override` uses
549-
> `WhichDoNotOverride()` / `DoesNotOverride()` / `DoNotOverride()`.
559+
> **Negation:** the `static`, `nullable`, `abstract`, `sealed`, `virtual`, `required`, `indexer`,
560+
> `extension property`, `read-only` *(fields)* and `constant` rows have a negated form: `WhichAreNot…` on filters
561+
> and `IsNot…` / `AreNot…` on assertions (e.g. `WhichAreNotConstant()`, `IsNotConstant()`, `AreNotConstant()`);
562+
> `override` uses `WhichDoNotOverride()` / `DoesNotOverride()` / `DoNotOverride()`.
563+
564+
#### Nullability
565+
566+
A property or field counts as *nullable* when its type is a `Nullable<T>` value type (e.g. `int?`) or a
567+
reference type annotated as nullable (e.g. `string?`, based on the nullable reference type metadata emitted
568+
by the compiler). The check follows the declared annotation on every target framework: reference types
569+
without nullability annotations (oblivious code compiled without `<Nullable>enable</Nullable>`) and
570+
unconstrained generic type parameters (`T`, as opposed to `T?`) count as non-nullable, and post-condition
571+
attributes like `[AllowNull]` or `[MaybeNull]` are ignored.
572+
573+
```csharp
574+
// All properties and fields of the request types must be nullable
575+
await Expect.That(In.AssemblyContaining<MyRequest>()
576+
.Types().WithName("Request").AsSuffix()
577+
.Properties())
578+
.AreNullable();
579+
```
550580

551581
`WhichAreExtensionProperties()`, `IsAnExtensionProperty()` and `AreExtensionProperties()` match extension properties
552582
declared with the C# extension block syntax (`extension(...) { … }`), both instance and static. The real properties
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System.Reflection;
2+
using aweXpect.Reflection.Collections;
3+
using aweXpect.Reflection.Helpers;
4+
5+
namespace aweXpect.Reflection;
6+
7+
public static partial class FieldFilters
8+
{
9+
/// <summary>
10+
/// Filters for fields that are nullable.
11+
/// </summary>
12+
public static Filtered.Fields WhichAreNullable(this Filtered.Fields @this)
13+
=> @this.Which(Filter.Prefix<FieldInfo>(
14+
field => field.IsNullable(),
15+
"nullable "));
16+
17+
/// <summary>
18+
/// Filters for fields that are not nullable.
19+
/// </summary>
20+
public static Filtered.Fields WhichAreNotNullable(this Filtered.Fields @this)
21+
=> @this.Which(Filter.Prefix<FieldInfo>(
22+
field => !field.IsNullable(),
23+
"non-nullable "));
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System.Reflection;
2+
using aweXpect.Reflection.Collections;
3+
using aweXpect.Reflection.Helpers;
4+
5+
namespace aweXpect.Reflection;
6+
7+
public static partial class PropertyFilters
8+
{
9+
/// <summary>
10+
/// Filters for properties that are nullable.
11+
/// </summary>
12+
public static Filtered.Properties WhichAreNullable(this Filtered.Properties @this)
13+
=> @this.Which(Filter.Prefix<PropertyInfo>(
14+
property => property.IsNullable(),
15+
"nullable "));
16+
17+
/// <summary>
18+
/// Filters for properties that are not nullable.
19+
/// </summary>
20+
public static Filtered.Properties WhichAreNotNullable(this Filtered.Properties @this)
21+
=> @this.Which(Filter.Prefix<PropertyInfo>(
22+
property => !property.IsNullable(),
23+
"non-nullable "));
24+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using System;
2+
using aweXpect.Reflection.Collections;
3+
using aweXpect.Reflection.Helpers;
4+
5+
namespace aweXpect.Reflection;
6+
7+
public static partial class TypeFilters
8+
{
9+
/// <summary>
10+
/// Filters for types whose fields and properties are all nullable, including inherited members or only
11+
/// those declared directly on the type according to the <paramref name="memberScope" />.
12+
/// </summary>
13+
public static Filtered.Types WhichOnlyHaveNullableMembers(this Filtered.Types @this,
14+
MemberScope memberScope = MemberScope.DeclaredOnly)
15+
=> @this.Which(Filter.Suffix<Type>(
16+
type => type.GetNotNullableMembers(memberScope).Length == 0,
17+
"which only have nullable members "));
18+
19+
/// <summary>
20+
/// Filters for types whose fields and properties are all non-nullable, including inherited members or only
21+
/// those declared directly on the type according to the <paramref name="memberScope" />.
22+
/// </summary>
23+
public static Filtered.Types WhichOnlyHaveNonNullableMembers(this Filtered.Types @this,
24+
MemberScope memberScope = MemberScope.DeclaredOnly)
25+
=> @this.Which(Filter.Suffix<Type>(
26+
type => type.GetNullableMembers(memberScope).Length == 0,
27+
"which only have non-nullable members "));
28+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Reflection;
4+
using System.Text;
5+
using aweXpect.Core;
6+
7+
namespace aweXpect.Reflection.Helpers;
8+
9+
/// <summary>
10+
/// Renders the grouped failure output of the nullability member constraints: one indented line per failing
11+
/// type, each followed by its list of violating members.
12+
/// </summary>
13+
/// <remarks>
14+
/// Shared between the nullable and the non-nullable member constraints, so that the formatting (indentation,
15+
/// comma placement, null handling) cannot drift between the two.
16+
/// </remarks>
17+
internal static class MemberViolationRenderer
18+
{
19+
/// <summary>
20+
/// Appends <c>{it}{header}[</c>, one indented line per type in <paramref name="types" /> (appending
21+
/// <c>{memberHeader}[…]</c> when <paramref name="violations" /> has an entry for it), and a closing <c>]</c>.
22+
/// </summary>
23+
/// <remarks>
24+
/// A <see langword="null" /> type has no violations to list; it fails because it cannot satisfy the
25+
/// expectation, so it is rendered without a (contradictory empty) violation list.
26+
/// </remarks>
27+
public static void AppendTypesWithViolatingMembers(
28+
StringBuilder stringBuilder,
29+
string it,
30+
string header,
31+
IReadOnlyList<Type?> types,
32+
IReadOnlyDictionary<Type, MemberInfo[]> violations,
33+
string memberHeader,
34+
string? indentation)
35+
{
36+
string itemIndentation = (indentation ?? string.Empty) + " ";
37+
stringBuilder.Append(it).Append(header).Append('[');
38+
for (int index = 0; index < types.Count; index++)
39+
{
40+
Type? type = types[index];
41+
stringBuilder.Append(Environment.NewLine).Append(itemIndentation)
42+
.Append(Formatter.Format(type));
43+
44+
if (type is not null && violations.TryGetValue(type, out MemberInfo[]? members))
45+
{
46+
stringBuilder.Append(memberHeader).Append(Formatter.Format(members));
47+
}
48+
49+
if (index < types.Count - 1)
50+
{
51+
stringBuilder.Append(',');
52+
}
53+
}
54+
55+
stringBuilder.Append(Environment.NewLine).Append(indentation).Append(']');
56+
}
57+
}

0 commit comments

Comments
 (0)