Skip to content

Commit

Permalink
Improve solution structure, add an option to allow empty collections,…
Browse files Browse the repository at this point in the history
… bump version to 1.0.6
  • Loading branch information
kasthack committed Dec 25, 2021
1 parent 0000000 commit 0000000
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 125 deletions.
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.5</PackageVersion>
<PackageVersion>1.0.6</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
29 changes: 29 additions & 0 deletions src/kasthack.NotEmpty.Core/AssertOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace kasthack.NotEmpty.Core
{
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>
/// Allows empty strings but not nulls.
/// </summary>
public bool AllowEmptyStrings { get; set; } = false;

/// <summary>
/// Allows empty strings but not nulls.
/// </summary>
public bool AllowEmptyCollections { get; set; } = false;
}
}
60 changes: 60 additions & 0 deletions src/kasthack.NotEmpty.Core/CachedEmptyDelegate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
namespace kasthack.NotEmpty.Core
{
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;

// Creates NotEmptyInternal<T> wrapper:
// (object value, AssertOptions options, string path) => this.NotEmptyInternal<ACTUAL_TYPE_OF_VALUE>((ACTUAL_TYPE_OF_VALUE)value, options, path)
internal static class CachedEmptyDelegate
{
private static readonly MethodInfo NotEmptyMethod = typeof(NotEmptyExtensionsBase)
.GetMethod(nameof(NotEmptyExtensionsBase.NotEmptyInternal), BindingFlags.NonPublic | BindingFlags.Instance)!
.GetGenericMethodDefinition();

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

public static Action<NotEmptyExtensionsBase, object?, AssertOptions, string?> GetDelegate(Type type)
{
if (!Delegates.TryGetValue(type, out var result))
{
lock (Delegates)
{
if (!Delegates.TryGetValue(type, out result))
{
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 parameters = new[]
{
thisParam,
valueParam,
optionsParam,
pathParam,
};
result = (Action<NotEmptyExtensionsBase, object?, AssertOptions, string?>)Expression
.Lambda(
Expression.Call(
thisParam,
NotEmptyMethod.MakeGenericMethod(type),
arguments: new Expression[]
{
Expression.Convert(
valueParam,
type),
optionsParam,
pathParam,
}),
parameters)
.Compile();
Delegates[type] = result;
}
}
}

return result;
}
}
}
27 changes: 27 additions & 0 deletions src/kasthack.NotEmpty.Core/CachedPropertyExtractor{T}.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace kasthack.NotEmpty.Core
{
using System;
using System.Reflection;

// Returns all properties as an array of KV pairs
internal static class CachedPropertyExtractor<T>
{
private static readonly PropertyInfo[] Properties = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.SetProperty);

public static PathValue[] GetProperties(T? value)
{
if (Properties.Length == 0)
{
return Array.Empty<PathValue>();
}

var props = new PathValue[Properties.Length];
for (int i = 0; i < props.Length; i++)
{
props[i] = new PathValue(Properties[i].Name, Properties[i].GetValue(value));
}

return props;
}
}
}
135 changes: 14 additions & 121 deletions src/kasthack.NotEmpty.Core/NotEmptyExtensionsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
{
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;

public abstract class NotEmptyExtensionsBase
{
Expand All @@ -25,9 +23,7 @@ public void NotEmpty<T>(T? value, AssertOptions? assertOptions)

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

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

private void NotEmptyInternal<T>(T? value, AssertOptions assertOptions, string? path = null)
internal void NotEmptyInternal<T>(T? value, AssertOptions assertOptions, string? path = null)
{
string message = GetEmptyMessage(path);
this.Assert(value is not null, message); // fast lane
Expand All @@ -37,13 +33,14 @@ private void NotEmptyInternal<T>(T? value, AssertOptions assertOptions, string?
{
// TODO: add max-depth instead of doing this
// infinite recursion workaround
case DateTimeOffset _:
case DateTime _:
case TimeSpan _:
case DateTimeOffset _
or DateTime _
or TimeSpan _
#if NET6_0_OR_GREATER
case DateOnly _:
case TimeOnly _:
or DateOnly _
or TimeOnly _
#endif
:
break;
case string s:
this.Assert(
Expand All @@ -59,7 +56,11 @@ private void NotEmptyInternal<T>(T? value, AssertOptions assertOptions, string?
this.NotEmptyBoxed(item, assertOptions, $"{path}[{index++}]");
}

this.Assert(index != 0, message);
if (!assertOptions.AllowEmptyCollections)
{
this.Assert(index != 0, message);
}

break;
default:
foreach (var pathValue in CachedPropertyExtractor<T>.GetProperties(value))
Expand All @@ -71,120 +72,12 @@ private void NotEmptyInternal<T>(T? value, AssertOptions assertOptions, string?
}
}

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

// Creates NotEmptyInternal<T> wrapper:
// (object value, AssertOptions options, string path) => this.NotEmptyInternal<ACTUAL_TYPE_OF_VALUE>((ACTUAL_TYPE_OF_VALUE)value, options, path)
private static class CachedEmptyDelegate
{
private static readonly MethodInfo NotEmptyMethod = typeof(NotEmptyExtensionsBase)
.GetMethod(nameof(NotEmptyExtensionsBase.NotEmptyInternal), BindingFlags.NonPublic | BindingFlags.Instance)!
.GetGenericMethodDefinition();

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

public static Action<NotEmptyExtensionsBase, object?, AssertOptions, string?> GetDelegate(Type type)
{
if (!Delegates.TryGetValue(type, out var result))
{
lock (Delegates)
{
if (!Delegates.TryGetValue(type, out result))
{
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 parameters = new[]
{
thisParam,
valueParam,
optionsParam,
pathParam,
};
result = (Action<NotEmptyExtensionsBase, object?, AssertOptions, string?>)Expression
.Lambda(
Expression.Call(
thisParam,
NotEmptyMethod.MakeGenericMethod(type),
arguments: new Expression[]
{
Expression.Convert(
valueParam,
type),
optionsParam,
pathParam,
}),
parameters)
.Compile();
Delegates[type] = result;
}
}
}

return result;
}
}

// Returns all properties as an array of KV pairs
private static class CachedPropertyExtractor<T>
{
private static readonly PropertyInfo[] Properties = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.SetProperty);

public static PathValue[] GetProperties(T? value)
{
if (Properties.Length == 0)
{
return Array.Empty<PathValue>();
}

var props = new PathValue[Properties.Length];
for (int i = 0; i < props.Length; i++)
{
props[i] = new PathValue(Properties[i].Name, Properties[i].GetValue(value));
}

return props;
}
}

private struct PathValue
{
public PathValue(string path, object? value)
{
this.Path = path;
this.Value = value;
}

public string Path { get; }

public object? Value { get; }
}
}

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>
/// Allows empty strings but not nulls.
/// </summary>
public bool AllowEmptyStrings { get; set; } = false;
private static string GetEmptyMessage(string? path) => $"value{path} is empty";
}
}
15 changes: 15 additions & 0 deletions src/kasthack.NotEmpty.Core/PathValue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace kasthack.NotEmpty.Core
{
internal readonly struct PathValue
{
public PathValue(string path, object? value)
{
this.Path = path;
this.Value = value;
}

public readonly string Path { get; }

public readonly object? Value { get; }
}
}
8 changes: 6 additions & 2 deletions src/kasthack.NotEmpty.Tests/NotEmptyTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,18 @@ public abstract class NotEmptyTestBase
[Fact]
public void EmptyStringThrows() => Assert.ThrowsAny<Exception>(() => this.Action(string.Empty));

[Fact]
public void EmptyStringDoesntThrowWhenAllowed() => this.Action(string.Empty, new AssertOptions { AllowEmptyStrings = true, });

[Fact]
public void DefaultThrows() => Assert.ThrowsAny<Exception>(() => this.Action(0));

[Fact]
public void EmptyArrayThrows() => Assert.ThrowsAny<Exception>(() => this.Action(new object[] { }));

[Fact]
public void EmptyArrayDoesntThrowWhenAllowed() => this.Action(new object[] { }, new AssertOptions { AllowEmptyCollections = true, });

[Fact]
public void EmptyListThrows() => Assert.ThrowsAny<Exception>(() => this.Action(new List<object>()));

Expand All @@ -61,8 +67,6 @@ public abstract class NotEmptyTestBase
[Fact]
public void KnownTypeWithInfiniteRecursionDoesntThrow() => this.Action(new DateTime(2000, 1, 1, 0, 0, 0));

[Fact]
public void AllowsEmptyStringsWithConfiguredOption() => this.Action("", new AssertOptions { AllowEmptyStrings = true, });

private void Action(object? value, AssertOptions? options = null) => this.action(value, options);
}
Expand Down
15 changes: 14 additions & 1 deletion src/kasthack.NotEmpty.sln
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CI", "CI", "{FA63E98E-9B05-
..\.github\workflows\push.yml = ..\.github\workflows\push.yml
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "kasthack.NotEmpty.Tests", "kasthack.NotEmpty.Tests\kasthack.NotEmpty.Tests.csproj", "{49386A26-B16E-45B3-93D7-5846A721C2EC}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "kasthack.NotEmpty.Tests", "kasthack.NotEmpty.Tests\kasthack.NotEmpty.Tests.csproj", "{49386A26-B16E-45B3-93D7-5846A721C2EC}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FrameworkSpecific", "FrameworkSpecific", "{8B7A3E19-0D80-4474-B5E1-1881E5CE7852}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Source", "Source", "{2E7DFFBC-A4B6-414C-80BC-A39A7818B630}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand Down Expand Up @@ -69,6 +73,15 @@ Global
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C4901934-9C70-482E-BAFA-F627F9631E5D} = {2E7DFFBC-A4B6-414C-80BC-A39A7818B630}
{43D65B2D-B6A8-4E5A-A191-2F4DAB0070D6} = {8B7A3E19-0D80-4474-B5E1-1881E5CE7852}
{0F84F504-87BC-46F2-A7AB-960FD6ED1899} = {8B7A3E19-0D80-4474-B5E1-1881E5CE7852}
{0A2BA47F-488E-48A4-9513-56D672E2A77F} = {8B7A3E19-0D80-4474-B5E1-1881E5CE7852}
{78A06400-338F-4494-9BC9-A414080B7824} = {8B7A3E19-0D80-4474-B5E1-1881E5CE7852}
{49386A26-B16E-45B3-93D7-5846A721C2EC} = {2E7DFFBC-A4B6-414C-80BC-A39A7818B630}
{8B7A3E19-0D80-4474-B5E1-1881E5CE7852} = {2E7DFFBC-A4B6-414C-80BC-A39A7818B630}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {CF0E6B2E-03A8-40FA-8939-1B609DDDC39C}
EndGlobalSection
Expand Down

0 comments on commit 0000000

Please sign in to comment.