-
-
Save atrauzzi/dde4f5e92fb783cb6847ff2b1c2d6710 to your computer and use it in GitHub Desktop.
Strongly typed JSON columns in Entity Framework Core
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[AttributeUsage(AttributeTargets.Class)] | |
public class DiscriminatorAttribute : Attribute | |
{ | |
public readonly string Value; | |
public DiscriminatorAttribute(string value) | |
{ | |
Value = value; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static class DiscriminatorRegistry | |
{ | |
public static JsonSerializerOptions Options => (new JsonSerializerOptions | |
{ | |
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | |
WriteIndented = false, | |
TypeInfoResolver = new DynamicJsonTypeResolver(JsonPolymorphRegistry.PolymorphRegistrations), | |
Converters = | |
{ | |
new ColorTypeConverter(), | |
}, | |
}).ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | |
private static readonly IDictionary<string, Type> DiscriminatorToTypes = new Dictionary<string, Type>(); | |
private static readonly IDictionary<Type, string> TypesToDiscriminators = new Dictionary<Type, string>(); | |
public static void Register<TypeToRegister>(string discriminator) => Register(discriminator, typeof(TypeToRegister)); | |
public static void Register(string discriminator, Type type) | |
{ | |
if (DiscriminatorToTypes.TryGetValue(discriminator, out var existingType)) | |
throw new($"The discriminator \"{discriminator}\" has already been registered."); | |
if (TypesToDiscriminators.TryGetValue(type, out var existingDiscriminator)) | |
throw new($"The type \"{type.FullName}\" has already been registered."); | |
DiscriminatorToTypes[discriminator] = type; | |
TypesToDiscriminators[type] = discriminator; | |
} | |
public static bool TryGetType(string discriminator, out Type? type) => DiscriminatorToTypes.TryGetValue(discriminator, out type); | |
public static bool TryGetDiscriminator(Type type, out string? discriminator) => TypesToDiscriminators.TryGetValue(type, out discriminator); | |
public static bool TryGetDiscriminator(object instance, out string? discriminator) => TryGetDiscriminator(instance.GetType(), out discriminator); | |
public static bool TryGetDiscriminator<Search>(out string? discriminator) => TryGetDiscriminator(typeof(Search), out discriminator); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static class DynamicJsonModelBuilderExtensions | |
{ | |
public static void IsDynamicJson<PropertyType>(this PropertyBuilder<PropertyType> propertyBuilder, JsonSerializerOptions options) | |
{ | |
propertyBuilder.HasConversion( | |
(value) => value == null ? null : JsonSerializer.Serialize(value, options), | |
(json) => (json == null ? default : JsonSerializer.Deserialize<PropertyType>(json, options))!, | |
CreateJsonValueComparer<PropertyType>(options) | |
); | |
} | |
public static ValueComparer<PropertyType?> CreateJsonValueComparer<PropertyType>(JsonSerializerOptions jsonSerializerOptions) => new( | |
(left, right) => JsonSerializer.Serialize(left, jsonSerializerOptions).Equals(JsonSerializer.Serialize(right, jsonSerializerOptions)), | |
(instance) => JsonSerializer.Serialize(instance, jsonSerializerOptions).GetHashCode(), | |
// note: For now, "snapshot" by round-tripping through the serializer to create a new instance. | |
(instance) => JsonSerializer.Deserialize<PropertyType>(JsonSerializer.Serialize(instance, jsonSerializerOptions), jsonSerializerOptions)! | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class DynamicJsonTypeResolver : DefaultJsonTypeInfoResolver | |
{ | |
private const string DiscriminatorField = "?"; | |
private readonly IReadOnlyDictionary<Type, IReadOnlyList<Type>> polymorphicTypes; | |
private readonly IDictionary<Type, Type> invertedLookup; | |
public DynamicJsonTypeResolver(IReadOnlyDictionary<Type, IReadOnlyList<Type>> polymorphicTypes) | |
{ | |
this.polymorphicTypes = polymorphicTypes; | |
this.invertedLookup = polymorphicTypes | |
.SelectMany((pair) => pair.Value.Select((polymorphType) => (Child: polymorphType, Parent: pair.Key))) | |
.ToDictionary( | |
(pair) => pair.Child, | |
(pair) => pair.Parent | |
); | |
} | |
public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) | |
{ | |
var typeInfo = base.GetTypeInfo(type, options); | |
// note: Stick with the defaults unless we have something to say about it. | |
if (! polymorphicTypes.TryGetValue(type, out var derivedTypeDescriptors)) | |
return typeInfo; | |
typeInfo.PolymorphismOptions ??= new(); | |
typeInfo.PolymorphismOptions!.TypeDiscriminatorPropertyName = DiscriminatorField; | |
typeInfo.PolymorphismOptions!.IgnoreUnrecognizedTypeDiscriminators = true; | |
typeInfo.PolymorphismOptions!.UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization; | |
derivedTypeDescriptors.ToList().ForEach((currentType) => | |
{ | |
if (! DiscriminatorRegistry.TryGetDiscriminator(currentType, out var discriminator)) | |
return; | |
typeInfo.PolymorphismOptions.DerivedTypes.Add(new(currentType, discriminator!)); | |
}); | |
return typeInfo; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[AttributeUsage(AttributeTargets.Class)] | |
public class JsonPolymorphAttribute : Attribute | |
{ | |
public readonly Type? ParentType; | |
public JsonPolymorphAttribute(Type? parentType = null) | |
{ | |
ParentType = parentType; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static class JsonPolymorphRegistry | |
{ | |
private static readonly IDictionary<Type, IList<Type>> PolymorphicTypes = new Dictionary<Type, IList<Type>>(); | |
public static IReadOnlyDictionary<Type, IReadOnlyList<Type>> PolymorphRegistrations => PolymorphicTypes.ToImmutableDictionary( | |
(key) => key.Key, | |
(item) => (IReadOnlyList<Type>) item.Value.ToImmutableList() | |
); | |
public static void RegisterPolymorph<ParentType, PolymorphType>() | |
where PolymorphType : ParentType => RegisterPolymorph(typeof(ParentType), typeof(PolymorphType)); | |
public static void RegisterPolymorph(Type parentType, Type polymorphType) | |
{ | |
var polymorphs = (PolymorphicTypes.TryGetValue(parentType, out var check), check) switch | |
{ | |
(true, {}) => check, | |
_ => new List<Type>(), | |
}; | |
polymorphs.Add(polymorphType); | |
PolymorphicTypes[parentType] = polymorphs; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static class ServiceCollectionExtensions | |
{ | |
public static IServiceCollection RegisterDiscriminators(this IServiceCollection services, IEnumerable<Assembly> assemblies) | |
{ | |
assemblies | |
.SelectMany((assembly) => assembly | |
.GetTypes() | |
.Where((type) => type.GetCustomAttribute<DiscriminatorAttribute>() != null)) | |
.ToList() | |
.ForEach((type) => DiscriminatorRegistry.Register(type.GetCustomAttribute<DiscriminatorAttribute>()!.Value, type)); | |
return services; | |
} | |
public static IServiceCollection RegisterJsonPolymorphs(this IServiceCollection services, IEnumerable<Assembly> assemblies) | |
{ | |
assemblies | |
.SelectMany((assembly) => assembly | |
.GetTypes() | |
.Where((type) => type.GetCustomAttribute<JsonPolymorphAttribute>() != null)) | |
.ToList() | |
.ForEach((type) => | |
{ | |
var parent = type.GetCustomAttribute<JsonPolymorphAttribute>()!.ParentType | |
?? (type.BaseType == typeof(object) ? null : type.BaseType) | |
?? type.GetInterfaces().FirstOrDefault() | |
?? throw new($"{type.Name} is not a polymorph of anything!"); | |
JsonPolymorphRegistry.RegisterPolymorph(parent, type); | |
}); | |
return services; | |
} | |
} |
Note: This exists because of limitations in System.Text.Json
which require polymorphs to be defined on the parent type, rather than being able to have them inferred through regular inheritance.
@atrauzzi Thanks for this. Is there a working example to see how your idea maps JSON?
Nothing yet, but I'm sure you can use all this to see the results.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Disclaimer: I wrote this in a way that fits with my best understanding of
System.Text.Json
and EF Core. The code conventions satisfy my level of standards.That said, I'm open to meaningful optimizations or improvements if anyone reading this has them...