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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

33 changes: 26 additions & 7 deletions TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1538,16 +1538,35 @@ private static void GeneratePropertyInjections(CodeWriter writer, INamedTypeSymb
{
// For init-only properties, use UnsafeAccessor on .NET 8+ (but not for generic types)
// UnsafeAccessor doesn't work with open generic types
var containingTypeName = property.ContainingType.GloballyQualified();
var isGenericContainingType = property.ContainingType.IsGenericType;
// IMPORTANT: Use currentType (which is the closed generic type from the inheritance chain)
// instead of property.ContainingType (which is the open generic type definition)
var containingTypeName = currentType.GloballyQualified();
var isGenericContainingType = currentType.IsGenericType;

if (isGenericContainingType)
{
// For generic types, init-only properties with data sources are not supported
// UnsafeAccessor doesn't work with open generic types and reflection is not AOT-compatible
writer.AppendLine($"Setter = (instance, value) => throw new global::System.NotSupportedException(");
writer.AppendLine($" \"Init-only property '{property.Name}' on generic type '{containingTypeName}' cannot be set. \" +");
writer.AppendLine($" \"Use a regular settable property or constructor injection instead.\"),");
// For init-only properties on generic types, use reflection with the closed generic type.
// UnsafeAccessor doesn't work with generic base classes, but reflection does.
// This is AOT-compatible because we use the closed generic type known at compile time.
writer.AppendLine("Setter = (instance, value) =>");
writer.AppendLine("{");
writer.Indent();
writer.AppendLine($"var backingField = typeof({containingTypeName}).GetField(\"<{property.Name}>k__BackingField\",");
writer.AppendLine(" global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.NonPublic);");
writer.AppendLine("if (backingField != null)");
writer.AppendLine("{");
writer.Indent();
writer.AppendLine("backingField.SetValue(instance, value);");
writer.Unindent();
writer.AppendLine("}");
writer.AppendLine("else");
writer.AppendLine("{");
writer.Indent();
writer.AppendLine($"throw new global::System.InvalidOperationException(\"Could not find backing field for property {property.Name} on type {containingTypeName}\");");
writer.Unindent();
writer.AppendLine("}");
writer.Unindent();
writer.AppendLine("},");
}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,58 @@ internal sealed record PropertyDataSourceModel : IEquatable<PropertyDataSourceMo
/// </summary>
public required string ContainingTypeFullyQualified { get; init; }

/// <summary>
/// CLR type name format for UnsafeAccessorType attribute (e.g., "Namespace.GenericType`1[[Namespace.TypeArg, Assembly]]")
/// Only populated for generic containing types.
/// </summary>
public required string? ContainingTypeClrName { get; init; }

/// <summary>
/// The open generic type definition with type parameters (e.g., "global::NS.GenericBase&lt;T&gt;")
/// Only populated for generic containing types.
/// </summary>
public required string? ContainingTypeOpenGeneric { get; init; }

/// <summary>
/// Comma-separated list of type parameter names (e.g., "T" or "T1, T2")
/// Only populated for generic containing types.
/// </summary>
public required string? GenericTypeParameters { get; init; }

/// <summary>
/// Comma-separated list of concrete type arguments (e.g., "global::NS.ProviderType")
/// Only populated for generic containing types.
/// </summary>
public required string? GenericTypeArguments { get; init; }

/// <summary>
/// Type parameter constraints (e.g., "where T : class" or "where T1 : class where T2 : struct")
/// Only populated for generic containing types that have constraints.
/// </summary>
public required string? GenericTypeConstraints { get; init; }

/// <summary>
/// Whether the property has an init-only setter
/// </summary>
public required bool IsInitOnly { get; init; }

/// <summary>
/// Whether the containing type (where the property is declared) is a generic type
/// </summary>
public required bool IsContainingTypeGeneric { get; init; }

/// <summary>
/// Whether the property is static
/// </summary>
public required bool IsStatic { get; init; }

/// <summary>
/// If the property type is a type parameter in the original definition (e.g., "T"),
/// this contains the type parameter name. Otherwise null.
/// Used for UnsafeAccessor generation on generic types.
/// </summary>
public required string? PropertyTypeAsTypeParameter { get; init; }

/// <summary>
/// Whether the property type is a value type
/// </summary>
Expand Down Expand Up @@ -112,8 +154,15 @@ public bool Equals(PropertyDataSourceModel? other)
&& PropertyTypeFullyQualified == other.PropertyTypeFullyQualified
&& PropertyTypeForTypeof == other.PropertyTypeForTypeof
&& ContainingTypeFullyQualified == other.ContainingTypeFullyQualified
&& ContainingTypeClrName == other.ContainingTypeClrName
&& ContainingTypeOpenGeneric == other.ContainingTypeOpenGeneric
&& GenericTypeParameters == other.GenericTypeParameters
&& GenericTypeArguments == other.GenericTypeArguments
&& GenericTypeConstraints == other.GenericTypeConstraints
&& IsInitOnly == other.IsInitOnly
&& IsContainingTypeGeneric == other.IsContainingTypeGeneric
&& IsStatic == other.IsStatic
&& PropertyTypeAsTypeParameter == other.PropertyTypeAsTypeParameter
&& IsValueType == other.IsValueType
&& IsNullableValueType == other.IsNullableValueType
&& AttributeTypeName == other.AttributeTypeName
Expand All @@ -129,8 +178,15 @@ public override int GetHashCode()
hash = (hash * 397) ^ PropertyTypeFullyQualified.GetHashCode();
hash = (hash * 397) ^ PropertyTypeForTypeof.GetHashCode();
hash = (hash * 397) ^ ContainingTypeFullyQualified.GetHashCode();
hash = (hash * 397) ^ (ContainingTypeClrName?.GetHashCode() ?? 0);
hash = (hash * 397) ^ (ContainingTypeOpenGeneric?.GetHashCode() ?? 0);
hash = (hash * 397) ^ (GenericTypeParameters?.GetHashCode() ?? 0);
hash = (hash * 397) ^ (GenericTypeArguments?.GetHashCode() ?? 0);
hash = (hash * 397) ^ (GenericTypeConstraints?.GetHashCode() ?? 0);
hash = (hash * 397) ^ IsInitOnly.GetHashCode();
hash = (hash * 397) ^ IsContainingTypeGeneric.GetHashCode();
hash = (hash * 397) ^ IsStatic.GetHashCode();
hash = (hash * 397) ^ (PropertyTypeAsTypeParameter?.GetHashCode() ?? 0);
hash = (hash * 397) ^ IsValueType.GetHashCode();
hash = (hash * 397) ^ IsNullableValueType.GetHashCode();
hash = (hash * 397) ^ AttributeTypeName.GetHashCode();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public static PropertyInjectionPlan BuildSourceGeneratedPlan(Type type)
WalkInheritanceChain(type, currentType =>
{
var propertySource = PropertySourceRegistry.GetSource(currentType);

if (propertySource?.ShouldInitialize == true)
{
foreach (var metadata in propertySource.GetPropertyMetadata())
Expand Down
61 changes: 57 additions & 4 deletions TUnit.Core/PropertyInjection/PropertySetterFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,34 @@ internal static class PropertySetterFactory
#endif
}

var backingField = GetBackingField(property);
if (backingField != null)
// Check if the declaring type is an open generic type definition
// In this case, we need to resolve the backing field at runtime using the instance's actual type
var declaringType = property.DeclaringType;
if (declaringType != null && declaringType.IsGenericTypeDefinition)
{
// For open generic types, we must resolve the backing field at runtime
// because we don't know the closed generic type until we have an instance
return (instance, value) =>
{
var instanceType = instance.GetType();
var backingField = GetBackingField(property, instanceType);
if (backingField != null)
{
backingField.SetValue(instance, value);
}
else
{
throw new InvalidOperationException(
$"Property '{property.Name}' on type '{declaringType.Name}' " +
$"is not writable and no backing field was found for instance type '{instanceType.Name}'.");
}
};
}

var backingFieldStatic = GetBackingField(property);
if (backingFieldStatic != null)
{
return (instance, value) => backingField.SetValue(instance, value);
return (instance, value) => backingFieldStatic.SetValue(instance, value);
}

throw new InvalidOperationException(
Expand All @@ -84,14 +108,25 @@ internal static class PropertySetterFactory
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Backing field access for init-only properties requires reflection")]
#endif
private static FieldInfo? GetBackingField(PropertyInfo property)
private static FieldInfo? GetBackingField(PropertyInfo property, Type? instanceType = null)
{
var declaringType = property.DeclaringType;
if (declaringType == null)
{
return null;
}

// If the declaring type is an open generic type definition (e.g., GenericBase<T>),
// we need to find the closed generic type from the instance type's hierarchy
if (declaringType.IsGenericTypeDefinition && instanceType != null)
{
declaringType = FindClosedGenericType(instanceType, declaringType);
if (declaringType == null)
{
return null;
}
}

var backingFieldFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy;

// Try compiler-generated backing field name
Expand Down Expand Up @@ -128,6 +163,24 @@ internal static class PropertySetterFactory
return null;
}

/// <summary>
/// Finds the closed generic type in the inheritance hierarchy that matches the open generic type definition.
/// </summary>
private static Type? FindClosedGenericType(Type instanceType, Type openGenericTypeDefinition)
{
var currentType = instanceType;
while (currentType != null && currentType != typeof(object))
{
if (currentType.IsGenericType &&
currentType.GetGenericTypeDefinition() == openGenericTypeDefinition)
{
return currentType;
}
currentType = currentType.BaseType;
}
return null;
}

/// <summary>
/// Helper method to get field with proper trimming suppression.
/// </summary>
Expand Down
41 changes: 35 additions & 6 deletions TUnit.Core/PropertySourceRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public static PropertyInjectionData[] DiscoverInjectableProperties([DynamicallyA
{
try
{
var injection = CreatePropertyInjection(property);
var injection = CreatePropertyInjection(property, type);
injectableProperties.Add(injection);
}
catch (Exception ex)
Expand Down Expand Up @@ -150,9 +150,9 @@ private static PropertyDataSource ConvertToPropertyDataSource(PropertyInjectionM
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Backing field access for init-only properties requires reflection")]
#endif
private static PropertyInjectionData CreatePropertyInjection(System.Reflection.PropertyInfo property)
private static PropertyInjectionData CreatePropertyInjection(System.Reflection.PropertyInfo property, Type? testClassType = null)
{
var setter = CreatePropertySetter(property);
var setter = CreatePropertySetter(property, testClassType);

return new PropertyInjectionData
{
Expand All @@ -170,7 +170,7 @@ private static PropertyInjectionData CreatePropertyInjection(System.Reflection.P
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Backing field access for init-only properties requires reflection")]
#endif
private static Action<object, object?> CreatePropertySetter(System.Reflection.PropertyInfo property)
private static Action<object, object?> CreatePropertySetter(System.Reflection.PropertyInfo property, Type? testClassType = null)
{
if (property.CanWrite && property.SetMethod != null)
{
Expand All @@ -187,7 +187,7 @@ private static PropertyInjectionData CreatePropertyInjection(System.Reflection.P
#endif
}

var backingField = GetBackingField(property);
var backingField = GetBackingField(property, testClassType);
if (backingField != null)
{
return (instance, value) => backingField.SetValue(instance, value);
Expand All @@ -204,14 +204,25 @@ private static PropertyInjectionData CreatePropertyInjection(System.Reflection.P
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Backing field discovery needed for init-only properties in reflection mode")]
#endif
private static System.Reflection.FieldInfo? GetBackingField(System.Reflection.PropertyInfo property)
private static System.Reflection.FieldInfo? GetBackingField(System.Reflection.PropertyInfo property, Type? testClassType = null)
{
var declaringType = property.DeclaringType;
if (declaringType == null)
{
return null;
}

// If the declaring type is an open generic type definition (e.g., GenericBase<T>),
// we need to find the closed generic type from the test class hierarchy
if (declaringType.IsGenericTypeDefinition && testClassType != null)
{
declaringType = FindClosedGenericType(testClassType, declaringType);
if (declaringType == null)
{
return null;
}
}

var backingFieldFlags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.FlattenHierarchy;

var backingFieldName = $"<{property.Name}>k__BackingField";
Expand Down Expand Up @@ -248,6 +259,24 @@ private static PropertyInjectionData CreatePropertyInjection(System.Reflection.P
return null;
}

/// <summary>
/// Finds the closed generic type in the inheritance hierarchy that matches the open generic type definition
/// </summary>
private static Type? FindClosedGenericType(Type testClassType, Type openGenericTypeDefinition)
{
var currentType = testClassType;
while (currentType != null && currentType != typeof(object))
{
if (currentType.IsGenericType &&
currentType.GetGenericTypeDefinition() == openGenericTypeDefinition)
{
return currentType;
}
currentType = currentType.BaseType;
}
return null;
}

/// <summary>
/// Helper method to get field with proper trimming suppression
/// </summary>
Expand Down
Loading
Loading