Skip to content

Commit

Permalink
Fixes nullability issues with dictionaries (#3023)
Browse files Browse the repository at this point in the history
- Support nullable value types as `Dictionary` values.
- Support things that implement `IDictionary` or `IReadOnlyDictionary`.
  • Loading branch information
ozziepeeps committed Aug 13, 2024
1 parent 30fb944 commit f140d98
Show file tree
Hide file tree
Showing 4 changed files with 204 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public static class MemberInfoExtensions
private const string NullableFlagsFieldName = "NullableFlags";
private const string NullableContextAttributeFullTypeName = "System.Runtime.CompilerServices.NullableContextAttribute";
private const string FlagFieldName = "Flag";
private const int NotAnnotated = 1; // See https://github.com/dotnet/roslyn/blob/af7b0ebe2b0ed5c335a928626c25620566372dd1/docs/features/nullable-metadata.md?plain=1#L40

public static IEnumerable<object> GetInlineAndMetadataAttributes(this MemberInfo memberInfo)
{
Expand Down Expand Up @@ -50,7 +51,7 @@ public static bool IsNonNullableReferenceType(this MemberInfo memberInfo)

if (nullableAttribute.GetType().GetField(NullableFlagsFieldName) is FieldInfo field &&
field.GetValue(nullableAttribute) is byte[] flags &&
flags.Length >= 1 && flags[0] == 1)
flags.Length >= 1 && flags[0] == NotAnnotated)
{
return true;
}
Expand All @@ -67,17 +68,39 @@ public static bool IsDictionaryValueNonNullable(this MemberInfo memberInfo)
if (memberType.IsValueType) return false;

var nullableAttribute = memberInfo.GetNullableAttribute();
var genericArguments = memberType.GetGenericArguments();

if (genericArguments.Length != 2)
{
return false;
}

var valueArgument = genericArguments[1];
var valueArgumentIsNullable = valueArgument.IsGenericType && valueArgument.GetGenericTypeDefinition() == typeof(Nullable<>);

if (nullableAttribute == null)
{
return memberInfo.GetNullableFallbackValue();
return !valueArgumentIsNullable && memberInfo.GetNullableFallbackValue();
}

if (nullableAttribute.GetType().GetField(NullableFlagsFieldName) is FieldInfo field &&
field.GetValue(nullableAttribute) is byte[] flags &&
flags.Length == 3 && flags[2] == 1)
if (nullableAttribute.GetType().GetField(NullableFlagsFieldName) is FieldInfo field)
{
return true;
if (field.GetValue(nullableAttribute) is byte[] flags)
{
// See https://github.com/dotnet/roslyn/blob/af7b0ebe2b0ed5c335a928626c25620566372dd1/docs/features/nullable-metadata.md
// Observations in the debugger show that the arity of the flags array is 3 only if all 3 items are reference types, i.e.
// Dictionary<string, object> would have arity 3 (one for the Dictionary, one for the string key, one for the object value),
// however Dictionary<string, int> would have arity 2 (one for the Dictionary, one for the string key), the value is skipped
// due it being a value type.
if (flags.Length == 2) // Value in the dictionary is a value type.
{
return !valueArgumentIsNullable;
}
else if (flags.Length == 3) // Value in the dictionary is a reference type.
{
return flags[2] == NotAnnotated;
}
}
}

return false;
Expand Down Expand Up @@ -108,7 +131,7 @@ private static bool GetNullableFallbackValue(this MemberInfo memberInfo)
if (nullableContext != null)
{
if (nullableContext.GetType().GetField(FlagFieldName) is FieldInfo field &&
field.GetValue(nullableContext) is byte flag && flag == 1)
field.GetValue(nullableContext) is byte flag && flag == NotAnnotated)
{
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,26 @@ private OpenApiSchema GenerateSchemaForMember(
}

// NullableAttribute behaves differently for Dictionaries
if (schema.AdditionalPropertiesAllowed && modelType.IsGenericType &&
modelType.GetGenericTypeDefinition() == typeof(Dictionary<,>))
if (schema.AdditionalPropertiesAllowed && modelType.IsGenericType)
{
schema.AdditionalProperties.Nullable = !memberInfo.IsDictionaryValueNonNullable();
var genericTypes = modelType
.GetInterfaces()
#if NETSTANDARD2_0
.Concat(new[] { modelType })
#else
.Append(modelType)
#endif
.Where(t => t.IsGenericType)
.ToArray();

var isDictionaryType =
genericTypes.Any(t => t.GetGenericTypeDefinition() == typeof(IDictionary<,>)) ||
genericTypes.Any(t => t.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>));

if (isDictionaryType)
{
schema.AdditionalProperties.Nullable = !memberInfo.IsDictionaryValueNonNullable();
}
}

schema.ApplyValidationAttributes(customAttributes);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -696,11 +696,131 @@ public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes(
}

[Theory]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryWithNonNullableContent), true, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryWithNonNullableContent), false, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryWithNullableContent), false, true)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryWithNullableContent), true, true)]
public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations(
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryInNonNullableContent), true, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryInNonNullableContent), false, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryInNullableContent), false, true)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryInNullableContent), true, true)]
public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_Dictionary(
Type declaringType,
string propertyName,
bool expectedNullableProperty,
bool expectedNullableContent)
{
var subject = Subject(
configureGenerator: c => c.SupportNonNullableReferenceTypes = true
);
var schemaRepository = new SchemaRepository();

var referenceSchema = subject.GenerateSchema(declaringType, schemaRepository);

var propertySchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName];
var contentSchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName].AdditionalProperties;
Assert.Equal(expectedNullableProperty, propertySchema.Nullable);
Assert.Equal(expectedNullableContent, contentSchema.Nullable);
}

[Theory]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIDictionaryInNonNullableContent), true, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIDictionaryInNonNullableContent), false, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIDictionaryInNullableContent), false, true)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIDictionaryInNullableContent), true, true)]
public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_IDictionary(
Type declaringType,
string propertyName,
bool expectedNullableProperty,
bool expectedNullableContent)
{
var subject = Subject(
configureGenerator: c => c.SupportNonNullableReferenceTypes = true
);
var schemaRepository = new SchemaRepository();

var referenceSchema = subject.GenerateSchema(declaringType, schemaRepository);

var propertySchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName];
var contentSchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName].AdditionalProperties;
Assert.Equal(expectedNullableProperty, propertySchema.Nullable);
Assert.Equal(expectedNullableContent, contentSchema.Nullable);
}

[Theory]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIReadOnlyDictionaryInNonNullableContent), true, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIReadOnlyDictionaryInNonNullableContent), false, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIReadOnlyDictionaryInNullableContent), false, true)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIReadOnlyDictionaryInNullableContent), true, true)]
public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_IReadOnlyDictionary(
Type declaringType,
string propertyName,
bool expectedNullableProperty,
bool expectedNullableContent)
{
var subject = Subject(
configureGenerator: c => c.SupportNonNullableReferenceTypes = true
);
var schemaRepository = new SchemaRepository();

var referenceSchema = subject.GenerateSchema(declaringType, schemaRepository);

var propertySchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName];
var contentSchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName].AdditionalProperties;
Assert.Equal(expectedNullableProperty, propertySchema.Nullable);
Assert.Equal(expectedNullableContent, contentSchema.Nullable);
}

[Theory]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryWithValueTypeInNonNullableContent), true, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryWithValueTypeInNonNullableContent), false, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryWithValueTypeInNullableContent), false, true)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryWithValueTypeInNullableContent), true, true)]
public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_DictionaryWithValueType(
Type declaringType,
string propertyName,
bool expectedNullableProperty,
bool expectedNullableContent)
{
var subject = Subject(
configureGenerator: c => c.SupportNonNullableReferenceTypes = true
);
var schemaRepository = new SchemaRepository();

var referenceSchema = subject.GenerateSchema(declaringType, schemaRepository);

var propertySchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName];
var contentSchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName].AdditionalProperties;
Assert.Equal(expectedNullableProperty, propertySchema.Nullable);
Assert.Equal(expectedNullableContent, contentSchema.Nullable);
}

[Theory]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIDictionaryWithValueTypeInNonNullableContent), true, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIDictionaryWithValueTypeInNonNullableContent), false, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIDictionaryWithValueTypeInNullableContent), false, true)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIDictionaryWithValueTypeInNullableContent), true, true)]
public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_IDictionaryWithValueType(
Type declaringType,
string propertyName,
bool expectedNullableProperty,
bool expectedNullableContent)
{
var subject = Subject(
configureGenerator: c => c.SupportNonNullableReferenceTypes = true
);
var schemaRepository = new SchemaRepository();

var referenceSchema = subject.GenerateSchema(declaringType, schemaRepository);

var propertySchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName];
var contentSchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName].AdditionalProperties;
Assert.Equal(expectedNullableProperty, propertySchema.Nullable);
Assert.Equal(expectedNullableContent, contentSchema.Nullable);
}

[Theory]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIReadOnlyDictionaryWithValueTypeInNonNullableContent), true, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIReadOnlyDictionaryWithValueTypeInNonNullableContent), false, false)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIReadOnlyDictionaryWithValueTypeInNullableContent), false, true)]
[InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIReadOnlyDictionaryWithValueTypeInNullableContent), true, true)]
public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_IReadOnlyDictionaryWithValueType(
Type declaringType,
string propertyName,
bool expectedNullableProperty,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,35 @@ public class TypeWithNullableContext
public List<SubTypeWithOneNullableContent>? NullableList { get; set; }
public List<SubTypeWithOneNonNullableContent> NonNullableList { get; set; } = default!;

public Dictionary<string, string>? NullableDictionaryWithNonNullableContent { get; set; }
public Dictionary<string, string> NonNullableDictionaryWithNonNullableContent { get; set; } = default!;
public Dictionary<string, string?> NonNullableDictionaryWithNullableContent { get; set; } = default!;
public Dictionary<string, string?>? NullableDictionaryWithNullableContent { get; set; }
public Dictionary<string, string>? NullableDictionaryInNonNullableContent { get; set; }
public Dictionary<string, string> NonNullableDictionaryInNonNullableContent { get; set; } = default!;
public Dictionary<string, string?> NonNullableDictionaryInNullableContent { get; set; } = default!;
public Dictionary<string, string?>? NullableDictionaryInNullableContent { get; set; }

public IDictionary<string, string>? NullableIDictionaryInNonNullableContent { get; set; }
public IDictionary<string, string> NonNullableIDictionaryInNonNullableContent { get; set; } = default!;
public IDictionary<string, string?> NonNullableIDictionaryInNullableContent { get; set; } = default!;
public IDictionary<string, string?>? NullableIDictionaryInNullableContent { get; set; }

public IReadOnlyDictionary<string, string>? NullableIReadOnlyDictionaryInNonNullableContent { get; set; }
public IReadOnlyDictionary<string, string> NonNullableIReadOnlyDictionaryInNonNullableContent { get; set; } = default!;
public IReadOnlyDictionary<string, string?> NonNullableIReadOnlyDictionaryInNullableContent { get; set; } = default!;
public IReadOnlyDictionary<string, string?>? NullableIReadOnlyDictionaryInNullableContent { get; set; }

public Dictionary<string, int>? NullableDictionaryWithValueTypeInNonNullableContent { get; set; }
public Dictionary<string, int> NonNullableDictionaryWithValueTypeInNonNullableContent { get; set; } = default!;
public Dictionary<string, int?> NonNullableDictionaryWithValueTypeInNullableContent { get; set; } = default!;
public Dictionary<string, int?>? NullableDictionaryWithValueTypeInNullableContent { get; set; }

public IDictionary<string, int>? NullableIDictionaryWithValueTypeInNonNullableContent { get; set; }
public IDictionary<string, int> NonNullableIDictionaryWithValueTypeInNonNullableContent { get; set; } = default!;
public IDictionary<string, int?> NonNullableIDictionaryWithValueTypeInNullableContent { get; set; } = default!;
public IDictionary<string, int?>? NullableIDictionaryWithValueTypeInNullableContent { get; set; }

public IReadOnlyDictionary<string, int>? NullableIReadOnlyDictionaryWithValueTypeInNonNullableContent { get; set; }
public IReadOnlyDictionary<string, int> NonNullableIReadOnlyDictionaryWithValueTypeInNonNullableContent { get; set; } = default!;
public IReadOnlyDictionary<string, int?> NonNullableIReadOnlyDictionaryWithValueTypeInNullableContent { get; set; } = default!;
public IReadOnlyDictionary<string, int?>? NullableIReadOnlyDictionaryWithValueTypeInNullableContent { get; set; }

public class SubTypeWithOneNullableContent
{
Expand All @@ -33,4 +58,4 @@ public class SubTypeWithOneNonNullableContent

}
#nullable restore
}
}

0 comments on commit f140d98

Please sign in to comment.