diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index 71b1c32..210f670 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -22,6 +22,7 @@ on: - "cloud-native/polly-resilience/**" - "csharp-language/csharp-12-features/**" - "csharp-language/high-performance-memory-management/**" + - "csharp-language/modern-patterns-result-pipeline/**" - ".github/workflows/build-samples.yml" pull_request: @@ -44,6 +45,7 @@ on: - "cloud-native/polly-resilience/**" - "csharp-language/csharp-12-features/**" - "csharp-language/high-performance-memory-management/**" + - "csharp-language/modern-patterns-result-pipeline/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -894,3 +896,100 @@ jobs: )" test "${line_count}" = "6" + + test-result-pipeline: + name: Test C# Result pipeline sample + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: > + dotnet restore + csharp-language/modern-patterns-result-pipeline/ResultPipelineLab.slnx + + - name: Build + run: > + dotnet build + csharp-language/modern-patterns-result-pipeline/ResultPipelineLab.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + csharp-language/modern-patterns-result-pipeline/ResultPipelineLab.slnx + --configuration Release + --no-build + + - name: Verify explicit C# language version + shell: bash + run: | + app_version="$( + dotnet msbuild \ + csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/ResultPipelineLab.csproj \ + -getProperty:LangVersion + )" + + test_version="$( + dotnet msbuild \ + csharp-language/modern-patterns-result-pipeline/tests/ResultPipelineLab.Tests/ResultPipelineLab.Tests.csproj \ + -getProperty:LangVersion + )" + + echo "Application LangVersion: ${app_version}" + echo "Test LangVersion: ${test_version}" + + test "${app_version}" = "12.0" + test "${test_version}" = "12.0" + + - name: Run deterministic Result pipeline sample + shell: bash + run: | + output="$( + dotnet run \ + --project csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/ResultPipelineLab.csproj \ + --configuration Release \ + --no-build + )" + + printf '%s\n' "${output}" + + printf -v expected '%s\n' \ + 'C# 12 Result Pipeline Lab' \ + 'Success: imported=2, writes=1' \ + 'Products: Widget Pro | Travel Mug' \ + 'Failure: code=VALIDATE_BATCH_FAILED, writes=0' \ + 'Public message: 1 record(s) failed validation.' + + expected="${expected%$'\n'}" + + if [[ "${output}" != "${expected}" ]]; then + echo "Console output did not match the expected output." + + diff \ + --unified \ + <(printf '%s\n' "${expected}") \ + <(printf '%s\n' "${output}") \ + || true + + exit 1 + fi + + line_count="$( + printf '%s\n' "${output}" | + wc --lines | + tr --delete ' ' + )" + + test "${line_count}" = "5" diff --git a/README.md b/README.md index 4a25b50..2d3995c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`cloud-native/polly-resilience`](cloud-native/polly-resilience/) | Focused .NET 10 Polly v8 catalog sample demonstrating explicit stale-cache fallback, timeout-driven degradation, outbound concurrency isolation, strategy event counters, and deterministic integration testing | [Polly Resilience (Polly v8): Timeouts, Retries, Circuits, Bulkheads, Hedging](https://www.dotnet-guide.com/tutorials/cloud-native/polly-resilience/) | | [`csharp-language/csharp-12-features`](csharp-language/csharp-12-features/) | Focused .NET 10 console lab locked to C# 12.0, demonstrating primary constructors with explicit properties, collection expressions and spreads, default lambda parameters, tuple-type aliases, deterministic output, and unit tests | [C# 12 Language Features: Primary Constructors, Collections & More](https://www.dotnet-guide.com/tutorials/csharp-language/csharp-12-features/) | | [`csharp-language/high-performance-memory-management`](csharp-language/high-performance-memory-management/) | Focused .NET 10 UTF-8 ingestion sample demonstrating PipeReader framing, segmented ReadOnlySequence handling, span-based numeric parsing, bounded MemoryPool ownership, strict input validation, deterministic output, and tests | [High-Performance C#: Span, Memory, SIMD & Pipelines](https://www.dotnet-guide.com/tutorials/csharp-language/high-performance-memory-management/) | +| [`csharp-language/modern-patterns-result-pipeline`](csharp-language/modern-patterns-result-pipeline/) | Focused .NET 10 console companion locked to C# 12.0, demonstrating a custom Result type, safe and diagnostic errors, Map/Bind/BindAsync composition, invariant text-import validation, short-circuit persistence, cancellation, deterministic output, and tests | [C# 12 Functional Patterns: Result Type, Error Handling & Composable Pipeline Testing](https://www.dotnet-guide.com/tutorials/csharp-language/modern-patterns-result-pipeline/) | ## Companion articles - [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/) @@ -309,6 +310,29 @@ tutorials/ | | `-- TestSupport/ | | |-- ChunkedReadStream.cs | | `-- SegmentedSequence.cs +| |-- modern-patterns-result-pipeline/ +| | |-- ResultPipelineLab.slnx +| | |-- README.md +| | |-- src/ +| | | `-- ResultPipelineLab/ +| | | |-- ResultPipelineLab.csproj +| | | |-- Program.cs +| | | |-- Core/ +| | | | |-- Result.cs +| | | | `-- ResultExtensions.cs +| | | |-- Models/ +| | | | `-- ImportModels.cs +| | | |-- Persistence/ +| | | | `-- InMemoryProductStore.cs +| | | `-- Pipeline/ +| | | |-- ImportPipeline.cs +| | | |-- ParseStage.cs +| | | |-- TransformStage.cs +| | | `-- ValidateStage.cs +| | `-- tests/ +| | `-- ResultPipelineLab.Tests/ +| | |-- ResultPipelineLab.Tests.csproj +| | `-- ResultPipelineTests.cs |-- aspnet-core/ | |-- api-security-in-practice/ | | |-- ApiSecurityMinimal.slnx diff --git a/csharp-language/modern-patterns-result-pipeline/README.md b/csharp-language/modern-patterns-result-pipeline/README.md new file mode 100644 index 0000000..ec876ba --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/README.md @@ -0,0 +1,212 @@ +# C# 12 Result Pipeline Lab + +A focused .NET 10 console companion demonstrating typed expected failures, +`Map`, `Bind`, `Match`, one async persistence boundary, invariant validation, +safe error projection, short-circuit behavior, cancellation, deterministic +output, and tests. + +## Full tutorial + +[C# 12 Functional Patterns: Result Type, Error Handling & Composable Pipeline Testing](https://www.dotnet-guide.com/tutorials/csharp-language/modern-patterns-result-pipeline/) + +## Framework and language note + +The tutorial is written for C# 12 with the .NET 8 generation. + +This companion targets .NET 10 because that is the DOTNET GUIDE repository's +current SDK, but both projects explicitly set: + +```xml +12.0 +``` + +The sample uses an explicit fallback arm in every switch expression over +`Result`. + +C# 12 does not infer a closed hierarchy merely because the base constructor is +private and the known cases are nested and sealed. + +## Pipeline + +```text +restricted text + -> Parse + -> Bind Validate + -> Map Transform + -> BindAsync Persist + -> Match at the edge +``` + +Expected parse, validation, and configured storage failures are returned as +`Result.Failure`. + +Caller cancellation and programming defects remain exceptions. + +## Input format + +```text +Name,Price,Category,Stock +Widget Pro,9.99,Electronics,50 +``` + +This is a deliberately restricted comma-delimited format. + +It does not support: + +- quoted fields; +- commas inside values; +- escaped quotes; +- embedded newlines; +- locale-specific decimal separators. + +Use a reviewed CSV library when those capabilities are required. + +## Error projection + +`PipelineError` contains: + +```text +Code +Message +Detail +``` + +`ToPublic()` returns only: + +```text +Code +Message +``` + +`ToDiagnostic(stage)` includes internal detail. + +The existence of two projections does not automatically make debug detail safe. + +Do not serialize or ship diagnostic detail to external consumers without a +reviewed redaction and sink policy. + +## Map and Bind + +Use `Map` when validated input is transformed without an expected failure +result. + +Use `Bind` when the next stage returns its own `Result`. + +Use `BindAsync` to connect the prepared synchronous result to the asynchronous +persistence boundary. + +The callbacks can still throw programming exceptions. + +## Validation + +Numeric values are parsed with `CultureInfo.InvariantCulture`. + +Parsed values are carried forward in `ValidatedProduct`, so transformation does +not parse the same text again. + +All invalid rows are collected into internal detail. + +This sample is fail-whole-batch, not partial-success import. + +## Persistence + +The in-memory store has an asynchronous-shaped API for composition. + +It supports: + +- deterministic success; +- deterministic expected rejection; +- cancellation. + +It is not a database simulation or throughput benchmark. + +## Prerequisite + +- .NET 10 SDK + +## Restore, build, test, and run + +```powershell +dotnet restore ` + .\ResultPipelineLab.slnx + +dotnet build ` + .\ResultPipelineLab.slnx ` + --configuration Release ` + --no-restore + +dotnet test ` + .\ResultPipelineLab.slnx ` + --configuration Release ` + --no-build + +dotnet run ` + --project .\src\ResultPipelineLab\ResultPipelineLab.csproj ` + --configuration Release ` + --no-build +``` + +## Expected output + +```text +C# 12 Result Pipeline Lab +Success: imported=2, writes=1 +Products: Widget Pro | Travel Mug +Failure: code=VALIDATE_BATCH_FAILED, writes=0 +Public message: 1 record(s) failed validation. +``` + +## Project structure + +```text +ResultPipelineLab.slnx +README.md +src/ +`-- ResultPipelineLab/ + |-- ResultPipelineLab.csproj + |-- Program.cs + |-- Core/ + | |-- Result.cs + | `-- ResultExtensions.cs + |-- Models/ + | `-- ImportModels.cs + |-- Persistence/ + | `-- InMemoryProductStore.cs + `-- Pipeline/ + |-- ImportPipeline.cs + |-- ParseStage.cs + |-- TransformStage.cs + `-- ValidateStage.cs +tests/ +`-- ResultPipelineLab.Tests/ + |-- ResultPipelineLab.Tests.csproj + `-- ResultPipelineTests.cs +``` + +## Deliberately omitted + +- third-party Result packages; +- FluentAssertions; +- structured logging providers; +- databases; +- ASP.NET Core; +- file uploads; +- full CSV behavior; +- partial success; +- retries; +- exception swallowing. + +## Verification + +- Companion target framework: .NET 10 +- Explicit language version: C# 12.0 +- Application NuGet dependencies: none +- External services required: none +- Expected tests: 10 +- Expected console lines: 5 +- Last reviewed: 2026-08-05 + +This sample teaches composition and failure semantics. A production Result +abstraction requires decisions about nullability, equality, error accumulation, +serialization, observability, cancellation, exception translation, API +contracts, and team conventions. \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/ResultPipelineLab.slnx b/csharp-language/modern-patterns-result-pipeline/ResultPipelineLab.slnx new file mode 100644 index 0000000..b13862f --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/ResultPipelineLab.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Core/Result.cs b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Core/Result.cs new file mode 100644 index 0000000..a376c39 --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Core/Result.cs @@ -0,0 +1,102 @@ +namespace ResultPipelineLab.Core; + +public sealed record PipelineError( + string Code, + string Message, + string? Detail = null) +{ + public static PipelineError Parse( + string code, + string message, + string? detail = null) => + new( + code, + message, + detail); + + public static PipelineError Validation( + string code, + string message, + string? detail = null) => + new( + code, + message, + detail); + + public static PipelineError Storage( + string code, + string message, + string? detail = null) => + new( + code, + message, + detail); + + public PublicError ToPublic() => + new( + Code, + Message); + + public DiagnosticError ToDiagnostic( + string stage) + { + ArgumentException + .ThrowIfNullOrWhiteSpace( + stage); + + return new DiagnosticError( + Stage: + stage, + + Code: + Code, + + Message: + Message, + + Detail: + Detail + ?? "none"); + } +} + +public sealed record PublicError( + string Code, + string Message); + +public sealed record DiagnosticError( + string Stage, + string Code, + string Message, + string Detail); + +public abstract record Result +{ + private Result() + { + } + + public sealed record Success( + T Value) : + Result; + + public sealed record Failure( + PipelineError Error) : + Result; + + public static Result Ok( + T value) => + new Success( + value); + + public static Result Fail( + PipelineError error) + { + ArgumentNullException + .ThrowIfNull( + error); + + return new Failure( + error); + } +} \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Core/ResultExtensions.cs b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Core/ResultExtensions.cs new file mode 100644 index 0000000..3f389f6 --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Core/ResultExtensions.cs @@ -0,0 +1,141 @@ +using System.Diagnostics; + +namespace ResultPipelineLab.Core; + +public static class ResultExtensions +{ + public static Result Map< + T, + TNext>( + this Result result, + Func transform) + { + ArgumentNullException + .ThrowIfNull( + result); + + ArgumentNullException + .ThrowIfNull( + transform); + + return result switch + { + Result.Success success => + Result.Ok( + transform( + success.Value)), + + Result.Failure failure => + Result.Fail( + failure.Error), + + _ => + throw new UnreachableException( + "Unknown Result case.") + }; + } + + public static Result Bind< + T, + TNext>( + this Result result, + Func> next) + { + ArgumentNullException + .ThrowIfNull( + result); + + ArgumentNullException + .ThrowIfNull( + next); + + return result switch + { + Result.Success success => + next( + success.Value), + + Result.Failure failure => + Result.Fail( + failure.Error), + + _ => + throw new UnreachableException( + "Unknown Result case.") + }; + } + + public static TOut Match< + T, + TOut>( + this Result result, + Func onSuccess, + Func onFailure) + { + ArgumentNullException + .ThrowIfNull( + result); + + ArgumentNullException + .ThrowIfNull( + onSuccess); + + ArgumentNullException + .ThrowIfNull( + onFailure); + + return result switch + { + Result.Success success => + onSuccess( + success.Value), + + Result.Failure failure => + onFailure( + failure.Error), + + _ => + throw new UnreachableException( + "Unknown Result case.") + }; + } + + public static Task> + BindAsync< + T, + TNext>( + this Result result, + Func< + T, + CancellationToken, + Task>> + next, + CancellationToken cancellationToken = + default) + { + ArgumentNullException + .ThrowIfNull( + result); + + ArgumentNullException + .ThrowIfNull( + next); + + return result switch + { + Result.Success success => + next( + success.Value, + cancellationToken), + + Result.Failure failure => + Task.FromResult( + Result.Fail( + failure.Error)), + + _ => + throw new UnreachableException( + "Unknown Result case.") + }; + } +} \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Models/ImportModels.cs b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Models/ImportModels.cs new file mode 100644 index 0000000..d00b638 --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Models/ImportModels.cs @@ -0,0 +1,24 @@ +namespace ResultPipelineLab.Models; + +public sealed record RawProductRow( + int LineNumber, + string Name, + string PriceText, + string Category, + string StockText); + +public sealed record ValidatedProduct( + int LineNumber, + string Name, + decimal Price, + string Category, + int Stock); + +public sealed record ImportedProduct( + string Name, + decimal Price, + string Category, + int Stock); + +public sealed record ImportSummary( + int TotalInserted); \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Persistence/InMemoryProductStore.cs b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Persistence/InMemoryProductStore.cs new file mode 100644 index 0000000..779350d --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Persistence/InMemoryProductStore.cs @@ -0,0 +1,82 @@ +using ResultPipelineLab.Core; +using ResultPipelineLab.Models; + +namespace ResultPipelineLab.Persistence; + +public interface IProductStore +{ + Task> + SaveAsync( + IReadOnlyList< + ImportedProduct> + products, + CancellationToken + cancellationToken); +} + +public sealed class InMemoryProductStore( + bool rejectWrites = false) : + IProductStore +{ + private readonly bool + _rejectWrites = + rejectWrites; + + private readonly + List + _products = + [ + ]; + + public int WriteAttempts + { + get; + private set; + } + + public IReadOnlyList< + ImportedProduct> + Products => + [ + .. _products + ]; + + public Task> + SaveAsync( + IReadOnlyList< + ImportedProduct> + products, + CancellationToken + cancellationToken) + { + ArgumentNullException + .ThrowIfNull( + products); + + cancellationToken + .ThrowIfCancellationRequested(); + + WriteAttempts++; + + if (_rejectWrites) + { + return Task.FromResult( + Result< + ImportSummary>.Fail( + PipelineError.Storage( + "STORE_WRITE_REJECTED", + "The products could not be saved.", + "The deterministic in-memory store was configured to reject writes."))); + } + + _products.AddRange( + products); + + return Task.FromResult( + Result< + ImportSummary>.Ok( + new ImportSummary( + TotalInserted: + products.Count))); + } +} \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/ImportPipeline.cs b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/ImportPipeline.cs new file mode 100644 index 0000000..a26194a --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/ImportPipeline.cs @@ -0,0 +1,51 @@ +using ResultPipelineLab.Core; +using ResultPipelineLab.Models; +using ResultPipelineLab.Persistence; + +namespace ResultPipelineLab.Pipeline; + +public sealed class ImportPipeline( + IProductStore store) +{ + private readonly IProductStore + _store = + store + ?? throw new + ArgumentNullException( + nameof(store)); + + public async Task< + Result> + RunAsync( + string text, + CancellationToken + cancellationToken = + default) + { + cancellationToken + .ThrowIfCancellationRequested(); + + Result + prepared = + ParseStage + .Parse( + text) + .Bind( + ValidateStage + .ValidateBatch) + .Map( + TransformStage + .ToProducts); + + return await prepared + .BindAsync( + ( + products, + token) => + _store.SaveAsync( + products, + token), + + cancellationToken); + } +} \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/ParseStage.cs b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/ParseStage.cs new file mode 100644 index 0000000..7853f49 --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/ParseStage.cs @@ -0,0 +1,186 @@ +using ResultPipelineLab.Core; +using ResultPipelineLab.Models; + +namespace ResultPipelineLab.Pipeline; + +public static class ParseStage +{ + private static readonly string[] + ExpectedHeader = + [ + "Name", + "Price", + "Category", + "Stock" + ]; + + public static Result + Parse( + string text) + { + if (string.IsNullOrWhiteSpace( + text)) + { + return Result< + RawProductRow[]>.Fail( + PipelineError.Parse( + "PARSE_EMPTY_INPUT", + "Input text is empty.")); + } + + if (text.Contains( + '"', + StringComparison.Ordinal)) + { + return Result< + RawProductRow[]>.Fail( + PipelineError.Parse( + "PARSE_QUOTES_UNSUPPORTED", + "Quoted fields are not supported by this sample.", + "Use a reviewed CSV library when quoted or escaped fields are required.")); + } + + List lines = + [ + ]; + + using var reader = + new StringReader( + text); + + while (reader.ReadLine() + is string line) + { + lines.Add( + line); + } + + if (lines.Count + == 0) + { + return Result< + RawProductRow[]>.Fail( + PipelineError.Parse( + "PARSE_NO_LINES", + "No import lines were found.")); + } + + string[] header = + [ + .. lines[0] + .Split( + ',', + StringSplitOptions.None) + .Select( + value => + value.Trim()) + ]; + + if (!header.SequenceEqual( + ExpectedHeader, + StringComparer.Ordinal)) + { + return Result< + RawProductRow[]>.Fail( + PipelineError.Parse( + "PARSE_INVALID_HEADER", + "The import header does not match the expected columns.", + $"Expected: {string.Join(", ", ExpectedHeader)}; received: {string.Join(", ", header)}")); + } + + List records = + [ + ]; + + List malformed = + [ + ]; + + for (int index = 1; + index < lines.Count; + index++) + { + int lineNumber = + index + + 1; + + string line = + lines[index]; + + if (string.IsNullOrWhiteSpace( + line)) + { + malformed.Add( + $"Line {lineNumber}: empty row."); + + continue; + } + + string[] columns = + [ + .. line + .Split( + ',', + StringSplitOptions.None) + .Select( + value => + value.Trim()) + ]; + + if (columns.Length + != 4) + { + malformed.Add( + $"Line {lineNumber}: expected 4 fields, received {columns.Length}."); + + continue; + } + + records.Add( + new RawProductRow( + LineNumber: + lineNumber, + + Name: + columns[0], + + PriceText: + columns[1], + + Category: + columns[2], + + StockText: + columns[3])); + } + + if (malformed.Count + > 0) + { + return Result< + RawProductRow[]>.Fail( + PipelineError.Parse( + "PARSE_MALFORMED_ROWS", + $"{malformed.Count} row(s) could not be parsed.", + string.Join( + " ", + malformed))); + } + + if (records.Count + == 0) + { + return Result< + RawProductRow[]>.Fail( + PipelineError.Parse( + "PARSE_NO_DATA_ROWS", + "The import contains no product rows.")); + } + + return Result< + RawProductRow[]>.Ok( + [ + .. records + ]); + } +} \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/TransformStage.cs b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/TransformStage.cs new file mode 100644 index 0000000..0da7245 --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/TransformStage.cs @@ -0,0 +1,54 @@ +using ResultPipelineLab.Models; + +namespace ResultPipelineLab.Pipeline; + +public static class TransformStage +{ + public static ImportedProduct[] + ToProducts( + ValidatedProduct[] records) + { + ArgumentNullException + .ThrowIfNull( + records); + + return + [ + .. records.Select( + record => + new ImportedProduct( + Name: + NormalizeName( + record.Name), + + Price: + Math.Round( + record.Price, + 2, + MidpointRounding + .AwayFromZero), + + Category: + record.Category, + + Stock: + record.Stock)) + ]; + } + + private static string NormalizeName( + string name) => + string.Join( + ' ', + name + .Split( + ' ', + StringSplitOptions + .RemoveEmptyEntries) + .Select( + word => + char.ToUpperInvariant( + word[0]) + + word[1..] + .ToLowerInvariant())); +} \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/ValidateStage.cs b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/ValidateStage.cs new file mode 100644 index 0000000..43fb2c1 --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Pipeline/ValidateStage.cs @@ -0,0 +1,184 @@ +using System.Globalization; +using ResultPipelineLab.Core; +using ResultPipelineLab.Models; + +namespace ResultPipelineLab.Pipeline; + +public static class ValidateStage +{ + private static readonly + Dictionary + KnownCategories = + new( + StringComparer + .OrdinalIgnoreCase) + { + ["Electronics"] = + "Electronics", + + ["Books"] = + "Books", + + ["Hardware"] = + "Hardware" + }; + + private const NumberStyles + DecimalStyles = + NumberStyles.AllowLeadingSign + | NumberStyles.AllowDecimalPoint; + + public static Result< + ValidatedProduct[]> + ValidateBatch( + RawProductRow[] records) + { + ArgumentNullException + .ThrowIfNull( + records); + + if (records.Length + == 0) + { + return Result< + ValidatedProduct[]>.Fail( + PipelineError.Validation( + "VALIDATE_EMPTY_BATCH", + "There are no records to validate.")); + } + + List valid = + [ + ]; + + List details = + [ + ]; + + int invalidRows = + 0; + + foreach (RawProductRow raw + in records) + { + List rowIssues = + [ + ]; + + string name = + raw.Name.Trim(); + + if (name.Length + == 0) + { + rowIssues.Add( + "Name is required."); + } + else if (name.Length + > 100) + { + rowIssues.Add( + $"Name exceeds 100 characters ({name.Length})."); + } + + bool priceValid = + decimal.TryParse( + raw.PriceText, + DecimalStyles, + CultureInfo + .InvariantCulture, + out decimal price); + + if (!priceValid) + { + rowIssues.Add( + $"Price '{raw.PriceText}' is not an invariant decimal."); + } + else if (price + <= 0) + { + rowIssues.Add( + $"Price must be greater than zero (received {price.ToString(CultureInfo.InvariantCulture)})."); + } + + bool stockValid = + int.TryParse( + raw.StockText, + NumberStyles.Integer, + CultureInfo + .InvariantCulture, + out int stock); + + if (!stockValid) + { + rowIssues.Add( + $"Stock '{raw.StockText}' is not an invariant integer."); + } + else if (stock + < 0) + { + rowIssues.Add( + $"Stock cannot be negative (received {stock})."); + } + + bool categoryValid = + KnownCategories.TryGetValue( + raw.Category.Trim(), + out string? + canonicalCategory); + + if (!categoryValid) + { + rowIssues.Add( + $"Category '{raw.Category}' is not supported."); + } + + if (rowIssues.Count + > 0) + { + invalidRows++; + + details.Add( + $"Line {raw.LineNumber}: {string.Join(" ", rowIssues)}"); + + continue; + } + + valid.Add( + new ValidatedProduct( + LineNumber: + raw.LineNumber, + + Name: + name, + + Price: + price, + + Category: + canonicalCategory!, + + Stock: + stock)); + } + + if (invalidRows + > 0) + { + return Result< + ValidatedProduct[]>.Fail( + PipelineError.Validation( + "VALIDATE_BATCH_FAILED", + $"{invalidRows} record(s) failed validation.", + string.Join( + " ", + details))); + } + + return Result< + ValidatedProduct[]>.Ok( + [ + .. valid + ]); + } +} \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Program.cs b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Program.cs new file mode 100644 index 0000000..9deb13a --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/Program.cs @@ -0,0 +1,91 @@ +using ResultPipelineLab.Core; +using ResultPipelineLab.Persistence; +using ResultPipelineLab.Pipeline; + +const string ValidInput = + """ + Name,Price,Category,Stock + widget pro,9.99,electronics,50 + travel mug,14.50,hardware,100 + """; + +const string InvalidInput = + """ + Name,Price,Category,Stock + Broken Item,-5,Electronics,10 + """; + +var successStore = + new InMemoryProductStore(); + +var successPipeline = + new ImportPipeline( + successStore); + +ResultPipelineLab.Core.Result< + ResultPipelineLab.Models.ImportSummary> + success = + await successPipeline + .RunAsync( + ValidInput); + +var failureStore = + new InMemoryProductStore(); + +var failurePipeline = + new ImportPipeline( + failureStore); + +ResultPipelineLab.Core.Result< + ResultPipelineLab.Models.ImportSummary> + failure = + await failurePipeline + .RunAsync( + InvalidInput); + +string successLine = + success.Match( + onSuccess: + summary => + $"Success: imported={summary.TotalInserted}, writes={successStore.WriteAttempts}", + + onFailure: + error => + $"Unexpected failure: {error.Code}"); + +string failureLine = + failure.Match( + onSuccess: + summary => + $"Unexpected success: imported={summary.TotalInserted}", + + onFailure: + error => + $"Failure: code={error.Code}, writes={failureStore.WriteAttempts}"); + +PublicError publicError = + failure.Match( + onSuccess: + _ => + new PublicError( + "UNEXPECTED_SUCCESS", + "The invalid import unexpectedly succeeded."), + + onFailure: + error => + error.ToPublic()); + +Console.WriteLine( + "C# 12 Result Pipeline Lab"); + +Console.WriteLine( + successLine); + +Console.WriteLine( + $"Products: {string.Join(" | ", successStore.Products.Select(product => product.Name))}"); + +Console.WriteLine( + failureLine); + +Console.WriteLine( + $"Public message: {publicError.Message}"); \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/ResultPipelineLab.csproj b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/ResultPipelineLab.csproj new file mode 100644 index 0000000..27eb670 --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/src/ResultPipelineLab/ResultPipelineLab.csproj @@ -0,0 +1,12 @@ + + + + Exe + net10.0 + 12.0 + enable + enable + true + + + \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/tests/ResultPipelineLab.Tests/ResultPipelineLab.Tests.csproj b/csharp-language/modern-patterns-result-pipeline/tests/ResultPipelineLab.Tests/ResultPipelineLab.Tests.csproj new file mode 100644 index 0000000..866d19d --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/tests/ResultPipelineLab.Tests/ResultPipelineLab.Tests.csproj @@ -0,0 +1,47 @@ + + + + net10.0 + 12.0 + enable + enable + true + false + true + Exe + + + + + + + + + all + + runtime; + build; + native; + contentfiles; + analyzers; + buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/csharp-language/modern-patterns-result-pipeline/tests/ResultPipelineLab.Tests/ResultPipelineTests.cs b/csharp-language/modern-patterns-result-pipeline/tests/ResultPipelineLab.Tests/ResultPipelineTests.cs new file mode 100644 index 0000000..18a7d6b --- /dev/null +++ b/csharp-language/modern-patterns-result-pipeline/tests/ResultPipelineLab.Tests/ResultPipelineTests.cs @@ -0,0 +1,530 @@ +using ResultPipelineLab.Core; +using ResultPipelineLab.Models; +using ResultPipelineLab.Persistence; +using ResultPipelineLab.Pipeline; + +namespace ResultPipelineLab.Tests; + +public sealed class ResultPipelineTests +{ + private const string ValidInput = + """ + Name,Price,Category,Stock + widget pro,9.99,electronics,50 + travel mug,14.50,hardware,100 + """; + + [Fact] + public void + Map_and_bind_transform_success() + { + Result source = + Result.Ok( + " 42 "); + + Result result = + source + .Map( + value => + value.Trim()) + .Bind( + value => + int.TryParse( + value, + out int parsed) + ? Result + .Ok( + parsed) + : Result + .Fail( + PipelineError + .Parse( + "PARSE_INT", + "The value is not an integer."))); + + Result.Success success = + Assert.IsType< + Result.Success>( + result); + + Assert.Equal( + 42, + success.Value); + } + + [Fact] + public async Task + Failure_short_circuits_sync_and_async_steps() + { + bool mapCalled = + false; + + bool bindCalled = + false; + + bool asyncCalled = + false; + + Result failure = + Result.Fail( + PipelineError.Validation( + "INPUT_INVALID", + "The input is invalid.")); + + Result syncResult = + failure + .Map( + value => + { + mapCalled = + true; + + return value.Length; + }) + .Bind( + value => + { + bindCalled = + true; + + return Result + .Ok( + value); + }); + + Result asyncResult = + await syncResult + .BindAsync( + ( + value, + _) => + { + asyncCalled = + true; + + return Task.FromResult( + Result.Ok( + value)); + }, + + TestContext.Current + .CancellationToken); + + Assert.False( + mapCalled); + + Assert.False( + bindCalled); + + Assert.False( + asyncCalled); + + Result.Failure final = + Assert.IsType< + Result.Failure>( + asyncResult); + + Assert.Equal( + "INPUT_INVALID", + final.Error.Code); + } + + [Fact] + public void + Match_and_error_projections_separate_public_and_internal_data() + { + PipelineError error = + PipelineError.Validation( + "VALIDATE_BATCH_FAILED", + "One record failed validation.", + "Line 2: raw value was SECRET-123."); + + Result failure = + Result.Fail( + error); + + string matched = + failure.Match( + onSuccess: + value => + value.ToString(), + + onFailure: + current => + current.Code); + + PublicError publicError = + error.ToPublic(); + + DiagnosticError diagnostic = + error.ToDiagnostic( + "Validate"); + + Assert.Equal( + "VALIDATE_BATCH_FAILED", + matched); + + Assert.Equal( + "One record failed validation.", + publicError.Message); + + Assert.DoesNotContain( + "SECRET-123", + publicError.Message, + StringComparison.Ordinal); + + Assert.Contains( + "SECRET-123", + diagnostic.Detail, + StringComparison.Ordinal); + } + + [Fact] + public void + Parse_stage_accepts_restricted_rows() + { + Result result = + ParseStage.Parse( + ValidInput); + + Result.Success + success = + Assert.IsType< + Result + .Success>( + result); + + Assert.Collection( + success.Value, + first => + { + Assert.Equal( + 2, + first.LineNumber); + + Assert.Equal( + "widget pro", + first.Name); + }, + second => + { + Assert.Equal( + 3, + second.LineNumber); + + Assert.Equal( + "14.50", + second.PriceText); + }); + } + + [Fact] + public void + Parse_stage_rejects_quotes_and_malformed_rows() + { + const string quoted = + """ + Name,Price,Category,Stock + "Widget, Pro",9.99,Electronics,10 + """; + + Result.Failure + quoteFailure = + Assert.IsType< + Result + .Failure>( + ParseStage.Parse( + quoted)); + + Assert.Equal( + "PARSE_QUOTES_UNSUPPORTED", + quoteFailure.Error.Code); + + const string malformed = + """ + Name,Price,Category,Stock + Widget,9.99,Electronics + Travel Mug,14.50,Hardware + """; + + Result.Failure + rowFailure = + Assert.IsType< + Result + .Failure>( + ParseStage.Parse( + malformed)); + + Assert.Equal( + "PARSE_MALFORMED_ROWS", + rowFailure.Error.Code); + + Assert.Equal( + "2 row(s) could not be parsed.", + rowFailure.Error.Message); + + Assert.Contains( + "Line 2", + rowFailure.Error.Detail, + StringComparison.Ordinal); + + Assert.Contains( + "Line 3", + rowFailure.Error.Detail, + StringComparison.Ordinal); + } + + [Fact] + public void + Validation_collects_all_invalid_rows() + { + RawProductRow[] rows = + [ + new RawProductRow( + 2, + "", + "-5", + "Electronics", + "10"), + + new RawProductRow( + 3, + "Book", + "12.50", + "Unknown", + "-1"), + + new RawProductRow( + 4, + "Valid", + "9.99", + "Books", + "2") + ]; + + Result.Failure + failure = + Assert.IsType< + Result + .Failure>( + ValidateStage + .ValidateBatch( + rows)); + + Assert.Equal( + "VALIDATE_BATCH_FAILED", + failure.Error.Code); + + Assert.Equal( + "2 record(s) failed validation.", + failure.Error.Message); + + Assert.Contains( + "Line 2", + failure.Error.Detail, + StringComparison.Ordinal); + + Assert.Contains( + "Line 3", + failure.Error.Detail, + StringComparison.Ordinal); + + Assert.DoesNotContain( + "Line 4", + failure.Error.Detail, + StringComparison.Ordinal); + } + + [Fact] + public async Task + Pipeline_success_transforms_and_persists() + { + var store = + new InMemoryProductStore(); + + var pipeline = + new ImportPipeline( + store); + + Result result = + await pipeline.RunAsync( + ValidInput, + TestContext.Current + .CancellationToken); + + Result.Success + success = + Assert.IsType< + Result + .Success>( + result); + + Assert.Equal( + 2, + success.Value + .TotalInserted); + + Assert.Equal( + 1, + store.WriteAttempts); + + Assert.Collection( + store.Products, + first => + { + Assert.Equal( + "Widget Pro", + first.Name); + + Assert.Equal( + 9.99m, + first.Price); + + Assert.Equal( + "Electronics", + first.Category); + }, + second => + { + Assert.Equal( + "Travel Mug", + second.Name); + + Assert.Equal( + 100, + second.Stock); + }); + } + + [Fact] + public async Task + Parse_and_validation_failures_do_not_write() + { + var parseStore = + new InMemoryProductStore(); + + var parsePipeline = + new ImportPipeline( + parseStore); + + Result.Failure + parseFailure = + Assert.IsType< + Result + .Failure>( + await parsePipeline + .RunAsync( + "", + TestContext + .Current + .CancellationToken)); + + Assert.Equal( + "PARSE_EMPTY_INPUT", + parseFailure.Error.Code); + + Assert.Equal( + 0, + parseStore.WriteAttempts); + + var validationStore = + new InMemoryProductStore(); + + var validationPipeline = + new ImportPipeline( + validationStore); + + const string invalid = + """ + Name,Price,Category,Stock + Broken,-5,Electronics,1 + """; + + Result.Failure + validationFailure = + Assert.IsType< + Result + .Failure>( + await validationPipeline + .RunAsync( + invalid, + TestContext + .Current + .CancellationToken)); + + Assert.Equal( + "VALIDATE_BATCH_FAILED", + validationFailure + .Error.Code); + + Assert.Equal( + 0, + validationStore + .WriteAttempts); + } + + [Fact] + public async Task + Storage_failure_is_returned_as_data() + { + var store = + new InMemoryProductStore( + rejectWrites: + true); + + var pipeline = + new ImportPipeline( + store); + + Result.Failure + failure = + Assert.IsType< + Result + .Failure>( + await pipeline + .RunAsync( + ValidInput, + TestContext + .Current + .CancellationToken)); + + Assert.Equal( + "STORE_WRITE_REJECTED", + failure.Error.Code); + + Assert.Equal( + 1, + store.WriteAttempts); + + Assert.Empty( + store.Products); + } + + [Fact] + public async Task + Caller_cancellation_propagates_without_write() + { + var store = + new InMemoryProductStore(); + + var pipeline = + new ImportPipeline( + store); + + using var cancellation = + new CancellationTokenSource(); + + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync< + OperationCanceledException>( + () => + pipeline.RunAsync( + ValidInput, + cancellation.Token)); + + Assert.Equal( + 0, + store.WriteAttempts); + } +} \ No newline at end of file