Skip to content

Commit

Permalink
1.4.0: add ValueTuple support, add an option to skip get-only compute…
Browse files Browse the repository at this point in the history
…d properties, tighten compiler warnings, update dotnet workflow
  • Loading branch information
kasthack committed Jan 8, 2023
1 parent 0000000 commit 0000000
Show file tree
Hide file tree
Showing 34 changed files with 597 additions and 291 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
dotnet-version: ['6.0.x' ]
dotnet-version: ['7.x']
steps:
- uses: actions/checkout@v3
- name: Setup .NET Core SDK ${{ matrix.dotnet-version }}
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ public class MyAmazingTest
}.NotEmpty(new AssertOptions {
MaxDepth = 200
});

// won't throw
new ComputedProperty().NotEmpty(new AssertOptions {
IgnoreComputedProperties = true
})
}

public struct InfiniteNestedStruct
Expand All @@ -81,5 +86,10 @@ public class MyAmazingTest

public InfiniteNestedStruct Child => new InfiniteNestedStruct { Value = this.Value + 1 };
}

public class ComputedProperty
{
public int Value => 1;
}
}
````
5 changes: 3 additions & 2 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
<TargetFrameworks>net6.0;net7.0</TargetFrameworks>
<LangVersion>10.0</LangVersion>
<Nullable>enable</Nullable>
<NoWarn>SA1633;SA1600;SA1601;CS1591;SA1300</NoWarn>
<NoWarn>SA1633;SA1623;SA1600;SA1601;CS1591;SA1300</NoWarn>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<StyleCopTreatErrorsAsWarnings>True</StyleCopTreatErrorsAsWarnings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PublishReferencesDocumentationFiles>true</PublishReferencesDocumentationFiles>
<PublishDocumentationFile>true</PublishDocumentationFile>
<PublishDocumentationFiles>true</PublishDocumentationFiles>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageVersion>1.3.0</PackageVersion>
<PackageVersion>1.4.0</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
4 changes: 2 additions & 2 deletions src/kasthack.NotEmpty.Core/AssertContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ internal class AssertContext
{
private readonly Stack<string> pathSegments = new();

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

public AssertOptions Options { get; }

public int CurrentDepth => this.pathSegments.Count;
Expand All @@ -19,8 +21,6 @@ internal class AssertContext

public ElementKind ElementKind { get; set; } = Core.ElementKind.Root;

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

public IDisposable EnterPath(string segment, ElementKind elementKind) => new PathContext(this, segment, elementKind);

private struct PathContext : IDisposable
Expand Down
73 changes: 39 additions & 34 deletions src/kasthack.NotEmpty.Core/AssertOptions.cs
Original file line number Diff line number Diff line change
@@ -1,36 +1,41 @@
namespace kasthack.NotEmpty.Core
namespace kasthack.NotEmpty.Core;

public class AssertOptions
{
public class AssertOptions
{
/// <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.
/// </summary>
public bool AllowEmptyStrings { get; set; } = false;

/// <summary>
/// Allows empty strings but not nulls.
/// </summary>
public bool AllowEmptyCollections { get; set; } = false;

/// <summary>
/// Allows bool properties to be false.
/// </summary>
public bool AllowFalseBooleanProperties { get; set; } = false;

/// <summary>
/// Allows enum values to have default values if there's a defined option for that.
/// </summary>
public bool AllowDefinedDefaultEnumValues { get; set; } = false;
internal static AssertOptions Default { get; } = new();
}
/// <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.
/// </summary>
public bool AllowEmptyStrings { get; set; } = false;

/// <summary>
/// Allows empty strings but not nulls.
/// </summary>
public bool AllowEmptyCollections { get; set; } = false;

/// <summary>
/// Allows bool properties to be false.
/// </summary>
public bool AllowFalseBooleanProperties { get; set; } = false;

/// <summary>
/// Allows enum values to have default values if there's a defined option for that.
/// </summary>
public bool AllowDefinedDefaultEnumValues { get; set; } = false;

/// <summary>
/// Ignore computed properties.
/// </summary>
public bool IgnoreComputedProperties { get; set; } = false;

internal static AssertOptions Default { get; } = new();
}
104 changes: 90 additions & 14 deletions src/kasthack.NotEmpty.Core/CachedPropertyExtractor{T}.cs
Original file line number Diff line number Diff line change
@@ -1,27 +1,103 @@
namespace kasthack.NotEmpty.Core
namespace kasthack.NotEmpty.Core;

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;

// Returns all properties as an array of KV pairs
internal static class CachedPropertyExtractor<T>
{
using System;
using System.Reflection;
private static IReadOnlyList<(string Name, bool Computed, Func<T, object?> Getter)> Properties { get; } = GetPropertyAccessors();

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

// Returns all properties as an array of KV pairs
internal static class CachedPropertyExtractor<T>
var props = new PathValue[Properties.Count];
for (int i = 0; i < props.Length; i++)
{
var (name, computed, getter) = Properties[i];
props[i] = new PathValue(name, computed, getter(value!));
}

return props;
}

internal static IReadOnlyList<(string Name, bool Computed, Func<T, object?> Getter)> GetPropertyAccessors()
{
private static readonly PropertyInfo[] Properties = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.SetProperty);
// https://stackoverflow.com/questions/2210309/how-to-find-out-if-a-property-is-an-auto-implemented-property-with-reflection
var type = typeof(T);

var fieldnames = type
.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
.Select(a => a.Name)
.ToHashSet();
var allProperties = type
.GetProperties(BindingFlags.Instance | BindingFlags.Public);

public static PathValue[] GetProperties(T? value)
var propertyAccessors = allProperties
.Where(property => property.CanRead && property.GetMethod!.IsPublic)
.Select<PropertyInfo, (string Name, bool Computed, Func<Expression, MemberExpression> ExpressionBuilder)>(
propertyInfo => (propertyInfo.Name, IsComputed(propertyInfo), instance => Expression.Property(instance, propertyInfo)))
.ToList();

var fieldAccessors = type.IsValueTuple()
? type
.GetFields(BindingFlags.Public | BindingFlags.Instance)
.Select<FieldInfo, (string Name, bool Computed, Func<Expression, MemberExpression> ExpressionBuilder)>(
fieldInfo => (fieldInfo.Name, false, instance => Expression.Field(instance, fieldInfo)))
.ToArray()
: Array.Empty<(string Name, bool Computed, Func<Expression, MemberExpression> ExpressionBuilder)>();

return propertyAccessors
.Concat(fieldAccessors)
.Select(accessorPair =>
{
var instance = Expression.Parameter(typeof(T), "instance");
var memberAccessor = accessorPair.ExpressionBuilder(instance);
var convert = Expression.Convert(memberAccessor, typeof(object));
var lambda = Expression.Lambda<Func<T, object?>>(convert, instance);
return (accessorPair.Name, accessorPair.Computed, lambda.Compile());
}).ToArray();

bool IsComputed(PropertyInfo property)
{
if (Properties.Length == 0)
// has a setter / init
if (property.CanWrite)
{
return false;
}

// get-only auto-property
var isGetOnlyAutoProperty =
property.GetMethod!.CustomAttributes.Any(a => a.AttributeType == typeof(CompilerGeneratedAttribute))
&& fieldnames.Contains($"<{property.Name}>k__BackingField");

if (isGetOnlyAutoProperty)
{
return false;
}

// init-only property in anonymous type
if (type.IsAnonymousType())
{
return Array.Empty<PathValue>();
return false;
}

var props = new PathValue[Properties.Length];
for (int i = 0; i < props.Length; i++)
// built-in tuples
if (type.IsTuple())
{
props[i] = new PathValue(Properties[i].Name, Properties[i].GetValue(value));
return false;
}

return props;
return true;
}
}
}
}
19 changes: 19 additions & 0 deletions src/kasthack.NotEmpty.Core/ElementKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,29 @@
{
internal enum ElementKind
{
/// <summary>
/// No idea where we are.
/// </summary>
Unknown,

/// <summary>
/// Examinating root value.
/// </summary>
Root,

/// <summary>
/// Examinating an object property.
/// </summary>
Property,

/// <summary>
/// Examinating an array element.
/// </summary>
ArrayElement,

/// <summary>
/// Examinating a dictionary element.
/// </summary>
DictionaryElement,
}
}
13 changes: 9 additions & 4 deletions src/kasthack.NotEmpty.Core/NotEmptyExtensionsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

public abstract class NotEmptyExtensionsBase
{
private static readonly ISet<Type> KnownNumericTypes = new HashSet<Type>()
private static readonly IReadOnlySet<Type> KnownNumericTypes = new HashSet<Type>()
{
typeof(byte),
typeof(sbyte),
Expand Down Expand Up @@ -90,7 +90,9 @@ or TimeSpan _
or DateOnly _
or TimeOnly _
#endif
#pragma warning disable SA1024 // Doesn't work with #if
:
#pragma warning restore SA1024
break;
case string s:
this.Assert(
Expand Down Expand Up @@ -134,11 +136,14 @@ or TimeOnly _

break;
default:
foreach (var pathValue in CachedPropertyExtractor<T>.GetProperties(value))
foreach (var pathValue in CachedPropertyExtractor<T>.GetPropertiesAndTupleFields(value!))
{
using (context.EnterPath($".{pathValue.Path}", ElementKind.Property))
if (!pathValue.IsComputed || !context.Options.IgnoreComputedProperties)
{
this.NotEmptyBoxed(pathValue.Value, context);
using (context.EnterPath($".{pathValue.Path}", ElementKind.Property))
{
this.NotEmptyBoxed(pathValue.Value, context);
}
}
}

Expand Down
13 changes: 1 addition & 12 deletions src/kasthack.NotEmpty.Core/PathValue.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,4 @@
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; }
}
internal readonly record struct PathValue(string Path, bool IsComputed, object? Value);
}
Loading

0 comments on commit 0000000

Please sign in to comment.