Skip to content
Open
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
73 changes: 73 additions & 0 deletions src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,25 @@ internal static bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, W
return false;
}

// Type parameters (e.g., TRequest from a generic MapCommand<TRequest>() extension)
// have DeclaredAccessibility == NotApplicable. The concrete type is only known at
// call sites, not inside the generic method body where the endpoint delegate is
// defined. Walk constraint types to discover any validatable types reachable
// through type constraints.
if (typeSymbol is ITypeParameterSymbol typeParam)
{
// Add to visitedTypes BEFORE iterating constraints to prevent
// infinite recursion through circular constraints such as
// where T : class, IEnumerable<T>.
visitedTypes.Add(typeSymbol);
var foundValidatable = false;
foreach (var constraintType in typeParam.ConstraintTypes)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally looks reasonable, but I'm not sure of a good use case for this. Why would you make it generic and constrained instead of having the parameter as the type you need right away?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern comes from reusable endpoint mappers in vertical-slice / CQRS-style minimal APIs — one generic helper like MapValidated<T>(...) registers many request DTOs, so the concrete type only flows through the type parameter. But the stronger motivation is shape-independent: today the generator emits typeof(TSelf) into ValidatableInfoResolver.g.cs whenever a discovered model uses the CRTP pattern (e.g. class CreateOrderCommand : RequestBase<CreateOrderCommand> used directly as a handler parameter) — invalid C#, so the user's build breaks with errors inside generated code they can't edit. A source generator emitting uncompilable code is a correctness bug regardless of how common the trigger is; the constraint-walking half then makes discovery actually work for the generic-mapper shape instead of silently skipping it.

{
foundValidatable |= TryExtractValidatableType(constraintType, wellKnownTypes, validatableTypes, visitedTypes);
}
return foundValidatable;
}
Comment thread
ANcpLua marked this conversation as resolved.

// Skip types that are not accessible from generated code
if (typeSymbol.DeclaredAccessibility is not Accessibility.Public)
{
Expand Down Expand Up @@ -209,6 +228,16 @@ private static ImmutableArray<ValidatableProperty> ExtractValidatableMembers(ITy
validatableTypes,
visitedTypes);

// Skip properties whose type is a type parameter (e.g., TSelf
// from CRTP pattern RequestBase<TSelf>). The emitter would
// generate typeof(TSelf) which is not valid C#. Concrete
// validatable types reachable through constraints are already
// discovered by TryExtractValidatableType above.
if (ContainsTypeParameter(correspondingProperty.Type))
{
continue;
}

// Record primary-constructor parameters can carry [Display]/[DisplayName] too.
// Prefer the parameter's attribute over the property's.
var (paramLiteral, paramHasResource) = parameter.GetDisplayInfo(displayAttributeSymbol, displayNameAttributeSymbol);
Expand Down Expand Up @@ -276,6 +305,14 @@ private static ImmutableArray<ValidatableProperty> ExtractValidatableMembers(ITy
continue;
}

// Skip properties whose type is a type parameter (e.g., TSelf
// from CRTP pattern RequestBase<TSelf>). The emitter would
// generate typeof(TSelf) which is not valid C#.
if (ContainsTypeParameter(member.Type))
{
continue;
}

var (memberLiteral, memberHasResource) = member.GetDisplayInfo(displayAttributeSymbol, displayNameAttributeSymbol);

members.Add(new ValidatableProperty(
Expand Down Expand Up @@ -310,4 +347,40 @@ internal static bool HasIValidatableObjectInterface(ITypeSymbol typeSymbol, Well
var validatableObjectSymbol = wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_IValidatableObject);
return typeSymbol.ImplementsInterface(validatableObjectSymbol);
}

/// <summary>
/// Returns true if the given type symbol contains an unresolved type parameter
/// anywhere in its type tree. This catches not only bare <c>T</c> but also
/// constructed types like <c>List&lt;T&gt;</c>, <c>T[]</c>, <c>T?</c>, and
/// <c>Dictionary&lt;string, T&gt;</c> — all of which would produce invalid
/// <c>typeof(...)</c> expressions in the emitted code.
/// </summary>
private static bool ContainsTypeParameter(ITypeSymbol type)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this code covered by tests?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — as of 333f003: DoesNotEmitTypeofForTypeParameterMembersReachedThroughConstraint exercises exactly this path. Without the guard, the generated snapshot contains typeof(TSelf) and fails to compile.

{
// Bare type parameter: T, TSelf, TSelf?
if (type is ITypeParameterSymbol)
{
return true;
}

// Array: T[], T[,], List<T>[]
if (type is IArrayTypeSymbol arrayType)
{
return ContainsTypeParameter(arrayType.ElementType);
}

// Constructed generic: List<T>, Dictionary<string, T>, Nullable<T>, Func<T, bool>
if (type is INamedTypeSymbol { IsGenericType: true } namedType)
{
foreach (var typeArg in namedType.TypeArguments)
{
if (ContainsTypeParameter(typeArg))
{
return true;
}
}
}

return false;
}
}
Loading
Loading