Skip to content
Merged
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Read ./ai-plans/AGENTS.md for details on how to write plans.

## Here is Your Space

If you encounter something worth noting while you are working on this code base, write it down here in this section. Once you are finished, I will discuss it with you and we can decide where to put your notes.
If you encounter something worth noting while you are working on this code base, write it down here in this section. Once you are finished, I will discuss it with you, and we can decide where to put your notes.

- Validation target composition needs a clear ownership boundary: when a child validation context is already working with a fully composed `Error.Target`, composing the prefix again duplicates paths such as `address.address.zipCode`. The new validation package treats explicit `Error.Target` values as already absolute and only prefixes targets when the context itself creates the target path.
- `Check<string?>` normalizes `null` to `string.Empty` by default because the shared string value normalizer runs before assertions. That means `IsNotNull` only observes actual `null` strings when a no-op string normalizer is configured for the validation context or the individual check.
1 change: 1 addition & 0 deletions Light.PortableResults.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<File Path="ai-plans\0024-validation-context-optimization.md" />
<File Path="ai-plans\0024-validation-outcome-removal.md" />
<File Path="ai-plans\0024-validation-support.md" />
<File Path="ai-plans\0028-child-validation-refactoring.md" />
<File Path="ai-plans\AGENTS.md" />
</Folder>
<Folder Name="/benchmarks/">
Expand Down
118 changes: 118 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ Install only the packages you need for your scenario.
dotnet add package Light.PortableResults
```

- Validation contexts, checks, synchronous/asynchronous validators, and flat hierarchical validation errors:

```bash
dotnet add package Light.PortableResults.Validation
```

- ASP.NET Core Minimal APIs integration with support for Dependency Injection and `IResult`:

```bash
Expand All @@ -46,6 +52,118 @@ dotnet add package Light.PortableResults.AspNetCore.Mvc

If you only need the Result Pattern itself, install `Light.PortableResults` only.

## Validation Quick Start

`Light.PortableResults.Validation` keeps validation targets flat across nested objects and collections, so child
validation produces paths such as
`address.zipCode` and
`lines[0].sku`. Nullable collections must be guarded
explicitly before item validation, typically with
`IsNotNull()`.

```csharp
using System.Collections.Generic;
using Light.PortableResults.Validation;

public sealed class CreateOrderValidator : Validator<CreateOrderRequest, CreateOrderCommand>
{
private readonly AddressValidator _addressValidator;
private readonly OrderLineValidator _lineValidator;

public CreateOrderValidator(IValidationContextFactory validationContextFactory)
: base(validationContextFactory)
{
_addressValidator = new AddressValidator(validationContextFactory);
_lineValidator = new OrderLineValidator(validationContextFactory);
}

protected override ValidatedValue<CreateOrderCommand> PerformValidation(
ValidationContext context,
CreateOrderRequest value
)
{
var address = context.Check(value.Address).IsNotNull().ValidateChild(_addressValidator);

var tags = context.Check(value.Tags).IsNotNull().ValidateItems(static tag =>
{
if (string.IsNullOrWhiteSpace(tag.Value))
{
tag.AddError("tag must not be empty", "NotEmpty");
}
});

var lines = context.Check(value.Lines).IsNotNull().ValidateItems(_lineValidator);

if (context.HasErrors)
{
return ValidatedValue<CreateOrderCommand>.NoValue;
}

return ValidatedValue.Success(new CreateOrderCommand(address.Value, tags.Value, lines.Value));
}
}
```

Delegate-based item validation works well for primitive collections, while validator-based item transformation is
intentionally limited to
`T[]`,
`List<T>`, and
`ImmutableArray<T>`. For custom collection shapes, validate the
collection through a dedicated child validator instead of expecting the built-in item helpers to preserve that shape.

Async child and collection validation follows the same model and uses
`ValueTask` plus
`CancellationToken` throughout:

```csharp
using System.Threading;
using System.Threading.Tasks;
using Light.PortableResults.Validation;

public sealed class ImportValidator : AsyncValidator<ImportRequest, ImportCommand>
{
private readonly AsyncCustomerValidator _customerValidator;

public ImportValidator(IValidationContextFactory validationContextFactory)
: base(validationContextFactory) =>
_customerValidator = new AsyncCustomerValidator(validationContextFactory);

protected override async ValueTask<ValidatedValue<ImportCommand>> PerformValidationAsync(
ValidationContext context,
ImportRequest value,
CancellationToken cancellationToken
)
{
var customer = await context.Check(value.Customer)
.IsNotNull()
.ValidateChildAsync(_customerValidator, cancellationToken);

var amounts = await context.Check(value.Amounts)
.IsNotNull()
.ValidateItemsAsync(
async (amount, ct) =>
{
await Task.Yield();
ct.ThrowIfCancellationRequested();

if (amount.Value < 0)
{
amount.AddError("amount must be zero or greater", "NonNegative");
}
},
cancellationToken
);

if (context.HasErrors)
{
return ValidatedValue<ImportCommand>.NoValue;
}

return ValidatedValue.Success(new ImportCommand(customer.Value, amounts.Value));
}
}
```

## 🚀 HTTP Quick Start

### Minimal APIs
Expand Down
83 changes: 83 additions & 0 deletions ai-plans/0028-child-validation-refactoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Child Validation Refactoring Completion

## Rationale

The recent child-validation refactoring moved composition from `BaseValidator<TSource>` into `Check<T>`, which makes validator implementations easier to read and aligns better with the fluent validation style of the package. However, the refactoring is not complete yet: target-composition ownership is still ambiguous in the new helpers, collection validation does not cover primitive or transforming item scenarios well, and the async surface is missing entirely. This plan completes the refactoring so that child validation, collection validation, and async validation follow one consistent design that preserves the package's flat target semantics and low-allocation goals.

## Acceptance Criteria

- [x] `Check<T>` exposes child-context helpers that preserve target ownership and target-normalization state for nested validation scenarios.
- [x] The fluent child-validation APIs use those helpers instead of composing child scopes manually, so already-normalized or already-composed targets are not normalized or prefixed a second time.
- [x] `ValidateItems` no longer performs automatic null-error creation; callers must guard nullable collections explicitly, and the method only tolerates `null` when the check has already been short-circuited.
- [x] Collection validation supports delegate-based item validation for primitive and ad-hoc validation scenarios, including a normalization-capable overload that can return `ValidatedValue<TItem>`.
- [x] Collection validation supports transforming item validators for `T[]`, `List<T>`, and `ImmutableArray<T>` in addition to same-type item validators.
- [x] Async parity is added for child and collection validation, including async validator overloads and async delegate-based item validation.
- [x] The completed API preserves deterministic error ordering, target composition, and existing short-circuit behavior across synchronous and asynchronous flows.
- [x] Unsupported collection shapes are intentionally left to user-defined child validators, and this scope boundary is documented in the public API guidance.
- [x] Automated tests cover target-normalization ownership, short-circuited null collections, delegate-based item validation, transforming item validation, async child validation, async collection validation, and representative root/member/index target paths.
- [x] XML documentation and representative usage examples in README.md are updated for the completed child-validation and collection-validation API surface.

## Technical Details

Keep `ValidationState` unchanged. The target-composition problem is path-local metadata, not shared run-level state, so the ownership boundary should stay on `Check<T>` and `ValidationContext`.

Add explicit child-scope helpers to `Check<T>`:

- `CreateChildContext()` should create a child context for the current check target while preserving `IsTargetNormalized`.
- `CreateChildContextForMember(string memberName, bool isNormalized = false)` should compose an additional member segment under the current check target.
- `CreateChildContextForIndex(int index)` should append an index segment under the current check target.

These helpers should become the single fluent entry point for nested scope creation from a check. `CheckExtensions` should stop calling `check.Context.ForMember(check.Target)` directly and should instead use the new helpers so that raw caller-expression targets are normalized once and already-normalized targets are treated as absolute within the current validation scope.

`ValidateChild` and all future child-validation overloads should be refactored around `check.CreateChildContext()`. The same ownership rule must be applied to `ValidateItems`, including indexed child scopes created for collection items.

Change collection null semantics deliberately. `ValidateItems` should assume that the collection has already passed through an explicit null guard such as `IsNotNull`. If the incoming check is short-circuited, the method should return the appropriate "no validated value" result without touching the collection. If the incoming check is not short-circuited and the collection value is `null`, the method should throw an `InvalidOperationException` with a clear message that callers must guard nullable collections before validating items. This keeps null semantics aligned with the rest of the fluent assertion model and removes duplicate automatic-null handling logic from the collection helper.

Extend `CheckExtensions` with delegate-based item-validation overloads for collections of primitives or ad-hoc rules. The plan should support both of the following shapes explicitly:

- `Action<Check<TItem>>` for pure-validation scenarios that only add errors to the shared context
- `Func<Check<TItem>, ValidatedValue<TItem>>` for normalization-capable scenarios

The delegate-based overloads should reuse the same child-scope creation logic as validator-based overloads so that target paths and short-circuit semantics remain identical.

Collection support should be split by capability so that mutability requirements stay explicit:

| Capability | Intended behavior | Supported collection shapes |
| --- | --- | --- |
| Validation-only item checks | Iterate items, add errors, do not replace items | `IReadOnlyList<T>` |
| Same-type normalization | Validate and replace items in place | Mutable indexed collections only (generic with `IList<T>` constraint) |
| Transforming item validation | Validate items and materialize a new collection of a different item type | `T[]`, `List<T>`, and `ImmutableArray<T>` only |

`ImmutableArray<T>` therefore participates in validation-only and transforming flows, but not in-place same-type normalization. The implementation should keep these capability boundaries visible in the overload set instead of relying on runtime failures.

Complete collection support for transforming validators as well, but keep the built-in scope intentionally narrow. Same-type item validation can continue to normalize in place for mutable `IList<TItem>` collections. Transforming item validation should only be provided for linear indexed collection types with straightforward, deterministic materialization semantics:

- `T[]`
- `List<T>`
- `ImmutableArray<T>`

These overloads should materialize the corresponding target collection type directly with predictable allocation behavior rather than trying to preserve arbitrary source collection shapes. Because extensibility is less important than performance in this library, do not introduce generic collection-factory abstractions for transforming validation. Collection types outside this built-in set, including custom collections and non-linear collections such as dictionaries or sets, should be handled by user-defined child validators.

Add async parity in `CheckExtensions` rather than reintroducing special-case helper methods on `BaseValidator<TSource>`. Introduce `ValidateChildAsync` overloads for `AsyncValidator<T>` and `AsyncValidator<TSource, TValidated>`, and introduce `ValidateItemsAsync` overloads for async validators and async delegate-based item validation. The async delegate overloads should be explicit and follow the synchronous design closely:

- `Func<Check<TItem>, CancellationToken, ValueTask>` for pure-validation async item checks
- `Func<Check<TItem>, CancellationToken, ValueTask<ValidatedValue<TItem>>>` for normalization-capable async item checks

Use `ValueTask` and `CancellationToken` throughout the async surface to stay aligned with the existing validator model and to avoid unnecessary allocations. Item validation should remain sequential by contract so that error ordering and collection normalization stay deterministic.

The automated tests should be written through the public API and should cover at least the following scenarios:

- validating a child from a raw check target
- validating a child from an already-normalized check target
- validating items under member and indexed paths without duplicate prefixes
- calling `ValidateItems` on a short-circuited null collection
- calling `ValidateItems` on a non-short-circuited null collection
- delegate-based validation of primitive items such as `string` and `int`
- delegate-based normalization of collection items
- transforming collection items through validator-based overloads for arrays, `List<T>`, and `ImmutableArray<T>`
- `ValidateChildAsync` with same-type and transforming async validators
- `ValidateItemsAsync` with validator-based and delegate-based item validation

XML documentation and representative examples should be updated together with the API changes so that callers can discover the intended patterns for nullable collections, primitive item validation, transforming item validation, and async item validation without having to infer them from tests alone.

If existing benchmarks exercise the affected validation hot paths, update them only as needed to keep the benchmark project compiling and representative. A dedicated benchmark expansion is not required unless implementation choices indicate a measurable regression risk.
9 changes: 3 additions & 6 deletions benchmarks/Benchmarks/ValidationEndpointBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,8 @@ protected override ValidatedValue<ComplexCommand> PerformValidation(ValidationCo
AddressCommand? addressCommand = null;
if (value.Address is not null)
{
var addressOutcome = ValidateChild(_addressValidator, value.Address, context.For(value.Address));
if (addressOutcome.TryGetValue(out var validatedAddress))
{
addressCommand = validatedAddress;
}
var validatedValue = context.Check(value.Address).ValidateChild(_addressValidator);
validatedValue.TryGetValue(out addressCommand);
}

var items = value.Items ?? new List<ItemRequest>();
Expand All @@ -183,7 +180,7 @@ protected override ValidatedValue<ComplexCommand> PerformValidation(ValidationCo
for (var i = 0; i < items.Count; i++)
{
var childContext = itemsContext.ForIndex(i);
var itemOutcome = ValidateChild(_itemValidator, items[i], childContext);
var itemOutcome = _itemValidator.ValidateChildValue(items[i], childContext);
if (itemOutcome.TryGetValue(out var validatedItem))
{
itemCommands[i] = validatedItem;
Expand Down
Loading
Loading