Skip to content

Commit 0206cf0

Browse files
committed
Gate record primary-constructor members in the validations generator
The record primary-constructor path in ExtractValidatableMembers added members unconditionally, unlike the property path which skips members that have no validation attributes and whose type is not itself validatable. As a result any record with a primary constructor was emitted as a validatable type, which also made every property of that record type a validatable member of its containing types. This inflated generated code, added per-request traversal for members that can never produce an error, consumed the MaxDepth budget for nested record graphs, and made records behave differently from equivalently-shaped classes. Apply the same gate to the record path, capturing the previously-discarded TryExtractValidatableType result and checking the parameter as well as the corresponding property. Attributes on record primary-constructor parameters bind to the parameter rather than the property by default, so both are checked, matching how the runtime already resolves them. Fixes #68805
1 parent 130e3f5 commit 0206cf0

3 files changed

Lines changed: 120 additions & 1 deletion

File tree

src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,25 @@ private static ImmutableArray<ValidatableProperty> ExtractValidatableMembers(ITy
243243

244244
// Check if the property's type is validatable, this resolves
245245
// validatable types in the inheritance hierarchy
246-
_ = TryExtractValidatableType(
246+
var hasValidatableType = TryExtractValidatableType(
247247
correspondingProperty.Type,
248248
wellKnownTypes,
249249
validatableTypes,
250250
visitedTypes);
251251

252+
// If neither the parameter nor the corresponding property has validation
253+
// attributes, and the property's type is not itself validatable, skip it.
254+
// This mirrors the gate applied to non-record properties below and prevents
255+
// records with no validatable members from being emitted as validatable types.
256+
// Attributes on record primary constructor parameters bind to the parameter
257+
// rather than the property by default, so both are checked.
258+
if (!HasValidationAttributes(parameter, wellKnownTypes)
259+
&& !HasValidationAttributes(correspondingProperty, wellKnownTypes)
260+
&& !hasValidatableType)
261+
{
262+
continue;
263+
}
264+
252265
// Record primary-constructor parameters can carry [Display]/[DisplayName] too.
253266
// Prefer the parameter's attribute over the property's.
254267
var (paramLiteral, paramHasResource) = parameter.GetDisplayInfo(displayAttributeSymbol, displayNameAttributeSymbol);

src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.RecordType.cs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,4 +526,88 @@ async Task ValidInputProducesNoWarnings(Endpoint endpoint)
526526
});
527527

528528
}
529+
530+
[Fact]
531+
public Task DoesNotEmit_ForRecordWithNoValidatableMembers()
532+
{
533+
// A record whose primary-constructor parameters carry no validation attributes and
534+
// whose types are not themselves validatable should not be emitted as a validatable
535+
// type - matching the behavior of an equivalently-shaped class. A record with a
536+
// validation attribute on a primary-constructor parameter must still be emitted.
537+
var source = """
538+
using System.ComponentModel.DataAnnotations;
539+
using Microsoft.AspNetCore.Builder;
540+
using Microsoft.Extensions.Validation;
541+
using Microsoft.Extensions.DependencyInjection;
542+
543+
static class Program
544+
{
545+
public static void Main(string[] args)
546+
{
547+
var builder = WebApplication.CreateBuilder();
548+
builder.Services.AddValidation();
549+
var app = builder.Build();
550+
app.Run();
551+
}
552+
}
553+
554+
[ValidatableType]
555+
public class Holder
556+
{
557+
[Required]
558+
public string Name { get; set; } = "";
559+
public PlainRecord? PlainRec { get; set; }
560+
public PlainClass? PlainCls { get; set; }
561+
public MixedRecord? Mixed { get; set; }
562+
public ValidatedRecord? ValidatedRec { get; set; }
563+
}
564+
565+
// No validation attributes and no validatable member types -> should not be emitted.
566+
public record PlainRecord(int Number, string Text);
567+
568+
// Same shape as PlainRecord but a class -> already correctly not emitted; asserted for symmetry.
569+
public class PlainClass
570+
{
571+
public int Number { get; set; }
572+
public string Text { get; set; } = "";
573+
}
574+
575+
// Neither the primary-constructor parameters nor the body properties are validatable
576+
// -> the record must not be emitted (documents the case called out in #68805).
577+
public record MixedRecord(int CtorParam, string CtorText)
578+
{
579+
public int BodyProperty { get; set; }
580+
public string BodyText { get; set; } = "";
581+
}
582+
583+
// [Required] binds to the primary-constructor parameter -> must still be emitted.
584+
public record ValidatedRecord([Required] string Name, int Age);
585+
""";
586+
RunGenerator(source, out var compilation);
587+
return VerifyValidatableType(compilation, "Holder", (validationOptions, type) =>
588+
{
589+
// The containing type is validatable (it has a [Required] member).
590+
Assert.True(validationOptions.TryGetValidatableTypeInfo(type, out var holderInfo));
591+
592+
// Core repro from #68805: a record with no validatable members must NOT become a
593+
// validatable member of its containing type - matching the equivalently-shaped class.
594+
// Before the fix, PlainRec (but not PlainCls) was incorrectly a validatable member.
595+
Assert.True(holderInfo.TryFindProperty("Name", validationOptions, out _));
596+
Assert.False(holderInfo.TryFindProperty("PlainRec", validationOptions, out _));
597+
Assert.False(holderInfo.TryFindProperty("PlainCls", validationOptions, out _));
598+
Assert.False(holderInfo.TryFindProperty("Mixed", validationOptions, out _));
599+
Assert.True(holderInfo.TryFindProperty("ValidatedRec", validationOptions, out _));
600+
601+
// The unvalidatable records are also not emitted as standalone validatable types.
602+
Assert.False(validationOptions.TryGetValidatableTypeInfo(type.Assembly.GetType("PlainRecord")!, out _));
603+
Assert.False(validationOptions.TryGetValidatableTypeInfo(type.Assembly.GetType("PlainClass")!, out _));
604+
Assert.False(validationOptions.TryGetValidatableTypeInfo(type.Assembly.GetType("MixedRecord")!, out _));
605+
606+
// Regression guard: a record with a validation attribute on a primary-constructor
607+
// parameter is still emitted (the parameter, not the property, carries the attribute).
608+
Assert.True(validationOptions.TryGetValidatableTypeInfo(type.Assembly.GetType("ValidatedRecord")!, out _));
609+
610+
return Task.CompletedTask;
611+
});
612+
}
529613
}

src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGeneratorTestBase.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,28 @@ internal static Task Verify(string source, out Compilation compilation)
6464
.DisableRequireUniquePrefix();
6565
}
6666

67+
// Runs the generator against the source and returns the resulting compilation without
68+
// producing a Verify snapshot. Useful for tests that assert the generated behavior at
69+
// runtime (via VerifyValidatableType/VerifyEndpoint) rather than the generated source text.
70+
internal static void RunGenerator(string source, out Compilation compilation)
71+
{
72+
var references = GetMetadataReferences();
73+
var inputCompilation = CSharpCompilation.Create("ValidationsGeneratorSample",
74+
[CSharpSyntaxTree.ParseText(source, options: ParseOptions, path: "Program.cs")],
75+
references,
76+
new CSharpCompilationOptions(OutputKind.ConsoleApplication));
77+
78+
var programEmitResult = inputCompilation.Emit(Stream.Null);
79+
if (!programEmitResult.Success)
80+
{
81+
throw new InvalidOperationException($"Failed to compile Program.cs: {string.Join(Environment.NewLine, programEmitResult.Diagnostics)}");
82+
}
83+
84+
var generator = new ValidationsGenerator();
85+
var driver = CSharpGeneratorDriver.Create(generators: [generator.AsSourceGenerator()], parseOptions: ParseOptions);
86+
driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out compilation, out _);
87+
}
88+
6789
private static IEnumerable<MetadataReference> GetMetadataReferences()
6890
=> AppDomain.CurrentDomain.GetAssemblies()
6991
.Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location))

0 commit comments

Comments
 (0)