Skip to content

Commit

Permalink
Add max depth, add dictionary handling, add support for zeros in arra…
Browse files Browse the repository at this point in the history
…ys, bump package version to 1.0.7
  • Loading branch information
kasthack committed Dec 25, 2021
1 parent 0000000 commit 0000000
Show file tree
Hide file tree
Showing 7 changed files with 265 additions and 52 deletions.
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,45 @@ public class MyAmazingTest

//<actual asserts>
}

[Fact]
public void TestOptions()
{
// won't throw
new {
PropertyThanLegitimatelyCanBeAnEmptyStringButNotNull = "",
}.NotEmpty(new AssertOptions {
AllowEmptyStrings = true,
});

//won't throw
new {
PropertyThanLegitimatelyCanBeAnEmptyCollectionButNotNull = new int[]{},
}.NotEmpty(new AssertOptions {
AllowEmptyCollections = true,
});

// won't throw
new {
FileContentThatObviouslyContainsSomeNullBytes = new byte[]{ 0 }
}.NotEmpty(new AssertOptions {
AllowZerosInNumberArrays = true
});

// won't throw BUT will stop at 200 iterations
// default MaxDepth is 100
new {
DeeplyNestedObject = new InfiniteNestedStruct()
}.NotEmpty(new AssertOptions {
MaxDepth = 200
});
}

public struct InfiniteNestedStruct
{
public int Value { get; set; } = 1;

public InfiniteNestedStruct Child => new InfiniteNestedStruct { Value = this.Value + 1 };
}
}
````
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<PublishDocumentationFile>true</PublishDocumentationFile>
<PublishDocumentationFiles>true</PublishDocumentationFiles>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageVersion>1.0.6</PackageVersion>
<PackageVersion>1.0.7</PackageVersion>
<PackageDescription>.NotEmpty&lt;T&gt;() test extension</PackageDescription>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
<PackageTags>kasthack nunit xunit mstest test empty null notempty emptinness nullability</PackageTags>
Expand Down
51 changes: 51 additions & 0 deletions src/kasthack.NotEmpty.Core/AssertContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace kasthack.NotEmpty.Core
{
using System;
using System.Collections.Generic;

/// <summary>
/// Dispose doesn't do what you migth think.
/// </summary>
internal class AssertContext
{
private readonly Stack<string> pathSegments = new();

public AssertOptions Options { get; }

public int CurrentDepth => this.pathSegments.Count;

public string Path => string.Concat(this.pathSegments);

public bool IsArrayElement { get; set; } = false;

public AssertContext(AssertOptions options) => this.Options = options;

public IDisposable EnterPath(string segment, bool isArray) => new PathContext(this, segment, isArray);

private struct PathContext : IDisposable
{
private readonly AssertContext context;
private readonly bool originalIsArrayElement;
private bool disposed = false;

public PathContext(AssertContext context, string segment, bool isArray)
{
this.context = context ?? throw new ArgumentNullException(nameof(context));
this.originalIsArrayElement = this.context.IsArrayElement;

this.context.pathSegments.Push(segment);
this.context.IsArrayElement = isArray;
}

public void Dispose()
{
if (!this.disposed)
{
this.disposed = true;
this.context.pathSegments.Pop();
this.context.IsArrayElement = this.originalIsArrayElement;
}
}
}
}
}
18 changes: 8 additions & 10 deletions src/kasthack.NotEmpty.Core/AssertOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@ public class AssertOptions
{
internal static AssertOptions Default { get; } = new();

/*
///// <summary>
///// Maximum assert depth. Useful for preventing stack overflows for objects with generated properties / complex graphs.
///// </summary>
//public int? MaxDepth { get; set; } = 100;
/// <summary>
/// Allow zeros in number arrays. Useful when you have binary data as a byte array.
/// </summary>
public bool AllowZerosInNumberArrays { get; set; } = false;

///// <summary>
///// Allow zeros in number arrays. Useful when you have binary data as a byte array.
///// </summary>
//public bool AllowZerosInNumberArrays { get; set; } = false;
*/
/// <summary>
/// Maximum assert depth. Useful for preventing stack overflows for objects with generated properties / complex graphs.
/// </summary>
public int? MaxDepth { get; set; } = 100;

/// <summary>
/// Allows empty strings but not nulls.
Expand Down
21 changes: 9 additions & 12 deletions src/kasthack.NotEmpty.Core/CachedEmptyDelegate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ internal static class CachedEmptyDelegate
.GetMethod(nameof(NotEmptyExtensionsBase.NotEmptyInternal), BindingFlags.NonPublic | BindingFlags.Instance)!
.GetGenericMethodDefinition();

private static readonly Dictionary<Type, Action<NotEmptyExtensionsBase, object?, AssertOptions, string?>> Delegates = new();
private static readonly Dictionary<Type, Action<NotEmptyExtensionsBase, object?, AssertContext>> Delegates = new();

public static Action<NotEmptyExtensionsBase, object?, AssertOptions, string?> GetDelegate(Type type)
public static Action<NotEmptyExtensionsBase, object?, AssertContext> GetDelegate(Type type)
{
if (!Delegates.TryGetValue(type, out var result))
{
Expand All @@ -25,27 +25,24 @@ internal static class CachedEmptyDelegate
{
var thisParam = Expression.Parameter(typeof(NotEmptyExtensionsBase));
var valueParam = Expression.Parameter(typeof(object));
var optionsParam = Expression.Parameter(typeof(AssertOptions));
var pathParam = Expression.Parameter(typeof(string));
var contextParam = Expression.Parameter(typeof(AssertContext));
var parameters = new[]
{
thisParam,
valueParam,
optionsParam,
pathParam,
contextParam,
};
result = (Action<NotEmptyExtensionsBase, object?, AssertOptions, string?>)Expression
result = (Action<NotEmptyExtensionsBase, object?, AssertContext>)Expression
.Lambda(
Expression.Call(
thisParam,
NotEmptyMethod.MakeGenericMethod(type),
arguments: new Expression[]
{
Expression.Convert(
valueParam,
type),
optionsParam,
pathParam,
Expression.Convert(
valueParam,
type),
contextParam,
}),
parameters)
.Compile();
Expand Down
71 changes: 54 additions & 17 deletions src/kasthack.NotEmpty.Core/NotEmptyExtensionsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
{
using System;
using System.Collections.Generic;
using System.Numerics;

public abstract class NotEmptyExtensionsBase
{
Expand All @@ -11,28 +12,41 @@ public abstract class NotEmptyExtensionsBase
public void NotEmpty<T>(T? value, AssertOptions? assertOptions)
{
assertOptions ??= AssertOptions.Default;

// workaround for boxed structs passed as objects
if (value is not null && typeof(T) == typeof(object) && value.GetType() != typeof(object))
if (assertOptions.MaxDepth.HasValue && assertOptions.MaxDepth < 0)
{
this.NotEmptyBoxed(value, assertOptions, null!);
throw new ArgumentOutOfRangeException($"{nameof(assertOptions)}.{nameof(assertOptions.MaxDepth)}", $"{nameof(assertOptions.MaxDepth)} must be greater than 0 / null for unlimited traversing.");
}

this.NotEmptyInternal(value, assertOptions, null);
// workaround for boxed structs passed as objects
this.NotEmptyBoxed(value, new AssertContext(assertOptions));
}

protected abstract void Assert(bool value, string message);

internal void NotEmptyInternal<T>(T? value, AssertOptions assertOptions, string? path = null)
internal void NotEmptyInternal<T>(T? value, AssertContext context)
{
string message = GetEmptyMessage(path);
if (context.Options.MaxDepth != null && context.Options.MaxDepth.Value < context.CurrentDepth)
{
return;
}

string message = GetEmptyMessage(context.Path);
this.Assert(value is not null, message); // fast lane
this.Assert(!EqualityComparer<T>.Default.Equals(default!, value!), message);
if (
!(context.Options.AllowZerosInNumberArrays &&
context.IsArrayElement &&
value is byte or sbyte or short or ushort or char or int or uint or long or ulong or float or double or decimal or BigInteger
#if NET5_0_OR_GREATER
or Half
#endif
))
{
this.Assert(!EqualityComparer<T>.Default.Equals(default!, value!), message);
}

switch (value)
{
// TODO: add max-depth instead of doing this
// infinite recursion workaround
// ignore properties on builtin time structs as it's a reasonable thing to do
case DateTimeOffset _
or DateTime _
or TimeSpan _
Expand All @@ -44,19 +58,39 @@ or TimeOnly _
break;
case string s:
this.Assert(
assertOptions.AllowEmptyStrings
context.Options.AllowEmptyStrings
? s != null
: !string.IsNullOrEmpty(s),
message);
break;
case System.Collections.IDictionary d:
var cnt = 0;
foreach (var key in d.Keys)
{
cnt++;
using (context.EnterPath($"[{key}]", false))
{
this.NotEmptyBoxed(d[key], context);
}
}

if (!context.Options.AllowEmptyCollections)
{
this.Assert(cnt != 0, message);
}

break;
case System.Collections.IEnumerable e:
var index = 0;
foreach (var item in e)
{
this.NotEmptyBoxed(item, assertOptions, $"{path}[{index++}]");
using (context.EnterPath(index++.ToString(), true))
{
this.NotEmptyBoxed(item, context);
}
}

if (!assertOptions.AllowEmptyCollections)
if (!context.Options.AllowEmptyCollections)
{
this.Assert(index != 0, message);
}
Expand All @@ -65,17 +99,20 @@ or TimeOnly _
default:
foreach (var pathValue in CachedPropertyExtractor<T>.GetProperties(value))
{
this.NotEmptyBoxed(pathValue.Value, assertOptions, $"{path}.{pathValue.Path}");
using (context.EnterPath(pathValue.Path, false))
{
this.NotEmptyBoxed(pathValue.Value, context);
}
}

break;
}
}

internal void NotEmptyBoxed(object? value, AssertOptions assertOptions, string? path)
internal void NotEmptyBoxed(object? value, AssertContext context)
{
this.Assert(value is not null, GetEmptyMessage(path));
CachedEmptyDelegate.GetDelegate(value!.GetType())(this, value, assertOptions, path);
this.Assert(value is not null, GetEmptyMessage(context.Path));
CachedEmptyDelegate.GetDelegate(value!.GetType())(this, value, context);
}

private static string GetEmptyMessage(string? path) => $"value{path} is empty";
Expand Down
Loading

0 comments on commit 0000000

Please sign in to comment.