Skip to content

Commit 46dfd21

Browse files
authored
Merge pull request #161 from Reefact/144-nullable-value-binding
fix(binder): bind nullable value-type properties over underlying type
2 parents 0ad5870 + 12977c6 commit 46dfd21

7 files changed

Lines changed: 521 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
#region Usings declarations
2+
3+
using NFluent;
4+
5+
#endregion
6+
7+
namespace FirstClassErrors.RequestBinder.UnitTests;
8+
9+
#region Value-type request DTO and value objects
10+
11+
internal sealed record ValueTypeRequest(
12+
int? Quantity,
13+
bool? Flag,
14+
string? Note,
15+
IReadOnlyList<int?>? Quantities,
16+
IReadOnlyList<string?>? Notes,
17+
int Count = 0);
18+
19+
/// <summary>A struct value object built from the underlying <see cref="int" /> — the shape a converter binds against.</summary>
20+
internal readonly struct PositiveQty {
21+
22+
public int Value { get; private init; }
23+
24+
public static Outcome<PositiveQty> From(int n) {
25+
return n > 0
26+
? Outcome<PositiveQty>.Success(new PositiveQty { Value = n })
27+
: Outcome<PositiveQty>.Failure(BookingDomainError.NotAPositiveNumber(n.ToString()));
28+
}
29+
30+
}
31+
32+
/// <summary>A second struct value object over a different underlying value type (<see cref="bool" />).</summary>
33+
internal readonly struct Consent {
34+
35+
public bool Given { get; private init; }
36+
37+
public static Outcome<Consent> From(bool given) {
38+
return Outcome<Consent>.Success(new Consent { Given = given });
39+
}
40+
41+
}
42+
43+
#endregion
44+
45+
public sealed class ValueTypePropertyBindingTests {
46+
47+
private static ValueTypeRequest Request(int? quantity = 5, bool? flag = true, string? note = "hello",
48+
IReadOnlyList<int?>? quantities = null, IReadOnlyList<string?>? notes = null) {
49+
return new ValueTypeRequest(quantity, flag, note, quantities, notes);
50+
}
51+
52+
// ── Scalar value-type property ────────────────────────────────────────────────────────────────────────
53+
54+
[Fact(DisplayName = "A nullable value-type property binds via a method-group converter over its underlying type.")]
55+
public void ValueTypeRequiredBindsViaMethodGroup() {
56+
var bind = Bind.PropertiesOf(Request(quantity: 5)).FailWith(BookingEnvelopeError.CommandInvalid);
57+
58+
RequiredField<PositiveQty> qty = bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From);
59+
60+
Outcome<int> outcome = bind.New(s => s.Get(qty).Value);
61+
Check.That(outcome.IsSuccess).IsTrue();
62+
Check.That(outcome.GetResultOrThrow()).IsEqualTo(5);
63+
}
64+
65+
[Fact(DisplayName = "A required value-type property that is absent records REQUEST_ARGUMENT_REQUIRED with its path.")]
66+
public void ValueTypeRequiredMissingRecords() {
67+
var bind = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid);
68+
69+
bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From);
70+
71+
Outcome<string> outcome = bind.New(_ => "never");
72+
Check.That(outcome.IsFailure).IsTrue();
73+
Error required = outcome.Error!.InnerErrors.Single();
74+
Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
75+
Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Quantity");
76+
}
77+
78+
[Fact(DisplayName = "A present-but-invalid value-type property records REQUEST_ARGUMENT_INVALID wrapping the converter error.")]
79+
public void ValueTypeRequiredInvalidRecords() {
80+
var bind = Bind.PropertiesOf(Request(quantity: -1)).FailWith(BookingEnvelopeError.CommandInvalid);
81+
82+
bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From);
83+
84+
Outcome<string> outcome = bind.New(_ => "never");
85+
Error invalid = outcome.Error!.InnerErrors.Single();
86+
Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
87+
Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NOT_A_POSITIVE_NUMBER");
88+
}
89+
90+
[Fact(DisplayName = "AsOptionalValue on a value-type property yields the value when present and a real null when absent.")]
91+
public void ValueTypeOptionalValue() {
92+
var present = Bind.PropertiesOf(Request(quantity: 7)).FailWith(BookingEnvelopeError.CommandInvalid);
93+
OptionalValueField<PositiveQty> some = present.SimpleProperty(r => r.Quantity).AsOptionalValue(PositiveQty.From);
94+
Check.That(present.New(s => s.Get(some)!.Value.Value).GetResultOrThrow()).IsEqualTo(7);
95+
96+
var absent = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid);
97+
OptionalValueField<PositiveQty> none = absent.SimpleProperty(r => r.Quantity).AsOptionalValue(PositiveQty.From);
98+
Check.That(absent.New(s => s.Get(none) is null).GetResultOrThrow()).IsTrue();
99+
}
100+
101+
[Fact(DisplayName = "AsOptional with a fallback converts the underlying-typed fallback when the value-type property is absent.")]
102+
public void ValueTypeOptionalWithFallback() {
103+
var absent = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid);
104+
105+
RequiredField<PositiveQty> defaulted = absent.SimpleProperty(r => r.Quantity).AsOptional(PositiveQty.From, 1);
106+
107+
Check.That(absent.New(s => s.Get(defaulted).Value).GetResultOrThrow()).IsEqualTo(1);
108+
}
109+
110+
[Fact(DisplayName = "The underlying-type inference is not int-specific: a bool? property binds the same way.")]
111+
public void ValueTypeWorksForBool() {
112+
var bind = Bind.PropertiesOf(Request(flag: true)).FailWith(BookingEnvelopeError.CommandInvalid);
113+
114+
RequiredField<Consent> consent = bind.SimpleProperty(r => r.Flag).AsRequired(Consent.From);
115+
116+
Check.That(bind.New(s => s.Get(consent).Given).GetResultOrThrow()).IsTrue();
117+
}
118+
119+
// ── List of value-type elements ───────────────────────────────────────────────────────────────────────
120+
121+
[Fact(DisplayName = "A list of nullable value types binds each element via a method-group converter over the underlying type.")]
122+
public void ValueTypeListRequiredBinds() {
123+
var bind = Bind.PropertiesOf(Request(quantities: [1, 2, 3])).FailWith(BookingEnvelopeError.CommandInvalid);
124+
125+
RequiredField<IReadOnlyList<PositiveQty>> qtys = bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From);
126+
127+
Outcome<int> outcome = bind.New(s => s.Get(qtys).Sum(q => q.Value));
128+
Check.That(outcome.IsSuccess).IsTrue();
129+
Check.That(outcome.GetResultOrThrow()).IsEqualTo(6);
130+
}
131+
132+
[Fact(DisplayName = "A null element of a value-type list records REQUEST_ARGUMENT_REQUIRED under its indexed path.")]
133+
public void ValueTypeListNullElementRecords() {
134+
var bind = Bind.PropertiesOf(Request(quantities: [1, null, 3])).FailWith(BookingEnvelopeError.CommandInvalid);
135+
136+
bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From);
137+
138+
Error required = bind.New(_ => "never").Error!.InnerErrors.Single();
139+
Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
140+
Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Quantities[1]");
141+
}
142+
143+
[Fact(DisplayName = "An invalid element of a value-type list records REQUEST_ARGUMENT_INVALID under its indexed path.")]
144+
public void ValueTypeListInvalidElementRecords() {
145+
var bind = Bind.PropertiesOf(Request(quantities: [1, -2, 3])).FailWith(BookingEnvelopeError.CommandInvalid);
146+
147+
bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From);
148+
149+
Error invalid = bind.New(_ => "never").Error!.InnerErrors.Single();
150+
Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
151+
Check.That(BindingAssertions.ArgumentPathOf(invalid)).IsEqualTo("Quantities[1]");
152+
}
153+
154+
[Fact(DisplayName = "An optional value-type list that is absent yields an empty list and records nothing; a required one records REQUEST_ARGUMENT_REQUIRED.")]
155+
public void ValueTypeListOptionalVsRequiredWhenAbsent() {
156+
var optional = Bind.PropertiesOf(Request(quantities: null)).FailWith(BookingEnvelopeError.CommandInvalid);
157+
RequiredField<IReadOnlyList<PositiveQty>> empty = optional.ListOfSimpleProperties(r => r.Quantities).AsOptional(PositiveQty.From);
158+
Check.That(optional.New(s => s.Get(empty).Count).GetResultOrThrow()).IsEqualTo(0);
159+
160+
var required = Bind.PropertiesOf(Request(quantities: null)).FailWith(BookingEnvelopeError.CommandInvalid);
161+
required.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From);
162+
Check.That(required.New(_ => "never").Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
163+
}
164+
165+
// ── Regressions: reference/string paths keep resolving to the original overloads ──────────────────────
166+
167+
[Fact(DisplayName = "A string property still resolves to the reference overload — no ambiguity introduced by the value-type overload.")]
168+
public void StringPropertyStillBindsViaReferenceOverload() {
169+
var bind = Bind.PropertiesOf(Request(note: "a@b.c")).FailWith(BookingEnvelopeError.CommandInvalid);
170+
171+
RequiredField<EmailAddress> email = bind.SimpleProperty(r => r.Note).AsRequired(EmailAddress.Parse);
172+
173+
Check.That(bind.New(s => s.Get(email).Value).GetResultOrThrow()).IsEqualTo("a@b.c");
174+
}
175+
176+
[Fact(DisplayName = "A list of strings still resolves to the reference list overload.")]
177+
public void StringListStillBindsViaReferenceOverload() {
178+
var bind = Bind.PropertiesOf(Request(notes: ["a", "b"])).FailWith(BookingEnvelopeError.CommandInvalid);
179+
180+
RequiredField<IReadOnlyList<Tag>> tags = bind.ListOfSimpleProperties(r => r.Notes).AsOptional(Tag.Parse);
181+
182+
Check.That(bind.New(s => s.Get(tags).Count).GetResultOrThrow()).IsEqualTo(2);
183+
}
184+
185+
[Fact(DisplayName = "A non-nullable value-type property still throws ArgumentException — the value-type overload does not bypass the guard.")]
186+
public void NonNullableValueTypeStillThrows() {
187+
var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid);
188+
189+
Check.ThatCode(() => bind.SimpleProperty(r => r.Count)).Throws<ArgumentException>();
190+
}
191+
192+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
namespace FirstClassErrors.RequestBinder;
2+
3+
/// <summary>
4+
/// Binds a list request property whose elements are a nullable <b>value type</b> (e.g. <c>int?</c>), each converted
5+
/// by a value-object converter over the underlying non-nullable type. Each failing element is recorded
6+
/// individually, under its indexed path (<c>Quantities[2]</c>), so one bad element never hides the others.
7+
/// </summary>
8+
/// <remarks>
9+
/// The value-type counterpart of <see cref="ListOfSimplePropertiesConverter{TRequest, TArgument}" />: because a
10+
/// <c>Nullable&lt;TArgument&gt;</c> element and a reference-type element need different null handling, the value-type
11+
/// path is a separate converter rather than a reuse of the reference one. It differs only in that a present element
12+
/// is unwrapped (<c>element.Value</c>) before conversion.
13+
/// </remarks>
14+
/// <typeparam name="TRequest">The type of the request DTO.</typeparam>
15+
/// <typeparam name="TArgument">The underlying (non-nullable) value type of the DTO list elements.</typeparam>
16+
public sealed class ListOfSimpleValuePropertiesConverter<TRequest, TArgument> where TArgument : struct {
17+
18+
#region Fields declarations
19+
20+
private readonly RequestBinder<TRequest> _binder;
21+
private readonly string _argumentPath;
22+
private readonly IEnumerable<TArgument?>? _values;
23+
private readonly bool _isMissing;
24+
25+
#endregion
26+
27+
#region Constructors declarations
28+
29+
internal ListOfSimpleValuePropertiesConverter(RequestBinder<TRequest> binder, string argumentPath, IEnumerable<TArgument?>? values, bool isMissing) {
30+
_binder = binder;
31+
_argumentPath = argumentPath;
32+
_values = values;
33+
_isMissing = isMissing;
34+
}
35+
36+
#endregion
37+
38+
/// <summary>
39+
/// Binds a required list: a missing list records <c>REQUEST_ARGUMENT_REQUIRED</c>; each failing element
40+
/// records <c>REQUEST_ARGUMENT_INVALID</c> under its indexed path, and a <c>null</c> element records
41+
/// <c>REQUEST_ARGUMENT_REQUIRED</c>.
42+
/// </summary>
43+
/// <typeparam name="TProperty">The type of the element value object.</typeparam>
44+
/// <param name="convertElement">The value-object converter applied to each element's underlying value.</param>
45+
/// <returns>The bound field token.</returns>
46+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="convertElement" /> is <c>null</c>.</exception>
47+
public RequiredField<IReadOnlyList<TProperty>> AsRequired<TProperty>(Func<TArgument, Outcome<TProperty>> convertElement) where TProperty : notnull {
48+
if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); }
49+
50+
if (_isMissing) {
51+
_binder.Record(RequestBindingError.ArgumentRequired(_argumentPath));
52+
53+
return new RequiredField<IReadOnlyList<TProperty>>(_binder, default!);
54+
}
55+
56+
return ConvertElements(convertElement);
57+
}
58+
59+
/// <summary>
60+
/// Binds an optional list: absent yields an <b>empty</b> list (never <c>null</c>) and records nothing; each
61+
/// failing element of a present list still records <c>REQUEST_ARGUMENT_INVALID</c> under its indexed path.
62+
/// </summary>
63+
/// <typeparam name="TProperty">The type of the element value object.</typeparam>
64+
/// <param name="convertElement">The value-object converter applied to each element's underlying value.</param>
65+
/// <returns>The bound field token.</returns>
66+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="convertElement" /> is <c>null</c>.</exception>
67+
public RequiredField<IReadOnlyList<TProperty>> AsOptional<TProperty>(Func<TArgument, Outcome<TProperty>> convertElement) where TProperty : notnull {
68+
if (convertElement is null) { throw new ArgumentNullException(nameof(convertElement)); }
69+
70+
if (_isMissing) {
71+
IReadOnlyList<TProperty> empty = new List<TProperty>();
72+
73+
return new RequiredField<IReadOnlyList<TProperty>>(_binder, empty);
74+
}
75+
76+
return ConvertElements(convertElement);
77+
}
78+
79+
private RequiredField<IReadOnlyList<TProperty>> ConvertElements<TProperty>(Func<TArgument, Outcome<TProperty>> convertElement) where TProperty : notnull {
80+
List<TProperty> converted = new();
81+
int index = 0;
82+
83+
foreach (TArgument? element in _values!) {
84+
string elementPath = $"{_argumentPath}[{index}]";
85+
index++;
86+
87+
if (element is null) {
88+
_binder.Record(RequestBindingError.ArgumentRequired(elementPath));
89+
90+
continue;
91+
}
92+
93+
Outcome<TProperty> outcome = convertElement(element.Value);
94+
if (outcome.IsFailure) {
95+
_binder.Record(RequestBindingError.ArgumentInvalid(elementPath, outcome.Error!));
96+
97+
continue;
98+
}
99+
100+
converted.Add(outcome.GetResultOrThrow());
101+
}
102+
103+
// The value is read only through a BindingScope, which a build terminal creates solely when no failure was recorded —
104+
// i.e. only when every element converted — so `converted` is the complete list exactly when it is observed.
105+
return new RequiredField<IReadOnlyList<TProperty>>(_binder, converted);
106+
}
107+
108+
}

FirstClassErrors.RequestBinder/RequestBinder.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,29 @@ public SimplePropertyConverter<TRequest, TArgument> SimpleProperty<TArgument>(Ex
9595
return new SimplePropertyConverter<TRequest, TArgument>(this, path, (TArgument?)value, value is null);
9696
}
9797

98+
/// <summary>
99+
/// Selects a scalar <b>value-type</b> property declared nullable (e.g. <c>int?</c>), converted by a value-object
100+
/// converter over the <b>underlying, non-nullable type</b> (<c>Func&lt;int, Outcome&lt;T&gt;&gt;</c>).
101+
/// </summary>
102+
/// <remarks>
103+
/// A nullable value-type property surfaces its underlying type here, not its <see cref="Nullable{T}" />: a
104+
/// converter written against the underlying type — a value-object factory such as <c>PositiveInt.From</c> — binds
105+
/// as a method group, exactly as a reference-type converter does on the reference overload. A missing value (a
106+
/// <c>null</c> property) is still distinguished from a legitimate default and is handled by the <c>AsRequired</c>
107+
/// / <c>AsOptional</c> variant chosen next. This overload exists because a value type and a reference type cannot
108+
/// share one selector method (the two would differ only by a <c>class</c> / <c>struct</c> constraint, which C#
109+
/// does not treat as an overload); the two selectors carry genuinely different parameter types
110+
/// (<c>TArgument</c> versus <c>Nullable&lt;TArgument&gt;</c>), so they coexist.
111+
/// </remarks>
112+
/// <typeparam name="TArgument">The underlying (non-nullable) value type of the DTO property.</typeparam>
113+
/// <param name="selector">A direct property access on a nullable value-type property (e.g. <c>r =&gt; r.MaxNights</c>).</param>
114+
/// <returns>The converter stage offering <c>AsRequired</c> / <c>AsOptional</c> and their variants.</returns>
115+
public SimplePropertyConverter<TRequest, TArgument> SimpleProperty<TArgument>(Expression<Func<TRequest, TArgument?>> selector) where TArgument : struct {
116+
(string path, object? value) = ResolveArgument(selector);
117+
118+
return new SimplePropertyConverter<TRequest, TArgument>(this, path, value is null ? default : (TArgument)value, value is null);
119+
}
120+
98121
/// <summary>
99122
/// Selects a complex property, bound by a nested binder. Declare the nested envelope next, with
100123
/// <see cref="ComplexPropertyEnvelopeStage{TRequest, TArgument}.FailWith" />.
@@ -120,6 +143,26 @@ public ListOfSimplePropertiesConverter<TRequest, TArgument> ListOfSimpleProperti
120143
return new ListOfSimplePropertiesConverter<TRequest, TArgument>(this, path, (IEnumerable<TArgument?>?)value, value is null);
121144
}
122145

146+
/// <summary>
147+
/// Selects a list property whose elements are a nullable <b>value type</b> (e.g. <c>int?</c>), each converted by
148+
/// a value-object converter over the <b>underlying, non-nullable type</b> (<c>Func&lt;int, Outcome&lt;T&gt;&gt;</c>).
149+
/// </summary>
150+
/// <remarks>
151+
/// The value-type counterpart of the reference/string list overload: each element surfaces its underlying type,
152+
/// so a converter written against it binds as a method group, while a <c>null</c> element is still recorded as a
153+
/// missing argument under its indexed path. It exists for the same reason as the value-type
154+
/// <c>SimpleProperty</c> overload — an <c>IEnumerable&lt;TArgument&gt;</c> selector and an
155+
/// <c>IEnumerable&lt;Nullable&lt;TArgument&gt;&gt;</c> selector are distinct parameter types, so the two coexist.
156+
/// </remarks>
157+
/// <typeparam name="TArgument">The underlying (non-nullable) value type of the DTO list elements.</typeparam>
158+
/// <param name="selector">A direct property access on a list of nullable value types (e.g. <c>r =&gt; r.Quantities</c>).</param>
159+
/// <returns>The converter stage offering <c>AsRequired</c> / <c>AsOptional</c>.</returns>
160+
public ListOfSimpleValuePropertiesConverter<TRequest, TArgument> ListOfSimpleProperties<TArgument>(Expression<Func<TRequest, IEnumerable<TArgument?>?>> selector) where TArgument : struct {
161+
(string path, object? value) = ResolveArgument(selector);
162+
163+
return new ListOfSimpleValuePropertiesConverter<TRequest, TArgument>(this, path, (IEnumerable<TArgument?>?)value, value is null);
164+
}
165+
123166
/// <summary>
124167
/// Selects a list property whose elements are bound by a nested binder (one per element). Declare the
125168
/// per-element envelope next, with

0 commit comments

Comments
 (0)