Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add nullable reference type support to schema builder #184

Merged
merged 3 commits into from
Feb 11, 2022
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
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