Skip to content

Validate objects inside items that are collections - #82

Merged
tgharold merged 12 commits into
masterfrom
tgh-20261001-nested-collections-items
Oct 2, 2026
Merged

tgharold merged 12 commits into
masterfrom
tgh-20261001-nested-collections-items

Conversation

@tgharold

@tgharold tgharold commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Motivation

The validator enumerated a collection that a property holds, but never an item that is itself a collection. A List<List<T>>, a List<HashSet<T>>, an object[] that holds a list, or a list of ImmutableList<T> passed validation even when an object inside was invalid. A probe of v2.0.0, v2.2.0 and v2.3.3 shows the same result in all three, so this is a long-standing silent pass. Some shapes were caught only by accident, through a public property of the inner collection (Array.SyncRoot, Dictionary.Values), with paths such as Value[0].SyncRoot[0].Name.

The fix enumerates items that are collections and reports each level's index: Value[0][0].Name. Paths are now the same for every collection type, which is why this is a breaking change for the few shapes that had an accidental path. By the rule in RELEASE-PROCESS.md it needs a major version, so I expect it to ship in 3.0.

Summary

  • An item that is a collection is validated as an object first, so its own attributes and IValidatableObject.Validate still run, then its items are enumerated before its properties are walked. A collection of simple values is not enumerated. The root object is not enumerated either, and that is pinned by tests.
  • IsLeafType now treats a sealed class or struct that is a collection as a leaf only when its items are leaves. Without it, List<ImmutableList<T>> and lists of sealed collections were skipped as lists of values.
  • Edge cases decided and tested: a collection that contains itself ends, a shared inner collection is walked once, and null or bracketed member names are joined without being inspected. A struct item that equals its default value, such as an unset ImmutableArray<T> or ArraySegment<T>, is skipped, because enumerating it throws.
  • CHANGELOG has one BREAKING entry for the changed member names, with one line per shape and the new path, and one for enumeration side effects (lazy sequences run, a collection that throws now throws, a never-ending one hangs). The README has a new Collections section and a note that a struct reached by two routes is reported twice.
  • Known limits, with guard tests: a struct collection held in a property is still not enumerated, and skipped specs describe the fix for the next release. A struct reached by two routes is reported twice because boxed copies cannot be matched by reference.

Test plan

  • dotnet test on net8.0 and net10.0: 503 passed, 14 skipped, 0 failed. The 14 skipped include the next-release specs.
  • The new specs failed on the code before the fix, and the guards pass on v2.3.3 source.
  • Differential run of about 65 models against v2.3.3: every changed result is a new detection or a new path, and no new silent pass.
  • dotnet build -p:IncludeNetFramework=true builds net481 with no warnings.
  • CI, including net481 on Windows.

tgharold and others added 12 commits October 1, 2026 23:48
An item that is itself a collection is validated as an object but never
enumerated, so a List of List, a List of HashSet, or an object array that
holds a List passes with invalid objects inside. Arrays and ArrayList
are caught by accident through SyncRoot.

The new specs state the fixed behavior and fail until the fix lands:
index at each level (Value[0][0].Name), null items, repeated and cyclic
collections, sealed and struct collections, and an item that has its own
attributes. Guards pin what must keep working: attributes and
IValidatableObject on a collection item, leaf collections not being
enumerated, and a collection as the root object. Open cases are skipped
with their reasoning in NestedCollectionEdgeCaseTests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The validator enumerated a collection that a property holds, but never
an item that is itself a collection. A List of List, a List of HashSet,
or an object array that holds a List passed with invalid objects inside.
Arrays and ArrayList were reached by accident through SyncRoot.

TryValidateObjectRecursive now takes enumerateItems. An item that is a
collection is validated as an object first, so its attributes and
IValidatableObject still run, then its items are enumerated before its
properties are walked. The path has the index of each level:
Value[0][0].Name. A collection of leaf types is not enumerated.

IsLeafType now treats a sealed class or struct that is a collection as a
leaf only when it is a collection of leaf types. Without that, a List of
ImmutableList or of a sealed collection was skipped as a list of values.
A type that yields itself is answered as not a leaf, to end the check.

A default ImmutableArray item is skipped, because enumerating it throws
and it holds nothing. Any other item that throws when enumerated throws
from validation, like a collection that a property holds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A struct collection such as ImmutableArray is now validated as an item
of another collection, but a property that holds it is still not read,
because the validator walks reference-type properties only. The same
invalid objects pass or fail depending on one level of nesting.

A guard pins the gap. Skipped specs state the planned behavior for the
next release, with the plan and the steps for converting the guards.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mark the two changed member name formats as BREAKING, and add a
Collections section to the README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A class-level attribute that passes ValidationContext.MemberName, or a
Validate method that yields a null member name, gives an item result
with a null member name. Release 2.4.0 read the name to decide whether
it was an index, so it threw NullReferenceException. A name that starts
with "[" changed from "Value[0].[Totals]" to "Value[0][Totals]".

The tests pin the paths of 2.3.3 for these names and state the path of
a nested collection. They fail until the fix lands.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The validator joined the path of an item that is a collection by
looking at the start of each member name. A null name threw
NullReferenceException, and a name that starts with "[" lost its dot.

A private ValidationResult subclass now marks the results whose names
start with an item index, so the names the items choose are never
inspected. A null name gives "Value[0]." as in 2.3.3.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The BREAKING entry now states the rule: an item that is a collection
is enumerated before its properties are walked, so any path through a
property of the item changed. List the verified examples. Add the lazy
sequence, hang and stack depth notes, and fix the README claim about
dictionary values that are structs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The validator remembers objects by reference, and a boxed struct copy is
never the same reference. A struct that two properties hold was already
reported twice in 2.3.3. Since 2.4.0 an item collection that returns its
structs by enumeration and through a property, such as ArraySegment,
reports each one twice, and LinkedList three times.

Limitation guards, so a later fix is a deliberate change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A default ArraySegment, or a struct of your own that wraps a null array,
threw when the item was enumerated, where 2.3.3 returned valid. The
check for a default ImmutableArray found the type by name and covered
only that one. IsDefaultStruct compares the struct to its default value
with its own Equals, so it needs no list of types.

Tests cover ImmutableArray, ArraySegment and a struct of the caller's
own, boxed and unboxed, and the cases that must still be validated: a
segment with an array, an empty segment, and a segment of bytes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Add the gotcha that a struct reached by two routes is reported twice,
with the way out for a collection of the caller's own. Say that any
struct item that equals its default is skipped, not only ImmutableArray.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Replace the long BREAKING bullet with one BREAKING entry for the
changed member names, with one line per shape, and one for enumeration
side effects. Move the stack and known-limit notes to plain entries.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tgharold
tgharold merged commit 6af696c into master Oct 2, 2026
3 checks passed
@tgharold
tgharold deleted the tgh-20261001-nested-collections-items branch October 2, 2026 04:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant