Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public class PublishModule : Module<None>
protected override async Task<None> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
var buildResult = await context.GetModule<BuildModule>();
var outputPath = buildResult.ValueOrDefault!.OutputPath; // Strongly-typed, compile-time checked
var outputPath = buildResult.Value.OutputPath; // Throws with module context if unavailable
Comment thread
thomhurst marked this conversation as resolved.
// Publish using the build output...
return None.Value;
}
Expand Down
2 changes: 1 addition & 1 deletion README_Template.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public class PublishModule : Module<None>
protected override async Task<None> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
{
var buildResult = await context.GetModule<BuildModule>();
var outputPath = buildResult.ValueOrDefault!.OutputPath; // Strongly-typed, compile-time checked
var outputPath = buildResult.Value.OutputPath; // Throws with module context if unavailable
// Publish using the build output...
return None.Value;
}
Expand Down
20 changes: 13 additions & 7 deletions docs/docs/fundamentals.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,22 @@ Modules are strongly typed, so we can return clear, concrete objects, and other
// Get a module's result
var myModule = await context.GetModule<MyFirstModule>();

// Access the value using pattern matching (recommended)
if (myModule is ModuleResult<MyFirstModuleResult>.Success { Value: var result })
// Access a required dependency value directly.
// This throws with module context if it failed, was skipped, or returned null.
var requiredValue = myModule.Value;
var firstString = requiredValue.MyFirstString;
var secondString = requiredValue.MySecondString;

// Or use pattern matching when every outcome needs different handling
if (myModule is ModuleResult<MyFirstModuleResult>.Success { Value: var successfulValue })
{
var string1 = result.MyFirstString;
var string2 = result.MySecondString;
Console.WriteLine(successfulValue.MyFirstString);
Console.WriteLine(successfulValue.MySecondString);
}

// Or use ValueOrDefault for simpler access
var string1 = myModule.ValueOrDefault?.MyFirstString;
var string2 = myModule.ValueOrDefault?.MySecondString;
// ValueOrDefault remains available when missing data is expected
var optionalFirstString = myModule.ValueOrDefault?.MyFirstString;
var optionalSecondString = myModule.ValueOrDefault?.MySecondString;
```

## Custom Types
Expand Down
18 changes: 15 additions & 3 deletions docs/docs/how-to/sharing-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ public class DeployModule : Module<DeployResult>
// Get the build module's result
var buildResult = await context.GetModule<BuildModule>();

// Access the value safely
var artifact = buildResult.ValueOrDefault?.ArtifactPath;
// Access the required dependency value. This throws with module context
// if the module failed, was skipped, or returned null.
var artifact = buildResult.Value.ArtifactPath;

return await Deploy(artifact);
}
Expand Down Expand Up @@ -63,9 +64,20 @@ return result.Match(
);
```

## Accessing Required Values

Use `Value` when the dependency must have produced a non-null value. It returns `T`
without a null-forgiveness operator. If the module failed, was skipped, or returned
`null`, it throws an `InvalidOperationException` that identifies the module and outcome:

```csharp
var result = await context.GetModule<MyModule>();
var value = result.Value;
```

## Safe Accessors

For simpler checks, inspect the union through its safe accessors:
When an absent value is expected, inspect the union through its non-throwing accessors:

```csharp
var result = await context.GetModule<MyModule>();
Expand Down
79 changes: 64 additions & 15 deletions src/ModularPipelines/Models/ModuleResult.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using ModularPipelines.Engine;
Expand Down Expand Up @@ -218,6 +219,25 @@ private protected ModuleResult()
[JsonConverter(typeof(ModuleResultJsonConverterFactory))]
public abstract record ModuleResult<T> : ModuleResult
{
/// <summary>
/// Gets the successful non-null value.
/// </summary>
/// <exception cref="InvalidOperationException">
/// The module failed, was skipped, or succeeded with a <see langword="null"/> value.
/// </exception>
[JsonIgnore]
public T Value => this switch
Comment thread
thomhurst marked this conversation as resolved.
{
Success { Value: not null } success => success.Value,
Success => throw new InvalidOperationException($"{ModuleName} succeeded but returned null"),
Failure failure => throw new InvalidOperationException(
$"{ModuleName} failed: {failure.Exception.Message}",
failure.Exception),
Skipped skipped => throw new InvalidOperationException(
$"{ModuleName} was skipped: {skipped.Decision.Reason ?? "No reason was provided"}"),
_ => throw new InvalidOperationException($"{ModuleName} has an unknown result type"),
};

// === Safe accessors (no exceptions) ===

/// <summary>
Expand Down Expand Up @@ -298,9 +318,39 @@ public void Switch(
/// <summary>
/// Represents a successful module execution with a value.
/// </summary>
/// <param name="Value">The value produced by the module, which may be <c>null</c>.</param>
[JsonConverter(typeof(ModuleResultJsonConverterFactory))]
public sealed record Success(T? Value) : ModuleResult<T>;
public sealed record Success : ModuleResult<T>
{
#pragma warning disable SA1313 // Preserve the public parameter names generated by the former positional record.
/// <summary>
/// Initialises a new instance of the <see cref="Success"/> class.
/// </summary>
/// <param name="Value">The value produced by the module, which may be <c>null</c>.</param>
public Success(T? Value)
{
this.Value = Value;
}

/// <summary>
/// Gets the value produced by the module, which may be <c>null</c>.
/// </summary>
/// <remarks>
/// This property intentionally hides the required <see cref="ModuleResult{T}.Value"/>
/// accessor to preserve the nullable value carried by a known successful result.
/// Access through <see cref="ModuleResult{T}"/> uses the required accessor instead.
/// </remarks>
public new T? Value { get; init; }

/// <summary>
/// Deconstructs the result into its successful value.
/// </summary>
/// <param name="Value">The value produced by the module, which may be <c>null</c>.</param>
public void Deconstruct(out T? Value)
{
Value = this.Value;
}
#pragma warning restore SA1313
}

/// <summary>
/// Represents a failed module execution with an exception.
Expand Down Expand Up @@ -407,6 +457,9 @@ internal static Success CreateSuccess(T? value, ModuleExecutionContext ctx)
/// <inheritdoc />
protected override object? GetValueOrDefault() => ValueOrDefault;

/// <inheritdoc />
protected override bool PrintMembers(StringBuilder builder) => base.PrintMembers(builder);

// Prevent external inheritance - only Success, Failure, and Skipped are valid
private protected ModuleResult()
{
Expand Down Expand Up @@ -455,8 +508,6 @@ internal sealed class ExceptionJsonConverter : JsonConverter<Exception>

string? typeName = null;
string? message = null;
string? stackTrace = null;

while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
Expand All @@ -478,7 +529,6 @@ internal sealed class ExceptionJsonConverter : JsonConverter<Exception>
message = reader.GetString();
break;
case "StackTrace":
stackTrace = reader.GetString();
break;
}
}
Expand All @@ -495,8 +545,7 @@ internal sealed class ExceptionJsonConverter : JsonConverter<Exception>
{
try
{
var ex = Activator.CreateInstance(exceptionType, message) as Exception;
if (ex != null)
if (Activator.CreateInstance(exceptionType, message) is Exception ex)
{
return ex;
}
Expand Down Expand Up @@ -618,10 +667,10 @@ internal sealed class ModuleResultNonGenericJsonConverter : JsonConverter<Module
string? discriminator = null;
string? moduleName = null;
string? moduleTypeName = null;
TimeSpan moduleDuration = TimeSpan.Zero;
DateTimeOffset moduleStart = DateTimeOffset.MinValue;
DateTimeOffset moduleEnd = DateTimeOffset.MinValue;
Status moduleStatus = Status.NotYetStarted;
var moduleDuration = TimeSpan.Zero;
var moduleStart = DateTimeOffset.MinValue;
var moduleEnd = DateTimeOffset.MinValue;
var moduleStatus = Status.NotYetStarted;
Exception? exception = null;
SkipDecision? skipDecision = null;

Expand Down Expand Up @@ -779,10 +828,10 @@ internal sealed class ModuleResultJsonConverter<T> : JsonConverter<ModuleResult<
string? discriminator = null;
string? moduleName = null;
string? moduleTypeName = null;
TimeSpan moduleDuration = TimeSpan.Zero;
DateTimeOffset moduleStart = DateTimeOffset.MinValue;
DateTimeOffset moduleEnd = DateTimeOffset.MinValue;
Status moduleStatus = Status.NotYetStarted;
var moduleDuration = TimeSpan.Zero;
var moduleStart = DateTimeOffset.MinValue;
var moduleEnd = DateTimeOffset.MinValue;
var moduleStatus = Status.NotYetStarted;
T? value = default;
Exception? exception = null;
SkipDecision? skipDecision = null;
Expand Down
Loading
Loading