dotnet / dotnet/aspnetcore

Validation source generator emits ValidatableTypeInfo for records with no validation attributes

Open
#68,805 2 comments 0 reactions 0 assignees View on GitHub
area-minimal feature-validation
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 6h
Merged PRs (30d)
290

Description

The validation source generator treats record primary constructor parameters differently from every other member: they are added unconditionally, without checking whether anything about them is actually validatable.

In `ValidationsGenerator.TypesParser.ExtractValidatableMembers`, the property path gates each member:

```csharp
var hasValidatableType = TryExtractValidatableType(member.Type, wellKnownTypes, validatableTypes, visitedTypes);

// If the member has no validation attributes or validatable types, skip it.
if (!HasValidationAttributes(member, wellKnownTypes) && !hasValidatableType)
{
continue;
}
```

The record primary constructor path above it has no equivalent check. After the service/`SkipValidation`/accessibility/`JsonIgnore` filters, it calls `members.Add(...)` unconditionally.

Because a type is emitted whenever `members` is non-empty, any record with a primary constructor becomes a validatable type, which then makes every property of that record type validatable in its containing types.

### Repro

With `AddValidation()` and an endpoint taking `Holder`:

```csharp
public class Holder
{
[Required] public string Name { get; set; } = "";
public PlainRecord? Rec { get; set; }
public PlainClass? Cls { get; set; }
}

public record PlainRecord(int Number, string Text);

public class PlainClass
{
public int Number { get; set; }
public string Text { get; set; } = "";
}
```

`PlainRecord` is emitted with members `Number` and `Text`, and `Holder.Rec` becomes a validatable member. `PlainClass` is correctly not emitted and `Holder.Cls` is not a member, despite being the same shape.

The asymmetry is visible within a single record too. For

```csharp
public record MixedRecord(int CtorParam, string CtorText)
{
public int BodyProperty { get; set; }
public string BodyText { get; set; } = "";
}
```

only `CtorParam` and `CtorText` are emitted. The body properties are correctly skipped.

### Impact

- Generated code size grows with every record in the app, including DTOs and strongly typed IDs that carry no validation.
- Each request pays reflection and traversal for members that can never produce an error.
- Records and classes of identical shape behave differently, which is surprising.
- Useless recursion consumes the `MaxDepth` budget (default 32), so deeply nested record graphs can hit the depth limit where equivalent classes would not.

### Suggested fix

Apply the same gate to the record path, checking the parameter as well as the property. Attributes on record primary constructor parameters bind to the parameter rather than the property by default, and the runtime already accounts for this in `ValidationAttributeCache.GetPropertyValidationAttributes`, which inspects constructor parameters alongside the property. The generator side needs the same treatment:

```csharp
var hasValidatableType = TryExtractValidatableType(correspondingProperty.Type, wellKnownTypes, validatableTypes, visitedTypes);

if (!HasValidationAttributes(parameter, wellKnownTypes)
&& !HasValidationAttributes(correspondingProperty, wellKnownTypes)
&& !hasValidatableType)
{
continue;
}
```

`HasValidationAttributes` already accepts an `ISymbol`, so it works for both. Note the existing call site discards the `TryExtractValidatableType` result with `_ =`, so that would need to be captured.

Contributor guide

Open the contributing guide

Research direction

Start in ValidationsGenerator.TypesParser.ExtractValidatableMembers and compare the record primary-constructor path with the existing property gate; inspect ValidationAttributeCache.GetPropertyValidationAttributes for how parameter and property attributes are handled. Add regression coverage using the PlainRecord, PlainClass, and MixedRecord repros, and verify records without validation attributes or validatable members are no longer emitted.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
backend-api-design, tooling
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.