Skip to content

Instantly share code, notes, and snippets.

@atrauzzi
Last active August 17, 2024 15:49
Show Gist options
  • Save atrauzzi/dde4f5e92fb783cb6847ff2b1c2d6710 to your computer and use it in GitHub Desktop.
Save atrauzzi/dde4f5e92fb783cb6847ff2b1c2d6710 to your computer and use it in GitHub Desktop.
Strongly typed JSON columns in Entity Framework Core
[AttributeUsage(AttributeTargets.Class)]
public class DiscriminatorAttribute : Attribute
{
public readonly string Value;
public DiscriminatorAttribute(string value)
{
Value = value;
}
}
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);
}
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)!
);
}
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;
}
}
[AttributeUsage(AttributeTargets.Class)]
public class JsonPolymorphAttribute : Attribute
{
public readonly Type? ParentType;
public JsonPolymorphAttribute(Type? parentType = null)
{
ParentType = parentType;
}
}
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;
}
}
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;
}
}
@atrauzzi
Copy link
Author

atrauzzi commented Dec 5, 2023

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...

@atrauzzi
Copy link
Author

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.

@ftgo
Copy link

ftgo commented Aug 13, 2024

@atrauzzi Thanks for this. Is there a working example to see how your idea maps JSON?

@atrauzzi
Copy link
Author

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