Skip to content

Commit

Permalink
Merge nullable reference type support
Browse files Browse the repository at this point in the history
  • Loading branch information
dstelljes committed Feb 11, 2022
2 parents db1364a + 88596cb commit 246f0ca
Show file tree
Hide file tree
Showing 14 changed files with 1,366 additions and 189 deletions.
32 changes: 18 additions & 14 deletions docs/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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.',
Expand Down
6 changes: 3 additions & 3 deletions src/Chr.Avro.Cli/Cli/CreateSchemaVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/Chr.Avro.Cli/Cli/GenerateCodeVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
16 changes: 15 additions & 1 deletion src/Chr.Avro.Codegen/Codegen/CSharpCodeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ namespace Chr.Avro.Codegen
/// </summary>
public class CSharpCodeGenerator : ICodeGenerator
{
private readonly bool enableNullableReferenceTypes;

/// <summary>
/// Initializes a new instance of the <see cref="CSharpCodeGenerator" /> class.
/// </summary>
/// <param name="enableNullableReferenceTypes">
/// Whether reference types selected for nullable record fields should be annotated as
/// nullable.
/// </param>
public CSharpCodeGenerator(bool enableNullableReferenceTypes = true)
{
this.enableNullableReferenceTypes = enableNullableReferenceTypes;
}

/// <summary>
/// Generates a class declaration for a record schema.
/// </summary>
Expand Down Expand Up @@ -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);
}
Expand Down
56 changes: 56 additions & 0 deletions src/Chr.Avro.Fixtures/Fixtures/NullableMemberClass.cs
Original file line number Diff line number Diff line change
@@ -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<string> ObliviousListOfStringsProperty { get; set; }

public Dictionary<string, object> 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<string> ListOfStringsProperty { get; set; }

public List<string?> ListOfNullableStringsProperty { get; set; }

public List<string>? NullableListOfStringsProperty { get; set; }

public List<string?>? NullableListOfNullableStringsProperty { get; set; }

public Dictionary<string, object> DictionaryOfObjectsProperty { get; set; }

public Dictionary<string, object?> DictionaryOfNullableObjectsProperty { get; set; }

public Dictionary<string, object>? NullableDictionaryOfObjectsProperty { get; set; }

public Dictionary<string, object?>? NullableDictionaryOfNullableObjectsProperty { get; set; }
#nullable disable
}
}
15 changes: 0 additions & 15 deletions src/Chr.Avro.Fixtures/Fixtures/NullablePropertyClass.cs

This file was deleted.

6 changes: 6 additions & 0 deletions src/Chr.Avro/Abstract/NullableReferenceTypeBehavior.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,11 @@ public enum NullableReferenceTypeBehavior
/// Match .NET’s nullable semantics, assuming reference types are always nullable.
/// </summary>
All,

/// <summary>
/// Inspect nullable reference type metadata to infer nullability. For types where metadata
/// is not present, behavior will be identical to <see cref="None" />.
/// </summary>
Annotated,
}
}
147 changes: 144 additions & 3 deletions src/Chr.Avro/Abstract/RecordSchemaBuilderCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ namespace Chr.Avro.Abstract
/// </summary>
public class RecordSchemaBuilderCase : SchemaBuilderCase, ISchemaBuilderCase
{
private readonly NullabilityInfoContext nullabilityContext;

/// <summary>
/// Initializes a new instance of the <see cref="RecordSchemaBuilderCase" /> class.
/// </summary>
Expand All @@ -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();
}

/// <summary>
Expand Down Expand Up @@ -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
Expand All @@ -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,
};

Expand All @@ -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<DefaultValueAttribute>() is DefaultValueAttribute defaultAttribute)
{
field.Default = new ObjectDefaultValue<object>(defaultAttribute.Value, field.Type);
Expand All @@ -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."));
}
}

/// <summary>
/// Makes a schema and any of its children nullable based on a type member's nullability.
/// </summary>
/// <param name="schema">
/// A <see cref="Schema" /> object to apply nullability info to.
/// </param>
/// <param name="nullabilityInfo">
/// A <see cref="NullabilityInfo" /> object for the member that <paramref name="schema" />
/// represents.
/// </param>
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<T> 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<T>:
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;
}

/// <summary>
/// Ensures that a schema is nullable (a <see cref="UnionSchema" /> containing a
/// <see cref="NullSchema" />).
/// </summary>
/// <param name="schema">
/// A schema to make nullable if not already.
/// </param>
/// <returns>
/// A <see cref="UnionSchema" /> containing a <see cref="NullSchema" />. If
/// <paramref name="schema" /> is already a <see cref="UnionSchema" /> containing a
/// <see cref="NullSchema" />, it will be returned as is. If <paramref name="schema" />
/// is a <see cref="UnionSchema" /> but does not contain a <see cref="NullSchema" />, a new
/// <see cref="UnionSchema" /> will be returned with a new <see cref="NullSchema" /> as the
/// first member. If <paramref name="schema" /> is not a <see cref="UnionSchema" />, a new
/// <see cref="UnionSchema" /> will be returned comprising a <see cref="NullSchema" /> and
/// <paramref name="schema" />.
/// </returns>
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 });
}
}
}
}
Loading

0 comments on commit 246f0ca

Please sign in to comment.