Skip to content

Commit 475b693

Browse files
authored
feat: add required filters and assertions for fields (#340)
Add a `WhichAreRequired` filter and `IsRequired` / `AreRequired` assertions (plus negated variants) for `FieldInfo`, matching what already exists for properties.
1 parent cb48fb5 commit 475b693

18 files changed

Lines changed: 795 additions & 7 deletions

Docs/pages/02-filters.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ In addition to [access modifiers](#access-modifiers),
400400
| abstract / sealed *(properties only)* | `.WhichAreAbstract()` / `.WhichAreSealed()` | `.IsAbstract()` / `.IsSealed()` | `.AreAbstract()` / `.AreSealed()` |
401401
| virtual *(properties only)* | `.WhichAreVirtual()` | `.IsVirtual()` | `.AreVirtual()` |
402402
| override *(properties only)* | `.WhichOverride()` | `.Overrides()` | `.Override()` |
403-
| required *(properties only)* | `.WhichAreRequired()` | `.IsRequired()` | `.AreRequired()` |
403+
| required *(properties & fields)* | `.WhichAreRequired()` | `.IsRequired()` | `.AreRequired()` |
404404
| readable *(properties only)* | `.WhichAreReadable()` | `.IsReadable()` | `.AreReadable()` |
405405
| writable *(properties only)* | `.WhichAreWritable()` | `.IsWritable()` | `.AreWritable()` |
406406
| read-only *(properties only)* | `.WhichAreReadOnly()` | `.IsReadOnly()` | `.AreReadOnly()` |

Docs/pages/comparison/01-fluentassertions-comparison.mdx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ Legend: ✅ dedicated assertion · ⚠️ only via a general mechanism (selector
4141
| **Type**: member presence by signature || ⚠️ |
4242
| **Type**: operator presence (by kind) | ⚠️ ||
4343
| **Type**: conversion operators by signature |||
44+
| **Type**: immutability / member nullability |||
45+
| **Type**: dependencies (on / only on / outside an allowed set) |||
4446
| **Method**: virtual / async / return type |||
4547
| **Method**: static / abstract / sealed / generic / extension / operator |||
4648
| **Method**: override |||
@@ -50,9 +52,10 @@ Legend: ✅ dedicated assertion · ⚠️ only via a general mechanism (selector
5052
| **Property**: readable / writable |||
5153
| **Property**: virtual |||
5254
| **Property**: static / abstract / required / indexer / init-setter / override |||
55+
| **Property**: nullable |||
5356
| **Property**: return / declared type |||
5457
| **Constructor**: dedicated assertions / filtering | ⚠️ ||
55-
| **Field**: assertions |||
58+
| **Field**: assertions (static / read-only / constant / required / nullable) |||
5659
| **Event**: assertions |||
5760

5861
The sections below detail each target with side-by-side examples.
@@ -91,7 +94,7 @@ await Expect.That(In.AllLoadedAssemblies()).HaveName("System").AsPrefix();
9194

9295
- **FluentAssertions** (`TypeAssertions`): `Be<T>`/`NotBe<T>`, `BeAssignableTo<T>`, `Implement<T>`/`NotImplement<T>`, `BeDerivedFrom<T>`, `BeAbstract`/`BeSealed`/`BeStatic`, `HaveAccessModifier(CSharpAccessModifier)`, `BeDecoratedWith<T>` (with attribute predicate and `OrInherit` variants), member presence (`HaveProperty`/`HaveMethod`/`HaveConstructor`/`HaveIndexer`/`HaveExplicit*`), and conversion operators. Type *kind* is available only as a selector filter (`ThatAreClasses()`), and namespace
9396
only on the selector (`BeInNamespace`); a single type's name/namespace use the string API.
94-
- **aweXpect.Reflection**: type-kind assertions (`IsAClass`, `IsAnInterface`, `IsAnEnum`, `IsAStruct`, `IsARecord`, `IsARecordStruct`, `IsARefStruct`, `IsADelegate`, `IsAnAttribute`, `IsAnException`), `IsAbstract`/`IsSealed`/`IsStatic`/`IsReadOnly`/`IsNested`/`IsGeneric`/`IsInstantiable`, access modifiers (`IsPublic`, …), `InheritsFrom<T>().Directly()`, `HasName`, `HasNamespace`/`IsWithinNamespace`, `Has<TAttribute>`, quantified member containment (`ContainsMethods()`, …), operator presence by
97+
- **aweXpect.Reflection**: type-kind assertions (`IsAClass`, `IsAnInterface`, `IsAnEnum`, `IsAStruct`, `IsARecord`, `IsARecordStruct`, `IsARefStruct`, `IsADelegate`, `IsAnAttribute`, `IsAnException`), `IsAbstract`/`IsSealed`/`IsStatic`/`IsReadOnly`/`IsNested`/`IsGeneric`/`IsInstantiable`/`IsImmutable`, access modifiers (`IsPublic`, …), `InheritsFrom<T>().Directly()`, `HasName`, `HasNamespace`/`IsWithinNamespace`, `Has<TAttribute>`, quantified member containment (`ContainsMethods()`, …), member nullability (`OnlyHasNullableMembers`/`OnlyHasNonNullableMembers`), type-level dependencies (`DependsOn`/`DoesNotDependOn`/`DependsOnlyOn`/`HasDependenciesOutside`, against namespaces or type selections), operator presence by
9598
kind (`HasOperator(Operator)`, `HasOperator<TOperand>(Operator)`) and conversion operators by signature (`HasImplicitConversionOperator<TSource, TTarget>`, `HasExplicitConversionOperator<TSource, TTarget>`).
9699

97100
<Tabs groupId="assertion-library">
@@ -169,7 +172,7 @@ await Expect.That(In.AssemblyContaining<MyClass>()
169172
## Property
170173

171174
- **FluentAssertions** (`PropertyInfoAssertions`): `BeVirtual`/`NotBeVirtual`, `BeReadable`/`BeWritable` (each with an optional `CSharpAccessModifier`), `NotBeReadable`/`NotBeWritable`, `Return<T>`/`NotReturn<T>`, `BeDecoratedWith<T>`. No assertions for `static`/`abstract`/`sealed`/`required`/indexer/init-setter/override, and no name assertion.
172-
- **aweXpect.Reflection**: `IsReadable`/`IsWritable`/`IsReadOnly`/`IsWriteOnly`/`IsReadWrite`, `HasAGetter`/`HasASetter`/`HasAnInitSetter`, `IsStatic`/`IsAbstract`/`IsSealed`/`IsVirtual`, `IsRequired`, `IsAnIndexer`, `Overrides<T>`, `IsOfType<T>`/`IsOfExactType<T>`, `HasName`, `Has<TAttribute>`.
175+
- **aweXpect.Reflection**: `IsReadable`/`IsWritable`/`IsReadOnly`/`IsWriteOnly`/`IsReadWrite`, `HasAGetter`/`HasASetter`/`HasAnInitSetter`, `IsStatic`/`IsAbstract`/`IsSealed`/`IsVirtual`, `IsRequired`, `IsNullable`, `IsAnIndexer`, `IsAnExtensionProperty`, `Overrides<T>`, `IsOfType<T>`/`IsOfExactType<T>`, `HasName`, `Has<TAttribute>`.
173176

174177
<Tabs groupId="assertion-library">
175178
<TabItem value="fluentassertions" label="FluentAssertions" default>
@@ -228,7 +231,7 @@ await Expect.That(typeof(MyClass)).HasADefaultConstructor();
228231
## Field
229232

230233
- **FluentAssertions**: no `FieldInfo` assertions.
231-
- **aweXpect.Reflection**: singular (`ThatField`) and plural (`ThatFields`) assertions and an `In.*…Fields()` filter: `IsStatic`, `IsReadOnly`, `IsConstant`, `IsOfType<T>`/`IsOfExactType<T>`, access modifiers (`IsPublic`, …), `HasName`, `Has<TAttribute>`.
234+
- **aweXpect.Reflection**: singular (`ThatField`) and plural (`ThatFields`) assertions and an `In.*…Fields()` filter: `IsStatic`, `IsReadOnly`, `IsConstant`, `IsRequired`, `IsNullable`, `IsOfType<T>`/`IsOfExactType<T>`, access modifiers (`IsPublic`, …), `HasName`, `Has<TAttribute>`.
232235

233236
<Tabs groupId="assertion-library">
234237
<TabItem value="fluentassertions" label="FluentAssertions" default>

Docs/pages/comparison/02-architecture-comparison.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@ Legend: ✅ built-in · ⚠️ partial or via a general mechanism · ❌ not ava
9393
| Type kinds | ⚠️ classes, interfaces | ✅ classes, interfaces, enums, structs, records | ✅ plus delegates, exceptions, attributes, ref structs, record structs |
9494
| Access modifiers | ⚠️ public, nested |||
9595
| Attribute rules | ✅ presence | ✅ incl. argument values | ✅ incl. typed predicate on the attribute instance |
96-
| Member-level rules (methods, properties, fields) ||| ✅ plus events, constructors, parameter modifiers, async, extension methods, operators |
97-
| Immutability / nullability rules || ⚠️ immutability | |
96+
| Member-level rules (methods, properties, fields) ||| ✅ plus events, constructors, parameter modifiers, async, extension methods, operators, required / nullable members |
97+
| Immutability / nullability rules || ⚠️ immutability | ✅ type- and member-level |
9898
| Dependency rules (on / not on / only on) ||||
9999
| Body-level (IL) dependency detection ||| ⚠️ signature-level default, IL via [custom resolver](../04-configuration.md#dependency-resolver) |
100100
| Dependency cycle detection || ✅ via slices | ✅ via namespaces / [slice roots](../03-architecture-rules.md#dependency-cycles) |
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 required.
11+
/// </summary>
12+
public static Filtered.Fields WhichAreRequired(this Filtered.Fields @this)
13+
=> @this.Which(Filter.Prefix<FieldInfo>(
14+
field => field.IsRequired(),
15+
"required "));
16+
17+
/// <summary>
18+
/// Filters for fields that are not required.
19+
/// </summary>
20+
public static Filtered.Fields WhichAreNotRequired(this Filtered.Fields @this)
21+
=> @this.Which(Filter.Prefix<FieldInfo>(
22+
field => !field.IsRequired(),
23+
"non-required "));
24+
}

Source/aweXpect.Reflection/Helpers/FieldInfoHelpers.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,15 @@ public static bool HasAttribute(
128128

129129
return false;
130130
}
131+
132+
/// <summary>
133+
/// Checks if the <paramref name="fieldInfo" /> is required (marked with the <c>required</c> modifier).
134+
/// </summary>
135+
/// <remarks>
136+
/// A field is considered required if it carries the
137+
/// <c>System.Runtime.CompilerServices.RequiredMemberAttribute</c>.
138+
/// </remarks>
139+
public static bool IsRequired(this FieldInfo? fieldInfo)
140+
=> fieldInfo != null && fieldInfo.GetCustomAttributes(true)
141+
.Any(attribute => attribute.GetType().FullName == "System.Runtime.CompilerServices.RequiredMemberAttribute");
131142
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using System.Reflection;
2+
using System.Text;
3+
using aweXpect.Core;
4+
using aweXpect.Core.Constraints;
5+
using aweXpect.Reflection.Helpers;
6+
using aweXpect.Results;
7+
8+
namespace aweXpect.Reflection;
9+
10+
public static partial class ThatField
11+
{
12+
/// <summary>
13+
/// Verifies that the <see cref="FieldInfo" /> is required.
14+
/// </summary>
15+
public static AndOrResult<FieldInfo?, IThat<FieldInfo?>> IsRequired(
16+
this IThat<FieldInfo?> subject)
17+
=> new(subject.Get().ExpectationBuilder.AddConstraint((it, grammars)
18+
=> new IsRequiredConstraint(it, grammars)),
19+
subject);
20+
21+
/// <summary>
22+
/// Verifies that the <see cref="FieldInfo" /> is not required.
23+
/// </summary>
24+
public static AndOrResult<FieldInfo?, IThat<FieldInfo?>> IsNotRequired(
25+
this IThat<FieldInfo?> subject)
26+
=> new(subject.Get().ExpectationBuilder.AddConstraint((it, grammars)
27+
=> new IsRequiredConstraint(it, grammars).Invert()),
28+
subject);
29+
30+
private sealed class IsRequiredConstraint(string it, ExpectationGrammars grammars)
31+
: ConstraintResult.WithNotNullValue<FieldInfo?>(it, grammars),
32+
IValueConstraint<FieldInfo?>
33+
{
34+
public ConstraintResult IsMetBy(FieldInfo? actual)
35+
{
36+
Actual = actual;
37+
Outcome = actual.IsRequired() ? Outcome.Success : Outcome.Failure;
38+
return this;
39+
}
40+
41+
protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null)
42+
=> stringBuilder.Append("is required");
43+
44+
protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null)
45+
{
46+
stringBuilder.Append(It).Append(" was non-required ");
47+
Formatter.Format(stringBuilder, Actual);
48+
}
49+
50+
protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null)
51+
=> stringBuilder.Append("is not required");
52+
53+
protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null)
54+
{
55+
stringBuilder.Append(It).Append(" was required ");
56+
Formatter.Format(stringBuilder, Actual);
57+
}
58+
}
59+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
using System.Collections.Generic;
2+
using System.Reflection;
3+
using System.Text;
4+
using aweXpect.Core;
5+
using aweXpect.Core.Constraints;
6+
using aweXpect.Reflection.Helpers;
7+
using aweXpect.Results;
8+
#if NET8_0_OR_GREATER
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
#endif
12+
13+
// ReSharper disable PossibleMultipleEnumeration
14+
15+
namespace aweXpect.Reflection;
16+
17+
public static partial class ThatFields
18+
{
19+
/// <summary>
20+
/// Verifies that all items in the filtered collection of <see cref="FieldInfo" /> are required.
21+
/// </summary>
22+
public static AndOrResult<IEnumerable<FieldInfo?>, IThat<IEnumerable<FieldInfo?>>> AreRequired(
23+
this IThat<IEnumerable<FieldInfo?>> subject)
24+
=> new(subject.Get().ExpectationBuilder.AddConstraint<IEnumerable<FieldInfo?>>((it, grammars)
25+
=> new AreRequiredConstraint(it, grammars)),
26+
subject);
27+
28+
#if NET8_0_OR_GREATER
29+
/// <summary>
30+
/// Verifies that all items in the filtered collection of <see cref="FieldInfo" /> are required.
31+
/// </summary>
32+
public static AndOrResult<IAsyncEnumerable<FieldInfo?>, IThat<IAsyncEnumerable<FieldInfo?>>> AreRequired(
33+
this IThat<IAsyncEnumerable<FieldInfo?>> subject)
34+
=> new(subject.Get().ExpectationBuilder.AddConstraint<IAsyncEnumerable<FieldInfo?>>((it, grammars)
35+
=> new AreRequiredConstraint(it, grammars)),
36+
subject);
37+
#endif
38+
39+
/// <summary>
40+
/// Verifies that all items in the filtered collection of <see cref="FieldInfo" /> are not required.
41+
/// </summary>
42+
public static AndOrResult<IEnumerable<FieldInfo?>, IThat<IEnumerable<FieldInfo?>>> AreNotRequired(
43+
this IThat<IEnumerable<FieldInfo?>> subject)
44+
=> new(subject.Get().ExpectationBuilder.AddConstraint<IEnumerable<FieldInfo?>>((it, grammars)
45+
=> new AreNotRequiredConstraint(it, grammars)),
46+
subject);
47+
48+
#if NET8_0_OR_GREATER
49+
/// <summary>
50+
/// Verifies that all items in the filtered collection of <see cref="FieldInfo" /> are not required.
51+
/// </summary>
52+
public static AndOrResult<IAsyncEnumerable<FieldInfo?>, IThat<IAsyncEnumerable<FieldInfo?>>> AreNotRequired(
53+
this IThat<IAsyncEnumerable<FieldInfo?>> subject)
54+
=> new(subject.Get().ExpectationBuilder.AddConstraint<IAsyncEnumerable<FieldInfo?>>((it, grammars)
55+
=> new AreNotRequiredConstraint(it, grammars)),
56+
subject);
57+
#endif
58+
59+
private sealed class AreRequiredConstraint(string it, ExpectationGrammars grammars)
60+
: CollectionConstraintResult<FieldInfo?>(grammars),
61+
IValueConstraint<IEnumerable<FieldInfo?>>
62+
#if NET8_0_OR_GREATER
63+
, IAsyncConstraint<IAsyncEnumerable<FieldInfo?>>
64+
#endif
65+
{
66+
#if NET8_0_OR_GREATER
67+
public async Task<ConstraintResult> IsMetBy(IAsyncEnumerable<FieldInfo?> actual,
68+
CancellationToken cancellationToken)
69+
=> await SetAsyncValue(actual, field => field.IsRequired());
70+
#endif
71+
72+
public ConstraintResult IsMetBy(IEnumerable<FieldInfo?> actual)
73+
=> SetValue(actual, field => field.IsRequired());
74+
75+
protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null)
76+
=> stringBuilder.Append("are all required");
77+
78+
protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null)
79+
{
80+
stringBuilder.Append(it).Append(" contained non-required fields ");
81+
Formatter.Format(stringBuilder, NotMatching, FormattingOptions.Indented(indentation));
82+
}
83+
84+
protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null)
85+
=> stringBuilder.Append("are not all required");
86+
87+
protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null)
88+
{
89+
stringBuilder.Append(it).Append(" only contained required fields ");
90+
Formatter.Format(stringBuilder, Matching, FormattingOptions.Indented(indentation));
91+
}
92+
}
93+
94+
private sealed class AreNotRequiredConstraint(string it, ExpectationGrammars grammars)
95+
: CollectionConstraintResult<FieldInfo?>(grammars),
96+
IValueConstraint<IEnumerable<FieldInfo?>>
97+
#if NET8_0_OR_GREATER
98+
, IAsyncConstraint<IAsyncEnumerable<FieldInfo?>>
99+
#endif
100+
{
101+
#if NET8_0_OR_GREATER
102+
public async Task<ConstraintResult> IsMetBy(IAsyncEnumerable<FieldInfo?> actual,
103+
CancellationToken cancellationToken)
104+
=> await SetAsyncValue(actual, field => !field.IsRequired());
105+
#endif
106+
107+
public ConstraintResult IsMetBy(IEnumerable<FieldInfo?> actual)
108+
=> SetValue(actual, field => !field.IsRequired());
109+
110+
protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null)
111+
=> stringBuilder.Append("are all not required");
112+
113+
protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null)
114+
{
115+
stringBuilder.Append(it).Append(" contained required fields ");
116+
Formatter.Format(stringBuilder, NotMatching, FormattingOptions.Indented(indentation));
117+
}
118+
119+
protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null)
120+
=> stringBuilder.Append("also contain a required field");
121+
122+
protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null)
123+
{
124+
stringBuilder.Append(it).Append(" only contained non-required fields ");
125+
Formatter.Format(stringBuilder, Matching, FormattingOptions.Indented(indentation));
126+
}
127+
}
128+
}

0 commit comments

Comments
 (0)