2 Commits

Author SHA1 Message Date
b8ea74ec5b added missing Append extensions
All checks were successful
.NET Test / test (push) Successful in 3m18s
.NET Publish / publish (push) Successful in 3m28s
2023-12-12 19:02:11 +04:00
9ae185342b made Error fully immutable
All checks were successful
.NET Test / test (push) Successful in 4m16s
2023-12-12 18:55:53 +04:00
8 changed files with 332 additions and 125 deletions

106
README.md
View File

@@ -4,6 +4,108 @@ This library uses features of C# to achieve railway-oriented programming.
The desire is to make somewhat user-friendly experience while using result-object pattern.
## Contents
## Features
_Coming soon..._
- Immutable ```Error``` class
- ```Result``` object
- A bunch of extensions to use result-object pattern with
- ```Try``` extensions to wrap function calls with result-object
- ```Ensure``` extensions to utilize result-object in validation scenarios
## Getting Started
### Install from local Gitea package repository
```sh
# Setup NuGet registry from the command line
dotnet nuget add source --name gitea_jstdev https://gitea.jstdev.ru/api/packages/just/nuget/index.json
# then install the package using NuGet
dotnet add package --source gitea_jstdev Just.Railway
```
## Examples
### Error
```csharp
using Just.Railway;
Error expectedError = Error.New(type: "Some Error", message: "Some error detail");
Error exceptionalError = Error.New(new Exception("Some Exception"));
Error manyErrors = Error.Many(expectedError, exceptionalError);
// the same result while using .Append(..) or +
manyErrors = expectedError.Append(exceptionalError);
manyErrors = expectedError + exceptionalError;
```
> **Note**
> You can easily serialize/deserialize Error to and from JSON
### Result
#### As return value:
```csharp
Result Foo()
{
// ...
if (SomeCondition())
return Result.Failure(Error.New("Some Error"));
// or just: return Error.New("Some Error");
// ...
return Result.Success();
}
Result<T> Bar()
{
T value;
// ...
if (SomeCondition())
return Error.New("Some Error");
// ...
return value;
}
```
#### Consume Result object
```csharp
Result<int> result = GetResult();
var value = result
.Append("new")
.Map((i, s) => $"{s} result {i}")
.Match(
onSuccess: x => x,
onFailure: err => err.ToString()
);
// value: "new result 1"
Result<int> GetResult() => Result.Success(1);
```
### Try
```csharp
Result result = Try.Run(SomeAction);
// you can pass up to 5 arguments like this
result = Try.Run(SomeActionWithArguments, 1, 2.0, "3");
// you also can call functions
Result<int> resultWithValue = Try.Run(SomeFunction);
void SomeAction() {}
void SomeActionWithArguments(int a1, double a2, string? a3) {}
int SomeFunction() => 1;
```
### Ensure
```csharp
var value = GetValue();
Result<int> result = Ensure.That(value)
.NotNull()
.Satisfies(i => i < 100)
.Result();
int? GetValue() => 1;
```

View File

@@ -7,7 +7,7 @@ using Microsoft.CodeAnalysis;
namespace Just.Railway.SourceGen;
public sealed class EnsureExtensionExecutor : IGeneratorExecutor
public sealed class EnsureExtensionsExecutor : IGeneratorExecutor
{
public void Execute(SourceProductionContext context, Compilation source)
{
@@ -80,7 +80,7 @@ public sealed class EnsureExtensionExecutor : IGeneratorExecutor
typeOverloads.ForEach(def => sb.AppendLine($$"""
[PureAttribute]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionExecutor)}}", "1.0.0.0")]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionsExecutor)}}", "1.0.0.0")]
public static Ensure<{{def.CollectionType}}> NotEmpty{{def.TemplateDef}}(this in Ensure<{{def.CollectionType}}> ensure, {{errorParameterDecl}})
{
return ensure.State switch
@@ -101,7 +101,7 @@ public sealed class EnsureExtensionExecutor : IGeneratorExecutor
sb.AppendLine($$"""
[PureAttribute]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionExecutor)}}", "1.0.0.0")]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionsExecutor)}}", "1.0.0.0")]
public static Ensure<T> NotNull<T>(this in Ensure<T?> ensure, {{errorParameterDecl}})
where T : struct
{
@@ -117,7 +117,7 @@ public sealed class EnsureExtensionExecutor : IGeneratorExecutor
""");
sb.AppendLine($$"""
[PureAttribute]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionExecutor)}}", "1.0.0.0")]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionsExecutor)}}", "1.0.0.0")]
public static Ensure<T> NotNull<T>(this in Ensure<T?> ensure, {{errorParameterDecl}})
where T : notnull
{
@@ -138,7 +138,7 @@ public sealed class EnsureExtensionExecutor : IGeneratorExecutor
string defaultErrorExpr = "?? Error.New(DefaultErrorType, $\"Value {{{ensure.ValueExpression}}} does not satisfy the requirement.\")";
sb.AppendLine($$"""
[PureAttribute]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionExecutor)}}", "1.0.0.0")]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionsExecutor)}}", "1.0.0.0")]
public static Ensure<T> Satisfies<T>(this in Ensure<T> ensure, Func<T, bool> requirement, {{errorParameterDecl}})
{
return ensure.State switch
@@ -160,7 +160,7 @@ public sealed class EnsureExtensionExecutor : IGeneratorExecutor
{
sb.AppendLine($$"""
[PureAttribute]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionExecutor)}}", "1.0.0.0")]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionsExecutor)}}", "1.0.0.0")]
public static async {{taskType}}<Ensure<T>> Satisfies<T>(this {{taskType}}<Ensure<T>> ensureTask, Func<T, bool> requirement, {{errorParameterDecl}})
{
var ensure = await ensureTask.ConfigureAwait(false);
@@ -176,7 +176,7 @@ public sealed class EnsureExtensionExecutor : IGeneratorExecutor
""");
sb.AppendLine($$"""
[PureAttribute]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionExecutor)}}", "1.0.0.0")]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionsExecutor)}}", "1.0.0.0")]
public static async {{taskType}}<Ensure<T>> Satisfies<T>(this Ensure<T> ensure, Func<T, {{taskType}}<bool>> requirement, {{errorParameterDecl}})
{
return ensure.State switch
@@ -191,7 +191,7 @@ public sealed class EnsureExtensionExecutor : IGeneratorExecutor
""");
sb.AppendLine($$"""
[PureAttribute]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionExecutor)}}", "1.0.0.0")]
[GeneratedCodeAttribute("{{nameof(EnsureExtensionsExecutor)}}", "1.0.0.0")]
public static async {{taskType}}<Ensure<T>> Satisfies<T>(this {{taskType}}<Ensure<T>> ensureTask, Func<T, {{taskType}}<bool>> requirement, {{errorParameterDecl}})
{
var ensure = await ensureTask.ConfigureAwait(false);

View File

@@ -19,7 +19,7 @@ public class ExtensionsMethodGenerator : IIncrementalGenerator
new ResultTapExecutor(),
new ResultAppendExecutor(),
new TryExtensionsExecutor(),
new EnsureExtensionExecutor(),
new EnsureExtensionsExecutor(),
};
public void Initialize(IncrementalGeneratorInitializationContext context)

View File

@@ -200,6 +200,48 @@ internal sealed class ResultAppendExecutor : ResultExtensionsExecutor
string resultExpandedTypeDef = GenerateResultTypeDef(expandedTemplateArgNames);
string methodExpandedTemplateDecl = GenerateTemplateDecl(expandedTemplateArgNames);
sb.AppendLine($$"""
[PureAttribute]
[GeneratedCodeAttribute("{{nameof(ResultAppendExecutor)}}", "1.0.0.0")]
public static async {{taskType}}<{{resultExpandedTypeDef}}> Append{{methodExpandedTemplateDecl}}(this {{taskType}}<{{resultTypeDef}}> resultTask, TNext next)
{
var result = await resultTask.ConfigureAwait(false);
return result.State switch
{
ResultState.Success => Result.Success({{JoinArguments(resultValueExpansion, "next")}}),
ResultState.Error => result.Error!,
_ => throw new ResultNotInitializedException(nameof(result))
};
}
""");
sb.AppendLine($$"""
[PureAttribute]
[GeneratedCodeAttribute("{{nameof(ResultAppendExecutor)}}", "1.0.0.0")]
public static async {{taskType}}<{{resultExpandedTypeDef}}> Append{{methodExpandedTemplateDecl}}(this {{taskType}}<{{resultTypeDef}}> resultTask, Result<TNext> next)
{
var result = await resultTask.ConfigureAwait(false);
if ((result.State & next.State) == ResultState.Bottom)
{
throw new ResultNotInitializedException(string.Join(';', GetBottom(result.State, next.State)));
}
Error? error = null;
if (result.IsFailure)
{
error += result.Error;
}
if (next.IsFailure)
{
error += next.Error;
}
return error is null
? Result.Success({{JoinArguments(resultValueExpansion, "next.Value")}})
: error;
}
""");
sb.AppendLine($$"""
[PureAttribute]
[GeneratedCodeAttribute("{{nameof(ResultAppendExecutor)}}", "1.0.0.0")]

View File

@@ -1,4 +1,5 @@
using System.Collections;
using System.Collections.Immutable;
using System.Runtime.Serialization;
using System.Text;
@@ -10,8 +11,6 @@ namespace Just.Railway;
[JsonDerivedType(typeof(ManyErrors))]
public abstract class Error : IEquatable<Error>, IComparable<Error>
{
private IDictionary<string, object>? _extensionData;
protected internal Error(){}
/// <summary>
@@ -77,28 +76,8 @@ public abstract class Error : IEquatable<Error>, IComparable<Error>
[Pure] public abstract string Type { get; }
[Pure] public abstract string Message { get; }
[Pure, JsonExtensionData] public IDictionary<string, object> ExtensionData
{
get => _extensionData ??= new Dictionary<string, object>();
init => _extensionData = value ?? new Dictionary<string, object>();
}
[Pure] public object? this[string name]
{
get => _extensionData?.TryGetValue(name, out var val) == true ? val : null;
set
{
if (value is null)
{
_extensionData?.Remove(name);
}
else
{
_extensionData ??= new Dictionary<string, object>();
_extensionData[name] = value;
}
}
}
[Pure, JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ImmutableDictionary<string, string>? ExtensionData { get; init; }
[Pure] public string? this[string key] => ExtensionData?.TryGetValue(key, out var value) == true ? value : null;
[Pure, JsonIgnore] public abstract int Count { get; }
[Pure, JsonIgnore] public abstract bool IsEmpty { get; }
@@ -199,13 +178,13 @@ public sealed class ExceptionalError : Error
: this(exception.GetType().Name, exception.Message)
{
Exception = exception;
FillExtensionData(exception);
ExtensionData = ExtractExtensionData(exception);
}
internal ExceptionalError(string message, Exception exception)
: this(exception.GetType().Name, message)
{
Exception = exception;
FillExtensionData(exception);
ExtensionData = ExtractExtensionData(exception);
}
[JsonConstructor]
@@ -231,15 +210,28 @@ public sealed class ExceptionalError : Error
yield return this;
}
private void FillExtensionData(Exception exception)
private static ImmutableDictionary<string, string>? ExtractExtensionData(Exception exception)
{
if (!(exception.Data?.Count > 0))
return null;
List<KeyValuePair<string, string>>? values = null;
foreach (var key in exception.Data.Keys)
{
if (key is null) continue;
var value = exception.Data[key];
if (key is null || value is null)
continue;
this.ExtensionData[key.ToString() ?? string.Empty] = value;
if (value is null) continue;
var keyString = key.ToString();
var valueString = value.ToString();
if (string.IsNullOrEmpty(keyString) || string.IsNullOrEmpty(valueString)) continue;
values ??= [];
values.Add(new(keyString, valueString));
}
return values?.ToImmutableDictionary();
}
}

View File

@@ -1,3 +1,5 @@
using System.Collections.Immutable;
namespace Railway.Tests.Errors;
public class Serialization
@@ -8,9 +10,8 @@ public class Serialization
// Given
Error many_errors = new ManyErrors(new Error[]{
new ExpectedError("err1", "msg1"){
ExtensionData = {
["ext"] = "ext_value"
}
ExtensionData = ImmutableDictionary<string, string>.Empty
.Add("ext", "ext_value"),
},
new ExceptionalError(new Exception("msg2")),
});
@@ -18,7 +19,7 @@ public class Serialization
var result = JsonSerializer.Serialize(many_errors);
// Then
Assert.Equal(
expected: "[{\"$$err\":0,\"Type\":\"err1\",\"Message\":\"msg1\",\"ext\":\"ext_value\"},{\"$$err\":1,\"Type\":\"Exception\",\"Message\":\"msg2\"}]",
expected: "[{\"$$err\":0,\"Type\":\"err1\",\"Message\":\"msg1\",\"ExtensionData\":{\"ext\":\"ext_value\"}},{\"$$err\":1,\"Type\":\"Exception\",\"Message\":\"msg2\"}]",
result);
}
@@ -26,7 +27,7 @@ public class Serialization
public void WhenDeserializingManyErrors()
{
// Given
var json = "[{\"$$err\":0,\"Type\":\"err1\",\"Message\":\"msg1\",\"ext\":\"ext_value\"},{\"$$err\":1,\"Type\":\"Exception\",\"Message\":\"msg2\"}]";
var json = "[{\"$$err\":0,\"Type\":\"err1\",\"Message\":\"msg1\",\"ExtensionData\":{\"ext1\":\"ext_value1\",\"ext2\":\"ext_value2\"}},{\"$$err\":1,\"Type\":\"Exception\",\"Message\":\"msg2\"}]";
// When
var result = JsonSerializer.Deserialize<Error[]>(json);
// Then
@@ -39,7 +40,10 @@ public class Serialization
result
);
Assert.Equal(
expected: "ext_value",
result[0].ExtensionData["ext"].ToString());
expected: "ext_value1",
result[0]["ext1"]);
Assert.Equal(
expected: "ext_value2",
result[0]["ext2"]);
}
}

View File

@@ -0,0 +1,78 @@
namespace Raliway.Tests.Results;
public class Combine
{
[Fact]
public void TwoResultCombination_WhenThereIsAnError()
{
// Given
var result1 = Result.Success(1);
var result2 = Result.Failure(Error.New("some error"));
// When
var result = Result.Combine(result1, result2);
// Then
Assert.True(result.IsFailure);
Assert.Equal(result2.Error, result.Error);
}
[Fact]
public void TwoResultCombination_WhenThereAreTwoErrors()
{
// Given
var result1 = Result.Failure<byte>(Error.New("1"));
var result2 = Result.Failure(Error.New("2"));
// When
var result = Result.Combine(result1, result2);
// Then
Assert.True(result.IsFailure);
Assert.Equal(result1.Error + result2.Error, result.Error);
}
[Fact]
public void TwoResultCombination_WhenThereIsNoError()
{
// Given
var result1 = Result.Success(1);
var result2 = Result.Success(3.14);
// When
var result = Result.Combine(result1, result2);
// Then
Assert.True(result.IsSuccess);
}
[Fact]
public void ThreeResultCombination_WhenThereIsAnError()
{
// Given
var result1 = Result.Success(1);
var result2 = Result.Success(3.14);
var result3 = Result.Failure(Error.New("some error"));
// When
Result<(int, double)> result = Result.Combine(result1, result2, result3);
// Then
Assert.True(result.IsFailure);
Assert.Equal(result3.Error, result.Error);
}
[Fact]
public void ThreeResultCombination_WhenThereAreTwoErrors()
{
// Given
var result1 = Result.Failure<int?>(Error.New("1"));
var result2 = Result.Success(3.14);
var result3 = Result.Failure(Error.New("3"));
// When
Result<(int?, double)> result = Result.Combine(result1, result2, result3);
// Then
Assert.True(result.IsFailure);
Assert.Equal(result1.Error + result3.Error, result.Error);
}
[Fact]
public void ThreeResultCombination_WhenThereIsNoError()
{
// Given
var result1 = Result.Success(1);
var result2 = Result.Success(3.14);
var result3 = Result.Success();
// When
var result = Result.Combine(result1, result2, result3);
// Then
Assert.True(result.IsSuccess);
}
}

View File

@@ -2,80 +2,6 @@ namespace Raliway.Tests.Results;
public class GeneralUsage
{
[Fact]
public void TwoResultCombination_WhenThereIsAnError()
{
// Given
var result1 = Result.Success(1);
var result2 = Result.Failure(Error.New("some error"));
// When
var result = Result.Combine(result1, result2);
// Then
Assert.True(result.IsFailure);
Assert.Equal(result2.Error, result.Error);
}
[Fact]
public void TwoResultCombination_WhenThereAreTwoErrors()
{
// Given
var result1 = Result.Failure<byte>(Error.New("1"));
var result2 = Result.Failure(Error.New("2"));
// When
var result = Result.Combine(result1, result2);
// Then
Assert.True(result.IsFailure);
Assert.Equal(result1.Error + result2.Error, result.Error);
}
[Fact]
public void TwoResultCombination_WhenThereIsNoError()
{
// Given
var result1 = Result.Success(1);
var result2 = Result.Success(3.14);
// When
var result = Result.Combine(result1, result2);
// Then
Assert.True(result.IsSuccess);
}
[Fact]
public void ThreeResultCombination_WhenThereIsAnError()
{
// Given
var result1 = Result.Success(1);
var result2 = Result.Success(3.14);
var result3 = Result.Failure(Error.New("some error"));
// When
Result<(int, double)> result = Result.Combine(result1, result2, result3);
// Then
Assert.True(result.IsFailure);
Assert.Equal(result3.Error, result.Error);
}
[Fact]
public void ThreeResultCombination_WhenThereAreTwoErrors()
{
// Given
var result1 = Result.Failure<int?>(Error.New("1"));
var result2 = Result.Success(3.14);
var result3 = Result.Failure(Error.New("3"));
// When
Result<(int?, double)> result = Result.Combine(result1, result2, result3);
// Then
Assert.True(result.IsFailure);
Assert.Equal(result1.Error + result3.Error, result.Error);
}
[Fact]
public void ThreeResultCombination_WhenThereIsNoError()
{
// Given
var result1 = Result.Success(1);
var result2 = Result.Success(3.14);
var result3 = Result.Success();
// When
var result = Result.Combine(result1, result2, result3);
// Then
Assert.True(result.IsSuccess);
}
[Fact]
public void ChainedResultExtensions_WhenThereIsNoError()
{
@@ -104,10 +30,9 @@ public class GeneralUsage
public void ChainedResultExtensions_WhenThereIsAnError()
{
// Given
// When
var error = Error.New("test");
// When
var result = Result.Success()
.Append(() => Result.Failure<int>(error))
@@ -115,7 +40,7 @@ public class GeneralUsage
.Map((i, s) =>
{
Assert.Fail();
return Result.Success("");
return "";
})
.Append("some")
.Bind((s1, s2) =>
@@ -139,4 +64,68 @@ public class GeneralUsage
// Then
Assert.Equal("satisfied", result);
}
[Fact]
public async Task ChainedResultAsyncExtensions_WhenThereIsNoError()
{
// Given
// When
var result = await Result.Success()
.Append(() => ValueTask.FromResult(Result.Success(1)))
.Append("test")
.Map((i, s) => $"{s}_{i}")
.Append("some")
.Bind(async (s1, s2) => await ValueTask.FromResult(Result.Success(string.Join(';', s1, s2))))
.Match(
onSuccess: s => s.ToUpper(),
onFailure: _ =>
{
Assert.Fail();
return "";
}
);
Assert.Equal("TEST_1;SOME", result);
}
[Fact]
public async Task ChainedResultAsyncExtensions_WhenThereIsAnError()
{
// Given
var error = Error.New("test");
// When
var result = await Result.Success()
.Append(() => Task.FromResult(Result.Failure<int>(error)))
.Append("test")
.Map((i, s) =>
{
Assert.Fail();
return "";
})
.Append("some")
.Bind(async (s1, s2) =>
{
Assert.Fail();
await Task.CompletedTask;
return Result.Success("");
})
.Match(
onSuccess: _ =>
{
Assert.Fail();
return "";
},
onFailure: err =>
{
Assert.Equal(error, err);
return "satisfied";
}
);
// Then
Assert.Equal("satisfied", result);
}
}