From 91cbaeda433af3f8319ec81b8304475ff221d773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Mon, 6 Apr 2026 10:09:17 +0000 Subject: [PATCH 1/5] Add ZibStack.NET.Result package with Map/Bind/Match monad Functional Result type for eliminating manual error propagation. Includes Error types, sync/async extensions, Combine, Ensure, Tap, implicit conversions, and comprehensive test suite. --- .github/workflows/ci.yml | 3 + ZibStack.NET.slnx | 7 + packages/ZibStack.NET.Result/README.md | 198 +++++++++++++ .../src/ZibStack.NET.Result/Error.cs | 56 ++++ .../src/ZibStack.NET.Result/Result.cs | 152 ++++++++++ .../ResultAsyncExtensions.cs | 146 ++++++++++ .../ZibStack.NET.Result/ResultExtensions.cs | 73 +++++ .../ZibStack.NET.Result.csproj | 28 ++ .../ResultAsyncTests.cs | 149 ++++++++++ .../ResultExtensionsTests.cs | 134 +++++++++ .../ZibStack.NET.Result.Tests/ResultTests.cs | 269 ++++++++++++++++++ .../ZibStack.NET.Result.Tests.csproj | 20 ++ 12 files changed, 1235 insertions(+) create mode 100644 packages/ZibStack.NET.Result/README.md create mode 100644 packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Error.cs create mode 100644 packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Result.cs create mode 100644 packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultAsyncExtensions.cs create mode 100644 packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultExtensions.cs create mode 100644 packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ZibStack.NET.Result.csproj create mode 100644 packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultAsyncTests.cs create mode 100644 packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultExtensionsTests.cs create mode 100644 packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultTests.cs create mode 100644 packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ZibStack.NET.Result.Tests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2b260f..23bdffa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: - name: Test Dto run: dotnet test packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/ZibStack.NET.Dto.Tests.csproj -c Release --no-build + - name: Test Result + run: dotnet test packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ZibStack.NET.Result.Tests.csproj -c Release --no-build + - name: Benchmark Log run: dotnet run --project packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Benchmarks/ZibStack.NET.Log.Benchmarks.csproj -c Release -- --filter "*" --exporters json diff --git a/ZibStack.NET.slnx b/ZibStack.NET.slnx index f7557a5..2ca7e23 100644 --- a/ZibStack.NET.slnx +++ b/ZibStack.NET.slnx @@ -33,4 +33,11 @@ + + + + + + + diff --git a/packages/ZibStack.NET.Result/README.md b/packages/ZibStack.NET.Result/README.md new file mode 100644 index 0000000..ab218b9 --- /dev/null +++ b/packages/ZibStack.NET.Result/README.md @@ -0,0 +1,198 @@ +# ZibStack.NET.Result + +Functional `Result` monad for .NET — eliminate manual error propagation across layers. + +## Install + +```bash +dotnet add package ZibStack.NET.Result +``` + +## The Problem + +```csharp +// Every layer re-checks and re-wraps errors manually: +Result orderResult = _orderService.GetOrder(id); +if (orderResult.IsFailure) + return Result.Failure(orderResult.Error); // maintenance hell + +var dto = MapToDto(orderResult.Value); +return Result.Success(dto); +``` + +## The Solution + +```csharp +// Map — transform the value, errors propagate automatically +Result dto = _orderService.GetOrder(id) + .Map(order => MapToDto(order)); + +// Bind — chain Result-returning calls +Result result = _orderService.GetOrder(id) + .Bind(order => _shipmentService.GetShipment(order.ShipmentId)) + .Map(shipment => MapToDto(shipment)); +``` + +## Quick Start + +### Creating Results + +```csharp +// Success +Result ok = Result.Success(42); + +// Failure +Result fail = Result.Failure(Error.NotFound("Order not found")); + +// Implicit conversions +Result ok2 = 42; +Result fail2 = Error.Validation("Invalid input"); + +// From nullable +Order? order = db.Find(id); +Result result = order.ToResult(Error.NotFound($"Order {id} not found")); +``` + +### Built-in Error Types + +```csharp +Error.Validation("Name is required") +Error.NotFound("User not found") +Error.Conflict("Email already exists") +Error.Unauthorized("Invalid token") +Error.Forbidden("Insufficient permissions") +Error.Unexpected("Something went wrong") + +// Aggregate errors +Error.Validation("Multiple errors", new[] { error1, error2 }) +``` + +### Map & Bind + +```csharp +// Map — transform value (T → K) +Result name = GetUser(id) + .Map(user => user.Name); + +// Bind — chain operations (T → Result) +Result order = GetUser(id) + .Bind(user => GetLatestOrder(user.Id)); + +// Chain multiple +Result tracking = GetUser(id) + .Bind(user => GetLatestOrder(user.Id)) + .Bind(order => GetShipment(order.ShipmentId)) + .Map(shipment => shipment.TrackingNumber); +``` + +### Match & Switch + +```csharp +// Match — extract a value from either path +string message = result.Match( + value => $"Found: {value}", + error => $"Error: {error.Message}"); + +// Switch — execute side effects +result.Switch( + value => Console.WriteLine($"OK: {value}"), + error => logger.LogError(error.Message)); +``` + +### Ensure + +```csharp +Result result = GetAge() + .Ensure(age => age >= 18, Error.Validation("Must be 18+")) + .Ensure(age => age <= 120, Error.Validation("Invalid age")); +``` + +### Tap + +```csharp +// Execute a side effect without changing the result +var result = GetOrder(id) + .Tap(order => logger.LogInformation("Found order {Id}", order.Id)) + .Map(order => MapToDto(order)); +``` + +### Fallbacks + +```csharp +Result config = LoadFromFile() + .OrElse(_ => LoadFromEnvironment()) + .OrElse(_ => Result.Success(Config.Default)); +``` + +## Async Support + +Full async pipeline support — chain `Task>` without awaiting each step: + +```csharp +Result result = await GetOrderAsync(id) + .BindAsync(order => GetShipmentAsync(order.ShipmentId)) + .MapAsync(shipment => MapToDto(shipment)); + +// Async mapper/binder +Result data = await GetUrlAsync(id) + .BindAsync(async url => + { + var response = await httpClient.GetAsync(url); + return response.IsSuccessStatusCode + ? Result.Success(await response.Content.ReadAsByteArrayAsync()) + : Result.Failure(Error.Unexpected("Download failed")); + }); + +// TapAsync with async side effect +var result = await GetOrderAsync(id) + .TapAsync(async order => await PublishEventAsync(order)) + .MapAsync(order => MapToDto(order)); +``` + +Supported async extensions: `MapAsync`, `BindAsync`, `MatchAsync`, `TapAsync`, `SwitchAsync`, `GetValueOrDefaultAsync`, `OrElseAsync`. + +Both `Task>` and `ValueTask>` overloads are provided. + +## Combining Results + +```csharp +// Combine — fail on first error +var results = new[] { Validate(a), Validate(b), Validate(c) }; +Result> combined = results.Combine(); + +// CombineAll — collect ALL errors +Result> all = results.CombineAll(); +// all.Error.InnerErrors contains every individual error +``` + +## Real-World Example + +```csharp +public class OrderService +{ + public async Task> PlaceOrderAsync(CreateOrderRequest request) + { + return await ValidateRequest(request) + .BindAsync(req => FindCustomerAsync(req.CustomerId)) + .BindAsync(customer => CreateOrderAsync(customer, request)) + .TapAsync(order => SendConfirmationEmailAsync(order)) + .MapAsync(order => new OrderConfirmation(order.Id, order.Total)); + } + + private Result ValidateRequest(CreateOrderRequest request) + { + return Result.Success(request) + .Ensure(r => r.Items.Count > 0, Error.Validation("Order must have items")) + .Ensure(r => r.CustomerId > 0, Error.Validation("Invalid customer")); + } +} +``` + +## Requirements + +- .NET 8.0+ (async extensions) +- .NET Standard 2.0 (core Result/Error types) + +## License + +MIT diff --git a/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Error.cs b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Error.cs new file mode 100644 index 0000000..acc9873 --- /dev/null +++ b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Error.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; + +namespace ZibStack.NET.Result; + +/// +/// Represents a structured error with a code and message. +/// +public sealed class Error +{ + public string Code { get; } + public string Message { get; } + public IReadOnlyList InnerErrors { get; } + + public Error(string code, string message, IReadOnlyList? innerErrors = null) + { + Code = code ?? throw new ArgumentNullException(nameof(code)); + Message = message ?? throw new ArgumentNullException(nameof(message)); + InnerErrors = innerErrors ?? Array.Empty(); + } + + public static Error Validation(string message, IReadOnlyList? innerErrors = null) + => new("Validation", message, innerErrors); + + public static Error NotFound(string message) + => new("NotFound", message); + + public static Error Conflict(string message) + => new("Conflict", message); + + public static Error Unauthorized(string message) + => new("Unauthorized", message); + + public static Error Forbidden(string message) + => new("Forbidden", message); + + public static Error Unexpected(string message) + => new("Unexpected", message); + + public override string ToString() => $"[{Code}] {Message}"; + + public override bool Equals(object? obj) + => obj is Error other && Code == other.Code && Message == other.Message; + + public override int GetHashCode() + { +#if NET8_0_OR_GREATER + return HashCode.Combine(Code, Message); +#else + unchecked + { + return (Code.GetHashCode() * 397) ^ Message.GetHashCode(); + } +#endif + } +} diff --git a/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Result.cs b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Result.cs new file mode 100644 index 0000000..2b8c615 --- /dev/null +++ b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/Result.cs @@ -0,0 +1,152 @@ +using System; + +namespace ZibStack.NET.Result; + +/// +/// Represents the outcome of an operation that does not return a value. +/// +public readonly struct Result +{ + private readonly Error? _error; + + private Result(Error? error) + { + _error = error; + } + + public bool IsSuccess => _error is null; + public bool IsFailure => _error is not null; + + public Error Error => _error ?? throw new InvalidOperationException("Cannot access Error on a successful result."); + + public static Result Success() => new(null); + + public static Result Failure(Error error) => new(error ?? throw new ArgumentNullException(nameof(error))); + + public static Result Success(T value) => Result.Success(value); + + public static Result Failure(Error error) => Result.Failure(error); + + /// + /// Pattern match on the result. + /// + public TOut Match(Func onSuccess, Func onFailure) + => IsSuccess ? onSuccess() : onFailure(_error!); + + /// + /// Execute an action based on the result. + /// + public void Switch(Action onSuccess, Action onFailure) + { + if (IsSuccess) + onSuccess(); + else + onFailure(_error!); + } + + public override string ToString() + => IsSuccess ? "Success" : $"Failure({_error})"; +} + +/// +/// Represents the outcome of an operation that returns a value of type . +/// +public readonly struct Result +{ + private readonly T? _value; + private readonly Error? _error; + + private Result(T value) + { + _value = value; + _error = null; + } + + private Result(Error error) + { + _value = default; + _error = error; + } + + public bool IsSuccess => _error is null; + public bool IsFailure => _error is not null; + + public T Value => IsSuccess + ? _value! + : throw new InvalidOperationException("Cannot access Value on a failed result."); + + public Error Error => _error ?? throw new InvalidOperationException("Cannot access Error on a successful result."); + + public static Result Success(T value) => new(value); + + public static Result Failure(Error error) => new(error ?? throw new ArgumentNullException(nameof(error))); + + /// + /// Transform the success value. If the result is a failure, the error propagates automatically. + /// + public Result Map(Func map) + => IsSuccess ? Result.Success(map(_value!)) : Result.Failure(_error!); + + /// + /// Chain another Result-returning operation. If the result is a failure, the error propagates automatically. + /// + public Result Bind(Func> bind) + => IsSuccess ? bind(_value!) : Result.Failure(_error!); + + /// + /// Pattern match on the result. + /// + public TOut Match(Func onSuccess, Func onFailure) + => IsSuccess ? onSuccess(_value!) : onFailure(_error!); + + /// + /// Execute an action based on the result. + /// + public void Switch(Action onSuccess, Action onFailure) + { + if (IsSuccess) + onSuccess(_value!); + else + onFailure(_error!); + } + + /// + /// Execute a side effect on success, then return the original result. + /// + public Result Tap(Action action) + { + if (IsSuccess) + action(_value!); + return this; + } + + /// + /// Returns the value if success, or the provided default if failure. + /// + public T GetValueOrDefault(T defaultValue) + => IsSuccess ? _value! : defaultValue; + + /// + /// Returns the value if success, or invokes the factory if failure. + /// + public T GetValueOrDefault(Func factory) + => IsSuccess ? _value! : factory(_error!); + + /// + /// Convert to a non-generic Result, discarding the value. + /// + public Result ToResult() + => IsSuccess ? Result.Success() : Result.Failure(_error!); + + /// + /// Provide a fallback Result if this one failed. + /// + public Result OrElse(Func> fallback) + => IsSuccess ? this : fallback(_error!); + + public static implicit operator Result(T value) => Success(value); + public static implicit operator Result(Error error) => Failure(error); + + public override string ToString() + => IsSuccess ? $"Success({_value})" : $"Failure({_error})"; +} diff --git a/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultAsyncExtensions.cs b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultAsyncExtensions.cs new file mode 100644 index 0000000..b4835ef --- /dev/null +++ b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultAsyncExtensions.cs @@ -0,0 +1,146 @@ +using System; +using System.Threading.Tasks; + +namespace ZibStack.NET.Result; + +/// +/// Async extensions for Result<T>, enabling fluent async pipelines. +/// +public static class ResultAsyncExtensions +{ + // ── Map ─────────────────────────────────────────────────────────── + + public static async Task> MapAsync( + this Task> resultTask, Func map) + { + var result = await resultTask.ConfigureAwait(false); + return result.Map(map); + } + + public static async Task> MapAsync( + this Task> resultTask, Func> map) + { + var result = await resultTask.ConfigureAwait(false); + if (result.IsFailure) + return Result.Failure(result.Error); + + var mapped = await map(result.Value).ConfigureAwait(false); + return Result.Success(mapped); + } + + public static async ValueTask> MapAsync( + this ValueTask> resultTask, Func map) + { + var result = await resultTask.ConfigureAwait(false); + return result.Map(map); + } + + // ── Bind ────────────────────────────────────────────────────────── + + public static async Task> BindAsync( + this Task> resultTask, Func> bind) + { + var result = await resultTask.ConfigureAwait(false); + return result.Bind(bind); + } + + public static async Task> BindAsync( + this Task> resultTask, Func>> bind) + { + var result = await resultTask.ConfigureAwait(false); + if (result.IsFailure) + return Result.Failure(result.Error); + + return await bind(result.Value).ConfigureAwait(false); + } + + public static async ValueTask> BindAsync( + this ValueTask> resultTask, Func> bind) + { + var result = await resultTask.ConfigureAwait(false); + return result.Bind(bind); + } + + public static async ValueTask> BindAsync( + this ValueTask> resultTask, Func>> bind) + { + var result = await resultTask.ConfigureAwait(false); + if (result.IsFailure) + return Result.Failure(result.Error); + + return await bind(result.Value).ConfigureAwait(false); + } + + // ── Match ───────────────────────────────────────────────────────── + + public static async Task MatchAsync( + this Task> resultTask, Func onSuccess, Func onFailure) + { + var result = await resultTask.ConfigureAwait(false); + return result.Match(onSuccess, onFailure); + } + + public static async Task MatchAsync( + this Task> resultTask, Func> onSuccess, Func> onFailure) + { + var result = await resultTask.ConfigureAwait(false); + return result.IsSuccess + ? await onSuccess(result.Value).ConfigureAwait(false) + : await onFailure(result.Error).ConfigureAwait(false); + } + + // ── Tap ─────────────────────────────────────────────────────────── + + public static async Task> TapAsync( + this Task> resultTask, Action action) + { + var result = await resultTask.ConfigureAwait(false); + return result.Tap(action); + } + + public static async Task> TapAsync( + this Task> resultTask, Func action) + { + var result = await resultTask.ConfigureAwait(false); + if (result.IsSuccess) + await action(result.Value).ConfigureAwait(false); + return result; + } + + // ── Switch ──────────────────────────────────────────────────────── + + public static async Task SwitchAsync( + this Task> resultTask, Action onSuccess, Action onFailure) + { + var result = await resultTask.ConfigureAwait(false); + result.Switch(onSuccess, onFailure); + } + + // ── GetValueOrDefault ───────────────────────────────────────────── + + public static async Task GetValueOrDefaultAsync( + this Task> resultTask, T defaultValue) + { + var result = await resultTask.ConfigureAwait(false); + return result.GetValueOrDefault(defaultValue); + } + + // ── OrElse ──────────────────────────────────────────────────────── + + public static async Task> OrElseAsync( + this Task> resultTask, Func> fallback) + { + var result = await resultTask.ConfigureAwait(false); + return result.OrElse(fallback); + } + + public static async Task> OrElseAsync( + this Task> resultTask, Func>> fallback) + { + var result = await resultTask.ConfigureAwait(false); + if (result.IsSuccess) + return result; + + return await fallback(result.Error).ConfigureAwait(false); + } +} diff --git a/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultExtensions.cs b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultExtensions.cs new file mode 100644 index 0000000..6b5a783 --- /dev/null +++ b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ResultExtensions.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ZibStack.NET.Result; + +/// +/// Utility extensions for working with collections of Results. +/// +public static class ResultExtensions +{ + /// + /// Combines multiple results into a single Result containing a list of values. + /// If any result is a failure, returns the first error. + /// + public static Result> Combine(this IEnumerable> results) + { + var values = new List(); + foreach (var result in results) + { + if (result.IsFailure) + return Result>.Failure(result.Error); + values.Add(result.Value); + } + return Result>.Success(values); + } + + /// + /// Combines multiple results, collecting all errors into a single Validation error. + /// + public static Result> CombineAll(this IEnumerable> results) + { + var values = new List(); + var errors = new List(); + + foreach (var result in results) + { + if (result.IsFailure) + errors.Add(result.Error); + else + values.Add(result.Value); + } + + if (errors.Count > 0) + return Result>.Failure( + Error.Validation("One or more operations failed.", errors)); + + return Result>.Success(values); + } + + /// + /// Converts a nullable value to a Result, using the provided error if null. + /// + public static Result ToResult(this T? value, Error error) where T : class + => value is not null ? Result.Success(value) : Result.Failure(error); + + /// + /// Converts a nullable value type to a Result, using the provided error if null. + /// + public static Result ToResult(this T? value, Error error) where T : struct + => value.HasValue ? Result.Success(value.Value) : Result.Failure(error); + + /// + /// Ensures a condition is met on the value; otherwise returns the provided error. + /// + public static Result Ensure(this Result result, Func predicate, Error error) + { + if (result.IsFailure) + return result; + + return predicate(result.Value) ? result : Result.Failure(error); + } +} diff --git a/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ZibStack.NET.Result.csproj b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ZibStack.NET.Result.csproj new file mode 100644 index 0000000..e1f038c --- /dev/null +++ b/packages/ZibStack.NET.Result/src/ZibStack.NET.Result/ZibStack.NET.Result.csproj @@ -0,0 +1,28 @@ + + + + netstandard2.0;net8.0 + latest + enable + enable + + ZibStack.NET.Result + Functional Result monad for .NET with Map, Bind, Match and async support. Eliminate manual error propagation. + $(Authors) + MIT + https://github.com/MistyKuu/ZibStack.NET + https://github.com/MistyKuu/ZibStack.NET + result;monad;functional;error-handling;csharp + README.md + + + + + + + + + + + + diff --git a/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultAsyncTests.cs b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultAsyncTests.cs new file mode 100644 index 0000000..287b214 --- /dev/null +++ b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultAsyncTests.cs @@ -0,0 +1,149 @@ +using ZibStack.NET.Result; + +namespace ZibStack.NET.Result.Tests; + +public class ResultAsyncTests +{ + // ── MapAsync ────────────────────────────────────────────────────── + + [Fact] + public async Task MapAsync_Success_TransformsValue() + { + var result = await Task.FromResult(Result.Success(5)) + .MapAsync(x => x * 2); + + Assert.True(result.IsSuccess); + Assert.Equal(10, result.Value); + } + + [Fact] + public async Task MapAsync_Failure_PropagatesError() + { + var result = await Task.FromResult(Result.Failure(Error.NotFound("x"))) + .MapAsync(x => x * 2); + + Assert.True(result.IsFailure); + Assert.Equal("NotFound", result.Error.Code); + } + + [Fact] + public async Task MapAsync_WithAsyncMapper_Success() + { + var result = await Task.FromResult(Result.Success(5)) + .MapAsync(async x => + { + await Task.Delay(1); + return x.ToString(); + }); + + Assert.True(result.IsSuccess); + Assert.Equal("5", result.Value); + } + + // ── BindAsync ───────────────────────────────────────────────────── + + [Fact] + public async Task BindAsync_Success_ChainsOperation() + { + var result = await Task.FromResult(Result.Success(10)) + .BindAsync(x => Result.Success($"val:{x}")); + + Assert.True(result.IsSuccess); + Assert.Equal("val:10", result.Value); + } + + [Fact] + public async Task BindAsync_Failure_PropagatesError() + { + var result = await Task.FromResult(Result.Failure(Error.Unexpected("boom"))) + .BindAsync(x => Result.Success(x.ToString())); + + Assert.True(result.IsFailure); + } + + [Fact] + public async Task BindAsync_WithAsyncBinder_Success() + { + var result = await Task.FromResult(Result.Success(5)) + .BindAsync(async x => + { + await Task.Delay(1); + return Result.Success(x.ToString()); + }); + + Assert.True(result.IsSuccess); + Assert.Equal("5", result.Value); + } + + // ── Full async pipeline ─────────────────────────────────────────── + + [Fact] + public async Task Full_Async_Pipeline() + { + var result = await GetOrderAsync(1) + .BindAsync(order => GetShipmentAsync(order)) + .MapAsync(shipment => $"shipped:{shipment}"); + + Assert.True(result.IsSuccess); + Assert.Equal("shipped:SHIP-1", result.Value); + } + + [Fact] + public async Task Full_Async_Pipeline_FailsAtFirstError() + { + var result = await GetOrderAsync(999) // will fail + .BindAsync(order => GetShipmentAsync(order)) + .MapAsync(shipment => $"shipped:{shipment}"); + + Assert.True(result.IsFailure); + Assert.Equal("NotFound", result.Error.Code); + } + + // ── TapAsync ────────────────────────────────────────────────────── + + [Fact] + public async Task TapAsync_Success_ExecutesSideEffect() + { + var logged = ""; + var result = await Task.FromResult(Result.Success("hello")) + .TapAsync(v => logged = v); + + Assert.Equal("hello", logged); + Assert.Equal("hello", result.Value); + } + + // ── MatchAsync ──────────────────────────────────────────────────── + + [Fact] + public async Task MatchAsync_Success_CallsOnSuccess() + { + var output = await Task.FromResult(Result.Success(42)) + .MatchAsync( + v => $"ok:{v}", + e => $"err:{e.Code}"); + + Assert.Equal("ok:42", output); + } + + // ── OrElseAsync ─────────────────────────────────────────────────── + + [Fact] + public async Task OrElseAsync_Failure_ReturnsFallback() + { + var result = await Task.FromResult(Result.Failure(Error.NotFound("x"))) + .OrElseAsync(_ => Result.Success(0)); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value); + } + + // ── Helpers ─────────────────────────────────────────────────────── + + private static Task> GetOrderAsync(int id) + => Task.FromResult(id == 1 + ? Result.Success("ORDER-1") + : Result.Failure(Error.NotFound($"Order {id} not found"))); + + private static Task> GetShipmentAsync(string orderId) + => Task.FromResult(Result.Success($"SHIP-{orderId.Split('-')[1]}")); +} diff --git a/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultExtensionsTests.cs b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultExtensionsTests.cs new file mode 100644 index 0000000..949ace7 --- /dev/null +++ b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultExtensionsTests.cs @@ -0,0 +1,134 @@ +using ZibStack.NET.Result; + +namespace ZibStack.NET.Result.Tests; + +public class ResultExtensionsTests +{ + // ── Combine ─────────────────────────────────────────────────────── + + [Fact] + public void Combine_AllSuccess_ReturnsList() + { + var results = new[] + { + Result.Success(1), + Result.Success(2), + Result.Success(3), + }; + + var combined = results.Combine(); + + Assert.True(combined.IsSuccess); + Assert.Equal(new[] { 1, 2, 3 }, combined.Value); + } + + [Fact] + public void Combine_OneFailure_ReturnsFirstError() + { + var results = new[] + { + Result.Success(1), + Result.Failure(Error.Validation("bad")), + Result.Success(3), + }; + + var combined = results.Combine(); + + Assert.True(combined.IsFailure); + Assert.Equal("bad", combined.Error.Message); + } + + // ── CombineAll ──────────────────────────────────────────────────── + + [Fact] + public void CombineAll_CollectsAllErrors() + { + var results = new[] + { + Result.Failure(Error.Validation("err1")), + Result.Success(2), + Result.Failure(Error.Validation("err2")), + }; + + var combined = results.CombineAll(); + + Assert.True(combined.IsFailure); + Assert.Equal(2, combined.Error.InnerErrors.Count); + Assert.Equal("err1", combined.Error.InnerErrors[0].Message); + Assert.Equal("err2", combined.Error.InnerErrors[1].Message); + } + + // ── ToResult (reference type) ───────────────────────────────────── + + [Fact] + public void ToResult_NotNull_ReturnsSuccess() + { + string? value = "hello"; + var result = value.ToResult(Error.NotFound("missing")); + + Assert.True(result.IsSuccess); + Assert.Equal("hello", result.Value); + } + + [Fact] + public void ToResult_Null_ReturnsFailure() + { + string? value = null; + var result = value.ToResult(Error.NotFound("missing")); + + Assert.True(result.IsFailure); + } + + // ── ToResult (value type) ───────────────────────────────────────── + + [Fact] + public void ToResult_NullableStruct_HasValue_ReturnsSuccess() + { + int? value = 42; + var result = value.ToResult(Error.NotFound("missing")); + + Assert.True(result.IsSuccess); + Assert.Equal(42, result.Value); + } + + [Fact] + public void ToResult_NullableStruct_Null_ReturnsFailure() + { + int? value = null; + var result = value.ToResult(Error.NotFound("missing")); + + Assert.True(result.IsFailure); + } + + // ── Ensure ──────────────────────────────────────────────────────── + + [Fact] + public void Ensure_PredicateTrue_ReturnsOriginal() + { + var result = Result.Success(10) + .Ensure(x => x > 5, Error.Validation("too small")); + + Assert.True(result.IsSuccess); + Assert.Equal(10, result.Value); + } + + [Fact] + public void Ensure_PredicateFalse_ReturnsFailure() + { + var result = Result.Success(3) + .Ensure(x => x > 5, Error.Validation("too small")); + + Assert.True(result.IsFailure); + Assert.Equal("too small", result.Error.Message); + } + + [Fact] + public void Ensure_OnFailure_PropagatesOriginalError() + { + var result = Result.Failure(Error.NotFound("gone")) + .Ensure(x => x > 5, Error.Validation("too small")); + + Assert.True(result.IsFailure); + Assert.Equal("NotFound", result.Error.Code); + } +} diff --git a/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultTests.cs b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultTests.cs new file mode 100644 index 0000000..d5c6eab --- /dev/null +++ b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ResultTests.cs @@ -0,0 +1,269 @@ +using ZibStack.NET.Result; + +namespace ZibStack.NET.Result.Tests; + +public class ResultTests +{ + // ── Success / Failure ───────────────────────────────────────────── + + [Fact] + public void Success_IsSuccess_ReturnsTrue() + { + var result = Result.Success(42); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Equal(42, result.Value); + } + + [Fact] + public void Failure_IsFailure_ReturnsTrue() + { + var result = Result.Failure(Error.NotFound("not found")); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + Assert.Equal("NotFound", result.Error.Code); + } + + [Fact] + public void Accessing_Value_On_Failure_Throws() + { + var result = Result.Failure(Error.NotFound("nope")); + + Assert.Throws(() => result.Value); + } + + [Fact] + public void Accessing_Error_On_Success_Throws() + { + var result = Result.Success(1); + + Assert.Throws(() => result.Error); + } + + // ── Map ─────────────────────────────────────────────────────────── + + [Fact] + public void Map_Success_TransformsValue() + { + var result = Result.Success(5) + .Map(x => x * 2); + + Assert.True(result.IsSuccess); + Assert.Equal(10, result.Value); + } + + [Fact] + public void Map_Failure_PropagatesError() + { + var error = Error.Unexpected("boom"); + var result = Result.Failure(error) + .Map(x => x * 2); + + Assert.True(result.IsFailure); + Assert.Equal(error, result.Error); + } + + // ── Bind ────────────────────────────────────────────────────────── + + [Fact] + public void Bind_Success_ChainsOperation() + { + var result = Result.Success(10) + .Bind(x => x > 5 + ? Result.Success($"big:{x}") + : Result.Failure(Error.Validation("too small"))); + + Assert.True(result.IsSuccess); + Assert.Equal("big:10", result.Value); + } + + [Fact] + public void Bind_Failure_PropagatesError() + { + var error = Error.NotFound("missing"); + var result = Result.Failure(error) + .Bind(x => Result.Success(x.ToString())); + + Assert.True(result.IsFailure); + Assert.Equal(error, result.Error); + } + + [Fact] + public void Bind_Chain_StopsAtFirstError() + { + var result = Result.Success(1) + .Bind(x => Result.Success(x + 1)) + .Bind(x => Result.Failure(Error.Validation("stop here"))) + .Bind(x => Result.Success(x + 100)); // should not execute + + Assert.True(result.IsFailure); + Assert.Equal("stop here", result.Error.Message); + } + + // ── Match ───────────────────────────────────────────────────────── + + [Fact] + public void Match_Success_CallsOnSuccess() + { + var output = Result.Success(42) + .Match( + v => $"value={v}", + e => $"error={e.Code}"); + + Assert.Equal("value=42", output); + } + + [Fact] + public void Match_Failure_CallsOnFailure() + { + var output = Result.Failure(Error.Forbidden("nope")) + .Match( + v => $"value={v}", + e => $"error={e.Code}"); + + Assert.Equal("error=Forbidden", output); + } + + // ── Tap ─────────────────────────────────────────────────────────── + + [Fact] + public void Tap_Success_ExecutesSideEffect() + { + var sideEffect = 0; + var result = Result.Success(7) + .Tap(v => sideEffect = v); + + Assert.Equal(7, sideEffect); + Assert.Equal(7, result.Value); + } + + [Fact] + public void Tap_Failure_DoesNotExecute() + { + var executed = false; + Result.Failure(Error.Unexpected("err")) + .Tap(_ => executed = true); + + Assert.False(executed); + } + + // ── GetValueOrDefault ───────────────────────────────────────────── + + [Fact] + public void GetValueOrDefault_Success_ReturnsValue() + { + var result = Result.Success(42); + + Assert.Equal(42, result.GetValueOrDefault(0)); + } + + [Fact] + public void GetValueOrDefault_Failure_ReturnsDefault() + { + var result = Result.Failure(Error.NotFound("x")); + + Assert.Equal(0, result.GetValueOrDefault(0)); + } + + [Fact] + public void GetValueOrDefault_Factory_Failure_InvokesFactory() + { + var result = Result.Failure(Error.NotFound("x")); + + Assert.Equal("fallback:NotFound", result.GetValueOrDefault(e => $"fallback:{e.Code}")); + } + + // ── OrElse ──────────────────────────────────────────────────────── + + [Fact] + public void OrElse_Success_ReturnsOriginal() + { + var result = Result.Success(1) + .OrElse(_ => Result.Success(999)); + + Assert.Equal(1, result.Value); + } + + [Fact] + public void OrElse_Failure_ReturnsFallback() + { + var result = Result.Failure(Error.NotFound("x")) + .OrElse(_ => Result.Success(999)); + + Assert.Equal(999, result.Value); + } + + // ── Implicit conversions ────────────────────────────────────────── + + [Fact] + public void Implicit_Value_CreatesSuccess() + { + Result result = 42; + + Assert.True(result.IsSuccess); + Assert.Equal(42, result.Value); + } + + [Fact] + public void Implicit_Error_CreatesFailure() + { + Result result = Error.NotFound("gone"); + + Assert.True(result.IsFailure); + Assert.Equal("NotFound", result.Error.Code); + } + + // ── Non-generic Result ──────────────────────────────────────────── + + [Fact] + public void NonGeneric_Success() + { + var result = Result.Result.Success(); + + Assert.True(result.IsSuccess); + } + + [Fact] + public void NonGeneric_Failure() + { + var result = Result.Result.Failure(Error.Unexpected("boom")); + + Assert.True(result.IsFailure); + Assert.Equal("Unexpected", result.Error.Code); + } + + [Fact] + public void NonGeneric_Match() + { + var result = Result.Result.Failure(Error.Conflict("dup")); + + var output = result.Match( + () => "ok", + e => $"err:{e.Code}"); + + Assert.Equal("err:Conflict", output); + } + + // ── ToResult ────────────────────────────────────────────────────── + + [Fact] + public void ToResult_DiscardValue() + { + var typed = Result.Success(42); + var untyped = typed.ToResult(); + + Assert.True(untyped.IsSuccess); + } + + [Fact] + public void ToResult_PreservesError() + { + var typed = Result.Failure(Error.NotFound("x")); + var untyped = typed.ToResult(); + + Assert.True(untyped.IsFailure); + Assert.Equal("NotFound", untyped.Error.Code); + } +} diff --git a/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ZibStack.NET.Result.Tests.csproj b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ZibStack.NET.Result.Tests.csproj new file mode 100644 index 0000000..9c1ded6 --- /dev/null +++ b/packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ZibStack.NET.Result.Tests.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + From de60451d03056b6b236a407b32dcd88d1bea92dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Mon, 6 Apr 2026 10:13:40 +0000 Subject: [PATCH 2/5] Add PaginatedResponse and sortable QueryDto support - PaginatedResponse: generic wrapper with Items, TotalCount, Page, PageSize, TotalPages, HasNextPage, HasPreviousPage, Map(), CreateAsync() - QueryDto(Sortable = true): adds SortBy/SortDirection properties, ApplySort() with case-insensitive switch, Apply() convenience method - DefaultSort and DefaultSortDirection configuration - SortDirection enum (Asc/Desc) - Tests for both features - Updated README with docs and examples --- packages/ZibStack.NET.Dto/README.md | 79 +++++++++++ .../DtoGenerator.AttributeSources.cs | 84 ++++++++++++ .../DtoGenerator.Extraction.cs | 9 +- .../DtoGenerator.Generation.cs | 48 +++++++ .../src/ZibStack.NET.Dto/DtoGenerator.cs | 2 + .../ZibStack.NET.Dto/Models/QueryModels.cs | 8 +- .../PaginatedResponseTests.cs | 113 ++++++++++++++++ .../SortableQueryTests.cs | 128 ++++++++++++++++++ 8 files changed, 469 insertions(+), 2 deletions(-) create mode 100644 packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/PaginatedResponseTests.cs create mode 100644 packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/SortableQueryTests.cs diff --git a/packages/ZibStack.NET.Dto/README.md b/packages/ZibStack.NET.Dto/README.md index 5a8bb33..e7eb705 100644 --- a/packages/ZibStack.NET.Dto/README.md +++ b/packages/ZibStack.NET.Dto/README.md @@ -198,6 +198,8 @@ public IActionResult HandleCreate(ICanCreate request) where T : class | `[PickFrom(typeof(T), ...)]` | Record (partial) | Like TS `Pick` — whitelist of properties | | `[OmitFrom(typeof(T), ...)]` | Record (partial) | Like TS `Omit` — exclude listed properties | | `[QueryDto]` | Class | Generates filter DTO with nullable properties + `ApplyFilter(IQueryable)` | +| `[QueryDto(Sortable = true)]` | Class | Adds `SortBy`, `SortDirection`, `ApplySort()`, `Apply()` to query DTO | +| `PaginatedResponse` | — | Generic paginated wrapper with `Items`, `TotalCount`, `Page`, `PageSize` | | `[PartialFrom(typeof(T))]` | Record (partial) | Generates `PatchField` properties + `ApplyTo()` for all properties of `T` | | `[IntersectFrom(typeof(T))]` | Record (partial) | Combine multiple types into one (like TS `&`). Apply multiple times. | | `[DtoIgnore]` | Property | Excludes from generated DTOs | @@ -363,6 +365,83 @@ public IActionResult List([FromQuery] ProductQuery query) } ``` +### Sortable queries + +Add `Sortable = true` to get `SortBy`, `SortDirection` properties and `ApplySort(IQueryable)`: + +```csharp +[QueryDto(Sortable = true, DefaultSort = "Name")] +public class Product +{ + public string Name { get; set; } + public decimal Price { get; set; } + public int Stock { get; set; } +} +``` + +```csharp +// Generated +public record ProductQuery +{ + public string? Name { get; init; } + public decimal? Price { get; init; } + public int? Stock { get; init; } + public string? SortBy { get; init; } + public SortDirection? SortDirection { get; init; } + + public IQueryable ApplyFilter(IQueryable query) { ... } + public IQueryable ApplySort(IQueryable query) { ... } + public IQueryable Apply(IQueryable query) { ... } // filter + sort +} + +// Usage +[HttpGet] +public IActionResult List([FromQuery] ProductQuery query) +{ + var results = query.Apply(_db.Products).ToList(); + return Ok(results); +} +``` + +`SortBy` is case-insensitive and matches property names. Unknown values are ignored (no sort applied). `DefaultSort` and `DefaultSortDirection` set fallback behavior when `SortBy`/`SortDirection` are not provided. + +## Paginated response (`PaginatedResponse`) + +Generic wrapper for paginated results: + +```csharp +// Simple creation +var page = PaginatedResponse.Create(items, totalCount: 100, page: 2, pageSize: 10); + +// From IQueryable (handles Skip/Take automatically) +var page = await PaginatedResponse.CreateAsync(_db.Products, page: 1, pageSize: 20); + +// Map items (e.g. entity → response DTO) +var response = page.Map(p => ProductResponse.FromEntity(p)); + +// Properties +page.Items // IReadOnlyList +page.TotalCount // int +page.Page // int +page.PageSize // int +page.TotalPages // computed +page.HasNextPage // computed +page.HasPreviousPage // computed +``` + +Full example with `[QueryDto]` + `[ResponseDto]`: + +```csharp +[HttpGet] +public async Task List([FromQuery] ProductQuery query, int page = 1, int pageSize = 20) +{ + var filtered = query.Apply(_db.Products); + var paginated = await PaginatedResponse.CreateAsync(filtered, page, pageSize); + var response = paginated.Map(p => ProductResponse.FromEntity(p)); + return Ok(response); +} +``` + ## `ApplyWithChanges()` (Update DTOs only) Like `ApplyTo()` but returns a tuple with the list of actually changed field names. Available on Update, Combined, and UpdateDtoFor requests: diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs index de067ad..a424dc8 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.AttributeSources.cs @@ -225,6 +225,12 @@ namespace ZibStack.NET.Dto internal sealed class QueryDtoAttribute : System.Attribute { public string? Name { get; set; } + /// When true, adds SortBy and SortDirection properties + ApplySort(IQueryable) method. + public bool Sortable { get; set; } + /// Default sort property name (used when SortBy is null). + public string? DefaultSort { get; set; } + /// Default sort direction when SortDirection is null. Defaults to Asc. + public ZibStack.NET.Dto.SortDirection DefaultSortDirection { get; set; } } } "; @@ -272,6 +278,84 @@ namespace ZibStack.NET.Dto [System.AttributeUsage(System.AttributeTargets.Property, Inherited = false)] internal sealed class FlattenAttribute : System.Attribute { } } +"; + + private const string PaginatedResponseSource = @"// +#nullable enable + +namespace ZibStack.NET.Dto +{ + /// + /// Generic paginated response wrapper. Use with any ResponseDto or entity type. + /// + public record PaginatedResponse + { + public System.Collections.Generic.IReadOnlyList Items { get; init; } = System.Array.Empty(); + public int TotalCount { get; init; } + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalPages => PageSize > 0 ? (int)System.Math.Ceiling((double)TotalCount / PageSize) : 0; + public bool HasNextPage => Page < TotalPages; + public bool HasPreviousPage => Page > 1; + + public static PaginatedResponse Create( + System.Collections.Generic.IReadOnlyList items, + int totalCount, + int page, + int pageSize) + { + return new PaginatedResponse + { + Items = items, + TotalCount = totalCount, + Page = page, + PageSize = pageSize, + }; + } + + public static async System.Threading.Tasks.Task> CreateAsync( + System.Linq.IQueryable query, + int page, + int pageSize, + System.Threading.CancellationToken cancellationToken = default) + { + var totalCount = query.Count(); + var items = query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToList(); + return Create(items, totalCount, page, pageSize); + } + + /// Maps items to a different type while preserving pagination metadata. + public PaginatedResponse Map(System.Func selector) + { + var mapped = new System.Collections.Generic.List(Items.Count); + foreach (var item in Items) + mapped.Add(selector(item)); + + return new PaginatedResponse + { + Items = mapped, + TotalCount = TotalCount, + Page = Page, + PageSize = PageSize, + }; + } + } +} +"; + + private const string SortDirectionSource = @"// +namespace ZibStack.NET.Dto +{ + /// Sort direction for sortable query DTOs. + public enum SortDirection + { + Asc, + Desc + } +} "; private const string DtoValidatorInterfaceSource = @"// diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs index 3634eba..25afa11 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs @@ -528,6 +528,10 @@ a.ConstructorArguments[0].Value is string from && .FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == QueryDtoAttributeFqn); var nameArg = attr.NamedArguments.FirstOrDefault(a => a.Key == "Name").Value.Value as string; + var sortable = attr.NamedArguments.FirstOrDefault(a => a.Key == "Sortable").Value.Value is true; + var defaultSort = attr.NamedArguments.FirstOrDefault(a => a.Key == "DefaultSort").Value.Value as string; + var defaultSortDirectionRaw = attr.NamedArguments.FirstOrDefault(a => a.Key == "DefaultSortDirection").Value.Value; + var defaultSortDirection = defaultSortDirectionRaw is int d ? d : 0; var properties = new List(); foreach (var prop in GetAllProperties(symbol)) @@ -559,7 +563,10 @@ a.ConstructorArguments[0].Value is string from && symbol.Name, ns, SanitizeHintName(symbol.ToDisplayString().Replace(".", "_")), nameArg ?? $"{symbol.Name}Query", - properties); + properties, + sortable, + defaultSort, + defaultSortDirection); } catch { return null; } } private static List CollectPropertiesFromType(INamedTypeSymbol type) diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs index 53906a4..8de8e1a 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Generation.cs @@ -529,6 +529,8 @@ private static string GenerateQueryDtoSource(QueryDtoInfo info) sb.AppendLine(); sb.AppendLine("using System;"); sb.AppendLine("using System.Linq;"); + if (info.Sortable) + sb.AppendLine("using ZibStack.NET.Dto;"); sb.AppendLine(); if (info.Namespace is not null) @@ -546,6 +548,16 @@ private static string GenerateQueryDtoSource(QueryDtoInfo info) sb.AppendLine($" public {prop.NullableTypeName} {prop.PropertyName} {{ get; init; }}"); } + // Sorting properties + if (info.Sortable) + { + sb.AppendLine(); + sb.AppendLine(" /// Property name to sort by. Case-insensitive."); + sb.AppendLine(" public string? SortBy { get; init; }"); + sb.AppendLine(" /// Sort direction. Defaults to Asc."); + sb.AppendLine(" public SortDirection? SortDirection { get; init; }"); + } + sb.AppendLine(); // ApplyFilter @@ -562,6 +574,42 @@ private static string GenerateQueryDtoSource(QueryDtoInfo info) sb.AppendLine(" return query;"); sb.AppendLine(" }"); + // ApplySort + if (info.Sortable) + { + sb.AppendLine(); + sb.AppendLine($" public IQueryable<{info.ClassName}> ApplySort(IQueryable<{info.ClassName}> query)"); + sb.AppendLine(" {"); + + var defaultSortStr = info.DefaultSort is not null ? $"\"{info.DefaultSort}\"" : "null"; + var defaultDirStr = info.DefaultSortDirection == 1 ? "SortDirection.Desc" : "SortDirection.Asc"; + sb.AppendLine($" var sortBy = SortBy ?? {defaultSortStr};"); + sb.AppendLine($" var direction = SortDirection ?? {defaultDirStr};"); + sb.AppendLine(); + sb.AppendLine(" if (sortBy is null) return query;"); + sb.AppendLine(); + sb.AppendLine(" return (sortBy.ToLowerInvariant(), direction) switch"); + sb.AppendLine(" {"); + foreach (var prop in info.Properties) + { + var lower = prop.PropertyName.ToLowerInvariant(); + sb.AppendLine($" (\"{lower}\", ZibStack.NET.Dto.SortDirection.Asc) => query.OrderBy(x => x.{prop.PropertyName}),"); + sb.AppendLine($" (\"{lower}\", ZibStack.NET.Dto.SortDirection.Desc) => query.OrderByDescending(x => x.{prop.PropertyName}),"); + } + sb.AppendLine(" _ => query,"); + sb.AppendLine(" };"); + sb.AppendLine(" }"); + + // Convenience: ApplyFilterAndSort + sb.AppendLine(); + sb.AppendLine($" public IQueryable<{info.ClassName}> Apply(IQueryable<{info.ClassName}> query)"); + sb.AppendLine(" {"); + sb.AppendLine(" query = ApplyFilter(query);"); + sb.AppendLine(" query = ApplySort(query);"); + sb.AppendLine(" return query;"); + sb.AppendLine(" }"); + } + sb.AppendLine("}"); return sb.ToString(); } diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs index 76af374..c9f76ea 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.cs @@ -49,6 +49,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ctx.AddSource("OmitFromAttribute.g.cs", OmitFromAttributeSource); ctx.AddSource("QueryDtoAttribute.g.cs", QueryDtoAttributeSource); ctx.AddSource("ResponseIgnoreAttribute.g.cs", ResponseIgnoreAttributeSource); + ctx.AddSource("PaginatedResponse.g.cs", PaginatedResponseSource); + ctx.AddSource("SortDirection.g.cs", SortDirectionSource); ctx.AddSource("IDtoValidator.g.cs", DtoValidatorInterfaceSource); ctx.AddSource("CreateDtoForAttribute.g.cs", CreateDtoForAttributeSource); ctx.AddSource("UpdateDtoForAttribute.g.cs", UpdateDtoForAttributeSource); diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/QueryModels.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/QueryModels.cs index 44603ab..cbe84dd 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/QueryModels.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/Models/QueryModels.cs @@ -27,13 +27,19 @@ internal sealed class QueryDtoInfo public string FullyQualifiedName { get; } public string QueryName { get; } public List Properties { get; } + public bool Sortable { get; } + public string? DefaultSort { get; } + public int DefaultSortDirection { get; } // 0 = Asc, 1 = Desc - public QueryDtoInfo(string className, string? ns, string fullyQualifiedName, string queryName, List properties) + public QueryDtoInfo(string className, string? ns, string fullyQualifiedName, string queryName, List properties, bool sortable = false, string? defaultSort = null, int defaultSortDirection = 0) { ClassName = className; Namespace = ns; FullyQualifiedName = fullyQualifiedName; QueryName = queryName; Properties = properties; + Sortable = sortable; + DefaultSort = defaultSort; + DefaultSortDirection = defaultSortDirection; } } diff --git a/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/PaginatedResponseTests.cs b/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/PaginatedResponseTests.cs new file mode 100644 index 0000000..39df7ae --- /dev/null +++ b/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/PaginatedResponseTests.cs @@ -0,0 +1,113 @@ +using ZibStack.NET.Dto; + +namespace ZibStack.NET.Dto.Tests; + +public class PaginatedResponseTests +{ + [Fact] + public void Create_SetsAllProperties() + { + var items = new[] { "a", "b", "c" }; + var page = PaginatedResponse.Create(items, totalCount: 10, page: 2, pageSize: 3); + + Assert.Equal(items, page.Items); + Assert.Equal(10, page.TotalCount); + Assert.Equal(2, page.Page); + Assert.Equal(3, page.PageSize); + } + + [Fact] + public void TotalPages_CalculatesCorrectly() + { + var page = PaginatedResponse.Create(new[] { 1 }, totalCount: 10, page: 1, pageSize: 3); + + Assert.Equal(4, page.TotalPages); // ceil(10/3) = 4 + } + + [Fact] + public void TotalPages_ExactDivision() + { + var page = PaginatedResponse.Create(new[] { 1 }, totalCount: 9, page: 1, pageSize: 3); + + Assert.Equal(3, page.TotalPages); + } + + [Fact] + public void TotalPages_ZeroPageSize_ReturnsZero() + { + var page = PaginatedResponse.Create(Array.Empty(), totalCount: 0, page: 1, pageSize: 0); + + Assert.Equal(0, page.TotalPages); + } + + [Fact] + public void HasNextPage_True_WhenNotLastPage() + { + var page = PaginatedResponse.Create(new[] { 1, 2, 3 }, totalCount: 10, page: 2, pageSize: 3); + + Assert.True(page.HasNextPage); // page 2 of 4 + } + + [Fact] + public void HasNextPage_False_WhenLastPage() + { + var page = PaginatedResponse.Create(new[] { 10 }, totalCount: 10, page: 4, pageSize: 3); + + Assert.False(page.HasNextPage); // page 4 of 4 + } + + [Fact] + public void HasPreviousPage_True_WhenNotFirstPage() + { + var page = PaginatedResponse.Create(new[] { 1 }, totalCount: 10, page: 2, pageSize: 3); + + Assert.True(page.HasPreviousPage); + } + + [Fact] + public void HasPreviousPage_False_WhenFirstPage() + { + var page = PaginatedResponse.Create(new[] { 1 }, totalCount: 10, page: 1, pageSize: 3); + + Assert.False(page.HasPreviousPage); + } + + [Fact] + public void Map_TransformsItems_PreservesPagination() + { + var page = PaginatedResponse.Create(new[] { 1, 2, 3 }, totalCount: 10, page: 2, pageSize: 3); + + var mapped = page.Map(x => x.ToString()); + + Assert.Equal(new[] { "1", "2", "3" }, mapped.Items); + Assert.Equal(10, mapped.TotalCount); + Assert.Equal(2, mapped.Page); + Assert.Equal(3, mapped.PageSize); + } + + [Fact] + public void CreateAsync_PaginatesQueryable() + { + var source = Enumerable.Range(1, 20).AsQueryable(); + + var page = PaginatedResponse.CreateAsync(source, page: 2, pageSize: 5).Result; + + Assert.Equal(new[] { 6, 7, 8, 9, 10 }, page.Items); + Assert.Equal(20, page.TotalCount); + Assert.Equal(2, page.Page); + Assert.Equal(5, page.PageSize); + Assert.Equal(4, page.TotalPages); + } + + [Fact] + public void Empty_Page() + { + var page = PaginatedResponse.Create( + Array.Empty(), totalCount: 0, page: 1, pageSize: 10); + + Assert.Empty(page.Items); + Assert.Equal(0, page.TotalPages); + Assert.False(page.HasNextPage); + Assert.False(page.HasPreviousPage); + } +} diff --git a/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/SortableQueryTests.cs b/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/SortableQueryTests.cs new file mode 100644 index 0000000..6badc0c --- /dev/null +++ b/packages/ZibStack.NET.Dto/tests/ZibStack.NET.Dto.Tests/SortableQueryTests.cs @@ -0,0 +1,128 @@ +using ZibStack.NET.Dto; + +namespace ZibStack.NET.Dto.Tests; + +[QueryDto(Sortable = true, DefaultSort = "Name")] +public class Product +{ + [DtoIgnore] + public int Id { get; set; } + + public string Name { get; set; } = ""; + public decimal Price { get; set; } + public int Stock { get; set; } +} + +public class SortableQueryTests +{ + private static readonly List Products = new() + { + new() { Id = 1, Name = "Cherry", Price = 3m, Stock = 10 }, + new() { Id = 2, Name = "Apple", Price = 1m, Stock = 30 }, + new() { Id = 3, Name = "Banana", Price = 2m, Stock = 20 }, + }; + + [Fact] + public void SortableQuery_HasSortByProperty() + { + var prop = typeof(ProductQuery).GetProperty("SortBy"); + Assert.NotNull(prop); + Assert.Equal(typeof(string), prop.PropertyType); + } + + [Fact] + public void SortableQuery_HasSortDirectionProperty() + { + var prop = typeof(ProductQuery).GetProperty("SortDirection"); + Assert.NotNull(prop); + } + + [Fact] + public void SortableQuery_HasApplySortMethod() + { + Assert.NotNull(typeof(ProductQuery).GetMethod("ApplySort")); + } + + [Fact] + public void SortableQuery_HasApplyMethod() + { + Assert.NotNull(typeof(ProductQuery).GetMethod("Apply")); + } + + [Fact] + public void ApplySort_ByName_Asc() + { + var query = new ProductQuery { SortBy = "Name", SortDirection = SortDirection.Asc }; + var result = query.ApplySort(Products.AsQueryable()).ToList(); + + Assert.Equal("Apple", result[0].Name); + Assert.Equal("Banana", result[1].Name); + Assert.Equal("Cherry", result[2].Name); + } + + [Fact] + public void ApplySort_ByPrice_Desc() + { + var query = new ProductQuery { SortBy = "Price", SortDirection = SortDirection.Desc }; + var result = query.ApplySort(Products.AsQueryable()).ToList(); + + Assert.Equal(3m, result[0].Price); + Assert.Equal(2m, result[1].Price); + Assert.Equal(1m, result[2].Price); + } + + [Fact] + public void ApplySort_CaseInsensitive() + { + var query = new ProductQuery { SortBy = "pRiCe", SortDirection = SortDirection.Asc }; + var result = query.ApplySort(Products.AsQueryable()).ToList(); + + Assert.Equal(1m, result[0].Price); + } + + [Fact] + public void ApplySort_DefaultSort_UsedWhenNoSortBy() + { + var query = new ProductQuery(); // No SortBy → defaults to "Name" + var result = query.ApplySort(Products.AsQueryable()).ToList(); + + Assert.Equal("Apple", result[0].Name); + Assert.Equal("Banana", result[1].Name); + Assert.Equal("Cherry", result[2].Name); + } + + [Fact] + public void ApplySort_UnknownField_ReturnsUnchanged() + { + var query = new ProductQuery { SortBy = "NonExistent" }; + var result = query.ApplySort(Products.AsQueryable()).ToList(); + + Assert.Equal(3, result.Count); // no crash, returns original order + } + + [Fact] + public void Apply_FiltersAndSorts() + { + var query = new ProductQuery + { + Stock = 20, + SortBy = "Name", + SortDirection = SortDirection.Asc + }; + + var result = query.Apply(Products.AsQueryable()).ToList(); + + Assert.Single(result); + Assert.Equal("Banana", result[0].Name); + } + + [Fact] + public void ApplyFilter_StillWorks() + { + var query = new ProductQuery { Name = "Apple" }; + var result = query.ApplyFilter(Products.AsQueryable()).ToList(); + + Assert.Single(result); + Assert.Equal("Apple", result[0].Name); + } +} From 8349b686c7cf9a7bceee5b4924be1cea6a9b88d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Mon, 6 Apr 2026 10:15:11 +0000 Subject: [PATCH 3/5] Add ZLOG001 analyzer: prefer LogXxxEx over LogXxx with interpolated strings Roslyn analyzer + code fix that detects logger.LogInformation($"...") calls and suggests using LogInformationEx() instead for structured logging. Includes analyzer unit tests and CI integration. --- .github/workflows/ci.yml | 3 + ZibStack.NET.slnx | 2 + .../UseLogExAnalyzer.cs | 100 +++++++++++ .../UseLogExCodeFixProvider.cs | 57 +++++++ .../ZibStack.NET.Log.Analyzers.csproj | 32 ++++ .../UseLogExAnalyzerTests.cs | 157 ++++++++++++++++++ .../ZibStack.NET.Log.Analyzers.Tests.csproj | 23 +++ 7 files changed, 374 insertions(+) create mode 100644 packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExAnalyzer.cs create mode 100644 packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExCodeFixProvider.cs create mode 100644 packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/ZibStack.NET.Log.Analyzers.csproj create mode 100644 packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/UseLogExAnalyzerTests.cs create mode 100644 packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23bdffa..df82304 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: - name: Test Result run: dotnet test packages/ZibStack.NET.Result/tests/ZibStack.NET.Result.Tests/ZibStack.NET.Result.Tests.csproj -c Release --no-build + - name: Test Log Analyzers + run: dotnet test packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj -c Release --no-build + - name: Benchmark Log run: dotnet run --project packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Benchmarks/ZibStack.NET.Log.Benchmarks.csproj -c Release -- --filter "*" --exporters json diff --git a/ZibStack.NET.slnx b/ZibStack.NET.slnx index 2ca7e23..a92b089 100644 --- a/ZibStack.NET.slnx +++ b/ZibStack.NET.slnx @@ -28,10 +28,12 @@ + + diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExAnalyzer.cs b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExAnalyzer.cs new file mode 100644 index 0000000..8c7053c --- /dev/null +++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExAnalyzer.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace ZibStack.NET.Log.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UseLogExAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "ZLOG001"; + + private static readonly LocalizableString Title = "Use LogXxxEx for interpolated strings"; + private static readonly LocalizableString MessageFormat = "Use '{0}' instead of '{1}' with interpolated strings for structured logging"; + private static readonly LocalizableString Description = "Standard ILogger.LogXxx methods with interpolated strings lose structured logging data. Use the ZibStack.NET.Log LogXxxEx extension methods instead, which preserve property names for structured logging sinks like Serilog/Seq."; + + private static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + Title, + MessageFormat, + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Description); + + private static readonly Dictionary MethodMapping = new() + { + { "LogTrace", "LogTraceEx" }, + { "LogDebug", "LogDebugEx" }, + { "LogInformation", "LogInformationEx" }, + { "LogWarning", "LogWarningEx" }, + { "LogError", "LogErrorEx" }, + { "LogCritical", "LogCriticalEx" }, + }; + + public override ImmutableArray SupportedDiagnostics + => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + + // Must be a member access: logger.LogXxx(...) + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + return; + + var methodName = memberAccess.Name.Identifier.Text; + + // Quick check: is it one of the standard log methods? + if (!MethodMapping.TryGetValue(methodName, out var suggestedMethod)) + return; + + // Check if any argument is an interpolated string + var hasInterpolatedArg = invocation.ArgumentList.Arguments + .Any(arg => arg.Expression is InterpolatedStringExpressionSyntax); + + if (!hasInterpolatedArg) + return; + + // Verify the method is on ILogger (from Microsoft.Extensions.Logging) + var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken); + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + return; + + var containingType = methodSymbol.ContainingType?.ToDisplayString(); + + // Match both the extension class and direct ILogger calls + if (containingType != "Microsoft.Extensions.Logging.LoggerExtensions" + && !IsILoggerType(methodSymbol.ContainingType)) + return; + + var diagnostic = Diagnostic.Create( + Rule, + memberAccess.Name.GetLocation(), + suggestedMethod, + methodName); + + context.ReportDiagnostic(diagnostic); + } + + private static bool IsILoggerType(INamedTypeSymbol? type) + { + if (type is null) return false; + + // Check if the type implements ILogger + return type.AllInterfaces.Any(i => + i.ToDisplayString() == "Microsoft.Extensions.Logging.ILogger") + || type.ToDisplayString() == "Microsoft.Extensions.Logging.ILogger"; + } +} diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExCodeFixProvider.cs b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExCodeFixProvider.cs new file mode 100644 index 0000000..c1a53a3 --- /dev/null +++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/UseLogExCodeFixProvider.cs @@ -0,0 +1,57 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ZibStack.NET.Log.Analyzers; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseLogExCodeFixProvider)), Shared] +public sealed class UseLogExCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds + => ImmutableArray.Create(UseLogExAnalyzer.DiagnosticId); + + public override FixAllProvider GetFixAllProvider() + => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) return; + + var diagnostic = context.Diagnostics.First(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + var node = root.FindNode(diagnosticSpan); + if (node is not SimpleNameSyntax identifierName) return; + + var currentName = identifierName.Identifier.Text; + var newName = currentName + "Ex"; + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Use '{newName}' instead", + createChangedDocument: ct => ReplaceMethodNameAsync(context.Document, identifierName, newName, ct), + equivalenceKey: UseLogExAnalyzer.DiagnosticId), + diagnostic); + } + + private static async Task ReplaceMethodNameAsync( + Document document, SimpleNameSyntax identifierName, string newName, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) return document; + + var newIdentifier = SyntaxFactory.IdentifierName(newName) + .WithTriviaFrom(identifierName); + + var newRoot = root.ReplaceNode(identifierName, newIdentifier); + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/ZibStack.NET.Log.Analyzers.csproj b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/ZibStack.NET.Log.Analyzers.csproj new file mode 100644 index 0000000..ac90ff7 --- /dev/null +++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Analyzers/ZibStack.NET.Log.Analyzers.csproj @@ -0,0 +1,32 @@ + + + + netstandard2.0 + latest + enable + true + RS2008 + true + + ZibStack.NET.Log.Analyzers + Roslyn analyzer that detects logger.LogXxx($"...") calls and suggests using LogXxxEx() for structured logging. + $(Authors) + MIT + https://github.com/MistyKuu/ZibStack.NET + https://github.com/MistyKuu/ZibStack.NET + analyzer;logging;roslyn;csharp + + false + true + + + + + + + + + + + + diff --git a/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/UseLogExAnalyzerTests.cs b/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/UseLogExAnalyzerTests.cs new file mode 100644 index 0000000..7d8719f --- /dev/null +++ b/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/UseLogExAnalyzerTests.cs @@ -0,0 +1,157 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.CSharp.Testing; +using ZibStack.NET.Log.Analyzers; + +namespace ZibStack.NET.Log.Analyzers.Tests; + +using Verify = CSharpAnalyzerVerifier; + +public class UseLogExAnalyzerTests +{ + private const string LoggerStubs = @" +namespace Microsoft.Extensions.Logging +{ + public enum LogLevel { Trace, Debug, Information, Warning, Error, Critical } + + public interface ILogger + { + bool IsEnabled(LogLevel logLevel); + } + + public static class LoggerExtensions + { + public static void LogTrace(this ILogger logger, string message, params object[] args) { } + public static void LogDebug(this ILogger logger, string message, params object[] args) { } + public static void LogInformation(this ILogger logger, string message, params object[] args) { } + public static void LogWarning(this ILogger logger, string message, params object[] args) { } + public static void LogError(this ILogger logger, string message, params object[] args) { } + public static void LogCritical(this ILogger logger, string message, params object[] args) { } + } +} +"; + + [Fact] + public async Task LogInformation_WithInterpolatedString_Reports() + { + var test = @" +using Microsoft.Extensions.Logging; + +class MyService +{ + void Do(ILogger logger, string name) + { + logger.{|#0:LogInformation|}($""Hello {name}""); + } +} +" + LoggerStubs; + + var expected = Verify.Diagnostic(UseLogExAnalyzer.DiagnosticId) + .WithLocation(0) + .WithArguments("LogInformationEx", "LogInformation"); + + await Verify.VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task LogWarning_WithInterpolatedString_Reports() + { + var test = @" +using Microsoft.Extensions.Logging; + +class MyService +{ + void Do(ILogger logger, int count) + { + logger.{|#0:LogWarning|}($""Count is {count}""); + } +} +" + LoggerStubs; + + var expected = Verify.Diagnostic(UseLogExAnalyzer.DiagnosticId) + .WithLocation(0) + .WithArguments("LogWarningEx", "LogWarning"); + + await Verify.VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task LogError_WithInterpolatedString_Reports() + { + var test = @" +using Microsoft.Extensions.Logging; + +class MyService +{ + void Do(ILogger logger, string err) + { + logger.{|#0:LogError|}($""Error: {err}""); + } +} +" + LoggerStubs; + + var expected = Verify.Diagnostic(UseLogExAnalyzer.DiagnosticId) + .WithLocation(0) + .WithArguments("LogErrorEx", "LogError"); + + await Verify.VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task LogInformation_WithPlainString_NoDiagnostic() + { + var test = @" +using Microsoft.Extensions.Logging; + +class MyService +{ + void Do(ILogger logger) + { + logger.LogInformation(""Hello world""); + } +} +" + LoggerStubs; + + await Verify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task LogInformation_WithTemplateString_NoDiagnostic() + { + var test = @" +using Microsoft.Extensions.Logging; + +class MyService +{ + void Do(ILogger logger, string name) + { + logger.LogInformation(""Hello {Name}"", name); + } +} +" + LoggerStubs; + + await Verify.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task NonLoggerMethod_WithInterpolatedString_NoDiagnostic() + { + var test = @" +class SomeOther +{ + public void LogInformation(string msg) { } +} + +class MyService +{ + void Do() + { + var other = new SomeOther(); + other.LogInformation($""Hello {42}""); + } +} +"; + + await Verify.VerifyAnalyzerAsync(test); + } +} diff --git a/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj b/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj new file mode 100644 index 0000000..df70dd4 --- /dev/null +++ b/packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + From 200fd45f307c7aac04ba050269874021452aeac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Mon, 6 Apr 2026 10:18:43 +0000 Subject: [PATCH 4/5] Add ZibStack.NET.Validation source generator Compile-time validation with zero reflection. Supports attributes: [Required], [MinLength], [MaxLength], [Range], [Email], [Url], [Match], [NotEmpty]. Generates Validate() returning ValidationResult. Types implement IValidatable interface. Works with classes and records. --- .github/workflows/ci.yml | 3 + ZibStack.NET.slnx | 7 + packages/ZibStack.NET.Validation/README.md | 141 +++++ .../ValidationGenerator.cs | 483 ++++++++++++++++++ .../ZibStack.NET.Validation.csproj | 34 ++ .../ZibStack.NET.Validation.Tests/Models.cs | 50 ++ .../ValidationTests.cs | 248 +++++++++ .../ZibStack.NET.Validation.Tests.csproj | 23 + 8 files changed, 989 insertions(+) create mode 100644 packages/ZibStack.NET.Validation/README.md create mode 100644 packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationGenerator.cs create mode 100644 packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ZibStack.NET.Validation.csproj create mode 100644 packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/Models.cs create mode 100644 packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ValidationTests.cs create mode 100644 packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ZibStack.NET.Validation.Tests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df82304..524c0a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: - name: Test Log Analyzers run: dotnet test packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Analyzers.Tests/ZibStack.NET.Log.Analyzers.Tests.csproj -c Release --no-build + - name: Test Validation + run: dotnet test packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ZibStack.NET.Validation.Tests.csproj -c Release --no-build + - name: Benchmark Log run: dotnet run --project packages/ZibStack.NET.Log/tests/ZibStack.NET.Log.Benchmarks/ZibStack.NET.Log.Benchmarks.csproj -c Release -- --filter "*" --exporters json diff --git a/ZibStack.NET.slnx b/ZibStack.NET.slnx index a92b089..be41c5a 100644 --- a/ZibStack.NET.slnx +++ b/ZibStack.NET.slnx @@ -35,6 +35,13 @@ + + + + + + + diff --git a/packages/ZibStack.NET.Validation/README.md b/packages/ZibStack.NET.Validation/README.md new file mode 100644 index 0000000..2db52b1 --- /dev/null +++ b/packages/ZibStack.NET.Validation/README.md @@ -0,0 +1,141 @@ +# ZibStack.NET.Validation + +Source-generated validation for .NET. Add validation attributes, get a compile-time `Validate()` method — no reflection, no runtime overhead. + +## Install + +```bash +dotnet add package ZibStack.NET.Validation +``` + +## Quick Start + +```csharp +using ZibStack.NET.Validation; + +[Validate] +public partial class CreateUserRequest +{ + [Required] + [MinLength(2)] + [MaxLength(50)] + public string Name { get; set; } = ""; + + [Required] + [Email] + public string Email { get; set; } = ""; + + [Range(18, 120)] + public int Age { get; set; } + + [Url] + public string? Website { get; set; } +} +``` + +The generator creates a `Validate()` method at compile-time: + +```csharp +var request = new CreateUserRequest { Name = "", Email = "bad", Age = 10 }; +var result = request.Validate(); + +if (!result.IsValid) +{ + foreach (var error in result.Errors) + Console.WriteLine(error); + // "Name is required." + // "Email must be a valid email address." + // "Age must be between 18 and 120." +} +``` + +## Validation Attributes + +| Attribute | Target | Description | +|-----------|--------|-------------| +| `[Validate]` | Class/Record | Marks the type for validation generation (must be `partial`) | +| `[Required]` | Property | Must not be null (or empty/whitespace for strings) | +| `[MinLength(n)]` | Property | String/collection must have at least `n` characters/items | +| `[MaxLength(n)]` | Property | String/collection must have at most `n` characters/items | +| `[Range(min, max)]` | Property | Numeric value must be within range (inclusive) | +| `[Email]` | Property | Must be a valid email address | +| `[Url]` | Property | Must be a valid absolute URL | +| `[Match(pattern)]` | Property | Must match the regex pattern | +| `[NotEmpty]` | Property | Collection must have items; string must not be whitespace | + +All attributes support a `Message` property for custom error messages: + +```csharp +[Required(Message = "Please provide your name")] +[Match(@"^\d{3}-\d{4}$", Message = "Phone must be in format XXX-XXXX")] +``` + +## IValidatable Interface + +All `[Validate]` types implement `IValidatable`: + +```csharp +public interface IValidatable +{ + ValidationResult Validate(); +} + +// Use in generic code +void Process(T request) where T : IValidatable +{ + var result = request.Validate(); + if (!result.IsValid) + throw new ArgumentException(string.Join(", ", result.Errors)); +} +``` + +## ValidationResult + +```csharp +public sealed class ValidationResult +{ + public IReadOnlyList Errors { get; } + public bool IsValid { get; } + + public static readonly ValidationResult Success; // singleton +} +``` + +## Records + +Works with records too: + +```csharp +[Validate] +public partial record ProductRequest +{ + [Required] + public string Sku { get; init; } = ""; + + [Range(0, 999999)] + public decimal Price { get; init; } +} +``` + +## ASP.NET Core Integration + +```csharp +[HttpPost] +public IActionResult Create(CreateUserRequest request) +{ + var validation = request.Validate(); + if (!validation.IsValid) + return BadRequest(new { Errors = validation.Errors }); + + // proceed... +} +``` + +## Requirements + +- .NET 8.0+ +- Types must be `partial` + +## License + +MIT diff --git a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationGenerator.cs b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationGenerator.cs new file mode 100644 index 0000000..2487f15 --- /dev/null +++ b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ValidationGenerator.cs @@ -0,0 +1,483 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ZibStack.NET.Validation; + +[Generator] +public sealed class ValidationGenerator : IIncrementalGenerator +{ + private const string ValidateAttributeFqn = "ZibStack.NET.Validation.ValidateAttribute"; + + private const string ValidateAttributeSource = @"// +namespace ZibStack.NET.Validation +{ + /// Generates a compile-time Validate() method based on validation attributes. + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct, Inherited = false)] + internal sealed class ValidateAttribute : System.Attribute { } +} +"; + + private const string ValidationResultSource = @"// +#nullable enable +namespace ZibStack.NET.Validation +{ + /// Result of a validation operation. + public sealed class ValidationResult + { + public static readonly ValidationResult Success = new(System.Array.Empty()); + + public System.Collections.Generic.IReadOnlyList Errors { get; } + public bool IsValid => Errors.Count == 0; + + public ValidationResult(System.Collections.Generic.IReadOnlyList errors) + { + Errors = errors; + } + } + + /// Interface implemented by all [Validate]-decorated types. + public interface IValidatable + { + ValidationResult Validate(); + } +} +"; + + // Attribute sources for custom rules + private const string RequiredAttributeSource = @"// +namespace ZibStack.NET.Validation +{ + /// Property must not be null (or empty for strings). + [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)] + internal sealed class RequiredAttribute : System.Attribute + { + public string? Message { get; set; } + } +} +"; + + private const string MinLengthAttributeSource = @"// +namespace ZibStack.NET.Validation +{ + /// String/collection must have at least this many characters/items. + [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)] + internal sealed class MinLengthAttribute : System.Attribute + { + public int Length { get; } + public string? Message { get; set; } + public MinLengthAttribute(int length) => Length = length; + } +} +"; + + private const string MaxLengthAttributeSource = @"// +namespace ZibStack.NET.Validation +{ + /// String/collection must have at most this many characters/items. + [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)] + internal sealed class MaxLengthAttribute : System.Attribute + { + public int Length { get; } + public string? Message { get; set; } + public MaxLengthAttribute(int length) => Length = length; + } +} +"; + + private const string RangeAttributeSource = @"// +namespace ZibStack.NET.Validation +{ + /// Numeric value must be within the specified range (inclusive). + [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)] + internal sealed class RangeAttribute : System.Attribute + { + public double Min { get; } + public double Max { get; } + public string? Message { get; set; } + public RangeAttribute(double min, double max) { Min = min; Max = max; } + } +} +"; + + private const string EmailAttributeSource = @"// +namespace ZibStack.NET.Validation +{ + /// String must be a valid email address. + [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)] + internal sealed class EmailAttribute : System.Attribute + { + public string? Message { get; set; } + } +} +"; + + private const string UrlAttributeSource = @"// +namespace ZibStack.NET.Validation +{ + /// String must be a valid URL. + [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)] + internal sealed class UrlAttribute : System.Attribute + { + public string? Message { get; set; } + } +} +"; + + private const string RegexAttributeSource = @"// +namespace ZibStack.NET.Validation +{ + /// String must match the specified regex pattern. + [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)] + internal sealed class MatchAttribute : System.Attribute + { + public string Pattern { get; } + public string? Message { get; set; } + public MatchAttribute(string pattern) => Pattern = pattern; + } +} +"; + + private const string NotEmptyAttributeSource = @"// +namespace ZibStack.NET.Validation +{ + /// Collection must contain at least one element. String must not be empty or whitespace. + [System.AttributeUsage(System.AttributeTargets.Property, Inherited = true)] + internal sealed class NotEmptyAttribute : System.Attribute + { + public string? Message { get; set; } + } +} +"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static ctx => + { + ctx.AddSource("ValidateAttribute.g.cs", ValidateAttributeSource); + ctx.AddSource("ValidationResult.g.cs", ValidationResultSource); + ctx.AddSource("RequiredAttribute.g.cs", RequiredAttributeSource); + ctx.AddSource("MinLengthAttribute.g.cs", MinLengthAttributeSource); + ctx.AddSource("MaxLengthAttribute.g.cs", MaxLengthAttributeSource); + ctx.AddSource("RangeAttribute.g.cs", RangeAttributeSource); + ctx.AddSource("EmailAttribute.g.cs", EmailAttributeSource); + ctx.AddSource("UrlAttribute.g.cs", UrlAttributeSource); + ctx.AddSource("MatchAttribute.g.cs", RegexAttributeSource); + ctx.AddSource("NotEmptyAttribute.g.cs", NotEmptyAttributeSource); + }); + + var targets = context.SyntaxProvider + .ForAttributeWithMetadataName( + ValidateAttributeFqn, + predicate: static (node, _) => node is ClassDeclarationSyntax or RecordDeclarationSyntax or StructDeclarationSyntax, + transform: static (ctx, _) => ExtractValidationInfo(ctx)) + .Where(static info => info is not null) + .Select(static (info, _) => info!); + + context.RegisterSourceOutput(targets, static (spc, info) => + { + var source = GenerateValidation(info); + spc.AddSource($"{info.HintName}.Validation.g.cs", source); + }); + } + + private static ValidationInfo? ExtractValidationInfo(GeneratorAttributeSyntaxContext context) + { + try + { + var symbol = (INamedTypeSymbol)context.TargetSymbol; + var ns = symbol.ContainingNamespace.IsGlobalNamespace + ? null + : symbol.ContainingNamespace.ToDisplayString(); + + var isRecord = context.TargetNode is RecordDeclarationSyntax; + var isStruct = context.TargetNode is StructDeclarationSyntax; + + var properties = new List(); + + foreach (var member in symbol.GetMembers()) + { + if (member is not IPropertySymbol prop) continue; + if (prop.DeclaredAccessibility != Accessibility.Public) continue; + if (prop.GetMethod is null) continue; + + var rules = new List(); + var isNullableRef = prop.Type.NullableAnnotation == NullableAnnotation.Annotated; + var isValueType = prop.Type.IsValueType; + var typeName = prop.Type.ToDisplayString(); + + foreach (var attr in prop.GetAttributes()) + { + var attrName = attr.AttributeClass?.ToDisplayString(); + if (attrName is null) continue; + + var customMessage = attr.NamedArguments + .FirstOrDefault(a => a.Key == "Message").Value.Value as string; + + switch (attrName) + { + case "ZibStack.NET.Validation.RequiredAttribute": + rules.Add(new ValidationRule(ValidationRuleKind.Required, customMessage)); + break; + + case "ZibStack.NET.Validation.MinLengthAttribute": + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is int minLen) + rules.Add(new ValidationRule(ValidationRuleKind.MinLength, customMessage, minValue: minLen)); + break; + + case "ZibStack.NET.Validation.MaxLengthAttribute": + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is int maxLen) + rules.Add(new ValidationRule(ValidationRuleKind.MaxLength, customMessage, maxValue: maxLen)); + break; + + case "ZibStack.NET.Validation.RangeAttribute": + if (attr.ConstructorArguments.Length >= 2) + { + var min = attr.ConstructorArguments[0].Value is double dmin ? dmin : 0; + var max = attr.ConstructorArguments[1].Value is double dmax ? dmax : 0; + rules.Add(new ValidationRule(ValidationRuleKind.Range, customMessage, minValue: min, maxValue: max)); + } + break; + + case "ZibStack.NET.Validation.EmailAttribute": + rules.Add(new ValidationRule(ValidationRuleKind.Email, customMessage)); + break; + + case "ZibStack.NET.Validation.UrlAttribute": + rules.Add(new ValidationRule(ValidationRuleKind.Url, customMessage)); + break; + + case "ZibStack.NET.Validation.MatchAttribute": + if (attr.ConstructorArguments.Length > 0 && attr.ConstructorArguments[0].Value is string pattern) + rules.Add(new ValidationRule(ValidationRuleKind.Match, customMessage, pattern: pattern)); + break; + + case "ZibStack.NET.Validation.NotEmptyAttribute": + rules.Add(new ValidationRule(ValidationRuleKind.NotEmpty, customMessage)); + break; + } + } + + if (rules.Count > 0) + { + properties.Add(new PropertyValidationInfo( + prop.Name, typeName, isNullableRef, isValueType, rules)); + } + } + + if (properties.Count == 0) return null; + + var hintName = symbol.ToDisplayString().Replace(".", "_").Replace("<", "_").Replace(">", "_"); + + return new ValidationInfo( + symbol.Name, ns, hintName, isRecord, properties); + } + catch + { + return null; + } + } + + private static string GenerateValidation(ValidationInfo info) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine($"// Generated by ZibStack.NET.Validation for {info.ClassName}"); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using System;"); + sb.AppendLine("using System.Collections.Generic;"); + sb.AppendLine("using ZibStack.NET.Validation;"); + sb.AppendLine(); + + if (info.Namespace is not null) + { + sb.AppendLine($"namespace {info.Namespace};"); + sb.AppendLine(); + } + + var keyword = info.IsRecord ? "record" : "class"; + sb.AppendLine($"partial {keyword} {info.ClassName} : IValidatable"); + sb.AppendLine("{"); + sb.AppendLine(" public ValidationResult Validate()"); + sb.AppendLine(" {"); + sb.AppendLine(" var errors = new List();"); + sb.AppendLine(); + + foreach (var prop in info.Properties) + { + foreach (var rule in prop.Rules) + { + EmitRule(sb, prop, rule); + } + } + + sb.AppendLine(); + sb.AppendLine(" return errors.Count == 0 ? ValidationResult.Success : new ValidationResult(errors);"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static void EmitRule(StringBuilder sb, PropertyValidationInfo prop, ValidationRule rule) + { + var name = prop.PropertyName; + var displayName = prop.PropertyName; + + switch (rule.Kind) + { + case ValidationRuleKind.Required: + { + var msg = rule.CustomMessage ?? $"{displayName} is required."; + if (prop.IsValueType) + { + // For nullable value types + if (prop.TypeName.EndsWith("?")) + sb.AppendLine($" if ({name} is null) errors.Add(\"{EscapeString(msg)}\");"); + } + else + { + sb.AppendLine($" if ({name} is null) errors.Add(\"{EscapeString(msg)}\");"); + if (prop.TypeName == "string" || prop.TypeName == "string?") + sb.AppendLine($" else if (string.IsNullOrWhiteSpace({name})) errors.Add(\"{EscapeString(msg)}\");"); + } + break; + } + + case ValidationRuleKind.MinLength: + { + var len = (int)rule.MinValue; + var msg = rule.CustomMessage ?? $"{displayName} must be at least {len} characters."; + sb.AppendLine($" if ({name} is not null && {name}.Length < {len}) errors.Add(\"{EscapeString(msg)}\");"); + break; + } + + case ValidationRuleKind.MaxLength: + { + var len = (int)rule.MaxValue; + var msg = rule.CustomMessage ?? $"{displayName} must be at most {len} characters."; + sb.AppendLine($" if ({name} is not null && {name}.Length > {len}) errors.Add(\"{EscapeString(msg)}\");"); + break; + } + + case ValidationRuleKind.Range: + { + var msg = rule.CustomMessage ?? $"{displayName} must be between {rule.MinValue} and {rule.MaxValue}."; + if (prop.IsNullableRef || prop.TypeName.EndsWith("?")) + { + sb.AppendLine($" if ({name} is not null && ((double){name} < {rule.MinValue} || (double){name} > {rule.MaxValue})) errors.Add(\"{EscapeString(msg)}\");"); + } + else + { + sb.AppendLine($" if ((double){name} < {rule.MinValue} || (double){name} > {rule.MaxValue}) errors.Add(\"{EscapeString(msg)}\");"); + } + break; + } + + case ValidationRuleKind.Email: + { + var msg = rule.CustomMessage ?? $"{displayName} must be a valid email address."; + sb.AppendLine($" if ({name} is not null && !System.Text.RegularExpressions.Regex.IsMatch({name}, @\"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$\")) errors.Add(\"{EscapeString(msg)}\");"); + break; + } + + case ValidationRuleKind.Url: + { + var msg = rule.CustomMessage ?? $"{displayName} must be a valid URL."; + sb.AppendLine($" if ({name} is not null && !System.Uri.TryCreate({name}, System.UriKind.Absolute, out _)) errors.Add(\"{EscapeString(msg)}\");"); + break; + } + + case ValidationRuleKind.Match: + { + var msg = rule.CustomMessage ?? $"{displayName} does not match the required pattern."; + sb.AppendLine($" if ({name} is not null && !System.Text.RegularExpressions.Regex.IsMatch({name}, @\"{EscapeString(rule.Pattern!)}\")) errors.Add(\"{EscapeString(msg)}\");"); + break; + } + + case ValidationRuleKind.NotEmpty: + { + var msg = rule.CustomMessage ?? $"{displayName} must not be empty."; + if (prop.TypeName == "string" || prop.TypeName == "string?") + sb.AppendLine($" if ({name} is not null && string.IsNullOrWhiteSpace({name})) errors.Add(\"{EscapeString(msg)}\");"); + else + sb.AppendLine($" if ({name} is not null && ((System.Collections.ICollection){name}).Count == 0) errors.Add(\"{EscapeString(msg)}\");"); + break; + } + } + } + + private static string EscapeString(string s) + => s.Replace("\\", "\\\\").Replace("\"", "\\\""); +} + +// ─── Models ─────────────────────────────────────────────────────────── + +internal enum ValidationRuleKind +{ + Required, + MinLength, + MaxLength, + Range, + Email, + Url, + Match, + NotEmpty, +} + +internal sealed class ValidationRule +{ + public ValidationRuleKind Kind { get; } + public string? CustomMessage { get; } + public double MinValue { get; } + public double MaxValue { get; } + public string? Pattern { get; } + + public ValidationRule(ValidationRuleKind kind, string? customMessage = null, double minValue = 0, double maxValue = 0, string? pattern = null) + { + Kind = kind; + CustomMessage = customMessage; + MinValue = minValue; + MaxValue = maxValue; + Pattern = pattern; + } +} + +internal sealed class PropertyValidationInfo +{ + public string PropertyName { get; } + public string TypeName { get; } + public bool IsNullableRef { get; } + public bool IsValueType { get; } + public List Rules { get; } + + public PropertyValidationInfo(string propertyName, string typeName, bool isNullableRef, bool isValueType, List rules) + { + PropertyName = propertyName; + TypeName = typeName; + IsNullableRef = isNullableRef; + IsValueType = isValueType; + Rules = rules; + } +} + +internal sealed class ValidationInfo +{ + public string ClassName { get; } + public string? Namespace { get; } + public string HintName { get; } + public bool IsRecord { get; } + public List Properties { get; } + + public ValidationInfo(string className, string? ns, string hintName, bool isRecord, List properties) + { + ClassName = className; + Namespace = ns; + HintName = hintName; + IsRecord = isRecord; + Properties = properties; + } +} diff --git a/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ZibStack.NET.Validation.csproj b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ZibStack.NET.Validation.csproj new file mode 100644 index 0000000..2a3cbff --- /dev/null +++ b/packages/ZibStack.NET.Validation/src/ZibStack.NET.Validation/ZibStack.NET.Validation.csproj @@ -0,0 +1,34 @@ + + + + netstandard2.0 + latest + enable + true + RS2008;RSEXPERIMENTAL002;NU5128 + true + + ZibStack.NET.Validation + Source-generated validation for .NET. Add validation attributes and get a compile-time Validate() method — no reflection, no runtime overhead. + $(Authors) + MIT + https://github.com/MistyKuu/ZibStack.NET + https://github.com/MistyKuu/ZibStack.NET + source-generator;validation;csharp;codegen + README.md + + false + true + + + + + + + + + + + + + diff --git a/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/Models.cs b/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/Models.cs new file mode 100644 index 0000000..a887bf9 --- /dev/null +++ b/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/Models.cs @@ -0,0 +1,50 @@ +using ZibStack.NET.Validation; + +namespace ZibStack.NET.Validation.Tests; + +[Validate] +public partial class CreateUserRequest +{ + [Required] + [MinLength(2)] + [MaxLength(50)] + public string Name { get; set; } = ""; + + [Required] + [Email] + public string Email { get; set; } = ""; + + [Range(18, 120)] + public int Age { get; set; } + + [Url] + public string? Website { get; set; } + + [Match(@"^\+?\d{7,15}$", Message = "Invalid phone number format.")] + public string? Phone { get; set; } +} + +[Validate] +public partial class TeamRequest +{ + [Required] + [MinLength(1)] + [MaxLength(100)] + public string TeamName { get; set; } = ""; + + [NotEmpty] + public List Members { get; set; } = new(); +} + +[Validate] +public partial record ProductRecord +{ + [Required] + public string Sku { get; init; } = ""; + + [Range(0, 999999)] + public decimal Price { get; init; } + + [MaxLength(500)] + public string? Description { get; init; } +} diff --git a/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ValidationTests.cs b/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ValidationTests.cs new file mode 100644 index 0000000..6226d33 --- /dev/null +++ b/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ValidationTests.cs @@ -0,0 +1,248 @@ +using ZibStack.NET.Validation; + +namespace ZibStack.NET.Validation.Tests; + +public class ValidationTests +{ + // ── Required ────────────────────────────────────────────────────── + + [Fact] + public void Required_NullString_ReturnsError() + { + var request = new CreateUserRequest { Name = null!, Email = "test@test.com", Age = 25 }; + var result = request.Validate(); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Name")); + } + + [Fact] + public void Required_EmptyString_ReturnsError() + { + var request = new CreateUserRequest { Name = " ", Email = "test@test.com", Age = 25 }; + var result = request.Validate(); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Name")); + } + + [Fact] + public void Required_ValidString_NoError() + { + var request = new CreateUserRequest { Name = "John", Email = "john@test.com", Age = 25 }; + var result = request.Validate(); + + Assert.True(result.IsValid); + } + + // ── MinLength / MaxLength ───────────────────────────────────────── + + [Fact] + public void MinLength_TooShort_ReturnsError() + { + var request = new CreateUserRequest { Name = "J", Email = "j@t.com", Age = 25 }; + var result = request.Validate(); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("at least 2")); + } + + [Fact] + public void MaxLength_TooLong_ReturnsError() + { + var request = new CreateUserRequest + { + Name = new string('A', 51), + Email = "j@t.com", + Age = 25 + }; + var result = request.Validate(); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("at most 50")); + } + + // ── Range ───────────────────────────────────────────────────────── + + [Fact] + public void Range_BelowMin_ReturnsError() + { + var request = new CreateUserRequest { Name = "John", Email = "j@t.com", Age = 10 }; + var result = request.Validate(); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("between")); + } + + [Fact] + public void Range_AboveMax_ReturnsError() + { + var request = new CreateUserRequest { Name = "John", Email = "j@t.com", Age = 200 }; + var result = request.Validate(); + + Assert.False(result.IsValid); + } + + [Fact] + public void Range_AtBoundary_NoError() + { + var request = new CreateUserRequest { Name = "John", Email = "j@t.com", Age = 18 }; + var result = request.Validate(); + + Assert.True(result.IsValid); + } + + // ── Email ───────────────────────────────────────────────────────── + + [Fact] + public void Email_Invalid_ReturnsError() + { + var request = new CreateUserRequest { Name = "John", Email = "not-an-email", Age = 25 }; + var result = request.Validate(); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("email")); + } + + [Fact] + public void Email_Valid_NoError() + { + var request = new CreateUserRequest { Name = "John", Email = "john@example.com", Age = 25 }; + var result = request.Validate(); + + Assert.True(result.IsValid); + } + + // ── Url ─────────────────────────────────────────────────────────── + + [Fact] + public void Url_Invalid_ReturnsError() + { + var request = new CreateUserRequest { Name = "John", Email = "j@t.com", Age = 25, Website = "not-a-url" }; + var result = request.Validate(); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("URL")); + } + + [Fact] + public void Url_Valid_NoError() + { + var request = new CreateUserRequest { Name = "John", Email = "j@t.com", Age = 25, Website = "https://example.com" }; + var result = request.Validate(); + + Assert.True(result.IsValid); + } + + [Fact] + public void Url_Null_NoError() + { + var request = new CreateUserRequest { Name = "John", Email = "j@t.com", Age = 25, Website = null }; + var result = request.Validate(); + + Assert.True(result.IsValid); + } + + // ── Match (Regex) ───────────────────────────────────────────────── + + [Fact] + public void Match_Invalid_ReturnsCustomMessage() + { + var request = new CreateUserRequest { Name = "John", Email = "j@t.com", Age = 25, Phone = "abc" }; + var result = request.Validate(); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Invalid phone number")); + } + + [Fact] + public void Match_Valid_NoError() + { + var request = new CreateUserRequest { Name = "John", Email = "j@t.com", Age = 25, Phone = "+48123456789" }; + var result = request.Validate(); + + Assert.True(result.IsValid); + } + + // ── NotEmpty ────────────────────────────────────────────────────── + + [Fact] + public void NotEmpty_EmptyCollection_ReturnsError() + { + var request = new TeamRequest { TeamName = "Team A", Members = new List() }; + var result = request.Validate(); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("not be empty")); + } + + [Fact] + public void NotEmpty_WithItems_NoError() + { + var request = new TeamRequest { TeamName = "Team A", Members = new List { "Alice" } }; + var result = request.Validate(); + + Assert.True(result.IsValid); + } + + // ── IValidatable ────────────────────────────────────────────────── + + [Fact] + public void ImplementsIValidatable() + { + IValidatable validatable = new CreateUserRequest { Name = "John", Email = "j@t.com", Age = 25 }; + var result = validatable.Validate(); + + Assert.True(result.IsValid); + } + + // ── Record support ──────────────────────────────────────────────── + + [Fact] + public void Record_Validates() + { + var product = new ProductRecord { Sku = "ABC-123", Price = 9.99m }; + var result = product.Validate(); + + Assert.True(result.IsValid); + } + + [Fact] + public void Record_Required_Empty_ReturnsError() + { + var product = new ProductRecord { Sku = "", Price = 9.99m }; + var result = product.Validate(); + + Assert.False(result.IsValid); + } + + [Fact] + public void Record_Range_Negative_ReturnsError() + { + var product = new ProductRecord { Sku = "ABC", Price = -1m }; + var result = product.Validate(); + + Assert.False(result.IsValid); + } + + // ── Multiple errors ─────────────────────────────────────────────── + + [Fact] + public void MultipleErrors_AllCollected() + { + var request = new CreateUserRequest { Name = "", Email = "bad", Age = 5 }; + var result = request.Validate(); + + Assert.False(result.IsValid); + Assert.True(result.Errors.Count >= 3); // name required, email invalid, age out of range + } + + // ── ValidationResult.Success singleton ──────────────────────────── + + [Fact] + public void ValidationResult_Success_IsValid() + { + Assert.True(ValidationResult.Success.IsValid); + Assert.Empty(ValidationResult.Success.Errors); + } +} diff --git a/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ZibStack.NET.Validation.Tests.csproj b/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ZibStack.NET.Validation.Tests.csproj new file mode 100644 index 0000000..8642d52 --- /dev/null +++ b/packages/ZibStack.NET.Validation/tests/ZibStack.NET.Validation.Tests/ZibStack.NET.Validation.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + From c9206afb811c86a7b9405e0fb0d7ea96a4cac0bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zbigniew=20Szepczy=C5=84ski?= Date: Mon, 6 Apr 2026 10:38:03 +0000 Subject: [PATCH 5/5] Add ZibException with structured interpolated strings + fix LogEx overloads - ZibException captures template + properties from interpolated strings for structured logging: throw new ZibException($"Order {id} not found") - ZibException for typed error codes - LogTo(logger) method for structured exception logging - LogException() extension: structured logging for ZibException, fallback for others - Add missing Exception overloads to LogTraceEx, LogDebugEx, LogInformationEx, LogWarningEx - Dto generator now propagates ZibStack.NET.Validation attributes alongside DataAnnotations --- .../DtoGenerator.Extraction.cs | 3 +- .../LoggerInterpolatedExtensions.cs | 24 ++++ .../Interpolation/ZibException.cs | 107 ++++++++++++++++++ .../ZibExceptionInterpolatedStringHandler.cs | 88 ++++++++++++++ 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibException.cs create mode 100644 packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibExceptionInterpolatedStringHandler.cs diff --git a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs index 25afa11..1b8ec7b 100644 --- a/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs +++ b/packages/ZibStack.NET.Dto/src/ZibStack.NET.Dto/DtoGenerator.Extraction.cs @@ -933,7 +933,8 @@ private static string GetJsonName(IPropertySymbol prop) private static readonly HashSet ValidationNamespaces = new() { - "System.ComponentModel.DataAnnotations" + "System.ComponentModel.DataAnnotations", + "ZibStack.NET.Validation" }; private static List GetValidationAttributes(IPropertySymbol prop) diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/LoggerInterpolatedExtensions.cs b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/LoggerInterpolatedExtensions.cs index 5e1a0e6..bdd424b 100644 --- a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/LoggerInterpolatedExtensions.cs +++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/LoggerInterpolatedExtensions.cs @@ -21,24 +21,48 @@ public static void LogTraceEx(this ILogger logger, ref ZibLogInterpolatedStringH logger.Log(LogLevel.Trace, handler.GetTemplate(), handler.GetArgs()); } + public static void LogTraceEx(this ILogger logger, Exception exception, ref ZibLogInterpolatedStringHandler handler) + { + if (logger.IsEnabled(LogLevel.Trace)) + logger.Log(LogLevel.Trace, exception, handler.GetTemplate(), handler.GetArgs()); + } + public static void LogDebugEx(this ILogger logger, ref ZibLogInterpolatedStringHandler handler) { if (logger.IsEnabled(LogLevel.Debug)) logger.Log(LogLevel.Debug, handler.GetTemplate(), handler.GetArgs()); } + public static void LogDebugEx(this ILogger logger, Exception exception, ref ZibLogInterpolatedStringHandler handler) + { + if (logger.IsEnabled(LogLevel.Debug)) + logger.Log(LogLevel.Debug, exception, handler.GetTemplate(), handler.GetArgs()); + } + public static void LogInformationEx(this ILogger logger, ref ZibLogInterpolatedStringHandler handler) { if (logger.IsEnabled(LogLevel.Information)) logger.Log(LogLevel.Information, handler.GetTemplate(), handler.GetArgs()); } + public static void LogInformationEx(this ILogger logger, Exception exception, ref ZibLogInterpolatedStringHandler handler) + { + if (logger.IsEnabled(LogLevel.Information)) + logger.Log(LogLevel.Information, exception, handler.GetTemplate(), handler.GetArgs()); + } + public static void LogWarningEx(this ILogger logger, ref ZibLogInterpolatedStringHandler handler) { if (logger.IsEnabled(LogLevel.Warning)) logger.Log(LogLevel.Warning, handler.GetTemplate(), handler.GetArgs()); } + public static void LogWarningEx(this ILogger logger, Exception exception, ref ZibLogInterpolatedStringHandler handler) + { + if (logger.IsEnabled(LogLevel.Warning)) + logger.Log(LogLevel.Warning, exception, handler.GetTemplate(), handler.GetArgs()); + } + public static void LogErrorEx(this ILogger logger, ref ZibLogInterpolatedStringHandler handler) { if (logger.IsEnabled(LogLevel.Error)) diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibException.cs b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibException.cs new file mode 100644 index 0000000..420c0cc --- /dev/null +++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibException.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace ZibStack.NET.Log; + +/// +/// Exception that preserves structured logging data from interpolated strings. +/// Use like a normal exception with interpolated strings — the template and properties +/// are captured automatically for structured logging. +/// +/// +/// +/// throw new ZibException($"Order {orderId} not found for user {userId}"); +/// // Message: "Order 123 not found for user 42" +/// // Template: "Order {orderId} not found for user {userId}" +/// // Properties: { orderId: 123, userId: 42 } +/// +/// // Later when catching: +/// catch (ZibException ex) +/// { +/// ex.LogTo(logger, LogLevel.Error); +/// // Structured log: "Order {orderId} not found for user {userId}" with orderId=123, userId=42 +/// } +/// +/// +public class ZibException : Exception +{ + /// The structured message template (e.g. "Order {orderId} not found"). + public string Template { get; } + + /// Captured property names and values from the interpolated string. + public IReadOnlyList> Properties { get; } + + public ZibException(ref ZibExceptionInterpolatedStringHandler handler) + : base(handler.GetMessage()) + { + Template = handler.GetTemplate(); + Properties = handler.GetProperties(); + } + + public ZibException(ref ZibExceptionInterpolatedStringHandler handler, Exception innerException) + : base(handler.GetMessage(), innerException) + { + Template = handler.GetTemplate(); + Properties = handler.GetProperties(); + } + + /// + /// Log this exception with structured data preserved. + /// + public void LogTo(ILogger logger, LogLevel level = LogLevel.Error) + { + if (!logger.IsEnabled(level)) return; + + var args = new object?[Properties.Count]; + for (int i = 0; i < Properties.Count; i++) + args[i] = Properties[i].Value; + + logger.Log(level, this, Template, args); + } +} + +/// +/// Typed version of ZibException for domain-specific exception hierarchies. +/// +public class ZibException : ZibException where TCode : struct, Enum +{ + /// Application-specific error code. + public TCode Code { get; } + + public ZibException(TCode code, ref ZibExceptionInterpolatedStringHandler handler) + : base(ref handler) + { + Code = code; + } + + public ZibException(TCode code, ref ZibExceptionInterpolatedStringHandler handler, Exception innerException) + : base(ref handler, innerException) + { + Code = code; + } +} + +/// +/// Extensions for logging any exception, with special handling for ZibException. +/// +public static class ZibExceptionLoggerExtensions +{ + /// + /// Logs the exception. If it's a ZibException, preserves structured data. + /// Otherwise falls back to standard exception logging. + /// + public static void LogException(this ILogger logger, Exception exception, LogLevel level = LogLevel.Error) + { + if (!logger.IsEnabled(level)) return; + + if (exception is ZibException zibEx) + { + zibEx.LogTo(logger, level); + } + else + { + logger.Log(level, exception, exception.Message); + } + } +} diff --git a/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibExceptionInterpolatedStringHandler.cs b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibExceptionInterpolatedStringHandler.cs new file mode 100644 index 0000000..b7d19dc --- /dev/null +++ b/packages/ZibStack.NET.Log/src/ZibStack.NET.Log.Abstractions/Interpolation/ZibExceptionInterpolatedStringHandler.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text; + +namespace ZibStack.NET.Log; + +/// +/// Interpolated string handler for exceptions that captures both the formatted message +/// and the structured template + properties for structured logging. +/// +[InterpolatedStringHandler] +public ref struct ZibExceptionInterpolatedStringHandler +{ + private readonly StringBuilder _template; + private readonly StringBuilder _message; + private readonly List> _properties; + + public ZibExceptionInterpolatedStringHandler(int literalLength, int formattedCount) + { + _template = new StringBuilder(literalLength + formattedCount * 16); + _message = new StringBuilder(literalLength + formattedCount * 16); + _properties = new List>(formattedCount); + } + + public void AppendLiteral(string s) + { + _template.Append(s.Replace("{", "{{").Replace("}", "}}")); + _message.Append(s); + } + + public void AppendFormatted( + T value, + [CallerArgumentExpression(nameof(value))] string name = "") + { + var sanitized = SanitizeName(name); + _template.Append('{').Append(sanitized).Append('}'); + _message.Append(value); + _properties.Add(new KeyValuePair(sanitized, value)); + } + + public void AppendFormatted( + T value, + string format, + [CallerArgumentExpression(nameof(value))] string name = "") + { + var sanitized = SanitizeName(name); + _template.Append('{').Append(sanitized); + if (!string.IsNullOrEmpty(format)) + _template.Append(':').Append(format); + _template.Append('}'); + + if (value is IFormattable formattable) + _message.Append(formattable.ToString(format, null)); + else + _message.Append(value); + + _properties.Add(new KeyValuePair(sanitized, value)); + } + + internal readonly string GetMessage() => _message.ToString(); + internal readonly string GetTemplate() => _template.ToString(); + internal readonly IReadOnlyList> GetProperties() => _properties; + + private static string SanitizeName(string expression) + { + if (string.IsNullOrEmpty(expression)) + return "_"; + + var sb = new StringBuilder(expression.Length); + bool capitalizeNext = false; + + for (int i = 0; i < expression.Length; i++) + { + char c = expression[i]; + if (char.IsLetterOrDigit(c) || c == '_') + { + sb.Append(capitalizeNext ? char.ToUpperInvariant(c) : c); + capitalizeNext = false; + } + else + { + capitalizeNext = sb.Length > 0; + } + } + + return sb.Length > 0 ? sb.ToString() : "_"; + } +}