From cee3c33605eec3aa028856025130bb1a2a4971bd Mon Sep 17 00:00:00 2001 From: Dan Stelljes Date: Thu, 10 Feb 2022 14:17:14 -0600 Subject: [PATCH 1/3] Shim .NET 6 NullableInfoContext APIs --- .../Infrastructure/NullabilityInfo.cs | 72 +++ .../Infrastructure/NullabilityInfoContext.cs | 574 ++++++++++++++++++ .../Infrastructure/NullabilityState.cs | 28 + .../Infrastructure/ReflectionExtensions.cs | 84 +++ 4 files changed, 758 insertions(+) create mode 100644 src/Chr.Avro/Infrastructure/NullabilityInfo.cs create mode 100644 src/Chr.Avro/Infrastructure/NullabilityInfoContext.cs create mode 100644 src/Chr.Avro/Infrastructure/NullabilityState.cs diff --git a/src/Chr.Avro/Infrastructure/NullabilityInfo.cs b/src/Chr.Avro/Infrastructure/NullabilityInfo.cs new file mode 100644 index 000000000..379866cc0 --- /dev/null +++ b/src/Chr.Avro/Infrastructure/NullabilityInfo.cs @@ -0,0 +1,72 @@ +namespace Chr.Avro.Infrastructure +{ + using System; + + /// + /// Represents nullability information. + /// + /// + /// This type is a stand-in for the NullabilityInfo class available in .NET 6 and above. See + /// the .NET runtime source + /// (also MIT licensed) for the reference implementation. + /// + internal sealed class NullabilityInfo + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The type of the member or generic parameter to which this instance belongs. + /// + /// + /// The nullability read state of the member. + /// + /// + /// The nullability write state of the member. + /// + /// + /// The nullability information for the element type of the array. + /// + /// + /// The nullability information for each type parameter. + /// + internal NullabilityInfo( + Type type, + NullabilityState readState, + NullabilityState writeState, + NullabilityInfo? elementType, + NullabilityInfo[] typeArguments) + { + Type = type; + ReadState = readState; + WriteState = writeState; + ElementType = elementType; + GenericTypeArguments = typeArguments; + } + + /// + /// Gets the type of the member or generic parameter to which this instance belongs. + /// + public Type Type { get; } + + /// + /// Gets or sets the nullability read state of the member. + /// + public NullabilityState ReadState { get; internal set; } + + /// + /// Gets or sets the nullability write state of the member. + /// + public NullabilityState WriteState { get; internal set; } + + /// + /// Gets the nullability information for the element type of the array. + /// + public NullabilityInfo? ElementType { get; } + + /// + /// Gets the nullability information for each type parameter. + /// + public NullabilityInfo[] GenericTypeArguments { get; } + } +} diff --git a/src/Chr.Avro/Infrastructure/NullabilityInfoContext.cs b/src/Chr.Avro/Infrastructure/NullabilityInfoContext.cs new file mode 100644 index 000000000..461a56f0e --- /dev/null +++ b/src/Chr.Avro/Infrastructure/NullabilityInfoContext.cs @@ -0,0 +1,574 @@ +namespace Chr.Avro.Infrastructure +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Linq; + using System.Reflection; + + /// + /// Provides APIs for populating nullability information/context from reflection members + /// and . + /// + /// + /// This type is a stand-in for the NullabilityInfoContext class available in .NET 6 and above. See + /// the .NET runtime source + /// (also MIT licensed) for the reference implementation. + /// + internal sealed class NullabilityInfoContext + { + private const string CompilerServicesNamespace = "System.Runtime.CompilerServices"; + + private readonly Dictionary context = new(); + private readonly Dictionary publicOnlyModules = new(); + + [Flags] + private enum NotAnnotatedStatus + { + /// + /// No restriction, all members annotated. + /// + None = 0x0, + + /// + /// Private members not annotated. + /// + Private = 0x1, + + /// + /// Internal members not annotated. + /// + Internal = 0x2, + } + + /// + /// Populates a for the given . If + /// the nullablePublicOnly feature is set for an assembly, like it does in the .NET + /// SDK, the private and/or internal member's nullability attributes are omitted, and the + /// API will return the state. + /// + /// + /// The field for which to populate the nullability information. + /// + /// + /// is null. + /// + /// + /// A instance. + /// + public NullabilityInfo Create(FieldInfo fieldInfo) + { + var attributes = fieldInfo.GetCustomAttributesData(); + var parser = IsPrivateOrInternalFieldAndAnnotationDisabled(fieldInfo) + ? NullableAttributeStateParser.Unknown + : CreateParser(attributes); + + var nullability = GetNullabilityInfo(fieldInfo, fieldInfo.FieldType, parser); + CheckNullabilityAttributes(nullability, attributes); + + return nullability; + } + + /// + /// Populates a for the given . + /// If the nullablePublicOnly feature is set for an assembly, like it does in the + /// .NET SDK, the private and/or internal member's nullability attributes are omitted, and + /// the API will return the state. + /// + /// + /// The property for which to populate the nullability information. + /// + /// + /// is null. + /// + /// + /// A instance. + /// + public NullabilityInfo Create(PropertyInfo propertyInfo) + { + var getter = propertyInfo.GetGetMethod(true); + var setter = propertyInfo.GetSetMethod(true); + var annotationsDisabled = (getter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(getter)) + && (setter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(setter)); + var parser = annotationsDisabled + ? NullableAttributeStateParser.Unknown + : CreateParser(propertyInfo.GetCustomAttributesData()); + + var nullability = GetNullabilityInfo(propertyInfo, propertyInfo.PropertyType, parser); + + if (getter != null) + { + CheckNullabilityAttributes(nullability, getter.ReturnParameter.GetCustomAttributesData()); + } + else + { + nullability.ReadState = NullabilityState.Unknown; + } + + if (setter != null) + { + CheckNullabilityAttributes(nullability, setter.GetParameters().Last().GetCustomAttributesData()); + } + else + { + nullability.WriteState = NullabilityState.Unknown; + } + + return nullability; + } + + private static NullableAttributeStateParser CreateParser(IList customAttributes) + { + foreach (var attribute in customAttributes) + { + if (attribute.AttributeType.Name == "NullableAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNamespace && + attribute.ConstructorArguments.Count == 1) + { + return new(attribute.ConstructorArguments[0].Value); + } + } + + return new(null); + } + + private static MemberInfo GetMemberMetadataDefinition(MemberInfo member) + { + var type = member.DeclaringType; + + if ((type != null) && type.IsGenericType && !type.IsGenericTypeDefinition) + { + return type.GetGenericTypeDefinition().GetMemberWithSameMetadataDefinitionAs(member); + } + + return member; + } + + private static Type GetPropertyMetaType(PropertyInfo property) + { + if (property.GetGetMethod(true) is MethodInfo method) + { + return method.ReturnType; + } + + return property.GetSetMethod(true)!.GetParameters()[0].ParameterType; + } + + private static NullabilityState TranslateByte(object? value) + { + return value is byte b + ? TranslateByte(b) + : NullabilityState.Unknown; + } + + private static NullabilityState TranslateByte(byte b) + { + return b switch + { + 1 => NullabilityState.NotNull, + 2 => NullabilityState.Nullable, + _ => NullabilityState.Unknown, + }; + } + + private void CheckGenericParameters(NullabilityInfo nullability, MemberInfo metaMember, Type metaType, Type? reflectedType) + { + if (metaType.IsGenericParameter) + { + if (nullability.ReadState == NullabilityState.NotNull) + { + TryUpdateGenericParameterNullability(nullability, metaType, reflectedType); + } + } + else if (metaType.ContainsGenericParameters) + { + if (nullability.GenericTypeArguments.Length > 0) + { + var genericArguments = metaType.GetGenericArguments(); + + for (var i = 0; i < genericArguments.Length; i++) + { + CheckGenericParameters(nullability.GenericTypeArguments[i], metaMember, genericArguments[i], reflectedType); + } + } + else if (nullability.ElementType is NullabilityInfo elementNullability && metaType.IsArray) + { + CheckGenericParameters(elementNullability, metaMember, metaType.GetElementType()!, reflectedType); + } + } + } + + private void CheckNullabilityAttributes(NullabilityInfo nullability, IList attributes) + { + var codeAnalysisReadState = NullabilityState.Unknown; + var codeAnalysisWriteState = NullabilityState.Unknown; + + foreach (CustomAttributeData attribute in attributes) + { + if (attribute.AttributeType.Namespace == "System.Diagnostics.CodeAnalysis") + { + if (attribute.AttributeType.Name == "NotNullAttribute") + { + codeAnalysisReadState = NullabilityState.NotNull; + } + else if ((attribute.AttributeType.Name == "MaybeNullAttribute" || + attribute.AttributeType.Name == "MaybeNullWhenAttribute") && + codeAnalysisReadState == NullabilityState.Unknown && + !nullability.Type.IsValueType) + { + codeAnalysisReadState = NullabilityState.Nullable; + } + else if (attribute.AttributeType.Name == "DisallowNullAttribute") + { + codeAnalysisWriteState = NullabilityState.NotNull; + } + else if (attribute.AttributeType.Name == "AllowNullAttribute" && + codeAnalysisWriteState == NullabilityState.Unknown && + !nullability.Type.IsValueType) + { + codeAnalysisWriteState = NullabilityState.Nullable; + } + } + } + + if (codeAnalysisReadState != NullabilityState.Unknown) + { + nullability.ReadState = codeAnalysisReadState; + } + + if (codeAnalysisWriteState != NullabilityState.Unknown) + { + nullability.WriteState = codeAnalysisWriteState; + } + } + + private NullabilityState? GetNullableContext(MemberInfo? memberInfo) + { + while (memberInfo != null) + { + if (context.TryGetValue(memberInfo, out NullabilityState state)) + { + return state; + } + + foreach (CustomAttributeData attribute in memberInfo.GetCustomAttributesData()) + { + if (attribute.AttributeType.Name == "NullableContextAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNamespace && + attribute.ConstructorArguments.Count == 1) + { + state = TranslateByte(attribute.ConstructorArguments[0].Value); + context.Add(memberInfo, state); + + return state; + } + } + + memberInfo = memberInfo.DeclaringType; + } + + return null; + } + + private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser) + { + var index = 0; + + return GetNullabilityInfo(memberInfo, type, parser, ref index); + } + + private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser, ref int index) + { + var state = NullabilityState.Unknown; + var elementState = (NullabilityInfo?)null; + var genericArgumentsState = Array.Empty(); + var underlyingType = type; + + if (type.IsValueType) + { + underlyingType = Nullable.GetUnderlyingType(type); + + if (underlyingType != null) + { + state = NullabilityState.Nullable; + } + else + { + underlyingType = type; + state = NullabilityState.NotNull; + } + + if (underlyingType.IsGenericType) + { + ++index; + } + } + else + { + if (!parser.ParseNullableState(index++, ref state) + && GetNullableContext(memberInfo) is NullabilityState contextState) + { + state = contextState; + } + + if (type.IsArray) + { + elementState = GetNullabilityInfo(memberInfo, type.GetElementType()!, parser, ref index); + } + } + + if (underlyingType.IsGenericType) + { + var genericArguments = underlyingType.GetGenericArguments(); + genericArgumentsState = new NullabilityInfo[genericArguments.Length]; + + for (int i = 0; i < genericArguments.Length; i++) + { + genericArgumentsState[i] = GetNullabilityInfo(memberInfo, genericArguments[i], parser, ref index); + } + } + + var nullability = new NullabilityInfo(type, state, state, elementState, genericArgumentsState); + + if (!type.IsValueType && state != NullabilityState.Unknown) + { + TryLoadGenericMetaTypeNullability(memberInfo, nullability); + } + + return nullability; + } + + private bool IsPrivateOrInternalFieldAndAnnotationDisabled(FieldInfo fieldInfo) + { + return (fieldInfo.IsPrivate || fieldInfo.IsFamilyAndAssembly || fieldInfo.IsAssembly) && + IsPublicOnly(fieldInfo.IsPrivate, fieldInfo.IsFamilyAndAssembly, fieldInfo.IsAssembly, fieldInfo.Module); + } + + private bool IsPrivateOrInternalMethodAndAnnotationDisabled(MethodBase method) + { + return (method.IsPrivate || method.IsFamilyAndAssembly || method.IsAssembly) && + IsPublicOnly(method.IsPrivate, method.IsFamilyAndAssembly, method.IsAssembly, method.Module); + } + + private bool IsPublicOnly(bool isPrivate, bool isFamilyAndAssembly, bool isAssembly, Module module) + { + if (!publicOnlyModules.TryGetValue(module, out NotAnnotatedStatus value)) + { + value = PopulateAnnotationInfo(module.GetCustomAttributesData()); + publicOnlyModules.Add(module, value); + } + + if (value == NotAnnotatedStatus.None) + { + return false; + } + + return ((isPrivate || isFamilyAndAssembly) && value.HasFlag(NotAnnotatedStatus.Private)) || + (isAssembly && value.HasFlag(NotAnnotatedStatus.Internal)); + } + + private NotAnnotatedStatus PopulateAnnotationInfo(IList customAttributes) + { + foreach (CustomAttributeData attribute in customAttributes) + { + if (attribute.AttributeType.Name == "NullablePublicOnlyAttribute" && + attribute.AttributeType.Namespace == CompilerServicesNamespace && + attribute.ConstructorArguments.Count == 1) + { + if (attribute.ConstructorArguments[0].Value is bool boolValue && boolValue) + { + return NotAnnotatedStatus.Internal | NotAnnotatedStatus.Private; + } + else + { + return NotAnnotatedStatus.Private; + } + } + } + + return NotAnnotatedStatus.None; + } + + private void TryLoadGenericMetaTypeNullability(MemberInfo memberInfo, NullabilityInfo nullability) + { + var metaMember = GetMemberMetadataDefinition(memberInfo); + var metaType = (Type?)null; + + if (metaMember is FieldInfo field) + { + metaType = field.FieldType; + } + else if (metaMember is PropertyInfo property) + { + metaType = GetPropertyMetaType(property); + } + + if (metaType != null) + { + CheckGenericParameters(nullability, metaMember!, metaType, memberInfo.ReflectedType); + } + } + + private bool TryUpdateGenericParameterNullability(NullabilityInfo nullability, Type genericParameter, Type? reflectedType) + { + if (reflectedType is not null + && !genericParameter.IsGenericMethodParameter() + && TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, reflectedType, reflectedType)) + { + return true; + } + + var state = NullabilityState.Unknown; + + if (CreateParser(genericParameter.GetCustomAttributesData()).ParseNullableState(0, ref state)) + { + nullability.ReadState = state; + nullability.WriteState = state; + return true; + } + + if (GetNullableContext(genericParameter) is { } contextState) + { + nullability.ReadState = contextState; + nullability.WriteState = contextState; + return true; + } + + return false; + } + + private bool TryUpdateGenericTypeParameterNullabilityFromReflectedType(NullabilityInfo nullability, Type genericParameter, Type context, Type reflectedType) + { + var contextTypeDefinition = context.IsGenericType && !context.IsGenericTypeDefinition + ? context.GetGenericTypeDefinition() + : context; + + if (genericParameter.DeclaringType == contextTypeDefinition) + { + return false; + } + + var baseType = contextTypeDefinition.BaseType; + + if (baseType is null) + { + return false; + } + + if (!baseType.IsGenericType + || (baseType.IsGenericTypeDefinition ? baseType : baseType.GetGenericTypeDefinition()) != genericParameter.DeclaringType) + { + return TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, baseType, reflectedType); + } + + var genericArguments = baseType.GetGenericArguments(); + var genericArgument = genericArguments[genericParameter.GenericParameterPosition]; + + if (genericArgument.IsGenericParameter) + { + return TryUpdateGenericParameterNullability(nullability, genericArgument, reflectedType); + } + + var parser = CreateParser(contextTypeDefinition.GetCustomAttributesData()); + var nullabilityStateIndex = 1; // start at 1 since index 0 is the type itself + + for (int i = 0; i < genericParameter.GenericParameterPosition; i++) + { + nullabilityStateIndex += CountNullabilityStates(genericArguments[i]); + } + + return TryPopulateNullabilityInfo(nullability, parser, ref nullabilityStateIndex); + + static int CountNullabilityStates(Type type) + { + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + + if (underlyingType.IsGenericType) + { + var count = 1; + + foreach (Type genericArgument in underlyingType.GetGenericArguments()) + { + count += CountNullabilityStates(genericArgument); + } + + return count; + } + + if (underlyingType.IsArray) + { + return 1 + CountNullabilityStates(underlyingType.GetElementType()!); + } + + return type.IsValueType ? 0 : 1; + } + } + + private bool TryPopulateNullabilityInfo(NullabilityInfo nullability, NullableAttributeStateParser parser, ref int index) + { + var isValueType = nullability.Type.IsValueType; + + if (!isValueType) + { + var state = NullabilityState.Unknown; + + if (!parser.ParseNullableState(index, ref state)) + { + return false; + } + + nullability.ReadState = state; + nullability.WriteState = state; + } + + if (!isValueType || (Nullable.GetUnderlyingType(nullability.Type) ?? nullability.Type).IsGenericType) + { + index++; + } + + if (nullability.GenericTypeArguments.Length > 0) + { + foreach (NullabilityInfo genericTypeArgumentNullability in nullability.GenericTypeArguments) + { + TryPopulateNullabilityInfo(genericTypeArgumentNullability, parser, ref index); + } + } + else if (nullability.ElementType is { } elementTypeNullability) + { + TryPopulateNullabilityInfo(elementTypeNullability, parser, ref index); + } + + return true; + } + + private readonly struct NullableAttributeStateParser + { + private static readonly object UnknownByte = (byte)0; + + private readonly object? nullableAttributeArgument; + + public NullableAttributeStateParser(object? nullableAttributeArgument) + { + this.nullableAttributeArgument = nullableAttributeArgument; + } + + public static NullableAttributeStateParser Unknown => new(UnknownByte); + + public bool ParseNullableState(int index, ref NullabilityState state) + { + switch (nullableAttributeArgument) + { + case byte b: + state = TranslateByte(b); + return true; + case ReadOnlyCollection args + when index < args.Count && args[index].Value is byte elementB: + state = TranslateByte(elementB); + return true; + default: + return false; + } + } + } + } +} diff --git a/src/Chr.Avro/Infrastructure/NullabilityState.cs b/src/Chr.Avro/Infrastructure/NullabilityState.cs new file mode 100644 index 000000000..1f16f316d --- /dev/null +++ b/src/Chr.Avro/Infrastructure/NullabilityState.cs @@ -0,0 +1,28 @@ +namespace Chr.Avro.Infrastructure +{ + /// + /// Describes nullability states. + /// + /// + /// This type is a stand-in for the NullabilityState enum available in .NET 6 and above. See + /// the .NET runtime source + /// (also MIT licensed) for the reference implementation. + /// + internal enum NullabilityState + { + /// + /// Nullability context not enabled (oblivious). + /// + Unknown, + + /// + /// Non-nullable value or reference type. + /// + NotNull, + + /// + /// Nullable value or reference type. + /// + Nullable, + } +} diff --git a/src/Chr.Avro/Infrastructure/ReflectionExtensions.cs b/src/Chr.Avro/Infrastructure/ReflectionExtensions.cs index f22be1186..88510f1bc 100644 --- a/src/Chr.Avro/Infrastructure/ReflectionExtensions.cs +++ b/src/Chr.Avro/Infrastructure/ReflectionExtensions.cs @@ -94,6 +94,44 @@ public static (Type Key, Type Value)? GetDictionaryTypes(this Type type) .ElementAt(0); } + /// + /// Searches for the on that matches the + /// specified . + /// + /// + /// This method is a stand-in for the Type.GetMemberWithSameMetadataDefinitionAs + /// method available in .NET 6 and above. See + /// the .NET runtime source + /// (also MIT licensed) for the reference implementation. + /// + /// + /// A object to search for a matching member. + /// + /// + /// The to find on . + /// + /// + /// An object representing the member on that matches the + /// specified member. + /// + /// + /// does not match a member on . + /// + public static MemberInfo GetMemberWithSameMetadataDefinitionAs(this Type type, MemberInfo member) + { + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + + foreach (var candidate in type.GetMembers(all)) + { + if (candidate.HasSameMetadataDefinitionAs(member)) + { + return candidate; + } + } + + throw new ArgumentException($"A {nameof(MemberInfo)} that matches {member} could not be found.", nameof(member)); + } + /// /// Creates an instance of without any fields or properties /// initialized. @@ -109,5 +147,51 @@ public static T GetUninitializedInstance() { return (T)FormatterServices.GetUninitializedObject(typeof(T)); } + + /// + /// Determines whether has the same metadata as + /// . + /// + /// + /// This method is a stand-in for the MemberInfo.HasSameMetadataDefinitionAs + /// method available in .NET Standard 2.1 and above. See + /// the .NET runtime source + /// (also MIT licensed) for the reference implementation. + /// + /// + /// A object. + /// + /// + /// The to compare to . + /// + /// + /// true if the metadata definitions are equivalent; false otherwise. + /// + public static bool HasSameMetadataDefinitionAs(this MemberInfo member, MemberInfo other) + { + return member.MetadataToken == other.MetadataToken && member.Module.Equals(other.Module); + } + + /// + /// Gets a value that indicates whether represents a type + /// parameter in the definition of a generic method. + /// + /// + /// This method is a stand-in for the Type.IsGenericMethodParameter property + /// available in .NET Standard 2.1 and above. See + /// the .NET runtime source + /// (also MIT licensed) for the reference implementation. + /// + /// + /// A object. + /// + /// + /// true if represents a type parameter of a generic + /// method definition; false otherwise. + /// + public static bool IsGenericMethodParameter(this Type type) + { + return type.IsGenericParameter && type.DeclaringMethod != null; + } } } From 3a0632bbd834e87b6478d15dddecc1935bea681f Mon Sep 17 00:00:00 2001 From: Dan Stelljes Date: Thu, 10 Feb 2022 14:17:50 -0600 Subject: [PATCH 2/3] Add nullable reference type support to schema builder --- .../Fixtures/NullableMemberClass.cs | 56 ++ .../Fixtures/NullablePropertyClass.cs | 15 - .../Abstract/NullableReferenceTypeBehavior.cs | 6 + .../Abstract/RecordSchemaBuilderCase.cs | 147 ++++- src/Chr.Avro/Abstract/SchemaBuilder.cs | 2 +- .../Abstract/SchemaBuilderShould.cs | 511 ++++++++++++------ 6 files changed, 567 insertions(+), 170 deletions(-) create mode 100644 src/Chr.Avro.Fixtures/Fixtures/NullableMemberClass.cs delete mode 100644 src/Chr.Avro.Fixtures/Fixtures/NullablePropertyClass.cs diff --git a/src/Chr.Avro.Fixtures/Fixtures/NullableMemberClass.cs b/src/Chr.Avro.Fixtures/Fixtures/NullableMemberClass.cs new file mode 100644 index 000000000..738869714 --- /dev/null +++ b/src/Chr.Avro.Fixtures/Fixtures/NullableMemberClass.cs @@ -0,0 +1,56 @@ +#pragma warning disable CS8618 + +namespace Chr.Avro.Fixtures +{ + using System; + using System.Collections.Generic; + + public class NullableMemberClass + { + public Guid ObliviousGuidProperty { get; set; } + + public Guid? ObliviousNullableGuidProperty { get; set; } + + public string ObliviousStringProperty { get; set; } + + public string[] ObliviousArrayOfStringsProperty { get; set; } + + public List ObliviousListOfStringsProperty { get; set; } + + public Dictionary ObliviousDictionaryOfObjectsProperty { get; set; } + +#nullable enable + public Guid GuidProperty { get; set; } + + public Guid? NullableGuidProperty { get; set; } + + public string StringProperty { get; set; } + + public string? NullableStringProperty { get; set; } + + public string[] ArrayOfStringsProperty { get; set; } + + public string?[] ArrayOfNullableStringsProperty { get; set; } + + public string[]? NullableArrayOfStringsProperty { get; set; } + + public string?[]? NullableArrayOfNullableStringsProperty { get; set; } + + public List ListOfStringsProperty { get; set; } + + public List ListOfNullableStringsProperty { get; set; } + + public List? NullableListOfStringsProperty { get; set; } + + public List? NullableListOfNullableStringsProperty { get; set; } + + public Dictionary DictionaryOfObjectsProperty { get; set; } + + public Dictionary DictionaryOfNullableObjectsProperty { get; set; } + + public Dictionary? NullableDictionaryOfObjectsProperty { get; set; } + + public Dictionary? NullableDictionaryOfNullableObjectsProperty { get; set; } +#nullable disable + } +} diff --git a/src/Chr.Avro.Fixtures/Fixtures/NullablePropertyClass.cs b/src/Chr.Avro.Fixtures/Fixtures/NullablePropertyClass.cs deleted file mode 100644 index fa076e60e..000000000 --- a/src/Chr.Avro.Fixtures/Fixtures/NullablePropertyClass.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Chr.Avro.Fixtures -{ - using System; - - public class NullablePropertyClass - { - public Guid Id { get; set; } - - public DateTime Created { get; set; } - - public DateTime? Updated { get; set; } - - public DateTime? Deleted { get; set; } - } -} diff --git a/src/Chr.Avro/Abstract/NullableReferenceTypeBehavior.cs b/src/Chr.Avro/Abstract/NullableReferenceTypeBehavior.cs index e4e5252df..0cd333e5e 100644 --- a/src/Chr.Avro/Abstract/NullableReferenceTypeBehavior.cs +++ b/src/Chr.Avro/Abstract/NullableReferenceTypeBehavior.cs @@ -14,5 +14,11 @@ public enum NullableReferenceTypeBehavior /// Match .NET’s nullable semantics, assuming reference types are always nullable. /// All, + + /// + /// Inspect nullable reference type metadata to infer nullability. For types where metadata + /// is not present, behavior will be identical to . + /// + Annotated, } } diff --git a/src/Chr.Avro/Abstract/RecordSchemaBuilderCase.cs b/src/Chr.Avro/Abstract/RecordSchemaBuilderCase.cs index b64cb242e..1eb3b3d5b 100644 --- a/src/Chr.Avro/Abstract/RecordSchemaBuilderCase.cs +++ b/src/Chr.Avro/Abstract/RecordSchemaBuilderCase.cs @@ -12,6 +12,8 @@ namespace Chr.Avro.Abstract /// public class RecordSchemaBuilderCase : SchemaBuilderCase, ISchemaBuilderCase { + private readonly NullabilityInfoContext nullabilityContext; + /// /// Initializes a new instance of the class. /// @@ -32,6 +34,8 @@ public RecordSchemaBuilderCase( MemberVisibility = memberVisibility; NullableReferenceTypeBehavior = nullableReferenceTypeBehavior; SchemaBuilder = schemaBuilder ?? throw new ArgumentNullException(nameof(schemaBuilder), "Schema builder cannot be null."); + + nullabilityContext = new NullabilityInfoContext(); } /// @@ -73,7 +77,7 @@ public virtual SchemaBuilderCaseResult BuildSchema(Type type, SchemaBuilderConte if (!type.IsValueType && NullableReferenceTypeBehavior == NullableReferenceTypeBehavior.All) { - schema = new UnionSchema(new[] { new NullSchema(), schema }); + schema = MakeNullableSchema(schema); } try @@ -89,8 +93,8 @@ public virtual SchemaBuilderCaseResult BuildSchema(Type type, SchemaBuilderConte { var memberType = member switch { - FieldInfo fieldMember => fieldMember.FieldType, - PropertyInfo propertyMember => propertyMember.PropertyType, + FieldInfo fieldInfo => fieldInfo.FieldType, + PropertyInfo propertyInfo => propertyInfo.PropertyType, _ => default, }; @@ -101,6 +105,21 @@ public virtual SchemaBuilderCaseResult BuildSchema(Type type, SchemaBuilderConte var field = new RecordField(member.Name, SchemaBuilder.BuildSchema(memberType, context)); + if (NullableReferenceTypeBehavior == NullableReferenceTypeBehavior.Annotated) + { + var nullabilityInfo = member switch + { + FieldInfo fieldInfo => nullabilityContext.Create(fieldInfo), + PropertyInfo propertyInfo => nullabilityContext.Create(propertyInfo), + _ => default, + }; + + if (nullabilityInfo != null) + { + field.Type = ApplyNullabilityInfo(field.Type, nullabilityInfo); + } + } + if (member.GetAttribute() is DefaultValueAttribute defaultAttribute) { field.Default = new ObjectDefaultValue(defaultAttribute.Value, field.Type); @@ -116,5 +135,127 @@ public virtual SchemaBuilderCaseResult BuildSchema(Type type, SchemaBuilderConte return SchemaBuilderCaseResult.FromException(new UnsupportedTypeException(type, $"{nameof(RecordSchemaBuilderCase)} cannot be applied to array or primitive types.")); } } + + /// + /// Makes a schema and any of its children nullable based on a type member's nullability. + /// + /// + /// A object to apply nullability info to. + /// + /// + /// A object for the member that + /// represents. + /// + private static Schema ApplyNullabilityInfo(Schema schema, NullabilityInfo nullabilityInfo) + { + if (schema is ArraySchema arraySchema) + { + if (nullabilityInfo.ElementType != null) + { + // if the type is an array, this is easy; recurse with the element type info: + schema = new ArraySchema(ApplyNullabilityInfo( + arraySchema.Item, + nullabilityInfo.ElementType)) + { + LogicalType = schema.LogicalType, + }; + } + else + { + // otherwise, if the type is generic, try to map one of its type arguments to + // the IEnumerable type argument and recurse with that argument type info: + var genericType = nullabilityInfo.Type + .GetGenericTypeDefinition(); + + if (genericType.GetEnumerableType() is Type genericItemType) + { + var infoIndex = genericType + .GetGenericArguments() + .ToList() + .FindIndex(candidate => candidate == genericItemType); + + if (infoIndex >= 0) + { + schema = new ArraySchema(ApplyNullabilityInfo( + arraySchema.Item, + nullabilityInfo.GenericTypeArguments[infoIndex])); + } + } + } + } + else if (schema is MapSchema mapSchema) + { + // if the type is generic, use the same trick as for IEnumerable: + var genericType = nullabilityInfo.Type + .GetGenericTypeDefinition(); + + if (genericType.GetDictionaryTypes() is (_, Type genericValueType)) + { + var infoIndex = genericType + .GetGenericArguments() + .ToList() + .FindIndex(candidate => candidate == genericValueType); + + if (infoIndex >= 0) + { + schema = new MapSchema(ApplyNullabilityInfo( + mapSchema.Value, + nullabilityInfo.GenericTypeArguments[infoIndex])); + } + } + } + + // check the top level last (this also handles UnionSchema, which is the only other + // schema type that can have children): + if (nullabilityInfo.ReadState == NullabilityState.Nullable) + { + schema = MakeNullableSchema(schema); + } + + return schema; + } + + /// + /// Ensures that a schema is nullable (a containing a + /// ). + /// + /// + /// A schema to make nullable if not already. + /// + /// + /// A containing a . If + /// is already a containing a + /// , it will be returned as is. If + /// is a but does not contain a , a new + /// will be returned with a new as the + /// first member. If is not a , a new + /// will be returned comprising a and + /// . + /// + private static Schema MakeNullableSchema(Schema schema) + { + if (schema is UnionSchema unionSchema) + { + // if a null schema is already present, all good: + if (unionSchema.Schemas.Any(schema => schema is NullSchema)) + { + return schema; + } + else + { + // ordinarily, null would come first in a union, but since default values for + // record fields correspond to the first schema in the union, we append to + // avoid invalidating any default values: + return new UnionSchema(unionSchema.Schemas.Concat(new[] { new NullSchema() })) + { + LogicalType = unionSchema.LogicalType, + }; + } + } + else + { + return new UnionSchema(new[] { new NullSchema(), schema }); + } + } } } diff --git a/src/Chr.Avro/Abstract/SchemaBuilder.cs b/src/Chr.Avro/Abstract/SchemaBuilder.cs index a22ddabce..4b975359c 100644 --- a/src/Chr.Avro/Abstract/SchemaBuilder.cs +++ b/src/Chr.Avro/Abstract/SchemaBuilder.cs @@ -29,7 +29,7 @@ public class SchemaBuilder : ISchemaBuilder public SchemaBuilder( BindingFlags memberVisibility = BindingFlags.Public | BindingFlags.Instance, EnumBehavior enumBehavior = EnumBehavior.Symbolic, - NullableReferenceTypeBehavior nullableReferenceTypeBehavior = NullableReferenceTypeBehavior.None, + NullableReferenceTypeBehavior nullableReferenceTypeBehavior = NullableReferenceTypeBehavior.Annotated, TemporalBehavior temporalBehavior = TemporalBehavior.Iso8601) : this(CreateDefaultCaseBuilders(memberVisibility, enumBehavior, nullableReferenceTypeBehavior, temporalBehavior)) { diff --git a/tests/Chr.Avro.Tests/Abstract/SchemaBuilderShould.cs b/tests/Chr.Avro.Tests/Abstract/SchemaBuilderShould.cs index 0a3fb30d5..f14c44294 100644 --- a/tests/Chr.Avro.Tests/Abstract/SchemaBuilderShould.cs +++ b/tests/Chr.Avro.Tests/Abstract/SchemaBuilderShould.cs @@ -25,9 +25,7 @@ public SchemaBuilderShould() [InlineData(typeof(int[][]), typeof(ArraySchema))] public void BuildArrays(Type type, Type inner) { - var schema = builder.BuildSchema(type) as ArraySchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.IsType(inner, schema.Item); Assert.Null(schema.LogicalType); } @@ -36,9 +34,7 @@ public void BuildArrays(Type type, Type inner) [InlineData(typeof(bool))] public void BuildBooleans(Type type) { - var schema = builder.BuildSchema(type) as BooleanSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.Null(schema.LogicalType); } @@ -46,53 +42,47 @@ public void BuildBooleans(Type type) [InlineData(typeof(byte[]))] public void BuildByteArrays(Type type) { - var schema = builder.BuildSchema(type) as BytesSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.Null(schema.LogicalType); } [Fact] public void BuildClassesWithDefaultValues() { - var schema = builder.BuildSchema() as RecordSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.Collection( schema.Fields, - f => + field => { - Assert.Equal(nameof(DefaultValuesClass.DefaultIntField), f.Name); - Assert.Equal(1, f.Default.ToObject()); + Assert.Equal(nameof(DefaultValuesClass.DefaultIntField), field.Name); + Assert.Equal(1, field.Default.ToObject()); }, - f => + field => { - Assert.Equal(nameof(DefaultValuesClass.DefaultIntProperty), f.Name); - Assert.Equal(1, f.Default.ToObject()); + Assert.Equal(nameof(DefaultValuesClass.DefaultIntProperty), field.Name); + Assert.Equal(1, field.Default.ToObject()); }, - f => + field => { - Assert.Equal(nameof(DefaultValuesClass.DefaultObjectField), f.Name); - Assert.Null(f.Default.ToObject()); + Assert.Equal(nameof(DefaultValuesClass.DefaultObjectField), field.Name); + Assert.Null(field.Default.ToObject()); }, - f => + field => { - Assert.Equal(nameof(DefaultValuesClass.DefaultObjectProperty), f.Name); - Assert.Null(f.Default.ToObject()); + Assert.Equal(nameof(DefaultValuesClass.DefaultObjectProperty), field.Name); + Assert.Null(field.Default.ToObject()); }); } [Fact] public void BuildClassesWithFields() { - var schema = builder.BuildSchema() as RecordSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.All(schema.Fields, f => Assert.IsType(f.Type)); Assert.Collection( schema.Fields, - f => Assert.Equal(nameof(VisibilityClass.PublicField), f.Name), - f => Assert.Equal(nameof(VisibilityClass.PublicProperty), f.Name)); + field => Assert.Equal(nameof(VisibilityClass.PublicField), field.Name), + field => Assert.Equal(nameof(VisibilityClass.PublicProperty), field.Name)); Assert.Null(schema.LogicalType); Assert.Equal(typeof(VisibilityClass).Name, schema.Name); Assert.Equal(typeof(VisibilityClass).Namespace, schema.Namespace); @@ -101,18 +91,14 @@ public void BuildClassesWithFields() [Fact] public void BuildClassesWithMultipleRecursion() { - var schema = builder.BuildSchema() as RecordSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.Collection( schema.Fields, - f => + field => { - Assert.Equal(nameof(CircularClassA.B), f.Name); - - var b = f.Type as RecordSchema; + Assert.Equal(nameof(CircularClassA.B), field.Name); - Assert.NotNull(b); + var b = Assert.IsType(field.Type); Assert.Collection( b.Fields, a => @@ -132,9 +118,7 @@ public void BuildClassesWithMultipleRecursion() [Fact] public void BuildClassesWithNoFields() { - var schema = builder.BuildSchema() as RecordSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.Empty(schema.Fields); Assert.Null(schema.LogicalType); Assert.Equal(typeof(EmptyClass).Name, schema.Name); @@ -145,49 +129,323 @@ public void BuildClassesWithNoFields() public void BuildClassesWithNullableProperties() { var context = new SchemaBuilderContext(); - var schema = builder.BuildSchema(context) as RecordSchema; + var schema = Assert.IsType(builder.BuildSchema(context)); Assert.Collection( context.Schemas.Keys, - t => t.Equals(typeof(DateTime)), - t => t.Equals(typeof(DateTime?)), - t => t.Equals(typeof(Guid)), - t => t.Equals(typeof(NullablePropertyClass))); + type => Assert.Equal(typeof(NullableMemberClass), type), + type => Assert.Equal(typeof(string[]), type), + type => Assert.Equal(typeof(string), type), + type => Assert.Equal(typeof(Dictionary), type), + type => Assert.Equal(typeof(object), type), + type => Assert.Equal(typeof(Guid), type), + type => Assert.Equal(typeof(List), type), + type => Assert.Equal(typeof(Guid?), type)); - Assert.NotNull(schema); Assert.Collection( schema.Fields, - f => + field => { - Assert.Equal(nameof(NullablePropertyClass.Created), f.Name); - Assert.IsType(f.Type); + Assert.Equal(nameof(NullableMemberClass.ArrayOfNullableStringsProperty), field.Name); + + var array = Assert.IsType(field.Type); + var union = Assert.IsType(array.Item); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + Assert.IsType(child); + }); }, - f => + field => { - Assert.Equal(nameof(NullablePropertyClass.Deleted), f.Name); - Assert.IsType(f.Type); + Assert.Equal(nameof(NullableMemberClass.ArrayOfStringsProperty), field.Name); + + var array = Assert.IsType(field.Type); + Assert.IsType(array.Item); }, - f => + field => { - Assert.Equal(nameof(NullablePropertyClass.Id), f.Name); - Assert.IsType(f.Type); + Assert.Equal(nameof(NullableMemberClass.DictionaryOfNullableObjectsProperty), field.Name); + + var map = Assert.IsType(field.Type); + var union = Assert.IsType(map.Value); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + Assert.IsType(child); + }); }, - f => + field => + { + Assert.Equal(nameof(NullableMemberClass.DictionaryOfObjectsProperty), field.Name); + + var map = Assert.IsType(field.Type); + Assert.IsType(map.Value); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.GuidProperty), field.Name); + Assert.IsType(field.Type); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.ListOfNullableStringsProperty), field.Name); + + var array = Assert.IsType(field.Type); + var union = Assert.IsType(array.Item); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + Assert.IsType(child); + }); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.ListOfStringsProperty), field.Name); + + var array = Assert.IsType(field.Type); + Assert.IsType(array.Item); + }, + field => { - Assert.Equal(nameof(NullablePropertyClass.Updated), f.Name); - Assert.IsType(f.Type); + Assert.Equal(nameof(NullableMemberClass.NullableArrayOfNullableStringsProperty), field.Name); + + var union = Assert.IsType(field.Type); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + var array = Assert.IsType(child); + var union = Assert.IsType(array.Item); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + Assert.IsType(child); + }); + }); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.NullableArrayOfStringsProperty), field.Name); + + var union = Assert.IsType(field.Type); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + var array = Assert.IsType(child); + Assert.IsType(array.Item); + }); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.NullableDictionaryOfNullableObjectsProperty), field.Name); + + var union = Assert.IsType(field.Type); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + var map = Assert.IsType(child); + var union = Assert.IsType(map.Value); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + Assert.IsType(child); + }); + }); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.NullableDictionaryOfObjectsProperty), field.Name); + + var union = Assert.IsType(field.Type); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + var map = Assert.IsType(child); + Assert.IsType(map.Value); + }); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.NullableGuidProperty), field.Name); + + var union = Assert.IsType(field.Type); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + Assert.IsType(child); + }); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.NullableListOfNullableStringsProperty), field.Name); + + var union = Assert.IsType(field.Type); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + var array = Assert.IsType(child); + var union = Assert.IsType(array.Item); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + Assert.IsType(child); + }); + }); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.NullableListOfStringsProperty), field.Name); + + var union = Assert.IsType(field.Type); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + var array = Assert.IsType(child); + Assert.IsType(array.Item); + }); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.NullableStringProperty), field.Name); + + var union = Assert.IsType(field.Type); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + Assert.IsType(child); + }); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.ObliviousArrayOfStringsProperty), field.Name); + + var array = Assert.IsType(field.Type); + Assert.IsType(array.Item); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.ObliviousDictionaryOfObjectsProperty), field.Name); + + var map = Assert.IsType(field.Type); + Assert.IsType(map.Value); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.ObliviousGuidProperty), field.Name); + Assert.IsType(field.Type); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.ObliviousListOfStringsProperty), field.Name); + + var array = Assert.IsType(field.Type); + Assert.IsType(array.Item); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.ObliviousNullableGuidProperty), field.Name); + + var union = Assert.IsType(field.Type); + Assert.Collection( + union.Schemas, + child => + { + Assert.IsType(child); + }, + child => + { + Assert.IsType(child); + }); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.ObliviousStringProperty), field.Name); + Assert.IsType(field.Type); + }, + field => + { + Assert.Equal(nameof(NullableMemberClass.StringProperty), field.Name); + Assert.IsType(field.Type); }); Assert.Null(schema.LogicalType); - Assert.Equal(typeof(NullablePropertyClass).Name, schema.Name); - Assert.Equal(typeof(NullablePropertyClass).Namespace, schema.Namespace); + Assert.Equal(typeof(NullableMemberClass).Name, schema.Name); + Assert.Equal(typeof(NullableMemberClass).Namespace, schema.Namespace); } [Fact] public void BuildClassesWithSingleRecursion() { - var schema = builder.BuildSchema() as RecordSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.Collection( schema.Fields, f => @@ -204,13 +462,8 @@ public void BuildClassesWithSingleRecursion() [InlineData(typeof(decimal))] public void BuildDecimals(Type type) { - var schema = builder.BuildSchema(type) as BytesSchema; - - Assert.NotNull(schema); - - var logicalType = schema.LogicalType as DecimalLogicalType; - - Assert.NotNull(logicalType); + var schema = Assert.IsType(builder.BuildSchema(type)); + var logicalType = Assert.IsType(schema.LogicalType); Assert.Equal(29, logicalType.Precision); Assert.Equal(14, logicalType.Scale); } @@ -219,9 +472,7 @@ public void BuildDecimals(Type type) [InlineData(typeof(double))] public void BuildDoubles(Type type) { - var schema = builder.BuildSchema(type) as DoubleSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.Null(schema.LogicalType); } @@ -229,70 +480,60 @@ public void BuildDoubles(Type type) [InlineData(typeof(TimeSpan))] public void BuildDurations(Type type) { - var schema = builder.BuildSchema(type) as StringSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.Null(schema.LogicalType); } [Fact] public void BuildEnumsWithDuplicateValues() { - var schema = builder.BuildSchema() as EnumSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.Null(schema.LogicalType); Assert.Equal(typeof(DuplicateEnum).Name, schema.Name); Assert.Equal(typeof(DuplicateEnum).Namespace, schema.Namespace); Assert.Collection( schema.Symbols, - s => Assert.Equal(nameof(DuplicateEnum.A), s), - s => Assert.Equal(nameof(DuplicateEnum.B), s), - s => Assert.Equal(nameof(DuplicateEnum.C), s)); + symbol => Assert.Equal(nameof(DuplicateEnum.A), symbol), + symbol => Assert.Equal(nameof(DuplicateEnum.B), symbol), + symbol => Assert.Equal(nameof(DuplicateEnum.C), symbol)); } [Fact] public void BuildEnumsWithExplicitValues() { - var schema = builder.BuildSchema() as EnumSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.Null(schema.LogicalType); Assert.Equal(typeof(ExplicitEnum).Name, schema.Name); Assert.Equal(typeof(ExplicitEnum).Namespace, schema.Namespace); Assert.Collection( schema.Symbols, - s => Assert.Equal(nameof(ExplicitEnum.Third), s), - s => Assert.Equal(nameof(ExplicitEnum.First), s), - s => Assert.Equal(nameof(ExplicitEnum.None), s), - s => Assert.Equal(nameof(ExplicitEnum.Second), s), - s => Assert.Equal(nameof(ExplicitEnum.Fourth), s)); + symbol => Assert.Equal(nameof(ExplicitEnum.Third), symbol), + symbol => Assert.Equal(nameof(ExplicitEnum.First), symbol), + symbol => Assert.Equal(nameof(ExplicitEnum.None), symbol), + symbol => Assert.Equal(nameof(ExplicitEnum.Second), symbol), + symbol => Assert.Equal(nameof(ExplicitEnum.Fourth), symbol)); } [Fact] public void BuildEnumsWithImplicitValues() { - var schema = builder.BuildSchema() as EnumSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.Null(schema.LogicalType); Assert.Equal(typeof(ImplicitEnum).Name, schema.Name); Assert.Equal(typeof(ImplicitEnum).Namespace, schema.Namespace); Assert.Collection( schema.Symbols, - s => Assert.Equal(nameof(ImplicitEnum.None), s), - s => Assert.Equal(nameof(ImplicitEnum.First), s), - s => Assert.Equal(nameof(ImplicitEnum.Second), s), - s => Assert.Equal(nameof(ImplicitEnum.Third), s), - s => Assert.Equal(nameof(ExplicitEnum.Fourth), s)); + symbol => Assert.Equal(nameof(ImplicitEnum.None), symbol), + symbol => Assert.Equal(nameof(ImplicitEnum.First), symbol), + symbol => Assert.Equal(nameof(ImplicitEnum.Second), symbol), + symbol => Assert.Equal(nameof(ImplicitEnum.Third), symbol), + symbol => Assert.Equal(nameof(ExplicitEnum.Fourth), symbol)); } [Fact] public void BuildEnumsWithNoSymbols() { - var schema = builder.BuildSchema() as EnumSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.Null(schema.LogicalType); Assert.Equal(typeof(EmptyEnum).Name, schema.Name); Assert.Equal(typeof(EmptyEnum).Namespace, schema.Namespace); @@ -306,7 +547,6 @@ public void BuildEnumsWithNoSymbols() public void BuildFlagEnums(Type enumType, Type schemaType) { var schema = builder.BuildSchema(enumType); - Assert.IsType(schemaType, schema); Assert.Null(schema.LogicalType); } @@ -315,24 +555,20 @@ public void BuildFlagEnums(Type enumType, Type schemaType) [InlineData(typeof(float))] public void BuildFloats(Type type) { - var schema = builder.BuildSchema(type) as FloatSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.Null(schema.LogicalType); } [Fact] public void BuildInterfacesWithFields() { - var schema = builder.BuildSchema() as RecordSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.Collection( schema.Fields, - f => + field => { - Assert.Equal(nameof(IVisibilityInterface.PublicProperty), f.Name); - Assert.IsType(f.Type); + Assert.Equal(nameof(IVisibilityInterface.PublicProperty), field.Name); + Assert.IsType(field.Type); }); Assert.Null(schema.LogicalType); Assert.Equal(typeof(IVisibilityInterface).Name, schema.Name); @@ -342,9 +578,7 @@ public void BuildInterfacesWithFields() [Fact] public void BuildInterfacesWithNoFields() { - var schema = builder.BuildSchema() as RecordSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.Empty(schema.Fields); Assert.Null(schema.LogicalType); Assert.Equal(typeof(IEmptyInterface).Name, schema.Name); @@ -361,9 +595,7 @@ public void BuildInterfacesWithNoFields() [InlineData(typeof(ushort))] public void BuildInts(Type type) { - var schema = builder.BuildSchema(type) as IntSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.Null(schema.LogicalType); } @@ -372,9 +604,7 @@ public void BuildInts(Type type) [InlineData(typeof(ulong))] public void BuildLongs(Type type) { - var schema = builder.BuildSchema(type) as LongSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.Null(schema.LogicalType); } @@ -384,9 +614,7 @@ public void BuildLongs(Type type) [InlineData(typeof(IEnumerable>), typeof(StringSchema))] public void BuildMaps(Type type, Type inner) { - var schema = builder.BuildSchema(type) as MapSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.IsType(inner, schema.Value); Assert.Null(schema.LogicalType); } @@ -395,18 +623,14 @@ public void BuildMaps(Type type, Type inner) [InlineData(typeof(string))] public void BuildStrings(Type type) { - var schema = builder.BuildSchema(type) as StringSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.Null(schema.LogicalType); } [Fact] public void BuildStructsWithNoFields() { - var schema = builder.BuildSchema() as RecordSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema()); Assert.Empty(schema.Fields); Assert.Null(schema.LogicalType); Assert.Equal(typeof(EmptyStruct).Name, schema.Name); @@ -438,9 +662,7 @@ public void BuildStructsWithNoFields() [InlineData(typeof(UIntFlagEnum?), typeof(IntSchema))] public void BuildNullables(Type type, Type inner) { - var schema = builder.BuildSchema(type) as UnionSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.Collection( schema.Schemas, s => Assert.IsType(s), @@ -453,9 +675,7 @@ public void BuildNullables(Type type, Type inner) public void BuildTimestampsAsIso8601Strings(Type type) { var builder = new SchemaBuilder(temporalBehavior: TemporalBehavior.Iso8601); - var schema = builder.BuildSchema(type) as StringSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.Null(schema.LogicalType); } @@ -465,9 +685,7 @@ public void BuildTimestampsAsIso8601Strings(Type type) public void BuildTimestampsAsMicrosecondsFromEpoch(Type type) { var builder = new SchemaBuilder(temporalBehavior: TemporalBehavior.EpochMicroseconds); - var schema = builder.BuildSchema(type) as LongSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.IsType(schema.LogicalType); } @@ -477,9 +695,7 @@ public void BuildTimestampsAsMicrosecondsFromEpoch(Type type) public void BuildTimestampsAsMillisecondsFromEpoch(Type type) { var builder = new SchemaBuilder(temporalBehavior: TemporalBehavior.EpochMilliseconds); - var schema = builder.BuildSchema(type) as LongSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.IsType(schema.LogicalType); } @@ -487,9 +703,7 @@ public void BuildTimestampsAsMillisecondsFromEpoch(Type type) [InlineData(typeof(Uri))] public void BuildUris(Type type) { - var schema = builder.BuildSchema(type) as StringSchema; - - Assert.NotNull(schema); + var schema = Assert.IsType(builder.BuildSchema(type)); Assert.Null(schema.LogicalType); } @@ -497,13 +711,8 @@ public void BuildUris(Type type) [InlineData(typeof(Guid))] public void BuildUuids(Type type) { - var schema = builder.BuildSchema(type) as StringSchema; - - Assert.NotNull(schema); - - var logicalType = schema.LogicalType as UuidLogicalType; - - Assert.NotNull(logicalType); + var schema = Assert.IsType(builder.BuildSchema(type)); + Assert.IsType(schema.LogicalType); } [Theory] From 88596cb8c2d76e789d8d4cd7764d3cfb7444a1f4 Mon Sep 17 00:00:00 2001 From: Dan Stelljes Date: Thu, 10 Feb 2022 14:36:15 -0600 Subject: [PATCH 3/3] Expose new nullable reference type options on CLI --- docs/cli/index.js | 32 +++++++++++-------- src/Chr.Avro.Cli/Cli/CreateSchemaVerb.cs | 6 ++-- src/Chr.Avro.Cli/Cli/GenerateCodeVerb.cs | 6 +++- .../Codegen/CSharpCodeGenerator.cs | 16 +++++++++- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/docs/cli/index.js b/docs/cli/index.js index 3467eca61..8ab3ccc44 100644 --- a/docs/cli/index.js +++ b/docs/cli/index.js @@ -7,18 +7,6 @@ const clrTypeOptions = [{ name: 'assembly', required: false, summary: 'The name of or path to an assembly to load (multiple space-separated values accepted).' -}, { - name: 'enums-as-ints', - required: false, - summary: 'Whether enums should be represented as integers.' -}, { - name: 'nullable-references', - required: false, - summary: 'Whether reference types should be nullable.' -}, { - name: 'temporal-behavior', - required: false, - summary: 'Whether timestamps should be represented with "string" schemas (ISO 8601) or "long" schemas (timestamp logical types). Options are iso8601, epochmilliseconds, and epochmicroseconds.' }, { abbreviation: 't', name: 'type', @@ -66,7 +54,19 @@ module.exports = [{ body: `$ dotnet avro create --assembly ./out/Example.Models.dll --type Example.Models.ExampleModel {"name":"Example.Models.ExampleModel",type":"record",fields:[{"name":"Text","type":"string"}]}` }], - options: [...clrTypeOptions] + options: [...clrTypeOptions, { + name: 'enums-as-ints', + required: false, + summary: 'Whether enums should be represented as integers.' + }, { + name: 'nullable-references', + required: false, + summary: 'Which reference types should be represented with nullable union schemas. Options are annotated (use nullable annotations if available), none, and all.' + }, { + name: 'temporal-behavior', + required: false, + summary: 'Whether timestamps should be represented with "string" schemas (ISO 8601) or "long" schemas (timestamp logical types). Options are iso8601, epochmilliseconds, and epochmicroseconds.' + }] }, { name: 'generate', summary: 'Generates C# code for a schema from the Schema Registry.', @@ -98,7 +98,11 @@ namespace Example.Models body: `PS C:\\> Get-Content .\\example-model.avsc | dotnet avro generate | Out-File .\\ExampleModel.cs`, language: 'powershell' }], - options: [...schemaResolutionOptions] + options: [...schemaResolutionOptions, { + name: 'nullable-references', + required: false, + summary: 'Whether reference types selected for nullable record fields should be annotated as nullable.' + }] }, { name: 'registry-get', summary: 'Retrieve a schema from the Schema Registry.', diff --git a/src/Chr.Avro.Cli/Cli/CreateSchemaVerb.cs b/src/Chr.Avro.Cli/Cli/CreateSchemaVerb.cs index d140c9766..9435af0d3 100644 --- a/src/Chr.Avro.Cli/Cli/CreateSchemaVerb.cs +++ b/src/Chr.Avro.Cli/Cli/CreateSchemaVerb.cs @@ -36,8 +36,8 @@ public class CreateSchemaVerb : Verb, IClrTypeOptions [Option("enums-as-integers", HelpText = "Whether enums should be represented with \"int\" or \"long\" schemas.")] public bool EnumsAsIntegers { get; set; } - [Option("nullable-references", HelpText = "Whether reference types should be represented with nullable union schemas.")] - public bool NullableReferences { get; set; } + [Option("nullable-references", HelpText = "Which reference types should be represented with nullable union schemas. Options are annotated (use nullable annotations if available), none, and all.", Default = NullableReferenceTypeBehavior.Annotated)] + public NullableReferenceTypeBehavior NullableReferences { get; set; } [Option("temporal-behavior", HelpText = "Whether timestamps should be represented with \"string\" schemas (ISO 8601) or \"long\" schemas (timestamp logical types). Options are iso8601, epochmilliseconds, and epochmicroseconds.")] public TemporalBehavior TemporalBehavior { get; set; } @@ -61,7 +61,7 @@ protected Schema CreateSchema() var builder = new SchemaBuilder( enumBehavior: EnumsAsIntegers ? EnumBehavior.Integral : EnumBehavior.Symbolic, - nullableReferenceTypeBehavior: NullableReferences ? NullableReferenceTypeBehavior.All : NullableReferenceTypeBehavior.None, + nullableReferenceTypeBehavior: NullableReferences, temporalBehavior: TemporalBehavior); try diff --git a/src/Chr.Avro.Cli/Cli/GenerateCodeVerb.cs b/src/Chr.Avro.Cli/Cli/GenerateCodeVerb.cs index bf6fb8c4d..2faccecd1 100644 --- a/src/Chr.Avro.Cli/Cli/GenerateCodeVerb.cs +++ b/src/Chr.Avro.Cli/Cli/GenerateCodeVerb.cs @@ -46,12 +46,16 @@ public class GenerateCodeVerb : Verb, ISchemaResolutionOptions [Option('s', "subject", SetName = BySubjectSet, HelpText = "If an ID is not specified, the subject of the schema.")] public string SchemaSubject { get; set; } + [Option("nullable-references", HelpText = "Whether reference types selected for nullable record fields should be annotated as nullable.")] + public bool NullableReferences { get; set; } + [Option('v', "version", SetName = BySubjectSet, HelpText = "The version of the schema.")] public int? SchemaVersion { get; set; } protected override async Task Run() { - var generator = new CSharpCodeGenerator(); + var generator = new CSharpCodeGenerator( + enableNullableReferenceTypes: NullableReferences); var reader = new JsonSchemaReader(); var schema = reader.Read(await ((ISchemaResolutionOptions)this).ResolveSchema()); diff --git a/src/Chr.Avro.Codegen/Codegen/CSharpCodeGenerator.cs b/src/Chr.Avro.Codegen/Codegen/CSharpCodeGenerator.cs index e1a52c2aa..2dba611cf 100644 --- a/src/Chr.Avro.Codegen/Codegen/CSharpCodeGenerator.cs +++ b/src/Chr.Avro.Codegen/Codegen/CSharpCodeGenerator.cs @@ -16,6 +16,20 @@ namespace Chr.Avro.Codegen /// public class CSharpCodeGenerator : ICodeGenerator { + private readonly bool enableNullableReferenceTypes; + + /// + /// Initializes a new instance of the class. + /// + /// + /// Whether reference types selected for nullable record fields should be annotated as + /// nullable. + /// + public CSharpCodeGenerator(bool enableNullableReferenceTypes = true) + { + this.enableNullableReferenceTypes = enableNullableReferenceTypes; + } + /// /// Generates a class declaration for a record schema. /// @@ -304,7 +318,7 @@ protected virtual TypeSyntax GetPropertyType(Schema schema, bool nullable = fals throw new UnsupportedSchemaException(schema, $"{schema.GetType()} is not recognized by the code generator."); } - if (nullable && value) + if (nullable && (enableNullableReferenceTypes || value)) { type = SyntaxFactory.NullableType(type); }