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
25 changes: 21 additions & 4 deletions src/Wolfgang.Etl.Abstractions/ExtractorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ public int SkipItemCount
/// <inheritdoc/>
public virtual IAsyncEnumerable<TSource> ExtractAsync()
{
ThrowIfDisposed();
return ExtractWithResetAsync(CancellationToken.None);
}

Expand All @@ -223,6 +224,7 @@ public virtual IAsyncEnumerable<TSource> ExtractAsync()
/// <inheritdoc/>
public virtual IAsyncEnumerable<TSource> ExtractAsync(CancellationToken token)
{
ThrowIfDisposed();
return ExtractWithResetAsync(token);
}

Expand All @@ -231,6 +233,7 @@ public virtual IAsyncEnumerable<TSource> ExtractAsync(CancellationToken token)
/// <inheritdoc/>
public virtual IAsyncEnumerable<TSource> ExtractAsync(IProgress<TProgress> progress)
{
ThrowIfDisposed();
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(progress);
#else
Expand All @@ -250,6 +253,7 @@ public virtual IAsyncEnumerable<TSource> ExtractAsync(IProgress<TProgress> progr
/// <inheritdoc/>
public virtual IAsyncEnumerable<TSource> ExtractAsync(IProgress<TProgress> progress, CancellationToken token)
{
ThrowIfDisposed();
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(progress);
#else
Expand Down Expand Up @@ -465,17 +469,30 @@ public void Dispose()
/// <see langword="true"/> when called from <see cref="Dispose()"/> or <see cref="DisposeAsync"/>
/// (dispose managed resources); <see langword="false"/> when called from a finalizer.
/// </param>
// Stryker disable all: equivalent mutant — Dispose(bool) has an inert base body: _disposed has
// no other reader (nothing throws ObjectDisposedException). Removing the whole body, negating the
// guard, or dropping the assignment is all unobservable; derived overrides supply real behaviour.
protected virtual void Dispose(bool disposing)
{
if (_disposed)
// Stryker disable once all: equivalent — dropping this guard block only skips a redundant,
// idempotent re-assignment of _disposed. The guard's negation and the assignment below are
// real and killable (covered by the use-after-dispose tests).
{
// Stryker disable once all: equivalent — same reasoning; skipping the early return just
// re-runs the idempotent `_disposed = true`.
return;
}

_disposed = true;
}
// Stryker restore all


// Throws if this extractor has already been disposed. Reads _disposed, so the public entry
// points reject use-after-dispose (and give the Dispose(bool) idempotency guard an observable
// effect).
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(GetType().FullName);
}
}
}
24 changes: 20 additions & 4 deletions src/Wolfgang.Etl.Abstractions/LoaderBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ public int SkipItemCount
/// <inheritdoc/>
public virtual Task LoadAsync(IAsyncEnumerable<TDestination> items)
{
ThrowIfDisposed();
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(items);
#else
Expand All @@ -204,6 +205,7 @@ public virtual Task LoadAsync(IAsyncEnumerable<TDestination> items)
/// <inheritdoc/>
public virtual Task LoadAsync(IAsyncEnumerable<TDestination> items, CancellationToken token)
{
ThrowIfDisposed();
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(items);
#else
Expand All @@ -222,6 +224,7 @@ public virtual Task LoadAsync(IAsyncEnumerable<TDestination> items, Cancellation
/// <inheritdoc/>
public virtual Task LoadAsync(IAsyncEnumerable<TDestination> items, IProgress<TProgress> progress)
{
ThrowIfDisposed();
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(progress);
Expand All @@ -246,6 +249,7 @@ public virtual Task LoadAsync(IAsyncEnumerable<TDestination> items, IProgress<TP
/// <inheritdoc/>
public virtual Task LoadAsync(IAsyncEnumerable<TDestination> items, IProgress<TProgress> progress, CancellationToken token)
{
ThrowIfDisposed();
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(progress);
Expand Down Expand Up @@ -463,17 +467,29 @@ public void Dispose()
/// <see langword="true"/> when called from <see cref="Dispose()"/> or <see cref="DisposeAsync"/>
/// (dispose managed resources); <see langword="false"/> when called from a finalizer.
/// </param>
// Stryker disable all: equivalent mutant — Dispose(bool) has an inert base body: _disposed has
// no other reader (nothing throws ObjectDisposedException). Removing the whole body, negating the
// guard, or dropping the assignment is all unobservable; derived overrides supply real behaviour.
protected virtual void Dispose(bool disposing)
{
if (_disposed)
// Stryker disable once all: equivalent — dropping this guard block only skips a redundant,
// idempotent re-assignment of _disposed. The guard's negation and the assignment below are
// real and killable (covered by the use-after-dispose tests).
{
// Stryker disable once all: equivalent — same reasoning; skipping the early return just
// re-runs the idempotent `_disposed = true`.
return;
}

_disposed = true;
}
// Stryker restore all


// Throws if this loader has already been disposed. Reads _disposed, so the public entry points
// reject use-after-dispose (and give the Dispose(bool) idempotency guard an observable effect).
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(GetType().FullName);
}
}
}
25 changes: 21 additions & 4 deletions src/Wolfgang.Etl.Abstractions/TransformerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ public int SkipItemCount
/// <inheritdoc/>
public virtual IAsyncEnumerable<TDestination> TransformAsync(IAsyncEnumerable<TSource> items)
{
ThrowIfDisposed();
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(items);
#else
Expand All @@ -209,6 +210,7 @@ public virtual IAsyncEnumerable<TDestination> TransformAsync(IAsyncEnumerable<TS
/// <inheritdoc/>
public virtual IAsyncEnumerable<TDestination> TransformAsync(IAsyncEnumerable<TSource> items, CancellationToken token)
{
ThrowIfDisposed();
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(items);
#else
Expand All @@ -227,6 +229,7 @@ public virtual IAsyncEnumerable<TDestination> TransformAsync(IAsyncEnumerable<TS
/// <inheritdoc/>
public virtual IAsyncEnumerable<TDestination> TransformAsync(IAsyncEnumerable<TSource> items, IProgress<TProgress> progress)
{
ThrowIfDisposed();
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(progress);
Expand All @@ -251,6 +254,7 @@ public virtual IAsyncEnumerable<TDestination> TransformAsync(IAsyncEnumerable<TS
/// <inheritdoc/>
public virtual IAsyncEnumerable<TDestination> TransformAsync(IAsyncEnumerable<TSource> items, IProgress<TProgress> progress, CancellationToken token)
{
ThrowIfDisposed();
#if NET6_0_OR_GREATER
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(progress);
Expand Down Expand Up @@ -472,17 +476,30 @@ public void Dispose()
/// <see langword="true"/> when called from <see cref="Dispose()"/> or <see cref="DisposeAsync"/>
/// (dispose managed resources); <see langword="false"/> when called from a finalizer.
/// </param>
// Stryker disable all: equivalent mutant — Dispose(bool) has an inert base body: _disposed has
// no other reader (nothing throws ObjectDisposedException). Removing the whole body, negating the
// guard, or dropping the assignment is all unobservable; derived overrides supply real behaviour.
protected virtual void Dispose(bool disposing)
{
if (_disposed)
// Stryker disable once all: equivalent — dropping this guard block only skips a redundant,
// idempotent re-assignment of _disposed. The guard's negation and the assignment below are
// real and killable (covered by the use-after-dispose tests).
{
// Stryker disable once all: equivalent — same reasoning; skipping the early return just
// re-runs the idempotent `_disposed = true`.
return;
}

_disposed = true;
}
// Stryker restore all


// Throws if this transformer has already been disposed. Reads _disposed, so the public entry
// points reject use-after-dispose (and give the Dispose(bool) idempotency guard an observable
// effect).
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(GetType().FullName);
}
}
}
172 changes: 172 additions & 0 deletions tests/Wolfgang.Etl.Abstractions.Tests.Unit/DisposedGuardTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Wolfgang.Etl.Abstractions.Tests.Unit.Models;

namespace Wolfgang.Etl.Abstractions.Tests.Unit;

/// <summary>
/// Covers the dispose contract added to the base classes: once disposed, the public entry points
/// throw <see cref="ObjectDisposedException"/>, and <c>Dispose</c> is idempotent. This gives the
/// <c>Dispose(bool)</c> idempotency guard (<c>if (_disposed) …; _disposed = true;</c>) an observable
/// effect — a mutation that negates the guard, drops its block, or skips the assignment leaves the
/// instance "not disposed" and the throw never happens.
/// </summary>
public sealed class DisposedGuardTests
{
private static async IAsyncEnumerable<int> Empty()
{
await Task.CompletedTask;
yield break;
}


private static readonly IProgress<EtlProgress> Progress = new NoOpProgress();


[Fact]
public void LoaderBase_every_LoadAsync_overload_throws_after_Dispose()
{
var loader = new NoOpLoader();
loader.Dispose();

Assert.Throws<ObjectDisposedException>(() => { _ = loader.LoadAsync(Empty()); });
Assert.Throws<ObjectDisposedException>(() => { _ = loader.LoadAsync(Empty(), CancellationToken.None); });
Assert.Throws<ObjectDisposedException>(() => { _ = loader.LoadAsync(Empty(), Progress); });
Assert.Throws<ObjectDisposedException>(() => { _ = loader.LoadAsync(Empty(), Progress, CancellationToken.None); });
}


[Fact]
public async Task LoaderBase_LoadAsync_throws_after_DisposeAsync()
{
var loader = new NoOpLoader();
await loader.DisposeAsync();

Assert.Throws<ObjectDisposedException>(() => { _ = loader.LoadAsync(Empty()); });
}


[Fact]
public void ExtractorBase_every_ExtractAsync_overload_throws_after_Dispose()
{
var extractor = new NoOpExtractor();
extractor.Dispose();

Assert.Throws<ObjectDisposedException>(() => extractor.ExtractAsync());
Assert.Throws<ObjectDisposedException>(() => extractor.ExtractAsync(CancellationToken.None));
Assert.Throws<ObjectDisposedException>(() => extractor.ExtractAsync(Progress));
Assert.Throws<ObjectDisposedException>(() => extractor.ExtractAsync(Progress, CancellationToken.None));
}


[Fact]
public async Task ExtractorBase_ExtractAsync_throws_after_DisposeAsync()
{
var extractor = new NoOpExtractor();
await extractor.DisposeAsync();

Assert.Throws<ObjectDisposedException>(() => extractor.ExtractAsync());
}


[Fact]
public void TransformerBase_every_TransformAsync_overload_throws_after_Dispose()
{
var transformer = new NoOpTransformer();
transformer.Dispose();

Assert.Throws<ObjectDisposedException>(() => transformer.TransformAsync(Empty()));
Assert.Throws<ObjectDisposedException>(() => transformer.TransformAsync(Empty(), CancellationToken.None));
Assert.Throws<ObjectDisposedException>(() => transformer.TransformAsync(Empty(), Progress));
Assert.Throws<ObjectDisposedException>(() => transformer.TransformAsync(Empty(), Progress, CancellationToken.None));
}


[Fact]
public async Task TransformerBase_TransformAsync_throws_after_DisposeAsync()
{
var transformer = new NoOpTransformer();
await transformer.DisposeAsync();

Assert.Throws<ObjectDisposedException>(() => transformer.TransformAsync(Empty()));
}


[Fact]
public void Dispose_is_idempotent()
{
var loader = new NoOpLoader();

loader.Dispose();
var second = Record.Exception(() => loader.Dispose());

Assert.Null(second);
}


[Fact]
public void A_live_component_does_not_throw()
{
// Guards against a mutant that makes ThrowIfDisposed always throw (or the guard being
// inverted): a freshly-constructed, undisposed loader must accept a call.
var loader = new NoOpLoader();

var exception = Record.Exception(() => { _ = loader.LoadAsync(Empty()); });

Assert.Null(exception);
}


[ExcludeFromCodeCoverage]
private sealed class NoOpProgress : IProgress<EtlProgress>
{
public void Report(EtlProgress value)
{
}
}


[ExcludeFromCodeCoverage]
private sealed class NoOpLoader : LoaderBase<int, EtlProgress>
{
protected override Task LoadWorkerAsync(IAsyncEnumerable<int> items, CancellationToken token)
=> Task.CompletedTask;

protected override EtlProgress CreateProgressReport() => new(CurrentItemCount);
}


[ExcludeFromCodeCoverage]
private sealed class NoOpExtractor : ExtractorBase<int, EtlProgress>
{
#pragma warning disable CS1998 // async iterator with no yielded items is intentional
protected override async IAsyncEnumerable<int> ExtractWorkerAsync(
[EnumeratorCancellation] CancellationToken token)
{
yield break;
}
#pragma warning restore CS1998

protected override EtlProgress CreateProgressReport() => new(CurrentItemCount);
}


[ExcludeFromCodeCoverage]
private sealed class NoOpTransformer : TransformerBase<int, int, EtlProgress>
{
#pragma warning disable CS1998 // async iterator with no yielded items is intentional
protected override async IAsyncEnumerable<int> TransformWorkerAsync(
IAsyncEnumerable<int> items,
[EnumeratorCancellation] CancellationToken token)
{
yield break;
}
#pragma warning restore CS1998

protected override EtlProgress CreateProgressReport() => new(CurrentItemCount);
}
}
Loading