Skip to content

Commit d912e22

Browse files
committed
feat: add assertion helpers, async primitives, and EFCore extensions
New features across multiple packages: - Testing: assertion extensions for objects, booleans, comparables, strings, and exceptions (ShouldBeNull, ShouldBeTrue, ShouldThrow, etc.) - Threading: AsyncLazy<T>, Debouncer, and Throttler - Data.EFCore: soft-delete (ISoftDeletable) and audit trail (IAuditableEntity) with DbContext extensions - Data.Validation: IAsyncValidator and AsyncValidatorCollection - Networking: MultipartFormDataPostNetworkRequest and RetryDelegatingHandler with exponential backoff - Web.Mvc: ForbiddenObjectResult (403) with controller extensions Also adds CancellationToken support to async methods that were missing it, and updates documentation for all new and existing features.
1 parent ee45ce8 commit d912e22

53 files changed

Lines changed: 1578 additions & 93 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

assets/Logo.afdesign

-1.44 KB
Binary file not shown.

assets/Logo.png

-202 Bytes
Loading

assets/ProjectBanner.afdesign

106 KB
Binary file not shown.

assets/ProjectBanner.png

-11.1 KB
Loading

assets/ProjectIcon.jpg

3.34 KB
Loading

assets/ProjectIcon.png

3.98 KB
Loading

docs/articles/features/data-efcore.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,81 @@ The `MADE.Data.EFCore.Extensions.QueryableExtensions` class provides helpers for
103103

104104
- `Page<T>` - Applies skip and take pagination to a query based on page number and page size.
105105
- `OrderBy<T>` - Dynamically orders query results by a property name string, with optional descending sort.
106+
107+
## Soft-delete support with ISoftDeletable
108+
109+
The `MADE.Data.EFCore.ISoftDeletable` interface adds soft-delete support to your entities. Instead of permanently removing records, entities are marked as deleted and filtered out of queries by default.
110+
111+
```csharp
112+
public class User : EntityBase, ISoftDeletable
113+
{
114+
public string Name { get; set; }
115+
116+
public bool IsDeleted { get; set; }
117+
118+
public DateTime? DeletedDate { get; set; }
119+
}
120+
```
121+
122+
### Applying a global query filter
123+
124+
Use `ApplySoftDeleteFilter` in your model builder to automatically exclude soft-deleted entities from all queries:
125+
126+
```csharp
127+
protected override void OnModelCreating(ModelBuilder modelBuilder)
128+
{
129+
modelBuilder.ApplySoftDeleteFilter();
130+
}
131+
```
132+
133+
To query soft-deleted entities, use `IgnoreQueryFilters()` on a specific query.
134+
135+
### Automatic soft-delete interception
136+
137+
Use `InterceptSoftDeletions` in your `SaveChangesAsync` override to automatically convert hard deletes to soft deletes:
138+
139+
```csharp
140+
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
141+
{
142+
this.InterceptSoftDeletions();
143+
this.SetEntityDates();
144+
return await base.SaveChangesAsync(cancellationToken);
145+
}
146+
```
147+
148+
### Manual soft-delete and restore
149+
150+
The `SoftDelete` and `Restore` extension methods allow you to explicitly manage the soft-delete state:
151+
152+
```csharp
153+
user.SoftDelete(); // Sets IsDeleted = true and DeletedDate
154+
user.Restore(); // Clears IsDeleted and DeletedDate
155+
```
156+
157+
## Audit trail support with IAuditableEntity
158+
159+
The `MADE.Data.EFCore.IAuditableEntity` interface adds user tracking to your entities, recording who created and last updated each record.
160+
161+
```csharp
162+
public class Order : EntityBase, IAuditableEntity
163+
{
164+
public string Description { get; set; }
165+
166+
public string? CreatedBy { get; set; }
167+
168+
public string? UpdatedBy { get; set; }
169+
}
170+
```
171+
172+
### Automatic audit info tracking
173+
174+
Use `SetEntityAuditInfo` in your `SaveChangesAsync` override to automatically set audit fields:
175+
176+
```csharp
177+
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
178+
{
179+
this.SetEntityDates();
180+
this.SetEntityAuditInfo(currentUserId);
181+
return await base.SaveChangesAsync(cancellationToken);
182+
}
183+
```

docs/articles/features/data-validation.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,57 @@ The `MADE.Data.Validation.FluentValidation` package provides an easy way to take
247247
Using the `MADE.Data.Validation.FluentValidatorCollection<T>` based on a `List` type, you can construct a collection of `AbstractValidator` instances which can be used to validate values.
248248

249249
This way, you can bring FluentValidation's out-of-the-box validators or your own custom validators based on the `AbstractValidator` type and get all the benefits of using the existing MADE.NET validation framework. This is great for example with input validator controls that currently support the MADE.NET validation framework!
250+
251+
## Asynchronous validation with IAsyncValidator
252+
253+
For validation scenarios that require I/O operations (such as checking uniqueness against a database), the `IAsyncValidator` interface and `AsyncValidatorCollection` provide an asynchronous validation pipeline.
254+
255+
### Creating an async validator
256+
257+
Implement the `IAsyncValidator` interface for validators that need to perform asynchronous work:
258+
259+
```csharp
260+
public class UniqueEmailValidator : IAsyncValidator
261+
{
262+
private readonly IUserRepository repository;
263+
264+
public UniqueEmailValidator(IUserRepository repository)
265+
{
266+
this.repository = repository;
267+
}
268+
269+
public string Key { get; set; } = nameof(UniqueEmailValidator);
270+
271+
public bool IsInvalid { get; set; }
272+
273+
public bool IsDirty { get; set; }
274+
275+
public string FeedbackMessage { get; set; } = "Email address is already in use.";
276+
277+
public async Task ValidateAsync(object value, CancellationToken cancellationToken = default)
278+
{
279+
var email = value?.ToString();
280+
this.IsInvalid = !string.IsNullOrWhiteSpace(email)
281+
&& await this.repository.EmailExistsAsync(email, cancellationToken);
282+
this.IsDirty = true;
283+
}
284+
}
285+
```
286+
287+
### Using the AsyncValidatorCollection
288+
289+
The `AsyncValidatorCollection` works the same as the `ValidatorCollection` but executes each validator asynchronously:
290+
291+
```csharp
292+
var validators = new AsyncValidatorCollection
293+
{
294+
new UniqueEmailValidator(userRepository),
295+
};
296+
297+
await validators.ValidateAsync(emailAddress);
298+
299+
if (validators.IsInvalid)
300+
{
301+
var messages = validators.FeedbackMessages;
302+
}
303+
```

docs/articles/features/networking.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,39 @@ public void UpdateProfileDetails(Profile profile)
8585
`NetworkRequest` objects have a `Guid` identifier also, so if you need to update a pending request with different data or a change in URL, you can do simply by recalling `NetworkManager.AddOrUpdate` passing in a network request with the same ID.
8686

8787
The `AddOrUpdate` method has overloads for providing a success callback, as well as an error callback. This allows you to make decisions in your code to handle a successful or failed network request.
88+
89+
## Uploading files with MultipartFormDataPostNetworkRequest
90+
91+
The `MultipartFormDataPostNetworkRequest` allows you to upload files and form data using multipart/form-data encoding. It provides a fluent API for building the request content.
92+
93+
```csharp
94+
public async Task<UploadResult> UploadFileAsync(Stream fileStream, string fileName, CancellationToken cancellationToken = default)
95+
{
96+
var request = new MultipartFormDataPostNetworkRequest(new HttpClient(), "https://api.example.com/upload")
97+
.AddStreamContent("file", fileStream, fileName, "image/png")
98+
.AddStringContent("description", "Profile photo");
99+
100+
return await request.ExecuteAsync<UploadResult>(cancellationToken);
101+
}
102+
```
103+
104+
You can add multiple types of content:
105+
106+
- `AddStringContent` - Adds a string form field.
107+
- `AddStreamContent` - Adds a file stream with a file name and content type.
108+
- `AddByteArrayContent` - Adds byte array content with a file name and content type.
109+
110+
## Adding retry support with RetryDelegatingHandler
111+
112+
The `RetryDelegatingHandler` is a `DelegatingHandler` that automatically retries failed HTTP requests with exponential backoff. It handles transient failures including timeouts, server errors (500, 502, 503, 504), and rate limiting (429).
113+
114+
```csharp
115+
var handler = new RetryDelegatingHandler(maxRetries: 3, initialDelay: TimeSpan.FromSeconds(1));
116+
var client = new HttpClient(handler);
117+
118+
// All requests made with this client will automatically retry on transient failures
119+
var request = new JsonGetNetworkRequest(client, "https://api.example.com/data");
120+
var result = await request.ExecuteAsync<MyData>();
121+
```
122+
123+
The handler uses exponential backoff, doubling the delay between each retry attempt. You can customize the maximum number of retries and the initial delay via the constructor parameters.

docs/articles/features/testing.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,96 @@ public void InvalidTest()
3838
```
3939

4040
You can also perform the same check for scenarios where the collections are **not** equivalent.
41+
42+
## Asserting null state with ObjectAssertExtensions
43+
44+
The `ShouldBeNull` and `ShouldNotBeNull` extension methods for any object allow you to assert the null state of a value.
45+
46+
```csharp
47+
[Test]
48+
public void ShouldBeNullTest()
49+
{
50+
object? value = null;
51+
value.ShouldBeNull();
52+
}
53+
54+
[Test]
55+
public void ShouldNotBeNullTest()
56+
{
57+
object value = new();
58+
value.ShouldNotBeNull();
59+
}
60+
```
61+
62+
## Asserting boolean values with BooleanAssertExtensions
63+
64+
The `ShouldBeTrue` and `ShouldBeFalse` extension methods for `bool` values allow you to assert the expected state of a boolean.
65+
66+
```csharp
67+
[Test]
68+
public void ShouldBeTrueTest()
69+
{
70+
bool result = true;
71+
result.ShouldBeTrue();
72+
}
73+
74+
[Test]
75+
public void ShouldBeFalseTest()
76+
{
77+
bool result = false;
78+
result.ShouldBeFalse();
79+
}
80+
```
81+
82+
## Comparing values with ComparableAssertExtensions
83+
84+
The `ShouldBeGreaterThan`, `ShouldBeGreaterThanOrEqualTo`, `ShouldBeLessThan`, and `ShouldBeLessThanOrEqualTo` extension methods allow you to assert the comparison of `IComparable` values.
85+
86+
```csharp
87+
[Test]
88+
public void ComparisonTest()
89+
{
90+
int value = 10;
91+
value.ShouldBeGreaterThan(5);
92+
value.ShouldBeLessThan(20);
93+
value.ShouldBeGreaterThanOrEqualTo(10);
94+
value.ShouldBeLessThanOrEqualTo(10);
95+
}
96+
```
97+
98+
## Asserting strings with StringAssertExtensions
99+
100+
The `ShouldContain`, `ShouldNotContain`, `ShouldStartWith`, and `ShouldEndWith` extension methods allow you to assert the contents of strings.
101+
102+
```csharp
103+
[Test]
104+
public void StringAssertionTest()
105+
{
106+
string value = "Hello, World!";
107+
value.ShouldContain("World");
108+
value.ShouldNotContain("Goodbye");
109+
value.ShouldStartWith("Hello");
110+
value.ShouldEndWith("World!");
111+
}
112+
```
113+
114+
## Asserting exceptions with ExceptionAssertExtensions
115+
116+
The `ShouldThrow` and `ShouldNotThrow` extension methods allow you to assert that an action throws or does not throw an exception. Async variants `ShouldThrowAsync` and `ShouldNotThrowAsync` are also available.
117+
118+
```csharp
119+
[Test]
120+
public void ShouldThrowTest()
121+
{
122+
Action action = () => throw new InvalidOperationException("Oops");
123+
var exception = action.ShouldThrow<InvalidOperationException>();
124+
// exception.Message is "Oops"
125+
}
126+
127+
[Test]
128+
public void ShouldNotThrowTest()
129+
{
130+
Action action = () => { /* no error */ };
131+
action.ShouldNotThrow();
132+
}
133+
```

0 commit comments

Comments
 (0)