Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,25 @@ Breaking changes are marked **BREAKING**.

## Unreleased

### Fixed

- 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.

### Changed

- **BREAKING** The member names of an object in an item that is a collection. The validator now enumerates the items first, so the path no longer goes through a property of the item. Change any code that matches these names:
- Array, multi-dimensional array, `ArrayList`: `Value[0].SyncRoot[0].Name` is now `Value[0][0].Name`.
- Dictionary, `Hashtable`: `Value[0].Values[0].Name` is now `Value[0][0].Value.Name`, and `Value[0].Keys[0].Name` is now `Value[0][0].Key.Name`.
- `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]`.
- 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 validator still has no maximum depth.
- 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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,23 @@ The [`[SkipRecursiveValidation]`](https://github.com/tgharold/RecursiveDataAnnot
### Shared objects, cycles and computed properties

- Each object is validated once, even when several properties point to it. This also stops cycles, such as a child that points back to its parent.
- A struct is the exception to the first rule. The validator cannot tell that two copies of a struct are the same value, so it reports an invalid struct once for each route that reaches it. For example, two properties that hold the same array of structs report each struct twice, and so does a `List<object>` that holds an `ArraySegment<T>` of structs, because the segment returns its items by enumeration and through its `Array` property. The error is not lost, but the result list has a duplicate with a different member name. For a collection type of your own, mark the property that repeats the items with `[SkipRecursiveValidation]`.
- Objects are compared by reference. Two separate objects that are `Equals` to each other, such as records with the same values or entities with the same `Id`, are each validated.
- Public static properties are walked, as well as instance properties.
- A property that builds a new object on each read, such as `public Money Zero => new Money(0)`, could make the walk go on forever. So when an object's type overrides `Equals`, and the object equals an object of the same type, a base type or a derived type on its own path from the root, the validator checks that object's own attributes but does not walk into its properties. An invalid object below it is not reached.
- For a type that does not override `Equals`, mark such a property with `[SkipRecursiveValidation]`. Otherwise the walk overflows the stack, which ends the process.
- Two records that reference each other can also overflow the stack. A record's generated `Equals` compares properties in declaration order, so it follows the reference forever if it reaches it before a property that differs. This happens when the records have equal values, or when the reference is declared first. On .NET Framework it always happens, because the framework's `Validator` calls the record's generated `GetHashCode`, which follows the reference too. Marking the reference with `[SkipRecursiveValidation]` avoids the walk, but not the `GetHashCode` call on .NET Framework. There, override `GetHashCode` so it does not include the reference.

### 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 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.

### Framework types that are not walked

The validator does not walk the properties that these framework types declare: `Type` and other `MemberInfo` types, `Assembly`, `Module`, delegates, `Uri`, `FileSystemInfo`, which covers `DirectoryInfo` and `FileInfo`, `Thread`, and `Process`. Reading those properties throws or never ends, for example `Uri.Segments` on a relative `Uri`, and they carry no validation attributes.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
Expand All @@ -7,6 +8,7 @@
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Serialization;
using System.Threading;
using RecursiveDataAnnotationsValidation.Attributes;

Expand All @@ -26,6 +28,12 @@ internal static class TypeExtensions
private static readonly ConcurrentDictionary<Type, bool> OverridesEqualsCache =
new ConcurrentDictionary<Type, bool>();

// A boxed default(T) for each struct type. GetUninitializedObject returns zeroed memory, so
// it is default(T) even for a struct that declares a parameterless constructor, which
// Activator.CreateInstance would run.
private static readonly ConcurrentDictionary<Type, object> DefaultValues =
new ConcurrentDictionary<Type, object>();

private static readonly ConcurrentDictionary<Type, bool> UnsafeToWalkCache =
new ConcurrentDictionary<Type, bool>();

Expand All @@ -44,6 +52,11 @@ internal static class TypeExtensions
typeof(Process), // StartInfo and others throw for a process this object did not start
};

// The types IsLeafType is deciding on this thread. A type that yields itself, such as
// `sealed class Node : IEnumerable<Node>`, asks about its own type while it is being decided.
[ThreadStatic]
private static HashSet<Type> _typesBeingChecked;

private static int _typeDescriptorVersion;

static TypeExtensions()
Expand Down Expand Up @@ -72,6 +85,10 @@ public static bool IsCollectionOfLeafType(this Type collectionType)
/// 2. No validation attribute on any of its properties.
/// 3. It does not implement IValidatableObject.
/// 4. No property the validator walks into (see <see cref="IsWalked"/>).
/// 5. If it is a collection, it is a collection of leaf types (see <see cref="IsCollectionOfLeafType"/>),
/// because the validator enumerates an item that is a collection, and the items it
/// yields can have attributes of their own. A collection of an unknown item type, such as
/// a non-generic one, is not a leaf type.
/// Checks 1 and 2 use TypeDescriptor, like Validator, so attributes added at runtime count.
/// </summary>
public static bool IsLeafType(this Type type)
Expand All @@ -82,7 +99,22 @@ public static bool IsLeafType(this Type type)
if (LeafTypeCache.TryGetValue(type, out var cached) && cached.Version == version)
return cached.IsLeaf;

var isLeaf = FindIsLeafType(type);
var typesBeingChecked = _typesBeingChecked ?? (_typesBeingChecked = new HashSet<Type>());

// A type that is already being decided is not a leaf type. That is the safe answer,
// because the validator then looks at the type instead of skipping it.
if (!typesBeingChecked.Add(type)) return false;

bool isLeaf;
try
{
isLeaf = FindIsLeafType(type);
}
finally
{
typesBeingChecked.Remove(type);
}

LeafTypeCache[type] = (version, isLeaf);
return isLeaf;
}
Expand Down Expand Up @@ -127,6 +159,21 @@ public static bool IsUnsafeToWalk(this Type type)
&& UnsafeToWalkTypes.Any(unsafeType => unsafeType.IsAssignableFrom(t)));
}

/// <summary>
/// True for a struct that equals its default value, such as an ImmutableArray or an
/// ArraySegment that nobody set, or a struct of your own whose fields are all null or zero.
/// 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.
/// </summary>
public static bool IsDefaultStruct(this object obj)
{
var type = obj.GetType();

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

private static bool IsInSystemNamespace(Type type)
{
var ns = type.Namespace;
Expand All @@ -148,7 +195,8 @@ private static bool FindIsLeafType(Type type)
return (type.IsValueType || type.IsSealed)
&& !HasValidationAttributes(type)
&& !typeof(IValidatableObject).IsAssignableFrom(type)
&& !type.GetProperties().Any(IsWalked);
&& !type.GetProperties().Any(IsWalked)
&& (!typeof(IEnumerable).IsAssignableFrom(type) || type.IsCollectionOfLeafType());
}

/// <summary>
Expand Down
Loading
Loading