Skip to content
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ Breaking changes are marked **BREAKING**.

### Fixed

- A struct collection that a property holds, such as `ImmutableArray<T>` or a struct of your own that implements `IEnumerable<T>`, is now validated. Before, the validator skipped every property whose type is a struct, so an invalid object inside passed, although the same struct was validated as an item of a list. The error is reported like one for a class collection, for example `Lines[0].Sku`. A model that passed before can fail now. This includes an item of a list, or the value of a dictionary, that holds such a struct. It was skipped before as having nothing to walk. A struct property that is not a collection, such as a `Money`, is still not walked.
- Objects inside an item that is itself a collection are now validated. Before, a `List<List<T>>`, a `List<HashSet<T>>`, an `object[]` that holds a list, and a list of a sealed or struct collection such as `ImmutableList<T>` passed validation even when an object inside was invalid. The error is reported with the index of each level, for example `Value[0][0].Name`. A model that passed before can fail now.
- An item that is a collection is still validated as an object first, so its own attributes and `IValidatableObject.Validate` run, as before. Its items are then validated as well.
- A struct item that equals its default value, such as an `ImmutableArray<T>` or an `ArraySegment<T>` that nobody set, is skipped, because it holds nothing and enumerating it throws.
- A struct item, or a property declared as a struct, that is its default value, such as an `ImmutableArray<T>` or an `ArraySegment<T>` that nobody set, is skipped, because it holds nothing and enumerating it throws. The validator compares the memory of the struct with its default, and does not call the `Equals` of your struct, which can say "equal" for a struct that holds objects, or throw.

### Changed

Expand All @@ -26,10 +27,9 @@ Breaking changes are marked **BREAKING**.
- `LinkedList<T>`: `Value[0].First.List[1].Name` is now `Value[0][1].Name`.
- `SortedSet<T>`: `Value[0].Min.Name` is now `Value[0][0].Name`.
- A collection of your own with a property that returns its items, such as `View`: `Value[0].View[0].Name` is now `Value[0][0].Name`.
- **BREAKING** An item that is a collection is now enumerated, so a lazy sequence in an item runs, as it does when a property holds it. An item that throws when enumerated now throws from validation, and an item that never ends makes validation hang. To avoid it, mark the property that holds the collection with `[SkipRecursiveValidation]`.
- **BREAKING** An item that is a collection is now enumerated, so a lazy sequence in an item runs, as it does when a property holds it. An item that throws when enumerated now throws from validation, and an item that never ends makes validation hang. The same holds for a struct collection that a property holds, such as `ImmutableArray<T>`: one that throws when enumerated now throws from validation, and one that builds new objects on each read stops at the maximum depth and fails the validation. To avoid it, mark the property that holds the collection with `[SkipRecursiveValidation]`.
- A deeply nested collection uses more of the stack. On a 1 MB stack, a chain of `List<object>` that holds the next list passes at 1,100 levels and overflows at 1,200, which ends the process (net8.0, macOS, Release). Before, such a list passed without being enumerated. The maximum depth of 128 now stops the walk long before the stack is full.
- The NuGet package title now reads "Recursive DataAnnotations Validation". It was misspelled "Recurisive".
- Known limit: a struct collection such as `ImmutableArray<T>` is validated when it is an item, but not yet when a property holds it.
- Known limit: an invalid struct that an item collection returns by enumeration and also through one of its properties is reported twice, with two member names, for example `Value[0][0].Text` and `Value[0].Array[0].Text` for an `ArraySegment<T>`. The validator cannot match two copies of a struct by reference. A struct that two properties hold was already reported twice. Objects of a class are reported once.

## 2.3.3 - 2026-10-01
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ A tree that holds its children in a `List<T>` uses two levels for each tree leve
### Collections

- The items of a collection that a property holds are validated. The error names the property and the index of the item: `Items[1].Name`.
- An item that is itself a collection is validated too, at each level: `Matrix[0][2].Name`. This holds for lists, arrays, sets, dictionaries and your own collection types. A dictionary is enumerated as `KeyValuePair` items, so a value that is an object is reported as `Map[0].Value.Name`. A value that is a struct is not walked, because the validator reads only properties of reference types.
- An item that is itself a collection is validated too, at each level: `Matrix[0][2].Name`. This holds for lists, arrays, sets, dictionaries and your own collection types. A dictionary is enumerated as `KeyValuePair` items, so a value that is an object is reported as `Map[0].Value.Name`. A value that is a struct collection is validated, as it is when a property holds it (see below). A value that is any other struct, such as a `Money`, is not walked, because the validator reads only properties of reference types.
- An item that is a collection is validated as an object first, so its own attributes and `IValidatableObject.Validate` run, then its items.
- A collection of simple values, such as `List<int>` or `string[]`, is not enumerated, because it cannot hold an invalid object.
- A collection that you pass to the validator as the root object is not enumerated. Wrap it in an object with a property.
- A collection that is a struct, such as `ImmutableArray<T>`, is validated when it is an item of another collection, but not when a property holds it. The validator only reads properties of reference types.
- The validator runs each collection it enumerates, so a lazy sequence, such as a LINQ query or an iterator, runs during validation. A sequence that never ends makes validation hang. The validator does not catch exceptions, so an exception that a collection throws when it is enumerated reaches your code. A struct item that equals its default value, such as an `ImmutableArray<T>` or an `ArraySegment<T>` that nobody set, is skipped, because it holds nothing and enumerating it throws.
- A collection that is a struct, such as `ImmutableArray<T>`, is validated when a property holds it and when it is an item of another collection: `Lines[0].Sku`. The validator walks a property whose type is a class, or a struct that is a collection of objects. A struct that is not a collection, such as a `Money`, a tuple or a `KeyValuePair`, is not walked, because the validator only reads properties of reference types.
- The validator runs each collection it enumerates, so a lazy sequence, such as a LINQ query or an iterator, runs during validation. A sequence that never ends makes validation hang. The validator does not catch exceptions, so an exception that a collection throws when it is enumerated reaches your code. A struct collection that is its default value, such as an `ImmutableArray<T>` or an `ArraySegment<T>` that nobody set, is skipped when it is an item or when the property is declared as that struct, because it holds nothing and enumerating it throws. The validator compares the memory of the struct with its default. It does not call the `Equals` of your struct. A property declared as an interface or as `object` is always enumerated.

### Framework types that are not walked

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using System.Threading;
using RecursiveDataAnnotationsValidation.Attributes;
Expand Down Expand Up @@ -80,7 +81,7 @@ public static bool IsCollectionOfLeafType(this Type collectionType)
/// <summary>
/// True when validating an item of this type can never produce a result. A Nullable&lt;T&gt;
/// is checked as T. The type must be a value type or a sealed class, so an item cannot be
/// a derived type with its own attributes. It must also pass all four checks:
/// a derived type with its own attributes. It must also pass all five checks:
/// 1. No validation attribute on the type.
/// 2. No validation attribute on any of its properties.
/// 3. It does not implement IValidatableObject.
Expand Down Expand Up @@ -121,13 +122,14 @@ public static bool IsLeafType(this Type type)

/// <summary>
/// True for a property the validator walks into: readable, not an indexer, not marked
/// [SkipRecursiveValidation], of a reference type other than string, and not declared by a
/// framework type whose properties are unsafe to read (see <see cref="IsUnsafeToWalk"/>).
/// [SkipRecursiveValidation], of a reference type other than string or of a struct that is a
/// collection of items that can have attributes, and not declared by a framework type whose
/// properties are unsafe to read (see <see cref="IsUnsafeToWalk"/>).
/// </summary>
public static bool IsWalked(this PropertyInfo property)
{
return property.PropertyType != typeof(string)
&& !property.PropertyType.IsValueType
&& IsWalkedType(property.PropertyType)
&& property.CanRead
&& property.GetIndexParameters().Length == 0
&& !property.IsDefined(typeof(SkipRecursiveValidationAttribute), false)
Expand Down Expand Up @@ -165,13 +167,32 @@ public static bool IsUnsafeToWalk(this Type type)
/// A default struct collection usually holds nothing to validate, and enumerating one throws:
/// InvalidOperationException for these two framework types, NullReferenceException for a
/// struct that wraps an array. The validator skips it, so a model that has an unset struct
/// field does not start to throw. Equality is the struct's own Equals.
/// field does not start to throw.
/// The struct is compared with default(T) by its memory. The Equals of the caller's type is
/// not called: it can say "equal" for a struct that holds objects, such as one that compares
/// only an Id, which would let invalid objects pass, and it can throw. A struct with no
/// fields is always default.
/// See: https://learn.microsoft.com/dotnet/api/system.runtime.compilerservices.runtimehelpers.equals
/// </summary>
public static bool IsDefaultStruct(this object obj)
{
var type = obj.GetType();

return type.IsValueType && obj.Equals(DefaultValues.GetOrAdd(type, FormatterServices.GetUninitializedObject));
return type.IsValueType
&& RuntimeHelpers.Equals(obj, DefaultValues.GetOrAdd(type, FormatterServices.GetUninitializedObject));
}

// A property of a reference type is walked. A property of a struct is not, because a struct such
// as a Point or a Money has nothing to walk into. The exception is a struct that is a collection
// of items that can have attributes, such as ImmutableArray<T> of a class, because the same
// struct is enumerated when it is an item of another collection, and a model must not pass
// because the collection sits one level higher. A Nullable<T> is checked as T.
private static bool IsWalkedType(Type type)
{
type = Nullable.GetUnderlyingType(type) ?? type;

return !type.IsValueType
|| (typeof(IEnumerable).IsAssignableFrom(type) && !type.IsCollectionOfLeafType());
}

private static bool IsInSystemNamespace(Type type)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,13 @@ private bool TryValidateObjectRecursive(
case IEnumerable _ when value.GetType().IsCollectionOfLeafType():
continue;

//a struct collection nobody set, such as a default ImmutableArray, holds nothing
//and enumerating it throws, so skip it as an item is skipped. Only for a property
//declared as a struct: a property declared as an interface or object was always
//enumerated, and a boxed struct in one must not start to be skipped.
case IEnumerable _ when property.PropertyType.IsValueType && value.IsDefaultStruct():
continue;

case IEnumerable asEnumerable:
//an item that was enumerated above can return itself from a property, such as
//Array.SyncRoot, and enumerating it a second time would find nothing new
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ namespace RecursiveDataAnnotationsValidation.Tests.Extensions
/// <summary>
/// A leaf type is one where validating an item of that type can never produce a result.
/// The validator skips a collection whose element types are all leaf types.
/// An item can produce a result in only four ways, so a leaf type must pass all four checks:
/// An item can produce a result in only five ways, so a leaf type must pass all five checks:
/// 1. No validation attribute on the type.
/// 2. No validation attribute on any of its properties.
/// 3. It does not implement IValidatableObject. Validator calls Validate() on any item
/// that implements it.
/// 4. No property the validator would walk into: a readable, non-indexer property of a
/// reference type other than string.
/// reference type other than string, or of a struct that is a collection of objects.
/// 5. If it is a collection, it is a collection of leaf types. The validator enumerates an
/// item that is a collection, so a sealed class or a struct that yields objects must not
/// be skipped. A collection that yields an unknown type, such as a non-generic one, counts
Expand Down Expand Up @@ -198,8 +198,74 @@ public void Sealed_collection_of_values_is_a_leaf_type()
Assert.True(typeof(string).IsLeafType());
}

public class StructBagHolder
{
public ChildBagStruct Bag { get; set; }

public ChildBagStruct? MaybeBag { get; set; }

public PlainPoint Point { get; set; }

public KeyValuePair<string, Child> Pair { get; set; }
}

// A property of a struct is walked only when the struct is a collection of items that can have
// attributes. A struct that is not a collection is not, and neither is a KeyValuePair, which
// is not an IEnumerable.
[Fact]
public void Struct_collection_property_is_walked()
{
Assert.True(typeof(StructBagHolder).GetProperty(nameof(StructBagHolder.Bag)).IsWalked());
Assert.True(typeof(StructBagHolder).GetProperty(nameof(StructBagHolder.MaybeBag)).IsWalked());
}

[Fact]
public void Struct_property_that_is_not_a_collection_is_not_walked()
{
Assert.False(typeof(StructBagHolder).GetProperty(nameof(StructBagHolder.Point)).IsWalked());
Assert.False(typeof(StructBagHolder).GetProperty(nameof(StructBagHolder.Pair)).IsWalked());
}

// Check 4 of IsLeafType uses IsWalked. A type whose only walked member is a struct collection
// was a leaf type before struct collection properties were walked, and it is not now.
[Fact]
public void Type_with_a_struct_collection_property_is_not_a_leaf_type()
{
Assert.False(typeof(SealedStructBagHolder).IsLeafType());
}

public sealed class SealedStructBagHolder
{
public ChildBagStruct Bag { get; set; }
}

#if NET8_0_OR_GREATER
public class ImmutableArrayHolder
{
public System.Collections.Immutable.ImmutableArray<Child> Children { get; set; }

public System.Collections.Immutable.ImmutableArray<Child>? MaybeChildren { get; set; }

public System.Collections.Immutable.ImmutableArray<int> Numbers { get; set; }
}

[Fact]
public void Immutable_array_property_of_objects_is_walked()
{
Assert.True(typeof(ImmutableArrayHolder).GetProperty(nameof(ImmutableArrayHolder.Children)).IsWalked());
Assert.True(typeof(ImmutableArrayHolder).GetProperty(nameof(ImmutableArrayHolder.MaybeChildren)).IsWalked());
}

// A collection of leaf types has nothing to validate, so its property is not walked.
[Fact]
public void Immutable_array_property_of_values_is_not_walked()
{
Assert.False(typeof(ImmutableArrayHolder).GetProperty(nameof(ImmutableArrayHolder.Numbers)).IsWalked());
}
#endif

// A default struct collection holds nothing, and enumerating one throws, so the validator skips it.
// The check is the struct's own Equals against default(T), so it needs no list of types.
// The check compares the memory of the struct with default(T), so it needs no list of types.
[Fact]
public void Default_struct_is_detected()
{
Expand Down Expand Up @@ -233,6 +299,24 @@ public void Struct_that_has_a_value_is_not_detected()
Assert.False(((object)5).IsDefaultStruct());
}

// The check compares the memory of the struct with default(T). It does not call the Equals of
// the caller's type, which can say "equal" for a struct that holds objects, or throw.
[Fact]
public void Struct_whose_Equals_ignores_its_items_is_not_a_default_struct()
{
var bag = new StructCollectionPropertyTests.StructsWithOddEquals.IdBag(0, new StructCollectionPropertyTests.Leaf());

Assert.True(bag.Equals(default(StructCollectionPropertyTests.StructsWithOddEquals.IdBag)));
Assert.False(((object)bag).IsDefaultStruct());
}

[Fact]
public void Struct_whose_Equals_throws_is_checked_without_calling_it()
{
Assert.True(((object)default(StructCollectionPropertyTests.StructsWithOddEquals.SequenceBag)).IsDefaultStruct());
Assert.False(((object)new StructCollectionPropertyTests.StructsWithOddEquals.SequenceBag(new StructCollectionPropertyTests.Leaf())).IsDefaultStruct());
}

[Fact]
public void Object_of_a_class_is_not_a_default_struct()
{
Expand Down
Loading
Loading